* 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:
@@ -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 };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user