Files
msd-core/scripts/gen-state-md-docs.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

728 lines
32 KiB
JavaScript

#!/usr/bin/env node
'use strict';
/**
* Generates the schema-derived MARKED REGIONS inside `gsd-core/templates/state.md`
* and the five `docs/{,ja-JP/,zh-CN/,ko-KR/,pt-BR/}reference/state-md.md` pages
* from `STATE_FIELD_SCHEMA` (`src/state-md-schema.cts`, ADR-3473 §8.8, #3873).
*
* WHY THIS EXISTS. Four hand-maintained declarations of "which STATE.md keys
* exist and what they carry" already disagreed (see `src/state-md-schema.cts`'s
* own docstring for the `last_activity` case). Two of the SIX places named in
* `.gsd/phase/feat-3873-state-md-schema/40-design.md`'s table are documents,
* not code: the shipped template and the reference docs. This generator is
* their half of the consolidation. `FIELD_CLASSIFICATION` /
* `FRONTMATTER_BODY_SOURCE` / `FRONTMATTER_KEY_TO_BODY_LABEL` project from the
* same schema at MODULE LOAD (`src/state-transition.cts`, `src/state.cts`);
* this script projects at BUILD TIME instead, into committed markdown.
*
* GENERATED REGIONS, NOT GENERATED FILES (design doc §4). The five reference
* pages are hand-translated prose with schema-derived tables and section
* skeletons embedded in them; a generator that rewrote the whole file would
* clobber a community translation. So — following `gen-features.cjs`, not
* `gen-context-index.cjs` (the design's own precedent choice) — this script
* owns only explicitly MARKED regions inside otherwise hand-authored files,
* and never touches a byte outside a marker pair.
*
* Two regions are declared today:
* - `frontmatter` — the initial YAML frontmatter block in
* `gsd-core/templates/state.md`, the block a new project starts from.
* - `status-lifecycle` — the `### Status lifecycle (ADR-2207)` section
* documenting the `status` field's lifecycle. This is the section
* ISSUE #3873's own design doc found missing from all four locale
* translations (verified by line/heading count) — the generator emitting
* this skeleton is what turns `tests/docs-state-md-locale-parity.test.cjs`
* green.
*
* A NOTE ON WHAT "SCHEMA-DERIVED" MEANS HERE. `STATE_FIELD_SCHEMA.status.enum`
* (`STATUS_LIFECYCLE_ENUM`) is the seven-member NORMALIZED status value set
* `normalizeStateStatus()` computes. The `### Status lifecycle (ADR-2207)`
* table documents a DIFFERENT, narrower vocabulary — the raw `Status:` body
* strings `completePhaseCore`/`milestoneCompleteCore` actually write
* (`Ready to plan`, `All phases complete`, ...) — `src/state-md-schema.cts`'s
* own docstring is explicit that these are not the same set. This generator
* therefore does not literally enumerate `schema.status.enum` into the table;
* it asserts `schema.status.enum` is declared and non-empty (so a future
* removal of the `status` row's enum fails this generator loudly, keeping the
* link real) and renders the ADR-2207 table from `STATUS_LIFECYCLE_ROWS`
* below, which the codebase does not (yet) declare in one place either. This
* is a section SKELETON, not a full re-derivation, per the design's own
* scoping: "Parsers are checked, not generated."
*
* COLUMN HEADERS ARE PER-LOCALE (design doc §4: "Column headers come from a
* per-locale string table so a translated table has translated headers").
* Table BODY content (the four `Ready to plan` / ... rows) is left in English
* across every locale deliberately: those are literal STATE.md body-field
* values a project actually writes, not prose to translate, and the
* "Meaning" column is exactly the kind of hand-translatable prose the design
* doc says a human still owns — the generator's contract there is only that
* the SECTION exists (structural, ADR-3873/#3853), never that its prose is
* translated (design doc "Not-corruption": "A locale is not 'wrong' for
* having different prose").
*
* Usage:
* node scripts/gen-state-md-docs.cjs # print every region to stdout
* node scripts/gen-state-md-docs.cjs --write # rewrite every region in place
* node scripts/gen-state-md-docs.cjs --check # exit 1 if any region is stale
* node scripts/gen-state-md-docs.cjs --json # --check semantics; JSON report
* node scripts/gen-state-md-docs.cjs --write --force # write despite violations
* node scripts/gen-state-md-docs.cjs ... --root <dir># resolve target files under <dir>
* # instead of the repo root (test-only;
* # the schema module itself is always
* # required from THIS repo's build output)
*/
const fs = require('node:fs');
const path = require('node:path');
const { ExitError, runMain } = require('./lib/cli-exit.cjs');
const REPO_ROOT = path.resolve(__dirname, '..');
const SCHEMA_LIB_PATH = path.join(REPO_ROOT, 'gsd-core', 'bin', 'lib', 'state-md-schema.cjs');
const MARKER_TAG = 'STATE-MD-SCHEMA';
/** Stable reason codes for every violation this gate can emit. */
const REASON = Object.freeze({
SCHEMA_LIB_MISSING: 'schema_lib_missing',
LOCALE_STRINGS_MISSING: 'locale_strings_missing',
MARKERS_MISSING: 'markers_missing',
MARKER_UNCLOSED: 'marker_unclosed',
MARKER_ORDER_INVALID: 'marker_order_invalid',
REGION_STALE: 'region_stale',
FIELD_REFERENCE_DRIFT: 'field_reference_drift',
STATUS_VALUES_DRIFT: 'status_values_drift',
});
/**
* The five reference-doc locales plus the shipped template — each an
* independent target file, each declaring the ordered list of region names it
* carries. `en` and the four translations all carry `status-lifecycle`;
* only the template carries `frontmatter`.
*/
const TARGETS = Object.freeze([
{ key: 'template', relPath: path.join('gsd-core', 'templates', 'state.md'), regions: ['frontmatter'] },
{ key: 'en', relPath: path.join('docs', 'reference', 'state-md.md'), locale: 'en', regions: ['status-lifecycle', 'cardinality'] },
{ key: 'ja-JP', relPath: path.join('docs', 'ja-JP', 'reference', 'state-md.md'), locale: 'ja-JP', regions: ['status-lifecycle', 'cardinality'] },
{ key: 'zh-CN', relPath: path.join('docs', 'zh-CN', 'reference', 'state-md.md'), locale: 'zh-CN', regions: ['status-lifecycle', 'cardinality'] },
{ key: 'ko-KR', relPath: path.join('docs', 'ko-KR', 'reference', 'state-md.md'), locale: 'ko-KR', regions: ['status-lifecycle', 'cardinality'] },
{ key: 'pt-BR', relPath: path.join('docs', 'pt-BR', 'reference', 'state-md.md'), locale: 'pt-BR', regions: ['status-lifecycle', 'cardinality'] },
]);
/** `<!-- STATE-MD-SCHEMA:START:<region> — generated by ...; do not edit by hand -->` */
function startMarker(region) {
return `<!-- ${MARKER_TAG}:START:${region} — generated by scripts/gen-state-md-docs.cjs from src/state-md-schema.cts; do not edit by hand -->`;
}
/** `<!-- STATE-MD-SCHEMA:END:<region> -->` */
function endMarker(region) {
return `<!-- ${MARKER_TAG}:END:${region} -->`;
}
// ─── Frontmatter region (gsd-core/templates/state.md) ──────────────────────
/**
* The subset of `STATE_FIELD_SCHEMA` keys a FRESH project's initial STATE.md
* frontmatter declares. Validated against the schema at render time — if a
* future schema change drops one of these keys, rendering throws rather than
* silently emitting a template field the schema no longer recognizes.
*/
const TEMPLATE_FRONTMATTER_FIELDS = Object.freeze(['gsd_state_version', 'status', 'progress']);
function renderFrontmatterRegion(schema) {
for (const field of TEMPLATE_FRONTMATTER_FIELDS) {
if (!(field in schema)) {
throw new ExitError(
1,
`template frontmatter field '${field}' is not declared in STATE_FIELD_SCHEMA (src/state-md-schema.cts) — update TEMPLATE_FRONTMATTER_FIELDS or the schema`,
);
}
}
// NOTE: this body deliberately opens the SAME ```markdown fence the
// hand-authored File Template body continues in — it does NOT wrap the
// frontmatter in its own separate ```yaml fence. `tests/state-transition
// .test.cjs`'s bug #21 regression guard extracts the entire ```markdown
// ... ``` block and asserts the extracted text STARTS with '---': a
// separate preceding fence broke that contract (#3873 fixed-forward). The
// END marker therefore lands inside the still-open fence, right after the
// frontmatter's closing '---' and before the hand-authored '# Project
// State' line — that is intentional, not a rendering bug.
return [
'```markdown',
'---',
"gsd_state_version: '1.0' # placeholder; syncStateFrontmatter overwrites on first state.* call",
'status: planning',
'progress:',
' total_phases: 0',
' completed_phases: 0',
' total_plans: 0',
' completed_plans: 0',
' percent: 0',
'---',
].join('\n');
}
// ─── Status-lifecycle region (the five reference docs) ──────────────────────
/** Per-locale heading text and translated column headers (design §4). */
const STATUS_LIFECYCLE_STRINGS = Object.freeze({
en: { heading: 'Status lifecycle (ADR-2207)', cols: ['Value', 'Written by', 'Meaning'] },
'ja-JP': { heading: 'ステータスライフサイクル (ADR-2207)', cols: ['値', '書き込み元', '意味'] },
'zh-CN': { heading: '状态生命周期 (ADR-2207)', cols: ['值', '写入方', '含义'] },
'ko-KR': { heading: '상태 라이프사이클 (ADR-2207)', cols: ['값', '작성자', '의미'] },
'pt-BR': { heading: 'Ciclo de vida do status (ADR-2207)', cols: ['Valor', 'Escrito por', 'Significado'] },
});
/** Canonical, deliberately untranslated across locales — see file header note. */
const STATUS_LIFECYCLE_ROWS = Object.freeze([
['`Ready to plan`', '`completePhaseCore` (non-last phase)', 'Next phase is ready for planning'],
['`All phases complete`', '`completePhaseCore` (last phase)', 'All phases done; milestone awaiting formal close'],
['`<version> milestone complete`', '`milestoneCompleteCore`', 'Milestone formally closed and archived'],
['`Awaiting next milestone`', '`milestoneCompleteCore`', 'Terminal/archived state'],
]);
const STATUS_LIFECYCLE_INTRO =
'The `Status` field follows a strict lifecycle across phase and milestone boundaries:';
const STATUS_LIFECYCLE_FOOTNOTE =
'Phase-completion verbs never write `Milestone complete` (the overloaded bare value was removed in ' +
'#2204 per ADR-2207 to decouple phase-level writes from milestone termination).';
/**
* Render the status-lifecycle region for `locale`.
*
* `schema.status` lacking a non-empty `enum` is a hard SCHEMA DEFECT (there
* is nothing coherent to render for any locale) and remains a thrown
* `ExitError` — unlike the locale-strings case below, `--force` could not
* meaningfully "override" it, because no fallback content exists.
*
* A locale absent from `STATUS_LIFECYCLE_STRINGS`, by contrast, is exactly
* the forceable-violation shape `gen-features.cjs` established (a corpus gap
* that still has a renderable fallback): pushed onto `violations` rather
* than thrown, and rendered using the English strings so `--write --force`
* can still emit something reviewable while `--check`/`--write` (no force)
* refuse.
*/
function renderStatusLifecycleRegion(locale, schema, violations) {
const statusRow = schema.status;
if (!statusRow || !Array.isArray(statusRow.enum) || statusRow.enum.length === 0) {
throw new ExitError(
1,
"STATE_FIELD_SCHEMA.status must declare a non-empty 'enum' for the status-lifecycle section to be generated",
);
}
let strings = STATUS_LIFECYCLE_STRINGS[locale];
if (!strings) {
violations.push({ reason: REASON.LOCALE_STRINGS_MISSING, file: null, region: 'status-lifecycle', locale });
strings = STATUS_LIFECYCLE_STRINGS.en;
}
const [c0, c1, c2] = strings.cols;
const lines = [
`### ${strings.heading}`,
'',
STATUS_LIFECYCLE_INTRO,
'',
`| ${c0} | ${c1} | ${c2} |`,
'|---|---|---|',
];
for (const [value, writtenBy, meaning] of STATUS_LIFECYCLE_ROWS) {
lines.push(`| ${value} | ${writtenBy} | ${meaning} |`);
}
lines.push('', STATUS_LIFECYCLE_FOOTNOTE);
return lines.join('\n');
}
// ─── Cardinality region (the five reference docs) ───────────────────────────
//
// ADR-3473 §8.8 names three schema-derived tables: "field reference, status
// values, cardinality". No "### Field cardinality" section existed anywhere
// before this generator — unlike status-lifecycle (which REPLACED an
// existing English section and filled a gap in the four translations), this
// is wholly NEW content, so there is nothing hand-translated to lose by
// generating it. Every row is schema-derived: the field NAME and its
// `cardinality` value ('one' | 'optional' | 'many'), nothing else — no
// prose, so no translation to overwrite.
//
// EXCLUDED_FIELD_TABLE_KEYS: the bare `progress` object row. The existing
// Field-reference table (hand-authored) never lists `progress` itself as its
// own row either — only its five `progress.*` leaves — matching this repo's
// "reporting granularity is the dotted leaf path" convention (ADR-3473 §8.8,
// "Rule — reporting granularity is the dotted leaf path"). The cardinality
// table mirrors that same convention rather than introducing a new one.
const EXCLUDED_FIELD_TABLE_KEYS = Object.freeze(['progress']);
const CARDINALITY_STRINGS = Object.freeze({
en: { heading: 'Field cardinality', cols: ['Field', 'Cardinality'] },
'ja-JP': { heading: 'フィールドの多重度', cols: ['フィールド', '多重度'] },
'zh-CN': { heading: '字段基数', cols: ['字段', '基数'] },
'ko-KR': { heading: '필드 카디널리티', cols: ['필드', '카디널리티'] },
'pt-BR': { heading: 'Cardinalidade dos campos', cols: ['Campo', 'Cardinalidade'] },
});
function renderCardinalityRegion(locale, schema) {
const strings = CARDINALITY_STRINGS[locale];
if (!strings) {
throw new ExitError(1, `no localized strings registered in CARDINALITY_STRINGS for locale '${locale}'`);
}
const [c0, c1] = strings.cols;
const lines = [`### ${strings.heading}`, '', `| ${c0} | ${c1} |`, '|---|---|'];
for (const key of Object.keys(schema)) {
if (EXCLUDED_FIELD_TABLE_KEYS.includes(key)) continue;
lines.push(`| \`${key}\` | ${schema[key].cardinality} |`);
}
return lines.join('\n');
}
// ─── Field-reference / status-values KEY-SET PARITY (detection, not generation) ──
//
// The Field-reference and Status-values tables carry hand-translated PROSE
// per row (a field's "Purpose"/"When populated" description; a status
// value's "Matched text" description) that the schema does not model at all
// — `StateFieldSchema` has no descriptive-text field, and translating that
// prose into a per-locale static registry inside this generator would mean
// OVERWRITING genuinely hand-translated ja-JP/zh-CN/ko-KR/pt-BR content with
// canonical English text on every `--write`. That is a real content loss,
// not a cosmetic one, and it is the "column the schema does not model"
// case (see the module header and the PR discussion this code answers).
//
// What the schema DOES let us check, losslessly: the ROW SET. A field's
// Purpose/When-populated text can be hand-authored forever; but the fact
// that `last_activity_desc` (declared in STATE_FIELD_SCHEMA) has NO row in
// the English Field-reference table at all is exactly the "unrepresentable
// drift" ADR-3473 §8.8 exists to close, and it is real, live, and pre-dates
// this generator (found while building it; fixed alongside it in this same
// change). This check makes that class of drift DETECTABLE (fails --check)
// without touching a single byte of translated prose.
//
// KNOWN_SCHEMA_GAP_FIELDS: `active_phase`, `next_action`, `next_phases` are
// real STATE.md frontmatter keys (ADR issue #2833), documented in the
// Field-reference table today, but NOT declared in `STATE_FIELD_SCHEMA` —
// they are computed/reported outside the classification/preservation
// dispatch model `FIELD_CLASSIFICATION` projects, and deciding whether they
// belong in that schema at all is a cross-cutting call this generator does
// not make unilaterally (it would ripple into `state-transition.cts` /
// `state.cts`'s pinned projection-parity tests). Grandfathered here, by
// name, so the check is a real ratchet (a FIFTH undeclared field would still
// fail) rather than silently disabled.
const KNOWN_SCHEMA_GAP_FIELDS = Object.freeze(['active_phase', 'next_action', 'next_phases']);
/** Per-locale heading text for the two hand-authored, prose-bearing tables (parity-parsed, never spliced). */
const FIELD_REFERENCE_HEADING = Object.freeze({
en: 'Field reference',
'ja-JP': 'フィールドリファレンス',
'zh-CN': '字段参考',
'ko-KR': '필드 참조',
'pt-BR': 'Referência de campos',
});
const STATUS_VALUES_HEADING = Object.freeze({
en: 'Status values',
'ja-JP': 'ステータス値',
'zh-CN': '状态值',
'ko-KR': '상태 값',
'pt-BR': 'Valores de status',
});
/**
* The first column's cell values of the FIRST markdown table immediately
* following the `### <headingText>` heading in `normalizedText` (LF-normalized).
* Returns `null` if the heading is not found. Backtick-fenced inline code is
* stripped (`` `key` `` -> `key`) since every row in both tables opens its
* first cell that way.
*/
function firstColumnAfterHeading(normalizedText, headingText) {
const lines = normalizedText.split('\n');
const headingIdx = lines.findIndex((l) => l === `### ${headingText}`);
if (headingIdx === -1) return null;
const values = [];
let sawHeaderRow = false;
for (let i = headingIdx + 1; i < lines.length; i++) {
const line = lines[i];
if (!line.startsWith('|')) {
if (sawHeaderRow) break; // table ended
continue; // still skipping the heading's intro prose
}
const cells = line.split('|');
const first = (cells[1] ?? '').trim();
if (/^:?-+:?$/.test(first)) continue; // the `|---|---|` separator row
if (!sawHeaderRow) {
sawHeaderRow = true; // this is the `| Header | Header |` row itself
continue;
}
values.push(first.replace(/`/g, ''));
}
return values;
}
/**
* Field-reference / status-values row-set violations for one target, or `[]`
* when the target has neither table (the template) or the parity holds.
*/
function collectKeySetParityViolations(target, schema, normalizedText) {
if (!target.locale) return [];
const found = [];
const fieldHeading = FIELD_REFERENCE_HEADING[target.locale];
const docFieldKeys = fieldHeading ? firstColumnAfterHeading(normalizedText, fieldHeading) : null;
if (docFieldKeys !== null) {
const schemaKeys = Object.keys(schema).filter((k) => !EXCLUDED_FIELD_TABLE_KEYS.includes(k));
const docSet = new Set(docFieldKeys);
const schemaSet = new Set(schemaKeys);
const missingFromDoc = schemaKeys.filter((k) => !docSet.has(k));
const undeclaredInSchema = docFieldKeys.filter(
(k) => !schemaSet.has(k) && !KNOWN_SCHEMA_GAP_FIELDS.includes(k),
);
if (missingFromDoc.length > 0 || undeclaredInSchema.length > 0) {
found.push({
reason: REASON.FIELD_REFERENCE_DRIFT,
file: target.relPath,
region: null,
missingFromDoc,
undeclaredInSchema,
});
}
}
const statusHeading = STATUS_VALUES_HEADING[target.locale];
const docStatusValues = statusHeading ? firstColumnAfterHeading(normalizedText, statusHeading) : null;
if (docStatusValues !== null) {
const enumMembers = schema.status && Array.isArray(schema.status.enum) ? schema.status.enum : [];
const docSet = new Set(docStatusValues);
const enumSet = new Set(enumMembers);
const missingFromDoc = enumMembers.filter((v) => !docSet.has(v));
const undeclaredInSchema = docStatusValues.filter((v) => !enumSet.has(v));
if (missingFromDoc.length > 0 || undeclaredInSchema.length > 0) {
found.push({
reason: REASON.STATUS_VALUES_DRIFT,
file: target.relPath,
region: null,
missingFromDoc,
undeclaredInSchema,
});
}
}
return found;
}
const REGION_RENDERERS = Object.freeze({
frontmatter: (target, schema, _violations) => renderFrontmatterRegion(schema),
'status-lifecycle': (target, schema, violations) => renderStatusLifecycleRegion(target.locale, schema, violations),
cardinality: (target, schema, _violations) => renderCardinalityRegion(target.locale, schema),
});
// ─── Marker splicing ─────────────────────────────────────────────────────────
/**
* Locate a region's `[start, end)` byte range (over LF-normalized text),
* INCLUSIVE of both marker lines, in `doc`. Mirrors `gen-features.cjs`'s
* `spliceIntoFeatures`: the END marker is found via `lastIndexOf` so a forged
* marker cannot shrink the region it governs.
*
* Returns `{ violation }` when the pair is missing, unclosed, or out of
* order — the caller decides whether that is fatal (splice) or merely
* reportable (check/json).
*/
function findRegion(doc, file, region) {
const start = startMarker(region);
const end = endMarker(region);
const startIdx = doc.indexOf(start);
const endIdx = doc.lastIndexOf(end);
if (startIdx === -1 && endIdx === -1) {
return { violation: { reason: REASON.MARKERS_MISSING, file, region } };
}
if (startIdx === -1 || endIdx === -1) {
return { violation: { reason: REASON.MARKER_UNCLOSED, file, region } };
}
if (endIdx < startIdx) {
return { violation: { reason: REASON.MARKER_ORDER_INVALID, file, region } };
}
return { range: [startIdx, endIdx + end.length] };
}
/** Replace `region`'s marked span in `doc` with `body`, markers included. */
function spliceRegion(doc, region, body) {
const { range, violation } = findRegion(doc, '<in-memory>', region);
if (violation) {
throw new ExitError(
1,
`region '${region}' markers are ${violation.reason} — expected:\n ${startMarker(region)}\n ${endMarker(region)}\n`,
);
}
const [start, end] = range;
const replacement = `${startMarker(region)}\n${body}\n${endMarker(region)}`;
return doc.slice(0, start) + replacement + doc.slice(end);
}
/** `true` if `text`'s line endings are predominantly CRLF. */
function isCrlf(text) {
return /\r\n/.test(text);
}
// ─── Corpus assembly ─────────────────────────────────────────────────────────
function loadSchema() {
if (!fs.existsSync(SCHEMA_LIB_PATH)) {
throw new ExitError(
1,
`${SCHEMA_LIB_PATH} does not exist. Run 'npm run build:lib' first (STATE_FIELD_SCHEMA is compiled from src/state-md-schema.cts).`,
);
}
// SCHEMA_LIB_PATH is a fixed, repo-relative constant computed from __dirname — never user input.
const mod = require(SCHEMA_LIB_PATH);
return mod.STATE_FIELD_SCHEMA;
}
/**
* Build every target's rendered regions plus, for a given `filesRoot`, the
* violations found reading its on-disk file (missing/unclosed markers,
* staleness). `filesRoot` defaults to the real repo root; tests pass a temp
* copy so no fixture is ever planted in the real tree.
*/
function buildCorpus(filesRoot) {
const schema = loadSchema();
const violations = [];
const targets = TARGETS.map((target) => {
const absPath = path.join(filesRoot, target.relPath);
const regionBodies = {};
for (const region of target.regions) {
const renderer = REGION_RENDERERS[region];
regionBodies[region] = renderer(target, schema, violations);
}
return { ...target, absPath, regionBodies };
});
return { schema, targets, violations };
}
/** Read `target.absPath`, returning `null` (with a violation appended) on ENOENT. */
function readTarget(target, violations) {
try {
return fs.readFileSync(target.absPath, 'utf8');
} catch {
violations.push({ reason: REASON.MARKERS_MISSING, file: target.relPath, region: null, detail: 'file unreadable' });
return null;
}
}
/** Splice every declared region of `target` into `original`, returning the new text (or throwing). */
function spliceTarget(target, original) {
const crlf = isCrlf(original);
let normalized = original.replace(/\r\n/g, '\n');
for (const region of target.regions) {
normalized = spliceRegion(normalized, region, target.regionBodies[region]);
}
return crlf ? normalized.replace(/\n/g, '\r\n') : normalized;
}
/** Which of `target`'s regions differ between `original` and its spliced form. */
function staleRegionsOf(target, original) {
const crlf = isCrlf(original);
const normalizedOriginal = original.replace(/\r\n/g, '\n');
const stale = [];
for (const region of target.regions) {
const { range, violation } = findRegion(normalizedOriginal, target.relPath, region);
if (violation) continue; // already reported as a hostile violation elsewhere
const [start, end] = range;
const currentSpan = normalizedOriginal.slice(start, end);
const freshSpan = `${startMarker(region)}\n${target.regionBodies[region]}\n${endMarker(region)}`;
if (currentSpan !== freshSpan) stale.push(region);
}
return { stale, crlf };
}
/** Non-throwing per-target-per-region violation scan, for --check/--json. */
function collectTargetViolations(target, original) {
const found = [];
if (original === null) return found;
const normalized = original.replace(/\r\n/g, '\n');
for (const region of target.regions) {
const { violation } = findRegion(normalized, target.relPath, region);
if (violation) {
found.push({ ...violation, file: target.relPath });
}
}
return found;
}
/** Human one-liner for a violation, keyed off its typed reason. */
function describeViolation(v) {
switch (v.reason) {
case REASON.SCHEMA_LIB_MISSING:
return `${SCHEMA_LIB_PATH} is missing — run 'npm run build:lib'`;
case REASON.MARKERS_MISSING:
return `${v.file}: region '${v.region}' markers are entirely absent — expected ${startMarker(v.region)} / ${endMarker(v.region)}`;
case REASON.MARKER_UNCLOSED:
return `${v.file}: region '${v.region}' has only one of its START/END markers — malformed or unclosed`;
case REASON.MARKER_ORDER_INVALID:
return `${v.file}: region '${v.region}' END marker precedes its START marker`;
case REASON.REGION_STALE:
return `${v.file}: region '${v.region}' is stale — run 'node scripts/gen-state-md-docs.cjs --write'`;
case REASON.LOCALE_STRINGS_MISSING:
return `no localized strings registered in STATUS_LIFECYCLE_STRINGS for locale '${v.locale}' — falls back to 'en' under --force`;
case REASON.FIELD_REFERENCE_DRIFT:
return (
`${v.file}: Field-reference table row set disagrees with STATE_FIELD_SCHEMA — ` +
`missing from doc: [${v.missingFromDoc.join(', ')}]; undeclared in schema: [${v.undeclaredInSchema.join(', ')}]`
);
case REASON.STATUS_VALUES_DRIFT:
return (
`${v.file}: Status-values table row set disagrees with STATE_FIELD_SCHEMA.status.enum — ` +
`missing from doc: [${v.missingFromDoc.join(', ')}]; undeclared in schema: [${v.undeclaredInSchema.join(', ')}]`
);
default:
return `${v.file}: ${v.reason}`;
}
}
// ─── CLI ─────────────────────────────────────────────────────────────────────
function parseArgs(argv) {
const opts = { write: false, check: false, json: false, force: false, root: REPO_ROOT };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === '--write') opts.write = true;
else if (arg === '--check') opts.check = true;
else if (arg === '--json') opts.json = true;
else if (arg === '--force') opts.force = true;
else if (arg === '--root') {
opts.root = argv[++i];
if (!opts.root) throw new ExitError(1, '--root requires a directory argument');
} else {
throw new ExitError(1, `unknown flag: ${arg}\nRecognized flags: --write, --check, --json, --force, --root <dir>.`);
}
}
return opts;
}
function main() {
const { write, check, json, force, root } = parseArgs(process.argv.slice(2));
const { schema, targets, violations } = buildCorpus(root);
// Collect per-target hostile-input violations (missing/malformed markers)
// up front — these are fatal for --write regardless of --force, because
// --force overrides a STALE region (a legitimate "write it anyway" ask),
// never a structurally broken target (there is nothing to splice into).
const originals = new Map();
for (const target of targets) {
const original = readTarget(target, violations);
originals.set(target.key, original);
violations.push(...collectTargetViolations(target, original));
if (original !== null) {
violations.push(...collectKeySetParityViolations(target, schema, original.replace(/\r\n/g, '\n')));
}
}
const hostileViolations = violations.filter((v) =>
[REASON.MARKERS_MISSING, REASON.MARKER_UNCLOSED, REASON.MARKER_ORDER_INVALID].includes(v.reason),
);
// Staleness (only computable for targets with clean markers), per region.
const staleTargets = [];
for (const target of targets) {
const original = originals.get(target.key);
if (original === null) continue;
const hasHostile = hostileViolations.some((v) => v.file === target.relPath);
if (hasHostile) continue;
const { stale } = staleRegionsOf(target, original);
if (stale.length > 0) {
staleTargets.push(target);
for (const region of stale) violations.push({ reason: REASON.REGION_STALE, file: target.relPath, region });
}
}
if (json) {
const ok = violations.length === 0;
process.stdout.write(
JSON.stringify({
ok,
targetCount: targets.length,
staleCount: staleTargets.length,
violations,
}) + '\n',
);
return ok ? 0 : 1;
}
if (check) {
if (violations.length > 0) {
process.stderr.write(`gen-state-md-docs: ${violations.length} violation(s).\n\n`);
for (const v of violations) process.stderr.write(` ✗ ${describeViolation(v)}\n`);
process.stderr.write('\n');
throw new ExitError(1);
}
process.stdout.write(`All ${targets.length} target(s) are up to date.\n`);
return 0;
}
if (write) {
// FAIL-CLOSED (gen-features.cjs precedent, design doc §3): a hostile
// marker violation is ALWAYS fatal — --force overrides staleness, not a
// structurally broken target with nothing to splice into.
if (hostileViolations.length > 0) {
process.stderr.write(`gen-state-md-docs: refusing to write — ${hostileViolations.length} target(s) have broken markers.\n\n`);
for (const v of hostileViolations) process.stderr.write(` ✗ ${describeViolation(v)}\n`);
process.stderr.write('\n');
throw new ExitError(1);
}
const otherViolations = violations.filter((v) => v.reason !== REASON.REGION_STALE && !hostileViolations.includes(v));
if (otherViolations.length > 0 && !force) {
process.stderr.write(`gen-state-md-docs: ${otherViolations.length} violation(s). Refusing to write; pass --force to override.\n\n`);
for (const v of otherViolations) process.stderr.write(` ✗ ${describeViolation(v)}\n`);
process.stderr.write('\n');
throw new ExitError(1);
}
let written = 0;
for (const target of targets) {
const original = originals.get(target.key);
if (original === null) continue;
const hasHostile = hostileViolations.some((v) => v.file === target.relPath);
if (hasHostile) continue;
const spliced = spliceTarget(target, original);
if (spliced !== original) {
fs.writeFileSync(target.absPath, spliced);
written++;
}
}
process.stdout.write(`Wrote ${written} of ${targets.length} target(s).\n`);
return 0;
}
// Default: print every region, labeled, to stdout.
for (const target of targets) {
for (const region of target.regions) {
process.stdout.write(`\n=== ${target.relPath} :: ${region} ===\n`);
process.stdout.write(target.regionBodies[region] + '\n');
}
}
return 0;
}
if (require.main === module) runMain(main);
module.exports = {
REASON,
TARGETS,
MARKER_TAG,
startMarker,
endMarker,
TEMPLATE_FRONTMATTER_FIELDS,
STATUS_LIFECYCLE_STRINGS,
STATUS_LIFECYCLE_ROWS,
CARDINALITY_STRINGS,
EXCLUDED_FIELD_TABLE_KEYS,
KNOWN_SCHEMA_GAP_FIELDS,
FIELD_REFERENCE_HEADING,
STATUS_VALUES_HEADING,
renderFrontmatterRegion,
renderStatusLifecycleRegion,
renderCardinalityRegion,
firstColumnAfterHeading,
collectKeySetParityViolations,
findRegion,
spliceRegion,
spliceTarget,
staleRegionsOf,
isCrlf,
buildCorpus,
describeViolation,
};