Skip to content

Commit d187eec

Browse files
feat(resolve): INT-01 — wire qualified-value path use Mod; Mod.fn(x) (Refs #178) (#253)
The INT-01 follow-up recorded after #244: `use Mod;` + qualified *value* call `Mod.fn(x)` failed with Resolve.UndefinedVariable on the qualifier. Root cause: `use Mod;` (ImportSimple) flat-imports Mod's public symbols (import_resolved_symbols) but binds no module namespace; the parser yields ExprField(ExprVar Mod, fn) (ExprSpan-wrapped), and resolve.ml's ExprField case drops the field and resolves the base ExprVar Mod -> undefined. So `Mod.fn` could never resolve even though `fn` was in scope flat. Fix: a pure, idempotent, total parse-boundary lowering `Resolve.lower_qualified_value_paths : program -> program` that rewrites `ExprField(ExprVar m, fld)` -> `ExprVar fld` when `m` is an ImportSimple qualifier (alias preferred; ImportList/ImportGlob bind no qualifier). Span-peels the base; genuine record access `r.f` is untouched (`r` is not an import qualifier). This is the value-expression analogue of #241/ADR-014 (qualified type/effect paths in the grammar). Applied once in `parse_with_face` so resolve/typecheck/borrow/quantity/codegen all see the lowered form uniformly; the formatter uses a separate path (Formatter.format_file) so `fmt` preserves source `Mod.fn`. Embedders that bypass parse_with_face call the exposed function (as the tests do) — boundary documented in INT-01. Verified (oracle): `use CrossCallee; CrossCallee.consume(42)` and `use CrossCallee as CC; CC.consume(7)` -> Type checking passed; `use CrossCallee::{consume}; consume(42)` still passes (no regression); genuine `p.x` preserved. Full gate 275/275 (was 271; +4 hermetic "E2E Qualified Value #178" regression tests + 2 fixtures). Scope (honest): the `.`-qualified value path (the recorded gap) is closed. `Mod::fn(x)` in *expression* position remains a parse error — a DISTINCT parser gap (`::` is reserved for Type::Variant in expr position), not the resolver; tracked as a separate follow-up. Refs #178 (not Closes). Co-authored-by: hyperpolymath <hyperpolymath@users.noreply.github.com> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 85488d7 commit d187eec

7 files changed

Lines changed: 242 additions & 13 deletions

File tree

bin/main.ml

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -101,13 +101,20 @@ let resolve_face ?(quiet = false) face path =
101101

102102
(** Parse a file using the requested face. *)
103103
let parse_with_face (face : Affinescript.Face.face) path =
104-
match face with
105-
| Affinescript.Face.Canonical -> Affinescript.Parse_driver.parse_file path
106-
| Affinescript.Face.Python -> Affinescript.Python_face.parse_file_python path
107-
| Affinescript.Face.Js -> Affinescript.Js_face.parse_file_js path
108-
| Affinescript.Face.Pseudocode -> Affinescript.Pseudocode_face.parse_file_pseudocode path
109-
| Affinescript.Face.Lucid -> Affinescript.Lucid_face.parse_file_lucid path
110-
| Affinescript.Face.Cafe -> Affinescript.Cafe_face.parse_file_cafe path
104+
let prog =
105+
match face with
106+
| Affinescript.Face.Canonical -> Affinescript.Parse_driver.parse_file path
107+
| Affinescript.Face.Python -> Affinescript.Python_face.parse_file_python path
108+
| Affinescript.Face.Js -> Affinescript.Js_face.parse_file_js path
109+
| Affinescript.Face.Pseudocode -> Affinescript.Pseudocode_face.parse_file_pseudocode path
110+
| Affinescript.Face.Lucid -> Affinescript.Lucid_face.parse_file_lucid path
111+
| Affinescript.Face.Cafe -> Affinescript.Cafe_face.parse_file_cafe path
112+
in
113+
(* #178 INT-01: lower `use Mod;` + qualified value path `Mod.fn` to the
114+
flat-imported `fn` so resolve/typecheck/codegen see it uniformly. The
115+
formatter uses a separate path (Formatter.format_file) and is unaffected,
116+
so source `Mod.fn` is preserved on `fmt`. *)
117+
Affinescript.Resolve.lower_qualified_value_paths prog
111118

112119
(** Preview the Python-face text transform (debug tool). *)
113120
let preview_python_transform path =

docs/ECOSYSTEM.adoc

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -181,8 +181,9 @@ link:TECH-DEBT.adoc[TECH-DEBT.adoc].
181181
|ID |Item |Issue |Status
182182

183183
|INT-01 |Cross-module WASM import emission (the substrate) |#178 |
184-
`use Mod::{fn}`/`::*` PROVEN+locked (271 gate + deno link harness);
185-
`use Mod;`+qualified-value-call resolver gap remains (distinct)
184+
`use Mod::{fn}`/`::*` PROVEN+locked (deno link harness); `use Mod;`/`as`
185+
+ `Mod.fn(x)` qualified-value path WIRED+locked (parse-boundary lowering;
186+
4 hermetic tests). Distinct parser follow-up: `Mod::fn(x)` in expr position
186187
|INT-02 |Host-agnostic loader bridge (`affinescript-dom-loader`) |#179 |loader
187188
landed in `packages/affine-js` (SAT-02 fixed; Deno/Node/browser parity,
188189
multi-namespace import object, ownership-section accessor). S1; unblocks

docs/TECH-DEBT.adoc

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,10 +145,17 @@ indices line up: *PROVEN end-to-end* (two separately-compiled `.wasm`
145145
link + execute, cross-call = 42) and regression-locked — hermetic
146146
structural test in the gate (271/271) + reproducible deno harness
147147
`tests/modules/xmod-link/`. Multi-file libraries via `use Mod::{…}` are
148-
shippable. *Remaining (distinct, NOT emission):* `use Mod;` + qualified
149-
value call `Mod.fn(x)` hits a resolution error (post-#228 qualified-value
150-
resolution unwired) — own follow-up. |S1 |`use ::{}`/`::*` DONE (PR Refs
151-
#178); qualified-value-call resolver gap open #178
148+
shippable. *Qualified-value path now wired:* `use Mod;` (or `use Mod as M;`)
149+
+ `Mod.fn(x)` resolves+typechecks — pure parse-boundary lowering
150+
`Resolve.lower_qualified_value_paths` (applied in `parse_with_face`;
151+
embedders bypassing it call it directly — the formatter is unaffected, so
152+
source `Mod.fn` is preserved on `fmt`); regression-locked (4 hermetic
153+
tests, "E2E Qualified Value #178"). *Remaining (distinct, NOT this unit —
154+
a parser gap, not the resolver):* `Mod::fn(x)` in *expression* position is
155+
a parse error (`::`-in-value-expr unwired; `::` reserved for
156+
`Type::Variant`). |S1 |`use ::{}`/`::*` + `use Mod;`/`as`-qualified
157+
`Mod.fn(x)` DONE (PR Refs #178); `::`-in-expression a separate parser
158+
follow-up
152159
|INT-02 |Host-agnostic loader bridge |S1 |open #179 (blocks INT-05/08/11)
153160
|INT-03 |WASI preview2 / host I/O |S1 |#180 ADR-015 accepted (full
154161
Component-Model re-target, staged S1..S6); S3+ hard-gated on S2

lib/resolve.ml

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -831,6 +831,133 @@ let resolve_program_with_imports (program : program) : (context, resolve_error *
831831
| Error e -> Error e
832832

833833
(** Resolve a complete program with module loader support *)
834+
(* ---- #178 INT-01: lower module-qualified value paths --------------------
835+
`use Mod;` (ImportSimple) flat-imports Mod's public symbols (see
836+
[import_resolved_symbols]), so a qualified *value* reference `Mod.fn` /
837+
`Mod.fn(x)` denotes the same flat symbol `fn`. The parser yields
838+
`ExprField (ExprVar Mod, fn)` (optionally ExprSpan-wrapped); rewrite it to
839+
`ExprVar fn` when `Mod` is a bound module qualifier from [prog_imports].
840+
This is the value-expression analogue of #241/ADR-014 (which handled
841+
qualified *type/effect* paths in the grammar). Pure; applied once on the
842+
parsed program before resolve/typecheck/codegen so every backend sees the
843+
lowered form uniformly. Genuine record access `r.f` is untouched (`r` is
844+
not an import qualifier). Only ImportSimple binds a qualifier; ImportList
845+
/ ImportGlob bring names unqualified and bind no module name. *)
846+
let import_qualifiers (imports : import_decl list) : (string, unit) Hashtbl.t =
847+
let h = Hashtbl.create 8 in
848+
List.iter (fun imp -> match imp with
849+
| ImportSimple (path, alias) ->
850+
let name = match alias with
851+
| Some id -> id.name
852+
| None -> (match List.rev path with id :: _ -> id.name | [] -> "")
853+
in
854+
if name <> "" then Hashtbl.replace h name ()
855+
| ImportList _ | ImportGlob _ -> ()
856+
) imports;
857+
h
858+
859+
let rec strip_span (e : expr) : expr =
860+
match e with ExprSpan (e', _) -> strip_span e' | e -> e
861+
862+
let rec lower_expr quals (e : expr) : expr =
863+
match e with
864+
| ExprField (base, fld) ->
865+
(match strip_span base with
866+
| ExprVar m when Hashtbl.mem quals m.name -> ExprVar fld
867+
| _ -> ExprField (lower_expr quals base, fld))
868+
| ExprSpan (e', sp) -> ExprSpan (lower_expr quals e', sp)
869+
| ExprLit _ | ExprVar _ | ExprVariant _ -> e
870+
| ExprLet r ->
871+
ExprLet { r with el_value = lower_expr quals r.el_value;
872+
el_body = Option.map (lower_expr quals) r.el_body }
873+
| ExprIf r ->
874+
ExprIf { ei_cond = lower_expr quals r.ei_cond;
875+
ei_then = lower_expr quals r.ei_then;
876+
ei_else = Option.map (lower_expr quals) r.ei_else }
877+
| ExprMatch r ->
878+
ExprMatch { em_scrutinee = lower_expr quals r.em_scrutinee;
879+
em_arms = List.map (lower_arm quals) r.em_arms }
880+
| ExprLambda r -> ExprLambda { r with elam_body = lower_expr quals r.elam_body }
881+
| ExprApp (f, args) ->
882+
ExprApp (lower_expr quals f, List.map (lower_expr quals) args)
883+
| ExprTupleIndex (e1, i) -> ExprTupleIndex (lower_expr quals e1, i)
884+
| ExprIndex (a, i) -> ExprIndex (lower_expr quals a, lower_expr quals i)
885+
| ExprTuple es -> ExprTuple (List.map (lower_expr quals) es)
886+
| ExprArray es -> ExprArray (List.map (lower_expr quals) es)
887+
| ExprRecord r ->
888+
ExprRecord
889+
{ er_fields =
890+
List.map (fun (id, eo) -> (id, Option.map (lower_expr quals) eo))
891+
r.er_fields;
892+
er_spread = Option.map (lower_expr quals) r.er_spread }
893+
| ExprRowRestrict (e1, id) -> ExprRowRestrict (lower_expr quals e1, id)
894+
| ExprBinary (l, op, r) -> ExprBinary (lower_expr quals l, op, lower_expr quals r)
895+
| ExprUnary (op, e1) -> ExprUnary (op, lower_expr quals e1)
896+
| ExprBlock b -> ExprBlock (lower_block quals b)
897+
| ExprReturn eo -> ExprReturn (Option.map (lower_expr quals) eo)
898+
| ExprTry r ->
899+
ExprTry { et_body = lower_block quals r.et_body;
900+
et_catch = Option.map (List.map (lower_arm quals)) r.et_catch;
901+
et_finally = Option.map (lower_block quals) r.et_finally }
902+
| ExprHandle r ->
903+
ExprHandle { eh_body = lower_expr quals r.eh_body;
904+
eh_handlers = List.map (lower_handler quals) r.eh_handlers }
905+
| ExprResume eo -> ExprResume (Option.map (lower_expr quals) eo)
906+
| ExprUnsafe ops -> ExprUnsafe (List.map (lower_unsafe quals) ops)
907+
908+
and lower_arm quals a =
909+
{ a with ma_guard = Option.map (lower_expr quals) a.ma_guard;
910+
ma_body = lower_expr quals a.ma_body }
911+
912+
and lower_handler quals = function
913+
| HandlerReturn (p, e) -> HandlerReturn (p, lower_expr quals e)
914+
| HandlerOp (id, ps, e) -> HandlerOp (id, ps, lower_expr quals e)
915+
916+
and lower_unsafe quals = function
917+
| UnsafeRead e -> UnsafeRead (lower_expr quals e)
918+
| UnsafeWrite (a, b) -> UnsafeWrite (lower_expr quals a, lower_expr quals b)
919+
| UnsafeOffset (a, b) -> UnsafeOffset (lower_expr quals a, lower_expr quals b)
920+
| UnsafeTransmute (t1, t2, e) -> UnsafeTransmute (t1, t2, lower_expr quals e)
921+
| UnsafeForget e -> UnsafeForget (lower_expr quals e)
922+
923+
and lower_block quals b =
924+
{ blk_stmts = List.map (lower_stmt quals) b.blk_stmts;
925+
blk_expr = Option.map (lower_expr quals) b.blk_expr }
926+
927+
and lower_stmt quals = function
928+
| StmtLet r -> StmtLet { r with sl_value = lower_expr quals r.sl_value }
929+
| StmtExpr e -> StmtExpr (lower_expr quals e)
930+
| StmtAssign (a, op, b) ->
931+
StmtAssign (lower_expr quals a, op, lower_expr quals b)
932+
| StmtWhile (e, b) -> StmtWhile (lower_expr quals e, lower_block quals b)
933+
| StmtFor (p, e, b) -> StmtFor (p, lower_expr quals e, lower_block quals b)
934+
935+
let lower_fn_body quals = function
936+
| FnBlock b -> FnBlock (lower_block quals b)
937+
| FnExpr e -> FnExpr (lower_expr quals e)
938+
| FnExtern -> FnExtern
939+
940+
let lower_top quals = function
941+
| TopFn fd -> TopFn { fd with fd_body = lower_fn_body quals fd.fd_body }
942+
| TopConst r -> TopConst { r with tc_value = lower_expr quals r.tc_value }
943+
| TopImpl ib ->
944+
TopImpl { ib with ib_items = List.map (function
945+
| ImplFn fd -> ImplFn { fd with fd_body = lower_fn_body quals fd.fd_body }
946+
| ImplType _ as it -> it) ib.ib_items }
947+
| TopTrait td ->
948+
TopTrait { td with trd_items = List.map (function
949+
| TraitFnDefault fd ->
950+
TraitFnDefault { fd with fd_body = lower_fn_body quals fd.fd_body }
951+
| other -> other) td.trd_items }
952+
| (TopType _ | TopEffect _ | TopExternType _ | TopExternFn _) as t -> t
953+
954+
(** #178: lower module-qualified value paths. Idempotent, pure. *)
955+
let lower_qualified_value_paths (program : program) : program =
956+
let quals = import_qualifiers program.prog_imports in
957+
if Hashtbl.length quals = 0 then program
958+
else { program with
959+
prog_decls = List.map (lower_top quals) program.prog_decls }
960+
834961
let resolve_program_with_loader
835962
(program : program)
836963
(loader : Module_loader.t) : (context * Typecheck.context) result =
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// #178 INT-01: `use Mod;` + qualified *value* call `Mod.fn(x)`. Before the
5+
// qualified-value lowering this failed with Resolve.UndefinedVariable on the
6+
// module qualifier (it parsed as field access on an undefined variable).
7+
8+
use CrossCallee;
9+
10+
pub fn main() -> Int {
11+
return CrossCallee.consume(42);
12+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// SPDX-License-Identifier: PMPL-1.0-or-later
2+
// Copyright (c) 2026 Jonathan D.A. Jewell
3+
//
4+
// #178 INT-01: aliased qualifier — `use CrossCallee as CC;` + `CC.consume(x)`.
5+
// The alias is the bound qualifier (import_qualifiers prefers the alias).
6+
7+
use CrossCallee as CC;
8+
9+
pub fn main() -> Int {
10+
return CC.consume(7);
11+
}

test/test_e2e.ml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3346,6 +3346,69 @@ let qualified_path_tests = [
33463346
Alcotest.test_case "bare unqualified forms unaffected" `Quick test_qual_unqualified_still_parses;
33473347
]
33483348

3349+
(* ---- #178 INT-01: qualified *value* path use Mod; Mod.fn(x) ----------
3350+
The companion to #228 (qualified type/effect paths). Mirrors the CLI's
3351+
parse→lower→resolve→typecheck (the lowering is applied at the parse
3352+
boundary by `parse_with_face`; embedders bypassing it call the exposed
3353+
`Resolve.lower_qualified_value_paths` — exactly as here). Hermetic. *)
3354+
let qualval_frontend_ok path : bool =
3355+
let loader = Module_loader.create {
3356+
Module_loader.stdlib_path = "stdlib";
3357+
search_paths = [];
3358+
current_dir = fixture_dir;
3359+
} in
3360+
match parse_fixture path with
3361+
| Error _ -> false
3362+
| Ok raw ->
3363+
let prog = Resolve.lower_qualified_value_paths raw in
3364+
(match Resolve.resolve_program_with_loader prog loader with
3365+
| Error _ -> false
3366+
| Ok (resolve_ctx, import_type_ctx) ->
3367+
(match Typecheck.check_program
3368+
~import_types:import_type_ctx.Typecheck.name_types
3369+
resolve_ctx.symbols prog with
3370+
| Error _ -> false
3371+
| Ok _ -> true))
3372+
3373+
let test_qualval_dot_call () =
3374+
Alcotest.(check bool)
3375+
"use CrossCallee; CrossCallee.consume(42) resolves+typechecks" true
3376+
(qualval_frontend_ok (fixture "cross_caller_qualified.affine"))
3377+
3378+
let test_qualval_alias_call () =
3379+
Alcotest.(check bool)
3380+
"use CrossCallee as CC; CC.consume(7) resolves+typechecks" true
3381+
(qualval_frontend_ok (fixture "cross_caller_qualified_alias.affine"))
3382+
3383+
let test_qualval_item_import_regression () =
3384+
Alcotest.(check bool)
3385+
"use CrossCallee::{consume}; consume(42) still works (no regression)" true
3386+
(qualval_frontend_ok (fixture "cross_caller_ok.affine"))
3387+
3388+
let test_qualval_record_access_unaffected () =
3389+
(* Genuine record projection must NOT be rewritten: `p` is a value, not an
3390+
import qualifier, so `p.x` stays field access. *)
3391+
let src = {|module RecGuard;
3392+
struct P { x: Int, y: Int }
3393+
fn getx(p: P) -> Int { return p.x; }|} in
3394+
let prog = Resolve.lower_qualified_value_paths
3395+
(Parse_driver.parse_string ~file:"<test>" src) in
3396+
let loader = Module_loader.create (Module_loader.default_config ()) in
3397+
Alcotest.(check bool) "p.x preserved (not lowered)" true
3398+
(match Resolve.resolve_program_with_loader prog loader with
3399+
| Ok (rc, itc) ->
3400+
(match Typecheck.check_program
3401+
~import_types:itc.Typecheck.name_types rc.symbols prog with
3402+
| Ok _ -> true | Error _ -> false)
3403+
| Error _ -> false)
3404+
3405+
let qualified_value_tests = [
3406+
Alcotest.test_case "use Mod; Mod.fn(x) resolves (#178)" `Quick test_qualval_dot_call;
3407+
Alcotest.test_case "use Mod as M; M.fn(x) resolves (#178)" `Quick test_qualval_alias_call;
3408+
Alcotest.test_case "use Mod::{fn}; fn(x) no regression" `Quick test_qualval_item_import_regression;
3409+
Alcotest.test_case "genuine record access p.x unaffected" `Quick test_qualval_record_access_unaffected;
3410+
]
3411+
33493412
(* ---- Type-syntax sugars: fn(...) -> T, Option<T>, (A, B) -> C ---- *)
33503413

33513414
let parse_check_passes src : bool =
@@ -3743,6 +3806,7 @@ let tests =
37433806
("E2E Vscode Bindings", vscode_bindings_tests);
37443807
("E2E Array Type Sugar", array_type_tests);
37453808
("E2E Qualified Paths #228", qualified_path_tests);
3809+
("E2E Qualified Value #178", qualified_value_tests);
37463810
("E2E WasmGC PatCon Destructure", wasm_gc_patcon_tests);
37473811
("E2E Type Syntax Sugar", type_syntax_sugar_tests);
37483812
]

0 commit comments

Comments
 (0)