spec: ct-tidy — canonical-type-names follow-up sweep
This commit is contained in:
@@ -0,0 +1,423 @@
|
||||
# Canonical-Type-Names Tidy Sweep — Design Spec
|
||||
|
||||
**Date:** 2026-05-12
|
||||
**Status:** Draft — awaiting user spec review
|
||||
**Authors:** Brummel (orchestrator) + Claude
|
||||
|
||||
## Goal
|
||||
|
||||
Close three follow-up todos that the canonical-type-names milestone
|
||||
(ct.1 → ct.4, closed 2026-05-11) explicitly deferred or surfaced.
|
||||
All three touch workspace / env / registry mechanics and share the
|
||||
theme "structural gaps the canonical-type-names milestone declared
|
||||
out-of-scope". Bundling them into one tidy milestone keeps the
|
||||
closeout coherent rather than scattering three orphan tidy patches
|
||||
across the next several weeks.
|
||||
|
||||
The three items:
|
||||
|
||||
1. **env-overlay shape ratification + narrowing.** Decide whether
|
||||
the env's parallel `types` / `ctor_index` maps should collapse
|
||||
into one overlay or stay split, anchor the decision in
|
||||
`docs/DESIGN.md`, and narrow `check_in_workspace`'s per-module
|
||||
overlay rebuild symmetric to ct.3.2's mono-side fix.
|
||||
2. **`Registry.type_def_module` re-key.** Replace the
|
||||
`BTreeMap<String, String>` keyed by bare type name with a key
|
||||
that survives cross-module bare-name collisions.
|
||||
3. **`KindMismatch` retire.** Delete the
|
||||
`WorkspaceLoadError::KindMismatch` variant and `walk_kind_mismatch`
|
||||
helper — structurally unreachable post-ct.1, ratified by the
|
||||
already-green test `class_param_in_applied_position_fires_canonical_form_rejection`
|
||||
at `crates/ailang-core/src/workspace.rs:1867`.
|
||||
|
||||
Items deliberately **not** in scope: CLI human-mode diagnostic
|
||||
surface for `WorkspaceLoadError` (different layer — diagnostic
|
||||
routing, not workspace mechanics); Split `BadCrossModuleTypeRef`
|
||||
(diagnostic shape); Workspace search beyond entry-module's
|
||||
directory (CLI / IO layer); `io/print_float` `.0` fallback
|
||||
(runtime printer asymmetry); Boehm retirement (memory
|
||||
subsystem); rustdoc warning sweep (documentation). Each of these
|
||||
is a coherent tidy in its own right but does not share the
|
||||
workspace-mechanics theme.
|
||||
|
||||
## Architecture
|
||||
|
||||
The milestone is three iterations, ordered from highest-design-
|
||||
substance to lowest. The order is deliberate: the env-overlay
|
||||
decision in ctt.1 is the only one carrying a real choice, so it
|
||||
happens first while the design context is fresh. ctt.2 and ctt.3
|
||||
are mechanical follow-ups whose shape does not depend on ctt.1.
|
||||
|
||||
### Iteration ctt.1 — env-overlay shape ratification + narrowing
|
||||
|
||||
**Design substance:** Today `Env` carries two parallel maps that
|
||||
are rebuilt in lockstep:
|
||||
|
||||
- `env.types: IndexMap<String, TypeDef>` — bare-type-name →
|
||||
full TypeDef. The owning index for type definitions.
|
||||
- `env.ctor_index: IndexMap<String, CtorRef>` — bare-ctor-name →
|
||||
owning-type-name. A reverse index over `env.types`.
|
||||
|
||||
The "shape question" from `docs/journals/2026-05-10` ("Audit close:
|
||||
env-construction unify") is: collapse into one overlay
|
||||
(`Map<Name, EntryKind>` with `Type(TypeDef) | Ctor(owner)`) or
|
||||
stay split?
|
||||
|
||||
**Decision: stay split.** Reason (semantic, not effort): the two
|
||||
maps have distinct semantic roles. `env.types` is an *owning*
|
||||
index — it stores the data. `env.ctor_index` is a *reverse*
|
||||
index — it accelerates `ctor-name → type-name` lookup over the
|
||||
same data. A collapsed variant-keyed map would conflate these
|
||||
roles and force every consumer to discriminate `EntryKind` at
|
||||
the call site. The current split lets type-consumers iterate
|
||||
`env.types` directly and ctor-consumers query `env.ctor_index`
|
||||
directly; neither needs to know the other exists.
|
||||
|
||||
This rationale gets anchored in `docs/DESIGN.md` as a new
|
||||
top-level section `## Env construction`, placed between the
|
||||
existing `## Convention: qualified cross-module references`
|
||||
(`docs/DESIGN.md:1941`) and `## Data model` (`docs/DESIGN.md:1959`).
|
||||
DESIGN.md today has no env / overlay section, so this iter
|
||||
introduces the heading. The initial body is one short paragraph
|
||||
naming the split (`env.types` owning vs. `env.ctor_index`
|
||||
reverse-index) with the rationale above; future env / overlay
|
||||
mechanics can extend the section without re-anchoring. Future
|
||||
tidies cannot re-litigate the split without first amending this
|
||||
section.
|
||||
|
||||
**Code change: one new pinning test.** The DESIGN.md anchor is
|
||||
the primary deliverable, plus the roadmap close. The iter also
|
||||
adds one positive test that pins the load-bearing
|
||||
`DuplicateCtor` consumer, because the in-band check at
|
||||
`crates/ailang-check/src/lib.rs:1366-1376` has no in-tree test
|
||||
ratifying it today (the variant is emitted in the production
|
||||
code path but no fixture currently triggers it). The pinning
|
||||
test is what makes the ratification load-bearing rather than
|
||||
documentation-of-belief — the asymmetry-with-mono-side argument
|
||||
rests on this consumer being real.
|
||||
|
||||
Inspection of the existing per-module rebuild at
|
||||
`crates/ailang-check/src/lib.rs:1342-1384` confirms both halves
|
||||
are load-bearing:
|
||||
|
||||
- `env.types.clear()` + rebuild: needed for the local
|
||||
`DuplicateType` diagnostic at `lib.rs:1358-1364` and for
|
||||
bare-name `Type::Con` lookup against the current module.
|
||||
- `env.ctor_index.clear()` + rebuild: emitted into the in-band
|
||||
`DuplicateCtor` diagnostic at `lib.rs:1366-1376`, which
|
||||
consults the rebuilt per-module `env.ctor_index` to detect
|
||||
duplicates *within the current module*. The new pinning test
|
||||
is the consumer evidence.
|
||||
|
||||
The ct.3.2 mono-side fix narrowed the *mono overlay* because the
|
||||
mono-side `env.ctor_index` was only feeding runtime ctor lookup
|
||||
(type-driven post-ct.2.2). The check-side overlay is shaped
|
||||
differently — it serves the workspace-build-time duplicate
|
||||
diagnostic — so the analogous narrowing does not apply.
|
||||
|
||||
The roadmap todo "check_in_workspace per-module overlay narrowing"
|
||||
closes via this finding: the rebuild stays, the asymmetry with
|
||||
the mono-side is intentional and now documented. The roadmap
|
||||
todo "types / ctor_index overlay shape question" closes via the
|
||||
DESIGN.md anchor recording the split rationale.
|
||||
|
||||
If a future recon surfaces a non-DuplicateCtor consumer of the
|
||||
post-rebuild `env.ctor_index` that *could* survive removal of
|
||||
the per-module rebuild, the question re-opens; until then the
|
||||
ratification holds.
|
||||
|
||||
### Iteration ctt.2 — `Registry.type_def_module` re-key
|
||||
|
||||
**Bug being closed:** `crates/ailang-core/src/workspace.rs:106`
|
||||
declares `type_def_module: BTreeMap<String, String>`. Today this
|
||||
map is bare-name → defining-module. The doc-comment at
|
||||
`workspace.rs:97-105` flags the issue: if two modules each
|
||||
declare `type Foo`, the second insert at
|
||||
`workspace.rs:547` silently overwrites the first; subsequently
|
||||
`normalize_type_for_registry` (`workspace.rs:892`) qualifies
|
||||
`Foo` to whichever module won the insert race.
|
||||
|
||||
For the current corpus (every in-tree fixture uses distinct bare
|
||||
type names across modules) the bug is dormant. The proper fix is
|
||||
explicitly written into the doc-comment: re-key as
|
||||
`(owning_module, bare_name) -> defining_module` and thread the
|
||||
calling module through every consumer site.
|
||||
|
||||
**Re-keying choice.** Two shapes are conceivable:
|
||||
|
||||
- `BTreeMap<(String, String), String>` keyed by
|
||||
`(owning_module, bare_name)` — straightforward extension.
|
||||
- `BTreeMap<String, Vec<(String, String)>>` keyed by bare name,
|
||||
value being a list of `(owner, defining_module)` pairs —
|
||||
preserves the bare-name-as-primary-lookup intuition but forces
|
||||
every consumer to handle the multi-candidate case.
|
||||
|
||||
**Decision: tuple key.** Reason (semantic): the function the map
|
||||
serves — "given a type reference written in module M, where is
|
||||
its type defined?" — already requires the owning module M as
|
||||
input. The tuple key makes that input explicit at the API
|
||||
surface. The list-valued alternative would defer
|
||||
multi-candidate handling to every consumer.
|
||||
|
||||
**Consumer site count.** `grep "type_def_module"` returns 13 hits
|
||||
in `workspace.rs`. Most are inside the workspace-load function
|
||||
where the calling module is local context (loop variable
|
||||
`mod_name`), so threading is trivial. The interesting site is
|
||||
`normalize_type_for_registry` at `workspace.rs:879-928`, which
|
||||
today takes `type_def_module: &BTreeMap<String, String>` as a
|
||||
parameter. The signature changes to accept the calling module
|
||||
as well, and the map type changes to
|
||||
`&BTreeMap<(String, String), String>`. All call sites already
|
||||
have the calling module in scope (the workspace-load loop
|
||||
threads it through `mod_name`).
|
||||
|
||||
The `Registry::normalize_type_for_lookup` method at
|
||||
`workspace.rs:121-123` exposes `normalize_type_for_registry`
|
||||
publicly without a calling-module argument. The signature gains
|
||||
a `caller_module: &str` argument. The two test-only call sites
|
||||
at `workspace.rs:2525, 2549` use synthetic modules; both update
|
||||
trivially.
|
||||
|
||||
**Test:** add a positive fixture where two modules each declare
|
||||
`type Foo` with distinct ctors, an instance on each, and confirm
|
||||
both instances resolve correctly under registry lookup. This
|
||||
catches the regression: under the old bare-name key, one of the
|
||||
two instances would silently lose its registry entry.
|
||||
|
||||
### Iteration ctt.3 — `KindMismatch` retire
|
||||
|
||||
**Path being deleted:**
|
||||
|
||||
- `WorkspaceLoadError::KindMismatch` variant
|
||||
(`crates/ailang-core/src/workspace.rs:259-264`).
|
||||
- `walk_kind_mismatch` helper
|
||||
(`crates/ailang-core/src/workspace.rs:834-854`).
|
||||
- Dispatch site in `validate_classdefs`
|
||||
(`crates/ailang-core/src/workspace.rs:786-795`).
|
||||
- Display arm in `crates/ail/src/main.rs:1163` for the
|
||||
human-mode diagnostic.
|
||||
|
||||
**Ratification:** Already-green test
|
||||
`class_param_in_applied_position_fires_canonical_form_rejection`
|
||||
(`crates/ailang-core/src/workspace.rs:1867-1888`) demonstrates
|
||||
that the malformed shape `class C f { foo : f Int -> f Int }`
|
||||
fires `BareCrossModuleTypeRef` before `validate_classdefs`
|
||||
runs. This is the structural-unreachability claim made by the
|
||||
roadmap entry, ratified by a workspace-loaded test that
|
||||
currently passes.
|
||||
|
||||
The test must keep passing after the deletion. It asserts on
|
||||
`BareCrossModuleTypeRef`, not `KindMismatch`, so no test edit
|
||||
is required.
|
||||
|
||||
**Defensive vs. retired:** The doc comment at
|
||||
`workspace.rs:1858-1866` reads "stays in the codebase as
|
||||
dead-but-defensive code; a future tidy may retire it." This
|
||||
iter is that future tidy. The "defensive" framing was a
|
||||
deliberate hedge during ct.1 in case a canonical-form bug let
|
||||
malformed shapes through later — none has surfaced. Carrying
|
||||
the dead branch indefinitely is itself a hazard: future
|
||||
refactors of the workspace error-enum touch shapes that no
|
||||
code path produces. Retire now, before the dead branch
|
||||
accumulates churn.
|
||||
|
||||
If a future canonical-form bug *does* let a malformed shape
|
||||
through, the right repair is to fix the canonical-form
|
||||
validator, not to resurrect `KindMismatch`. The diagnostic
|
||||
that fires today (`BareCrossModuleTypeRef`) is structurally
|
||||
correct: a class param `f` used as `f Int` is, post-ct.1, a
|
||||
reference to a non-existent type `f` in the owning module —
|
||||
that *is* a bare-cross-module type ref. The earlier
|
||||
`KindMismatch` diagnostic was a less-precise pre-ct.1 framing.
|
||||
|
||||
## Components
|
||||
|
||||
### Files touched per iter
|
||||
|
||||
**ctt.1 — env-overlay shape ratification + DuplicateCtor pin:**
|
||||
- `docs/DESIGN.md` — new top-level section `## Env construction`
|
||||
inserted between `## Convention: qualified cross-module
|
||||
references` (line 1941) and `## Data model` (line 1959).
|
||||
Initial body: one paragraph naming the `env.types` /
|
||||
`env.ctor_index` split with the owning-vs-reverse-index
|
||||
rationale.
|
||||
- `crates/ailang-check/tests/` — new test (path TBD by planner —
|
||||
candidate: a new file `duplicate_ctor_pin.rs`, or appended to
|
||||
an existing fixture-driven test module) that builds a one-
|
||||
module fixture with two `Def::Type`s declaring same-named
|
||||
ctors and asserts `check_workspace` returns
|
||||
`CheckError::DuplicateCtor { ctor, a, b }`. The exact
|
||||
assertion path follows the existing `CheckError`-asserting
|
||||
test conventions in the crate.
|
||||
- `docs/roadmap.md` — strike both relevant todo lines
|
||||
("types / ctor_index overlay shape question" and
|
||||
"check_in_workspace per-module overlay narrowing") with a
|
||||
reference to `docs/journals/<this iter>.md`.
|
||||
|
||||
**ctt.2 — `Registry.type_def_module` re-key:**
|
||||
- `crates/ailang-core/src/workspace.rs:106` — field type change
|
||||
to `BTreeMap<(String, String), String>`.
|
||||
- `crates/ailang-core/src/workspace.rs:121-123` —
|
||||
`Registry::normalize_type_for_lookup` gains `caller_module: &str`.
|
||||
- `crates/ailang-core/src/workspace.rs:537, 547` — Pass 1
|
||||
collection: insert `(mod_name.clone(), t.name.clone())` instead
|
||||
of `t.name.clone()`.
|
||||
- `crates/ailang-core/src/workspace.rs:656-657` —
|
||||
`type_def_module.get(&type_repr)` becomes a per-module-aware
|
||||
lookup (the call-site context provides `mod_name`).
|
||||
- `crates/ailang-core/src/workspace.rs:879-928` —
|
||||
`normalize_type_for_registry` signature + body adapt to tuple
|
||||
key + `caller_module` argument.
|
||||
- `crates/ailang-core/src/workspace.rs:2525, 2549` — test-only
|
||||
call sites update.
|
||||
- `crates/ailang-core/src/workspace.rs:97-105` — doc-comment
|
||||
flagging the bug is replaced with the new shape's invariant.
|
||||
- New test fixture: `examples/ct_tidy_cross_module_foo_collision.ail.json`
|
||||
(or analogous), exercising two-module bare-name collision.
|
||||
- `crates/ailang-core/tests/` or `crates/ail/tests/e2e.rs` —
|
||||
test that exercises the new fixture and asserts both
|
||||
instances resolve correctly.
|
||||
|
||||
**ctt.3 — `KindMismatch` retire:**
|
||||
- `crates/ailang-core/src/workspace.rs:259-264` — variant deletion.
|
||||
- `crates/ailang-core/src/workspace.rs:786-795` — dispatch site
|
||||
deletion (the `walk_kind_mismatch(&method.ty, &c.param)` call
|
||||
inside `validate_classdefs`).
|
||||
- `crates/ailang-core/src/workspace.rs:834-854` — helper deletion.
|
||||
- `crates/ail/src/main.rs:1163` — Display arm deletion.
|
||||
- `crates/ailang-core/src/workspace.rs:1858-1866` — the
|
||||
"dead-but-defensive" doc comment on the existing test gets
|
||||
dropped (the test stays; its rationale is now "the canonical-form
|
||||
rejection is the path", which the test name already states).
|
||||
|
||||
### Files NOT touched
|
||||
|
||||
- `crates/ailang-check/src/lib.rs:1342-1384` — the per-module
|
||||
overlay rebuild stays as-is after ctt.1's recon confirms
|
||||
`DuplicateCtor` depends on it. The narrowing question closes
|
||||
via DESIGN.md ratification, not code change. If planner-recon
|
||||
for ctt.1 surfaces a consumer that *would* survive narrowing,
|
||||
this changes; default expectation is no edit here.
|
||||
- `crates/ailang-codegen/` — no codegen edits. None of the three
|
||||
items touch IR or runtime.
|
||||
- Snapshots — no IR-shape snapshots are affected. The bench
|
||||
baseline is not affected (no codepath frequency change).
|
||||
|
||||
## Data flow
|
||||
|
||||
The three iters do not interact at the data level:
|
||||
|
||||
- ctt.1 records a decision in DESIGN.md; no code-side data flow
|
||||
change.
|
||||
- ctt.2 changes the registry's internal key shape but preserves
|
||||
the public `Registry::normalize_type_for_lookup` semantics for
|
||||
every call site that already has its calling module in scope.
|
||||
External canonical-form behaviour is unchanged for the current
|
||||
corpus (which has no bare-name collisions); the fix is
|
||||
load-bearing only when a future workspace introduces a
|
||||
collision.
|
||||
- ctt.3 deletes an unreachable branch; no data flow.
|
||||
|
||||
Canonical-form bytes: unchanged. None of the three iters touches
|
||||
serialised AST shapes. ctt.2's registry-key change is internal —
|
||||
the canonical-form output of `normalize_type_for_registry` for
|
||||
any *valid* input is the same as today (the bug surfaces only on
|
||||
the malformed-collision input that the current corpus doesn't
|
||||
contain, and the new behaviour is "qualify to the correct
|
||||
owner" rather than "qualify to whichever module won the race",
|
||||
which is by definition a fix not a shape change).
|
||||
|
||||
## Error handling
|
||||
|
||||
**ctt.1:** no new diagnostics. The ratification is documentation-
|
||||
only at the analyser level.
|
||||
|
||||
**ctt.2:** no new diagnostics. The `DuplicateInstance` /
|
||||
`OrphanInstance` paths at `workspace.rs:682, 660` already cover
|
||||
the collision-error space; the re-key fixes the silent
|
||||
collision by ensuring the right registry entry survives, which
|
||||
means a previously-masked instance becomes visible to the
|
||||
existing `DuplicateInstance` detector when its canonical-form
|
||||
key matches another (and is not falsely promoted to "duplicate"
|
||||
when it actually belongs to a different owner).
|
||||
|
||||
**ctt.3:** one diagnostic variant goes away. The Display path
|
||||
in `main.rs:1163` deletes the human-mode `[kind-mismatch]`
|
||||
line; the structurally-identical class-param-in-applied-position
|
||||
malformed shape now produces `[bare-cross-module-type-ref]`
|
||||
instead. Existing user-facing diagnostic for the canonical
|
||||
malformed-fixture is the latter (the existing test confirms
|
||||
this).
|
||||
|
||||
## Testing strategy
|
||||
|
||||
**ctt.1:**
|
||||
- New pinning test: one-module fixture with two `Def::Type`s
|
||||
declaring same-named ctors. Asserts `check_workspace` returns
|
||||
`CheckError::DuplicateCtor { ctor, a, b }` with `a` and `b`
|
||||
naming the two types. Catches any future regression that
|
||||
silently removes the per-module `env.ctor_index` rebuild —
|
||||
if the rebuild goes away, this test goes red, which is
|
||||
exactly the load-bearing-asymmetry-argument's safety net.
|
||||
- `cargo test --workspace` continues to pass with all other
|
||||
tests unchanged.
|
||||
- DESIGN.md anchor presence verified manually (no schema-drift
|
||||
test exists for the env section yet — see roadmap item
|
||||
"design_schema_drift.rs fidelity widening", out-of-scope here).
|
||||
- Roadmap close: both relevant lines struck through.
|
||||
|
||||
**ctt.2:**
|
||||
- New fixture: two modules each declaring `type Foo` with
|
||||
distinct ctors; an instance on each. Test: both instances
|
||||
registerable, both retrievable by their canonical-form
|
||||
registry key, no `DuplicateInstance` false positive, no
|
||||
silent overwrite.
|
||||
- Existing test suite must continue to pass (no current fixture
|
||||
exercises the collision case, so no existing test regresses).
|
||||
|
||||
**ctt.3:**
|
||||
- Existing test `class_param_in_applied_position_fires_canonical_form_rejection`
|
||||
(`crates/ailang-core/src/workspace.rs:1867`) continues to
|
||||
pass, asserting `BareCrossModuleTypeRef` on the malformed
|
||||
fixture.
|
||||
- `cargo test --workspace` continues to pass (no other test
|
||||
references `KindMismatch` — `grep KindMismatch` returns the
|
||||
three deletion sites only).
|
||||
- `cargo build --workspace` succeeds (no orphan match arms,
|
||||
no dead-code warnings introduced).
|
||||
|
||||
**Workspace-wide gate (all three iters):**
|
||||
- `bench/cross_lang.py`, `bench/compile_check.py`,
|
||||
`bench/check.py` all green. No metric should plausibly
|
||||
shift, but the audit at milestone close confirms.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- `docs/DESIGN.md` carries a new top-level section
|
||||
`## Env construction` (placed between `## Convention:
|
||||
qualified cross-module references` and `## Data model`)
|
||||
whose initial paragraph anchors the `env.types` /
|
||||
`env.ctor_index` split with the semantic rationale (owning
|
||||
index vs. reverse index).
|
||||
- A new pinning test in `crates/ailang-check/tests/` (path
|
||||
finalised in planning) asserts `CheckError::DuplicateCtor`
|
||||
fires for a one-module fixture with two `Def::Type`s
|
||||
declaring same-named ctors — ratifies the consumer the
|
||||
ctt.1 ratification rests on.
|
||||
- `Registry.type_def_module` key is `(String, String)`, value
|
||||
is `String`. Every consumer site provides the calling module
|
||||
at the call site. A regression test exercises two-module
|
||||
bare-name collision and confirms both instances resolve
|
||||
correctly.
|
||||
- `WorkspaceLoadError::KindMismatch`, `walk_kind_mismatch`, its
|
||||
dispatch site, and its `main.rs` Display arm are deleted. The
|
||||
existing test
|
||||
`class_param_in_applied_position_fires_canonical_form_rejection`
|
||||
still passes, asserting `BareCrossModuleTypeRef`.
|
||||
- `cargo test --workspace`, `bench/cross_lang.py`,
|
||||
`bench/compile_check.py`, `bench/check.py` all green at
|
||||
milestone close.
|
||||
- `docs/roadmap.md` has the three relevant todo lines struck
|
||||
through with reference to this spec or the per-iter journals.
|
||||
- `docs/WhatsNew.md` carries one entry covering the milestone
|
||||
in user-as-reader prose (no internals, no iter codes).
|
||||
Reference in New Issue
Block a user