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>
498 lines
23 KiB
JavaScript
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']);
|
|
});
|
|
});
|