Skip to content

feat: sound module-qualified type & effect paths, dot separator (Refs #228)#231

Draft
hyperpolymath wants to merge 2 commits into
mainfrom
stage-c/228-qualified-type-effect-path
Draft

feat: sound module-qualified type & effect paths, dot separator (Refs #228)#231
hyperpolymath wants to merge 2 commits into
mainfrom
stage-c/228-qualified-type-effect-path

Conversation

@hyperpolymath
Copy link
Copy Markdown
Owner

@hyperpolymath hyperpolymath commented May 18, 2026

Refs #228

STAGE-C peer of #225 (typed-wasm Http) and #160 (C-spine). Language
decision is human-gated per the repo ISSUE-CLOSURE rule, so this is
Refs #228, not Closes — do not auto-merge, do not close the issue.

What

The type/effect grammar had no module-qualified path production:
Externs.Res / prelude.Option / any Pkg.Type in type or effect
position failed parse error at the ., contradicting already-settled
ADR-011 (real modules with qualified paths). This implements full,
sound
module-qualified type & effect paths with . as the canonical
separator (:: stays value/import per ADR-011). 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
exactly as an import would, then look the member up inside that
module's own resolved symbols
, requiring Public/PubCrate and the
right kind (SKType for types, SKEffect for effects). An unknown or
wrong module, a missing member, a private member, or a wrong-kind
member is a resolution error at the use-site span — never a parse
error, never silently accepted. Importing a member flatly does not make
a bogus A.B.C resolve.

New grammar productions (verbatim)

path_seg:
  | s = lower_ident { s }
  | s = upper_ident { s }

module_prefix:
  | s = path_seg { [s] }
  | p = module_prefix DOT s = path_seg { p @ [s] }

qualified_type_path:
  | p = module_prefix DOT m = upper_ident
    { (p, m, $startpos, $endpos) }

type_expr_primary gains qualified bare / [args] / <args> forms
(TyCon/TyApp (mk_qualified_ident ...)); effect_term gains
qualified bare / [args] forms (EffVar/EffCon). Module-prefix
segments accept lower OR upper case because real stdlib modules are
prelude/option/string/result as well as Network/Http.

Files changed

File Why
lib/ast.ml ident gains modpath : string list; add mk_ident helper so synthetic idents set it in one place
lib/parser.mly mk_qualified_ident; path_seg/module_prefix/qualified_type_path; qualified type & effect productions
lib/typecheck.ml lower_type_expr/lower_effect_expr validate qualified paths via loader-backed qualified_member_check; Qualified_path_errorQualifiedPathError at the check_program boundary; validated qualified effect admitted as declared effect (#59)
lib/resolve.ml make_qualified_member_check (sound module-scoped lookup) inside the resolve_and_typecheck_module recursive group
bin/main.ml thread the validator into the 6 patterned check_program calls
lib/{trait,codegen_gc,borrow,verilog_codegen}.ml route ~10 internal ident literals through Ast.mk_ident
docs/specs/SETTLED-DECISIONS.adoc, .machine_readable/6a2/META.a2ml ADR-014 (same shape as ADR-011/012/013)
test/test_e2e.ml, test/dune, tests/conformance/qualified-paths/** 5 conformance fixtures wired into the gate
test/golden/*.expected regenerated — adding modpath reflows every AST dump; span-normalised golden harness confirms no structural change

Parser-conflict delta: ZERO

menhir --explain summary is byte-identical to the pre-change
parser:

21 states have shift/reduce conflicts.
one state has reduce/reduce conflicts.
68 shift/reduce conflicts were arbitrarily resolved.
7 reduce/reduce conflicts were 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; just build masks the same count as before.

Test gate

Before After
dune runtest 258 PASS / 0 FAIL 263 PASS / 0 FAIL (+5 #228 conformance cases)

Zero regressions. The 12 golden tests pass with regenerated .expected
(the change is purely the new modpath field + ppx_deriving.show
reflow; the span-normalising golden harness verifies no structural
change).

Oracle re-audit (build is the new oracle)

28 repos / 1176 .affine files. Pre-fix baseline vs post-fix:

Class Pre-fix Post-fix Δ
PASS 552 552 0
DRIFT-SYNTAX 498 491 -7
TYPE-ONLY 126 133 +7

7 files moved DRIFT-SYNTAX → TYPE-ONLY (accessibility-everywhere ×5,
standards ×2): their only parse blocker was a qualified type/effect
path; they now parse and resolve. Spot-check (before → after):

  • accessibility-everywhere/tools/cli/src/Index.affine: 6:17 parse error → resolves (now UndefinedModule, sound)
  • standards/a2ml/.../Json.affine: 15:29 parse error at A2ml.Inline → resolves
  • standards/k9-svc/.../K9_Renderer.affine: 17:37 parse error at K9_Types.Pedigree → resolves

The PASS count is unchanged (not the "large jump" the spec hoped for)
because the remaining DRIFT-SYNTAX is unrelated, out-of-#228-scope
syntax
extern fn … = "console" "warn" FFI string-bindings,
pub extern fn, record-construction expression syntax — confirmed by
sampling the residual parse errors. And because the audit harness uses
a bare loader without each repo's local module search paths, a
qualified path to a repo's own Externs/Types module now correctly
reports UndefinedModule (sound — not silently accepted) rather than
masquerading as a parse error. This is the correct sound outcome per
the spec ("a wrong/unknown module … is an error").

A/B sanity probes (all hold)

  • prelude.Option[Int] / prelude.Option<Int>Type checking passed
  • bare Option[Int] → still Type checking passed
  • Foo.Bar[Int] → resolution error Unknown module 'Foo' … (ADR-014) (not parse)
  • effects.IOMember 'IO' of module 'effects' is private … (ADR-014) (sound visibility)
  • effmod.Logging (a pub effect) → Type checking passed
  • prelude.Option used as effect → 'prelude.Option' resolves to a type, not a effect (ADR-014)
  • prelude.DefinitelyNotHereModule 'prelude' has no member … (ADR-014)
  • Int -{IO}-> Int → still parses (Type checking passed)

Deviations from spec (reality)

  1. Resolution happens in typecheck.ml, not resolve.ml. The spec
    directed edits to Resolve.resolve_type_ident/resolve_effect_ident,
    but those functions are dead code — actual type/effect name
    binding is in Typecheck.lower_type_expr/lower_effect_expr.
    typecheck.ml cannot depend on resolve.ml (would be circular), so the
    sound module-scoped validator is built in resolve.ml (where the
    loader + resolve_and_typecheck_module live) and injected into
    the typecheck context via an optional qualified_member_check
    closure threaded through check_program. Same soundness, no
    circular dependency.
  2. Module-prefix segments must accept lowercase. The spec said "a
    path of upper_ident segments", but real stdlib modules are
    lowercase (prelude, option). The grammar accepts lower-or-upper
    prefix segments (mirroring module_path/use), member is
    upper_ident. Conflict count still zero-delta.
  3. Qualified effect admitted as declared effect. A qualified effect
    that passes the module-scoped gate is registered in
    declared_effects so issue-[Repo] Effect-row v1 name list: define IO/Async/Partial/Throws[E]/Mut early #59's effect-name validator accepts it
    (a sibling module's public effect is as legitimate as a local one).
    Soundness preserved — only after the gate passes.
  4. Typecheck/codegen logic otherwise unchanged (as predicted): a
    qualified ident is treated by its resolved symbol exactly like an
    unqualified one.

🤖 Generated with Claude Code

Known limitation (disclosed, not a blocker)

The qualified-type positive conformance fixture is self-contained
(prelude.Option — a stdlib module). The qualified-effect positive
fixture (tests/conformance/qualified-paths/valid/qualified_effect.affine)
references a sibling support module effmod because no stdlib module
currently declares a public effect
. Consequence:

  • It passes the test gate (the harness adds the fixture dir to the module
    search-path) but fails a bare affinescript check with
    Unknown module 'effmod'.
  • Bare-oracle audits — including the estate dialect-conformance
    .affine-audit harness — must treat that one fixture as harness-only
    and exclude it from drift counts. This is now stated in the fixture
    header and in ADR-014.
  • Qualified-effect resolution itself is sound and independently proven
    by invalid/private_member.affine (loads stdlib effects, rejects the
    private member). The limitation is fixture self-containment, not the
    feature.

Also disclosed: the estate-payoff numbers from the re-audit are
understated by the bare-loader audit harness (repo-local modules
report UndefinedModule). A re-audit with each repo's own module graph
resolvable is pending and is the load-bearing review evidence.

claude added 2 commits May 18, 2026 23:31
…tor (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>
…ture limitation

- .machine_readable/6a2/STATE.a2ml: bump last-updated; add
  session-note-2026-05-18 recording #228/ADR-014/PR#231 (machine
  state/progress convention was stale).
- docs/specs/SETTLED-DECISIONS.adoc (ADR-014): add 'Known limitation'
  paragraph — qualified-effect positive fixture is harness-only (no
  public stdlib effect for a self-contained positive); bare-oracle
  audits must exclude it.
- tests/conformance/qualified-paths/valid/qualified_effect.affine:
  header now states the harness-search-path dependency explicitly.

Turns a silent landmine into a stated limitation. No code change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@hyperpolymath
Copy link
Copy Markdown
Owner Author

#228 estate-payoff proof (controlled before/after)

Method: same .affine files, two binaries — before = affinescript main
@417b97c (untouched), after = stage-c/228-qualified-type-effect-path.
Per-file first-error position+class captured. The coarse PASS/DRIFT count
understates #228 (oracle stops at the FIRST parse error); this measures
whether that first error stops being a module-qualified-path parse error.
Harness: .affine-audit/payoff.sh, raw: .affine-audit/payoff.tsv.

Result — #228 works, scope is narrow (honest correction)

Seed: 13 repos, ~98 files. Only accessibility-everywhere had
qualified-path drift.
The other 12 (airborne-submarine-squadron,
idaptik, idaptik-dlc-vm, stapeln, aerie, ambientops, echidna,
game-server-admin, git-scripts, invariant-path, ubicity, panll) showed
0 wall-cleared / 0 wall-advanced — their drift is ReScript-surface /
record-literal = #229 territory, NOT #228. This corrects the earlier
"single dominant fault estate-wide" framing: the qualified-path gap was
dominant in the diagnostic seed (accessibility-everywhere), not estate-wide.

accessibility-everywhere — controlled before/after (per-package cwd)

Bucket n BEFORE (main) AFTER (#228)
qualified-path files 11 all parse error 5 parse-clean → resolution; 6 parse-advanced past the qualified wall; 0 regress
Externs.affine support mods 4 PASS PASS (unchanged)

Sharpest proof: tools/monitoring-api/src/Server.affine — parse error
95:31 (the mut Externs.Res / -{Externs.Net}-> qualified wall) →
264:32: 169 lines of qualified-path walls removed, residual is a
later non-qualified construct (#229).

WALL-CLEARED (parse error → no longer a parse error): Cli, cli/Index,
github-action/Index, core/Index, scanner/Index.
WALL-ADVANCED (qualified wall gone, later non-qualified error): Server
95→264, Arangodb 103→127, Scanner 81→111, Action 105→114, Button
42:23→42:36, react/Index 17→30.

What this means for review

@hyperpolymath
Copy link
Copy Markdown
Owner Author

Independent gate re-confirmation (not relayed from the implementing agent)

Both #231 gates re-verified from scratch:

Test suitedune runtest in the worktree: exit 0, alcotest reports
Test Successful — 263 tests run, 0 failures. The pre-existing suite
is a subset and is fully green; the +5 over baseline are exactly the new
tests/conformance/qualified-paths/** fixtures. No regressions.

Menhir conflict delta — standalone menhir 20260209 (not via dune) on
git show main:lib/parser.mly vs git show HEAD:lib/parser.mly, same
--table flag:

21 states have shift/reduce conflicts.
68 shift/reduce conflicts were arbitrarily resolved.
7  reduce/reduce conflicts were arbitrarily resolved.
one state has reduce/reduce conflicts.

Byte-identical on both grammar versions — diff empty. Confirms ADR-014's
"the masked-conflict count is unchanged … identical to the pre-change
parser". The new qualified-path productions add the expected benign DOT
shift and introduce zero new conflicts.

Net: implementation gates verified independently. #231 residual is now
only the human review + merge it explicitly requires (Refs #228, not
Closes; human-gated per ISSUE-CLOSURE).

@github-actions
Copy link
Copy Markdown

🔍 Hypatia Security Scan

Findings: 44 issues detected

Severity Count
🔴 Critical 12
🟠 High 21
🟡 Medium 11

⚠️ Action Required: Critical security issues found!

View findings
[
  {
    "reason": "Stray AI.a2ml in root -- use 0-AI-MANIFEST.a2ml only",
    "type": "banned",
    "file": "AI.a2ml",
    "action": "delete",
    "rule_module": "root_hygiene",
    "severity": "high"
  },
  {
    "reason": "Superseded by 0-AI-MANIFEST.a2ml",
    "type": "banned",
    "file": "AI.djot",
    "action": "delete",
    "rule_module": "root_hygiene",
    "severity": "high"
  },
  {
    "reason": "Issue in quality.yml",
    "type": "missing_workflow",
    "file": "quality.yml",
    "action": "create",
    "rule_module": "workflow_audit",
    "severity": "high"
  },
  {
    "reason": "Issue in security-policy.yml",
    "type": "missing_workflow",
    "file": "security-policy.yml",
    "action": "create",
    "rule_module": "workflow_audit",
    "severity": "medium"
  },
  {
    "reason": "Action hyperpolymath/standards/.github/workflows/governance-reusable.yml@main needs attention",
    "type": "unpinned_action",
    "file": "governance.yml",
    "action": "pin_sha",
    "rule_module": "workflow_audit",
    "severity": "high"
  },
  {
    "reason": "TypeScript file detected -- banned language",
    "type": "banned_language_file",
    "file": "/home/runner/work/affinescript/affinescript/affinescript-deno-test/example/smoke_driver.ts",
    "action": "flag",
    "rule_module": "cicd_rules",
    "severity": "critical"
  },
  {
    "reason": "TypeScript file detected -- banned language",
    "type": "banned_language_file",
    "file": "/home/runner/work/affinescript/affinescript/affinescript-deno-test/cli.ts",
    "action": "flag",
    "rule_module": "cicd_rules",
    "severity": "critical"
  },
  {
    "reason": "TypeScript file detected -- banned language",
    "type": "banned_language_file",
    "file": "/home/runner/work/affinescript/affinescript/affinescript-deno-test/mod.ts",
    "action": "flag",
    "rule_module": "cicd_rules",
    "severity": "critical"
  },
  {
    "reason": "TypeScript file detected -- banned language",
    "type": "banned_language_file",
    "file": "/home/runner/work/affinescript/affinescript/affinescript-deno-test/lib/compile.ts",
    "action": "flag",
    "rule_module": "cicd_rules",
    "severity": "critical"
  },
  {
    "reason": "TypeScript file detected -- banned language",
    "type": "banned_language_file",
    "file": "/home/runner/work/affinescript/affinescript/affinescript-deno-test/lib/runner.ts",
    "action": "flag",
    "rule_module": "cicd_rules",
    "severity": "critical"
  }
]

Powered by Hypatia Neurosymbolic CI/CD Intelligence

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants