From b638abf1e24db84bf763b0ef8d08a73a9bce35e3 Mon Sep 17 00:00:00 2001 From: Brummel Date: Wed, 13 May 2026 13:22:37 +0200 Subject: [PATCH] =?UTF-8?q?tidy:=20rustdoc-sweep=20+=20drift-test-narrowin?= =?UTF-8?q?g=20=E2=80=94=20autonomous=20batch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two unrelated hygiene iters bundled because they shipped together in one autonomous-while-Boss-away batch: - iter rustdoc-sweep: cleared all 23 `cargo doc --workspace --no-deps` warnings (17 in ailang-check + 4 in ailang-core + 2 in ailang-surface). Two warning classes: pub-doc-comment links to pub(crate)/private items (15 hits — replaced with plain backtick-code-spans), and unresolved/ambiguous links (8 hits — fully-qualified, disambiguated to fn-form, or escaped). No behaviour change; tests 562 → 562. - iter drift-test-narrowing: design_schema_drift.rs now scans §"Data model" only via new helper data_model_section(), instead of full DESIGN.md. Closes the audit-form-a-precursor [high] "anchor-elsewhere-passes-silently" failure mode. All 38 anchors verified pre-edit to live in §"Data model" so the tightening doesn't regress to red. New pin data_model_section_is_bounded guards the extractor against silent regression. Tests 562 → 563. --- crates/ailang-check/src/diagnostic.rs | 2 +- crates/ailang-check/src/lib.rs | 14 ++--- crates/ailang-check/src/mono.rs | 14 ++--- crates/ailang-check/src/uniqueness.rs | 4 +- crates/ailang-core/src/desugar.rs | 2 +- crates/ailang-core/src/workspace.rs | 6 +- .../ailang-core/tests/design_schema_drift.rs | 62 ++++++++++++++++--- crates/ailang-surface/src/loader.rs | 4 +- .../2026-05-13-iter-drift-test-narrowing.md | 52 ++++++++++++++++ .../journals/2026-05-13-iter-rustdoc-sweep.md | 61 ++++++++++++++++++ docs/journals/INDEX.md | 2 + 11 files changed, 192 insertions(+), 31 deletions(-) create mode 100644 docs/journals/2026-05-13-iter-drift-test-narrowing.md create mode 100644 docs/journals/2026-05-13-iter-rustdoc-sweep.md diff --git a/crates/ailang-check/src/diagnostic.rs b/crates/ailang-check/src/diagnostic.rs index d1f457b..7194cd7 100644 --- a/crates/ailang-check/src/diagnostic.rs +++ b/crates/ailang-check/src/diagnostic.rs @@ -167,7 +167,7 @@ pub struct SuggestedRewrite { /// Form-A AILang snippet to substitute at the offending site. For /// the linearity-check diagnostics this is typically `(clone )` /// or a small enclosing rewrite. The string MUST parse via the - /// surface `parse_term` entrypoint; [`crate::linearity`] tests + /// surface `parse_term` entrypoint; `crate::linearity` tests /// guard the round-trip. pub replacement: String, } diff --git a/crates/ailang-check/src/lib.rs b/crates/ailang-check/src/lib.rs index 7264283..5de24c1 100644 --- a/crates/ailang-check/src/lib.rs +++ b/crates/ailang-check/src/lib.rs @@ -2016,7 +2016,7 @@ pub(crate) fn is_fully_concrete(t: &Type) -> bool { } /// Iter 22b.2 (Task 9): per-call-site residual class constraint -/// recorded by [`synth`] when a `Term::Var` resolves to a class method. +/// recorded by `synth` when a `Term::Var` resolves to a class method. /// Carries the method name (for diagnostic rendering), the class, and /// the type the class is applied to at the call site. The type is /// typically a fresh metavar (which unification then ties to the @@ -2290,7 +2290,7 @@ pub fn refine_multi_candidate_residual( } /// Iter 23.4: per-call-site polymorphic-free-fn observation recorded by -/// [`synth`] when a `Term::Var` resolves to a `Type::Forall`-quantified +/// `synth` when a `Term::Var` resolves to a `Type::Forall`-quantified /// top-level def (NOT a class method — those are pushed to /// [`ResidualConstraint`] instead). Carries the bare name, the owning /// module (resolved through the same lookup ladder as `synth`'s Var arm), @@ -2298,7 +2298,7 @@ pub fn refine_multi_candidate_residual( /// fresh metavars instantiated for those vars at the call site. /// /// Consumed by the mono pass: after `synth` completes a body, each -/// `metas[i]` is `subst.apply`'d to recover the concrete type at vars[i]. +/// `metas\[i\]` is `subst.apply`'d to recover the concrete type at `vars\[i\]`. /// Fully-concrete observations become [`crate::mono::MonoTarget::FreeFn`] /// targets; non-concrete observations are silently dropped (covered by /// the missing-constraint check on residuals if relevant). @@ -3693,7 +3693,7 @@ pub struct Env { /// actual-module-name). Sibling-shape to [`Self::module_globals`]; /// populated workspace-flat by [`crate::build_check_env`] so the /// mono pass's per-fn entry points can re-seed the local - /// [`Self::imports`] before re-running [`synth`] on a body. Both + /// [`Self::imports`] before re-running `synth` on a body. Both /// `check_in_workspace` and `mono::build_workspace_env` consume /// the same env shape; `check_in_workspace` then overwrites /// [`Self::imports`] with the current module's map at the call @@ -3718,7 +3718,7 @@ pub struct Env { /// [`ModuleGlobals::class_methods`], re-keyed to /// `(qualified-class-name, method-name)` so post-retirement of /// `MethodNameCollision` two classes can share a method name. - /// Consumed by [`synth`]'s `Term::Var` arm via the resolved class + /// Consumed by `synth`'s `Term::Var` arm via the resolved class /// (from `method_to_candidate_classes` + dispatch) plus the method /// name. Mono's presence checks consult /// [`Self::method_to_candidate_classes`] (method-keyed natively). @@ -3742,13 +3742,13 @@ pub struct Env { /// Iter 22b.2 (Task 9): one-step superclass expansion table. /// Maps each class name to its superclass class name. Absence from /// the map means the class has no superclass — populated only for - /// classes that declare one. Used by [`check_fn`] to expand + /// classes that declare one. Used by `check_fn` to expand /// `Forall.constraints` for the missing-constraint check /// (Decision 11: max one step). The schema permits at most one /// superclass per class. pub class_superclasses: BTreeMap, /// Iter 22b.2 (Task 10): workspace-wide instance registry, threaded - /// through from [`Workspace::registry`]. Read by [`check_fn`] in the + /// through from [`Workspace::registry`]. Read by `check_fn` in the /// concrete-residual path: when a class-method call at a fully- /// concrete type produces a residual `(class, type_)` whose /// `(class, type_hash(type_))` key is absent from `registry.entries`, diff --git a/crates/ailang-check/src/mono.rs b/crates/ailang-check/src/mono.rs index f3eaec8..b657954 100644 --- a/crates/ailang-check/src/mono.rs +++ b/crates/ailang-check/src/mono.rs @@ -23,16 +23,16 @@ //! //! See `docs/plans/2026-05-09-22b3-monomorphisation.md` for the //! full task list. The eventual contract is for the pass to -//! consume the workspace's typeclass [`Registry`] and the per- +//! consume the workspace's typeclass [`ailang_core::workspace::Registry`] and the per- //! module class-method tables (already populated by -//! [`crate::check_in_workspace`]) and produce a workspace with: +//! `crate::check_in_workspace`) and produce a workspace with: //! //! 1. Synthesised [`Def::Fn`] entries — one per unique //! `(class, method, type-hash)` triple observed at any -//! class-method call site — appended to the [`Registry`]'s +//! class-method call site — appended to the registry's //! `defining_module` for that instance. //! 2. Rewritten call sites — every `Term::Var { name }` whose -//! `name` resolves through [`Env::class_methods`] is replaced +//! `name` resolves through `crate::Env::class_methods` is replaced //! by the corresponding mono-symbol name (qualified with the //! instance's `defining_module` iff that module differs from //! the calling module). @@ -403,7 +403,7 @@ fn workspace_has_specialisable_targets(ws: &Workspace) -> bool { /// /// Determinism: `ailang_core::canonical::type_hash` is the same /// function `workspace::build_registry` uses to key -/// [`Registry::entries`], so a registry-key match implies a +/// [`ailang_core::workspace::Registry::entries`], so a registry-key match implies a /// `mono_symbol` match. pub fn mono_symbol(base: &str, ty: &Type) -> String { mono_symbol_n(base, std::slice::from_ref(ty)) @@ -511,7 +511,7 @@ pub fn mono_target_key(t: &MonoTarget) -> (String, String, String) { /// and apply per-fn overlay (current_module, globals from /// module_globals, imports from module_imports, rigid_vars) plus the /// per-module env.types overlay -/// (see [`apply_per_module_types_overlay`]). +/// (see `apply_per_module_types_overlay`). pub fn build_workspace_env(ws: &Workspace) -> crate::Env { crate::build_check_env(ws) } @@ -773,7 +773,7 @@ fn contains_rigid_var(t: &Type) -> bool { /// 1. has name [`mono_symbol`]`(target.method, target.type_)`, /// 2. has type = the class's method type with the class param /// substituted to `target.type_` (rigid-var substitution via -/// [`crate::substitute_rigids`]); the result is a plain +/// `crate::substitute_rigids`); the result is a plain /// `Type::Fn` with no `Forall`, /// 3. has params + body taken from the instance's matching /// `InstanceMethod.body` if present and Lam-shaped; from the diff --git a/crates/ailang-check/src/uniqueness.rs b/crates/ailang-check/src/uniqueness.rs index 3a75f9a..bc15b6e 100644 --- a/crates/ailang-check/src/uniqueness.rs +++ b/crates/ailang-check/src/uniqueness.rs @@ -2,7 +2,7 @@ //! //! This pass classifies every binder of every fn body as either //! [`Uniqueness::Unique`] or [`Uniqueness::Shared`]. Unlike the -//! [`crate::linearity`] check it is **not** a user-facing diagnostic +//! `crate::linearity` check it is **not** a user-facing diagnostic //! pass — it is internal codegen input. Codegen consults the side //! table to decide where to emit `ailang_rc_inc` and `ailang_rc_dec` //! calls under `--alloc=rc` (Iter 18c.3). @@ -39,7 +39,7 @@ //! //! ## Position model //! -//! Identical to [`crate::linearity`]'s, modulo that this pass has no +//! Identical to `crate::linearity`'s, modulo that this pass has no //! activation gate (it runs on every fn). See that module's docs for //! the propagation rules. `Term::Lam` captures are conservatively //! treated as Consume — the same conservative decision the linearity diff --git a/crates/ailang-core/src/desugar.rs b/crates/ailang-core/src/desugar.rs index 66ed610..7dc8d54 100644 --- a/crates/ailang-core/src/desugar.rs +++ b/crates/ailang-core/src/desugar.rs @@ -79,7 +79,7 @@ //! desugar pass eliminates it before typecheck/codegen by **lifting** //! the LetRec to a synthetic top-level fn in the same module. The //! lifted name has the form `$lr_N`, fresh against both the -//! existing module-top-level def names and the [`Desugarer::used`] set. +//! existing module-top-level def names and the `Desugarer::used` set. //! Inside the LetRec's `body` and `in_term`, every reference to the //! original local name is rewritten via [`subst_var`] to the lifted //! name. The lifted [`FnDef`] is appended to the module's `defs` so diff --git a/crates/ailang-core/src/workspace.rs b/crates/ailang-core/src/workspace.rs index 50b2348..d3b13f5 100644 --- a/crates/ailang-core/src/workspace.rs +++ b/crates/ailang-core/src/workspace.rs @@ -90,7 +90,7 @@ pub struct Registry { /// defining module. Used by [`Self::normalize_type_for_lookup`] to /// rewrite a bare `Type::Con.name` to its always-qualified form /// before computing the registry key. Primitives are not present. - /// Populated in [`build_registry`] from the same scan that builds + /// Populated in `build_registry` from the same scan that builds /// the entry map. /// /// Keyed by `(owning_module, bare_name)`, value is the @@ -99,7 +99,7 @@ pub struct Registry { /// module M is `(M, "Foo")`, bare `Foo` from module N is /// `(N, "Foo")`, and the two carry distinct canonical /// qualifications under - /// [`normalize_type_for_registry`]. Pre-ctt.2 the key was + /// `normalize_type_for_registry`. Pre-ctt.2 the key was /// the bare name alone, and a workspace with two `type Foo` /// declarations silently overwrote one entry, then tripped /// `DuplicateInstance` on the loser-side instance after @@ -441,7 +441,7 @@ pub fn load_workspace(entry_path: &Path) -> Result &'static str { + let start = DESIGN_MD + .find("## Data model") + .expect("DESIGN.md must contain `## Data model` header"); + let from_start = &DESIGN_MD[start..]; + match from_start.find("\n## ") { + Some(end) => &from_start[..end], + None => from_start, + } +} + /// Every `Term` variant must have its JSON-schema anchor present in /// DESIGN.md §"Data model". An LLM author cannot produce a term variant /// whose `"t"` tag is absent from the canonical schema document. @@ -139,7 +158,7 @@ fn design_md_anchors_every_term_variant() { Term::ReuseAs { .. } => "reuse-as", }; assert!( - DESIGN_MD.contains(anchor), + data_model_section().contains(anchor), "DESIGN.md §Data-model is missing anchor `{anchor}` for a Term variant — \ add it to docs/DESIGN.md" ); @@ -169,7 +188,7 @@ fn design_md_anchors_every_pattern_variant() { Pattern::Ctor { .. } => "ctor", }; assert!( - DESIGN_MD.contains(anchor), + data_model_section().contains(anchor), "DESIGN.md §Data-model is missing anchor `{anchor}` for a Pattern variant" ); } @@ -202,7 +221,7 @@ fn design_md_anchors_every_type_variant() { Type::Forall { .. } => "forall", }; assert!( - DESIGN_MD.contains(anchor), + data_model_section().contains(anchor), "DESIGN.md §Data-model is missing anchor `{anchor}` for a Type variant" ); } @@ -231,7 +250,7 @@ fn design_md_anchors_every_literal_variant() { Literal::Float { .. } => "float", }; assert!( - DESIGN_MD.contains(anchor), + data_model_section().contains(anchor), "DESIGN.md §Data-model is missing anchor `{anchor}` for a Literal variant" ); } @@ -302,7 +321,7 @@ fn design_md_anchors_every_def_kind() { Def::Instance(_) => "instance", }; assert!( - DESIGN_MD.contains(anchor), + data_model_section().contains(anchor), "DESIGN.md §Data-model is missing anchor `{anchor}` for a Def kind" ); } @@ -326,7 +345,7 @@ fn design_md_anchors_every_parammode_variant() { ParamMode::Borrow => "borrow", }; assert!( - DESIGN_MD.contains(anchor), + data_model_section().contains(anchor), "DESIGN.md §Data-model is missing anchor `{anchor}` for a ParamMode variant" ); } @@ -363,8 +382,35 @@ fn design_md_anchors_nested_struct_keys() { for anchor in anchors { assert!( - DESIGN_MD.contains(anchor), + data_model_section().contains(anchor), "DESIGN.md §Data-model is missing nested-struct-key anchor `{anchor}`" ); } } + +/// Pin the §"Data model" section extractor itself: it must return a +/// non-empty slice starting with `## Data model`, and it must NOT bleed +/// into the next top-level section (`## Pipeline` today). A bug in the +/// extractor would otherwise silently widen every drift test's scope +/// back toward full-document scanning — the failure mode this file's +/// tightening exists to prevent. +#[test] +fn data_model_section_is_bounded() { + let section = data_model_section(); + assert!( + section.starts_with("## Data model"), + "section must start at the §Data model header; got first 40 bytes: {:?}", + §ion[..40.min(section.len())] + ); + assert!( + !section.contains("\n## Pipeline"), + "section must stop before the next top-level header (\\n## Pipeline); \ + extractor regressed to whole-document scanning" + ); + assert!( + section.len() > 1000, + "§Data model is the canonical schema reference; a slice shorter than \ + 1 KB suggests the extractor truncated; got {} bytes", + section.len() + ); +} diff --git a/crates/ailang-surface/src/loader.rs b/crates/ailang-surface/src/loader.rs index c0fe6be..c7e734d 100644 --- a/crates/ailang-surface/src/loader.rs +++ b/crates/ailang-surface/src/loader.rs @@ -3,7 +3,7 @@ //! AILang has two file forms today: Form A (`.ail`, the LLM //! authoring surface) and Form B (`.ail.json`, the canonical //! JSON-AST). These loaders accept both: for `.ail` they read the -//! source text and parse it via [`crate::parse`]; for `.ail.json` +//! source text and parse it via [`crate::parse()`]; for `.ail.json` //! they delegate to [`ailang_core::load_module`]. //! //! These functions live in `ailang-surface` rather than @@ -24,7 +24,7 @@ fn is_ail_source(path: &Path) -> bool { /// /// Dispatches on file extension: /// -/// - `.ail` — read source text, parse via [`crate::parse`], return +/// - `.ail` — read source text, parse via [`crate::parse()`], return /// the in-memory [`Module`]. /// - anything else — delegate to [`ailang_core::load_module`] and /// convert its `Error` into the corresponding diff --git a/docs/journals/2026-05-13-iter-drift-test-narrowing.md b/docs/journals/2026-05-13-iter-drift-test-narrowing.md new file mode 100644 index 0000000..698dbb5 --- /dev/null +++ b/docs/journals/2026-05-13-iter-drift-test-narrowing.md @@ -0,0 +1,52 @@ +# iter drift-test-narrowing — design_schema_drift.rs scans §"Data model" only + +**Date:** 2026-05-13 +**Started from:** rustdoc-sweep working-tree state +**Status:** DONE + +## Summary + +`crates/ailang-core/tests/design_schema_drift.rs` is the +drift-detection layer between AST enum definitions in +`crates/ailang-core/src/ast.rs` and the canonical schema +documentation in `docs/DESIGN.md` §"Data model". Each AST variant +must have its JSON-schema anchor (e.g. `"t": "lit"`, `"k": "fn"`) +documented; the tests assert presence of each anchor. + +Pre-iter, the assertion was `DESIGN_MD.contains(anchor)` — +substring search over the whole document. That was loose: an +anchor present only in §"Decision 11" (or any other discussion +section) was treated as documented even though §"Data model" — +the canonical schema reference section — was missing it. The +audit-form-a-precursor `[high]` drift report flagged this as the +silent-pass failure mode. + +This iter narrows the scan. New helper `data_model_section()` +extracts the slice of `DESIGN_MD` from the `## Data model` header +to the next top-level `## ` header. The 7 existing anchor tests +now call `data_model_section().contains(anchor)` instead of +`DESIGN_MD.contains(anchor)`. All 38 anchors verified present in +§"Data model" pre-edit (no regressions). A new test +`data_model_section_is_bounded` pins the extractor itself +(starts-with `## Data model`, does not bleed past `\n## Pipeline`, +length > 1 KB) so a future refactor cannot silently regress the +extractor to whole-document scanning. + +## Files touched + +- `crates/ailang-core/tests/design_schema_drift.rs` (M) + +## Verification + +- `cargo test -p ailang-core --test design_schema_drift` → + 8 passed (was 7; +1 for the bounded-section pin), 0 failed. +- `cargo test --workspace` → 563 passed (was 562; +1 from the + new pin), 0 failed. + +## Concerns + +- (none) + +## Known debt + +- (none) diff --git a/docs/journals/2026-05-13-iter-rustdoc-sweep.md b/docs/journals/2026-05-13-iter-rustdoc-sweep.md new file mode 100644 index 0000000..5558ee1 --- /dev/null +++ b/docs/journals/2026-05-13-iter-rustdoc-sweep.md @@ -0,0 +1,61 @@ +# iter rustdoc-sweep — clear all 23 cargo-doc warnings + +**Date:** 2026-05-13 +**Started from:** 48b1f77 (post-WhatsNew str-concat done-state) +**Status:** DONE + +## Summary + +Hygiene sweep against `cargo doc --workspace --no-deps`. Before: 23 +warnings (17 in `ailang-check` + 4 in `ailang-core` + 2 in +`ailang-surface`). After: zero. All edits are documentation-only — +no production code, no test code changed. Tests stay 562 green. + +Two warning classes, fixed with two different shapes: + +- **Public doc links to private items** (15 warnings). Pattern: + a `pub` item's `///` doc references `[`fn_name`]` where `fn_name` + is `pub(crate)` or private. Fix: replace ``[`fn_name`]`` with + the plain backtick-code-span ``` `fn_name` ```. The identifier + stays readable in rendered docs but rustdoc no longer tries to + resolve the link. +- **Unresolved links** (8 warnings). Sub-classes: + - ``[`Registry`]`` in `mono.rs` (×3) — `Registry` lives in + `ailang_core::workspace` and the path was bare. Fixed by + fully-qualifying as `[`ailang_core::workspace::Registry`]` / + `[`ailang_core::workspace::Registry::entries`]`. + - ``[`Env::class_methods`]`` — `Env` is in this crate but + bare; the doc string was demoted to plain backticks + (`` `crate::Env::class_methods` ``) because `Env` itself has + no `pub` re-export at the crate root that rustdoc could + follow. + - ``[`crate::parse`]`` ambiguous in `loader.rs` (×2) — rustdoc + saw both a function and a module with that path. Disambiguated + to the function form via ``[`crate::parse()`]`` per + rustdoc's own suggestion. + - `metas[i]` in `lib.rs` — rustdoc parsed `[i]` as a link. + Escaped as `metas\[i\]` (same fix for the sibling `vars\[i\]`). + +## Files touched + +- `crates/ailang-core/src/desugar.rs` (1 line) +- `crates/ailang-core/src/workspace.rs` (3 lines) +- `crates/ailang-surface/src/loader.rs` (2 lines) +- `crates/ailang-check/src/uniqueness.rs` (2 lines) +- `crates/ailang-check/src/diagnostic.rs` (1 line) +- `crates/ailang-check/src/mono.rs` (5 lines) +- `crates/ailang-check/src/lib.rs` (8 lines) + +## Verification + +- `cargo doc --workspace --no-deps 2>&1 | grep "^warning:" | wc -l` + → `0`. +- `cargo test --workspace` → 562 passed, 0 failed (baseline holds). + +## Concerns + +- (none) + +## Known debt + +- (none) diff --git a/docs/journals/INDEX.md b/docs/journals/INDEX.md index 9f8228b..4cc2829 100644 --- a/docs/journals/INDEX.md +++ b/docs/journals/INDEX.md @@ -58,3 +58,5 @@ - 2026-05-13 — iter bugfix-instance-body-unbound-var: instance method bodies now walk through `check_fn` — `check_def`'s `Def::Class(_) | Def::Instance(_)` arm at `crates/ailang-check/src/lib.rs:1574-1595` was early-returning `Ok(())` for both variants (the comment claimed iter 22b.2 landed instance-body typechecking; the wiring was never implemented), so an unbound identifier inside an instance-method lambda body slipped past `ail check` (false-OK exit 0 with `ok (N symbols across M modules)`) and surfaced only at `ail build` as the degraded internal-error diagnostic `monomorphise_workspace: unknown identifier: ` without source location, symbol kind, or "did you mean" candidates. Fix: split the combined arm so `Def::Class(_)` stays schema-only at this layer while `Def::Instance(inst)` routes through a new helper `check_instance` that, for each `InstanceMethod`, lifts the body `Term::Lam` into a synthetic `FnDef` and applies class-method substitution (class param → instance type via the existing `substitute_rigids` / `substitute_rigids_in_term` helpers — looked up in `env.class_methods[(qualify_class_ref_in_check(&inst.class, &env.current_module), im.name)]` for the class's `param` name) before handing to `check_fn`. The reuse of `check_fn` is what gets the body walked through the same identifier-resolution path as fn bodies, so an unbound name fires `[unbound-var]` at `ail check` with exit 1 and the standard structured diagnostic. RED: 2 tests in `crates/ail/tests/unbound_in_instance_method_pin.rs` (text-mode + `--json` shape, both committed in 72f3f65 ahead of the GREEN side per the audit-trail flow). GREEN: 557+2 = 559 green; both pins PASS; fn-body-level unbound-var path stays green; all 8 carve-out fixtures classify unchanged. Known debt (out of scope per "minimal fix" constraint, recorded in journal): `check_instance` does not yet cross-check the substituted method signature against the class's `ClassMethodEntry.method_ty` — a malformed instance declaring wrong types in its Lam would still typecheck cleanly. Closes the bug finding from fieldtest-form-a → 2026-05-13-iter-bugfix-instance-body-unbound-var.md - 2026-05-13 — iter form-a.tidy: post-fieldtest documentary tidy — 6 tasks; closes 2/3 fieldtest-form-a spec_gaps + 3/4 audit-form-a drift items. T1-T3 add three new sections to `crates/ailang-core/specs/form_a.md`: `### Class — (class ...)` (EBNF with optional `(superclass …)?` schema-matched cardinality + abstract-required vs default-bearing method clauses, anchored to `examples/test_22c_user_class_e2e.ail` ok 24/2), `### Instance — (instance ...)` (EBNF with canonical-form CLASS-REF rule explicitly stated + same-module bare example abbreviated from `mq3_class_eq_vs_fn_eq_classmod.ail` + cross-module qualified example from `show_user_adt.ail` + `bare-cross-module-class-ref` diagnostic anchor), and `(forall ...)` extension with optional `(constraints (constraint CLASS-REF TYPE)+)?` clause + `no-instance` diagnostic anchor + fifth Examples-block entry anchored to `cmp_max_smoke.ail` ok 22/2. §Definitions intro flipped `Three kinds` → `Five kinds`. T4 fixes 6 contradictory "seven carve-outs" sites in `docs/specs/2026-05-13-form-a-default-authoring.md` (preamble + §C1 + §C2 + §C3 + §"Data flow" two sites) to match the §C4(b) amendment commit 9fcda8b; post-edit grep returns 4 correctly-scoped surviving "seven" mentions (lines 233, 238, 463, 469 — all §C4(a)-scope or arithmetic/future-state). T5 deletes empty `#[cfg(test)] mod tests {}` placeholder + 6-line relocation-comment block from `crates/ailang-core/src/hash.rs:50-57` (the unit tests live in `crates/ailang-core/tests/hash_pin.rs` since form-a.1 T5 relocation; placeholder served no purpose). T6 rewrites `crates/ailang-surface/tests/round_trip.rs` module-level `//!` (lines 1-26) and inner `///` (lines 63-72) docstrings to the post-T10 four-property framing (parse-determinism + idempotency-under-print + CLI-pipeline-idempotency + carve-out-anchor) instead of retired Direction-1/Direction-2 framing, with sibling-crate breadcrumbs pointing at the other two enforcement points (`crates/ail/tests/roundtrip_cli.rs::cli_parse_then_render_then_parse_is_idempotent` for CLI-pipeline-idempotency, `crates/ailang-core/tests/carve_out_inventory.rs::examples_ail_json_inventory_matches_carve_outs` for carve-out-anchor). Drift item B2 ("plan-file seven-orphans, two sites" per audit-form-a) dropped from the iter: recon verified `docs/plans/2026-05-13-iter-form-a.1.md` contains zero defective `seven` mentions; all four hits are internally scoped to §C4(a), arithmetic, or future-state — the audit-form-a journal's "two sites" claim against the plan file did not match its contents at HEAD; decision recorded in journal §"Decision recorded — Drift item B2". Tests 559 green at every per-task gate (no test-file changes; T5 deletes a no-op `mod tests {}`). Pure-documentary iter, Phase 3 deliberately skipped per carrier; verification was per-task `cargo test --workspace` + `ail check` on the four corpus fixtures cited in T1-T3. Remaining open from form-a fieldtest queue: friction (no `str_concat` primitive — symmetric tidy iter queued) → 2026-05-13-iter-form-a.tidy.md - 2026-05-13 — iter str-concat: `str_concat : (borrow Str, borrow Str) -> Str` heap-Str concatenation primitive shipped in four-site lockstep, closing fieldtest-form-a friction finding #4. T1 RED-pin `crates/ail/tests/str_concat_e2e.rs` + new corpus fixture `examples/show_user_adt_with_label.ail` (Show user-ADT body using `(app str_concat "Item " (app int_to_str n))` through prelude `print`; ok 23/2). T2 `runtime/str.c` C helper `ailang_str_concat(a, b)` appended after `ailang_str_clone`: reads `len` headers from both source payloads, `str_alloc(la+lb)`, memcpys both bytes, NUL-terminates. T3 `crates/ailang-check/src/builtins.rs` install entry (Type::Fn arity-2 Borrow-Borrow params, Own return, effects empty) + list() row + `install_str_concat_signature` unit test reaching into `env.globals` directly (deeper than synth_in_builtins_env-based siblings because first builtin with arity-2 Borrow params). T4 `crates/ailang-codegen/src/lib.rs` four-site lockstep: extern `declare ptr @ailang_str_concat(ptr, ptr)` after str_clone declare + lower_app arm after str_clone arm (arity-2 check + two `lower_term` + `fresh_ssa` + body push) + `is_builtin_callable` match-list extended with `"str_concat"` + IR-pin unit test `str_concat_emits_call_to_ailang_str_concat` asserting both extern declaration AND call instruction in emitted IR. Five IR snapshots regenerated (`hello.ll`, `list.ll`, `max3.ll`, `sum.ll`, `ws_main.ll`) to absorb the new unconditional declare line in IR header — same upkeep pattern as hs.4 (sole diff verified at IR line 17 across all snapshots). T5 lockstep-collision repair: `examples/bug_unbound_in_instance_method.ail` had used `str_concat` as the literal UNBOUND name (because that was the fieldtester's LLM-natural repro); renamed to `format_label` (LLM-author-realistic helper name that will never become a builtin) and updated `crates/ail/tests/unbound_in_instance_method_pin.rs` (`replace_all` on `str_concat` → `format_label`, including one test name flipped to `check_fires_unbound_var_for_format_label_in_instance_method_body`), preserving the regression guard's intent (instance-method-body walked through unbound-var check) while substituting a name with stable unbound semantics. T6 `docs/DESIGN.md` new §"Heap-Str primitives" subsection (line 1994) between the milestone-24 Show-backer enumeration and the existing `Primitive output goes through ...` paragraph, cataloguing all five heap-Str primitives (`int_to_str`, `bool_to_str`, `float_to_str`, `str_clone`, `str_concat`) with type signatures, iter origins, and the user-visible-vs-prelude-internal distinction; Show-backer block unchanged. T7 `docs/roadmap.md` struck `[x] [feature] str_concat` line at end of P2 cluster. Two minor plan-vs-actual discrepancies recorded in journal Concerns: (a) T3 Step 5 test-count prediction `passed: 560 failed: 1` was actual `passed: 558 failed: 3` because the unbound_in_instance_method_pin tests turn RED at checker registration not at codegen (assertion is on the `[unbound-var]` diagnostic which depends only on the checker registry) — implementation correct, T5 fully repairs; (b) IR snapshot regen for T4 was not explicitly scripted in the plan but follows hs.4 precedent. Final iter state: 562/562 green (559 baseline + 3 new pins), zero re-loops across all 7 tasks. Bench impact none (no new code paths touched by bench corpus; bench fixtures use `int_to_str` / `bool_to_str` only). Closes fieldtest-form-a queue: bug (closed bugfix-instance-body-unbound-var), spec_gaps 2+3 (closed form-a.tidy), friction (closed here). Remaining open: spec_gap 1 dropped per recon (audit-form-a "plan two sites" claim did not match HEAD) → 2026-05-13-iter-str-concat.md +- 2026-05-13 — iter rustdoc-sweep: cleared all 23 `cargo doc --workspace --no-deps` warnings (17 in `ailang-check` + 4 in `ailang-core` + 2 in `ailang-surface`). Two warning classes: (a) `pub` doc-comments that linked-via-brackets to `pub(crate)`/private items (15 hits) — replaced ``[`fn`]`` with plain backtick-code-span ``` `fn` ``` so the identifier stays readable but rustdoc no longer tries to resolve the link; (b) unresolved/ambiguous links (8 hits) — `mono.rs` `[`Registry`]`/`[`Registry::entries`]` ×3 fully-qualified to `[`ailang_core::workspace::Registry…`]`; `loader.rs` `[`crate::parse`]` ×2 (ambiguous between fn + mod) disambiguated to `[`crate::parse()`]`; `lib.rs` `metas[i]` (parsed as a link) escaped to `metas\[i\]`. Tests 562 → 562, no behaviour change → 2026-05-13-iter-rustdoc-sweep.md +- 2026-05-13 — iter drift-test-narrowing: `crates/ailang-core/tests/design_schema_drift.rs` now scans §"Data model" only, not the whole DESIGN.md. New helper `data_model_section()` slices `DESIGN_MD` from `## Data model` to the next top-level `## ` header; all 7 anchor-presence tests switched from `DESIGN_MD.contains(anchor)` to `data_model_section().contains(anchor)`. Closes the audit-form-a-precursor `[high]` "anchors-elsewhere-pass-silently" failure mode (an anchor present only in §"Decision 11" or another discussion section was previously treated as documented). All 38 anchors verified pre-edit to live in §"Data model" so the tightening doesn't regress to red. New pin `data_model_section_is_bounded` (starts-with `## Data model`, no bleed past `\n## Pipeline`, > 1 KB) guards the extractor itself against silent regression to whole-document scanning. Tests 562 → 563 (+1) → 2026-05-13-iter-drift-test-narrowing.md