Skip to content

LANG: type/effect grammar has no module-qualified path — Pkg.Type/Pkg.Effect unrepresentable (estate-wide port blocker; ADR-014) #228

Description

@hyperpolymath

Is your feature request related to a problem? Please describe.

The type/effect grammar has no module-qualified path production. A qualified
reference such as Externs.Res or Externs.Net is unrepresentable in any
type or effect position, so it fails with parse error at the ..

This was found while auditing the estate .affine corpus against
origin/main @417b97c with the compiler as oracle
(main.exe check). It is the single dominant fault estate-wide, not a
collection of dialect quirks: of ~1177 .affine across 28 repos, 525 fail to
parse, and the qualified-path gap is the leading cause in every hand-written
Frontier-playbook TS-port (accessibility-everywhere, idaptik, airborne-submarine-squadron,
and the per-file failures in aerie/ambientops/echidna/game-server-admin/git-scripts/
invariant-path/ubicity/panll). The Frontier playbook itself produces Externs.Foo
qualified types/effects, so the corpus and the grammar disagree.

Grounded evidence (oracle probes on main @417b97c)

Source Result
pub fn f() -{Net}-> () ✅ parses (legacy -{E}-> is not the fault)
pub fn f(x: mut Int) -> () / ref Int ✅ parses (sigils fine)
use Bar; pub fn f(x: Baz) -{Bar}-> () ✅ parses (resolution-only error)
pub fn f(x: Bar.Baz) -> () ❌ parse error at the .
pub type R = { a: Bar.Baz } ❌ parse error at the .
pub fn f() -{Bar.Baz}-> () ❌ parse error at the .

Root cause in lib/parser.mly:

  • type_expr_primary: (line 442) — the sole type producer — only has
    upper_ident → TyCon / upper_ident LBRACKET … → TyApp. There is no
    module_path DOT ident (or ::) alternative anywhere in the type grammar.
  • effect_term: — only name = ident (optionally ident LBRACKET …). An
    effect cannot be module-qualified at all.

This contradicts an already-settled decision

ADR-011 (Stdlib Namespace Model: Real Modules, SETTLED-DECISIONS.adoc:136)
states the language uses real modules with qualified paths, and that this
is "consistent with the machinery the compiler (module_loader.ml,
module/use/:: grammar) … already use."
The resolution layer already
understands modules (use Bar; … Bar reaches a resolution error, not a parse
error). But the type/effect grammar cannot name a module-qualified entity,
so a core consequence of ADR-011 is unspeakable. Per ADR-012 (Grammar Changes
Are Correctness Assertions
), closing this gap is asserting a truth the language
already settled, not adding surface sugar.

Describe the solution you'd like

Add a module-qualified path production to the type/effect grammar, and record
it as ADR-014 (new == section in docs/specs/SETTLED-DECISIONS.adoc,
full ADR in .machine_readable/6a2/META.a2ml per the ADR-011/012 convention):

  1. type_expr_primary: accept a qualified head — module_path <sep> upper_ident
    (+ optional LBRACKET type_arg,* RBRACKET) → a qualified TyCon/TyApp.
  2. effect_term: accept the same qualified head for EffVar/EffCon.

Open language-side decision for the ADR (deliberately not pre-decided here —
escalating, not guessing):
the separator. module_path (parser.mly:138) uses
DOT; use/value paths use COLONCOLON (ADR-011's Result::unwrap,
use option::{…}). The estate corpus uses . for qualified types. The ADR
should settle whether qualified type/effect references are Pkg.Type,
Pkg::Type, or both-accepted-one-canonical, consistent with the
existing module/use/:: machinery — this is the language owner's call and
should be fixed once, centrally, before any consumer adopts it.

