'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 ` — 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'), ''); // 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']); }); });