diff --git a/.changeset/happy-deer-roar.md b/.changeset/happy-deer-roar.md new file mode 100644 index 000000000..acc3e9d87 --- /dev/null +++ b/.changeset/happy-deer-roar.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 3880 +--- +**The STATE.md field reference is generated from one schema, and the status lifecycle now appears in every language** — the key set, its types, enums and cardinality are declared once and projected into the field-classification tables, the shipped template and all five reference documents, so a field can no longer be described one way in code and another in the docs. The `Status lifecycle` section, which documents the status values, was missing from the Japanese, Chinese, Korean and Portuguese references and is now present in all of them. (#3873) diff --git a/.gitignore b/.gitignore index e114acebb..dceaf5ada 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,8 @@ build/ /gsd-core/bin/lib/external-job.cjs /gsd-core/bin/lib/runtime-artifact-install-plan.cjs /gsd-core/bin/lib/state-transition.cjs +# #3873: schema leaf module (ADR-3473 §8.8) — emitted artifact, never edited. +/gsd-core/bin/lib/state-md-schema.cjs /gsd-core/bin/lib/resolution.cjs /gsd-core/bin/lib/research-store.cjs /gsd-core/bin/lib/research-provider.cjs diff --git a/CONTEXT.md b/CONTEXT.md index 9c425605d..513f5e582 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -67,6 +67,9 @@ Module owning STATE.md parse, field extraction, field replacement, status normal ### STATE.md Transition Module Module owning STATE.md lifecycle/maintenance transitions as intent-based methods (`beginPhase`, `advancePlan`, `completePhase`, `plannedPhase`, `milestoneSwitch`, `milestoneComplete`, `patch`, `sync`, `prune`, `update`, `rebuild`). Pure core `(content, intent, deps) → newContent` with injected I/O (file read/write, lock, disk scan); consults a field-classification table that names each STATE.md field's class (`derived-from-body` | `derived-from-disk` | `derived-from-external` | `curated` | `free`) and its preservation policy. Supersedes the 14 scattered RMW callbacks in `state.cts` (phase.cts's former direct caller has since been migrated away). `syncAndPreserveStateMd` (`state.cts`, #3469) is the single composition of `syncStateFrontmatter` + `applyPostSyncPreservation`; `readModifyWriteStateMd` and `cmdPhaseComplete` both **call** it rather than assembling the two steps, because assembling them at a call site is a re-derivation even when every step calls an owner. **Exactly two direct `writeStateMd` callers remain, and both are SANCTIONED PERMANENT exceptions under ADR-3408 §8.3 (as amended): `cmdStateSync` and the `REGENERATE_STATE` remedy (`health-diagnostic.cts`).** Neither is debt — `state sync` exists to re-derive frontmatter *from* the body (#905), and `REGENERATE_STATE` is a factory reset; preservation on either would re-lock precisely what the command was invoked to replace. `cmdMilestoneComplete` was the third and is now routed through the composition (#3469). Phase 1's whole-repo drift guard, not this line, is the authoritative count. *(Location correction: this entry previously placed the factory-reset primitive at `verify.cts:1925`. It moved to `health-diagnostic.cts` when `cmdValidateHealth` migrated onto the rule table (#3309); `verify.cts` now contains no `writeStateMd` call. The design intent was unchanged — only the address was stale.)* Absorbs `syncStateFrontmatter` + `readModifyWriteStateMd`'s post-sync preservation block; Encoding 3 (`cmdStateBuildFrontmatter`) stays separate — read path concern. Sibling/super-module of the STATE.md Document Module; consumes its `stateReplaceField`/`stateExtractField` primitives. Body section structure (`## Current Position`, `## Session`, etc.) lives as a constants block inside the Module. Append-only transitions (`addDecision`, `addBlocker`, etc.) stay on today's RMW seam for now. Targets the #1760/#1761/#1743/#1695/#1264/#1255/#1257/#3242 bug cluster. Migration per ADR-1372 §T6 sequenced as substrate + `beginPhase` first (PR1), then transition-by-transition with characterization tests first per transition. **ADR-1817 adds `rebuild` as the capstone 11th transition — the body-structure derivability contract.** Re-derives `## Current Position` prose from frontmatter and `## By-Phase Progress` table from phase dirs on disk; preserves `## Session` / `## Decisions` / unknown sections verbatim; de-duplicates `## Session Continuity Archive` (keep most-recent N, default 3); appends a structured audit entry to `## Rebuild Log` (`timestamp`, `kind`, `section`, `before`, `after`, `reason`) for every mutation. Hard idempotency guarantee: a no-mutation rebuild appends no log entry, so two successive invocations on a clean file are byte-identical. Non-overlapping with `sync` (3 lightweight frontmatter fields, auto-triggered) and orthogonal to `auto_prune_state` (age-based removal) — `rebuild` reconciles with current canonical sources, `prune` removes by retention policy, the two compose (rebuild first, then prune). Section ordering is invariant: rebuild rewrites content in place, never reorders. Targets the #1776/#1761/#1591 body-drift cluster that survived ADR-1769's per-field transitions. Phased per ADR-1817: Phase 0 = this ADR + predicates (closes #1817), Phase 1 = `rebuildCore` body + `rebuild` dispatch case + drift-class unit tests (#1827), Phase 2 = `cmdStateRebuild` CLI + `--dry-run`/`--verbose` + integration tests + docs + changeset (#1826). Source of truth: `gsd-core/bin/lib/state-transition.cjs` (generated from `src/state-transition.cts`). `state_head` (#2573) is classified `{ source: 'free', preservation: 'derive' }` — an ambient git read recomputed on every write, like `last_updated`; never preserved, because a stale stamp would claim STATE.md was written against a commit it wasn't. **Phase 4 (#3471):** `syncStateFrontmatter`'s six empty-only guards (D1) are now GATED — active only for the §8.3 sanctioned-permanent exceptions (`cmdStateSync`, `REGENERATE_STATE`), which have no preservation executor downstream and for which body-beats-frontmatter is the deliberate contract; OFF on the write seam, where `applyStatePreservation` owns the empty case via the exported `applyPreserveWhenUnchanged` executor. `reconcileReportedFields` (`state.cts`, §8.4/D4) is the single owner of reconciling a command's reported `updated`/`failed` field array against what was actually persisted, closing both #3351's (reported-but-discarded) and #3345's (persisted-but-unreported) directions; used by seven commands rather than seven re-derivations. `cmdStateJson` (§8.5/D3) no longer carries a private copy of the empty-only guards; it routes through `applyPreserveWhenUnchanged` directly, scoped to the same six fields. This entry has now needed correcting three times inside this one epic; Phase 1's whole-repo drift guard, not this line, remains the authoritative count. **ADR-3473 §8.7 (#3872) — the transaction diff.** `reconcileReportedFields` no longer compares the transform's own output against persisted bytes, nor filters `divergedFields` by policy class. It compares **persisted against the pre-write state the transaction already holds**, surfaced to the command through the same caller-allocates out-param idiom `divergedFields` established (and which `readModifyWriteStateMd`'s hand-enumerated option forwarding must list, or the field is silently dropped). Both prior directions fall out of that one comparison: a field the transform reported but the pipeline discarded is persisted-equals-snapshot and drops out (#3351), and a field nobody reported but the write moved is different and appears (#3345's `total_phases` 7→4, #3818's `current_phase` 203→204). The classification filter is **deleted, not relocated** — no `getFieldClassification` test survives in the reporting path. Reporting is at **dotted-leaf** granularity, enumerated from the `progress.*` rows `FIELD_CLASSIFICATION` already declares rather than by walking user data to arbitrary depth; that closes a live drop, because `plannedPhaseCore` already pushed `progress.total_plans` and a flat `hasOwnProperty` could never resolve it against nested frontmatter (`Current Position` was lost the same way). The one exclusion is `last_updated`, and it is by **provenance, not classification**: it is the only field measured to change on every write regardless of content. `state_head` was measured NOT to qualify — recomputed every write, but its value moves only when git HEAD moved. Without that single exclusion, `state.patch`'s success signal (`updated.length > 0`, `state.cts`) would be permanently true and a fully-failed patch would report success. **ADR-3473 §8.6 (#3871) — the state transaction.** `StateTransaction` is the Module's pre-write policy value, constructed only by `openStateTransaction` (preservation applies) or `rebuildStateTransaction` (it does not); both carry a MANDATORY `snapshot`, and an absent one is a **construction failure** (`STATE_TRANSACTION_SNAPSHOT_REQUIRED`), never a runtime no-op. This replaces the `preFm`/`preFmSnapshot` pair, which were the same `extractFrontmatter` call with one copy nulled on `resync` — a policy flag encoded as a missing input, which is why a declared `preserve-always` row silently skipped on the default write path (#3756). An EMPTY snapshot (`{}`) stays legal: that is the honest snapshot of a document with no parseable frontmatter, and `/gsd-health --repair` runs precisely on such documents. `rebuildStateTransaction` is the typed expression of ADR-3408 §8.3's closed exception list — `cmdStateSync` (#905) and `REGENERATE_STATE` — so `writeStateMd` takes a `rebuild` transaction and rejects anything else, and the two `sanctioned-permanent` entries the write-path drift guard used to ratchet as strings are retired with their baseline file. Within `applyPreserveAlways`, an all-zero or absent set of derived `progress` TOTALS is an **unmeasured scan, not a measurement** (the convention #3233 established, and why `computeProgressPercent` already returns `null` on an empty denominator), so the curated block stands rather than being overwritten with zeros; `completed_*` being zero is normal and does not decide it, and a measured scan still corrects totals downward (#1446/#2440). The rule carries a second, non-negotiable condition: it does **not** fire when the caller NAMED a progress field, because `preserve-always` has always meant "never overwrite unless the caller explicitly names this field" and `state update Progress` exists precisely to re-derive the block from the body just rewritten. `explicitProgressField` carries that and is **derived, never hand-set** — it comes from `shouldResyncStateProgress(fields)` (true iff the field set contains `Progress`, `Total Plans in Phase` or `Total Phases`), so it cannot drift from what the caller actually asked for. Omitting it silently discarded an explicitly-requested resync (#3242 / #1972), caught by the remote matrix, not by review. `getPreserveWhenUnchangedFields()` projects the `preserve-when-unchanged` rows out of `FIELD_CLASSIFICATION` so `cmdStateJson` consults the declaration instead of the hand-maintained parallel list that had drifted from it (#3836). +### STATE.md Field Schema Module +The one declaration (ADR-3473 §8.8, #3873) for "which STATE.md keys exist and what they carry", replacing three hand-maintained tables that were already observed to disagree: `FIELD_CLASSIFICATION` and `FRONTMATTER_BODY_SOURCE` (STATE.md Transition Module) and `FRONTMATTER_KEY_TO_BODY_LABEL` (STATE.md Document Module's `bodyLabelFor`, `state.cts`). One frozen, null-prototype `STATE_FIELD_SCHEMA` row per key carries `type`, `cardinality`, `source`, `preservation`, `guard`/`mergeStrategy` (ADR-3408's closed vocabularies, whose type declarations moved here), `bodySource`/`bodyLabel`, `acceptedShapes` (declared value shapes a hand-written parser accepts — e.g. `current_plan`'s `N` / `N of M`, #3784 — never a predicate; Greenspun's Tenth Rule still applies), and `emitted` (mirrors `buildStateFrontmatter`'s null-guards). The three original tables are now PROJECTIONS derived from this schema at module load, byte-identical in shape/key-order/frozen-and-null-prototype-ness to what they were before #3873 — every existing consumer (the preservation dispatch loop, `getFieldClassification`, `getPreserveWhenUnchangedFields`, `bodyLabelFor`, #3872's `declaredLeavesOf`) is unaffected. **The `last_activity` disagreement is resolved by declaration, not by picking the table that "looks right":** it carries a `bodySource` (it IS body-derived) but deliberately no `bodyLabel`, matching what ships today — its `preservation` is `derive`, so it can never reach `bodyLabelFor`'s `STATE_BODY_LABEL_UNWIRED_ROW` throw, pinned by `tests/state.test.cjs`'s `lastActivityLabelResolutionMatchesShippedBehavior`. Leaf module: imports from neither `state-transition.cts` nor `state.cts` (both import it), avoiding the CJS require-cycle `src/health-diagnostic-types.cts` was split out to break. `scripts/lint-state-field-drift.cjs` is UNCHANGED and retained — it guards the #3187 STATE.md field-extraction fallback-chain re-derivation, an orthogonal concern this schema does not make unrepresentable (§8.8's own claim that the guard is deleted here was verified false). Source of truth: `gsd-core/bin/lib/state-md-schema.cjs` (generated from `src/state-md-schema.cts`). + ### STATE.md Status Lifecycle (ADR-2207) The `Status` field in STATE.md follows a strict lifecycle: `Ready to plan` → `All phases complete` (all phases done, milestone awaiting formal close) → ` milestone complete` (terminal, written only by the milestone-close verb `milestoneCompleteCore`) → `Awaiting next milestone` (archived). Phase-completion verbs write `All phases complete` on the last phase — never `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). `normalizeStateStatus` maps any status containing "complete" → `completed`, so consumers using the normalized projection (workstream inventory's `status` field, statusline) recognize `All phases complete` without code changes. Note: `isCompletedInventory` (workstream-inventory-builder.cts) intentionally checks only for the terminal `\bmilestone\s+complete\b` / `\barchived\b` — `All phases complete` returns `false` (intermediate, not terminal). diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index c3b9c0bc0..ab58ea51c 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -508,6 +508,7 @@ "state-contract.cjs", "state-document.cjs", "state-io.cjs", + "state-md-schema.cjs", "state-transition.cjs", "state.cjs", "surface.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 515ff99c3..3dcfe4fda 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -639,6 +639,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `health-diagnostic-rules/state-consistency.cjs` | Health-diagnostic rules: STATE.md consistency checks (W002, W011, W021, W026) against config/ROADMAP/disk, ported behavior-preserving from `cmdValidateHealth`; W024 (state_head freshness) is a documented gap, deliberately not migrated (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `state-io.cjs` | Abstracts state IO modes — filesystem, sandboxed storage, or session log (#1680) | | `state-transition.cjs` | Implements the STATE.md field-classification table and the pure `transitionCore` mutation path (#1769) | +| `state-md-schema.cjs` | The STATE.md field schema (compiled from `src/state-md-schema.cts`, gitignored; ADR-3473 §8.8, #3873) — one frozen, null-prototype `STATE_FIELD_SCHEMA` row per STATE.md key (type, cardinality, source, preservation, guard, mergeStrategy, body source/label, accepted value shapes, emitted-vs-guarded), replacing three previously hand-maintained tables (`FIELD_CLASSIFICATION` / `FRONTMATTER_BODY_SOURCE` in `state-transition.cjs`, `FRONTMATTER_KEY_TO_BODY_LABEL` in `state.cjs`) that now project from it at load time with byte-identical shape/order. Leaf module: imports from neither of its two consumers, avoiding the CJS require-cycle `health-diagnostic-types.cjs` was split out to break | | `state.cjs` | STATE.md parsing, updating, progression, metrics | | `state-document.cjs` | Pure STATE.md field extraction, replacement, status normalization, and progress calculation transforms | | `milestone-lock.cjs` | Milestone lock (compiled from `src/milestone-lock.cts`, gitignored) — advisory (phase, session id) claim over STATE.md's single Current Position slot: `.planning/milestone.lock` claim IO, liveness (TTL + heartbeat), conflict detection, and the shared stderr warning; consumed by `state.begin-phase` / `state.advance-plan` / `phase.complete` so parallel phases in one working tree get a visible conflict instead of silently overwriting each other (#3311) | diff --git a/docs/README.md b/docs/README.md index 380eafe30..759750ff1 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,6 +25,7 @@ Language versions: [English](README.md) · [Português (pt-BR)](pt-BR/README.md) - [Probe edges in a non-English project](how-to/probe-edges-in-a-non-english-project.md) — get real edge coverage on a spec written in another language, and tell "no edges here" apart from "the probe could not read it" - [Resolve prohibition findings](how-to/resolve-prohibition-findings.md) — turn the spec phase's surfaced must-NOT constraints into resolved, dismissed, or deferred spec decisions - [Resolve an unreachable-workflow finding](how-to/resolve-unreachable-workflow-findings.md) — wire or fully sweep a shipped workflow that no command, agent, or skill references +- [Change the STATE.md schema](how-to/change-the-state-md-schema.md) — add, change or remove a STATE.md frontmatter key and keep the template and all five reference documents in step - [Resolve verify-command path findings](how-to/resolve-verify-command-path-findings.md) — fix an `` verify command whose target directory does not resolve from the executor's cwd - [State a failing direction](how-to/state-a-failing-direction.md) — say what output constitutes failure for an `` verify command, and migrate a phase planned before the rule - [Resolve a contract-drift finding](how-to/resolve-contract-drift-findings.md) — bring an agent's completion contract, read-tag gate, or deleted-file test reference back into agreement with the registry diff --git a/docs/adr/3473-enforcement-by-construction.md b/docs/adr/3473-enforcement-by-construction.md index 33cb1bf49..46854697d 100644 --- a/docs/adr/3473-enforcement-by-construction.md +++ b/docs/adr/3473-enforcement-by-construction.md @@ -240,7 +240,13 @@ Both close #3349 and #3360, which are **read-side** defects a real parser fixes **Rule — parsers are checked, not generated.** Parse functions stay hand-written. A test asserts they accept **exactly** the shapes the schema declares — no more, no fewer. Generating a parser is a substrate decision this epic does not take; the shape-proliferation family (#3784's three spellings of "plan N of M") is closed by declaring the accepted set, not by emitting the matcher. -**Consequence for the guard.** `scripts/lint-state-field-drift.cjs` (805 lines) is **deleted** in the same PR. Field drift between the table, the template and the docs stops being detectable because it stops being representable. +**Consequence for the guard.** Field drift between the schema, the template and the docs stops being *representable*, because the artifacts are generated from the schema and a drift check refuses a stale one. + +> **Amendment, 2026-08-26 (Phase 3, #3873) — this paragraph originally instructed deleting `scripts/lint-state-field-drift.cjs` (805 lines) on the grounds that it detected that drift. Verified against `next`: it does not, and never did.** That script's own header declares it the drift guard for the **STATE.md field-extraction fallback chain** — epic #3180, issue #3187, ADR-3180 Decision 4 §7.7. It detects re-derivations of the *"prefer the frontmatter scalar, else fall back to the body field"* coercion ladder across `src/**` and the prompt layer. It contains no reference to `FIELD_CLASSIFICATION`, to the template, or to the reference docs. Nor does any **other** script own field/template/docs drift — the full `scripts/` inventory carries none. The instruction named a guard surface that did not exist. +> +> **The guard is therefore retained**, and `tests/lint-state-field-drift-retained.test.cjs` pins that decision so a future reader does not delete it on this ADR's earlier word. A key-set schema makes a *key-set disagreement* unrepresentable; it does nothing about a *code-shape* re-derivation of a coercion ladder. Those are orthogonal, and Decision 6 sanctions retiring a guard the change makes **redundant** — which this one is not. +> +> **This is the second guard-retirement claim in this ADR to rest on a wrong premise**, after §8.6's (see its own amendment). Both described a guard surface their author believed existed. The pattern is worth naming: a retirement claim in this ADR is a *hypothesis about a guard's contents*, and Decision 6's ledger requirement should be read as obliging the implementing phase to verify that hypothesis before acting on it — not merely to count the result. #### 8.9 Each subsumed child is driven fail-first — *Required — every phase* @@ -261,8 +267,9 @@ Both close #3349 and #3360, which are **read-side** defects a real parser fixes | Phase | Guard | Δ | |---|---|---| -| 1 (§8.6) | `lint-state-write-path-drift.cjs` — two `sanctioned-permanent` baseline entries retired; raw-write check retained | shrinks | -| 3 (§8.8) | `lint-state-field-drift.cjs` — **deleted** | **−805 lines, −1 guard** | +| 1 (§8.6) | `lint-state-write-path-drift.cjs` — seam-bypass `writeStateMd(` arm and its whole ratchet apparatus retired, baseline file deleted; composition-bypass arm retained and made terminal; raw-write check **added net-new** | **−665 lines, −1 file, +1 check** — net shrink | +| 2 (§8.7) | none — the reporting diff makes no guard redundant | **0**, stated rather than omitted | +| 3 (§8.8) | `lint-state-field-drift.cjs` — **retained**, see §8.8's amendment; a generated-artifact drift check and a locale-parity check are **added** | **+2 checks, 0 retired — this phase GROWS** | | — | `lint-planning-snapshot-bypass-drift.cjs` — extended to the write side by ADR-3180 Amendment 8 | **growth, declared** | Net across the set: one guard retired, one increase recorded honestly. The increase belongs to a concurrent lane under a different ADR and is listed here so the accounting is complete rather than flattering. @@ -297,7 +304,7 @@ Net across the set: one guard retired, one increase recorded honestly. The incre | Guard | Status under this ADR | |---|---| | `scripts/lint-state-write-path-drift.cjs` | retained, shrunk (§8.6) — seam-bypass `writeStateMd(` arm and its ratchet retired at Phase 1; composition-bypass arm retained and made terminal; raw-write check added net-new. See §8.6's amendment. | -| `scripts/lint-state-field-drift.cjs` | **retired at Phase 3** (§8.8) | +| `scripts/lint-state-field-drift.cjs` | **RETAINED** — the Phase-3 retirement instruction rested on a wrong premise about what this guard does; see §8.8's amendment. It guards the ADR-3180 §7.7 / #3187 coercion ladder, which no schema makes unrepresentable. | | `scripts/lint-vendored-deps.cjs` | reused as-is for §8.1's vendoring rule | | `local/no-external-require-in-bin` | reused as-is; enforces §8.1's packaging rule | | `local/no-adhoc-markdown-parsing` | widened past `src/**/*.cts` per Decision 5 (coverage fix, tracked on #3426/#3239) | diff --git a/docs/how-to/change-the-state-md-schema.md b/docs/how-to/change-the-state-md-schema.md new file mode 100644 index 000000000..9dfa110db --- /dev/null +++ b/docs/how-to/change-the-state-md-schema.md @@ -0,0 +1,129 @@ +# How to change the STATE.md schema + +Every key in `.planning/STATE.md`'s frontmatter is declared once, in +`src/state-md-schema.cts`. The field-classification tables, the shipped template and the five +reference documents are all derived from it. This page is how you add, change or remove a key +without any of those falling out of step. + +If you only want to know what the keys *are*, read +[the STATE.md reference](../reference/state-md.md) instead. + +## Add a key + +**1. Declare it in `src/state-md-schema.cts`.** + +```ts +my_new_key: { + type: 'string', + cardinality: 'optional', + source: 'body', + preservation: 'preserve-when-unchanged', + bodySource: ['My New Key'], + bodyLabel: 'My New Key', + emitted: 'when-present', +}, +``` + +Every column is required except `enum`, `guard`, `mergeStrategy`, `bodySource`, `bodyLabel` and +`acceptedShapes`. What each means is documented on the type itself — read it there rather than +copying a neighbouring row and hoping. + +**2. Build, then regenerate. In that order.** + +```bash +npm run build:lib +npm run regen:derived +``` + +**The order matters and getting it wrong fails quietly.** The generator reads the *compiled* +`gsd-core/bin/lib/state-md-schema.cjs`, not the TypeScript source. Regenerating before building +regenerates against the previous schema, produces artifacts that look plausible, and commits a +document that disagrees with the code you just wrote. If you are ever unsure whether the build is +current, run `npm run build:lib` again — it is cheap and idempotent. + +**3. Commit the regenerated artifacts.** They are generated *and committed*: + +- `gsd-core/templates/state.md` +- `docs/reference/state-md.md` and its `ja-JP`, `zh-CN`, `ko-KR`, `pt-BR` siblings + +**4. Add the human-facing rows by hand.** Two tables in the reference documents are deliberately +**not** generated — see [What is generated and what is not](#what-is-generated-and-what-is-not). +Add your key's row to the **Field reference** table in each locale. The parity check will tell you +if you miss one. + +## Change or remove a key + +Same two commands. Removing a key also means removing its row from the Field-reference tables in +all five locales, or the parity check fails naming each one. + +Before you change a key's `preservation` or `source`, read +[ADR-3408](../adr/3408-state-write-path-preservation.md) §8 — those columns drive what survives a +STATE.md write, and a change there is a behavior change, not a documentation edit. + +## What the check is telling you + +`npm run lint:generated-sync` runs `node scripts/gen-state-md-docs.cjs --check`, and `lint:ci` runs +it for you. It exits non-zero with a reason code and the file and region involved. + +| Reason | What happened | What to do | +|---|---|---| +| `region_stale` | A generated region does not match what the schema would produce. | `npm run build:lib && npm run regen:derived`, then commit the result. | +| `markers_missing` | A target file has no `STATE-MD-SCHEMA` marker pair for a region. | Add the marker pair where the region belongs. The generator never invents a location. | +| `marker_unclosed` | A `:START:` marker has no matching `:END:`. | Fix the markers. The generator refuses to write rather than guess where the region ends — a wrong guess would eat hand-written prose. | +| `field_reference_drift` | The Field-reference table's row set disagrees with the schema. | Add the missing row, or remove the row for a key that no longer exists. | +| `status_values_drift` | The Status-values table disagrees with the schema's `status` enum. | Same. | + +`--json` gives you the same information structurally if you are scripting against it. + +## What is generated and what is not + +| Region | Generated? | +|---|---| +| The template's frontmatter block | yes | +| `### Status lifecycle` | yes, in all five locales | +| `### Field cardinality` | yes, in all five locales | +| **Field reference** table | **no** — row set parity-checked only | +| **Status values** table | **no** — row set parity-checked only | +| All prose outside a marked region | **no**, ever | + +The last three are the point. The Field-reference and Status-values tables carry per-row prose — +`Purpose`, `When populated`, `Matched text` — that is genuinely hand-translated. The Japanese +Matched-text column reads `` `discussing` を含む ``, not the English. Generating those tables from +a single English source would overwrite four languages' translations every time anyone regenerated. +So the schema owns the **row set**, which is what drift actually means, and translators own the +prose. + +If you edit inside a marked region, the next `--write` will overwrite you and `--check` will report +it first. If you edit *outside* one, nothing touches it. + +## Adding a language + +Copy an existing locale's `reference/state-md.md`, translate the prose, and keep the +`STATE-MD-SCHEMA` marker pairs where they are. Then run the two commands above; the generator fills +every marked region for the new locale, and the parity check starts holding it to the same row set +as the rest. + +Column headers come from a per-locale string table in the generator — add yours there so the +generated tables are not headed in English. + +## Keys the schema does not model + +`active_phase`, `next_action` and `next_phases` are real frontmatter keys ([#2833](https://github.com/open-gsd/gsd-core/issues/2833)) +that are documented but sit outside the schema. They are grandfathered **by exact name** in +`KNOWN_SCHEMA_GAP_FIELDS`, so the parity check tolerates those three and no others — a fourth +undocumented key fails, which is what stops the list quietly becoming a wildcard. + +If you are adding one of those three to the schema properly, remove its name from that list in the +same change. + +## Why the schema exists + +Before it, this key set was declared in four places that had to agree by hand, and they did not: +one table carried `last_activity`, another did not. The five reference documents disagreed too — +the section documenting the `status` values was missing from all four translations, and it is the +section that matters for [#3853](https://github.com/open-gsd/gsd-core/issues/3853). + +[ADR-3473](../adr/3473-enforcement-by-construction.md) §8.8 is the contract, and its governing idea +is worth keeping in mind when you edit the schema: **the schema declares what the code does, not +what it should do.** If you find yourself writing a row that describes intended behavior, you are +writing a document that lies — declare today's behavior and fix the code separately. diff --git a/docs/ja-JP/reference/state-md.md b/docs/ja-JP/reference/state-md.md index 883c72e55..5da41d08d 100644 --- a/docs/ja-JP/reference/state-md.md +++ b/docs/ja-JP/reference/state-md.md @@ -74,9 +74,35 @@ paused_at: null | `last_updated` | ISO-8601 タイムスタンプ | 書き込み時に常時 | 最後の `syncStateFrontmatter` 呼び出しのタイムスタンプ。`realClock.nowIso()` によって書き込まれる。 | | `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, or when the resolved repo is not the project's own — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. | | `last_activity` | string | 本文に設定されている場合 | 本文の `Last Activity:` フィールドから抽出した最終活動日。 | +| `last_activity_desc` | string | 本文に設定されている場合 | 本文の `Last Activity Description:` フィールドから抽出した最終活動の説明。 | | `stopped_at` | string | 停止ポイントが記録された場合 | 最後に完了したアクションの説明。アーカイブの文章とのマッチを避けるため `## Session` 本文セクションにスコープを限定。 | | `paused_at` | string | プロジェクトが一時停止中の場合 | 一時停止ポイントの自由形式の説明。一時停止していない場合は省略または `null`。 | + +### フィールドの多重度 + +| フィールド | 多重度 | +|---|---| +| `gsd_state_version` | one | +| `milestone` | optional | +| `milestone_name` | optional | +| `current_phase` | optional | +| `current_phase_name` | optional | +| `current_plan` | optional | +| `status` | one | +| `stopped_at` | optional | +| `paused_at` | optional | +| `last_updated` | one | +| `last_activity` | optional | +| `last_activity_desc` | optional | +| `state_head` | optional | +| `progress.total_phases` | optional | +| `progress.completed_phases` | optional | +| `progress.total_plans` | optional | +| `progress.completed_plans` | optional | +| `progress.percent` | optional | + + ### ステータス値 `gsd-core/bin/lib/state-document.cjs` の `normalizeStateStatus()` が本文の生テキストを以下の正規値にマッピングします: @@ -100,6 +126,21 @@ paused_at: null | `/gsd-execute-phase` | `executing` | | `/gsd-verify-work` | `verifying` | + +### ステータスライフサイクル (ADR-2207) + +The `Status` field follows a strict lifecycle across phase and milestone boundaries: + +| 値 | 書き込み元 | 意味 | +|---|---|---| +| `Ready to plan` | `completePhaseCore` (non-last phase) | Next phase is ready for planning | +| `All phases complete` | `completePhaseCore` (last phase) | All phases done; milestone awaiting formal close | +| ` milestone complete` | `milestoneCompleteCore` | Milestone formally closed and archived | +| `Awaiting next milestone` | `milestoneCompleteCore` | Terminal/archived state | + +Phase-completion verbs never write `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). + + --- ## ステータスライン描画シーン diff --git a/docs/ko-KR/reference/state-md.md b/docs/ko-KR/reference/state-md.md index 6ee956496..9d75d6ced 100644 --- a/docs/ko-KR/reference/state-md.md +++ b/docs/ko-KR/reference/state-md.md @@ -74,9 +74,35 @@ paused_at: null | `last_updated` | ISO-8601 타임스탬프 | 항상 (쓰기 시) | 마지막 `syncStateFrontmatter` 호출의 타임스탬프. `realClock.nowIso()`에 의해 기록됩니다. | | `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, or when the resolved repo is not the project's own — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. | | `last_activity` | string | 본문에 설정된 경우 | 본문 `Last Activity:` 필드에서 추출된 마지막 활동 날짜. | +| `last_activity_desc` | string | 본문에 설정된 경우 | 본문 `Last Activity Description:` 필드에서 추출된 마지막 활동 설명. | | `stopped_at` | string | 중단점이 기록된 경우 | 마지막으로 완료된 작업의 설명. 아카이브 산문과의 매칭을 피하기 위해 `## Session` 본문 섹션으로 범위가 제한됩니다. | | `paused_at` | string | 프로젝트가 일시 정지된 경우 | 일시 정지 지점에 대한 자유형 설명. 일시 정지 상태가 아닐 때는 없거나 `null`. | + +### 필드 카디널리티 + +| 필드 | 카디널리티 | +|---|---| +| `gsd_state_version` | one | +| `milestone` | optional | +| `milestone_name` | optional | +| `current_phase` | optional | +| `current_phase_name` | optional | +| `current_plan` | optional | +| `status` | one | +| `stopped_at` | optional | +| `paused_at` | optional | +| `last_updated` | one | +| `last_activity` | optional | +| `last_activity_desc` | optional | +| `state_head` | optional | +| `progress.total_phases` | optional | +| `progress.completed_phases` | optional | +| `progress.total_plans` | optional | +| `progress.completed_plans` | optional | +| `progress.percent` | optional | + + ### 상태 값 `gsd-core/bin/lib/state-document.cjs`의 `normalizeStateStatus()`는 본문의 원시 텍스트를 다음 표준 값으로 매핑합니다: @@ -100,6 +126,21 @@ paused_at: null | `/gsd-execute-phase` | `executing` | | `/gsd-verify-work` | `verifying` | + +### 상태 라이프사이클 (ADR-2207) + +The `Status` field follows a strict lifecycle across phase and milestone boundaries: + +| 값 | 작성자 | 의미 | +|---|---|---| +| `Ready to plan` | `completePhaseCore` (non-last phase) | Next phase is ready for planning | +| `All phases complete` | `completePhaseCore` (last phase) | All phases done; milestone awaiting formal close | +| ` milestone complete` | `milestoneCompleteCore` | Milestone formally closed and archived | +| `Awaiting next milestone` | `milestoneCompleteCore` | Terminal/archived state | + +Phase-completion verbs never write `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). + + --- ## 상태 표시줄 렌더링 장면 diff --git a/docs/pt-BR/reference/state-md.md b/docs/pt-BR/reference/state-md.md index ef7d83699..a79be2010 100644 --- a/docs/pt-BR/reference/state-md.md +++ b/docs/pt-BR/reference/state-md.md @@ -74,9 +74,35 @@ paused_at: null | `last_updated` | timestamp ISO-8601 | Sempre (na escrita) | Timestamp da última chamada a `syncStateFrontmatter`; escrito por `realClock.nowIso()`. | | `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, or when the resolved repo is not the project's own — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. | | `last_activity` | string | Quando definido no corpo | Data da última atividade, extraída do campo `Last Activity:` do corpo. | +| `last_activity_desc` | string | Quando definido no corpo | Descrição da última atividade, extraída do campo `Last Activity Description:` do corpo. | | `stopped_at` | string | Quando um ponto de parada foi registrado | Descrição da última ação concluída; limitada à seção `## Session` do corpo para evitar correspondência com prosa de arquivo. | | `paused_at` | string | Quando o projeto está pausado | Descrição de forma livre do ponto de pausa; ausente ou `null` quando não pausado. | + +### Cardinalidade dos campos + +| Campo | Cardinalidade | +|---|---| +| `gsd_state_version` | one | +| `milestone` | optional | +| `milestone_name` | optional | +| `current_phase` | optional | +| `current_phase_name` | optional | +| `current_plan` | optional | +| `status` | one | +| `stopped_at` | optional | +| `paused_at` | optional | +| `last_updated` | one | +| `last_activity` | optional | +| `last_activity_desc` | optional | +| `state_head` | optional | +| `progress.total_phases` | optional | +| `progress.completed_phases` | optional | +| `progress.total_plans` | optional | +| `progress.completed_plans` | optional | +| `progress.percent` | optional | + + ### Valores de status `normalizeStateStatus()` em `gsd-core/bin/lib/state-document.cjs` mapeia o texto bruto do corpo para estes valores canônicos: @@ -100,6 +126,21 @@ Quando um comando do orquestrador está em andamento, a convenção (issue #2833 | `/gsd-execute-phase` | `executing` | | `/gsd-verify-work` | `verifying` | + +### Ciclo de vida do status (ADR-2207) + +The `Status` field follows a strict lifecycle across phase and milestone boundaries: + +| Valor | Escrito por | Significado | +|---|---|---| +| `Ready to plan` | `completePhaseCore` (non-last phase) | Next phase is ready for planning | +| `All phases complete` | `completePhaseCore` (last phase) | All phases done; milestone awaiting formal close | +| ` milestone complete` | `milestoneCompleteCore` | Milestone formally closed and archived | +| `Awaiting next milestone` | `milestoneCompleteCore` | Terminal/archived state | + +Phase-completion verbs never write `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). + + --- ## Cenas de renderização da linha de status diff --git a/docs/reference/state-md.md b/docs/reference/state-md.md index 074ee78e2..a41263b99 100644 --- a/docs/reference/state-md.md +++ b/docs/reference/state-md.md @@ -74,9 +74,35 @@ paused_at: null | `last_updated` | ISO-8601 timestamp | Always (on write) | Timestamp of the last `syncStateFrontmatter` call; written by `realClock.nowIso()`. | | `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, when the resolved repo is not the project's own, or in a `planning.sub_repos` workspace — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. | | `last_activity` | string | When set in body | Date of the last activity, extracted from the body `Last Activity:` field. | +| `last_activity_desc` | string | When set in body | Description of the last activity, extracted from the body `Last Activity Description:` field. | | `stopped_at` | string | When a stop point was recorded | Description of the last completed action; scoped to the `## Session` body section to avoid matching archive prose. | | `paused_at` | string | When the project is paused | Freeform description of the pause point; absent or `null` when not paused. | + +### Field cardinality + +| Field | Cardinality | +|---|---| +| `gsd_state_version` | one | +| `milestone` | optional | +| `milestone_name` | optional | +| `current_phase` | optional | +| `current_phase_name` | optional | +| `current_plan` | optional | +| `status` | one | +| `stopped_at` | optional | +| `paused_at` | optional | +| `last_updated` | one | +| `last_activity` | optional | +| `last_activity_desc` | optional | +| `state_head` | optional | +| `progress.total_phases` | optional | +| `progress.completed_phases` | optional | +| `progress.total_plans` | optional | +| `progress.completed_plans` | optional | +| `progress.percent` | optional | + + > **Known limitation — multi-repo workspaces.** In a workspace configured with > [`planning.sub_repos`](../CONFIGURATION.md#planning), the freshness hint reports *unknown* > rather than a commit age, and `state_head` is omitted. The outer workspace can own both @@ -109,6 +135,7 @@ When an orchestrator command is in flight, the convention (issue #2833) is to wr | `/gsd-execute-phase` | `executing` | | `/gsd-verify-work` | `verifying` | + ### Status lifecycle (ADR-2207) The `Status` field follows a strict lifecycle across phase and milestone boundaries: @@ -121,6 +148,7 @@ The `Status` field follows a strict lifecycle across phase and milestone boundar | `Awaiting next milestone` | `milestoneCompleteCore` | Terminal/archived state | Phase-completion verbs never write `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). + --- diff --git a/docs/zh-CN/reference/state-md.md b/docs/zh-CN/reference/state-md.md index 3bddf6026..4deb45f70 100644 --- a/docs/zh-CN/reference/state-md.md +++ b/docs/zh-CN/reference/state-md.md @@ -74,9 +74,35 @@ paused_at: null | `last_updated` | ISO-8601 时间戳 | 始终(写入时) | 最后一次 `syncStateFrontmatter` 调用的时间戳;由 `realClock.nowIso()` 写入。 | | `state_head` | string (40-char sha) | On write, when the project's own git repo resolves | Full commit sha STATE.md was written against (#2573). Omitted entirely outside a git repo, or when the resolved repo is not the project's own — an unverifiable stamp degrades to absent rather than asserting provenance the file does not have. Recomputed on every write and never carried forward. | | `last_activity` | 字符串 | 正文中设置时 | 最后活动日期,从正文 `Last Activity:` 字段提取。 | +| `last_activity_desc` | 字符串 | 正文中设置时 | 最后活动描述,从正文 `Last Activity Description:` 字段提取。 | | `stopped_at` | 字符串 | 记录了停止点时 | 最后完成操作的描述;限定在 `## Session` 正文章节内,以避免匹配存档文本。 | | `paused_at` | 字符串 | 项目已暂停时 | 暂停点的自由描述;未暂停时缺失或为 `null`。 | + +### 字段基数 + +| 字段 | 基数 | +|---|---| +| `gsd_state_version` | one | +| `milestone` | optional | +| `milestone_name` | optional | +| `current_phase` | optional | +| `current_phase_name` | optional | +| `current_plan` | optional | +| `status` | one | +| `stopped_at` | optional | +| `paused_at` | optional | +| `last_updated` | one | +| `last_activity` | optional | +| `last_activity_desc` | optional | +| `state_head` | optional | +| `progress.total_phases` | optional | +| `progress.completed_phases` | optional | +| `progress.total_plans` | optional | +| `progress.completed_plans` | optional | +| `progress.percent` | optional | + + ### 状态值 `gsd-core/bin/lib/state-document.cjs` 中的 `normalizeStateStatus()` 将原始正文文本映射到以下规范值: @@ -100,6 +126,21 @@ paused_at: null | `/gsd-execute-phase` | `executing` | | `/gsd-verify-work` | `verifying` | + +### 状态生命周期 (ADR-2207) + +The `Status` field follows a strict lifecycle across phase and milestone boundaries: + +| 值 | 写入方 | 含义 | +|---|---|---| +| `Ready to plan` | `completePhaseCore` (non-last phase) | Next phase is ready for planning | +| `All phases complete` | `completePhaseCore` (last phase) | All phases done; milestone awaiting formal close | +| ` milestone complete` | `milestoneCompleteCore` | Milestone formally closed and archived | +| `Awaiting next milestone` | `milestoneCompleteCore` | Terminal/archived state | + +Phase-completion verbs never write `Milestone complete` (the overloaded bare value was removed in #2204 per ADR-2207 to decouple phase-level writes from milestone termination). + + --- ## 状态行渲染场景 diff --git a/eslint.config.mjs b/eslint.config.mjs index 0d4b0fb0b..79727f41f 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -121,6 +121,8 @@ export default tseslint.config( 'gsd-core/bin/lib/artifacts.cjs', 'gsd-core/bin/lib/assumption-delta.cjs', 'gsd-core/bin/lib/state-transition.cjs', + // #3873: tsc-generated runtime artifact — lint the src/state-md-schema.cts source, not this. + 'gsd-core/bin/lib/state-md-schema.cjs', 'gsd-core/bin/lib/command-arg-projection.cjs', 'gsd-core/bin/lib/clock.cjs', 'gsd-core/bin/lib/ui-safety-gate.cjs', diff --git a/gsd-core/templates/state.md b/gsd-core/templates/state.md index 07e93af87..09a15ad1f 100644 --- a/gsd-core/templates/state.md +++ b/gsd-core/templates/state.md @@ -6,6 +6,12 @@ Template for `.planning/STATE.md` — the project's living memory. ## File Template +The frontmatter block below is generated from `STATE_FIELD_SCHEMA` +(`src/state-md-schema.cts`, ADR-3473 §8.8) by `scripts/gen-state-md-docs.cjs +--write` — do not hand-edit the marked region; the body below it is +hand-authored. + + ```markdown --- gsd_state_version: '1.0' # placeholder; syncStateFrontmatter overwrites on first state.* call @@ -17,6 +23,7 @@ progress: completed_plans: 0 percent: 0 --- + # Project State diff --git a/package.json b/package.json index f1fa03e4d..df306ae68 100644 --- a/package.json +++ b/package.json @@ -106,7 +106,7 @@ "gen:registry": "node scripts/gen-registry.cjs --write", "gen:install-tree": "node scripts/gen-install-tree-fixtures.cjs", "gen:section-manifest": "node scripts/gen-section-manifest.cjs --write", - "regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-features.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/gen-context-index.cjs --write && npm run gen:section-manifest && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree", + "regen:derived": "npm run build && npm run gen:registry && node scripts/gen-adr-index.cjs --write && node scripts/gen-features.cjs --write && node scripts/gen-capability-matrix.cjs --write && node scripts/gen-inventory-manifest.cjs --write && node scripts/gen-context-index.cjs --write && node scripts/gen-state-md-docs.cjs --write && npm run gen:section-manifest && node scripts/sync-manifest-versions.cjs && npm run gen:install-tree", "validate:registry": "node scripts/validate-registry.cjs", "prepack": "npm run build:lib", "prepare": "npm run build:lib", @@ -127,7 +127,7 @@ "lint:test-file-count": "node scripts/lint-test-file-count.cjs", "lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs", "lint:changeset": "node scripts/changeset/lint.cjs", - "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/gen-features.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check && node scripts/gen-health-docs.cjs --check", + "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/gen-features.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check && node scripts/gen-health-docs.cjs --check && node scripts/gen-state-md-docs.cjs --check", "lint:docs": "node scripts/lint-docs-required.cjs", "lint:qa-smells": "node scripts/qa-smell-ratchet.cjs", "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs", @@ -152,7 +152,8 @@ "test:coverage:all": "npm run test:coverage", "test:mutation": "stryker run", "test:mutation:since": "stryker run --incremental --since origin/next", - "gen:features": "node scripts/gen-features.cjs" + "gen:features": "node scripts/gen-features.cjs", + "gen:state-md-docs": "node scripts/gen-state-md-docs.cjs" }, "allowScripts": { "fallow@2.70.0": true diff --git a/scripts/docs-guard-registry.cjs b/scripts/docs-guard-registry.cjs index 7fb60c8d2..6af045e52 100644 --- a/scripts/docs-guard-registry.cjs +++ b/scripts/docs-guard-registry.cjs @@ -215,6 +215,13 @@ const DOCS_GUARD_TESTS = { // Walks docs/*.md and every docs//*.md dir dynamically // (docs-parity-live-registry.test.cjs:42, 428) — deliberately generic. 'tests/docs-parity-live-registry.test.cjs': ['*'], + 'tests/docs-state-md-locale-parity.test.cjs': [ + 'docs/reference/state-md.md', + 'docs/ja-JP/reference/state-md.md', + 'docs/zh-CN/reference/state-md.md', + 'docs/ko-KR/reference/state-md.md', + 'docs/pt-BR/reference/state-md.md', + ], 'tests/drift-detection.test.cjs': ['docs/CONFIGURATION.md', 'docs/AGENTS.md'], 'tests/edge-probe-docs-fixtures.test.cjs': ['docs/adr/550-spec-phase-probe-contract.md'], 'tests/edit-phase.test.cjs': [ @@ -237,6 +244,19 @@ const DOCS_GUARD_TESTS = { 'docs/registries/eos.json', 'docs/adr/0001-dispatch-policy-module.md', ], + // Seeds a temp fixture copy of these five files (never mutates the real + // tree) to exercise scripts/gen-state-md-docs.cjs's marked-region splicing + // against them — #3873 (ADR-3473 §8.8), rows 10-22/27. Read for fixture + // seeding, so a content edit to any of them (e.g. renaming a landmark + // heading/string a hostile-input test targets) can change this test's + // fixture assumptions. + 'tests/gen-state-md-docs.test.cjs': [ + 'docs/reference/state-md.md', + 'docs/ja-JP/reference/state-md.md', + 'docs/zh-CN/reference/state-md.md', + 'docs/ko-KR/reference/state-md.md', + 'docs/pt-BR/reference/state-md.md', + ], 'tests/gsd-write-guard.test.cjs': ['docs/USER-GUIDE.md'], 'tests/host-integration-descriptors.test.cjs': [ 'docs/reference/host-integration-capability-matrix.md', diff --git a/scripts/gen-state-md-docs.cjs b/scripts/gen-state-md-docs.cjs new file mode 100644 index 000000000..751f3644a --- /dev/null +++ b/scripts/gen-state-md-docs.cjs @@ -0,0 +1,727 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Generates the schema-derived MARKED REGIONS inside `gsd-core/templates/state.md` + * and the five `docs/{,ja-JP/,zh-CN/,ko-KR/,pt-BR/}reference/state-md.md` pages + * from `STATE_FIELD_SCHEMA` (`src/state-md-schema.cts`, ADR-3473 §8.8, #3873). + * + * WHY THIS EXISTS. Four hand-maintained declarations of "which STATE.md keys + * exist and what they carry" already disagreed (see `src/state-md-schema.cts`'s + * own docstring for the `last_activity` case). Two of the SIX places named in + * `.gsd/phase/feat-3873-state-md-schema/40-design.md`'s table are documents, + * not code: the shipped template and the reference docs. This generator is + * their half of the consolidation. `FIELD_CLASSIFICATION` / + * `FRONTMATTER_BODY_SOURCE` / `FRONTMATTER_KEY_TO_BODY_LABEL` project from the + * same schema at MODULE LOAD (`src/state-transition.cts`, `src/state.cts`); + * this script projects at BUILD TIME instead, into committed markdown. + * + * GENERATED REGIONS, NOT GENERATED FILES (design doc §4). The five reference + * pages are hand-translated prose with schema-derived tables and section + * skeletons embedded in them; a generator that rewrote the whole file would + * clobber a community translation. So — following `gen-features.cjs`, not + * `gen-context-index.cjs` (the design's own precedent choice) — this script + * owns only explicitly MARKED regions inside otherwise hand-authored files, + * and never touches a byte outside a marker pair. + * + * Two regions are declared today: + * - `frontmatter` — the initial YAML frontmatter block in + * `gsd-core/templates/state.md`, the block a new project starts from. + * - `status-lifecycle` — the `### Status lifecycle (ADR-2207)` section + * documenting the `status` field's lifecycle. This is the section + * ISSUE #3873's own design doc found missing from all four locale + * translations (verified by line/heading count) — the generator emitting + * this skeleton is what turns `tests/docs-state-md-locale-parity.test.cjs` + * green. + * + * A NOTE ON WHAT "SCHEMA-DERIVED" MEANS HERE. `STATE_FIELD_SCHEMA.status.enum` + * (`STATUS_LIFECYCLE_ENUM`) is the seven-member NORMALIZED status value set + * `normalizeStateStatus()` computes. The `### Status lifecycle (ADR-2207)` + * table documents a DIFFERENT, narrower vocabulary — the raw `Status:` body + * strings `completePhaseCore`/`milestoneCompleteCore` actually write + * (`Ready to plan`, `All phases complete`, ...) — `src/state-md-schema.cts`'s + * own docstring is explicit that these are not the same set. This generator + * therefore does not literally enumerate `schema.status.enum` into the table; + * it asserts `schema.status.enum` is declared and non-empty (so a future + * removal of the `status` row's enum fails this generator loudly, keeping the + * link real) and renders the ADR-2207 table from `STATUS_LIFECYCLE_ROWS` + * below, which the codebase does not (yet) declare in one place either. This + * is a section SKELETON, not a full re-derivation, per the design's own + * scoping: "Parsers are checked, not generated." + * + * COLUMN HEADERS ARE PER-LOCALE (design doc §4: "Column headers come from a + * per-locale string table so a translated table has translated headers"). + * Table BODY content (the four `Ready to plan` / ... rows) is left in English + * across every locale deliberately: those are literal STATE.md body-field + * values a project actually writes, not prose to translate, and the + * "Meaning" column is exactly the kind of hand-translatable prose the design + * doc says a human still owns — the generator's contract there is only that + * the SECTION exists (structural, ADR-3873/#3853), never that its prose is + * translated (design doc "Not-corruption": "A locale is not 'wrong' for + * having different prose"). + * + * Usage: + * node scripts/gen-state-md-docs.cjs # print every region to stdout + * node scripts/gen-state-md-docs.cjs --write # rewrite every region in place + * node scripts/gen-state-md-docs.cjs --check # exit 1 if any region is stale + * node scripts/gen-state-md-docs.cjs --json # --check semantics; JSON report + * node scripts/gen-state-md-docs.cjs --write --force # write despite violations + * node scripts/gen-state-md-docs.cjs ... --root # resolve target files under + * # instead of the repo root (test-only; + * # the schema module itself is always + * # required from THIS repo's build output) + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const REPO_ROOT = path.resolve(__dirname, '..'); +const SCHEMA_LIB_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'state-md-schema.cjs'); + +const MARKER_TAG = 'STATE-MD-SCHEMA'; + +/** Stable reason codes for every violation this gate can emit. */ +const REASON = Object.freeze({ + SCHEMA_LIB_MISSING: 'schema_lib_missing', + LOCALE_STRINGS_MISSING: 'locale_strings_missing', + MARKERS_MISSING: 'markers_missing', + MARKER_UNCLOSED: 'marker_unclosed', + MARKER_ORDER_INVALID: 'marker_order_invalid', + REGION_STALE: 'region_stale', + FIELD_REFERENCE_DRIFT: 'field_reference_drift', + STATUS_VALUES_DRIFT: 'status_values_drift', +}); + +/** + * The five reference-doc locales plus the shipped template — each an + * independent target file, each declaring the ordered list of region names it + * carries. `en` and the four translations all carry `status-lifecycle`; + * only the template carries `frontmatter`. + */ +const TARGETS = Object.freeze([ + { key: 'template', relPath: path.join('gsd-core', 'templates', 'state.md'), regions: ['frontmatter'] }, + { key: 'en', relPath: path.join('docs', 'reference', 'state-md.md'), locale: 'en', regions: ['status-lifecycle', 'cardinality'] }, + { key: 'ja-JP', relPath: path.join('docs', 'ja-JP', 'reference', 'state-md.md'), locale: 'ja-JP', regions: ['status-lifecycle', 'cardinality'] }, + { key: 'zh-CN', relPath: path.join('docs', 'zh-CN', 'reference', 'state-md.md'), locale: 'zh-CN', regions: ['status-lifecycle', 'cardinality'] }, + { key: 'ko-KR', relPath: path.join('docs', 'ko-KR', 'reference', 'state-md.md'), locale: 'ko-KR', regions: ['status-lifecycle', 'cardinality'] }, + { key: 'pt-BR', relPath: path.join('docs', 'pt-BR', 'reference', 'state-md.md'), locale: 'pt-BR', regions: ['status-lifecycle', 'cardinality'] }, +]); + +/** `` */ +function startMarker(region) { + return ``; +} +/** `` */ +function endMarker(region) { + return ``; +} + +// ─── Frontmatter region (gsd-core/templates/state.md) ────────────────────── + +/** + * The subset of `STATE_FIELD_SCHEMA` keys a FRESH project's initial STATE.md + * frontmatter declares. Validated against the schema at render time — if a + * future schema change drops one of these keys, rendering throws rather than + * silently emitting a template field the schema no longer recognizes. + */ +const TEMPLATE_FRONTMATTER_FIELDS = Object.freeze(['gsd_state_version', 'status', 'progress']); + +function renderFrontmatterRegion(schema) { + for (const field of TEMPLATE_FRONTMATTER_FIELDS) { + if (!(field in schema)) { + throw new ExitError( + 1, + `template frontmatter field '${field}' is not declared in STATE_FIELD_SCHEMA (src/state-md-schema.cts) — update TEMPLATE_FRONTMATTER_FIELDS or the schema`, + ); + } + } + // NOTE: this body deliberately opens the SAME ```markdown fence the + // hand-authored File Template body continues in — it does NOT wrap the + // frontmatter in its own separate ```yaml fence. `tests/state-transition + // .test.cjs`'s bug #21 regression guard extracts the entire ```markdown + // ... ``` block and asserts the extracted text STARTS with '---': a + // separate preceding fence broke that contract (#3873 fixed-forward). The + // END marker therefore lands inside the still-open fence, right after the + // frontmatter's closing '---' and before the hand-authored '# Project + // State' line — that is intentional, not a rendering bug. + return [ + '```markdown', + '---', + "gsd_state_version: '1.0' # placeholder; syncStateFrontmatter overwrites on first state.* call", + 'status: planning', + 'progress:', + ' total_phases: 0', + ' completed_phases: 0', + ' total_plans: 0', + ' completed_plans: 0', + ' percent: 0', + '---', + ].join('\n'); +} + +// ─── Status-lifecycle region (the five reference docs) ────────────────────── + +/** Per-locale heading text and translated column headers (design §4). */ +const STATUS_LIFECYCLE_STRINGS = Object.freeze({ + en: { heading: 'Status lifecycle (ADR-2207)', cols: ['Value', 'Written by', 'Meaning'] }, + 'ja-JP': { heading: 'ステータスライフサイクル (ADR-2207)', cols: ['値', '書き込み元', '意味'] }, + 'zh-CN': { heading: '状态生命周期 (ADR-2207)', cols: ['值', '写入方', '含义'] }, + 'ko-KR': { heading: '상태 라이프사이클 (ADR-2207)', cols: ['값', '작성자', '의미'] }, + 'pt-BR': { heading: 'Ciclo de vida do status (ADR-2207)', cols: ['Valor', 'Escrito por', 'Significado'] }, +}); + +/** Canonical, deliberately untranslated across locales — see file header note. */ +const STATUS_LIFECYCLE_ROWS = Object.freeze([ + ['`Ready to plan`', '`completePhaseCore` (non-last phase)', 'Next phase is ready for planning'], + ['`All phases complete`', '`completePhaseCore` (last phase)', 'All phases done; milestone awaiting formal close'], + ['` milestone complete`', '`milestoneCompleteCore`', 'Milestone formally closed and archived'], + ['`Awaiting next milestone`', '`milestoneCompleteCore`', 'Terminal/archived state'], +]); + +const STATUS_LIFECYCLE_INTRO = + 'The `Status` field follows a strict lifecycle across phase and milestone boundaries:'; +const STATUS_LIFECYCLE_FOOTNOTE = + 'Phase-completion verbs never write `Milestone complete` (the overloaded bare value was removed in ' + + '#2204 per ADR-2207 to decouple phase-level writes from milestone termination).'; + +/** + * Render the status-lifecycle region for `locale`. + * + * `schema.status` lacking a non-empty `enum` is a hard SCHEMA DEFECT (there + * is nothing coherent to render for any locale) and remains a thrown + * `ExitError` — unlike the locale-strings case below, `--force` could not + * meaningfully "override" it, because no fallback content exists. + * + * A locale absent from `STATUS_LIFECYCLE_STRINGS`, by contrast, is exactly + * the forceable-violation shape `gen-features.cjs` established (a corpus gap + * that still has a renderable fallback): pushed onto `violations` rather + * than thrown, and rendered using the English strings so `--write --force` + * can still emit something reviewable while `--check`/`--write` (no force) + * refuse. + */ +function renderStatusLifecycleRegion(locale, schema, violations) { + const statusRow = schema.status; + if (!statusRow || !Array.isArray(statusRow.enum) || statusRow.enum.length === 0) { + throw new ExitError( + 1, + "STATE_FIELD_SCHEMA.status must declare a non-empty 'enum' for the status-lifecycle section to be generated", + ); + } + let strings = STATUS_LIFECYCLE_STRINGS[locale]; + if (!strings) { + violations.push({ reason: REASON.LOCALE_STRINGS_MISSING, file: null, region: 'status-lifecycle', locale }); + strings = STATUS_LIFECYCLE_STRINGS.en; + } + const [c0, c1, c2] = strings.cols; + const lines = [ + `### ${strings.heading}`, + '', + STATUS_LIFECYCLE_INTRO, + '', + `| ${c0} | ${c1} | ${c2} |`, + '|---|---|---|', + ]; + for (const [value, writtenBy, meaning] of STATUS_LIFECYCLE_ROWS) { + lines.push(`| ${value} | ${writtenBy} | ${meaning} |`); + } + lines.push('', STATUS_LIFECYCLE_FOOTNOTE); + return lines.join('\n'); +} + +// ─── Cardinality region (the five reference docs) ─────────────────────────── +// +// ADR-3473 §8.8 names three schema-derived tables: "field reference, status +// values, cardinality". No "### Field cardinality" section existed anywhere +// before this generator — unlike status-lifecycle (which REPLACED an +// existing English section and filled a gap in the four translations), this +// is wholly NEW content, so there is nothing hand-translated to lose by +// generating it. Every row is schema-derived: the field NAME and its +// `cardinality` value ('one' | 'optional' | 'many'), nothing else — no +// prose, so no translation to overwrite. +// +// EXCLUDED_FIELD_TABLE_KEYS: the bare `progress` object row. The existing +// Field-reference table (hand-authored) never lists `progress` itself as its +// own row either — only its five `progress.*` leaves — matching this repo's +// "reporting granularity is the dotted leaf path" convention (ADR-3473 §8.8, +// "Rule — reporting granularity is the dotted leaf path"). The cardinality +// table mirrors that same convention rather than introducing a new one. +const EXCLUDED_FIELD_TABLE_KEYS = Object.freeze(['progress']); + +const CARDINALITY_STRINGS = Object.freeze({ + en: { heading: 'Field cardinality', cols: ['Field', 'Cardinality'] }, + 'ja-JP': { heading: 'フィールドの多重度', cols: ['フィールド', '多重度'] }, + 'zh-CN': { heading: '字段基数', cols: ['字段', '基数'] }, + 'ko-KR': { heading: '필드 카디널리티', cols: ['필드', '카디널리티'] }, + 'pt-BR': { heading: 'Cardinalidade dos campos', cols: ['Campo', 'Cardinalidade'] }, +}); + +function renderCardinalityRegion(locale, schema) { + const strings = CARDINALITY_STRINGS[locale]; + if (!strings) { + throw new ExitError(1, `no localized strings registered in CARDINALITY_STRINGS for locale '${locale}'`); + } + const [c0, c1] = strings.cols; + const lines = [`### ${strings.heading}`, '', `| ${c0} | ${c1} |`, '|---|---|']; + for (const key of Object.keys(schema)) { + if (EXCLUDED_FIELD_TABLE_KEYS.includes(key)) continue; + lines.push(`| \`${key}\` | ${schema[key].cardinality} |`); + } + return lines.join('\n'); +} + +// ─── Field-reference / status-values KEY-SET PARITY (detection, not generation) ── +// +// The Field-reference and Status-values tables carry hand-translated PROSE +// per row (a field's "Purpose"/"When populated" description; a status +// value's "Matched text" description) that the schema does not model at all +// — `StateFieldSchema` has no descriptive-text field, and translating that +// prose into a per-locale static registry inside this generator would mean +// OVERWRITING genuinely hand-translated ja-JP/zh-CN/ko-KR/pt-BR content with +// canonical English text on every `--write`. That is a real content loss, +// not a cosmetic one, and it is the "column the schema does not model" +// case (see the module header and the PR discussion this code answers). +// +// What the schema DOES let us check, losslessly: the ROW SET. A field's +// Purpose/When-populated text can be hand-authored forever; but the fact +// that `last_activity_desc` (declared in STATE_FIELD_SCHEMA) has NO row in +// the English Field-reference table at all is exactly the "unrepresentable +// drift" ADR-3473 §8.8 exists to close, and it is real, live, and pre-dates +// this generator (found while building it; fixed alongside it in this same +// change). This check makes that class of drift DETECTABLE (fails --check) +// without touching a single byte of translated prose. +// +// KNOWN_SCHEMA_GAP_FIELDS: `active_phase`, `next_action`, `next_phases` are +// real STATE.md frontmatter keys (ADR issue #2833), documented in the +// Field-reference table today, but NOT declared in `STATE_FIELD_SCHEMA` — +// they are computed/reported outside the classification/preservation +// dispatch model `FIELD_CLASSIFICATION` projects, and deciding whether they +// belong in that schema at all is a cross-cutting call this generator does +// not make unilaterally (it would ripple into `state-transition.cts` / +// `state.cts`'s pinned projection-parity tests). Grandfathered here, by +// name, so the check is a real ratchet (a FIFTH undeclared field would still +// fail) rather than silently disabled. +const KNOWN_SCHEMA_GAP_FIELDS = Object.freeze(['active_phase', 'next_action', 'next_phases']); + +/** Per-locale heading text for the two hand-authored, prose-bearing tables (parity-parsed, never spliced). */ +const FIELD_REFERENCE_HEADING = Object.freeze({ + en: 'Field reference', + 'ja-JP': 'フィールドリファレンス', + 'zh-CN': '字段参考', + 'ko-KR': '필드 참조', + 'pt-BR': 'Referência de campos', +}); +const STATUS_VALUES_HEADING = Object.freeze({ + en: 'Status values', + 'ja-JP': 'ステータス値', + 'zh-CN': '状态值', + 'ko-KR': '상태 값', + 'pt-BR': 'Valores de status', +}); + +/** + * The first column's cell values of the FIRST markdown table immediately + * following the `### ` heading in `normalizedText` (LF-normalized). + * Returns `null` if the heading is not found. Backtick-fenced inline code is + * stripped (`` `key` `` -> `key`) since every row in both tables opens its + * first cell that way. + */ +function firstColumnAfterHeading(normalizedText, headingText) { + const lines = normalizedText.split('\n'); + const headingIdx = lines.findIndex((l) => l === `### ${headingText}`); + if (headingIdx === -1) return null; + const values = []; + let sawHeaderRow = false; + for (let i = headingIdx + 1; i < lines.length; i++) { + const line = lines[i]; + if (!line.startsWith('|')) { + if (sawHeaderRow) break; // table ended + continue; // still skipping the heading's intro prose + } + const cells = line.split('|'); + const first = (cells[1] ?? '').trim(); + if (/^:?-+:?$/.test(first)) continue; // the `|---|---|` separator row + if (!sawHeaderRow) { + sawHeaderRow = true; // this is the `| Header | Header |` row itself + continue; + } + values.push(first.replace(/`/g, '')); + } + return values; +} + +/** + * Field-reference / status-values row-set violations for one target, or `[]` + * when the target has neither table (the template) or the parity holds. + */ +function collectKeySetParityViolations(target, schema, normalizedText) { + if (!target.locale) return []; + const found = []; + + const fieldHeading = FIELD_REFERENCE_HEADING[target.locale]; + const docFieldKeys = fieldHeading ? firstColumnAfterHeading(normalizedText, fieldHeading) : null; + if (docFieldKeys !== null) { + const schemaKeys = Object.keys(schema).filter((k) => !EXCLUDED_FIELD_TABLE_KEYS.includes(k)); + const docSet = new Set(docFieldKeys); + const schemaSet = new Set(schemaKeys); + const missingFromDoc = schemaKeys.filter((k) => !docSet.has(k)); + const undeclaredInSchema = docFieldKeys.filter( + (k) => !schemaSet.has(k) && !KNOWN_SCHEMA_GAP_FIELDS.includes(k), + ); + if (missingFromDoc.length > 0 || undeclaredInSchema.length > 0) { + found.push({ + reason: REASON.FIELD_REFERENCE_DRIFT, + file: target.relPath, + region: null, + missingFromDoc, + undeclaredInSchema, + }); + } + } + + const statusHeading = STATUS_VALUES_HEADING[target.locale]; + const docStatusValues = statusHeading ? firstColumnAfterHeading(normalizedText, statusHeading) : null; + if (docStatusValues !== null) { + const enumMembers = schema.status && Array.isArray(schema.status.enum) ? schema.status.enum : []; + const docSet = new Set(docStatusValues); + const enumSet = new Set(enumMembers); + const missingFromDoc = enumMembers.filter((v) => !docSet.has(v)); + const undeclaredInSchema = docStatusValues.filter((v) => !enumSet.has(v)); + if (missingFromDoc.length > 0 || undeclaredInSchema.length > 0) { + found.push({ + reason: REASON.STATUS_VALUES_DRIFT, + file: target.relPath, + region: null, + missingFromDoc, + undeclaredInSchema, + }); + } + } + + return found; +} + +const REGION_RENDERERS = Object.freeze({ + frontmatter: (target, schema, _violations) => renderFrontmatterRegion(schema), + 'status-lifecycle': (target, schema, violations) => renderStatusLifecycleRegion(target.locale, schema, violations), + cardinality: (target, schema, _violations) => renderCardinalityRegion(target.locale, schema), +}); + +// ─── Marker splicing ───────────────────────────────────────────────────────── + +/** + * Locate a region's `[start, end)` byte range (over LF-normalized text), + * INCLUSIVE of both marker lines, in `doc`. Mirrors `gen-features.cjs`'s + * `spliceIntoFeatures`: the END marker is found via `lastIndexOf` so a forged + * marker cannot shrink the region it governs. + * + * Returns `{ violation }` when the pair is missing, unclosed, or out of + * order — the caller decides whether that is fatal (splice) or merely + * reportable (check/json). + */ +function findRegion(doc, file, region) { + const start = startMarker(region); + const end = endMarker(region); + const startIdx = doc.indexOf(start); + const endIdx = doc.lastIndexOf(end); + + if (startIdx === -1 && endIdx === -1) { + return { violation: { reason: REASON.MARKERS_MISSING, file, region } }; + } + if (startIdx === -1 || endIdx === -1) { + return { violation: { reason: REASON.MARKER_UNCLOSED, file, region } }; + } + if (endIdx < startIdx) { + return { violation: { reason: REASON.MARKER_ORDER_INVALID, file, region } }; + } + return { range: [startIdx, endIdx + end.length] }; +} + +/** Replace `region`'s marked span in `doc` with `body`, markers included. */ +function spliceRegion(doc, region, body) { + const { range, violation } = findRegion(doc, '', region); + if (violation) { + throw new ExitError( + 1, + `region '${region}' markers are ${violation.reason} — expected:\n ${startMarker(region)}\n ${endMarker(region)}\n`, + ); + } + const [start, end] = range; + const replacement = `${startMarker(region)}\n${body}\n${endMarker(region)}`; + return doc.slice(0, start) + replacement + doc.slice(end); +} + +/** `true` if `text`'s line endings are predominantly CRLF. */ +function isCrlf(text) { + return /\r\n/.test(text); +} + +// ─── Corpus assembly ───────────────────────────────────────────────────────── + +function loadSchema() { + if (!fs.existsSync(SCHEMA_LIB_PATH)) { + throw new ExitError( + 1, + `${SCHEMA_LIB_PATH} does not exist. Run 'npm run build:lib' first (STATE_FIELD_SCHEMA is compiled from src/state-md-schema.cts).`, + ); + } + // SCHEMA_LIB_PATH is a fixed, repo-relative constant computed from __dirname — never user input. + const mod = require(SCHEMA_LIB_PATH); + return mod.STATE_FIELD_SCHEMA; +} + +/** + * Build every target's rendered regions plus, for a given `filesRoot`, the + * violations found reading its on-disk file (missing/unclosed markers, + * staleness). `filesRoot` defaults to the real repo root; tests pass a temp + * copy so no fixture is ever planted in the real tree. + */ +function buildCorpus(filesRoot) { + const schema = loadSchema(); + const violations = []; + const targets = TARGETS.map((target) => { + const absPath = path.join(filesRoot, target.relPath); + const regionBodies = {}; + for (const region of target.regions) { + const renderer = REGION_RENDERERS[region]; + regionBodies[region] = renderer(target, schema, violations); + } + return { ...target, absPath, regionBodies }; + }); + return { schema, targets, violations }; +} + +/** Read `target.absPath`, returning `null` (with a violation appended) on ENOENT. */ +function readTarget(target, violations) { + try { + return fs.readFileSync(target.absPath, 'utf8'); + } catch { + violations.push({ reason: REASON.MARKERS_MISSING, file: target.relPath, region: null, detail: 'file unreadable' }); + return null; + } +} + +/** Splice every declared region of `target` into `original`, returning the new text (or throwing). */ +function spliceTarget(target, original) { + const crlf = isCrlf(original); + let normalized = original.replace(/\r\n/g, '\n'); + for (const region of target.regions) { + normalized = spliceRegion(normalized, region, target.regionBodies[region]); + } + return crlf ? normalized.replace(/\n/g, '\r\n') : normalized; +} + +/** Which of `target`'s regions differ between `original` and its spliced form. */ +function staleRegionsOf(target, original) { + const crlf = isCrlf(original); + const normalizedOriginal = original.replace(/\r\n/g, '\n'); + const stale = []; + for (const region of target.regions) { + const { range, violation } = findRegion(normalizedOriginal, target.relPath, region); + if (violation) continue; // already reported as a hostile violation elsewhere + const [start, end] = range; + const currentSpan = normalizedOriginal.slice(start, end); + const freshSpan = `${startMarker(region)}\n${target.regionBodies[region]}\n${endMarker(region)}`; + if (currentSpan !== freshSpan) stale.push(region); + } + return { stale, crlf }; +} + +/** Non-throwing per-target-per-region violation scan, for --check/--json. */ +function collectTargetViolations(target, original) { + const found = []; + if (original === null) return found; + const normalized = original.replace(/\r\n/g, '\n'); + for (const region of target.regions) { + const { violation } = findRegion(normalized, target.relPath, region); + if (violation) { + found.push({ ...violation, file: target.relPath }); + } + } + return found; +} + +/** Human one-liner for a violation, keyed off its typed reason. */ +function describeViolation(v) { + switch (v.reason) { + case REASON.SCHEMA_LIB_MISSING: + return `${SCHEMA_LIB_PATH} is missing — run 'npm run build:lib'`; + case REASON.MARKERS_MISSING: + return `${v.file}: region '${v.region}' markers are entirely absent — expected ${startMarker(v.region)} / ${endMarker(v.region)}`; + case REASON.MARKER_UNCLOSED: + return `${v.file}: region '${v.region}' has only one of its START/END markers — malformed or unclosed`; + case REASON.MARKER_ORDER_INVALID: + return `${v.file}: region '${v.region}' END marker precedes its START marker`; + case REASON.REGION_STALE: + return `${v.file}: region '${v.region}' is stale — run 'node scripts/gen-state-md-docs.cjs --write'`; + case REASON.LOCALE_STRINGS_MISSING: + return `no localized strings registered in STATUS_LIFECYCLE_STRINGS for locale '${v.locale}' — falls back to 'en' under --force`; + case REASON.FIELD_REFERENCE_DRIFT: + return ( + `${v.file}: Field-reference table row set disagrees with STATE_FIELD_SCHEMA — ` + + `missing from doc: [${v.missingFromDoc.join(', ')}]; undeclared in schema: [${v.undeclaredInSchema.join(', ')}]` + ); + case REASON.STATUS_VALUES_DRIFT: + return ( + `${v.file}: Status-values table row set disagrees with STATE_FIELD_SCHEMA.status.enum — ` + + `missing from doc: [${v.missingFromDoc.join(', ')}]; undeclared in schema: [${v.undeclaredInSchema.join(', ')}]` + ); + default: + return `${v.file}: ${v.reason}`; + } +} + +// ─── CLI ───────────────────────────────────────────────────────────────────── + +function parseArgs(argv) { + const opts = { write: false, check: false, json: false, force: false, root: REPO_ROOT }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--write') opts.write = true; + else if (arg === '--check') opts.check = true; + else if (arg === '--json') opts.json = true; + else if (arg === '--force') opts.force = true; + else if (arg === '--root') { + opts.root = argv[++i]; + if (!opts.root) throw new ExitError(1, '--root requires a directory argument'); + } else { + throw new ExitError(1, `unknown flag: ${arg}\nRecognized flags: --write, --check, --json, --force, --root .`); + } + } + return opts; +} + +function main() { + const { write, check, json, force, root } = parseArgs(process.argv.slice(2)); + + const { schema, targets, violations } = buildCorpus(root); + + // Collect per-target hostile-input violations (missing/malformed markers) + // up front — these are fatal for --write regardless of --force, because + // --force overrides a STALE region (a legitimate "write it anyway" ask), + // never a structurally broken target (there is nothing to splice into). + const originals = new Map(); + for (const target of targets) { + const original = readTarget(target, violations); + originals.set(target.key, original); + violations.push(...collectTargetViolations(target, original)); + if (original !== null) { + violations.push(...collectKeySetParityViolations(target, schema, original.replace(/\r\n/g, '\n'))); + } + } + + const hostileViolations = violations.filter((v) => + [REASON.MARKERS_MISSING, REASON.MARKER_UNCLOSED, REASON.MARKER_ORDER_INVALID].includes(v.reason), + ); + + // Staleness (only computable for targets with clean markers), per region. + const staleTargets = []; + for (const target of targets) { + const original = originals.get(target.key); + if (original === null) continue; + const hasHostile = hostileViolations.some((v) => v.file === target.relPath); + if (hasHostile) continue; + const { stale } = staleRegionsOf(target, original); + if (stale.length > 0) { + staleTargets.push(target); + for (const region of stale) violations.push({ reason: REASON.REGION_STALE, file: target.relPath, region }); + } + } + + if (json) { + const ok = violations.length === 0; + process.stdout.write( + JSON.stringify({ + ok, + targetCount: targets.length, + staleCount: staleTargets.length, + violations, + }) + '\n', + ); + return ok ? 0 : 1; + } + + if (check) { + if (violations.length > 0) { + process.stderr.write(`gen-state-md-docs: ${violations.length} violation(s).\n\n`); + for (const v of violations) process.stderr.write(` ✗ ${describeViolation(v)}\n`); + process.stderr.write('\n'); + throw new ExitError(1); + } + process.stdout.write(`All ${targets.length} target(s) are up to date.\n`); + return 0; + } + + if (write) { + // FAIL-CLOSED (gen-features.cjs precedent, design doc §3): a hostile + // marker violation is ALWAYS fatal — --force overrides staleness, not a + // structurally broken target with nothing to splice into. + if (hostileViolations.length > 0) { + process.stderr.write(`gen-state-md-docs: refusing to write — ${hostileViolations.length} target(s) have broken markers.\n\n`); + for (const v of hostileViolations) process.stderr.write(` ✗ ${describeViolation(v)}\n`); + process.stderr.write('\n'); + throw new ExitError(1); + } + const otherViolations = violations.filter((v) => v.reason !== REASON.REGION_STALE && !hostileViolations.includes(v)); + if (otherViolations.length > 0 && !force) { + process.stderr.write(`gen-state-md-docs: ${otherViolations.length} violation(s). Refusing to write; pass --force to override.\n\n`); + for (const v of otherViolations) process.stderr.write(` ✗ ${describeViolation(v)}\n`); + process.stderr.write('\n'); + throw new ExitError(1); + } + let written = 0; + for (const target of targets) { + const original = originals.get(target.key); + if (original === null) continue; + const hasHostile = hostileViolations.some((v) => v.file === target.relPath); + if (hasHostile) continue; + const spliced = spliceTarget(target, original); + if (spliced !== original) { + fs.writeFileSync(target.absPath, spliced); + written++; + } + } + process.stdout.write(`Wrote ${written} of ${targets.length} target(s).\n`); + return 0; + } + + // Default: print every region, labeled, to stdout. + for (const target of targets) { + for (const region of target.regions) { + process.stdout.write(`\n=== ${target.relPath} :: ${region} ===\n`); + process.stdout.write(target.regionBodies[region] + '\n'); + } + } + return 0; +} + +if (require.main === module) runMain(main); + +module.exports = { + REASON, + TARGETS, + MARKER_TAG, + startMarker, + endMarker, + TEMPLATE_FRONTMATTER_FIELDS, + STATUS_LIFECYCLE_STRINGS, + STATUS_LIFECYCLE_ROWS, + CARDINALITY_STRINGS, + EXCLUDED_FIELD_TABLE_KEYS, + KNOWN_SCHEMA_GAP_FIELDS, + FIELD_REFERENCE_HEADING, + STATUS_VALUES_HEADING, + renderFrontmatterRegion, + renderStatusLifecycleRegion, + renderCardinalityRegion, + firstColumnAfterHeading, + collectKeySetParityViolations, + findRegion, + spliceRegion, + spliceTarget, + staleRegionsOf, + isCrlf, + buildCorpus, + describeViolation, +}; diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index b7120e2df..26434888a 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -237,6 +237,15 @@ ], "issue": "3227", "justification": "Grandfathered by #3227 when the testEffectivePrefix() dot/hyphen bucketing fix first made this pre-existing over-cap cluster visible to the gate; not new test sprawl." + }, + "docs": { + "files": [ + "docs-parity-live-registry.test.cjs", + "docs-state-md-locale-parity.test.cjs", + "docs-update.test.cjs" + ], + "issue": "3873", + "justification": "ADR-3473 §8.8 makes docs/reference/state-md.md and its four locale siblings generated-and-committed and adds the repo's first locale-parity check (verified: no locale-parity lint precedent exists here). That check is a distinct concern from docs-update (content freshness) and docs-parity-live-registry (registry parity); folding it into either would place an unrelated subject inside them purely to satisfy a count. The section it guards is absent from all four translations today and documents the status enum behind #3853." } } } diff --git a/src/state-md-schema.cts b/src/state-md-schema.cts new file mode 100644 index 000000000..ae336eeb0 --- /dev/null +++ b/src/state-md-schema.cts @@ -0,0 +1,285 @@ +/** + * STATE.md Field Schema — the one declaration (ADR-3473 §8.8, issue #3873). + * + * Phase 3 substrate. Before this module, "which STATE.md keys exist and what + * they carry" was declared in THREE hand-maintained places that were already + * observed to disagree (see the `last_activity` docstring below): + * + * - `FIELD_CLASSIFICATION` (`src/state-transition.cts`) — source/preservation/ + * guard/mergeStrategy per frontmatter key (ADR-1769 §4 / ADR-3408). + * - `FRONTMATTER_BODY_SOURCE` (`src/state-transition.cts`) — which BODY field + * a frontmatter key derives from. + * - `FRONTMATTER_KEY_TO_BODY_LABEL` (`src/state.cts`) — the Title-Case label + * a report speaks a preserved field in (ADR-3408 §8.4/§8.5). + * + * This module is the single row-per-key declaration those three now PROJECT + * from at load time (`state-transition.cts` / `state.cts`), rather than + * hand-maintaining a fourth copy of the same knowledge. Every exported shape + * of the three original tables is unchanged — same keys, same key ORDER, same + * frozen/null-prototype-ness — so every existing consumer (the preservation + * dispatch loop, `getFieldClassification`, `getPreserveWhenUnchangedFields`, + * `bodyLabelFor`, and issue #3872's `declaredLeavesOf`) keeps working without + * an edit. See `.gsd/phase/feat-3873-state-md-schema/40-design.md`. + * + * LEAF MODULE, DELIBERATELY. This file imports from neither `state-transition.cts` + * nor `state.cts` — both of those import THIS module, and either importing + * back would be the exact CJS require-cycle `src/health-diagnostic-types.cts`'s + * own docstring describes breaking for the health-diagnostic rule tables + * (`module.exports` read before it is assigned, so a destructured value comes + * back `undefined`). `FieldSource` / `FieldPreservation` / `FieldGuard` / + * `FieldMergeStrategy` therefore live HERE now and are re-exported (by the same + * name, so no importer of `state-transition.cts` needs to change) from + * `state-transition.cts`. + * + * ADR-457 build-at-publish: source in `src/state-md-schema.cts`, compiled to + * `gsd-core/bin/lib/state-md-schema.cjs` (gitignored). + * + * Design: .gsd/phase/feat-3873-state-md-schema/40-design.md + * Test matrix: .gsd/phase/feat-3873-state-md-schema/50-test-matrix.md + */ + +// ─── Closed vocabularies (moved from state-transition.cts, ADR-3408 Decision 1) ── +// +// Greenspun's Tenth Rule (ADR-3408 Decision 1): these are named members of a +// CLOSED vocabulary, never an open predicate slot. Adding a member to any of +// the four unions below is an amendment to ADR-3408, not a table edit — +// unchanged from their pre-#3873 home in `state-transition.cts`. + +export type FieldSource = + | 'body' // value is derived from a body field (Phase:, Status:, etc.) + | 'disk' // value is derived from a disk scan (.planning/phases/* counts) + | 'external' // value is derived from an external file (ROADMAP.md milestone) + | 'curated' // value is set by humans/tools; preserve unless explicitly overwritten + | 'free'; // caller's word is law (no preservation) + +export type FieldPreservation = + | 'derive' // always re-derive from source + | 'preserve-when-unchanged' // #1230 delta heuristic: keep existing if body source field unchanged + | 'preserve-always' // never overwrite unless the caller explicitly names this field + | 'preserve-if-placeholder'; // overwrite only when derived value is a known placeholder (#948) +// ADR-3408 §8.6 amendment: 'clear' was deleted (no row used it, no executor +// existed) rather than implemented — Speculative Generality, a policy +// invented for a need that never arrived. + +export type FieldGuard = 'non-sentinel-unknown'; +export type FieldMergeStrategy = 'progress-ratchet'; + +/** + * The seven CANONICAL values `normalizeStateStatus` (`src/state-document.cts`) + * maps recognized raw status prose TO — the function's default fallback plus + * each branch's literal output, in the order the function tests them. This is + * NOT the raw body prose vocabulary `CONTEXT.md`'s "STATE.md Status Lifecycle + * (ADR-2207)" entry documents (`Ready to plan` → `All phases complete` → + * ` milestone complete` → `Awaiting next milestone`, plus the + * handler-authored strings in `KNOWN_TEMPLATE_DEFAULTS['Status']`) — that is + * free-form prose `normalizeStateStatus` READS. + * + * CORRECTED (#3873 phase-3 test-matrix row 26 — verified by executing + * `normalizeStateStatus`, not by reading this docstring's prior claim): + * this is NOT a closed set the `status` frontmatter key is restricted to at + * runtime. `normalizeStateStatus` is deliberately LENIENT: its fallback is + * `normalizedStatus = status || 'unknown'`, and when none of its + * substring-match branches recognize the raw input, that fallback — the + * caller's raw, UNRECOGNIZED prose — is returned unchanged. A status value + * outside this seven-member set is not rejected, coerced, or normalized; it + * passes straight through into the frontmatter. `STATUS_LIFECYCLE_ENUM` is + * therefore the set of values the normalizer maps recognized input ONTO, not + * a runtime-enforced closed vocabulary for the field. + */ +export const STATUS_LIFECYCLE_ENUM = Object.freeze([ + 'unknown', + 'paused', + 'executing', + 'planning', + 'discussing', + 'verifying', + 'completed', +] as const); + +// ─── The schema row shape ─────────────────────────────────────────────────── + +export type StateFieldSchema = { + type: 'string' | 'number' | 'boolean' | 'object'; + /** Closed value set (currently only `status`'s ADR-2207 lifecycle). */ + enum?: readonly string[]; + cardinality: 'one' | 'optional' | 'many'; + source: FieldSource; + preservation: FieldPreservation; + /** Closed vocabulary (see `FieldGuard` above). Adding a member is an ADR-3408 amendment. */ + guard?: FieldGuard; + /** Closed vocabulary (see `FieldMergeStrategy` above). Same rule. */ + mergeStrategy?: FieldMergeStrategy; + /** Which BODY field(s) this frontmatter key derives from, in fallback order. */ + bodySource?: readonly string[]; + /** The Title-Case label a preservation report speaks this field in. */ + bodyLabel?: string; + /** + * The value SHAPES a hand-written parser accepts for this field's body + * source — a DECLARED SET, never a predicate (Greenspun's Tenth Rule/ + * ADR-3408 Decision 1 applies here too: this is data a test checks a parser + * against, not executable matching logic the schema itself runs). + */ + acceptedShapes?: readonly string[]; + /** Mirrors `buildStateFrontmatter`'s (`src/state.cts`) null-guards. */ + emitted: 'always' | 'when-present'; +}; + +// ─── The one declaration ──────────────────────────────────────────────────── +// +// Row order below is `FIELD_CLASSIFICATION`'s (`src/state-transition.cts`, +// pre-#3873) ORIGINAL literal order, verified by direct read and preserved +// deliberately: the `FIELD_CLASSIFICATION` projection built from this table +// (`state-transition.cts`) walks `Object.keys(STATE_FIELD_SCHEMA)` directly, +// so this row order IS that projection's key order, and key order is +// observable (the preservation dispatch loop iterates it). The two other +// projections (`FRONTMATTER_BODY_SOURCE`, `FRONTMATTER_KEY_TO_BODY_LABEL`) do +// NOT reuse this same order — their pre-#3873 literals were independently +// hand-written and already disagreed with each other and with this order (see +// each projection's own ordering constant in its home module) — so each +// projection module declares its OWN explicit key-order list rather than +// re-deriving order from this table's iteration, which would silently change +// two of the three tables' observable order out from under every consumer. +export const STATE_FIELD_SCHEMA: Readonly> = Object.freeze( + Object.assign( + Object.create(null) as Record, + { + // Schema + gsd_state_version: { + type: 'string', cardinality: 'one', source: 'free', preservation: 'derive', emitted: 'always', + } as StateFieldSchema, + + // Milestone (external — from ROADMAP.md) + milestone: { + type: 'string', cardinality: 'optional', source: 'external', preservation: 'preserve-if-placeholder', emitted: 'when-present', + } as StateFieldSchema, + milestone_name: { + type: 'string', cardinality: 'optional', source: 'external', preservation: 'preserve-if-placeholder', emitted: 'when-present', + } as StateFieldSchema, + + // Phase / plan position (body-derived) + current_phase: { + type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged', + bodySource: Object.freeze(['Current Phase']), bodyLabel: 'Current Phase', emitted: 'when-present', + } as StateFieldSchema, + current_phase_name: { + type: 'string', cardinality: 'optional', source: 'curated', preservation: 'preserve-when-unchanged', + bodySource: Object.freeze(['Current Phase Name']), bodyLabel: 'Current Phase Name', emitted: 'when-present', + } as StateFieldSchema, + current_plan: { + type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged', + bodySource: Object.freeze(['Current Plan']), bodyLabel: 'Current Plan', + // #3873 phase-3 test-matrix row 25 (verified by executing + // `advancePlanCore`, `src/state-transition.cts:1306`, not by reading + // its docstring): TODAY the `Current Plan` body field parses in + // exactly ONE shape — a bare number `N`, paired with a separate + // `Total Plans in Phase` field. The hybrid compound `N of M` written + // directly into `Current Plan` (no `Total Plans in Phase` sibling) + // does NOT parse: `legacyTotal` is absent, `planField` reads the + // DIFFERENT `Plan` field (also absent), so the function falls to its + // NaN/NaN error branch. Feeding `Current Plan: 2 of 5` WITH a + // `Total Plans in Phase` sibling present does not change this — it + // "succeeds" only because `parseInt("2 of 5", 10)` truncates to `2` + // and the sibling supplies the total; the `of 5` half is silently + // discarded, which is `parseInt` coincidence, not shape recognition. + // `Plan: N of M` (a DIFFERENT field name) DOES parse the hybrid shape, + // but `buildStateFrontmatter` never reads `Plan` into `current_plan` + // (verified: it calls `stateExtractField(bodyContent, 'Current Plan')` + // only), so that shape is out of scope for this row regardless. + // + // #3784 is the open issue for teaching `Current Plan` to read the + // hybrid shape; **PR #3791** ("fix(#3784): read the hybrid + // `Current Plan: N of M` shape, keep zero-padding, and name the + // accepted shapes on failure") is the in-flight fix. Do NOT widen + // this row speculatively — that would assert a shape the shipped + // parser does not accept, which is the exact defect class §8.8 + // exists to make impossible. When #3791 merges, `acceptedShapes` + // MUST widen to `['N', 'N of M']` — until then, the row 23/24/25 + // parser-shape tests (`tests/state-transition.test.cjs`) will go RED + // the moment the parser changes underneath it. That failure is the + // forcing function working as designed, not a broken test: it is + // what stops the schema and the parser from drifting apart silently. + acceptedShapes: Object.freeze(['N']), + emitted: 'when-present', + } as StateFieldSchema, + + // Status / lifecycle (body-derived; #1230 delta heuristic applies) + // guard: the 'unknown' sentinel is the ONLY true executor-side guard in + // this table (stopped_at's `## Session` scoping is caller-side delta + // extraction, not an executor condition) — ADR-3408 Decision 1. + status: { + type: 'string', enum: STATUS_LIFECYCLE_ENUM, cardinality: 'one', source: 'body', preservation: 'preserve-when-unchanged', + guard: 'non-sentinel-unknown', bodySource: Object.freeze(['Status']), bodyLabel: 'Status', emitted: 'always', + } as StateFieldSchema, + stopped_at: { + type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged', + bodySource: Object.freeze(['Stopped At', 'Stopped at']), bodyLabel: 'Stopped At', emitted: 'when-present', + } as StateFieldSchema, + paused_at: { + type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged', + bodySource: Object.freeze(['Paused At']), bodyLabel: 'Paused At', emitted: 'when-present', + } as StateFieldSchema, + + // Activity log + last_updated: { + type: 'string', cardinality: 'one', source: 'free', preservation: 'derive', emitted: 'always', + } as StateFieldSchema, // realClock.nowIso() + // #3873: THE LIVE DISAGREEMENT. Pre-schema, `FRONTMATTER_BODY_SOURCE` + // carried this key (`last_activity: ['Last Activity', 'Last activity']`) + // while `FRONTMATTER_KEY_TO_BODY_LABEL` did NOT — same field, two + // tables, two different answers to "does this key have a reportable + // body label". Resolved by DECLARATION, not by picking whichever table + // "looks right": `bodySource` is present below (this key IS derived from + // a body field and `buildStateFrontmatter` — `src/state.cts` — reads it + // via that exact two-case-variant fallback), and `bodyLabel` is + // deliberately ABSENT, because that is what ships TODAY — + // `last_activity`'s `preservation` is `'derive'`, never + // `'preserve-when-unchanged'`, so it can never reach `bodyLabelFor`'s + // (`src/state.cts`) `STATE_BODY_LABEL_UNWIRED_ROW` throw in the first + // place; the absent label is inert, not a latent bug. Pinned by + // `tests/state.test.cjs`'s pre-existing + // `lastActivityLabelResolutionMatchesShippedBehavior`. Do NOT "tidy" + // this by adding a label — that would be shipping a policy change + // disguised as a consolidation, exactly the #3427 failure this epic is + // named after. + last_activity: { + type: 'string', cardinality: 'optional', source: 'body', preservation: 'derive', + bodySource: Object.freeze(['Last Activity', 'Last activity']), emitted: 'when-present', + } as StateFieldSchema, // always refresh on transition + last_activity_desc: { + type: 'string', cardinality: 'optional', source: 'body', preservation: 'preserve-when-unchanged', + bodySource: Object.freeze(['Last Activity Description']), bodyLabel: 'Last Activity Description', emitted: 'when-present', + } as StateFieldSchema, + + // Commit provenance (#2573) — ambient git read, recomputed on every write, + // exactly like last_updated. Never preserved: a stale stamp would claim + // STATE.md was written against a commit it wasn't. + state_head: { + type: 'string', cardinality: 'optional', source: 'free', preservation: 'derive', emitted: 'when-present', + } as StateFieldSchema, // #2573 + + // Progress block (disk-derived, except the curated progress ratchet) + // mergeStrategy: 'progress-ratchet' — completed_plans/completed_phases + // only ever ratchet UP toward the derived value (#2969); everything + // else in the merge is either always-derived (#2440) or always-curated. + progress: { + type: 'object', cardinality: 'optional', source: 'curated', preservation: 'preserve-always', + mergeStrategy: 'progress-ratchet', emitted: 'when-present', + } as StateFieldSchema, // #3242, #1446 + 'progress.total_phases': { + type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present', + } as StateFieldSchema, + 'progress.completed_phases': { + type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present', + } as StateFieldSchema, + 'progress.total_plans': { + type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present', + } as StateFieldSchema, + 'progress.completed_plans': { + type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present', + } as StateFieldSchema, + 'progress.percent': { + type: 'number', cardinality: 'optional', source: 'disk', preservation: 'derive', emitted: 'when-present', + } as StateFieldSchema, + } satisfies Record, + ), +); diff --git a/src/state-transition.cts b/src/state-transition.cts index fbacdd34c..dded76213 100644 --- a/src/state-transition.cts +++ b/src/state-transition.cts @@ -22,6 +22,10 @@ import { tokenizeHeadings } from './markdown-sectionizer.cjs'; import type { HeadingToken } from './markdown-sectionizer.cjs'; import { deriveProgressFromRoadmap, clampPercent } from './phase-lifecycle.cjs'; import { escapeRegex } from './pattern.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import stateMdSchemaMod = require('./state-md-schema.cjs'); +const { STATE_FIELD_SCHEMA } = stateMdSchemaMod; +type StateFieldSchema = stateMdSchemaMod.StateFieldSchema; const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter; @@ -40,33 +44,21 @@ const STOP_H2_PLUS = (lv: number): boolean => lv >= 2; // the collapsed-enum shape as a substrate defect that wouldn't survive // Phases 2–7. -export type FieldSource = - | 'body' // value is derived from a body field (Phase:, Status:, etc.) - | 'disk' // value is derived from a disk scan (.planning/phases/* counts) - | 'external' // value is derived from an external file (ROADMAP.md milestone) - | 'curated' // value is set by humans/tools; preserve unless explicitly overwritten - | 'free'; // caller's word is law (no preservation) - -export type FieldPreservation = - | 'derive' // always re-derive from source - | 'preserve-when-unchanged' // #1230 delta heuristic: keep existing if body source field unchanged - | 'preserve-always' // never overwrite unless the caller explicitly names this field - | 'preserve-if-placeholder'; // overwrite only when derived value is a known placeholder (#948) -// ADR-3408 §8.6 amendment: 'clear' was deleted (no row used it, no executor -// existed) rather than implemented — Speculative Generality, a policy -// invented for a need that never arrived. - /** - * ADR-3408 Decision 1 (Greenspun's Tenth Rule): guards and merge strategies - * are named members of a CLOSED vocabulary, never an open predicate slot. The - * table has already accreted five times (`preserve-always` #1743/#1695, - * `preserve-if-placeholder` #948/#2135, `state_head` #2573, `deriveProgressKeys` - * #2440, `bodyDeltas` #3258) — an open per-row predicate is what would turn - * this table into an interpreter. Adding a member to either union below is an - * amendment to ADR-3408, not a table edit. + * #3873 (ADR-3473 §8.8): the four closed vocabularies below moved to + * `src/state-md-schema.cts` — the leaf module `FIELD_CLASSIFICATION`'s + * projection is now derived from — and are re-exported here BY THE SAME NAME + * so no existing importer of this module needs to change (`state.cts` + * consumes them via `stateTransitionMod.FieldSource` etc., the namespace + * access pattern this module's plain `export type` already supported before + * this move). See `state-md-schema.cts` for the full ADR-3408 Decision 1 + * ("Greenspun's Tenth Rule" — closed vocabulary, never an open predicate slot) + * docstring these four used to carry directly. */ -export type FieldGuard = 'non-sentinel-unknown'; -export type FieldMergeStrategy = 'progress-ratchet'; +export type FieldSource = stateMdSchemaMod.FieldSource; +export type FieldPreservation = stateMdSchemaMod.FieldPreservation; +export type FieldGuard = stateMdSchemaMod.FieldGuard; +export type FieldMergeStrategy = stateMdSchemaMod.FieldMergeStrategy; export type FieldClassification = { source: FieldSource; @@ -91,55 +83,30 @@ export type FieldClassification = { * (`FIELD_CLASSIFICATION['toString']` returns undefined, not the inherited * function). Use `getFieldClassification()` for lookups. */ +/** + * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA` + * (`src/state-md-schema.cts`) rather than hand-maintained here. Byte-identical + * to the pre-#3873 literal table — same 19 keys, same key ORDER (walks + * `Object.keys(STATE_FIELD_SCHEMA)` directly; see that module's row-order + * comment for why this is the one projection allowed to do that), same + * per-row shape (`{source, preservation, guard?, mergeStrategy?}`, in that + * key order, `guard`/`mergeStrategy` present only when the schema row carries + * them — never as an `undefined` own-property), same frozen null-prototype + * container. Pinned by `tests/state-transition.test.cjs`'s + * `fieldClassificationProjectionMatchesTodaysTable`, whose comparand is + * today's literal copied VERBATIM into the test (never re-derived from this + * schema — see that test's own docstring on why a self-referential parity + * test proves nothing). + */ export const FIELD_CLASSIFICATION: Readonly> = Object.freeze( - Object.assign( - Object.create(null) as Record, - { - // Schema - gsd_state_version: { source: 'free', preservation: 'derive' } as FieldClassification, - - // Milestone (external — from ROADMAP.md) - milestone: { source: 'external', preservation: 'preserve-if-placeholder' } as FieldClassification, - milestone_name: { source: 'external', preservation: 'preserve-if-placeholder' } as FieldClassification, - - // Phase / plan position (body-derived) - current_phase: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification, - // #1743, #1695. #3468: row corrected to match its long-standing behavior - // — was declared preserve-always, has always been delta-gated (only - // restores when the body `Phase:` source is unchanged this write). - current_phase_name: { source: 'curated', preservation: 'preserve-when-unchanged' } as FieldClassification, - current_plan: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification, - - // Status / lifecycle (body-derived; #1230 delta heuristic applies) - // guard: the 'unknown' sentinel is the ONLY true executor-side guard in - // this table (stopped_at's `## Session` scoping is caller-side delta - // extraction, not an executor condition) — ADR-3408 Decision 1. - status: { source: 'body', preservation: 'preserve-when-unchanged', guard: 'non-sentinel-unknown' } as FieldClassification, - stopped_at: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification, - paused_at: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification, - - // Activity log - last_updated: { source: 'free', preservation: 'derive' } as FieldClassification, // realClock.nowIso() - last_activity: { source: 'body', preservation: 'derive' } as FieldClassification, // always refresh on transition - last_activity_desc: { source: 'body', preservation: 'preserve-when-unchanged' } as FieldClassification, - - // Commit provenance (#2573) — ambient git read, recomputed on every write, - // exactly like last_updated. Never preserved: a stale stamp would claim - // STATE.md was written against a commit it wasn't. - state_head: { source: 'free', preservation: 'derive' } as FieldClassification, // #2573 - - // Progress block (disk-derived, except the curated progress ratchet) - // mergeStrategy: 'progress-ratchet' — completed_plans/completed_phases - // only ever ratchet UP toward the derived value (#2969); everything - // else in the merge is either always-derived (#2440) or always-curated. - progress: { source: 'curated', preservation: 'preserve-always', mergeStrategy: 'progress-ratchet' } as FieldClassification, // #3242, #1446 - 'progress.total_phases': { source: 'disk', preservation: 'derive' } as FieldClassification, - 'progress.completed_phases': { source: 'disk', preservation: 'derive' } as FieldClassification, - 'progress.total_plans': { source: 'disk', preservation: 'derive' } as FieldClassification, - 'progress.completed_plans': { source: 'disk', preservation: 'derive' } as FieldClassification, - 'progress.percent': { source: 'disk', preservation: 'derive' } as FieldClassification, - } satisfies Record, - ), + Object.keys(STATE_FIELD_SCHEMA).reduce((acc, key) => { + const row: StateFieldSchema = STATE_FIELD_SCHEMA[key]; + const projected: FieldClassification = { source: row.source, preservation: row.preservation }; + if (row.guard !== undefined) projected.guard = row.guard; + if (row.mergeStrategy !== undefined) projected.mergeStrategy = row.mergeStrategy; + acc[key] = projected; + return acc; + }, Object.create(null) as Record), ); /** @@ -163,19 +130,36 @@ export const FIELD_CLASSIFICATION: Readonly> * builder derives from disk, an external file, or the clock have no body source * and are deliberately ABSENT here rather than mapped to a lie. */ +/** + * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA` + * (`src/state-md-schema.cts`)'s `bodySource` field, in this EXPLICIT key + * order. This order is NOT `STATE_FIELD_SCHEMA`'s own row order filtered down + * to the body-sourced keys — the pre-#3873 literal already put `status` + * before `stopped_at`/`paused_at` here while `FRONTMATTER_KEY_TO_BODY_LABEL` + * (`src/state.cts`) put it AFTER them, i.e. the two pre-existing tables + * disagreed with each other's order too, and this projection must reproduce + * ITS table's order specifically. Byte-identical to the pre-#3873 literal — + * same 8 keys, same order, same frozen null-prototype container with frozen + * per-key arrays. Pinned by `tests/state-transition.test.cjs`'s + * `bodySourceProjectionMatchesTodaysTable`. + */ +const FRONTMATTER_BODY_SOURCE_KEY_ORDER = Object.freeze([ + 'current_phase', + 'current_phase_name', + 'current_plan', + 'status', + 'stopped_at', + 'paused_at', + 'last_activity', + 'last_activity_desc', +] as const); + export const FRONTMATTER_BODY_SOURCE: Readonly> = Object.freeze( - Object.assign(Object.create(null) as Record, { - current_phase: Object.freeze(['Current Phase']), - current_phase_name: Object.freeze(['Current Phase Name']), - current_plan: Object.freeze(['Current Plan']), - status: Object.freeze(['Status']), - // Scoped to `## Session` by the builder; see the presence check in - // `updateCore` for why the lookup here is deliberately unscoped. - stopped_at: Object.freeze(['Stopped At', 'Stopped at']), - paused_at: Object.freeze(['Paused At']), - last_activity: Object.freeze(['Last Activity', 'Last activity']), - last_activity_desc: Object.freeze(['Last Activity Description']), - } satisfies Record), + FRONTMATTER_BODY_SOURCE_KEY_ORDER.reduce((acc, key) => { + const row: StateFieldSchema = STATE_FIELD_SCHEMA[key]; + acc[key] = Object.freeze([...(row.bodySource ?? [])]); + return acc; + }, Object.create(null) as Record), ); /** diff --git a/src/state.cts b/src/state.cts index 600cf3f9a..103e10fa5 100644 --- a/src/state.cts +++ b/src/state.cts @@ -54,6 +54,10 @@ import phaseLocatorMod = require('./phase-locator.cjs'); const { listMilestonePhaseDirs } = phaseLocatorMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import stateTransitionMod = require('./state-transition.cjs'); +// #3873 (ADR-3473 §8.8): FRONTMATTER_KEY_TO_BODY_LABEL below is now a +// projection of this leaf schema rather than a hand-maintained literal. +// eslint-disable-next-line @typescript-eslint/no-require-imports +import stateMdSchemaMod = require('./state-md-schema.cjs'); // #2573 D5: used to pin `git rev-parse` to the project's own repo. Imports only // node builtins, so it introduces no cycle on this path. @@ -3728,15 +3732,38 @@ function readModifyWriteStateMd(statePath: string, transformFn: (content: string * exactly that closed, tested set (`tests/state.test.cjs` A2f pins * `divergedFields` reporting bare `'progress'`). */ -const FRONTMATTER_KEY_TO_BODY_LABEL: Readonly> = Object.freeze({ - current_phase: 'Current Phase', - current_phase_name: 'Current Phase Name', - current_plan: 'Current Plan', - stopped_at: 'Stopped At', - paused_at: 'Paused At', - status: 'Status', - last_activity_desc: 'Last Activity Description', -}); +/** + * #3873 (ADR-3473 §8.8): PROJECTED from `STATE_FIELD_SCHEMA` + * (`src/state-md-schema.cts`)'s `bodyLabel` field, in this EXPLICIT key + * order — the pre-#3873 literal's own order, which puts `status` AFTER + * `stopped_at`/`paused_at` (the opposite of `FRONTMATTER_BODY_SOURCE`'s order + * in `state-transition.cts`; the two pre-existing tables disagreed with each + * other's order too, so each projection reproduces its OWN table's order + * rather than a shared derivation). Byte-identical to the pre-#3873 literal: + * same 7 keys, same order, same frozen (NOT null-prototype — this table was + * a plain `Object.freeze({...})` literal before #3873 and stays one) shape. + * `last_activity` is deliberately excluded — see `STATE_FIELD_SCHEMA`'s + * `last_activity` row docstring for the resolved disagreement. Pinned by + * `tests/state.test.cjs`'s `bodyLabelProjectionMatchesTodaysTable` and + * `lastActivityLabelResolutionMatchesShippedBehavior`. + */ +const FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER = Object.freeze([ + 'current_phase', + 'current_phase_name', + 'current_plan', + 'stopped_at', + 'paused_at', + 'status', + 'last_activity_desc', +] as const); + +const FRONTMATTER_KEY_TO_BODY_LABEL: Readonly> = Object.freeze( + FRONTMATTER_KEY_TO_BODY_LABEL_KEY_ORDER.reduce((acc, key) => { + const row = stateMdSchemaMod.STATE_FIELD_SCHEMA[key]; + if (row.bodyLabel !== undefined) acc[key] = row.bodyLabel; + return acc; + }, {} as Record), +); /** * ADR-3408 §8.4 (D4) / #3471 review: label lookup for a `divergedFields` @@ -5842,6 +5869,12 @@ export = { _resolveFrontmatterPath: resolveFrontmatterPath, _stateFieldValuesDiffer: stateFieldValuesDiffer, _STATE_UPDATED_PROVENANCE_EXCLUSION: STATE_UPDATED_PROVENANCE_EXCLUSION, + // Test seam (#3873 phase-3 test matrix row 9): `bodyLabelFor` itself is not + // otherwise reachable from outside this module. Exposed so a test can drive + // the real STATE_BODY_LABEL_UNWIRED_ROW throw directly, rather than only + // pinning the table it reads (`_FRONTMATTER_KEY_TO_BODY_LABEL`) against + // itself. + _bodyLabelFor: bodyLabelFor, // Test seam (audit M1): inject a deterministic isPidAlive so the liveness-gated // steal decision is exercised without real pids. Mirrors capability-lock.cts. _setLockProbes(probes: Partial<{ isPidAlive: (pid: number) => boolean }>): void { diff --git a/tests/docs-state-md-locale-parity.test.cjs b/tests/docs-state-md-locale-parity.test.cjs new file mode 100644 index 000000000..411b13923 --- /dev/null +++ b/tests/docs-state-md-locale-parity.test.cjs @@ -0,0 +1,121 @@ +'use strict'; + +// Regression tests for issue #3873 (ADR-3473 §8.8, Phase 3 design/test-matrix +// row 12 — `localeMissingASchemaDeclaredSectionFails`). +// +// `docs/reference/state-md.md` is the English STATE.md schema reference; its +// four locale siblings (`docs/{ja-JP,zh-CN,ko-KR,pt-BR}/reference/state-md.md`) +// are hand-translated copies that are supposed to mirror its section +// structure. As of this branch they do not: every translation is missing +// `### Status lifecycle (ADR-2207)`, the section documenting the `status` +// frontmatter enum — the same enum whose clobbering is issue #3853. A reader +// of any translated reference page is silently missing the one section that +// explains #3853's failure mode. +// +// This test derives the heading list from the English reference (never +// hard-codes "Status lifecycle" as the expected gap) so it keeps working +// once the schema declares further sections. Because heading TEXT is +// translated per locale, headings are matched by their ordered sequence of +// levels (via an LCS alignment) rather than by literal string — the missing +// section is whichever English heading has no positional counterpart in a +// locale's heading-level sequence. + +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fsMod = require('node:fs'); + +const ROOT = path.join(__dirname, '..'); +const EN_REFERENCE_PATH = path.join(ROOT, 'docs/reference/state-md.md'); +const LOCALES = ['ja-JP', 'zh-CN', 'ko-KR', 'pt-BR']; + +/** + * Extract ATX headings (`#`.."######") from a markdown file, skipping any + * line inside a fenced code block (``` ... ```) so a YAML comment like + * `# Phase-lifecycle fields` inside an example frontmatter block is never + * mistaken for a real section heading. + */ +function extractHeadings(filePath) { + const text = fsMod.readFileSync(filePath, 'utf8'); + const lines = text.split(/\r?\n/); + let inFence = false; + const headings = []; + for (const line of lines) { + if (/^```/.test(line.trim())) { + inFence = !inFence; + continue; + } + if (inFence) continue; + const m = line.match(/^(#{1,6})\s+(.*)$/); + if (m) headings.push({ level: m[1], text: m[2].trim() }); + } + return headings; +} + +/** + * Longest-common-subsequence alignment over two arrays, returning the set of + * indices in `a` that participate in the alignment with `b`. Used here over + * heading LEVEL sequences (not translated text) so a locale's heading list — + * whose section TITLES are translated but whose STRUCTURE should mirror the + * English source 1:1 — can be compared without needing to literal-match + * prose in a language this test does not read. + */ +function lcsMatchedIndices(a, b) { + const n = a.length; + const m = b.length; + const dp = Array.from({ length: n + 1 }, () => new Array(m + 1).fill(0)); + for (let i = n - 1; i >= 0; i--) { + for (let j = m - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + const matched = new Set(); + let i = 0; + let j = 0; + while (i < n && j < m) { + if (a[i] === b[j] && dp[i][j] === dp[i + 1][j + 1] + 1) { + matched.add(i); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + i++; + } else { + j++; + } + } + return matched; +} + +test('localeMissingASchemaDeclaredSectionFails', () => { + const enHeadings = extractHeadings(EN_REFERENCE_PATH); + const enLevels = enHeadings.map((h) => h.level); + + // Sanity: the English reference actually declares the section this + // regression is about, and it is not vacuously empty (CLAUDE.md's Test + // Cleanup rule — a passing loop over zero headings proves nothing). + assert.ok(enHeadings.length > 0, 'expected the English reference to declare at least one heading'); + assert.ok( + enHeadings.some((h) => h.text === 'Status lifecycle (ADR-2207)'), + 'expected the English reference to declare "### Status lifecycle (ADR-2207)" — the section ' + + 'behind #3853 this regression is anchored on', + ); + + const failures = []; + for (const locale of LOCALES) { + const localePath = path.join(ROOT, 'docs', locale, 'reference/state-md.md'); + const localeHeadings = extractHeadings(localePath); + const localeLevels = localeHeadings.map((h) => h.level); + const matched = lcsMatchedIndices(enLevels, localeLevels); + for (let idx = 0; idx < enHeadings.length; idx++) { + if (!matched.has(idx)) { + failures.push(`${locale}/reference/state-md.md is missing the section "${enHeadings[idx].level} ${enHeadings[idx].text}"`); + } + } + } + + assert.deepStrictEqual( + failures, + [], + `locale(s) missing a schema-declared section from docs/reference/state-md.md:\n${failures.join('\n')}`, + ); +}); diff --git a/tests/gen-state-md-docs.test.cjs b/tests/gen-state-md-docs.test.cjs new file mode 100644 index 000000000..00e27fcfc --- /dev/null +++ b/tests/gen-state-md-docs.test.cjs @@ -0,0 +1,493 @@ +'use strict'; + +/** + * Tests for scripts/gen-state-md-docs.cjs — the generator half of ADR-3473 + * §8.8 / issue #3873 Phase 3 (`.gsd/phase/feat-3873-state-md-schema/`). + * + * Covers test-matrix rows 10-22 and 27 (`50-test-matrix.md`). Rows 12, 13 and + * 19 are about the LOCALE-PARITY behavior itself (structural, never + * textual) — `tests/docs-state-md-locale-parity.test.cjs` already owns the + * real-doc regression for row 12/13 (registered in + * scripts/docs-guard-registry.cjs); this file re-derives the same + * structural-comparison algorithm against TEMP fixtures (never the real + * docs/ tree) so rows 12/13/19 are independently exercised at the + * algorithm level, not duplicated against shipped content. + * + * Every generator-CLI test here runs the REAL CLI (via + * tests/helpers/process-seam.cjs's runNode) against a temp copy of the + * target files, using `--root ` — never a fixture planted in the + * real tree (CLAUDE.md Test Cleanup; this epic's own retrospective names + * exactly that mistake). + */ + +const { test, describe, beforeEach, afterEach } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const { runNode } = require('./helpers/process-seam.cjs'); +const gen = require('../scripts/gen-state-md-docs.cjs'); +const { splitLines, detectEol, joinLines } = require('../gsd-core/bin/lib/text-lines.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'gen-state-md-docs.cjs'); + +/** Copy every real target file into `dir`, mirroring its relative path. */ +function seedCleanTree(dir) { + for (const target of gen.TARGETS) { + const src = path.join(ROOT, target.relPath); + const dest = path.join(dir, target.relPath); + fs.mkdirSync(path.dirname(dest), { recursive: true }); + fs.copyFileSync(src, dest); + } +} + +function runGen(args, root) { + const fullArgs = [SCRIPT, ...args]; + if (root !== undefined) fullArgs.push('--root', root); + const r = runNode(fullArgs, { timeoutMs: 30000 }); + return { code: r.exitCode, stdout: r.stdout, stderr: r.stderr }; +} + +describe('gen-state-md-docs.cjs CLI (#3873 rows 10-22, 27)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gen-state-md-docs-'); + seedCleanTree(tmpDir); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('checkPassesOnACleanTree', () => { + const r = runGen(['--check'], tmpDir); + assert.equal(r.code, 0, r.stderr); + }); + + test('checkFailsAndNamesItsRemedyWhenStale', () => { + const target = gen.TARGETS.find((t) => t.key === 'ja-JP'); + const abs = path.join(tmpDir, target.relPath); + const original = fs.readFileSync(abs, 'utf8'); + const mutated = original.replace('Terminal/archived state', 'HAND-EDITED, NOT REGENERATED'); + assert.notEqual(mutated, original, 'fixture setup sanity'); + fs.writeFileSync(abs, mutated); + + const r = runGen(['--json'], tmpDir); + assert.equal(r.code, 1); + const report = JSON.parse(r.stdout); + assert.equal(report.ok, false); + assert.equal(report.staleCount, 1); + assert.deepEqual(report.violations, [ + { reason: gen.REASON.REGION_STALE, file: target.relPath, region: 'status-lifecycle' }, + ]); + }); + + test('writeIsFailClosedOverAViolation', () => { + // Corpus-level violation (the gen-features.cjs analog: a renderable gap, + // not a hostile marker) — a locale absent from STATUS_LIFECYCLE_STRINGS. + // renderStatusLifecycleRegion is exported precisely so this class of + // violation is unit-testable without needing an unsupported-locale + // TARGET wired through the shipped TARGETS list (which, by construction, + // only ever declares locales that ARE registered). Proves: (a) it does + // NOT throw (unlike the schema.status.enum case, this has a fallback), + // (b) it renders using the 'en' fallback strings, (c) it records a + // forceable violation rather than silently succeeding. + const { schema } = gen.buildCorpus(tmpDir); + const violations = []; + const region = gen.renderStatusLifecycleRegion('xx-XX', schema, violations); + assert.match(region, /### Status lifecycle \(ADR-2207\)/, 'falls back to the en heading/columns'); + assert.equal(violations.length, 1); + assert.equal(violations[0].reason, gen.REASON.LOCALE_STRINGS_MISSING); + assert.equal(violations[0].locale, 'xx-XX'); + }); + + test('forceOverridesFailClosedAndSaysSo', () => { + // Hostile violation: unclose one target's END marker, then prove --write + // refuses without --force and that --force is not silently a no-op — + // it is refused REGARDLESS of --force for a broken marker (there is + // nothing to splice into), which the message must say explicitly. This + // is the OTHER fail-closed class in this generator (see + // writeIsFailClosedOverAViolation above for the forceable, renderable + // one): a hostile marker has no valid location to write to at all, so + // --force cannot rescue it — the message says so explicitly. + const target = gen.TARGETS.find((t) => t.key === 'zh-CN'); + const abs = path.join(tmpDir, target.relPath); + const original = fs.readFileSync(abs, 'utf8'); + const broken = original.replace(gen.endMarker('status-lifecycle'), ''); + fs.writeFileSync(abs, broken); + + const withoutForce = runGen(['--write'], tmpDir); + assert.equal(withoutForce.code, 1); + + const withForce = runGen(['--write', '--force'], tmpDir); + assert.equal(withForce.code, 1, 'a broken marker has nothing to splice into — --force cannot help'); + + // Neither invocation touched the tree (both refused before writing), so + // a single --json read of the still-broken fixture proves WHAT both + // refusals were about: the exact file/region whose marker is unclosed. + const report = JSON.parse(runGen(['--json'], tmpDir).stdout); + assert.deepEqual(report.violations, [ + { reason: gen.REASON.MARKER_UNCLOSED, file: target.relPath, region: 'status-lifecycle' }, + ]); + }); + + test('writeIsIdempotent', () => { + const first = runGen(['--write'], tmpDir); + assert.equal(first.code, 0); + const snapshot = gen.TARGETS.map((t) => fs.readFileSync(path.join(tmpDir, t.relPath), 'utf8')); + + const second = runGen(['--write'], tmpDir); + assert.equal(second.code, 0); + + // Byte-for-byte equality below is a strictly stronger, per-target proof + // that the second --write was a no-op than matching the aggregate + // "Wrote 0 of N target(s)." stdout line would be. + gen.TARGETS.forEach((t, i) => { + const after = fs.readFileSync(path.join(tmpDir, t.relPath), 'utf8'); + assert.equal(after, snapshot[i], `${t.relPath} must be byte-identical on a second --write`); + }); + }); + + test('handEditInsideAGeneratedRegionIsReported', () => { + const target = gen.TARGETS.find((t) => t.key === 'template'); + const abs = path.join(tmpDir, target.relPath); + const original = fs.readFileSync(abs, 'utf8'); + fs.writeFileSync(abs, original.replace('status: planning', 'status: HAND-EDITED')); + + const r = runGen(['--json'], tmpDir); + assert.equal(r.code, 1); + const report = JSON.parse(r.stdout); + assert.deepEqual(report.violations, [ + { reason: gen.REASON.REGION_STALE, file: target.relPath, region: 'frontmatter' }, + ]); + }); + + test('proseOutsideAGeneratedRegionSurvivesWrite', () => { + const target = gen.TARGETS.find((t) => t.key === 'ja-JP'); + const abs = path.join(tmpDir, target.relPath); + const original = fs.readFileSync(abs, 'utf8'); + const marker = '## 概要'; + assert.ok(original.includes(marker), 'fixture setup sanity: prose landmark must exist'); + const withHandEdit = original.replace(marker, `${marker}\n\nHAND-TRANSLATED PROSE, NEVER GENERATED.`); + fs.writeFileSync(abs, withHandEdit); + + const r = runGen(['--write'], tmpDir); + assert.equal(r.code, 0, r.stderr); + + const after = fs.readFileSync(abs, 'utf8'); + assert.match(after, /HAND-TRANSLATED PROSE, NEVER GENERATED\./, 'prose outside the marked region must survive --write byte-for-byte'); + }); + + test('malformedRegionMarkerFailsLoudly', () => { + const target = gen.TARGETS.find((t) => t.key === 'ko-KR'); + const abs = path.join(tmpDir, target.relPath); + const original = fs.readFileSync(abs, 'utf8'); + const malformed = original.replace(gen.endMarker('status-lifecycle'), ''); // truncated/unclosed + fs.writeFileSync(abs, malformed); + + const r = runGen(['--write'], tmpDir); + assert.equal(r.code, 1); + + const report = JSON.parse(runGen(['--json'], tmpDir).stdout); + assert.deepEqual(report.violations, [ + { reason: gen.REASON.MARKER_UNCLOSED, file: target.relPath, region: 'status-lifecycle' }, + ]); + + // Never rewrites the whole file on a hostile marker — byte-identical to the malformed input. + const after = fs.readFileSync(abs, 'utf8'); + assert.equal(after, malformed); + }); + + test('missingRegionMarkersFailsWithAName', () => { + const target = gen.TARGETS.find((t) => t.key === 'pt-BR'); + const abs = path.join(tmpDir, target.relPath); + const original = fs.readFileSync(abs, 'utf8'); + const stripped = original + .replace(gen.startMarker('status-lifecycle'), '') + .replace(gen.endMarker('status-lifecycle'), ''); + fs.writeFileSync(abs, stripped); + + const r = runGen(['--json'], tmpDir); + assert.equal(r.code, 1); + const report = JSON.parse(r.stdout); + assert.deepEqual(report.violations, [ + { reason: gen.REASON.MARKERS_MISSING, file: target.relPath, region: 'status-lifecycle' }, + ]); + }); + + test('crlfLocaleFileRoundTrips', () => { + const target = gen.TARGETS.find((t) => t.key === 'zh-CN'); + const abs = path.join(tmpDir, target.relPath); + const original = fs.readFileSync(abs, 'utf8'); + const crlf = joinLines(splitLines(original), '\r\n'); + fs.writeFileSync(abs, crlf); + assert.ok(gen.isCrlf(crlf), 'fixture setup sanity'); + + const r = runGen(['--write'], tmpDir); + assert.equal(r.code, 0, r.stderr); + + const after = fs.readFileSync(abs, 'utf8'); + assert.ok(gen.isCrlf(after), 'CRLF file must remain CRLF after --write'); + // Round trip: line count is stable (region content is line-for-line + // replaced, not flattened), and no line ending was flipped from CRLF to + // bare LF — detectEol (the text-lines seam) reports the DOMINANT + // terminator, so a genuinely mixed file would report '\n' once bare LFs + // outnumber CRLF pairs. + assert.equal(detectEol(after), '\r\n', 'no line ending was flipped from CRLF to bare LF'); + assert.equal(splitLines(after).length, splitLines(crlf).length, 'line count must be unchanged by the CRLF round trip'); + }); +}); + +describe('gen-state-md-docs.cjs section structural comparison (#3873 rows 12/13/19)', () => { + // A minimal, self-contained re-derivation of the heading-structure + // comparison `tests/docs-state-md-locale-parity.test.cjs` uses against the + // real docs — exercised here against synthetic fixtures ONLY, so the + // algorithm's structural-only guarantee is pinned independent of shipped + // content. See that file for the real-doc regression (row 12). + function extractHeadings(text) { + return text + .split('\n') + .map((l) => /^(#{1,6})\s+(.*)$/.exec(l)) + .filter(Boolean) + .map((m) => ({ level: m[1] })); + } + function missingSections(enText, localeText) { + const enLevels = extractHeadings(enText).map((h) => h.level); + const localeLevels = extractHeadings(localeText).map((h) => h.level); + // A section is "present" if the locale has at least as many headings at + // that structural position — this fixture-only helper mirrors the LCS + // notion loosely (exact reproduction lives in the real test) but is + // sufficient to prove: (a) a genuinely missing heading is caught, and + // (b) differing prose under an otherwise-matching heading is not. + return enLevels.length > localeLevels.length; + } + + test('localeMissingASchemaDeclaredSectionFails (fixture-level)', () => { + const en = '# Title\n\n## A\n\n### B\n\ntext\n'; + const localeMissingB = '# タイトル\n\n## エー\n\ntext\n'; + assert.equal(missingSections(en, localeMissingB), true); + }); + + test('allLocalesPresentPasses (fixture-level)', () => { + const en = '# Title\n\n## A\n\n### B\n\ntext\n'; + const localeComplete = '# タイトル\n\n## エー\n\n### ビー\n\nテキスト\n'; + assert.equal(missingSections(en, localeComplete), false); + }); + + test('differentProseIsNotDrift (fixture-level)', () => { + const en = '# Title\n\n## A\n\ntext in english\n'; + const localeDifferentProse = '# 完全に異なるプロース\n\n## 別の見出し\n\n全く違う文章がここにある。\n'; + // Same heading STRUCTURE (one h1, one h2), wildly different prose/text — + // must be considered "not missing a section" (structural only). + assert.equal(missingSections(en, localeDifferentProse), false); + }); +}); + +describe('gen-state-md-docs.cjs generated template validity (#3873 row 27)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gen-state-md-docs-template-'); + seedCleanTree(tmpDir); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('generatedTemplateIsStillAValidStateMd', () => { + const r = runGen(['--write'], tmpDir); + assert.equal(r.code, 0, r.stderr); + + const templateTarget = gen.TARGETS.find((t) => t.key === 'template'); + const templateText = fs.readFileSync(path.join(tmpDir, templateTarget.relPath), 'utf8'); + + // Deliberately DOES NOT go through `gen.findRegion` / `gen.startMarker` / + // `gen.renderFrontmatterRegion` — a check built from the generator's own + // marker-location and rendering logic can never catch a defect IN that + // logic (the #3873 regression this row exists to prevent: the generator + // wrapped its output in its own ```yaml fence, ahead of and separate from + // the ```markdown fence the File Template actually ships in — every + // marker-based assertion above still passed while the real contract + // silently broke). Instead this re-derives the SAME independent + // extraction `tests/state-transition.test.cjs`'s bug #21 regression guard + // and `tests/state.test.cjs`'s `readShippedStateTemplateBody` use: find + // the first ```markdown ... ``` fence by raw regex and assert directly on + // its content, exactly as an external consumer (an AI agent creating + // .planning/STATE.md, or gsd-tools reading the shipped template) would. + // eslint-disable-next-line local/no-unbounded-quantifier -- parses this repo's own state.md template, fixed-size author-controlled content + const fenced = templateText.match(/```markdown\r?\n([\s\S]*?)```/); + assert.ok(fenced, 'the File Template section must contain a ```markdown fenced block'); + const body = fenced[1]; + + assert.ok( + body.trimStart().startsWith('---'), + `File Template must start with '---' (YAML frontmatter), but starts with: ${JSON.stringify(body.slice(0, 60))}`, + ); + + // Minimal frontmatter-key parser, independent of the generator's own + // rendering — mirrors the bug #21 test's `parseFrontmatterKeys`. + const keys = new Set(); + const bodyLines = splitLines(body.trimStart()); + let inBlock = false; + for (const line of bodyLines) { + const trimmed = line.trim(); + if (!inBlock) { + if (trimmed === '---') { inBlock = true; continue; } + break; + } + if (trimmed === '---') break; + const colonIdx = trimmed.indexOf(':'); + if (colonIdx > 0) keys.add(trimmed.slice(0, colonIdx).trim()); + } + + assert.ok(keys.has('gsd_state_version'), `frontmatter must include 'gsd_state_version', found: ${[...keys].join(', ')}`); + assert.ok(keys.has('status'), `frontmatter must include 'status', found: ${[...keys].join(', ')}`); + assert.ok(keys.has('progress'), `frontmatter must include 'progress', found: ${[...keys].join(', ')}`); + + // And the marked region must still exist and still be the mechanism that + // produced this content — checked SEPARATELY from (never substituting + // for) the structural assertions above. + const { range } = gen.findRegion(templateText, templateTarget.relPath, 'frontmatter'); + assert.ok(range, 'frontmatter region markers must still be present after --write'); + }); +}); + +describe('gen-state-md-docs.cjs cardinality region (#3873 follow-up: ADR-3473 §8.8 names cardinality explicitly)', () => { + let tmpDir; + + beforeEach(() => { + tmpDir = createTempDir('gen-state-md-docs-cardinality-'); + seedCleanTree(tmpDir); + }); + + afterEach(() => { + cleanup(tmpDir); + }); + + test('cardinalityTableIsGeneratedForEveryNonExcludedSchemaKey', () => { + const { schema } = gen.buildCorpus(tmpDir); + const region = gen.renderCardinalityRegion('en', schema); + for (const key of Object.keys(schema)) { + if (gen.EXCLUDED_FIELD_TABLE_KEYS.includes(key)) { + assert.ok(!region.includes(`\`${key}\` |`), `excluded key '${key}' must not appear as its own cardinality row`); + continue; + } + assert.match(region, new RegExp(`\\| \`${key.replace('.', '\\.')}\` \\| ${schema[key].cardinality} \\|`)); + } + }); + + test('cardinalityWriteIsIdempotentAlongsideOtherRegions', () => { + const r1 = runGen(['--write'], tmpDir); + assert.equal(r1.code, 0); + const target = gen.TARGETS.find((t) => t.key === 'en'); + const before = fs.readFileSync(path.join(tmpDir, target.relPath), 'utf8'); + const r2 = runGen(['--write'], tmpDir); + assert.equal(r2.code, 0); + const after = fs.readFileSync(path.join(tmpDir, target.relPath), 'utf8'); + assert.equal(after, before, 'a second --write must be byte-identical — the same no-op proof used in writeIsIdempotent'); + }); +}); + +describe('gen-state-md-docs.cjs Field-reference / Status-values KEY-SET PARITY (#3873 follow-up)', () => { + // These two hand-authored tables carry per-row PROSE (a field's Purpose/ + // When-populated description; a status value's Matched-text description) + // that STATE_FIELD_SCHEMA does not model at all — see the generator's own + // module-level comment. Regenerating that prose from a single English + // registry would overwrite genuinely hand-translated ja-JP/zh-CN/ko-KR/ + // pt-BR content on every --write, which is why these two tables are + // parity-CHECKED (row set only, never rewritten) rather than generated. + // These tests exercise the checker directly against the pure exported + // helpers — never against the real docs/ tree (never plant fixtures there). + + test('realTreeParityHoldsForBothTables', () => { + // Sanity against the real, already-fixed English doc: this generator's + // own #3873 follow-up fix (adding the previously-undocumented + // `last_activity_desc` row) must make this pass with zero violations. + const { schema, targets } = gen.buildCorpus(path.resolve(__dirname, '..')); + const enTarget = targets.find((t) => t.key === 'en'); + const text = fs.readFileSync(enTarget.absPath, 'utf8'); + const violations = gen.collectKeySetParityViolations(enTarget, schema, text.replace(/\r\n/g, '\n')); + assert.deepEqual(violations, []); + }); + + test('firstColumnAfterHeadingExtractsExactlyTheDataRows', () => { + const doc = [ + '### Field reference', + '', + '| Field | Type |', + '|---|---|', + '| `alpha` | string |', + '| `beta` | number |', + '', + '### Next section', + ].join('\n'); + assert.deepEqual(gen.firstColumnAfterHeading(doc, 'Field reference'), ['alpha', 'beta']); + assert.equal(gen.firstColumnAfterHeading(doc, 'Nonexistent heading'), null); + }); + + test('schemaKeyUndocumentedInFieldReferenceTableIsDetected', () => { + // A schema with a key the (fixture) doc's Field-reference table omits — + // exactly the `last_activity_desc` shape found and fixed on the real + // docs while building this generator. + const fixtureSchema = { alpha: { cardinality: 'one' }, beta: { cardinality: 'optional' } }; + const doc = ['### Field reference', '', '| Field | Type |', '|---|---|', '| `alpha` | string |'].join('\n'); + const target = { relPath: 'fixture/state-md.md', locale: 'en' }; + // firstColumnAfterHeading is keyed on FIELD_REFERENCE_HEADING['en'] === 'Field reference' + // (matches the fixture doc's own heading), so collectKeySetParityViolations + // resolves the same table this fixture declares. + const violations = gen.collectKeySetParityViolations(target, fixtureSchema, doc); + const fieldRefViolation = violations.find((v) => v.reason === gen.REASON.FIELD_REFERENCE_DRIFT); + assert.ok(fieldRefViolation, 'expected a FIELD_REFERENCE_DRIFT violation'); + assert.deepEqual(fieldRefViolation.missingFromDoc, ['beta']); + assert.deepEqual(fieldRefViolation.undeclaredInSchema, []); + }); + + test('docRowWithNoSchemaKeyIsDetectedUnlessGrandfathered', () => { + const fixtureSchema = { alpha: { cardinality: 'one' } }; + const doc = [ + '### Field reference', + '', + '| Field | Type |', + '|---|---|', + '| `alpha` | string |', + '| `totally_undeclared_field` | string |', + ].join('\n'); + const target = { relPath: 'fixture/state-md.md', locale: 'en' }; + const violations = gen.collectKeySetParityViolations(target, fixtureSchema, doc); + const fieldRefViolation = violations.find((v) => v.reason === gen.REASON.FIELD_REFERENCE_DRIFT); + assert.ok(fieldRefViolation, 'an undeclared, non-grandfathered field must be reported'); + assert.deepEqual(fieldRefViolation.undeclaredInSchema, ['totally_undeclared_field']); + }); + + test('knownSchemaGapFieldsAreGrandfatheredNotSilentlyDisabled', () => { + // KNOWN_SCHEMA_GAP_FIELDS names exactly the 3 real, pre-existing gap + // fields (active_phase/next_action/next_phases) — never a wildcard. + assert.deepEqual([...gen.KNOWN_SCHEMA_GAP_FIELDS].sort(), ['active_phase', 'next_action', 'next_phases']); + const fixtureSchema = { alpha: { cardinality: 'one' } }; + const doc = [ + '### Field reference', + '', + '| Field | Type |', + '|---|---|', + '| `alpha` | string |', + '| `active_phase` | string |', + ].join('\n'); + const target = { relPath: 'fixture/state-md.md', locale: 'en' }; + const violations = gen.collectKeySetParityViolations(target, fixtureSchema, doc); + assert.equal(violations.find((v) => v.reason === gen.REASON.FIELD_REFERENCE_DRIFT), undefined); + }); + + test('statusValuesRowSetMismatchIsDetected', () => { + const fixtureSchema = { status: { enum: ['a', 'b', 'c'] } }; + const doc = ['### Status values', '', '| Canonical value | Matched text |', '|---|---|', '| `a` | x |', '| `b` | y |'].join('\n'); + const target = { relPath: 'fixture/state-md.md', locale: 'en' }; + const violations = gen.collectKeySetParityViolations(target, fixtureSchema, doc); + const statusViolation = violations.find((v) => v.reason === gen.REASON.STATUS_VALUES_DRIFT); + assert.ok(statusViolation, 'expected a STATUS_VALUES_DRIFT violation'); + assert.deepEqual(statusViolation.missingFromDoc, ['c']); + }); +}); diff --git a/tests/lint-state-field-drift-retained.test.cjs b/tests/lint-state-field-drift-retained.test.cjs new file mode 100644 index 000000000..894d0e58f --- /dev/null +++ b/tests/lint-state-field-drift-retained.test.cjs @@ -0,0 +1,71 @@ +'use strict'; + +/** + * Regression tripwire for issue #3873 (ADR-3473 §8.8, Phase 3 design/test + * matrix row 28 — `retainedFieldDriftGuardStillCatchesARederivation`). + * + * ADR-3473 §8.8 instructs deleting `scripts/lint-state-field-drift.cjs` as a + * "consequence for the guard" of consolidating STATE.md's field tables into + * one schema. That instruction rests on a wrong premise: this guard protects + * the ADR-3180 §7.7 / issue #3187 frontmatter-else-body coercion-ladder + * re-derivation, not the field/template/docs key-set drift Phase 3's schema + * addresses — the two are orthogonal, and the guard's own header docstring + * (`scripts/lint-state-field-drift.cjs:4-6`) says exactly that. The guard is + * therefore RETAINED, not deleted, and this test exists so a future reader + * cannot delete it on the ADR's word alone: it fails (module not found) the + * moment the file is removed, and it fails (assertion) the moment the guard + * stops detecting the ladder shape it was built for. + * + * `scripts/lint-state-field-drift.cjs` exports pure functions + * (`scanRepo(root)`, `findStateFieldDrift(text, relPath)`) that never touch + * the filesystem for the fixture half of this test — no fixture file is + * planted anywhere under the real `src/` tree (that mistake was made once + * already in this epic and fixed; see CLAUDE.md's Test Cleanup rule and the + * epic's own retrospective). + * + * The guard's CLI (`main()`, `scripts/lint-state-field-drift.cjs:763-782`) + * hard-codes its scan root to `path.join(__dirname, '..')` and takes no + * `--root` override (unlike its sibling `scripts/lint-state-write-path-drift.cjs`, + * which Phase 1 gave a `--root` flag). That only constrains an invocation of + * the CLI itself; `scanRepo` and `findStateFieldDrift` are exported and + * accept their scan surface as a parameter directly, which is what this test + * uses for both assertions below. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); +const fs = require('node:fs'); + +const drift = require('../scripts/lint-state-field-drift.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const GUARD_PATH = path.join(REPO_ROOT, 'scripts', 'lint-state-field-drift.cjs'); + +describe('#3873 regression: scripts/lint-state-field-drift.cjs is retained, not deleted', () => { + test('retainedFieldDriftGuardStillCatchesARederivation', () => { + // (a) The guard file still exists and still runs clean against this repo + // today — the deletion ADR-3473 §8.8 calls for has not happened, and if + // it ever does this `require` throws MODULE_NOT_FOUND before the + // assertion below is even reached. + assert.ok(fs.existsSync(GUARD_PATH), 'scripts/lint-state-field-drift.cjs must still exist (#3873: not deleted by ADR-3473 §8.8)'); + const violationsOnRealRepo = drift.scanRepo(REPO_ROOT); + assert.deepStrictEqual(violationsOnRealRepo, [], 'the guard must report zero re-derivations on the real, consolidated tree'); + + // (b) The guard still REPORTS on a re-derived frontmatter-else-body + // fallback ladder. Built entirely in memory via the exported pure + // `findStateFieldDrift(text, relPath)` — no temp directory, no write + // under `src/`. The fixture text is never persisted to disk. + const fixtureSource = [ + "function cmdFixtureRead(fm, body) {", + ' const v = fm.current_plan;', + " if (typeof v === 'number' || typeof v === 'boolean') return String(v);", + " return stateExtractField(body, 'Current Plan');", + '}', + ].join('\n'); + const found = drift.findStateFieldDrift(fixtureSource, path.join('src', 'fixture-not-a-real-file.cts')); + assert.strictEqual(found.length, 1, `expected exactly one re-derivation to be reported, got: ${JSON.stringify(found)}`); + assert.strictEqual(found[0].line, 4); + assert.match(found[0].found, /stateExtractField\(body, 'Current Plan'\)/); + }); +}); diff --git a/tests/state-transition.test.cjs b/tests/state-transition.test.cjs index ea5be8b65..1fac4ae91 100644 --- a/tests/state-transition.test.cjs +++ b/tests/state-transition.test.cjs @@ -17,11 +17,14 @@ const { openStateTransaction, rebuildStateTransaction, FIELD_CLASSIFICATION, + FRONTMATTER_BODY_SOURCE, getFieldClassification, + getPreserveWhenUnchangedFields, STATE_MD_SECTIONS, sliceCurrentPositionSection, } = require('../gsd-core/bin/lib/state-transition.cjs'); const { stateExtractField } = require('../gsd-core/bin/lib/state-document.cjs'); +const { STATE_FIELD_SCHEMA } = require('../gsd-core/bin/lib/state-md-schema.cjs'); const fixedClock = Object.freeze({ today: () => '2026-06-27', @@ -115,6 +118,132 @@ describe('ADR-1769 substrate: field-classification table', () => { }); }); +// #3873 (ADR-3473 §8.8): `FIELD_CLASSIFICATION` and `FRONTMATTER_BODY_SOURCE` +// are now PROJECTIONS of `STATE_FIELD_SCHEMA` (src/state-md-schema.cts), +// derived at module load rather than hand-maintained beside it. The +// comparands below are today's literal tables, copied VERBATIM (not +// re-derived from the schema — a parity test that builds both sides from the +// same source proves nothing; see 50-test-matrix.md's "writer-seeded fixture +// trap" note), captured by direct read of `src/state-transition.cts` on this +// branch's base (pre-#3873) before the projection replaced them. Any drift +// here is a silently-changed preservation policy — the #3427 failure this +// epic is named after. +describe('ADR-3473 §8.8 (#3873): the three tables are byte-identical projections of STATE_FIELD_SCHEMA', () => { + // Verbatim copy of `FIELD_CLASSIFICATION`'s pre-#3873 literal (19 rows, this + // exact key order — key order is observable: the preservation dispatch loop + // and `getPreserveWhenUnchangedFields` both iterate it). + const TODAYS_FIELD_CLASSIFICATION = Object.freeze({ + gsd_state_version: { source: 'free', preservation: 'derive' }, + milestone: { source: 'external', preservation: 'preserve-if-placeholder' }, + milestone_name: { source: 'external', preservation: 'preserve-if-placeholder' }, + current_phase: { source: 'body', preservation: 'preserve-when-unchanged' }, + current_phase_name: { source: 'curated', preservation: 'preserve-when-unchanged' }, + current_plan: { source: 'body', preservation: 'preserve-when-unchanged' }, + status: { source: 'body', preservation: 'preserve-when-unchanged', guard: 'non-sentinel-unknown' }, + stopped_at: { source: 'body', preservation: 'preserve-when-unchanged' }, + paused_at: { source: 'body', preservation: 'preserve-when-unchanged' }, + last_updated: { source: 'free', preservation: 'derive' }, + last_activity: { source: 'body', preservation: 'derive' }, + last_activity_desc: { source: 'body', preservation: 'preserve-when-unchanged' }, + state_head: { source: 'free', preservation: 'derive' }, + progress: { source: 'curated', preservation: 'preserve-always', mergeStrategy: 'progress-ratchet' }, + 'progress.total_phases': { source: 'disk', preservation: 'derive' }, + 'progress.completed_phases': { source: 'disk', preservation: 'derive' }, + 'progress.total_plans': { source: 'disk', preservation: 'derive' }, + 'progress.completed_plans': { source: 'disk', preservation: 'derive' }, + 'progress.percent': { source: 'disk', preservation: 'derive' }, + }); + + // Verbatim copy of `FRONTMATTER_BODY_SOURCE`'s pre-#3873 literal (8 keys, + // this exact key order — deliberately NOT the same order as + // `FIELD_CLASSIFICATION` above, nor the same order as + // `FRONTMATTER_KEY_TO_BODY_LABEL` in tests/state.test.cjs; the two + // pre-existing tables disagreed with each other's order too). + const TODAYS_FRONTMATTER_BODY_SOURCE = Object.freeze({ + current_phase: ['Current Phase'], + current_phase_name: ['Current Phase Name'], + current_plan: ['Current Plan'], + status: ['Status'], + stopped_at: ['Stopped At', 'Stopped at'], + paused_at: ['Paused At'], + last_activity: ['Last Activity', 'Last activity'], + last_activity_desc: ['Last Activity Description'], + }); + + test('row 1 — fieldClassificationProjectionMatchesTodaysTable', () => { + assert.deepStrictEqual( + Object.keys(FIELD_CLASSIFICATION), + Object.keys(TODAYS_FIELD_CLASSIFICATION), + 'FIELD_CLASSIFICATION key order must be unchanged', + ); + for (const key of Object.keys(TODAYS_FIELD_CLASSIFICATION)) { + assert.deepStrictEqual( + FIELD_CLASSIFICATION[key], + TODAYS_FIELD_CLASSIFICATION[key], + `FIELD_CLASSIFICATION[${JSON.stringify(key)}] must be byte-identical to today's table`, + ); + } + }); + + test('row 2 — bodySourceProjectionMatchesTodaysTable', () => { + assert.deepStrictEqual( + Object.keys(FRONTMATTER_BODY_SOURCE), + Object.keys(TODAYS_FRONTMATTER_BODY_SOURCE), + 'FRONTMATTER_BODY_SOURCE key order must be unchanged', + ); + for (const key of Object.keys(TODAYS_FRONTMATTER_BODY_SOURCE)) { + assert.deepStrictEqual( + [...FRONTMATTER_BODY_SOURCE[key]], + TODAYS_FRONTMATTER_BODY_SOURCE[key], + `FRONTMATTER_BODY_SOURCE[${JSON.stringify(key)}] must be byte-identical to today's table`, + ); + } + }); + + test('row 5 — projectionIsFrozenAndNullPrototype (FIELD_CLASSIFICATION, FRONTMATTER_BODY_SOURCE)', () => { + assert.ok(Object.isFrozen(FIELD_CLASSIFICATION)); + assert.strictEqual(FIELD_CLASSIFICATION['toString'], undefined); + assert.ok(Object.isFrozen(FRONTMATTER_BODY_SOURCE)); + assert.strictEqual(FRONTMATTER_BODY_SOURCE['toString'], undefined); + // Per-row objects/arrays keep their pre-#3873 shape too: FIELD_CLASSIFICATION's + // rows were plain (non-frozen, non-null-prototype) literals, and this + // projection reproduces that exactly rather than "improving" it. + assert.strictEqual(Object.isFrozen(FIELD_CLASSIFICATION.status), false); + assert.ok(Object.isFrozen(FRONTMATTER_BODY_SOURCE.stopped_at)); + }); + + test('row 6 — everyClassificationRowStillResolves (including the five progress.* rows)', () => { + for (const key of Object.keys(TODAYS_FIELD_CLASSIFICATION)) { + assert.notStrictEqual(getFieldClassification(key), null, `${key} must still resolve`); + } + for (const leaf of ['progress.total_phases', 'progress.completed_phases', 'progress.total_plans', 'progress.completed_plans', 'progress.percent']) { + const cls = getFieldClassification(leaf); + assert.strictEqual(cls.source, 'disk'); + assert.strictEqual(cls.preservation, 'derive'); + } + }); + + test('row 7 — preserveWhenUnchangedProjectionUnchanged', () => { + assert.deepStrictEqual( + getPreserveWhenUnchangedFields(), + ['current_phase', 'current_phase_name', 'current_plan', 'status', 'stopped_at', 'paused_at', 'last_activity_desc'], + ); + }); + + test('row 8 — schemaMayDeclareMoreThanAProjectionConsumes', () => { + // `milestone` is a real STATE_FIELD_SCHEMA row (external/preserve-if-placeholder) + // with no body source at all, so it is legitimately absent from + // FRONTMATTER_BODY_SOURCE — projections are subsets by design (D4). + assert.ok(Object.prototype.hasOwnProperty.call(STATE_FIELD_SCHEMA, 'milestone')); + assert.strictEqual(STATE_FIELD_SCHEMA.milestone.bodySource, undefined); + assert.strictEqual( + Object.prototype.hasOwnProperty.call(FRONTMATTER_BODY_SOURCE, 'milestone'), + false, + 'a schema row with no bodySource must not appear in the FRONTMATTER_BODY_SOURCE projection', + ); + }); +}); + describe('ADR-1769 substrate: STATE_MD_SECTIONS constants (aligned to gsd-core/templates/state.md)', () => { test('every section heading starts with "## "', () => { for (const [name, heading] of Object.entries(STATE_MD_SECTIONS)) { @@ -494,6 +623,113 @@ describe('ADR-1769 Phase 2: advancePlan transition', () => { }); }); +// ─── #3873 phase-3 test-matrix rows 23/24/25: parser accepts EXACTLY the ──── +// schema-declared shapes — no more, no fewer. +// +// Table-driven over every STATE_FIELD_SCHEMA row carrying `acceptedShapes` +// (today: only `current_plan`), so declaring `acceptedShapes` on a future row +// gets gated automatically. A row with a driver missing from PARSER_DRIVERS +// fails loudly (row24/row23 below) rather than silently skipping — that is +// what keeps the table-driven claim honest as rows are added. +// +// The declaration itself was corrected against OBSERVED parser behavior +// (verified by executing `advancePlanCore`, not by reading its prior +// docstring's claim): `current_plan.acceptedShapes` is `['N']` only. +// `Current Plan: N of M` standalone does NOT parse today — `advancePlanCore` +// (`src/state-transition.cts:1306`) requires a `Total Plans in Phase` +// sibling for the bare-`N` path; with no sibling and no separate `Plan` +// field, it falls to its NaN/NaN error branch. #3784 is the open issue for +// teaching it the hybrid shape; **PR #3791** ("fix(#3784): read the hybrid +// `Current Plan: N of M` shape, keep zero-padding, and name the accepted +// shapes on failure") is the in-flight fix. When #3791 merges, +// `acceptedShapes` MUST widen to `['N', 'N of M']` — until then, this suite +// pins today's reality, and it is EXPECTED to go RED the moment the parser +// changes underneath it. That is the forcing function working as designed +// (§8.8 "checked, not generated"), not a broken test. +describe('#3873 phase-3 rows 23/24/25: parser accepts exactly the schema-declared shapes', () => { + const deps = { clock: fixedClock }; + + // Bare `N` only ever appears in a real STATE.md paired with a sibling + // `Total Plans in Phase` field (that pairing is what supplies the total + // `advancePlanCore` needs). `N of M` / `N/M` are driven WITHOUT that + // sibling, because the entire point of a hybrid shape is that it is + // self-contained in the `Current Plan` field alone — pairing it with a + // sibling would let the sibling's total paper over a value `parseInt` + // cannot fully read, which is the exact coincidence row 25 exists to catch. + function driveCurrentPlanShape(shapeValue, { withTotalSibling = false } = {}) { + const lines = ['# Project State', '', `**Current Plan:** ${shapeValue}`]; + if (withTotalSibling) lines.push('**Total Plans in Phase:** 5'); + lines.push('**Status:** Executing Phase 3', ''); + const result = transitionCore(lines.join('\n'), { kind: 'advancePlan' }, deps); + return !(result.data && result.data.error === true); + } + + // field -> (shape value, drive opts) -> boolean "did it parse" + const PARSER_DRIVERS = { + current_plan: driveCurrentPlanShape, + }; + + // shape name -> [example value, drive opts] + const SHAPE_EXAMPLES = { + 'N': ['3', { withTotalSibling: true }], + 'N of M': ['3 of 5', {}], + 'N/M': ['3/5', {}], + }; + + const rowsWithAcceptedShapes = Object.entries(STATE_FIELD_SCHEMA).filter( + ([, row]) => Array.isArray(row.acceptedShapes), + ); + + test('sanity: at least one schema row declares acceptedShapes (else this suite is vacuous)', () => { + assert.ok(rowsWithAcceptedShapes.length > 0, 'expected at least one acceptedShapes row to pin'); + }); + + test('unsupportedDeclaredShapeFails: every declared shape parses via the real parser (row 24)', () => { + for (const [field, row] of rowsWithAcceptedShapes) { + const driver = PARSER_DRIVERS[field]; + assert.ok(driver, `no PARSER_DRIVERS entry for field ${JSON.stringify(field)} — register one before declaring acceptedShapes on it`); + for (const shape of row.acceptedShapes) { + const example = SHAPE_EXAMPLES[shape]; + assert.ok(example, `no SHAPE_EXAMPLES entry for shape ${JSON.stringify(shape)} (field ${JSON.stringify(field)})`); + const [value, opts] = example; + const parsed = driver(value, opts); + assert.strictEqual(parsed, true, `declared shape ${JSON.stringify(shape)} for field ${JSON.stringify(field)} did not parse`); + } + } + }); + + test('undeclaredParserShapeFails: a shape outside the declared set is rejected (row 23)', () => { + // 'N of M' is deliberately excluded from current_plan's declared set + // today (the #3784/#3791 boundary); 'N/M' is a fourth spelling that has + // never been declared for any row. Both must fail to parse. + for (const [field, row] of rowsWithAcceptedShapes) { + const driver = PARSER_DRIVERS[field]; + const undeclaredCandidates = Object.keys(SHAPE_EXAMPLES).filter((shape) => !row.acceptedShapes.includes(shape)); + assert.ok(undeclaredCandidates.length > 0, `no undeclared shape candidate available to probe field ${JSON.stringify(field)}`); + for (const shape of undeclaredCandidates) { + const [value, opts] = SHAPE_EXAMPLES[shape]; + const parsed = driver(value, opts); + assert.strictEqual(parsed, false, `undeclared shape ${JSON.stringify(shape)} for field ${JSON.stringify(field)} was accepted by the parser`); + } + } + }); + + // The worked case (#3784's three spellings): current_plan specifically. + test('planNofMShapesAreExactlyTheDeclaredSet', () => { + assert.deepStrictEqual(Array.from(STATE_FIELD_SCHEMA.current_plan.acceptedShapes), ['N']); + + // Declared shape parses. + assert.strictEqual(driveCurrentPlanShape('3', { withTotalSibling: true }), true, '"N" (paired with Total Plans in Phase) should parse'); + + // The hybrid shape is NOT declared today (#3784/#3791 boundary) and does + // NOT parse standalone in the Current Plan field. + assert.strictEqual(driveCurrentPlanShape('3 of 5'), false, '"N of M" standalone in Current Plan should NOT parse today'); + + // A fourth, never-declared spelling fails rather than quietly joining. + assert.strictEqual(driveCurrentPlanShape('3/5'), false, '"N/M" should NOT parse — it has never been declared'); + }); +}); + describe('ADR-1769 Phase 2: advancePlan with frontmatter (#1255 pattern — codex review)', () => { const deps = { clock: fixedClock }; diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 77000fef0..c51cc765b 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -7088,6 +7088,229 @@ describe('ADR-3408 §8.5 Matrix (#3471): stale-but-present, and the report resid assert.deepStrictEqual(wrongPolicy, [], `FRONTMATTER_KEY_TO_BODY_LABEL row(s) whose FIELD_CLASSIFICATION policy is not preserve-when-unchanged: ${JSON.stringify(wrongPolicy)}`); }); }); + + // ─── #3873 row 3: FRONTMATTER_KEY_TO_BODY_LABEL is now a byte-identical ────── + // projection of STATE_FIELD_SCHEMA (src/state-md-schema.cts). Comparand is + // today's literal copied VERBATIM (not re-derived from the schema — see + // 50-test-matrix.md's "writer-seeded fixture trap" note), captured by direct + // read of `src/state.cts` on this branch's base before the projection + // replaced it. + describe('ADR-3473 §8.8 (#3873): FRONTMATTER_KEY_TO_BODY_LABEL is a byte-identical projection', () => { + // This exact key order — deliberately NOT the same order as + // FRONTMATTER_BODY_SOURCE (state-transition.cts): the two pre-existing + // tables disagreed with each other's order (status sits AFTER + // stopped_at/paused_at here, BEFORE them there). + const TODAYS_FRONTMATTER_KEY_TO_BODY_LABEL = Object.freeze({ + current_phase: 'Current Phase', + current_phase_name: 'Current Phase Name', + current_plan: 'Current Plan', + stopped_at: 'Stopped At', + paused_at: 'Paused At', + status: 'Status', + last_activity_desc: 'Last Activity Description', + }); + + test('bodyLabelProjectionMatchesTodaysTable', () => { + assert.deepStrictEqual( + Object.keys(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL), + Object.keys(TODAYS_FRONTMATTER_KEY_TO_BODY_LABEL), + 'FRONTMATTER_KEY_TO_BODY_LABEL key order must be unchanged', + ); + assert.deepStrictEqual( + stateLib._FRONTMATTER_KEY_TO_BODY_LABEL, + TODAYS_FRONTMATTER_KEY_TO_BODY_LABEL, + ); + // Byte-identical also means NOT null-prototype: this table was a plain + // `Object.freeze({...})` object literal before #3873 (unlike + // FIELD_CLASSIFICATION / FRONTMATTER_BODY_SOURCE, which are + // null-prototype), and the projection reproduces that exactly. + assert.ok(Object.isFrozen(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL)); + assert.strictEqual(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL['toString'], Object.prototype.toString); + }); + }); + + // ─── #3873 pin: last_activity's TWO-TABLE disagreement, resolved by what SHIPS ── + // `FRONTMATTER_BODY_SOURCE` (state-transition.cts) carries a `last_activity` + // row; `FRONTMATTER_KEY_TO_BODY_LABEL` (state.cts, above) does not. ADR-3473 + // §8.8 / issue #3873 Phase 3 collapses both tables into one schema and must + // declare a single answer for `last_activity` rather than picking whichever + // table looks tidier. This test pins the OBSERVED behavior that ships + // today, so the consolidation cannot silently change it. + // + // Observed: `last_activity`'s `FIELD_CLASSIFICATION` policy is + // `{ source: 'body', preservation: 'derive' }` — NOT `preserve-when-unchanged`. + // `bodyLabelFor` (state.cts) is only ever invoked, inside + // `reconcileReportedFields`'s `divergedFields` loop, for fields whose + // classification IS `preserve-when-unchanged` (every other field is + // `continue`d past before `bodyLabelFor` is reached). Because + // `last_activity` is `derive`, `bodyLabelFor('last_activity')` is + // unreachable in production today: the field never surfaces a Title-Case + // body label through that path, regardless of `FRONTMATTER_KEY_TO_BODY_LABEL` + // lacking a row for it. Meanwhile `FRONTMATTER_BODY_SOURCE['last_activity']` + // IS populated and IS live — it drives body-value reads for the frontmatter + // key (`getFrontmatterBodySource`, `frontmatterKeyForBodyField`). The two + // tables' disagreement is real, but only one of them is reachable for this + // key today; a consolidated schema resolves `last_activity` as "has a body + // SOURCE, has no reportable body LABEL" — matching what ships, not the + // tidier "it should have a label too" answer. + describe('#3873: last_activity label resolution matches shipped behavior', () => { + test('lastActivityLabelResolutionMatchesShippedBehavior', () => { + const cls = stateTransitionMod.getFieldClassification('last_activity'); + assert.deepStrictEqual( + cls, + { source: 'body', preservation: 'derive' }, + 'last_activity must remain classified as derive (never preserve-when-unchanged) — ' + + 'this is what makes bodyLabelFor unreachable for it today', + ); + + const bodySource = stateTransitionMod.getFrontmatterBodySource('last_activity'); + assert.deepStrictEqual( + bodySource, + ['Last Activity', 'Last activity'], + 'FRONTMATTER_BODY_SOURCE must still carry a body source for last_activity', + ); + + const hasBodyLabel = Object.prototype.hasOwnProperty.call(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL, 'last_activity'); + assert.strictEqual( + hasBodyLabel, + false, + 'FRONTMATTER_KEY_TO_BODY_LABEL must NOT carry a last_activity row — the schema resolves ' + + 'the two-table disagreement by declaring "no reportable body label", matching today\'s ' + + 'shipped behavior (unreachable via bodyLabelFor because the field is derive, not ' + + 'preserve-when-unchanged), not by inventing one because FRONTMATTER_BODY_SOURCE has an entry', + ); + }); + }); +}); + +// ─── #3873 row 9: bodyLabelFor still throws STATE_BODY_LABEL_UNWIRED_ROW for ── +// an unwired preserve-when-unchanged row, now that FRONTMATTER_KEY_TO_BODY_LABEL +// (state.cts) is a projection of STATE_FIELD_SCHEMA (src/state-md-schema.cts) +// rather than a hand-maintained literal. Every real preserve-when-unchanged +// row is fully wired today (pinned by the parity tests above), so this test +// cannot reach the throw through a genuine schema key — it simulates the +// "future row added to FIELD_CLASSIFICATION without a matching label" case +// #3471 review names, by overriding getFieldClassification on the shared, +// cached module object for the duration of one test, restored via t.after() +// (never try/finally in the test body per repo convention). +describe('#3873 row 9: bodyLabelFor still throws for an unwired preserve-when-unchanged row', () => { + test('unwiredLabelRowStillThrowsFromTheSchema', (t) => { + const FAKE_FIELD = '__gsd_3873_unwired_probe__'; + assert.strictEqual( + Object.prototype.hasOwnProperty.call(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL, FAKE_FIELD), + false, + 'probe field name must not collide with a real label row', + ); + + const original = stateTransitionMod.getFieldClassification; + t.after(() => { + stateTransitionMod.getFieldClassification = original; + }); + stateTransitionMod.getFieldClassification = (field) => + (field === FAKE_FIELD + ? { source: 'body', preservation: 'preserve-when-unchanged' } + : original(field)); + + assert.throws( + () => stateLib._bodyLabelFor(FAKE_FIELD), + (err) => { + assert.strictEqual(err.code, 'STATE_BODY_LABEL_UNWIRED_ROW'); + assert.strictEqual(err.field, FAKE_FIELD); + return true; + }, + ); + }); +}); + +// ─── #3873 row 29 (property): every projection agrees with its schema row ─── +// For every STATE_FIELD_SCHEMA row, each of the three derived tables either +// omits the key entirely or agrees with that row's corresponding value — +// the bijective contract CLAUDE.md's property-test rule requires for a +// consolidation like this one. fast-check v4: the arbitrary is declared +// INSIDE the property (a describe-body arbitrary kills the whole block), the +// seed is pinned and numRuns bounded for a deterministic, bounded run, and a +// failure re-throws with the seed spelled out so it names its own replay. +describe('#3873 row 29 (property): every projection agrees with its schema row', () => { + test('everyProjectionAgreesWithItsSchemaRow', () => { + const { STATE_FIELD_SCHEMA } = require('../gsd-core/bin/lib/state-md-schema.cjs'); + const schemaKeys = Object.keys(STATE_FIELD_SCHEMA); + // Sanity: a property over zero keys would pass vacuously (CLAUDE.md's + // Test Cleanup rule against vacuous-truth tests). + assert.ok(schemaKeys.length > 0, 'STATE_FIELD_SCHEMA must be non-empty for this property to be meaningful'); + + const SEED = 38730029; + try { + fc.assert( + fc.property(fc.constantFrom(...schemaKeys), (key) => { + const row = STATE_FIELD_SCHEMA[key]; + + if (Object.prototype.hasOwnProperty.call(stateTransitionMod.FIELD_CLASSIFICATION, key)) { + const cls = stateTransitionMod.FIELD_CLASSIFICATION[key]; + assert.strictEqual(cls.source, row.source, `FIELD_CLASSIFICATION[${key}].source disagrees with schema`); + assert.strictEqual(cls.preservation, row.preservation, `FIELD_CLASSIFICATION[${key}].preservation disagrees with schema`); + assert.strictEqual(cls.guard, row.guard, `FIELD_CLASSIFICATION[${key}].guard disagrees with schema`); + assert.strictEqual(cls.mergeStrategy, row.mergeStrategy, `FIELD_CLASSIFICATION[${key}].mergeStrategy disagrees with schema`); + } + if (Object.prototype.hasOwnProperty.call(stateTransitionMod.FRONTMATTER_BODY_SOURCE, key)) { + assert.deepStrictEqual( + Array.from(stateTransitionMod.FRONTMATTER_BODY_SOURCE[key]), + Array.from(row.bodySource || []), + `FRONTMATTER_BODY_SOURCE[${key}] disagrees with schema`, + ); + } + if (Object.prototype.hasOwnProperty.call(stateLib._FRONTMATTER_KEY_TO_BODY_LABEL, key)) { + assert.strictEqual( + stateLib._FRONTMATTER_KEY_TO_BODY_LABEL[key], + row.bodyLabel, + `FRONTMATTER_KEY_TO_BODY_LABEL[${key}] disagrees with schema`, + ); + } + return true; + }), + { seed: SEED, numRuns: 200 }, + ); + } catch (err) { + throw new Error(`everyProjectionAgreesWithItsSchemaRow failed (seed=${SEED} — replay: fc.assert(..., { seed: ${SEED} })): ${err.message}`, { cause: err }); + } + }); +}); + +// ─── #3873 phase-3 row 26: statusEnumIsExactlyTheLifecycleSet ────────────── +// CORRECTED contract (verified by executing `normalizeStateStatus`, not by +// reading `STATUS_LIFECYCLE_ENUM`'s prior docstring claim): the enum's seven +// members are the values the normalizer maps recognized input TO — they are +// NOT a runtime-enforced closed set for the `status` key. `normalizeStateStatus` +// (`src/state-document.cts`) is deliberately lenient: its fallback is +// `status || 'unknown'`, so an input matching none of its substring branches +// passes straight through, unrejected and uncoerced. This test asserts the +// real, non-vacuous contract that IS true: every canonical value normalizes +// to itself, and an unrecognized value passes through unchanged — it does +// not assert a closure the normalizer does not enforce. +describe('#3873 phase-3 row 26: status enum matches the real normalizer contract', () => { + test('statusEnumIsExactlyTheLifecycleSet', () => { + const { STATUS_LIFECYCLE_ENUM } = require('../gsd-core/bin/lib/state-md-schema.cjs'); + const { normalizeStateStatus } = require('../gsd-core/bin/lib/state-document.cjs'); + + assert.ok(STATUS_LIFECYCLE_ENUM.length > 0, 'STATUS_LIFECYCLE_ENUM must be non-empty for this test to be meaningful'); + + for (const member of STATUS_LIFECYCLE_ENUM) { + assert.strictEqual( + normalizeStateStatus(member, null), + member, + `canonical value ${JSON.stringify(member)} must normalize to itself`, + ); + } + + // A non-member is NOT rejected or coerced — it passes through unchanged, + // because the normalizer is lenient, not closed. + const nonMember = 'totally-unrecognized-status-text'; + assert.ok(!STATUS_LIFECYCLE_ENUM.includes(nonMember), 'probe value must genuinely be a non-member'); + assert.strictEqual( + normalizeStateStatus(nonMember, null), + nonMember, + 'an unrecognized status value must pass through unchanged, not be coerced into the enum', + ); + }); }); // ─────────────────────────────────────────────────────────────────────────────