Files
msd-core/src/health-diagnostic-rules/root-existence.cts
sim 041414c4ad 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.
2026-08-13 03:13:25 -04:00

179 lines
8.3 KiB
TypeScript

/**
* Health Diagnostic — Root existence + PROJECT.md rules (Phase 11, #3309,
* ADR-3180 §8.2/§8.3/§8.5).
*
* Group: "Root existence + PROJECT.md" (design doc, "Rule table organization"
* table) — E002, E003, E004, W001. E001 (the `.planning/` root missing guard)
* stays OUTSIDE the rule table entirely per the design doc's "Two guards that
* stay OUTSIDE the rule table entirely" section — it is not a row here.
*
* Ported behavior-preserving from `cmdValidateHealth`
* (`src/verify.cts:1681-1705`), the exact call sites for E002/E003/E004/W001.
*
* E002's original message interpolates `${slash('new-project')}`
* (`verify.cts:1682`, ``Run ${slash('new-project')} to create``) and E003's
* interpolates `${slash('new-milestone')}` (`verify.cts:1694`, ``Run
* ${slash('new-milestone')} to create roadmap``) — both per-project
* runtime-resolved values (`formatGsdSlash`, `src/runtime-slash.cts`) this
* rule's `(snapshot) => Diagnostic[]` signature has no access to (§8.1 rule
* 1 forbids ambient I/O, including `cwd`, inside `check`). Hardcodes the
* canonical `/gsd-new-project`/`/gsd-new-milestone` hyphen form instead,
* mirroring the sibling "config.json validation" group's W016 rule
* (`src/health-diagnostic-rules/config-validation.cts`), which hardcodes
* `/gsd-ai-integration-phase` the same way for the identical reason.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
*
* ADR-457 build-at-publish: source in src/health-diagnostic-rules/root-existence.cts,
* compiled to gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs (gitignored).
*/
// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time, no runtime require emitted
import type planningSnapshotMod = require('../planning-snapshot.cjs');
type PlanningSnapshot = ReturnType<typeof planningSnapshotMod.buildPlanningSnapshot>;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import healthDiagnosticMod = require('../health-diagnostic-types.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK, adviseRemedy } = healthDiagnosticMod;
type Diagnostic = healthDiagnosticMod.Diagnostic;
type Rule = healthDiagnosticMod.Rule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningScopeMod = require('../planning-scope.cjs');
const { SCOPE } = planningScopeMod;
// ─── E002 — PROJECT.md not found (verify.cts:1682) ─────────────────────────
function checkE002(snapshot: PlanningSnapshot): Diagnostic[] {
if (snapshot.projectSections.exists) return [];
return [
{
code: 'E002',
severity: SEVERITY.ERROR,
message: 'PROJECT.md not found',
remedy: adviseRemedy('/gsd-new-project'),
},
];
}
// ─── E003 — ROADMAP.md not found (verify.cts:1694) ─────────────────────────
//
// Condition uses `snapshot.milestone.scope === SCOPE.UNREADABLE`
// (`getMilestoneInfo`, `src/roadmap-parser.cts`). KNOWN AMBIGUITY (flagged in
// this batch's report, not silently papered over): `getMilestoneInfo` returns
// `SCOPE.UNREADABLE` for TWO distinct causes it does not otherwise
// distinguish — (1) ROADMAP.md absent (`platformReadSync` returns `null` ->
// synthetic `Error('missing')`, no errno, `reportUnreadableRoadmap` finds no
// `.code` and stays silent) and (2) ROADMAP.md present but unreadable (a real
// read fault, e.g. EACCES/EISDIR, which DOES carry an errno and fires
// `warnUnusableInput(ROADMAP_UNREADABLE)`). Unlike `config`/`projectSections`,
// `milestone` carries no `exists` discriminator, so this rule cannot tell the
// two apart from the snapshot alone without adding cwd/fs access to `check`
// (forbidden by §8.1 rule 1). This is a best-effort port of the pre-migration
// condition (`!fs.existsSync(roadmapPath)`), which itself only asked "does
// the file exist" — this rule now also fires (message-mismatched, but
// error-preserving) on a present-but-corrupt ROADMAP.md.
function checkE003(snapshot: PlanningSnapshot): Diagnostic[] {
if (snapshot.milestone.scope !== SCOPE.UNREADABLE) return [];
return [
{
code: 'E003',
severity: SEVERITY.ERROR,
message: 'ROADMAP.md not found',
remedy: adviseRemedy('/gsd-new-milestone'),
},
];
}
// ─── E004 — STATE.md not found (verify.cts:1697) ───────────────────────────
//
// Condition uses `snapshot.currentPhaseLabel.scope === SCOPE.UNREADABLE`
// (`buildStateFields`, `src/planning-snapshot.cts:210-249`). KNOWN GAP
// (flagged in this batch's report): `buildStateFields` collapses TWO distinct
// causes into the same `UNREADABLE` scope with no discriminator field at
// all — STATE.md absent (`platformReadSync` returns `null`, a real
// non-answer, `warnUnusableInput` NOT called) and STATE.md present but
// unreadable (any other read error, e.g. EISDIR, corruption,
// `warnUnusableInput(STATE_UNREADABLE)` fires). Unlike `config`, there is no
// `exists` flag on `currentPhaseLabel` (or on `PlanningSnapshot` generally)
// to distinguish "STATE.md was never created" from "STATE.md exists but
// could not be read" — this is a REAL gap in the current 15-field
// `PlanningSnapshot` shape, not something this rule can work around without
// extending that snapshot (out of this batch's scope per the brief). This
// rule is therefore a best-effort port: it fires E004 ("STATE.md not found")
// for both causes, exactly mirroring what `snapshot.currentPhaseLabel.scope`
// can express today.
//
// Remedy is `regenerateState`, one of the two DESTRUCTIVE-risk actions (loses
// session history, design doc "Risk assignment" section) — per §8.3 rule 3
// `--repair` will refuse to auto-apply it once `applyRepairs`'s dispatch
// wires this rule in; the remedy is still described (ADVISE-shaped for
// display, per `applyRepairs`'s own contract) but never executed.
function checkE004(snapshot: PlanningSnapshot): Diagnostic[] {
if (snapshot.currentPhaseLabel.scope !== SCOPE.UNREADABLE) return [];
return [
{
code: 'E004',
severity: SEVERITY.ERROR,
message: 'STATE.md not found',
remedy: {
action: REMEDY_ACTION.REGENERATE_STATE,
risk: REMEDY_RISK.DESTRUCTIVE,
args: {},
},
},
];
}
// ─── W001 — PROJECT.md missing a required section (verify.cts:1684-1690) ──
//
// `REQUIRED_SECTIONS` carries the exact `## `-prefixed strings
// `verify.cts:1685` uses in its message text; membership is tested against
// `snapshot.projectSections.value`, which `buildProjectSectionsField`
// (`src/planning-snapshot.cts:367-381`) stores WITHOUT the `##` prefix (its
// `/^##\s+(.+)$/gm` capture group), so each required string's own `## `
// prefix is stripped before the membership check. `projectSections.value ===
// null` (PROJECT.md absent OR unreadable) emits zero diagnostics — E002
// already reports absence; this rule does not double-report it.
const REQUIRED_SECTIONS = ['## What This Is', '## Core Value', '## Requirements'];
function checkW001(snapshot: PlanningSnapshot): Diagnostic[] {
const { value } = snapshot.projectSections;
if (value === null) return [];
const diagnostics: Diagnostic[] = [];
for (const required of REQUIRED_SECTIONS) {
const heading = required.replace(/^##\s+/, '');
if (!value.includes(heading)) {
diagnostics.push({
code: 'W001',
severity: SEVERITY.WARNING,
message: `PROJECT.md missing section: ${required}`,
remedy: adviseRemedy('Add section manually'),
});
}
}
return diagnostics;
}
// ─── Exports ────────────────────────────────────────────────────────────────
const RULES: Rule[] = [
{ 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 };