feat(#3309): generate health.md's error-code and repair-action tables

Closes the issue's explicit acceptance criterion: "health.md's tables
are generated rather than hand-maintained, closing the 16-vs-30+
documentation gap structurally." The published roster listed 16 codes
against 30+ actually emitted; W010-W017 and W020-W023 had never been
documented.

Adds description/repairable as static fields on Rule (health-diagnostic-types.cts)
— generation needs a fixed, human-readable summary per code, distinct
from the dynamic per-instance Diagnostic.message a rule's check()
produces. repairable is true only when --repair will actually apply
the remedy: false for ADVISE-only rules AND for DESTRUCTIVE-risk rules
(regenerateState/resetConfig), which are described but never
auto-applied — matches verify.cts's diagnosticToIssueEntry semantics
exactly, after fixing E004/E005's static field to agree with it (both
were wrongly true, an inconsistency caught during this same commit's
own review, not left for later).

New scripts/gen-health-docs.cjs (--write/--check, wired into
lint:generated-sync) regenerates the two tagged table regions in
gsd-core/workflows/health.md from RULES (31 rules) plus the 3
pre-checks that stay outside the rule table by design (E001, E010,
I010) plus a small static Effect/Risk lookup for the 6 real repair
actions — including addAiIntegrationPhaseKey, live in code since an
earlier phase but never documented until now. 34 error-code rows, 6
repair-action rows. The table's old "grep verify.cts for the next free
number" footnote is rewritten to point at the rule table and its lint
guard instead.
This commit is contained in:
sim
2026-08-13 03:13:25 -04:00
parent 6a1860c579
commit 041414c4ad
13 changed files with 863 additions and 32 deletions

View File

@@ -216,14 +216,14 @@ Report final status.
</process>
<error_codes>
| 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.
</error_codes>
<repair_actions>
| 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):**

View File

@@ -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",

390
scripts/gen-health-docs.cjs Normal file
View File

@@ -0,0 +1,390 @@
#!/usr/bin/env node
'use strict';
/**
* Generates the `<error_codes>` and `<repair_actions>` 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 `<error_codes>` 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
* (`<step name="run_health_check">`), which is the authoritative, more
* detailed source `<error_codes>` 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,
* `<stale_task_cleanup>` step) — it was never part of the `<error_codes>`
* 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 <path> # 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 = '<error_codes>';
const ERROR_CODES_END = '</error_codes>';
const REPAIR_ACTIONS_START = '<repair_actions>';
const REPAIR_ACTIONS_END = '</repair_actions>';
/**
* 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 `<repair_actions>`). 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 `<error_codes>` 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 `<error_codes>` 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, '&lt;')
.replace(/>/g, '&gt;')
.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 <path>.`);
}
}
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 <error_codes>/<repair_actions> 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,
};

View File

@@ -107,6 +107,8 @@ const RULES: Rule[] = [
{
code: 'W010',
severity: SEVERITY.WARNING,
description: 'GSD agent installation missing or incomplete',
repairable: false,
check: checkAgentInstall,
},
];

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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<string>): Set<string> {
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 [];

View File

@@ -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 };

View File

@@ -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 `<error_codes>`
* 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
}

View File

@@ -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 <path>` 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 <error_codes> 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('<error_codes> 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);
});
});