enhance(#3873): the STATE.md schema — one owner, generated artifacts (#3880)

* test(#3873): failing-first locale parity, plus tripwires for what must not move

Pins ADR-3473 §8.8 at the artifact a reader actually sees. The English STATE.md
reference carries a Status lifecycle section that is missing from all four
translations — the section documenting the status enum whose clobbering is
#3853. The test derives the heading set rather than hard-coding the missing
one, and names the locale and the heading when it fails.

Two tripwires that must pass today and after. The field-drift guard still
catches a re-derived fallback ladder: §8.8 instructs deleting that script, and
that instruction rests on a wrong premise about what it guards, so the test
stops a future reader from deleting it on the ADR's word. And last_activity's
label resolution is pinned to what ships today, because it is declared in one
of the two tables this phase consolidates and not the other — the
consolidation must not silently pick a side.

The locale test buckets under docs rather than state, which is what it tests;
that bucket is allowlisted with justification rather than folded into an
unrelated docs suite. It reads only markdown, so it carries no allow-test-rule
marker — a marker there would suppress nothing and would grow the unverified
pool against its ceiling.

Refs #3873

* feat(#3873): one schema owns the STATE.md key set, three tables become projections

ADR-3473 §8.8. The key set was declared in four places that had to agree by
hand and already did not: FIELD_CLASSIFICATION, FRONTMATTER_BODY_SOURCE,
FRONTMATTER_KEY_TO_BODY_LABEL and buildStateFrontmatter's emit behavior. One
frozen null-prototype schema now declares each key's type, enum, cardinality,
source, preservation, body source, body label, accepted parse shapes and
whether it is emitted unconditionally; the three tables are derived from it at
module load.

The projections are byte-identical to the literals they replace, key order
included, and the parity tests compare against verbatim copies of today's
tables rather than re-deriving both sides from the schema — a parity test fed
from one source proves nothing, which is how a consolidation ships a changed
policy under a green test.

last_activity was the live disagreement: present in one table, absent from the
other. The schema declares what ships today rather than the tidier answer, and
a test pins it.

The schema is a leaf module and owns the four field-policy types, re-exported
from state-transition so existing importers are untouched — the same split
health-diagnostic-types made to break a CJS require cycle.

Refs #3873

* feat(#3873): generate the schema-derived regions, parity-check the prose tables

ADR-3473 §8.8's generator half. gen-state-md-docs.cjs owns marked regions in
the shipped template and all five reference docs, follows gen-features.cjs's
fail-closed contract, and is wired into regen:derived and lint:generated-sync.

The Status lifecycle section was missing from all four translations — the
section documenting the status enum behind #3853 — and is now generated into
every locale. Field cardinality is a new generated table: pure schema data,
no prose, so nothing to lose.

The Field-reference and Status-values tables are parity-CHECKED rather than
generated. Their Purpose, When-populated and Matched-text columns are
genuinely hand-translated per locale, and §8.8 itself says prose stays
hand-translated; generating them from an English registry would overwrite four
locales' translations on every write. The row set is checked against the schema
instead, so a key added to one and not the other fails, which is what field
drift actually means. Building that check found last_activity_desc
undocumented in all five tables.

Three keys the docs describe are absent from the schema — active_phase,
next_action, next_phases. They are grandfathered by name, not by wildcard, so a
fourth fails: a declared gap with a forcing function rather than a silent one.

Refs #3873

* fix(#3873): declare what the parsers do, and close the shape-parity gap

Two declarations in the new schema described intended behavior rather than
actual — the defect class this epic exists to end, committed inside the epic.
Both were caught by executing the parsers instead of reading their docstrings.

current_plan.acceptedShapes claimed ['N', 'N of M']. Standalone, the hybrid
shape errors; the path that looks like support is parseInt truncating '2 of 5'
to 2 and discarding the rest. Narrowed to ['N']. The parser is deliberately NOT
fixed here: that is #3784 and PR #3791 is already doing it. When #3791 lands
this row must widen, and the shape test will go red until it does — the schema
and the parser cannot drift apart quietly, which is what §8.8's checked-not-
generated rule is for.

STATUS_LIFECYCLE_ENUM claimed to be the closed set status can hold.
normalizeStateStatus passes unrecognized prose through unchanged, so it is not
closed at runtime. The seven members are the canonical values it maps onto; the
docstring now says that and the test asserts the real lenient contract.

Closes the acceptance item that a test asserts the parsers accept exactly the
declared shapes: the check is table-driven over every row carrying
acceptedShapes, guarded against passing vacuously on an empty set, and fails
loudly if a future row has no registered driver. Adds the unwired-label throw
and the fast-check property that every projection agrees with its schema row.

Refs #3873

* fix(#3873): keep the shipped template's frontmatter first, and make row 27 able to fail

The remote matrix caught 12 failures with one cause. Making the template's
frontmatter a generated region wrapped it in its own yaml fence ahead of the
markdown fence, so extractFileTemplate and readShippedStateTemplateBody — which
both match the single markdown block — found the heading first, not the
frontmatter. That breaks the contract every new project's STATE.md is created
from: bug #21 and epic #1969 B8 pin that the File Template block starts with
frontmatter and carries gsd_state_version.

The markers now sit inside the single markdown fence, so the fence opens before
the frontmatter and the region still ends ahead of the heading. Same layout as
before this phase, with markers embedded rather than a second fence.

Row 27 existed to catch exactly this and did not, because it was writer-seeded:
it asserted against the generator's own output shape, so it passed on the broken
template. It now parses the fence the way production does and was verified to
fail against the broken shape before being trusted against the fixed one. A test
that would not have caught the bug it exists to prevent is worse than no test.

The emitted-attribution failure was separate and the fragment was the wrong
remedy: gsd-core/templates/state.md self-attributes under a verbatim-copy
identity rule, so a diff touching it needs no acknowledgment. Fragment deleted
rather than left explaining nothing.

Refs #3873

* docs(#3873): how to change the STATE.md schema

The phase gate was right and my docs artifact was wrong. I listed
lint:generated-sync as the second enablement step, which is a verification
command dressed as one, and then claimed a one-step sequence owed no how-to.

The real sequence is build:lib then regen:derived, and the ordering is a trap:
the generator reads the COMPILED schema, so regenerating before building
regenerates against the previous schema and commits artifacts that look
plausible while disagreeing with the code just written. A reference table
cannot carry an ordering dependency; that is what the how-to test is for.

The page covers adding, changing and removing a key, every reason code the
check emits and what to do about each, what is generated versus hand-translated
and why the two prose-bearing tables are parity-checked instead of generated,
adding a language, and the three grandfathered keys. Indexed from docs/README.md.

Refs #3873

* chore(#3873): backfill changeset PR number

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-26 01:57:47 -04:00
committed by GitHub
parent 3b18eff388
commit ddde001af6
27 changed files with 2654 additions and 101 deletions

View File

@@ -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)

2
.gitignore vendored
View File

@@ -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

File diff suppressed because one or more lines are too long

View File

@@ -508,6 +508,7 @@
"state-contract.cjs",
"state-document.cjs",
"state-io.cjs",
"state-md-schema.cjs",
"state-transition.cjs",
"state.cjs",
"surface.cjs",

View File

@@ -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) |

View File

@@ -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 `<automated>` 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 `<automated>` 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

View File

@@ -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) |

View File

@@ -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.

View File

@@ -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`。 |
<!-- STATE-MD-SCHEMA:START:cardinality — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### フィールドの多重度
| フィールド | 多重度 |
|---|---|
| `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 |
<!-- STATE-MD-SCHEMA:END:cardinality -->
### ステータス値
`gsd-core/bin/lib/state-document.cjs` の `normalizeStateStatus()` が本文の生テキストを以下の正規値にマッピングします:
@@ -100,6 +126,21 @@ paused_at: null
| `/gsd-execute-phase` | `executing` |
| `/gsd-verify-work` | `verifying` |
<!-- STATE-MD-SCHEMA:START:status-lifecycle — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### ステータスライフサイクル (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 |
| `<version> 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).
<!-- STATE-MD-SCHEMA:END:status-lifecycle -->
---
## ステータスライン描画シーン

View File

@@ -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`. |
<!-- STATE-MD-SCHEMA:START:cardinality — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### 필드 카디널리티
| 필드 | 카디널리티 |
|---|---|
| `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 |
<!-- STATE-MD-SCHEMA:END:cardinality -->
### 상태 값
`gsd-core/bin/lib/state-document.cjs`의 `normalizeStateStatus()`는 본문의 원시 텍스트를 다음 표준 값으로 매핑합니다:
@@ -100,6 +126,21 @@ paused_at: null
| `/gsd-execute-phase` | `executing` |
| `/gsd-verify-work` | `verifying` |
<!-- STATE-MD-SCHEMA:START:status-lifecycle — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### 상태 라이프사이클 (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 |
| `<version> 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).
<!-- STATE-MD-SCHEMA:END:status-lifecycle -->
---
## 상태 표시줄 렌더링 장면

View File

@@ -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. |
<!-- STATE-MD-SCHEMA:START:cardinality — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### 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 |
<!-- STATE-MD-SCHEMA:END:cardinality -->
### 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` |
<!-- STATE-MD-SCHEMA:START:status-lifecycle — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### 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 |
| `<version> 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).
<!-- STATE-MD-SCHEMA:END:status-lifecycle -->
---
## Cenas de renderização da linha de status

View File

@@ -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. |
<!-- STATE-MD-SCHEMA:START:cardinality — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### 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 |
<!-- STATE-MD-SCHEMA:END:cardinality -->
> **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` |
<!-- STATE-MD-SCHEMA:START:status-lifecycle — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### 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).
<!-- STATE-MD-SCHEMA:END:status-lifecycle -->
---

View File

@@ -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`。 |
<!-- STATE-MD-SCHEMA:START:cardinality — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### 字段基数
| 字段 | 基数 |
|---|---|
| `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 |
<!-- STATE-MD-SCHEMA:END:cardinality -->
### 状态值
`gsd-core/bin/lib/state-document.cjs` 中的 `normalizeStateStatus()` 将原始正文文本映射到以下规范值:
@@ -100,6 +126,21 @@ paused_at: null
| `/gsd-execute-phase` | `executing` |
| `/gsd-verify-work` | `verifying` |
<!-- STATE-MD-SCHEMA:START:status-lifecycle — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
### 状态生命周期 (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 |
| `<version> 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).
<!-- STATE-MD-SCHEMA:END:status-lifecycle -->
---
## 状态行渲染场景

View File

@@ -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',

View File

@@ -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.
<!-- STATE-MD-SCHEMA:START:frontmatter — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->
```markdown
---
gsd_state_version: '1.0' # placeholder; syncStateFrontmatter overwrites on first state.* call
@@ -17,6 +23,7 @@ progress:
completed_plans: 0
percent: 0
---
<!-- STATE-MD-SCHEMA:END:frontmatter -->
# Project State

View File

@@ -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

View File

@@ -215,6 +215,13 @@ const DOCS_GUARD_TESTS = {
// Walks docs/*.md and every docs/<locale>/*.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',

View File

@@ -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 <dir># resolve target files under <dir>
* # 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'] },
]);
/** `<!-- STATE-MD-SCHEMA:START:<region> — generated by ...; do not edit by hand -->` */
function startMarker(region) {
return `<!-- ${MARKER_TAG}:START:${region} — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->`;
}
/** `<!-- STATE-MD-SCHEMA:END:<region> -->` */
function endMarker(region) {
return `<!-- ${MARKER_TAG}:END:${region} -->`;
}
// ─── 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'],
['`<version> 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 `### <headingText>` 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, '<in-memory>', 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 <dir>.`);
}
}
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,
};

View File

@@ -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."
}
}
}

