diff --git a/gsd-core/workflows/health.md b/gsd-core/workflows/health.md
index 55a9b2cda..84e183518 100644
--- a/gsd-core/workflows/health.md
+++ b/gsd-core/workflows/health.md
@@ -216,14 +216,14 @@ Report final status.
-
| Code | Severity | Description | Repairable |
|------|----------|-------------|------------|
| E001 | error | .planning/ directory not found | No |
| E002 | error | PROJECT.md not found | No |
| E003 | error | ROADMAP.md not found | No |
-| E004 | error | STATE.md not found | Yes |
-| E005 | error | config.json parse error | Yes |
+| E004 | error | STATE.md not found | No |
+| E005 | error | config.json parse error | No |
+| E010 | error | CWD resolves to the user's home directory — health check would target the wrong .planning/ | No |
| W001 | warning | PROJECT.md missing required section | No |
| W002 | warning | STATE.md references invalid phase | No |
| W003 | warning | config.json not found | Yes |
@@ -233,24 +233,38 @@ Report final status.
| W007 | warning | Phase on disk but not in ROADMAP | No |
| W008 | warning | config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip) | Yes |
| W009 | warning | Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md | No |
+| W010 | warning | GSD agent installation missing or incomplete | No |
+| W011 | warning | STATE.md current-phase status disagrees with ROADMAP.md checkbox | No |
+| W012 | warning | config.json invalid branching_strategy value | No |
+| W013 | warning | config.json context_window not a positive integer | No |
+| W014 | warning | config.json phase_branch_template missing {phase} placeholder | No |
+| W015 | warning | config.json milestone_branch_template missing {milestone} placeholder | No |
+| W016 | warning | config.json: workflow.ai_integration_phase absent (defaults to enabled but agents may skip AI-integration-phase planning) | Yes |
+| W017 | warning | Orphan git worktree (path no longer exists on disk) | No |
| W018 | warning | MILESTONES.md missing entry for archived milestone snapshot | Yes (`--backfill`) |
| W019 | warning | Unrecognized .planning/ root file — not a canonical GSD artifact | No |
+| W020 | warning | Worktree health scan degraded — git worktree list timed out, failed, or a finding could not be verified | No |
+| W021 | warning | Phase's integer prefix implies a different milestone than its ROADMAP section (phase_id_convention: milestone-prefixed) | No |
+| W022 | warning | config.json models entry malformed (unknown phase type, invalid tier, or non-object value) | No |
+| W023 | warning | Phase directories collide on normalized key | No |
| W024 | warning | STATE.md was written many commits ago — treat its contents as approximate | No |
-| W025 | warning | config.json: workflow.use_worktrees enabled on a runtime whose dispatch.isolation is none (#2486) | No |
+| W026 | warning | STATE says milestone complete but ROADMAP lists an unstarted phase for that milestone | No |
+| W027 | warning | Stale git worktree (not modified in a long time) | No |
| I001 | info | Plan without SUMMARY (may be in progress) | No |
+| I010 | info | Resolved CWD reported alongside the E010 home-directory guard | No |
-Note: the `W0NN` warning-code namespace is owned by `src/verify.cts` (`validate.health`), which also emits codes this table does not list (`W010`–`W017` and `W020`–`W023` as of #2486). `W001`–`W024` are all allocated, so this workflow's isolation warning is `W025`. Before assigning a new code here, grep `src/verify.cts` for the next free number — the table alone under-represents the live namespace, and two PRs in flight can otherwise claim the same code (which is exactly what happened between #2486 and #2573).
+Note: this table is **generated** — do not hand-edit it. It is produced by `node scripts/gen-health-docs.cjs --write` from `src/health-diagnostic.cts`'s `RULES` table (31 rules as of #3309, each carrying a static `description`/`repairable` on its `Rule` entry — see `src/health-diagnostic-types.cts`) plus the 3 pre-checks that stay outside the rule table by design (`E001`, `E010`, `I010` — safety rails in `cmdValidateHealth`, `src/verify.cts`, never `.planning/` findings). `scripts/lint-health-diagnostic-rule-table.cjs` already enforces the 1:1 code invariant this table depends on (no duplicate codes; severity is always a `Rule` property, never set per emit call) — before assigning a new code, add a `Rule` entry under `src/health-diagnostic-rules/` (its `code` is simply the next free number the lint guard has not yet seen) and run `node scripts/gen-health-docs.cjs --write` to regenerate this table; `npm run lint:generated-sync` fails if it drifts. `W025` (the `workflow.use_worktrees`/`dispatch.isolation` check, #2486) is a workflow-layer diagnostic emitted directly by this file's own `run_health_check` step, not by `cmdValidateHealth` — it stays documented in that step, not in this generated table.
-
| Action | Effect | Risk |
|--------|--------|------|
| createConfig | Create config.json with defaults | None |
| resetConfig | Delete + recreate config.json | Loses custom settings |
| regenerateState | Create STATE.md from ROADMAP structure when it is missing | Loses session history |
| addNyquistKey | Add workflow.nyquist_validation: true to config.json | None — matches existing default |
+| addAiIntegrationPhaseKey | Add workflow.ai_integration_phase: true to config.json | None — matches existing default |
| backfillMilestones | Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots | None — additive only; triggered by `--backfill` flag |
**Not repairable (too risky):**
diff --git a/package.json b/package.json
index 2ca21c99a..adfff3145 100644
--- a/package.json
+++ b/package.json
@@ -122,7 +122,7 @@
"lint:test-file-count": "node scripts/lint-test-file-count.cjs",
"lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs",
"lint:changeset": "node scripts/changeset/lint.cjs",
- "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check",
+ "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check && node scripts/gen-health-docs.cjs --check",
"lint:docs": "node scripts/lint-docs-required.cjs",
"lint:qa-smells": "node scripts/qa-smell-ratchet.cjs",
"lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs",
diff --git a/scripts/gen-health-docs.cjs b/scripts/gen-health-docs.cjs
new file mode 100644
index 000000000..53e1b7bc3
--- /dev/null
+++ b/scripts/gen-health-docs.cjs
@@ -0,0 +1,390 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * Generates the `` and `` tables in
+ * `gsd-core/workflows/health.md` from `src/health-diagnostic.cts`'s `RULES`
+ * table (Phase 11 follow-up, #3309 "Proposed behavior": "health.md's tables
+ * are generated rather than hand-maintained, closing the 16-vs-30+
+ * documentation gap structurally").
+ *
+ * Sources:
+ * - The 31 real rules in the compiled `RULES` array
+ * (`gsd-core/bin/lib/health-diagnostic.cjs`, built from
+ * `src/health-diagnostic.cts` + `src/health-diagnostic-rules/*.cts`),
+ * each carrying a static `description`/`repairable` (see
+ * `src/health-diagnostic-types.cts`'s `Rule` interface).
+ * - `PRECHECK_CODES` below — E001, E010, I010 — the three diagnostics
+ * `cmdValidateHealth` (`src/verify.cts`) emits as pre-checks OUTSIDE the
+ * rule table entirely (ADR-3180 §8.2 rule 4, "no precedence system" —
+ * see `.gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md`,
+ * "Two guards that stay OUTSIDE the rule table entirely"). These will
+ * never appear in `RULES`, so they are a small, static, clearly-labeled
+ * list merged in here instead.
+ * - `REMEDY_ACTION_METADATA` below — the Effect/Risk prose for each of the
+ * 6 real repair actions (`REMEDY_ACTION`, excluding `ADVISE`, which never
+ * acts). Static because the compiled module carries no Effect/Risk text
+ * of its own — only the action identifier.
+ *
+ * Deliberately EXCLUDED from the generated `` table: `W025`
+ * (the `workflow.use_worktrees`/`dispatch.isolation` check, #2486). It is a
+ * workflow-layer diagnostic emitted directly by this same file's own
+ * `run_health_check` step (a bash block in `health.md` itself), never by
+ * `cmdValidateHealth`/`RULES` — it has no `Rule` entry and is not one of the
+ * three pre-checks above. It stays fully documented in prose at its own step
+ * (``), which is the authoritative, more
+ * detailed source `` used to merely summarize; dropping the
+ * redundant table row is not a loss of information, and folding it back in
+ * here would require this generator to parse bash, which it does not do.
+ * Same reasoning for `I002` (stale Windows task-directory cleanup,
+ * `` step) — it was never part of the ``
+ * tagged region even before this generator existed.
+ *
+ * Usage:
+ * node scripts/gen-health-docs.cjs # print both tables to stdout
+ * node scripts/gen-health-docs.cjs --write # rewrite the tagged regions in health.md
+ * node scripts/gen-health-docs.cjs --check # exit 1 if either region is stale
+ * node scripts/gen-health-docs.cjs --write --target # test-only: target a fixture file
+ */
+
+const fs = require('node:fs');
+const path = require('node:path');
+
+const { ExitError, runMain } = require('./lib/cli-exit.cjs');
+
+const ROOT = path.resolve(__dirname, '..');
+const HEALTH_MD_REL = 'gsd-core/workflows/health.md';
+const HEALTH_MD_PATH = path.join(ROOT, HEALTH_MD_REL);
+const COMPILED_MODULE_REL = 'gsd-core/bin/lib/health-diagnostic.cjs';
+const COMPILED_MODULE_PATH = path.join(ROOT, COMPILED_MODULE_REL);
+
+const ERROR_CODES_START = '';
+const ERROR_CODES_END = '';
+const REPAIR_ACTIONS_START = '';
+const REPAIR_ACTIONS_END = '';
+
+/**
+ * The 3 pre-check diagnostics `cmdValidateHealth` emits OUTSIDE the rule
+ * table (see module header). All three are non-repairable safety rails, not
+ * `.planning/` findings a remedy could act on.
+ */
+const PRECHECK_CODES = [
+ {
+ code: 'E001',
+ severity: 'error',
+ description: '.planning/ directory not found',
+ repairable: false,
+ },
+ {
+ code: 'E010',
+ severity: 'error',
+ description: "CWD resolves to the user's home directory — health check would target the wrong .planning/",
+ repairable: false,
+ },
+ {
+ code: 'I010',
+ severity: 'info',
+ description: 'Resolved CWD reported alongside the E010 home-directory guard',
+ repairable: false,
+ },
+];
+
+/**
+ * Effect/Risk prose per real `REMEDY_ACTION` (everything except `ADVISE`,
+ * which never acts and has no row in ``). Text for the 5
+ * actions the hand-written table already documented is reused VERBATIM;
+ * `addAiIntegrationPhaseKey` is new — #3309 itself notes it was "live in
+ * code, missing from docs" (mirrors `addNyquistKey`, its structural sibling:
+ * same shape, one config key each).
+ */
+const REMEDY_ACTION_METADATA = new Map([
+ ['createConfig', { effect: 'Create config.json with defaults', risk: 'None' }],
+ ['resetConfig', { effect: 'Delete + recreate config.json', risk: 'Loses custom settings' }],
+ [
+ 'regenerateState',
+ {
+ effect: 'Create STATE.md from ROADMAP structure when it is missing',
+ risk: 'Loses session history',
+ },
+ ],
+ [
+ 'addNyquistKey',
+ { effect: 'Add workflow.nyquist_validation: true to config.json', risk: 'None — matches existing default' },
+ ],
+ [
+ 'addAiIntegrationPhaseKey',
+ { effect: 'Add workflow.ai_integration_phase: true to config.json', risk: 'None — matches existing default' },
+ ],
+ [
+ 'backfillMilestones',
+ {
+ effect: 'Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots',
+ risk: 'None — additive only; triggered by `--backfill` flag',
+ },
+ ],
+]);
+
+/** Order the Effect/Risk table renders in — matches `REMEDY_ACTION`'s own declaration order. */
+const REMEDY_ACTION_ORDER = [
+ 'createConfig',
+ 'resetConfig',
+ 'regenerateState',
+ 'addNyquistKey',
+ 'addAiIntegrationPhaseKey',
+ 'backfillMilestones',
+];
+
+/**
+ * Per-code override for the "Repairable" cell's display text, for codes
+ * whose remedy is conditional on a flag the plain `Yes`/`No` can't express
+ * (mirrors the hand-written table's pre-existing `W018` row: `Yes (--backfill)`).
+ */
+const REPAIRABLE_DISPLAY_OVERRIDE = new Map([['W018', 'Yes (`--backfill`)']]);
+
+const STATIC_NOT_REPAIRABLE_BULLETS = [
+ 'PROJECT.md, ROADMAP.md content',
+ 'Phase directory renaming',
+ 'Orphaned plan cleanup',
+];
+
+const FOOTNOTE_PARAGRAPH =
+ 'Note: this table is **generated** — do not hand-edit it. It is produced by ' +
+ '`node scripts/gen-health-docs.cjs --write` from `src/health-diagnostic.cts`\'s `RULES` table ' +
+ '(31 rules as of #3309, each carrying a static `description`/`repairable` on its `Rule` entry — ' +
+ 'see `src/health-diagnostic-types.cts`) plus the 3 pre-checks that stay outside the rule table by ' +
+ 'design (`E001`, `E010`, `I010` — safety rails in `cmdValidateHealth`, `src/verify.cts`, never ' +
+ '`.planning/` findings). `scripts/lint-health-diagnostic-rule-table.cjs` already enforces the 1:1 ' +
+ 'code invariant this table depends on (no duplicate codes; severity is always a `Rule` property, ' +
+ 'never set per emit call) — before assigning a new code, add a `Rule` entry under ' +
+ '`src/health-diagnostic-rules/` (its `code` is simply the next free number the lint guard has not ' +
+ 'yet seen) and run `node scripts/gen-health-docs.cjs --write` to regenerate this table; ' +
+ '`npm run lint:generated-sync` fails if it drifts. `W025` (the `workflow.use_worktrees`/' +
+ '`dispatch.isolation` check, #2486) is a workflow-layer diagnostic emitted directly by this file\'s ' +
+ 'own `run_health_check` step, not by `cmdValidateHealth` — it stays documented in that step, not in ' +
+ 'this generated table.';
+
+/**
+ * Load the compiled health-diagnostic module. Throws a clear ExitError (not
+ * a raw MODULE_NOT_FOUND) if `npm run build:lib` has not run — mirrors
+ * `scripts/lint-health-diagnostic-rule-table.cjs`'s `loadCompiledModule`.
+ */
+function loadCompiledModule(compiledPath = COMPILED_MODULE_PATH) {
+ if (!fs.existsSync(compiledPath)) {
+ throw new ExitError(
+ 2,
+ `gen-health-docs: compiled artifact not found at ${COMPILED_MODULE_REL}.\n` +
+ 'Run `npm run build:lib` first.',
+ );
+ }
+ return require(compiledPath);
+}
+
+/**
+ * Sort order for the `` table: E-codes, then W-codes
+ * numerically, then I-codes — matching the hand-written table's pre-existing
+ * order. NOT insertion order from `RULES` (which is grouped by
+ * subject-area file, not sorted by code).
+ */
+const PREFIX_RANK = { E: 0, W: 1, I: 2 };
+
+function parseCode(code) {
+ const m = code.match(/^([A-Z]+)(\d+)$/);
+ if (!m) throw new Error(`gen-health-docs: unparseable diagnostic code "${code}"`);
+ return { prefix: m[1], number: Number(m[2]) };
+}
+
+function compareCodes(a, b) {
+ const pa = parseCode(a.code);
+ const pb = parseCode(b.code);
+ const rankA = PREFIX_RANK[pa.prefix] ?? 99;
+ const rankB = PREFIX_RANK[pb.prefix] ?? 99;
+ if (rankA !== rankB) return rankA - rankB;
+ return pa.number - pb.number;
+}
+
+/**
+ * Combine the 31 real rules + the 3 static pre-checks into one sorted row
+ * list for the `` table.
+ *
+ * @param {Array<{code: string, severity: string, description: string, repairable: boolean}>} rules
+ */
+function buildErrorCodeRows(rules) {
+ const seen = new Set();
+ const rows = [];
+ for (const entry of [...rules, ...PRECHECK_CODES]) {
+ if (seen.has(entry.code)) {
+ throw new Error(`gen-health-docs: duplicate diagnostic code "${entry.code}" across RULES + PRECHECK_CODES`);
+ }
+ seen.add(entry.code);
+ rows.push(entry);
+ }
+ rows.sort(compareCodes);
+ return rows;
+}
+
+/** Escape a cell's markdown-table-hostile characters (mirrors gen-adr-index.cjs's `cellText`). */
+function cellText(text) {
+ return String(text)
+ .replace(/\\/g, '\\\\')
+ .replace(/\|/g, '\\|')
+ .replace(//g, '>')
+ .replace(/\r?\n/g, ' ')
+ .trim();
+}
+
+function repairableCell(row) {
+ if (REPAIRABLE_DISPLAY_OVERRIDE.has(row.code)) return REPAIRABLE_DISPLAY_OVERRIDE.get(row.code);
+ return row.repairable ? 'Yes' : 'No';
+}
+
+function renderErrorCodesRegion(rules) {
+ const rows = buildErrorCodeRows(rules);
+ const lines = ['', '| Code | Severity | Description | Repairable |', '|------|----------|-------------|------------|'];
+ for (const row of rows) {
+ lines.push(`| ${row.code} | ${row.severity} | ${cellText(row.description)} | ${repairableCell(row)} |`);
+ }
+ lines.push('', FOOTNOTE_PARAGRAPH, '');
+ return lines.join('\n');
+}
+
+function renderRepairActionsRegion() {
+ const lines = ['', '| Action | Effect | Risk |', '|--------|--------|------|'];
+ for (const action of REMEDY_ACTION_ORDER) {
+ const meta = REMEDY_ACTION_METADATA.get(action);
+ if (!meta) {
+ throw new Error(
+ `gen-health-docs: no Effect/Risk metadata registered for repair action "${action}" — add an entry to REMEDY_ACTION_METADATA.`,
+ );
+ }
+ lines.push(`| ${action} | ${meta.effect} | ${meta.risk} |`);
+ }
+ lines.push('', '**Not repairable (too risky):**');
+ for (const bullet of STATIC_NOT_REPAIRABLE_BULLETS) lines.push(`- ${bullet}`);
+ lines.push('');
+ return lines.join('\n');
+}
+
+/**
+ * Splice `newInner` between `${startTag}`/`${endTag}` inside `text`. Throws
+ * if either tag is missing, or if the tags appear more than once (this
+ * generator only ever targets the FIRST occurrence pair, and a duplicate
+ * tag anywhere in the file would silently corrupt the splice).
+ */
+function spliceRegion(text, startTag, endTag, newInner) {
+ const startIdx = text.indexOf(startTag);
+ const endIdx = text.indexOf(endTag);
+ if (startIdx === -1 || endIdx === -1) {
+ throw new ExitError(
+ 1,
+ `gen-health-docs: ${HEALTH_MD_REL} is missing the ${startTag}/${endTag} tags.`,
+ );
+ }
+ if (text.indexOf(startTag, startIdx + 1) !== -1 || text.indexOf(endTag, endIdx + 1) !== -1) {
+ throw new ExitError(1, `gen-health-docs: ${HEALTH_MD_REL} has more than one ${startTag}/${endTag} pair.`);
+ }
+ const before = text.slice(0, startIdx + startTag.length);
+ const after = text.slice(endIdx);
+ return `${before}${newInner}\n${after}`;
+}
+
+/**
+ * Regenerate `health.md`'s full text from `rules` (the compiled `RULES`
+ * array) and the current on-disk `health.md` content.
+ */
+function regenerateHealthMd(rules, currentText) {
+ let out = spliceRegion(currentText, ERROR_CODES_START, ERROR_CODES_END, renderErrorCodesRegion(rules));
+ out = spliceRegion(out, REPAIR_ACTIONS_START, REPAIR_ACTIONS_END, renderRepairActionsRegion());
+ return out;
+}
+
+/**
+ * @param {string[]} argv
+ * @returns {{write: boolean, check: boolean, targetPath: string|null}}
+ */
+function parseArgs(argv) {
+ const opts = { write: false, check: false, targetPath: null };
+ 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 === '--target') {
+ const value = argv[i + 1];
+ if (value === undefined) throw new ExitError(1, '--target requires a path argument.');
+ opts.targetPath = value;
+ i++;
+ } else {
+ throw new ExitError(1, `unknown flag: ${arg}\nRecognized flags: --write, --check, --target .`);
+ }
+ }
+ return opts;
+}
+
+function main() {
+ const { write, check, targetPath } = parseArgs(process.argv.slice(2));
+ const { RULES } = loadCompiledModule();
+
+ // `--target` overrides the real committed health.md path, exclusively for
+ // test isolation (mirrors gen-section-manifest.cjs's `--manifest-path`
+ // override) — no production caller ever passes it.
+ const resolvedPath = targetPath ? path.resolve(targetPath) : HEALTH_MD_PATH;
+ const displayPath = targetPath ? targetPath : HEALTH_MD_REL;
+
+ const currentText = fs.existsSync(resolvedPath) ? fs.readFileSync(resolvedPath, 'utf8') : null;
+ if (currentText === null) {
+ throw new ExitError(1, `gen-health-docs: ${displayPath} not found.`);
+ }
+
+ const expected = regenerateHealthMd(RULES, currentText);
+
+ if (write) {
+ fs.writeFileSync(resolvedPath, expected, 'utf8');
+ process.stdout.write(
+ `Wrote ${displayPath} — ${RULES.length + PRECHECK_CODES.length} error/warning/info code(s), ` +
+ `${REMEDY_ACTION_ORDER.length} repair action(s).\n`,
+ );
+ return 0;
+ }
+
+ if (check) {
+ if (expected !== currentText) {
+ process.stderr.write(
+ `${displayPath} is stale — its / tables do not match ` +
+ "src/health-diagnostic.cts's RULES table.\nRun:\n node scripts/gen-health-docs.cjs --write\n\n",
+ );
+ throw new ExitError(1);
+ }
+ process.stdout.write(
+ `${displayPath} is up to date (${RULES.length + PRECHECK_CODES.length} codes, ${REMEDY_ACTION_ORDER.length} repair actions).\n`,
+ );
+ return 0;
+ }
+
+ process.stdout.write(renderErrorCodesRegion(RULES) + '\n\n' + renderRepairActionsRegion() + '\n');
+ return 0;
+}
+
+// Guarded: requiring this module (the test suite imports the pure render
+// functions directly) must not also run the generator as a side effect.
+if (require.main === module) runMain(main);
+
+module.exports = {
+ loadCompiledModule,
+ buildErrorCodeRows,
+ renderErrorCodesRegion,
+ renderRepairActionsRegion,
+ regenerateHealthMd,
+ spliceRegion,
+ compareCodes,
+ parseCode,
+ PRECHECK_CODES,
+ REMEDY_ACTION_METADATA,
+ REMEDY_ACTION_ORDER,
+ REPAIRABLE_DISPLAY_OVERRIDE,
+ HEALTH_MD_PATH,
+ COMPILED_MODULE_PATH,
+ ERROR_CODES_START,
+ ERROR_CODES_END,
+ REPAIR_ACTIONS_START,
+ REPAIR_ACTIONS_END,
+};
diff --git a/src/health-diagnostic-rules/agent-install.cts b/src/health-diagnostic-rules/agent-install.cts
index 49392944f..ae7cdfad7 100644
--- a/src/health-diagnostic-rules/agent-install.cts
+++ b/src/health-diagnostic-rules/agent-install.cts
@@ -107,6 +107,8 @@ const RULES: Rule[] = [
{
code: 'W010',
severity: SEVERITY.WARNING,
+ description: 'GSD agent installation missing or incomplete',
+ repairable: false,
check: checkAgentInstall,
},
];
diff --git a/src/health-diagnostic-rules/config-validation.cts b/src/health-diagnostic-rules/config-validation.cts
index c997767d5..867b28440 100644
--- a/src/health-diagnostic-rules/config-validation.cts
+++ b/src/health-diagnostic-rules/config-validation.cts
@@ -276,16 +276,59 @@ function checkW022(snapshot: PlanningSnapshot): Diagnostic[] {
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
- { code: 'W003', severity: SEVERITY.WARNING, check: checkW003 },
- { code: 'E005', severity: SEVERITY.ERROR, check: checkE005 },
- { code: 'W004', severity: SEVERITY.WARNING, check: checkW004 },
- { code: 'W008', severity: SEVERITY.WARNING, check: checkW008 },
- { code: 'W016', severity: SEVERITY.WARNING, check: checkW016 },
- { code: 'W012', severity: SEVERITY.WARNING, check: checkW012 },
- { code: 'W013', severity: SEVERITY.WARNING, check: checkW013 },
- { code: 'W014', severity: SEVERITY.WARNING, check: checkW014 },
- { code: 'W015', severity: SEVERITY.WARNING, check: checkW015 },
- { code: 'W022', severity: SEVERITY.WARNING, check: checkW022 },
+ { code: 'W003', severity: SEVERITY.WARNING, description: 'config.json not found', repairable: true, check: checkW003 },
+ { code: 'E005', severity: SEVERITY.ERROR, description: 'config.json parse error', repairable: false, check: checkE005 },
+ { code: 'W004', severity: SEVERITY.WARNING, description: 'config.json invalid field value', repairable: false, check: checkW004 },
+ {
+ code: 'W008',
+ severity: SEVERITY.WARNING,
+ description: 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)',
+ repairable: true,
+ check: checkW008,
+ },
+ {
+ code: 'W016',
+ severity: SEVERITY.WARNING,
+ description:
+ 'config.json: workflow.ai_integration_phase absent (defaults to enabled but agents may skip AI-integration-phase planning)',
+ repairable: true,
+ check: checkW016,
+ },
+ {
+ code: 'W012',
+ severity: SEVERITY.WARNING,
+ description: 'config.json invalid branching_strategy value',
+ repairable: false,
+ check: checkW012,
+ },
+ {
+ code: 'W013',
+ severity: SEVERITY.WARNING,
+ description: 'config.json context_window not a positive integer',
+ repairable: false,
+ check: checkW013,
+ },
+ {
+ code: 'W014',
+ severity: SEVERITY.WARNING,
+ description: 'config.json phase_branch_template missing {phase} placeholder',
+ repairable: false,
+ check: checkW014,
+ },
+ {
+ code: 'W015',
+ severity: SEVERITY.WARNING,
+ description: 'config.json milestone_branch_template missing {milestone} placeholder',
+ repairable: false,
+ check: checkW015,
+ },
+ {
+ code: 'W022',
+ severity: SEVERITY.WARNING,
+ description: 'config.json models entry malformed (unknown phase type, invalid tier, or non-object value)',
+ repairable: false,
+ check: checkW022,
+ },
];
export = { RULES };
diff --git a/src/health-diagnostic-rules/milestone-archive-hygiene.cts b/src/health-diagnostic-rules/milestone-archive-hygiene.cts
index f7751ac0f..99a02c305 100644
--- a/src/health-diagnostic-rules/milestone-archive-hygiene.cts
+++ b/src/health-diagnostic-rules/milestone-archive-hygiene.cts
@@ -96,8 +96,20 @@ function checkW019(snapshot: PlanningSnapshot): Diagnostic[] {
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
- { code: 'W018', severity: SEVERITY.WARNING, check: checkW018 },
- { code: 'W019', severity: SEVERITY.WARNING, check: checkW019 },
+ {
+ code: 'W018',
+ severity: SEVERITY.WARNING,
+ description: 'MILESTONES.md missing entry for archived milestone snapshot',
+ repairable: true,
+ check: checkW018,
+ },
+ {
+ code: 'W019',
+ severity: SEVERITY.WARNING,
+ description: 'Unrecognized .planning/ root file — not a canonical GSD artifact',
+ repairable: false,
+ check: checkW019,
+ },
];
export = { RULES };
diff --git a/src/health-diagnostic-rules/phase-structure.cts b/src/health-diagnostic-rules/phase-structure.cts
index 504f0413b..7b78f1ce4 100644
--- a/src/health-diagnostic-rules/phase-structure.cts
+++ b/src/health-diagnostic-rules/phase-structure.cts
@@ -180,10 +180,34 @@ function checkW009(snapshot: PlanningSnapshot): Diagnostic[] {
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
- { code: 'W005', severity: SEVERITY.WARNING, check: checkW005 },
- { code: 'W023', severity: SEVERITY.WARNING, check: checkW023 },
- { code: 'I001', severity: SEVERITY.INFO, check: checkI001 },
- { code: 'W009', severity: SEVERITY.WARNING, check: checkW009 },
+ {
+ code: 'W005',
+ severity: SEVERITY.WARNING,
+ description: 'Phase directory naming mismatch',
+ repairable: false,
+ check: checkW005,
+ },
+ {
+ code: 'W023',
+ severity: SEVERITY.WARNING,
+ description: 'Phase directories collide on normalized key',
+ repairable: false,
+ check: checkW023,
+ },
+ {
+ code: 'I001',
+ severity: SEVERITY.INFO,
+ description: 'Plan without SUMMARY (may be in progress)',
+ repairable: false,
+ check: checkI001,
+ },
+ {
+ code: 'W009',
+ severity: SEVERITY.WARNING,
+ description: 'Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md',
+ repairable: false,
+ check: checkW009,
+ },
];
export = { RULES };
diff --git a/src/health-diagnostic-rules/roadmap-disk-consistency.cts b/src/health-diagnostic-rules/roadmap-disk-consistency.cts
index 7265fdeb8..83ec18898 100644
--- a/src/health-diagnostic-rules/roadmap-disk-consistency.cts
+++ b/src/health-diagnostic-rules/roadmap-disk-consistency.cts
@@ -210,8 +210,20 @@ function checkW007(snapshot: PlanningSnapshot): Diagnostic[] {
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
- { code: 'W006', severity: SEVERITY.WARNING, check: checkW006 },
- { code: 'W007', severity: SEVERITY.WARNING, check: checkW007 },
+ {
+ code: 'W006',
+ severity: SEVERITY.WARNING,
+ description: 'Phase in ROADMAP but no directory',
+ repairable: false,
+ check: checkW006,
+ },
+ {
+ code: 'W007',
+ severity: SEVERITY.WARNING,
+ description: 'Phase on disk but not in ROADMAP',
+ repairable: false,
+ check: checkW007,
+ },
];
export = { RULES };
diff --git a/src/health-diagnostic-rules/root-existence.cts b/src/health-diagnostic-rules/root-existence.cts
index 8208116b1..0e34689d9 100644
--- a/src/health-diagnostic-rules/root-existence.cts
+++ b/src/health-diagnostic-rules/root-existence.cts
@@ -163,10 +163,16 @@ function checkW001(snapshot: PlanningSnapshot): Diagnostic[] {
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
- { code: 'E002', severity: SEVERITY.ERROR, check: checkE002 },
- { code: 'E003', severity: SEVERITY.ERROR, check: checkE003 },
- { code: 'E004', severity: SEVERITY.ERROR, check: checkE004 },
- { code: 'W001', severity: SEVERITY.WARNING, check: checkW001 },
+ { code: 'E002', severity: SEVERITY.ERROR, description: 'PROJECT.md not found', repairable: false, check: checkE002 },
+ { code: 'E003', severity: SEVERITY.ERROR, description: 'ROADMAP.md not found', repairable: false, check: checkE003 },
+ { code: 'E004', severity: SEVERITY.ERROR, description: 'STATE.md not found', repairable: false, check: checkE004 },
+ {
+ code: 'W001',
+ severity: SEVERITY.WARNING,
+ description: 'PROJECT.md missing required section',
+ repairable: false,
+ check: checkW001,
+ },
];
export = { RULES };
diff --git a/src/health-diagnostic-rules/state-consistency.cts b/src/health-diagnostic-rules/state-consistency.cts
index e908b992d..b0a24e85c 100644
--- a/src/health-diagnostic-rules/state-consistency.cts
+++ b/src/health-diagnostic-rules/state-consistency.cts
@@ -86,6 +86,8 @@ const { getMilestoneFromPhaseId, matchPhaseDirs, normalizePhaseName, extractPhas
const RULE_W024: Rule = {
code: 'W024',
severity: SEVERITY.WARNING,
+ description: 'STATE.md was written many commits ago — treat its contents as approximate',
+ repairable: false,
check: (_snapshot: PlanningSnapshot): Diagnostic[] => [],
};
@@ -139,6 +141,8 @@ function normalizePhaseTokenSet(valid: Set): Set {
const RULE_W002: Rule = {
code: 'W002',
severity: SEVERITY.WARNING,
+ description: 'STATE.md references invalid phase',
+ repairable: false,
check: (snapshot: PlanningSnapshot): Diagnostic[] => {
const validPhases = buildValidPhaseSet(snapshot);
// Mirrors `verify.cts:1765`'s `if (normalizedValid.size > 0)` guard
@@ -193,6 +197,8 @@ function currentPhaseIdFromLabel(label: string | null): string | null {
const RULE_W011: Rule = {
code: 'W011',
severity: SEVERITY.WARNING,
+ description: 'STATE.md current-phase status disagrees with ROADMAP.md checkbox',
+ repairable: false,
check: (snapshot: PlanningSnapshot): Diagnostic[] => {
const phaseId = currentPhaseIdFromLabel(snapshot.currentPhaseLabel.value);
if (phaseId === null) return [];
@@ -216,6 +222,9 @@ const RULE_W011: Rule = {
const RULE_W021: Rule = {
code: 'W021',
severity: SEVERITY.WARNING,
+ description:
+ "Phase's integer prefix implies a different milestone than its ROADMAP section (phase_id_convention: milestone-prefixed)",
+ repairable: false,
check: (snapshot: PlanningSnapshot): Diagnostic[] => {
const convention = snapshot.config.value?.['phase_id_convention'];
if (convention !== 'milestone-prefixed') return [];
@@ -246,6 +255,8 @@ const RULE_W021: Rule = {
const RULE_W026: Rule = {
code: 'W026',
severity: SEVERITY.WARNING,
+ description: 'STATE says milestone complete but ROADMAP lists an unstarted phase for that milestone',
+ repairable: false,
check: (snapshot: PlanningSnapshot): Diagnostic[] => {
const statusVal = (snapshot.stateStatus.value ?? '').trim().toLowerCase();
if (!/milestone complete|archived/.test(statusVal)) return [];
diff --git a/src/health-diagnostic-rules/worktree-health.cts b/src/health-diagnostic-rules/worktree-health.cts
index 4ca349b5a..b5613f359 100644
--- a/src/health-diagnostic-rules/worktree-health.cts
+++ b/src/health-diagnostic-rules/worktree-health.cts
@@ -154,9 +154,27 @@ function checkW027(snapshot: PlanningSnapshot): Diagnostic[] {
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
- { code: 'W020', severity: SEVERITY.WARNING, check: checkW020 },
- { code: 'W017', severity: SEVERITY.WARNING, check: checkW017 },
- { code: 'W027', severity: SEVERITY.WARNING, check: checkW027 },
+ {
+ code: 'W020',
+ severity: SEVERITY.WARNING,
+ description: 'Worktree health scan degraded — git worktree list timed out, failed, or a finding could not be verified',
+ repairable: false,
+ check: checkW020,
+ },
+ {
+ code: 'W017',
+ severity: SEVERITY.WARNING,
+ description: 'Orphan git worktree (path no longer exists on disk)',
+ repairable: false,
+ check: checkW017,
+ },
+ {
+ code: 'W027',
+ severity: SEVERITY.WARNING,
+ description: 'Stale git worktree (not modified in a long time)',
+ repairable: false,
+ check: checkW027,
+ },
];
export = { RULES };
diff --git a/src/health-diagnostic-types.cts b/src/health-diagnostic-types.cts
index e3de6eb7b..c6a397364 100644
--- a/src/health-diagnostic-types.cts
+++ b/src/health-diagnostic-types.cts
@@ -77,6 +77,47 @@ interface Diagnostic {
interface Rule {
code: string;
severity: Severity;
+ /**
+ * Short, static, human-readable summary of what this rule checks — the
+ * source of `gsd-core/workflows/health.md`'s generated ``
+ * table (`scripts/gen-health-docs.cjs`). Deliberately distinct from a
+ * fired `Diagnostic`'s `message`, which is dynamic/per-instance (e.g.
+ * W001's message names the specific PROJECT.md section that is missing);
+ * `description` is exactly one fixed sentence per code, matching the
+ * hand-written table's pre-existing style for the codes it already
+ * documented (E001-E005, W001-W009, W018, W019, W024, I001).
+ */
+ description: string;
+ /**
+ * Whether `--repair` will actually apply this rule's remedy (`true`) or
+ * never will (`false`) — the source of the generated table's "Repairable"
+ * column. This MUST match `diagnosticToIssueEntry`'s (`src/verify.cts`)
+ * per-diagnostic semantics: `remedy.action !== ADVISE && remedy.risk !==
+ * REMEDY_RISK.DESTRUCTIVE`. `false` covers TWO distinct cases, and both
+ * must map to `false` here:
+ *
+ * 1. ADVISE-only rules — no real `REMEDY_ACTION` exists to apply.
+ * 2. DESTRUCTIVE-risk rules (`regenerateState`, `resetConfig`) — a real
+ * action exists and is described, but `applyRepairs`'s dispatcher
+ * (`src/health-diagnostic.cts`) refuses to auto-apply any
+ * DESTRUCTIVE-risk remedy (§8.3 rule 3), so `--repair` never applies it
+ * either. "A remedy exists to describe" is NOT sufficient for `true` —
+ * only "an unattended `--repair` run will actually apply it" is.
+ *
+ * STATIC field, not derived by executing `check` against a fixture at
+ * doc-gen time: confirmed by direct read of all 8
+ * `src/health-diagnostic-rules/*.cts` files that every rule in this
+ * codebase uses exactly ONE `remedy.action` (and therefore one
+ * `remedy.risk`) across every `Diagnostic` it can ever emit — no rule mixes
+ * ADVISE with a real action, or NONE-risk with DESTRUCTIVE-risk, depending
+ * on the triggering condition (the design doc's "primary remedy" ambiguity
+ * this field's doc comment was asked to consider does not arise in
+ * practice). A single static boolean is therefore a faithful,
+ * execution-free summary, and cheaper/simpler than adding a second
+ * `primaryRemedyAction` field or having the generator import and execute
+ * every rule against a synthetic snapshot.
+ */
+ repairable: boolean;
check: (snapshot: PlanningSnapshot) => Diagnostic[]; // §8.1 rule 1 signature, verbatim
}
diff --git a/tests/gen-health-docs.test.cjs b/tests/gen-health-docs.test.cjs
new file mode 100644
index 000000000..9653e7d07
--- /dev/null
+++ b/tests/gen-health-docs.test.cjs
@@ -0,0 +1,258 @@
+'use strict';
+
+/**
+ * gen-health-docs.cjs regression tests (#3309, "health.md's tables are
+ * generated rather than hand-maintained, closing the 16-vs-30+ documentation
+ * gap structurally").
+ *
+ * Every CLI-level test spawns the real generator (execFileSync) against a
+ * temp copy of the shipped `gsd-core/workflows/health.md`, using the
+ * generator's `--target ` override — never mutates the real committed
+ * file. No fs monkeypatching is needed for these cases.
+ */
+
+const { describe, test } = require('node:test');
+const assert = require('node:assert/strict');
+const fs = require('node:fs');
+const path = require('node:path');
+const { execFileSync } = require('node:child_process');
+
+const { createTempDir, cleanup } = require('./helpers.cjs');
+const {
+ buildErrorCodeRows,
+ renderErrorCodesRegion,
+ renderRepairActionsRegion,
+ regenerateHealthMd,
+ spliceRegion,
+ compareCodes,
+ PRECHECK_CODES,
+ REMEDY_ACTION_ORDER,
+ ERROR_CODES_START,
+ ERROR_CODES_END,
+} = require('../scripts/gen-health-docs.cjs');
+
+const ROOT = path.resolve(__dirname, '..');
+const SCRIPT = path.join(ROOT, 'scripts', 'gen-health-docs.cjs');
+const SHIPPED_HEALTH_MD = path.join(ROOT, 'gsd-core', 'workflows', 'health.md');
+const COMPILED_MODULE_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'health-diagnostic.cjs');
+
+function loadRealRules() {
+ // Real compiled RULES — build:lib is a pretest dependency for the whole
+ // suite (package.json `pretest`), so this is always present by the time
+ // node:test runs these files.
+ return require(COMPILED_MODULE_PATH).RULES;
+}
+
+/**
+ * @param {string[]} args
+ * @param {string} cwd
+ * @returns {{code: number, stdout: string, stderr: string}}
+ */
+function runGenHealthDocs(args, cwd = ROOT) {
+ try {
+ const stdout = execFileSync(process.execPath, [SCRIPT, ...args], {
+ cwd,
+ encoding: 'utf8',
+ stdio: ['pipe', 'pipe', 'pipe'],
+ timeout: 30000,
+ });
+ return { code: 0, stdout, stderr: '' };
+ } catch (err) {
+ return {
+ code: err.status ?? 1,
+ stdout: err.stdout ? err.stdout.toString() : '',
+ stderr: err.stderr ? err.stderr.toString() : '',
+ };
+ }
+}
+
+function copyShippedHealthMd(destDir) {
+ const dest = path.join(destDir, 'health.md');
+ fs.copyFileSync(SHIPPED_HEALTH_MD, dest);
+ return dest;
+}
+
+// ─── CLI: --check / --write round trip ─────────────────────────────────────
+
+describe('gen-health-docs.cjs --check / --write (CLI, --target fixture)', () => {
+ test('--check passes on a freshly-written file', (t) => {
+ const tmpRoot = createTempDir('gen-health-docs-');
+ t.after(() => cleanup(tmpRoot));
+
+ const target = copyShippedHealthMd(tmpRoot);
+
+ const w = runGenHealthDocs(['--write', '--target', target]);
+ assert.equal(w.code, 0, `stderr: ${w.stderr}`);
+
+ const c = runGenHealthDocs(['--check', '--target', target]);
+ assert.equal(c.code, 0, `--check must be clean immediately after --write; stderr: ${c.stderr}`);
+ assert.match(c.stdout, /up to date/);
+ });
+
+ test('--check fails when the tagged region is stale (mutate a temp copy)', (t) => {
+ const tmpRoot = createTempDir('gen-health-docs-');
+ t.after(() => cleanup(tmpRoot));
+
+ const target = copyShippedHealthMd(tmpRoot);
+
+ // Mutate the committed, already-up-to-date table so it drifts from what
+ // the generator would produce — a single row edit is enough.
+ let content = fs.readFileSync(target, 'utf8');
+ assert.ok(content.includes('| E001 | error |'), 'sanity: shipped health.md must carry the E001 row');
+ content = content.replace('| E001 | error |', '| E001 | error-STALE-MUTATION |');
+ fs.writeFileSync(target, content, 'utf8');
+
+ const c = runGenHealthDocs(['--check', '--target', target]);
+ assert.equal(c.code, 1, 'a hand-mutated table must fail --check');
+ assert.match(c.stderr, /is stale/);
+ assert.match(c.stderr, /gen-health-docs\.cjs --write/);
+ });
+
+ test('--write on a stale copy regenerates it back to a clean --check', (t) => {
+ const tmpRoot = createTempDir('gen-health-docs-');
+ t.after(() => cleanup(tmpRoot));
+
+ const target = copyShippedHealthMd(tmpRoot);
+ let content = fs.readFileSync(target, 'utf8');
+ content = content.replace('| W010 |', '| W010-DRIFTED |');
+ fs.writeFileSync(target, content, 'utf8');
+
+ const failedCheck = runGenHealthDocs(['--check', '--target', target]);
+ assert.equal(failedCheck.code, 1, 'sanity: the mutated copy must fail --check first');
+
+ const w = runGenHealthDocs(['--write', '--target', target]);
+ assert.equal(w.code, 0, `stderr: ${w.stderr}`);
+
+ const c = runGenHealthDocs(['--check', '--target', target]);
+ assert.equal(c.code, 0, `stderr: ${c.stderr}`);
+ });
+
+ test('plain invocation (no flag) prints both tables to stdout and exits 0', () => {
+ const r = runGenHealthDocs([]);
+ assert.equal(r.code, 0, `stderr: ${r.stderr}`);
+ assert.match(r.stdout, /\| Code \| Severity \| Description \| Repairable \|/);
+ assert.match(r.stdout, /\| Action \| Effect \| Risk \|/);
+ });
+
+ test('an unrecognized flag exits 1 rather than silently falling through', () => {
+ const r = runGenHealthDocs(['--bogus']);
+ assert.equal(r.code, 1);
+ assert.match(r.stderr, /unknown flag/);
+ });
+
+ test('the shipped gsd-core/workflows/health.md already passes --check against the real repo', () => {
+ const r = runGenHealthDocs(['--check']);
+ assert.equal(r.code, 0, `the committed health.md must already be up to date; stderr: ${r.stderr}`);
+ });
+});
+
+// ─── Row content: representative codes, including previously-undocumented ─
+
+describe('gen-health-docs.cjs row content (representative codes)', () => {
+ const rules = loadRealRules();
+
+ test('produces a 34-row table: 31 rules + 3 pre-checks (E001, E010, I010)', () => {
+ const rows = buildErrorCodeRows(rules);
+ assert.equal(rows.length, 34);
+ const codes = rows.map((r) => r.code);
+ for (const precheck of PRECHECK_CODES) {
+ assert.ok(codes.includes(precheck.code), `missing pre-check code ${precheck.code}`);
+ }
+ });
+
+ test('W010 (previously-undocumented, agent-install) renders with its Rule-sourced description and Repairable=No', () => {
+ const region = renderErrorCodesRegion(rules);
+ const row = region.split('\n').find((line) => line.startsWith('| W010 |'));
+ assert.ok(row, 'W010 row must be present');
+ const w010Rule = rules.find((r) => r.code === 'W010');
+ assert.ok(row.includes(w010Rule.description));
+ assert.match(row, /\| No \|$/);
+ });
+
+ test('W026 (previously-undocumented, new post-migration split code) renders with its Rule-sourced description', () => {
+ const region = renderErrorCodesRegion(rules);
+ const row = region.split('\n').find((line) => line.startsWith('| W026 |'));
+ assert.ok(row, 'W026 row must be present');
+ const w026Rule = rules.find((r) => r.code === 'W026');
+ assert.ok(row.includes(w026Rule.description));
+ });
+
+ test('E004 (already-documented, DESTRUCTIVE-risk remedy) renders with Repairable=No — --repair refuses to auto-apply regenerateState', () => {
+ const region = renderErrorCodesRegion(rules);
+ const row = region.split('\n').find((line) => line.startsWith('| E004 |'));
+ assert.ok(row);
+ assert.match(row, /\| No \|$/);
+ });
+
+ test('W018 renders the --backfill-qualified Repairable override, not a bare "Yes"', () => {
+ const region = renderErrorCodesRegion(rules);
+ const row = region.split('\n').find((line) => line.startsWith('| W018 |'));
+ assert.ok(row);
+ assert.match(row, /Yes \(`--backfill`\)/);
+ });
+
+ test('W025 (workflow-layer diagnostic, not a Rule) is absent from the generated table', () => {
+ const region = renderErrorCodesRegion(rules);
+ assert.ok(
+ !region.split('\n').some((line) => line.startsWith('| W025 |')),
+ 'W025 must not appear as a generated row — it is documented in its own workflow step, not the RULES table',
+ );
+ });
+
+ test(' rows are sorted E-codes, then W-codes numerically, then I-codes', () => {
+ const rows = buildErrorCodeRows(rules);
+ const sorted = [...rows].sort(compareCodes);
+ assert.deepEqual(rows, sorted, 'buildErrorCodeRows must already return its rows in sorted order');
+ // Spot-check the three-group boundary explicitly.
+ const codes = rows.map((r) => r.code);
+ const lastE = codes.lastIndexOf(codes.filter((c) => c.startsWith('E')).at(-1));
+ const firstW = codes.findIndex((c) => c.startsWith('W'));
+ const lastW = codes.lastIndexOf(codes.filter((c) => c.startsWith('W')).at(-1));
+ const firstI = codes.findIndex((c) => c.startsWith('I'));
+ assert.ok(lastE < firstW, 'every E-code must sort before every W-code');
+ assert.ok(lastW < firstI, 'every W-code must sort before every I-code');
+ });
+
+ test('renderRepairActionsRegion lists all 6 real repair actions, including the previously-undocumented addAiIntegrationPhaseKey', () => {
+ const region = renderRepairActionsRegion();
+ for (const action of REMEDY_ACTION_ORDER) {
+ assert.ok(region.includes(`| ${action} |`), `missing repair action row: ${action}`);
+ }
+ assert.equal(REMEDY_ACTION_ORDER.length, 6);
+ assert.ok(region.includes('addAiIntegrationPhaseKey'), '#3309: this action was "live in code, missing from docs"');
+ });
+});
+
+// ─── spliceRegion / regenerateHealthMd — pure-function edge cases ─────────
+
+describe('gen-health-docs.cjs spliceRegion (pure function)', () => {
+ test('throws when a tag is missing', () => {
+ assert.throws(
+ () => spliceRegion('no tags here', ERROR_CODES_START, ERROR_CODES_END, 'x'),
+ /missing the .*tags/,
+ );
+ });
+
+ test('throws when a tag appears more than once', () => {
+ const text = `${ERROR_CODES_START}a${ERROR_CODES_END}${ERROR_CODES_START}b${ERROR_CODES_END}`;
+ assert.throws(() => spliceRegion(text, ERROR_CODES_START, ERROR_CODES_END, 'x'), /more than one/);
+ });
+
+ test('preserves content strictly outside the tags, byte-for-byte', () => {
+ const before = 'PROSE BEFORE\n';
+ const after = '\nPROSE AFTER';
+ const text = `${before}${ERROR_CODES_START}old inner${ERROR_CODES_END}${after}`;
+ const out = spliceRegion(text, ERROR_CODES_START, ERROR_CODES_END, 'new inner');
+ assert.ok(out.startsWith(before + ERROR_CODES_START));
+ assert.ok(out.endsWith(ERROR_CODES_END + after));
+ assert.ok(!out.includes('old inner'));
+ assert.ok(out.includes('new inner'));
+ });
+
+ test('regenerateHealthMd is idempotent: regenerating an already-generated document is a no-op', () => {
+ const rules = loadRealRules();
+ const shipped = fs.readFileSync(SHIPPED_HEALTH_MD, 'utf8');
+ const regenerated = regenerateHealthMd(rules, shipped);
+ assert.equal(regenerated, shipped);
+ });
+});