Skip to content

Commit 050c74b

Browse files
committed
feat(grammar): sound module-qualified type & effect paths, dot separator (Refs #228)
The type/effect grammar had no module-qualified path production, so `Externs.Res` / `prelude.Option` / any `Pkg.Type` in type or effect position failed `parse error` at the `.` — contradicting ADR-011 (real modules with qualified paths). This adds full, SOUND module-qualified paths in type and effect position, with `.` as the canonical separator (`::` stays value/import per ADR-011); decision recorded as ADR-014. Resolution is by sound module-scoped member lookup, never flat scope: load module `[A;B]` through the loader, resolve+typecheck it as an import would, then look the member up *inside that module's own resolved symbols*, requiring Public/PubCrate and the right kind (SKType / SKEffect). Unknown/wrong module, missing member, private member, or wrong-kind member is a resolution error at the use-site span — never a parse error and never silently accepted. - lib/ast.ml: `ident` gains `modpath : string list` ([]=unqualified); add `mk_ident` helper so synthetic idents set the field in one place. - lib/parser.mly: `mk_qualified_ident`; `path_seg`/`module_prefix`/ `qualified_type_path` helpers; qualified bare/[args]/<args> forms for types and bare/[args] for effects. Module-prefix segments accept lower OR upper case (stdlib modules are `prelude`/`option` as well as `Network`/`Http`); member segment is upper_ident. - lib/typecheck.ml: `lower_type_expr`/`lower_effect_expr` validate the qualified case via a loader-backed `qualified_member_check` threaded into the context; `Qualified_path_error` -> `QualifiedPathError` at the check_program boundary (same pattern as Effect_validation_error). A validated qualified effect's name is admitted as a declared effect (issue #59) so a sibling module's public effect is usable like a local one. Resolved representation is the canonical nominal type/effect — identical to the bare/imported form; the validation, not the representation, is the soundness gate. Typecheck/codegen otherwise unchanged. - lib/resolve.ml: `make_qualified_member_check` built where the loader lives, inside the `resolve_and_typecheck_module` recursive group so transitively-qualified stdlib paths resolve too. - bin/main.ml: thread the validator into the 6 patterned check_program calls (via the type_ctx returned by resolve_program_with_loader). - Fixed ~10 internal direct `ident` record literals (trait/codegen_gc/ borrow/verilog) to route through `Ast.mk_ident`. - docs/specs/SETTLED-DECISIONS.adoc + .machine_readable/6a2/META.a2ml: ADR-014 (full stanza, same shape as ADR-011/012/013). - tests/conformance/qualified-paths/{valid,invalid}: 5 conformance fixtures wired into the e2e gate (valid qualified type+effect; unknown module; private member; absent member). Parser-conflict delta: ZERO. menhir --explain summary is byte-identical to the pre-change parser — 21 shift/reduce states, 1 reduce/reduce state, 68 s/r + 7 r/r arbitrarily resolved. The `upper_ident` vs `upper_ident DOT ...` decision is the expected benign DOT shift (Menhir shifts, disclosed per ADR-012); no new unexplained conflict. Test gate: 258 -> 263 (5 new #228 cases), zero regressions. Adding `modpath` to `ident` reflows every golden .expected AST dump; regenerated — the span-normalised golden harness confirms no structural change. STAGE-C peer of #225 (typed-wasm Http) and #160 (C-spine). Language decision is human-gated (ISSUE-CLOSURE): Refs #228, not Closes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 417b97c commit 050c74b

31 files changed

Lines changed: 950 additions & 367 deletions

.machine_readable/6a2/META.a2ml

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,3 +872,94 @@ references = [
872872
"stdlib/Vscode.affine + packages/affine-vscode/mod.js (#205 thenableThen)",
873873
"typed-wasm ADR-004 (convergence / aggregate library; Ephapax)",
874874
]
875+
876+
[[adr]]
877+
id = "ADR-014"
878+
status = "accepted"
879+
date = "2026-05-18"
880+
title = "Module-qualified type & effect paths: dot separator, sound module-scoped member lookup"
881+
context = """
882+
The type/effect grammar had NO module-qualified path production.
883+
`Externs.Res` / `Pkg.Type` in type or effect position failed
884+
`parse error` at the `.`. `type_expr_primary` only had
885+
`upper_ident -> TyCon` / `upper_ident [..]/<..> -> TyApp`;
886+
`effect_term` only `ident -> EffVar` / `ident [..] -> EffCon`.
887+
`module_path` already parsed `ident (DOT ident)*` for module/use.
888+
ADR-011 settled real modules with qualified paths; the estate's
889+
Frontier-playbook ports pervasively write `Externs.Foo` in type/effect
890+
position, contradicting a grammar that could not represent it. An
891+
oracle audit (28 repos / 1176 .affine files) measured 498 DRIFT-SYNTAX
892+
files, dominated by exactly this unrepresentable qualified-path shape.
893+
"""
894+
decision = """
895+
A module-qualified path `A.B.C` is admissible in TYPE and EFFECT
896+
position. The dot `.` is the canonical separator for these paths;
897+
`::` remains value/import (ADR-011 unchanged). Module-prefix segments
898+
may be lower- OR upper-case (stdlib modules are `prelude`/`option`/
899+
`string`/`result` as well as `Network`/`Http`); the final segment is
900+
the type/effect member and is `upper_ident`.
901+
902+
`A.B.C` resolves by SOUND MODULE-SCOPED MEMBER LOOKUP, never flat
903+
current scope:
904+
- load module `[A;B]` through the module loader;
905+
- resolve + type-check it exactly as an import would
906+
(`Resolve.resolve_and_typecheck_module`);
907+
- look `C` up INSIDE that module's own resolved symbol table,
908+
requiring `Public`/`PubCrate` and the right kind (`SKType` for
909+
types, `SKEffect` for effects).
910+
An unknown/wrong module, a missing member, a private member, or a
911+
member of the wrong kind is a *resolution* error at the use-site span
912+
— not a parse error, never silently accepted. A validated qualified
913+
effect's name is admitted as a declared effect (issue #59) so a
914+
sibling module's public effect is usable like a local one.
915+
916+
Design (ripple-minimised):
917+
- `ast.ml`: `ident` gains `modpath : string list` ([] = unqualified).
918+
- `parser.mly`: `mk_qualified_ident`; a `qualified_type_path` helper
919+
(`module_prefix DOT upper_ident`); bare / [args] / <args> applied
920+
forms for types, bare / [args] for effects.
921+
- `typecheck.ml`: `lower_type_expr`/`lower_effect_expr` handle the
922+
qualified case via a loader-backed validator threaded into the
923+
context (`qualified_member_check`), raising `Qualified_path_error`
924+
-> `QualifiedPathError` at the `check_program` boundary (same
925+
pattern as `Effect_validation_error`). Resolved representation is
926+
the canonical nominal type/effect — identical to the bare/imported
927+
form; the validation, not the representation, is the soundness gate.
928+
- `resolve.ml`: `make_qualified_member_check` built where the loader
929+
lives; in the `resolve_and_typecheck_module` recursive group so
930+
transitively-qualified stdlib paths resolve too.
931+
932+
The `upper_ident` vs `upper_ident DOT …` choice is the expected
933+
benign DOT shift (Menhir shifts, ADR-012). Verified: parser-conflict
934+
summary UNCHANGED — 21 shift/reduce states, 1 reduce/reduce state,
935+
68 s/r + 7 r/r arbitrarily resolved, identical to the pre-change
936+
parser. No new unexplained conflict.
937+
"""
938+
consequences = """
939+
- Estate Frontier-playbook ports that write `Externs.Foo` /
940+
`prelude.Option` in type/effect position now resolve soundly; the
941+
oracle re-audit shows a large DRIFT-SYNTAX -> PASS/TYPE-ONLY shift.
942+
- Typecheck/codegen unchanged: a qualified ident is treated by its
943+
resolved symbol exactly like an unqualified one.
944+
- Adding `modpath` to `ident` reflows every golden `.expected` AST
945+
dump (regenerated; the span-normalised gate confirms no structural
946+
change). 5 conformance cases added under
947+
tests/conformance/qualified-paths/{valid,invalid}; the full gate is
948+
green (258 -> 263, zero regressions).
949+
- STAGE-C peer of #225 (typed-wasm Http) and #160 (C-spine). Language
950+
decision is human-gated (ISSUE-CLOSURE): Refs #228, NOT Closes.
951+
- Settled; do not reopen without amending this ADR.
952+
"""
953+
references = [
954+
"https://github.com/hyperpolymath/affinescript/issues/228",
955+
"https://github.com/hyperpolymath/affinescript/issues/225",
956+
"https://github.com/hyperpolymath/affinescript/issues/160",
957+
"docs/specs/SETTLED-DECISIONS.adoc (ADR-014 section)",
958+
"lib/ast.ml (ident.modpath)",
959+
"lib/parser.mly (qualified_type_path; mk_qualified_ident)",
960+
"lib/typecheck.ml (lower_type_expr/lower_effect_expr; qualified_member_check)",
961+
"lib/resolve.ml (make_qualified_member_check; module-scoped lookup)",
962+
"tests/conformance/qualified-paths/{valid,invalid}",
963+
"ADR-011 (real modules with qualified paths)",
964+
"ADR-012 (parser-conflict disclosure; benign DOT shift)",
965+
]

bin/main.ml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ let check_file face json path =
187187
resolve_refs := List.rev resolve_ctx.references;
188188
(match Affinescript.Typecheck.check_program
189189
~import_types:type_ctx.Affinescript.Typecheck.name_types
190+
?qualified_member_check:type_ctx.Affinescript.Typecheck.qualified_member_check
190191
resolve_ctx.symbols prog with
191192
| Error e ->
192193
add (Affinescript.Json_output.of_type_error e)
@@ -234,6 +235,7 @@ let check_file face json path =
234235
| Ok (resolve_ctx, type_ctx) ->
235236
(match Affinescript.Typecheck.check_program
236237
~import_types:type_ctx.Affinescript.Typecheck.name_types
238+
?qualified_member_check:type_ctx.Affinescript.Typecheck.qualified_member_check
237239
resolve_ctx.symbols prog with
238240
| Error e ->
239241
Format.eprintf "@[<v>%s@]@."
@@ -497,6 +499,7 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc
497499
| Ok (resolve_ctx, import_type_ctx) ->
498500
(match Affinescript.Typecheck.check_program
499501
~import_types:import_type_ctx.Affinescript.Typecheck.name_types
502+
?qualified_member_check:import_type_ctx.Affinescript.Typecheck.qualified_member_check
500503
resolve_ctx.symbols prog with
501504
| Error e ->
502505
add (Affinescript.Json_output.of_type_error e)
@@ -716,6 +719,7 @@ let compile_file face json wasm_gc vscode_ext vscode_adapter vscode_no_lc
716719
| Ok (resolve_ctx, import_type_ctx) ->
717720
(match Affinescript.Typecheck.check_program
718721
~import_types:import_type_ctx.Affinescript.Typecheck.name_types
722+
?qualified_member_check:import_type_ctx.Affinescript.Typecheck.qualified_member_check
719723
resolve_ctx.symbols prog with
720724
| Error e ->
721725
Format.eprintf "@[<v>%s@]@."
@@ -1060,6 +1064,7 @@ let compile_to_wasm_module face path
10601064
| Ok (resolve_ctx, import_type_ctx) ->
10611065
match Affinescript.Typecheck.check_program
10621066
~import_types:import_type_ctx.Affinescript.Typecheck.name_types
1067+
?qualified_member_check:import_type_ctx.Affinescript.Typecheck.qualified_member_check
10631068
resolve_ctx.symbols prog with
10641069
| Error e ->
10651070
Format.eprintf "%s: %s@." path
@@ -1120,6 +1125,7 @@ let verify_file face path =
11201125
| Ok (resolve_ctx, import_type_ctx) ->
11211126
(match Affinescript.Typecheck.check_program
11221127
~import_types:import_type_ctx.Affinescript.Typecheck.name_types
1128+
?qualified_member_check:import_type_ctx.Affinescript.Typecheck.qualified_member_check
11231129
resolve_ctx.symbols prog with
11241130
| Error e ->
11251131
Format.eprintf "%s@."

docs/specs/SETTLED-DECISIONS.adoc

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,38 @@ agreed async ABI for the typed-wasm convergence layer; Ephapax is a
243243
co-stakeholder (typed-wasm ADR-004). Delivered as 4 incremental,
244244
gated PRs. Full design in `docs/specs/async-on-wasm-cps.adoc`; full ADR
245245
in `.machine_readable/6a2/META.a2ml` (ADR-013).
246+
247+
== Module-Qualified Type & Effect Paths: Dot Separator, Sound Member Lookup (ADR-014)
248+
249+
A module-qualified path `A.B.C` is admissible in *type* and *effect*
250+
position, not only value/import position. The dot `.` is the canonical
251+
separator for these paths; `::` remains the value/import separator
252+
(ADR-011 is unchanged). Module-prefix segments may be lower- or
253+
upper-case (real stdlib modules are `prelude`, `option`, `string`,
254+
`result` as well as `Network`, `Http`); the final member segment is
255+
the type/effect name.
256+
257+
`A.B.C` resolves by *sound module-scoped member lookup*, never by flat
258+
current scope: the compiler loads module `[A;B]` through the module
259+
loader, resolves and type-checks it exactly as an import would, then
260+
looks `C` up *inside that module's own resolved symbol table*,
261+
requiring it to be `Public`/`pub(crate)` and of the right kind
262+
(`SKType` for types, `SKEffect` for effects). An unknown or wrong
263+
module, a missing member, a private member, or a member of the wrong
264+
kind is a *resolution* error reported at the use-site span — not a
265+
parse error and never silently accepted. Soundness comes from looking
266+
the member up within the named module, so importing `C` flatly does
267+
not make a bogus `A.B.C` resolve. A validated qualified effect's name
268+
is admitted as a declared effect (issue #59), so a sibling module's
269+
public effect is usable exactly like a locally declared one.
270+
271+
This is consistent with ADR-011 (real modules with qualified paths)
272+
and ADR-012 (the `upper_ident` vs `upper_ident DOT …` choice is the
273+
expected benign DOT shift; the masked-conflict count is unchanged —
274+
21 shift/reduce states, 1 reduce/reduce state, identical to the
275+
pre-change parser). Typecheck/codegen are unchanged: a qualified
276+
ident is treated by its resolved symbol exactly like an unqualified
277+
one (the validation, not the representation, is the soundness gate).
278+
279+
Refs issue #228 (language decision human-gated; not auto-closed). Full
280+
ADR in `.machine_readable/6a2/META.a2ml` (ADR-014).

lib/ast.ml

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,31 @@
33

44
(** Abstract Syntax Tree for AffineScript *)
55

6-
(** Identifiers *)
6+
(** Identifiers.
7+
8+
[modpath] holds a module-qualified prefix for type/effect paths
9+
(issue #228, ADR-014). [modpath = []] means the identifier is
10+
unqualified (the overwhelming majority of idents). A qualified
11+
type/effect path like [A.B.C] is represented as
12+
[{ name = "C"; modpath = ["A"; "B"]; span }], where [name] is the
13+
final member segment and [modpath] is the module path that the
14+
member must be looked up *within* (sound module-scoped resolution,
15+
not flat-scope). The dot [.] is the canonical separator; [::]
16+
remains value/import per ADR-011. *)
717
type ident = {
818
name : string;
919
span : Span.t;
20+
modpath : string list;
1021
}
1122
[@@deriving show, eq]
1223

24+
(** Construct an unqualified identifier ([modpath = []]). Used by
25+
compiler-internal synthetic idents (trait desugaring, codegen
26+
helpers, …) so the [modpath] field (issue #228) is set in exactly
27+
one place rather than scattered as record literals. *)
28+
let mk_ident ?(span : Span.t = Span.dummy) (name : string) : ident =
29+
{ name; span; modpath = [] }
30+
1331
(** Quantity annotations for QTT *)
1432
type quantity =
1533
| QZero (** Erased - compile time only *)

lib/borrow.ml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -573,7 +573,7 @@ let rec check_expr (ctx : context) (state : state) (symbols : Symbol.t) (expr :
573573
let borrow_span = expr_span (ExprLambda lam) in
574574
let* () = List.fold_left (fun acc name ->
575575
let* () = acc in
576-
match expr_to_place symbols (ExprVar { name; span = borrow_span }) with
576+
match expr_to_place symbols (ExprVar (mk_ident ~span:borrow_span name)) with
577577
| Some place ->
578578
(* Check the place is not already moved before we borrow it. *)
579579
let* _borrow = record_borrow state place Shared borrow_span in

lib/codegen_gc.ml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,7 @@ let rec gen_gc_expr (ctx : gc_ctx) (expr : expr) : (gc_ctx * gc_instr list) cg_r
430430
| Some e -> e
431431
| None ->
432432
let i = List.length rev_codes in
433-
ExprVar { name = List.nth field_names i; span = Span.dummy }
433+
ExprVar (mk_ident (List.nth field_names i))
434434
in
435435
let* (c', code) = gen_gc_expr c fexpr in
436436
Ok (c', code :: rev_codes)

lib/parser.mly

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,15 @@ let mk_span startpos endpos =
1818
Span.make ~file ~start_pos ~end_pos
1919

2020
let mk_ident name startpos endpos =
21-
{ name; span = mk_span startpos endpos }
21+
{ name; span = mk_span startpos endpos; modpath = [] }
22+
23+
(* issue #228 / ADR-014: a module-qualified type/effect path `A.B.C`.
24+
[path] is the module prefix (["A"; "B"]) and [name] is the final
25+
member segment ("C") that resolution must look up *within* that
26+
module's resolved public symbols (sound module-scoped lookup, not
27+
flat current scope). The span covers the whole dotted path. *)
28+
let mk_qualified_ident (path : string list) name startpos endpos =
29+
{ name; span = mk_span startpos endpos; modpath = path }
2230

2331
(* issue #122 v2: inherent-impl support. The old grammar pre-committed to
2432
`impl_trait_ref? self_ty` where both alternatives start with an ident,
@@ -450,6 +458,26 @@ type_expr_primary:
450458
| MUT ty = type_expr_primary { TyMut ty }
451459
| name = lower_ident { TyVar (mk_ident name $startpos $endpos) }
452460
| name = upper_ident { TyCon (mk_ident name $startpos $endpos) }
461+
/* issue #228 / ADR-014: module-qualified type path `A.B.C`. The
462+
prefix segments `A.B` are the module path; the last segment `C`
463+
is the type member, resolved *within* that module's public
464+
symbols (sound module-scoped lookup — see lib/typecheck.ml
465+
lower_type_expr / lib/resolve.ml). `.` is canonical (ADR-011
466+
keeps `::` for value/import). The `upper_ident` vs
467+
`upper_ident DOT ...` choice is the expected benign shift on
468+
DOT (Menhir shifts; disclosed per ADR-012). Bare / [args] /
469+
<args> applied forms all supported. */
470+
| qp = qualified_type_path
471+
{ let (prefix, last, sp, ep) = qp in
472+
TyCon (mk_qualified_ident prefix last sp ep) }
473+
| qp = qualified_type_path
474+
LBRACKET args = separated_nonempty_list(COMMA, type_arg) RBRACKET
475+
{ let (prefix, last, sp, ep) = qp in
476+
TyApp (mk_qualified_ident prefix last sp ep, args) }
477+
| qp = qualified_type_path
478+
LT args = separated_nonempty_list(COMMA, type_arg) GT
479+
{ let (prefix, last, sp, ep) = qp in
480+
TyApp (mk_qualified_ident prefix last sp ep, args) }
453481
| name = upper_ident LBRACKET args = separated_nonempty_list(COMMA, type_arg) RBRACKET
454482
{ TyApp (mk_ident name $startpos(name) $endpos(name), args) }
455483
/* Angle-bracket alias for type application: `Option<T>` ≡ `Option[T]`,
@@ -569,6 +597,19 @@ effect_term:
569597
| name = ident { EffVar name }
570598
| name = ident LBRACKET args = separated_list(COMMA, type_arg) RBRACKET
571599
{ EffCon (name, args) }
600+
/* issue #228 / ADR-014: module-qualified effect path `A.B.Eff`.
601+
Same module-scoped soundness as qualified types: the prefix is a
602+
module path, the last segment the effect member resolved within
603+
it. Reuses `qualified_type_path` (module + member both upper —
604+
stdlib effects are `IO`/`Mut`/`Throws`, module names upper). The
605+
applied `[args]` form mirrors the parametric-effect rule above. */
606+
| qp = qualified_type_path
607+
{ let (prefix, last, sp, ep) = qp in
608+
EffVar (mk_qualified_ident prefix last sp ep) }
609+
| qp = qualified_type_path
610+
LBRACKET args = separated_list(COMMA, type_arg) RBRACKET
611+
{ let (prefix, last, sp, ep) = qp in
612+
EffCon (mk_qualified_ident prefix last sp ep, args) }
572613

573614
/* ========== Traits ========== */
574615

@@ -1062,6 +1103,32 @@ pattern_rest:
10621103

10631104
/* ========== Helpers ========== */
10641105

1106+
/* issue #228 / ADR-014: a module-qualified type/effect path
1107+
`A.B.C`. Returns (module-prefix, member, startpos, endpos). The
1108+
member (last segment) is the type/effect name and is always
1109+
`upper_ident` (stdlib types `Option`/`Result`/… and effects
1110+
`IO`/`Mut`/`Throws` are uppercase; a lowercase final segment is
1111+
not a valid type/effect anyway). The module-prefix segments may be
1112+
*either* case because real stdlib module names are lowercase
1113+
(`prelude`, `option`, `string`, `result`) as well as uppercase
1114+
(`Network`, `Http`) — this mirrors `module_path`/`use`, which
1115+
accept `ident` (both cases). Length is >=2 (>=1 prefix segment +
1116+
the member), enforced by the `module_prefix DOT upper_ident`
1117+
shape. Left-recursive prefix. The `upper_ident` vs
1118+
`upper_ident DOT ...` decision is the expected benign DOT shift
1119+
(Menhir shifts; disclosed per ADR-012). */
1120+
path_seg:
1121+
| s = lower_ident { s }
1122+
| s = upper_ident { s }
1123+
1124+
module_prefix:
1125+
| s = path_seg { [s] }
1126+
| p = module_prefix DOT s = path_seg { p @ [s] }
1127+
1128+
qualified_type_path:
1129+
| p = module_prefix DOT m = upper_ident
1130+
{ (p, m, $startpos, $endpos) }
1131+
10651132
ident:
10661133
| name = lower_ident { mk_ident name $startpos $endpos }
10671134
| name = upper_ident { mk_ident name $startpos $endpos }

0 commit comments

Comments
 (0)