Skip to content

Commit 7f2fb38

Browse files
hyperpolymathclaude
andcommitted
feat(parser)!: ADR-014 — module-qualified type/effect paths (Refs #228)
CORE-03. The type/effect grammar had no module-qualified path production: `Externs.Res` / `Externs::Res` were unrepresentable in any type or effect position and failed with `parse error` at the `.`. An estate audit found this the single dominant fault — 525 of ~1177 .affine files fail to parse, qualified-path the leading cause. ADR-011 already settled real modules + qualified paths; the consequence was simply unspeakable. Decision (ADR-014, owner-settled): accept BOTH `.` and `::` (mixed permitted); `Pkg::Type` is canonical; `.` normalised to `::`. Grammar (lib/parser.mly): - New `qualified_type_name`: >=2 `upper_ident` segments, right-recursive, `qsep = DOT | COLONCOLON`, folded to a single canonical `::`-joined ident string. - Added to `type_expr_primary` (TyCon + TyApp via `[ ]` and `< >`) and `effect_term` (EffVar + EffCon). - Folding to one ident means resolve/typecheck/all codegens are unchanged, and the formatter prints the `::` canonical form for free (`.`→`::` normalisation with zero formatter change). Conflict-neutral by construction — it only adds DOT/COLONCOLON lookahead after a type/effect-position `upper_ident`, where no reduce action previously existed (that void IS the `parse error at .`). Measured: Menhir conflict states unchanged at 21 S/R + 1 R/R; item counts unchanged at 68 S/R / 7 R/R. Zero delta. ADR recorded per the ADR-011/012 convention: - .machine_readable/6a2/META.a2ml [[adr]] ADR-014 - docs/specs/SETTLED-DECISIONS.adoc (== ADR-014 section) Tests (test/test_e2e.ml, "E2E Qualified Paths #228", parse-only — the #228 fault is a parse error; resolving a qualified name against a real module is a separate concern): qualified type in param (./::), struct field, type application [ ]/< >, deep + mixed-separator paths, qualified effect (./::), and a guard that bare unqualified forms are unaffected. Gate: opam exec --switch=. -- dune runtest --force = 267/267 (was 260; +7), zero regression. Refs #228 (not Closes — language-side, owner-gated; estate re-audit + ReScript-residue #229 follow separately). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dc5b817 commit 7f2fb38

4 files changed

Lines changed: 194 additions & 0 deletions

File tree

.machine_readable/6a2/META.a2ml

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -872,3 +872,69 @@ 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-19"
880+
title = "Module-qualified type/effect path separator: both `.` and `::`, `::` canonical"
881+
context = """
882+
The type/effect grammar had NO module-qualified path production: a
883+
qualified reference such as `Externs.Res` or `Externs.Net` was
884+
unrepresentable in any type or effect position and failed with
885+
`parse error` at the `.` (issue #228). An audit of the estate `.affine`
886+
corpus (compiler-as-oracle, origin/main) found this the SINGLE dominant
887+
fault estate-wide: 525 of ~1177 files fail to parse, qualified-path the
888+
leading cause in every hand-written Frontier-playbook TS-port. ADR-011
889+
already settled "real modules with qualified paths"; the consequence was
890+
unspeakable because the grammar could not name a module-qualified
891+
entity. Per ADR-012 (grammar changes are correctness assertions),
892+
closing this is asserting a settled truth, not adding sugar. The
893+
separator was the open, escalated language-design call: `module_path`
894+
(parser.mly) uses DOT; `use`/value paths use COLONCOLON (ADR-011
895+
`Result::unwrap`, `use option::{…}`); the estate corpus uses `.` for
896+
qualified types.
897+
"""
898+
decision = """
899+
Accept BOTH `.` and `::` (mixed permitted) as the module-qualified
900+
type/effect path separator. `Pkg::Type` is the CANONICAL form
901+
(consistent with ADR-011 value paths — one canonical separator
902+
estate-wide long-term). `.` is tolerated and normalised to `::`.
903+
904+
Implementation (parser.mly, conflict-neutral):
905+
- New `qualified_type_name` (>=2 `upper_ident` segments, right-recursive,
906+
`qsep = DOT | COLONCOLON`), folded into a single canonical `::`-joined
907+
ident string. Added to `type_expr_primary` (TyCon / TyApp via `[ ]`
908+
and `< >`) and `effect_term` (EffVar / EffCon).
909+
- Because the qualified name is folded to one ident, downstream
910+
(resolve/typecheck/all codegens) sees a single ident and needs no
911+
change; the formatter prints the stored `::` form, so `.`→`::`
912+
normalisation is automatic with NO formatter change.
913+
- Conflict-neutral by construction: it only adds DOT/COLONCOLON as
914+
lookahead after a type/effect-position `upper_ident`, where no reduce
915+
action previously existed (that void is precisely the
916+
`parse error at .` #228 reports). Measured: Menhir conflict states
917+
unchanged at 21 S/R + 1 R/R; item counts unchanged at 68 S/R / 7 R/R.
918+
- Resolution of the qualified name against a real module is a separate
919+
concern (the parse gap is what #228 names); the grammar PR unblocks
920+
parsing — most of the 525 estate parse failures clear with zero
921+
consumer churn. Genuine ReScript-surface residue (#229) is tracked
922+
separately.
923+
"""
924+
consequences = """
925+
- The estate `.affine` corpus parses without rewriting `.`→`::`; the
926+
formatter migrates to the `::` canonical form opportunistically.
927+
- No estate consumer porting was needed for the grammar gap (rejected
928+
alternative: rewriting hundreds of files entrenches a workaround
929+
against the Frontier playbook).
930+
- This decision is settled; do not reopen without amending this ADR.
931+
CORE-03 in docs/TECH-DEBT.adoc.
932+
"""
933+
references = [
934+
"https://github.com/hyperpolymath/affinescript/issues/228",
935+
"https://github.com/hyperpolymath/affinescript/issues/229",
936+
"lib/parser.mly (qualified_type_name; type_expr_primary; effect_term)",
937+
"docs/specs/SETTLED-DECISIONS.adoc (ADR-014 section)",
938+
"META.a2ml [[adr]] ADR-011 (real modules / qualified paths)",
939+
"META.a2ml [[adr]] ADR-012 (grammar changes are correctness assertions)",
940+
]

docs/specs/SETTLED-DECISIONS.adoc

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,3 +243,30 @@ 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 Path Separator (ADR-014)
248+
249+
The type/effect grammar had no module-qualified path production, so a
250+
qualified reference like `Externs.Res` was *unrepresentable* in any type
251+
or effect position (`parse error` at the `.`). An estate audit
252+
(compiler-as-oracle) found this the single dominant fault: 525 of ~1177
253+
`.affine` files fail to parse, qualified-path the leading cause. ADR-011
254+
already settled real modules with qualified paths; this consequence was
255+
simply unspeakable. The separator was the escalated, owner-decided
256+
question (`module_path` uses `.`; `use`/value paths use `::`; the corpus
257+
uses `.` for types).
258+
259+
Decision: accept *both* `.` and `::` (mixed permitted); *`Pkg::Type` is
260+
canonical* (consistent with ADR-011 value paths); `.` is tolerated and
261+
normalised to `::`. The parser folds a qualified name into one canonical
262+
`::`-joined ident, so resolve/typecheck/codegen need no change and the
263+
formatter prints the canonical form for free. Conflict-neutral by
264+
construction (it only adds `.`/`::` lookahead where no reduce action
265+
existed — exactly the parse-error void): measured Menhir conflict states
266+
unchanged at 21 S/R + 1 R/R, item counts unchanged at 68 S/R / 7 R/R.
267+
The grammar PR unblocks *parsing*; most estate parse failures clear with
268+
zero consumer churn (genuine ReScript-surface residue is #229).
269+
270+
This decision is settled; do not reopen without amending the ADR. Full
271+
ADR in `.machine_readable/6a2/META.a2ml` (ADR-014); ledger CORE-03 in
272+
`docs/TECH-DEBT.adoc`.

lib/parser.mly

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,29 @@ module_path:
137137
| id = ident { [id] }
138138
| path = module_path DOT id = ident { path @ [id] }
139139

140+
/* ADR-014 (#228): module-qualified type/effect path. Separator is `.` or
141+
`::` (mixed permitted); `::` is canonical (consistent with ADR-011 value
142+
paths). A qualified name is >=2 `upper_ident` segments; it is folded to a
143+
single canonical `::`-joined string so downstream (resolve/typecheck/all
144+
codegens, formatter) sees one ident and needs no changethe formatter
145+
prints the stored `::` form, normalising any `.` input for free.
146+
Right-recursive + restricted to `upper_ident` so it only adds
147+
`DOT`/`COLONCOLON` lookahead after a type/effect-position `upper_ident`
148+
(no prior reduce action therethat void is the `parse error at .`
149+
#228 reports), introducing zero new LR conflicts. */
150+
%inline qsep:
151+
| DOT { () }
152+
| COLONCOLON { () }
153+
154+
qualified_type_name:
155+
| head = upper_ident qsep rest = qualified_type_name_rest
156+
{ head ^ "::" ^ rest }
157+
158+
qualified_type_name_rest:
159+
| name = upper_ident { name }
160+
| name = upper_ident qsep rest = qualified_type_name_rest
161+
{ name ^ "::" ^ rest }
162+
140163
/* ========== Imports ========== */
141164

142165
import_decl:
@@ -450,6 +473,20 @@ type_expr_primary:
450473
| MUT ty = type_expr_primary { TyMut ty }
451474
| name = lower_ident { TyVar (mk_ident name $startpos $endpos) }
452475
| name = upper_ident { TyCon (mk_ident name $startpos $endpos) }
476+
/* ADR-014 (#228): module-qualified type name. `Pkg.Type` and
477+
`Pkg::Type` (and deeper, `A.B.C` / `A::B::C`, mixed separators) are
478+
accepted; the segments are folded into a single canonical name joined
479+
by `::`, so `::` is the canonical form on print with no formatter
480+
change and `.` is silently normalised. Conflict-safe: this only adds
481+
`DOT`/`COLONCOLON` as lookahead after a type-position `upper_ident`,
482+
where no reduce action previously existed (that absence is exactly the
483+
`parse error at .` #228 reports), so it introduces no new LR conflict.
484+
Resolution treats the `::`-joined name as a qualified reference. */
485+
| name = qualified_type_name { TyCon (mk_ident name $startpos $endpos) }
486+
| name = qualified_type_name LBRACKET args = separated_nonempty_list(COMMA, type_arg) RBRACKET
487+
{ TyApp (mk_ident name $startpos $endpos, args) }
488+
| name = qualified_type_name LT args = separated_nonempty_list(COMMA, type_arg) GT
489+
{ TyApp (mk_ident name $startpos $endpos, args) }
453490
| name = upper_ident LBRACKET args = separated_nonempty_list(COMMA, type_arg) RBRACKET
454491
{ TyApp (mk_ident name $startpos(name) $endpos(name), args) }
455492
/* Angle-bracket alias for type application: `Option<T>` ≡ `Option[T]`,
@@ -569,6 +606,12 @@ effect_term:
569606
| name = ident { EffVar name }
570607
| name = ident LBRACKET args = separated_list(COMMA, type_arg) RBRACKET
571608
{ EffCon (name, args) }
609+
/* ADR-014 (#228): module-qualified effect, e.g. `-{Externs.Net}->` /
610+
`-{Externs::Net}->`. Same canonical `::`-fold as qualified types. */
611+
| name = qualified_type_name
612+
{ EffVar (mk_ident name $startpos $endpos) }
613+
| name = qualified_type_name LBRACKET args = separated_list(COMMA, type_arg) RBRACKET
614+
{ EffCon (mk_ident name $startpos $endpos, args) }
572615

573616
/* ========== Traits ========== */
574617

test/test_e2e.ml

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3262,6 +3262,63 @@ let array_type_tests = [
32623262
Alcotest.test_case "[T] in struct field parses + typechecks" `Quick test_array_type_parses_in_struct_field;
32633263
]
32643264

3265+
(* ---- ADR-014 / #228: module-qualified type & effect paths ----
3266+
The #228 fault is a *parse error at the `.`* — these assert the
3267+
construct now PARSES (resolution of the qualified name against a real
3268+
module is a separate concern, deliberately not asserted here). Both the
3269+
`.` and the canonical `::` separators, in every position the estate
3270+
corpus uses, plus the #228 evidence-table cases. *)
3271+
3272+
let parses_ok src : bool =
3273+
match (try Some (Parse_driver.parse_string ~file:"<test>" src)
3274+
with _ -> None) with
3275+
| Some prog -> List.length prog.prog_decls > 0
3276+
| None -> false
3277+
3278+
let test_qual_type_param_dot () =
3279+
Alcotest.(check bool) "fn(x: Bar.Baz) parses" true
3280+
(parses_ok {|fn f(x: Bar.Baz) -> Int { return 0; }|})
3281+
3282+
let test_qual_type_param_coloncolon () =
3283+
Alcotest.(check bool) "fn(x: Bar::Baz) parses" true
3284+
(parses_ok {|fn f(x: Bar::Baz) -> Int { return 0; }|})
3285+
3286+
let test_qual_type_struct_field () =
3287+
Alcotest.(check bool) "struct { a: Bar.Baz } parses" true
3288+
(parses_ok {|struct R { a: Bar.Baz } fn m() -> Int { return 0; }|})
3289+
3290+
let test_qual_type_app () =
3291+
Alcotest.(check bool) "Pkg::Opt[Int] / Pkg.Opt<Int> parse" true
3292+
(parses_ok {|fn f(x: Pkg::Opt[Int]) -> Int { return 0; }|}
3293+
&& parses_ok {|fn g(x: Pkg.Opt<Int>) -> Int { return 0; }|})
3294+
3295+
let test_qual_type_deep_mixed () =
3296+
Alcotest.(check bool) "A.B.C and A::B::C deep paths parse" true
3297+
(parses_ok {|fn f(x: A.B.C) -> Int { return 0; }|}
3298+
&& parses_ok {|fn g(x: A::B::C) -> Int { return 0; }|})
3299+
3300+
let test_qual_effect_dot_and_colons () =
3301+
Alcotest.(check bool) "-{Bar.Baz}-> and -{Bar::Baz}-> parse" true
3302+
(parses_ok {|fn f() -{Bar.Baz}-> Int { return 0; }|}
3303+
&& parses_ok {|fn g() -{Bar::Baz}-> Int { return 0; }|})
3304+
3305+
let test_qual_unqualified_still_parses () =
3306+
(* Guard: the bare (unqualified) forms must be unaffected. *)
3307+
Alcotest.(check bool) "bare Type / Type[T] / -{Eff}-> still parse" true
3308+
(parses_ok {|fn f(x: Baz) -> Int { return 0; }|}
3309+
&& parses_ok {|fn g(x: Opt[Int]) -> Int { return 0; }|}
3310+
&& parses_ok {|fn h() -{Net}-> Int { return 0; }|})
3311+
3312+
let qualified_path_tests = [
3313+
Alcotest.test_case "qualified type in param (.)" `Quick test_qual_type_param_dot;
3314+
Alcotest.test_case "qualified type in param (::)" `Quick test_qual_type_param_coloncolon;
3315+
Alcotest.test_case "qualified type in struct field" `Quick test_qual_type_struct_field;
3316+
Alcotest.test_case "qualified type application [ ]/< >" `Quick test_qual_type_app;
3317+
Alcotest.test_case "deep + mixed-separator paths" `Quick test_qual_type_deep_mixed;
3318+
Alcotest.test_case "qualified effect (. and ::)" `Quick test_qual_effect_dot_and_colons;
3319+
Alcotest.test_case "bare unqualified forms unaffected" `Quick test_qual_unqualified_still_parses;
3320+
]
3321+
32653322
(* ---- Type-syntax sugars: fn(...) -> T, Option<T>, (A, B) -> C ---- *)
32663323

32673324
let parse_check_passes src : bool =
@@ -3574,6 +3631,7 @@ let tests =
35743631
("E2E Externs", extern_tests);
35753632
("E2E Vscode Bindings", vscode_bindings_tests);
35763633
("E2E Array Type Sugar", array_type_tests);
3634+
("E2E Qualified Paths #228", qualified_path_tests);
35773635
("E2E WasmGC PatCon Destructure", wasm_gc_patcon_tests);
35783636
("E2E Type Syntax Sugar", type_syntax_sugar_tests);
35793637
]

0 commit comments

Comments
 (0)