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

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