Describe alternatives considered

  • Estate consumer-port (rewrite every Pkg.Type→bare use Pkg::{…} across
    ~hundreds of files).
    Rejected: mechanical-but-unsound, churns hundreds of
    files, entrenches a workaround against the Frontier playbook the ports
    follow
    , and absorbs a language-design gap into N consumer repos instead of
    resolving it at source.
  • Do nothing / treat as "old dialect". Rejected: the prior diagnosis
    ("legacy -{E}-> arrow") was empirically falsified above; -{E}-> parses
    fine. The corpus is not on an old dialect — it is using a construct the
    grammar never supported.

Additional context

  • STAGE-C peer. This is coordinated with the active STAGE-C / typed-wasm
    line (Refs stdlib Http: typed-wasm target (convergence ABI; shared with Ephapax) #225, stdlib: portable Http.fetch (body/headers/methods/response) #160) as a peer workstream — the qualified type/effect form
    must equal the STAGE-C canonical; real-world estate drift data should inform
    STAGE-C prioritisation. Not "Closes" — language-side design decision, human-gated.
  • No estate porting will happen until this lands. After the grammar/ADR
    decision, the estate .affine corpus will be re-audited (most parse failures
    are expected to clear with zero consumer churn); only the genuine
    ReScript-surface residue (burble-class: array<>, Dict.t<>, mutable,
    type rec, %%raw, (~x) =>, List(X)) is a true consumer port, tracked
    separately.
  • Audit-first method also excluded ~500 false targets: affinescript's own
    conformance/invalid negative fixtures, and 3 repos
    (bofj-kitt/proof-burrower/developer-ecosystem) that vendor a full copy of
    the affinescript tree.
  • Oracle/build: dune build bin/main.exe, AFFINESCRIPT_STDLIB=$PWD/stdlib,
    main.exe check <file>; classify on stdout (Type checking passed vs
    parse error). Estate repo CI does not compile .affine — local oracle
    is the only validator.

Activity

  1. added
    enhancementNew capability or improvement to existing behaviour
    majorMajor issue — significant scope, broader impact than a feature/bug
    on May 18, 2026
  2. added 8 commits that reference this issue on May 18, 2026
  3. hyperpolymath commented on May 30, 2026

    @hyperpolymath
    OwnerAuthor

    Current state + proposed next slice — pre-PR design comment

    Parser layer (this issue's original gap): closed in #241 (sha). Pkg.Type / Pkg::Type (mixed seps) now parse end-to-end at type and effect positions; ADR-014 inline comments are visible at lib/parser.mly:140–149 / 506–514 / 667–668. Folded form is canonical ::-joined (formatter prints ::, accepts .).

    Remaining gap (this PR's target). The folded TyCon "Mod::Type" reaches Typecheck.lower_type_expr (lib/typecheck.ml:430) and silently falls through to abstract TCon "Mod::Type" — same lenient-on-unknown-TyCon behaviour as bare names. So pub fn f(x: NoSuchMod.Thing) -> () passes today (probed against HEAD). Effects are stricter — they reject — but with a misleading hint ("declare `effect NoSuchMod::IO;`").

    This is the symmetric piece to #178 / lower_qualified_value_paths (lib/resolve.ml:969), which lowers `Mod.fn` value paths. Type/effect positions don't yet have the equivalent.

    Proposed change — smallest viable slice

    Mirror #178 at the type/effect chokepoints in `typecheck.ml` (not via AST walk — avoids enumerating every type-position site):

    1. Add `module_quals : (string, unit) Hashtbl.t` to `Typecheck.context`; populate from `prog.prog_imports` (`ImportSimple` only; matches resolve.ml semantics) at `check_program` entry.
    2. New helper `strip_module_qualifier ctx name` — for any `name` containing `::`, split head; if `head` ∈ `module_quals` → return stripped tail; else raise `Module_resolution_error head`.
    3. Apply in `lower_type_expr` (`TyCon`, `TyApp` arms) and `lower_effect_expr` (`resolve` helper + `EffCon`).
    4. Add `UnknownModule of string` to `type_error`; catch the exception at `check_program` boundary (paralleling `Effect_validation_error → UnknownEffect`).
    5. Error text: `Unknown module 'X'. Add `use X;` (or `use X::{Type};`) to bring it into scope.`

    Tests (in `test/test_qualified_paths.ml`, wired into `test_main.ml`)

    • `type-qualified + use → strips and resolves locally` (`use Ajv; pub fn f(x: Ajv.Schema) -> () { () }`)
    • `type-qualified, no use → UnknownModule error` (`pub fn f(x: NoSuchMod.Thing) -> () { () }`)
    • `effect-qualified, no use → UnknownModule error` (`pub fn f() -{NoSuchMod.IO}-> () { () }`)
    • regression: `bare TyCon still permissive` (no scope creep)

    Out of scope (filed separately if owner agrees)

    • Lowercase-module qualified refs don't parse — `qualified_type_name` head requires `upper_ident` (`lib/parser.mly:155`), so `json.Value` / `option.Option` are unrepresentable even though those modules exist (lowercase `module json;` in stdlib). Either rename stdlib modules to UpperCase (`Json`, `Option`, `Prelude`) or relax head to `ident` — both are parser-level decisions deferring to owner.
    • `use Mod::Sub::{Item}` (multi-segment use with brace-list) fails parse against `{Item}` — adjacent grammar gap.
    • `use A.B;` registers only `B` (last segment) as the qualifier — `A.B.T` ref then fails. Decision: should multi-segment use bind the whole path as one qualifier, or only the leaf? Tracks ADR-011.
    • Bare unknown TyCons silently pass typecheck — pre-existing LANG: type/effect grammar has no module-qualified path — Pkg.Type/Pkg.Effect unrepresentable (estate-wide port blocker; ADR-014) #228-unrelated leniency; widening would change behaviour for many downstream files and belongs to its own slice.

    Refs not Closes

    Refs #228 (this is one of several slices). Will file follow-up issues for the four out-of-scope items above.

    — sketched against HEAD = `9f12643` (after #443).

  4. hyperpolymath commented on Jun 1, 2026

    @hyperpolymath
    OwnerAuthor

    Status update — AS stdlib codegen pattern now established (2026-06-01) 📌

    For ticket coordinators / anyone tracking this gap:

    What this means for the gap tracked in this ticket:

    • The codegen pattern is now a known repeatable shape: declare in stdlib/*.affine → wire in deno_builtins table + prelude → smoke-test in tests/codegen-deno/*.
    • This ticket remains open as a tracked gap; no new mass-port is being kicked off from this post.
    • Surfacing status so the broader picture stays accurate as more sub-tickets / dependent campaigns reference it.

    🤖 Cross-estate AS-status sweep on 2026-06-01.

  5. hyperpolymath commented on Sep 19, 2026

    @hyperpolymath
    OwnerAuthor

    Verified implemented in full — closing.

    The thread's own state comment split this into a parser layer and a remaining typecheck slice. Both have landed:

    Parser layer — closed in #241 (5cca379), as the thread records. Confirmed on main: lib/parser.mly carries the qualified_type_name production with the inline note "ADR-014 (#228): module-qualified type/effect path", accepting . or :: with :: canonical.

    The remaining typecheck slice — landed since, and this is what I checked today. Every element the comment proposed is present:

    Proposed On main
    module_quals on the context, populated from imports present
    strip_module_qualifier helper present
    Module_resolution_error present
    UnknownModule + boundary catch present
    Tests: qualified + use resolves · no use → UnknownModule · effect position same path · bare TyCon stays permissive all four in test/test_qualified_paths.ml

    Plus the follow-on for lowercase-qualifier heads (lib/parser.mly records it as #448 item 1), with its own test.

    The four out-of-scope items the comment listed were to be filed separately and are not part of this issue's acceptance criteria. Nothing in scope remains.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew capability or improvement to existing behaviourmajorMajor issue — significant scope, broader impact than a feature/bug

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions