Files
msd-core/tests/gen-state-md-docs.test.cjs
sim 2eef8ada4f test(#4520): migrate generators/doc-gates/attribution batch to named timeout constants
Batch 9 of 17 in the ad hoc timeout literal migration (epic #4445).
Replaces every bare numeric timeout/timeoutMs object-literal property in
14 files with a named constant, per eslint-rules/no-adhoc-timeout-literal.cjs.
Removes the 14 files from the rule's allowlist.

The issue's own guess ("all run a scripts/*.cjs generator or lint script
once, BUILD_TIMEOUT_MS class") needed two corrections found by reading
every site directly. First, BUILD_TIMEOUT_MS's own doc comment scopes it
specifically to scripts/build-hooks.js, which none of this batch's
generator/lint-script sites run — a new shared constant,
GENERATOR_SCRIPT_TIMEOUT_MS, covers the class instead. Second,
no-pending-3212-markers.test.cjs's single site spawns `git ls-files`
directly, not a scripts/*.cjs script at all — routed to a second new
shared constant, REAL_REPO_GIT_TIMEOUT_MS, promoted once
emitted-attribution.test.cjs's own git-plumbing sites were found sharing
the same class and value.

Isolated Standards-axis review caught a further misclassification: one
of REAL_REPO_GIT_TIMEOUT_MS's three emitted-attribution.test.cjs sites
actually builds a fresh throwaway temp repo (createTempDir + git init),
contradicting that constant's own real-repo-tree-only scope. Fixed with
a new file-local FRESH_FIXTURE_GIT_TIMEOUT_MS holding the exact
pre-existing value under an honest name, rather than reusing the shared
GIT_FIXTURE_TIMEOUT_MS (which would have doubled the bound).

emitted-attribution.test.cjs also gets two more file-local constants:
HEAVY_REAL_TREE_TEST_TIMEOUT_MS (node:test's own per-test timeout
option, not a spawn bound) and BUILD_HOOKS_UNDER_LOAD_TIMEOUT_MS (the
same build-hooks.js script as the shared norm, at 4x its bound inside
the suite's heaviest test). emitted-ack-trailer.test.cjs gets
IMPOSSIBLY_SHORT_GIT_TIMEOUT_MS — the one value in this migration that
is deliberately tiny (20ms), used to force a timeout in a negative test,
not generous headroom.

No src/bin file touched, no numeric value changed anywhere.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-10 19:22:04 -04:00

498 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 { GENERATOR_SCRIPT_TIMEOUT_MS } = require('./helpers/timeouts.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: GENERATOR_SCRIPT_TIMEOUT_MS });
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.
// Deliberately independent of markdown-sectionizer — this guards the #3873
// regression class (a defect IN the generator's own fence-handling), so it
// must not share the same seam the generator uses.
// 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]*?)```/); // allow-adhoc-markdown: deliberately independent of the generator's own fence-handling — regresses #3873
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']);
});
});