diff --git a/docs/plans/2026-05-21-schema-camelcase-fix.md b/docs/plans/2026-05-21-schema-camelcase-fix.md new file mode 100644 index 0000000..8a5747d --- /dev/null +++ b/docs/plans/2026-05-21-schema-camelcase-fix.md @@ -0,0 +1,460 @@ +# schema-camelcase-fix — Implementation Plan + +> **Parent spec:** `docs/specs/2026-05-21-schema-camelcase-fix.md` +> (commit `55ce6d0`, amended after plan-recon) +> +> **For agentic workers:** REQUIRED SUB-SKILL: use `skills/implement` +> to run this plan. Steps use `- [ ]` checkboxes for tracking. + +**Goal:** Rename the two camelCase JSON tags `paramTypes` / `retType` +on `Term::Lam` to the kebab-case `param-types` / `ret-type`, +migrate every checked-in code/contract reference in the same +iteration, and pin the new schema shape with a `design_schema_drift` +assertion. After this iteration the AILang canonical-JSON schema +has zero camelCase tags. Closes Gitea #30. + +**Architecture:** Single-iteration milestone. Four layers +touched: (1) `#[serde(rename)]` strings on `Term::Lam` in +`crates/ailang-core/src/ast.rs`; (2) one in-source test JSON-literal +in `crates/ailang-core/src/workspace.rs`; (3) two `.ail.json` fixtures ++ one canonical-schema contract document +(`design/contracts/data-model.md`); (4) three rustdoc strings that +describe present-state schema vocabulary. The Form-A surface +(`crates/ailang-surface/`) is structurally untouched — Form-A uses +`(params (typed ...)) (ret ...)`, not the renamed tags. No hash-pin +refresh is required (no `.ail` module currently pinned in +`hash_pin.rs` contains a `(lam ...)` term — verified per-file). + +**Tech Stack:** `serde_json` rename attributes, AILang `Term::Lam` +schema, `design_schema_drift.rs` anchor walker, `anchor_in_jsonc_block` +helper. + +--- + +## Files this plan creates or modifies + +- Modify: `crates/ailang-core/tests/design_schema_drift.rs` — add + one new `#[test] fn` (the schema-shape pin) and extend the + existing `anchor_in_jsonc_block` walk to require the new tag + spellings inside the `Term::Lam` fenced JSON block of + `data-model.md`. +- Modify: `crates/ailang-core/src/ast.rs:492,494` — two + `#[serde(rename)]` string-literal swaps. +- Modify: `crates/ailang-core/src/ast.rs:8` — module-level `//!` + rustdoc enumerates rename targets. +- Modify: `crates/ailang-core/src/workspace.rs:1925-1926` — in-source + JSON literal inside `#[test] fn ct1_validator_walks_lam_embedded_types` + (function at line 1911). +- Modify: `crates/ailang-surface/src/parse.rs:81` — module-level + `//!` rustdoc mentions `lam`'s `paramTypes` carry. +- Modify: `crates/ailang-check/src/lib.rs:1707` — `///` rustdoc on + the InstanceMethod routing helper. +- Modify: `design/contracts/data-model.md:142-147` — fenced JSON + block describing the `Term::Lam` schema. +- Modify: `examples/test_loop_binder_captured_by_lambda.ail.json` + — two key renames, one per line (currently lines 32-33; verify + by grep before editing). +- Modify: `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` + — two key renames, one per line (currently lines 25-26). + +**Forbidden touches** (per spec acceptance criterion 7): +- `docs/plans/2026-05-10-23.3-*.md`, `docs/plans/2026-05-09-22b3-monomorphisation.md`, + `docs/plans/2026-05-09-22c-user-class-e2e.md`, `docs/plans/24.2.md` +- `experiments/2026-05-12-cross-model-authoring/master/spec.md` +- `experiments/2026-05-12-cross-model-authoring/rendered/*.md` +- `experiments/2026-05-12-cross-model-authoring/runs/**` + +These embed the old tag names but are frozen historical artefacts. + +--- + +## Task 1: RED — pin the new schema shape + +**Files:** +- Modify: `crates/ailang-core/tests/design_schema_drift.rs` + +The pin lives in `design_schema_drift.rs` (not `schema_coverage.rs`) +because that file already constructs `Term::Lam` exemplars at +L121-129, already includes `data-model.md` via `include_str!` +(L22, constant name `DATA_MODEL`), and already calls the +`anchor_in_jsonc_block` helper (L34-47, called at L184) for fenced- +block walks. The new pin and the data-model anchor extension belong +in the same test file as the rest of the schema-drift family. + +- [ ] **Step 1: Read the existing pin family** + +Read `crates/ailang-core/tests/design_schema_drift.rs`. Note: +- the `Term::Lam` exemplar around L121-129 +- the `anchor_in_jsonc_block` helper at L34-47 +- the existing `design_md_anchors_every_term_variant` test (where + the new tag anchors will land) + +Step exists to ground the new test in the file's prevailing shape; +no edit. + +- [ ] **Step 2: Add the schema-shape pin** + +Append at end of `crates/ailang-core/tests/design_schema_drift.rs` +(or co-locate with the other `Term::Lam`-touching tests at L121-129 +— pick the location with the closest sibling test): + +```rust +/// Schema-shape pin: the JSON serialisation of `Term::Lam` must +/// emit kebab-case tags `"param-types"` and `"ret-type"`, and +/// must NOT emit the old camelCase `"paramTypes"` / `"retType"`. +/// Pinned because the closes-#30 milestone made the rename a +/// schema-stability invariant: a future regression that flips +/// the `#[serde(rename)]` string back to camelCase must fire RED +/// here, not slip through. +#[test] +fn lam_serialises_with_kebab_keys() { + use ailang_core::ast::*; + + let lam = Term::Lam { + params: vec!["x".into()], + param_tys: vec![Type::int()], + ret_ty: Box::new(Type::int()), + effects: vec![], + body: Box::new(Term::Var { name: "x".into() }), + }; + let v = serde_json::to_value(&lam).expect("Term::Lam serialises"); + let obj = v.as_object().expect("Term::Lam serialises as object"); + + assert!( + obj.contains_key("param-types"), + "Term::Lam must emit `param-types` key; got keys {:?}", + obj.keys().collect::>(), + ); + assert!( + obj.contains_key("ret-type"), + "Term::Lam must emit `ret-type` key; got keys {:?}", + obj.keys().collect::>(), + ); + assert!( + !obj.contains_key("paramTypes"), + "Term::Lam must NOT emit old camelCase `paramTypes` key", + ); + assert!( + !obj.contains_key("retType"), + "Term::Lam must NOT emit old camelCase `retType` key", + ); +} +``` + +- [ ] **Step 3: Extend the data-model.md anchor walk** + +Find the existing test that uses `anchor_in_jsonc_block` to assert +fenced-block content (recon names `design_md_anchors_every_term_variant` +at the test name level, and `anchor_in_jsonc_block` calls at L184). +Read the existing anchor list, identify where the `lam` block +anchors live, and add two new anchor assertions for the same `lam` +fenced JSON block: + +```rust +assert!( + anchor_in_jsonc_block(DATA_MODEL, "lam", "\"param-types\""), + "data-model.md `lam` fenced block must list `param-types` key", +); +assert!( + anchor_in_jsonc_block(DATA_MODEL, "lam", "\"ret-type\""), + "data-model.md `lam` fenced block must list `ret-type` key", +); +``` + +If the existing test asserts anchors via a `for (variant, anchor) in &[…]` table, add the two rows there instead of two +standalone `assert!`s — match the local shape. The implementer +reads the file to confirm the local shape before editing. + +- [ ] **Step 4: Run the new pin and verify it FAILS** + +Run: `cargo test -p ailang-core --test design_schema_drift lam_serialises_with_kebab_keys 2>&1 | tail -30` + +Expected: FAIL. The current `ast.rs:492,494` emit `paramTypes` / +`retType`, so `obj.contains_key("param-types")` is false. The +data-model anchor extension also FAILs because lines 142-147 still +carry camelCase. Both RED conditions confirm we are pinning a real +property the milestone is about to make true. + +If the test unexpectedly passes, STOP — the assumption "current +state emits camelCase" is wrong; re-read `ast.rs:490-499` before +proceeding. + +--- + +## Task 2: GREEN-1 — atomic schema-coupled swap + +**Files:** +- Modify: `crates/ailang-core/src/ast.rs:492` (line `#[serde(rename = "paramTypes")]`) +- Modify: `crates/ailang-core/src/ast.rs:494` (line `#[serde(rename = "retType")]`) +- Modify: `crates/ailang-core/src/workspace.rs:1925-1926` +- Modify: `examples/test_loop_binder_captured_by_lambda.ail.json` +- Modify: `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` +- Modify: `design/contracts/data-model.md:142-147` + +These files MUST move together. Changing only `ast.rs` makes any +JSON literal containing the old tags undeserialisable: serde +`Term::Lam` has no `#[serde(default)]` on `param_tys` / `ret_ty`, +so an absent (renamed-away) key is a hard deserialise error. The +workspace.rs test, the two `.ail.json` fixtures, and the +data-model.md fenced JSON block all participate in the same +serialiser/deserialiser contract — they migrate atomically, in a +single task, with the full `cargo test` gate at the end. + +`data-model.md` is included by `data-model.md` consumer +`design_schema_drift.rs` via `include_str!`; the constant is +`DATA_MODEL`. After Task 1's anchor extension, leaving the fenced +JSON block on camelCase would keep the test RED. + +- [ ] **Step 1: Edit `crates/ailang-core/src/ast.rs:492`** + +Locate line 492: + +```rust + #[serde(rename = "paramTypes")] +``` + +Replace with: + +```rust + #[serde(rename = "param-types")] +``` + +- [ ] **Step 2: Edit `crates/ailang-core/src/ast.rs:494`** + +Locate line 494: + +```rust + #[serde(rename = "retType")] +``` + +Replace with: + +```rust + #[serde(rename = "ret-type")] +``` + +- [ ] **Step 3: Edit `crates/ailang-core/src/workspace.rs:1925-1926`** + +The two lines live inside the test function +`ct1_validator_walks_lam_embedded_types` (function attribute `#[test]` +at L1911). Find the literal lines: + +```rust + "paramTypes": [{ "k": "con", "name": "Ordering" }], + "retType": { "k": "con", "name": "Unit" }, +``` + +Replace with (preserve exact indentation and surrounding context): + +```rust + "param-types": [{ "k": "con", "name": "Ordering" }], + "ret-type": { "k": "con", "name": "Unit" }, +``` + +- [ ] **Step 4: Edit `examples/test_loop_binder_captured_by_lambda.ail.json`** + +Grep first to confirm exact occurrences: + +Run: `grep -n '"paramTypes"\|"retType"' examples/test_loop_binder_captured_by_lambda.ail.json` + +Expected: 2 hits, one with `"paramTypes"`, one with `"retType"`, +on consecutive lines (currently 32-33; verify). + +For each line, replace `"paramTypes"` → `"param-types"` and +`"retType"` → `"ret-type"`. No other content changes. + +- [ ] **Step 5: Edit `experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json`** + +Grep first: + +Run: `grep -n '"paramTypes"\|"retType"' experiments/2026-05-12-cross-model-authoring/master/examples/fn_with_lambda.ail.json` + +Expected: 2 hits on consecutive lines (currently 25-26; verify). + +Same rename as Step 4. + +- [ ] **Step 6: Edit `design/contracts/data-model.md:142-147`** + +The fenced JSON block currently reads (lines 142-147): + +``` +{ "t": "lam", + "params": [""...], + "paramTypes": [Type...], + "retType": Type, + "effects": [""...], + "body": Term } +``` + +Replace lines 144-145 with: + +``` + "param-types": [Type...], + "ret-type": Type, +``` + +The surrounding `{ "t": "lam", ...` opener, `"params"` line, +`"effects"` line, and `"body"` line are untouched. Verify the +fenced-block boundaries (` ``` ` markers) above and below remain +intact. + +- [ ] **Step 7: Build the workspace to catch any deserialisation regression** + +Run: `cargo build --workspace 2>&1 | tail -20` + +Expected: clean build, no new warnings. + +- [ ] **Step 8: Run the schema-shape pin (now expected GREEN)** + +Run: `cargo test -p ailang-core --test design_schema_drift lam_serialises_with_kebab_keys 2>&1 | tail -10` + +Expected: PASS (Task 1's assertion now holds). + +- [ ] **Step 9: Run the extended data-model anchor walk** + +Run: `cargo test -p ailang-core --test design_schema_drift design_md_anchors_every_term_variant 2>&1 | tail -10` + +Expected: PASS (the data-model.md fenced JSON block now carries +the new tag anchors). + +- [ ] **Step 10: Run the workspace ct1 test** + +Run: `cargo test -p ailang-core ct1_validator_walks_lam_embedded_types 2>&1 | tail -10` + +Expected: PASS. This is the in-source test that owns the JSON +literal edited in Step 3; if Step 3 missed a key, this test fails +with a `serde_json::from_value` error mentioning the missing field. + +--- + +## Task 3: GREEN-2 — rustdoc honesty pass + +**Files:** +- Modify: `crates/ailang-core/src/ast.rs:8` +- Modify: `crates/ailang-surface/src/parse.rs:81` +- Modify: `crates/ailang-check/src/lib.rs:1707` + +These are pure prose edits — no compile or test consequence. They +exist solely to keep the rustdoc honest after the rename. Per the +Honesty Rule (`design/contracts/honesty-rule.md`), production +rustdoc describes present-state vocabulary, not history. + +- [ ] **Step 1: Edit `crates/ailang-core/src/ast.rs:8`** + +Locate line 8 in the module-level `//!` block. Current text: + +```rust +//! `fn`, `paramTypes`, `retType`), enum tags (`kind`, `t`, `k`, `p`), +``` + +Replace with: + +```rust +//! `fn`, `param-types`, `ret-type`), enum tags (`kind`, `t`, `k`, `p`), +``` + +The surrounding `//!` lines are untouched. + +- [ ] **Step 2: Edit `crates/ailang-surface/src/parse.rs:81`** + +Locate line 81 in the module-level `//!` block. Current text: + +```rust +//! - The `lam` form carries `paramTypes`, a `ret` type, and an +``` + +Replace with: + +```rust +//! - The `lam` form carries `param-types`, a `ret` type, and an +``` + +If the line wraps differently in the local file (the carrier word +`paramTypes` may sit on its own line or break across lines after +the comma), edit the contiguous `paramTypes` occurrence; do NOT +soft-wrap split the new `param-types` across two lines. + +- [ ] **Step 3: Edit `crates/ailang-check/src/lib.rs:1707`** + +Locate line 1707. Current text: + +```rust +/// convention a `Term::Lam` carrying its own `paramTypes` / `retType` +``` + +Replace with: + +```rust +/// convention a `Term::Lam` carrying its own `param-types` / `ret-type` +``` + +- [ ] **Step 4: Run a doc-build to catch broken intra-doc links** + +Run: `cargo doc --workspace --no-deps 2>&1 | grep -E "warning|error" | head -20` + +Expected: no NEW warnings (the workspace already carries a +known-stable warning count; flag any new diagnostic that mentions +`paramTypes` / `retType` / `param-types` / `ret-type`). + +--- + +## Task 4: Final verification + +**Files:** none modified — verification only. + +- [ ] **Step 1: Exhaustive grep — nothing left on the old tags** + +Run: `grep -rn '"paramTypes"\|"retType"\|paramTypes\|retType' crates/ examples/ design/ runtime/ --include='*.rs' --include='*.json' --include='*.md' 2>/dev/null` + +Expected output (and only this output): +- `docs/plans/*.md` matches in the historic plan files listed under "Forbidden touches" above. +- `experiments/2026-05-12-cross-model-authoring/master/spec.md` matches. +- `experiments/2026-05-12-cross-model-authoring/rendered/*.md` matches. +- `experiments/2026-05-12-cross-model-authoring/runs/**` matches. +- ZERO matches under `crates/`, `examples/`, `runtime/`, or `design/`. + +Any match outside the expected set is a missed touch-point; resolve +it by adding the rename, then re-run the full test suite (Step 2). + +- [ ] **Step 2: Run the workspace test suite** + +Run: `cargo test --workspace 2>&1 | tail -25` + +Expected: every crate's test summary shows `0 failed`. The total +test-group count must equal the pre-iter baseline of 90 +(unchanged — Task 1 adds one new test inside an existing test crate, +which does not change the test-group count). + +- [ ] **Step 3: Round-trip invariance check** + +Run: `cargo test -p ailang-surface --test round_trip 2>&1 | tail -10` + +Expected: PASS. This is the Form-A-unchanged gate; if it fails +the spec's Layer-2 claim ("Form-A never used these tag names") was +wrong and the iteration scope was mis-sized. + +- [ ] **Step 4: Confirm no forbidden files were touched** + +Run: `git diff --name-only HEAD 2>&1 | grep -E "docs/plans/|experiments/.*/(master/spec\.md|rendered/|runs/)" | head -20` + +Expected: empty output. If any line appears, the implementer +mutated a forbidden file — `git checkout -- ` the change. + +- [ ] **Step 5: Sanity check the new pin would actually go RED** + +Mental check (no `git revert` — main is sacrosanct): the pin in +Task 1 was confirmed RED in Task 1 Step 4 before the swap. Step 8 +of Task 2 confirmed it GREEN after the swap. Therefore the pin is +a real RED/GREEN bisector. No additional action — this step exists +to mark the property as audited. + +- [ ] **Step 6: Status summary for Boss commit** + +Print a one-line summary of the resulting `git status`: + +Run: `git status --short` + +Expected: nine modified files (the eight listed in "Files this +plan creates or modifies" plus the new test slot in +`design_schema_drift.rs`), no untracked files of interest. The +Boss will inspect the diff and assemble the iter-level commit.