285
src/state-md-schema.cts Normal file
View File

@@ -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` →
* `<version> 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<Record<string, StateFieldSchema>> = Object.freeze(
Object.assign(
Object.create(null) as Record<string, StateFieldSchema>,
{
// 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<string, StateFieldSchema>,
),
);

View File

@@ -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<Record<string, FieldClassification>> = Object.freeze(
Object.assign(
Object.create(null) as Record<string, FieldClassification>,
{
// 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<string, FieldClassification>,
),
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<string, FieldClassification>),
);
/**
@@ -163,19 +130,36 @@ export const FIELD_CLASSIFICATION: Readonly<Record<string, FieldClassification>>
* 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<Record<string, readonly string[]>> = Object.freeze(
Object.assign(Object.create(null) as Record<string, readonly string[]>, {
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<string, readonly string[]>),
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<string, readonly string[]>),
);
/**

View File

@@ -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<Record<string, string>> = 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<Record<string, string>> = 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<string, string>),
);
/**
* 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 {

View File

@@ -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')}`,
);
});

View File

@@ -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 <tmpDir>` — 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'), '<!-- STATE-MD-SCHEMA:END -->'); // 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']);
});
});

View File

@@ -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'\)/);
});
});

View File

@@ -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 };

View File

@@ -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',
);
});
});
// ─────────────────────────────────────────────────────────────────────────────