Files
msd-core/tests/gen-state-md-docs.test.cjs
Tom Boucher ddde001af6 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>
2026-08-26 01:57:47 -04:00

494 lines
23 KiB
JavaScript

'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']);
});
});