Files
msd-core/src/health-diagnostic.cts
sim d1760e3c31 refactor(#3309): migrate cmdValidateHealth onto the rule table
Replaces cmdValidateHealth's hand-rolled addIssue/switch accumulation
(961 lines) with buildPlanningSnapshot -> evaluateRules -> map to the
legacy {code, message, fix, repairable} shape, bucketed by severity.
Two pre-checks (home-dir E010/I010, .planning/-root-missing E001) stay
outside the rule table entirely, per ADR-3180 §8.2 rule 4 ("no
precedence system") — building "some rules suppress others" into the
table would itself be the forbidden precedence system.

W024 (STATE.md commit-age freshness) also stays outside the table:
its committed rule is a documented permanent no-op (readStateHeadFreshness's
git-log shell-out is ambient I/O a Rule.check may never perform, and no
PlanningSnapshot field carries a commits-behind count). Migrating onto
the rule table as designed would have silently regressed 7 passing
tests in tests/health-validation.test.cjs — found while wiring this
function, kept as a real check in the wrapper instead (same I/O
license applyRepairs already relies on), fixed inline per this repo's
no-defer policy rather than accepted as a silent loss.

Ports the real repair-handler bodies (createConfig/resetConfig,
regenerateState, addNyquistKey/addAiIntegrationPhaseKey,
backfillMilestones) into health-diagnostic.cts's applyRepairs,
replacing the skeleton's stub. DESTRUCTIVE-risk remedies
(resetConfig/regenerateState) are refused by --repair — a disclosed
breaking change; repairable now means "an automatic repair will
actually run," not merely "a remedy exists to describe," so E004/E005
now report repairable:false. --backfill alone now actually triggers
backfillMilestones, fixing a latent bug where its gate was unreachable
without --repair also being set (verify.cts:2504, confirmed dead code
pre-migration).

Test updates distinguish the two explicitly-authorized behavior
changes (DESTRUCTIVE refusal, backfill-alone fix, W021->W026 split)
from preservation — every changed assertion is commented with why, and
new regression tests were added for both changes plus W021/W026
mutual independence. Drift-guard bookkeeping (bypass-baseline shrunk
to the one disclosed W024 exception, milestone-window and
phase-enumeration exemptions, test-file-count allowlist) updated for
the relocated/new functions this migration introduces.
2026-08-13 02:28:49 -04:00

489 lines
22 KiB
TypeScript

/**
* Health Diagnostic — frozen rule-table types, enums, and evaluator for
* `validate health` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5).
*
* Establishes the exact contract every extracted rule builds onto: the
* frozen `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums, the
* `Diagnostic`/`Remedy`/`Rule` shapes, the `RULES` container (the 32 rules
* extracted from `cmdValidateHealth`, `src/verify.cts:1616-2577`, are
* concatenated in from each rule-group file under
* `src/health-diagnostic-rules/`), the `evaluateRules` evaluator, and the
* `applyRepairs` `--repair`/`--backfill` dispatcher — whose per-action
* handlers are REAL here (ported behavior-preserving from
* `verify.cts:2405-2553`'s repair switch), not stubs.
*
* `applyRepairs` does not receive a `PlanningSnapshot` (its call-site
* signature, `(cwd, diagnostics, repair, backfill)`, is a locked contract —
* see `tests/health-diagnostic.test.cjs`) — so, like `cmdValidateHealth`
* itself before this migration, it performs its own bounded filesystem I/O
* to apply a repair. This is not a §8.1 rule 1 violation: that rule
* constrains a RULE's `check(snapshot)` signature (no ambient I/O), not the
* evaluator/dispatcher, which the design doc's "subject-surface gap" section
* already establishes performs I/O once, up front, on the rules' behalf.
*
* `PlanningSnapshot` is deliberately NOT re-exported as a type from
* `planning-snapshot.cts` here (see the design doc's "Known limits" and this
* phase's brief): `ReturnType<typeof buildPlanningSnapshot>` is used inline
* instead, via a type-only `import ... = require(...)` that is fully erased
* at compile time — zero changes to the already-shipped, already-tested
* `planning-snapshot.cts`.
*
* Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md
* Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md
*
* ADR-457 build-at-publish: source in src/health-diagnostic.cts, compiled to
* gsd-core/bin/lib/health-diagnostic.cjs (gitignored).
*/
import fs from 'node:fs';
import path from 'node:path';
// 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>;
// Runtime values (SEVERITY/REMEDY_ACTION/REMEDY_RISK) are needed here — not
// just types — for `applyRepairs`'s comparisons, so this is a normal
// (non type-only) `import ... = require(...)`. `health-diagnostic-types.cjs`
// is the leaf module these enums/types were extracted to, so that this file
// can `require()` every rule-group file below without a circular dependency
// (see that module's file-level comment for the full explanation).
// eslint-disable-next-line @typescript-eslint/no-require-imports
import healthDiagnosticTypesMod = require('./health-diagnostic-types.cjs');
const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticTypesMod;
type Severity = healthDiagnosticTypesMod.Severity;
type RemedyAction = healthDiagnosticTypesMod.RemedyAction;
type RemedyRisk = healthDiagnosticTypesMod.RemedyRisk;
type Remedy = healthDiagnosticTypesMod.Remedy;
type Diagnostic = healthDiagnosticTypesMod.Diagnostic;
type Rule = healthDiagnosticTypesMod.Rule;
// ─── Rule table ─────────────────────────────────────────────────────────────
// Populated by concatenating each rule group's exported `RULES` array (design
// doc, "Rule table organization" section) — the 32 rule functions extracted
// from `cmdValidateHealth`, `src/verify.cts:1616-2577`.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import rootExistenceMod = require('./health-diagnostic-rules/root-existence.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateConsistencyMod = require('./health-diagnostic-rules/state-consistency.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configValidationMod = require('./health-diagnostic-rules/config-validation.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseStructureMod = require('./health-diagnostic-rules/phase-structure.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import agentInstallMod = require('./health-diagnostic-rules/agent-install.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapDiskConsistencyMod = require('./health-diagnostic-rules/roadmap-disk-consistency.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import worktreeHealthMod = require('./health-diagnostic-rules/worktree-health.cjs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
import milestoneArchiveHygieneMod = require('./health-diagnostic-rules/milestone-archive-hygiene.cjs');
const RULES: Rule[] = [
...rootExistenceMod.RULES,
...stateConsistencyMod.RULES,
...configValidationMod.RULES,
...phaseStructureMod.RULES,
...agentInstallMod.RULES,
...roadmapDiskConsistencyMod.RULES,
...worktreeHealthMod.RULES,
...milestoneArchiveHygieneMod.RULES,
];
// ─── Repair-handler runtime dependencies ───────────────────────────────────
//
// Same owners `cmdValidateHealth`'s pre-migration repair switch used
// (`verify.cts:2405-2553`) — ported verbatim, not reinvented.
// eslint-disable-next-line @typescript-eslint/no-require-imports
import planningWorkspaceMod = require('./planning-workspace.cjs');
const { planningRoot, planningDir } = planningWorkspaceMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import configLoaderMod = require('./config-loader.cjs');
const { CONFIG_DEFAULTS } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo } = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import stateMod = require('./state.cjs');
const { writeStateMd } = stateMod;
import { realClock } from './clock.cjs';
import { platformReadSync as safeReadFile, platformWriteSync } from './shell-command-projection.cjs';
import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs';
// ─── Evaluator ──────────────────────────────────────────────────────────────
/**
* Evaluate an explicit `rules` array against `snapshot`, throwing if any two
* entries share a `code` (defense in depth beside the static lint guard,
* §8.2 rule 1, `scripts/lint-health-diagnostic-rule-table.cjs`). Separated
* from `evaluateRules` so the duplicate-code guard is unit-testable against
* a small, locally-constructed fake rule array, independent of the real
* `RULES` table.
*/
function evaluateRuleTable(rules: Rule[], snapshot: PlanningSnapshot): Diagnostic[] {
const seen = new Set<string>();
for (const rule of rules) {
if (seen.has(rule.code)) {
throw new Error(`health-diagnostic: duplicate rule code "${rule.code}" in rule table`);
}
seen.add(rule.code);
}
return rules.flatMap((rule) => rule.check(snapshot));
}
/**
* Evaluate every rule in `RULES` against `snapshot`, flattening each rule's
* `Diagnostic[]` into one array.
*/
function evaluateRules(snapshot: PlanningSnapshot): Diagnostic[] {
return evaluateRuleTable(RULES, snapshot);
}
// ─── Repair dispatcher ──────────────────────────────────────────────────────
// Repair-handler bodies (real, ported from verify.cts:2405-2553).
/**
* One `repairs_performed`-shaped entry (legacy `cmdValidateHealth` output
* shape), tagged with the diagnostic `code` it came from so
* `applyRepairs`'s caller can build BOTH the code-keyed `applied`/`refused`
* arrays this module's own tests lock (`tests/health-diagnostic.test.cjs`)
* AND the action-keyed `repairs_performed` array `cmdValidateHealth` still
* emits. `code` is stripped by the caller before the entry reaches JSON
* output — the legacy shape never carried it.
*/
interface RepairDetail {
code: string;
action: string;
success: boolean;
path?: string;
detail?: string;
error?: string;
}
interface RepairPaths {
rootBase: string;
configPath: string;
statePath: string;
milestonesPath: string;
milestonesArchiveDir: string;
}
/**
* Derive every filesystem path a repair handler needs, from `cwd` alone —
* exactly how `cmdValidateHealth` derived them pre-migration
* (`verify.cts:1644-1652`/`2301-2302`). `config.json`/`MILESTONES.md`/
* `milestones/` are root-scoped (`planningRoot`); `STATE.md` is
* workstream-scoped (`planningDir`) — the same root-vs-workstream split
* `buildConfigField`/`buildStateFields` (`planning-snapshot.cts`) already
* document for the read side.
*/
function repairPaths(cwd: string): RepairPaths {
const rootBase = planningRoot(cwd);
const wsBase = planningDir(cwd);
return {
rootBase,
configPath: path.join(rootBase, 'config.json'),
statePath: path.join(wsBase, 'STATE.md'),
milestonesPath: path.join(rootBase, 'MILESTONES.md'),
milestonesArchiveDir: path.join(rootBase, 'milestones'),
};
}
/** `verify.cts:2413-2429`'s default config.json payload, ported verbatim. */
function defaultConfigPayload(): Record<string, unknown> {
return {
model_profile: CONFIG_DEFAULTS.model_profile,
commit_docs: CONFIG_DEFAULTS.commit_docs,
search_gitignored: CONFIG_DEFAULTS.search_gitignored,
branching_strategy: CONFIG_DEFAULTS.branching_strategy,
phase_branch_template: CONFIG_DEFAULTS.phase_branch_template,
milestone_branch_template: CONFIG_DEFAULTS.milestone_branch_template,
quick_branch_template: CONFIG_DEFAULTS.quick_branch_template,
workflow: {
research: CONFIG_DEFAULTS.research,
plan_check: CONFIG_DEFAULTS.plan_checker,
verifier: CONFIG_DEFAULTS.verifier,
nyquist_validation: CONFIG_DEFAULTS.nyquist_validation,
},
parallelization: CONFIG_DEFAULTS.parallelization,
brave_search: CONFIG_DEFAULTS.brave_search,
};
}
/**
* `verify.cts:2301-2335`'s W018 archived-vs-documented-versions diff,
* relocated verbatim (same two regexes, same two-file read) so
* `backfillMilestones` can recompute exactly which versions are missing
* without a `PlanningSnapshot` (`applyRepairs` is not a `Rule` and is not
* handed one — see this file's header comment). This is the same
* derivation `buildMilestoneArchiveStatusField`
* (`src/planning-snapshot.cts`) already performs for the W018 RULE's read
* side; recomputed here, not re-invented, because the rule's own
* `Diagnostic.remedy.args` carries no version list (confirmed by direct
* read of `src/health-diagnostic-rules/milestone-archive-hygiene.cts`).
*/
function computeMissingMilestoneVersions(milestonesArchiveDir: string, milestonesPath: string): string[] {
let archivedVersions: string[] = [];
try {
if (fs.existsSync(milestonesArchiveDir)) {
const archiveFiles = fs.readdirSync(milestonesArchiveDir);
archivedVersions = archiveFiles
.map((f) => f.match(/^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$/))
.filter((m): m is RegExpMatchArray => m !== null)
.map((m) => m[1]);
}
} catch {
/* intentionally empty — mirrors the original's advisory try/catch */
}
let documentedVersions: string[] = [];
try {
if (fs.existsSync(milestonesPath)) {
const registryContent = fs.readFileSync(milestonesPath, 'utf-8');
documentedVersions = [...registryContent.matchAll(/^##\s+(v\d+\.\d+(?:\.\d+)?)/gm)].map((m) => m[1]);
}
} catch {
/* intentionally empty */
}
const documented = new Set(documentedVersions);
return archivedVersions.filter((v) => !documented.has(v));
}
interface RepairOutcome {
success: boolean;
path?: string;
detail?: string;
error?: string;
// regenerateState's original backup step (verify.cts:2435-2440) pushed its
// own SEPARATE `repairActions` entry before the main one — preserved here
// as extra, prepended detail rows. Unreachable in practice today
// (regenerateState is DESTRUCTIVE and `applyRepairs`'s dispatcher below
// refuses it before this handler is ever invoked), but the handler stays
// complete rather than partially ported, per this batch's brief.
extraDetails?: { action: string; success: boolean; path?: string }[];
}
/**
* Execute exactly one real repair action, ported behavior-preserving from
* `verify.cts:2405-2553`'s `switch (repair)`. Throws are the caller's
* responsibility to catch (mirrors the original's per-action try/catch
* shape, collapsed to one seam here since every case now shares one
* caller).
*/
function runRepairAction(cwd: string, action: RemedyAction, paths: RepairPaths): RepairOutcome {
const { rootBase, configPath, statePath, milestonesPath, milestonesArchiveDir } = paths;
switch (action) {
case REMEDY_ACTION.CREATE_CONFIG:
case REMEDY_ACTION.RESET_CONFIG: {
platformWriteSync(configPath, JSON.stringify(defaultConfigPayload(), null, 2));
return { success: true, path: 'config.json' };
}
case REMEDY_ACTION.REGENERATE_STATE: {
const extraDetails: { action: string; success: boolean; path?: string }[] = [];
if (fs.existsSync(statePath)) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
const backupPath = `${statePath}.bak-${timestamp}`;
fs.copyFileSync(statePath, backupPath);
extraDetails.push({ action: 'backupState', success: true, path: backupPath });
}
const milestone = getMilestoneInfo(cwd).value;
const projectRef = path
.relative(cwd, path.join(rootBase, 'PROJECT.md'))
.split(path.sep)
.join('/');
const slashRuntime = resolveRuntime(cwd);
const slash = (name: string) => formatGsdSlash(name, slashRuntime) as string;
let stateContent = `# Session State\n\n`;
stateContent += `## Project Reference\n\n`;
stateContent += `See: ${projectRef}\n\n`;
stateContent += `## Position\n\n`;
stateContent += `**Milestone:** ${milestone?.version ?? ''} ${milestone?.name ?? ''}\n`;
stateContent += `**Current phase:** (determining...)\n`;
stateContent += `**Status:** Resuming\n\n`;
stateContent += `## Session Log\n\n`;
stateContent += `- ${realClock.localToday()}: STATE.md regenerated by ${slash('health')} --repair\n`;
writeStateMd(statePath, stateContent, cwd);
return { success: true, path: 'STATE.md', extraDetails };
}
case REMEDY_ACTION.ADD_NYQUIST_KEY:
case REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY: {
const key = action === REMEDY_ACTION.ADD_NYQUIST_KEY ? 'nyquist_validation' : 'ai_integration_phase';
const configRaw = fs.readFileSync(configPath, 'utf-8');
const configParsed = JSON.parse(configRaw) as Record<string, unknown>;
if (!configParsed['workflow']) configParsed['workflow'] = {};
const wf = configParsed['workflow'] as Record<string, unknown>;
if (wf[key] === undefined) {
wf[key] = true;
platformWriteSync(configPath, JSON.stringify(configParsed, null, 2));
}
return { success: true, path: 'config.json' };
}
case REMEDY_ACTION.BACKFILL_MILESTONES: {
const missing = computeMissingMilestoneVersions(milestonesArchiveDir, milestonesPath);
const today = realClock.localToday();
const slashRuntime = resolveRuntime(cwd);
const slash = (name: string) => formatGsdSlash(name, slashRuntime) as string;
let backfilled = 0;
for (const ver of missing) {
try {
const snapshotPath = path.join(milestonesArchiveDir, `${ver}-ROADMAP.md`);
const snapshot = safeReadFile(snapshotPath);
const titleMatch = snapshot && snapshot.match(/^#\s+(.+)$/m);
const milestoneName = titleMatch
? titleMatch[1].replace(/^Milestone\s+/i, '').replace(/^v[\d.]+\s*/, '').trim()
: ver;
const entry =
`## ${ver}${milestoneName && milestoneName !== ver ? ` ${milestoneName}` : ''} (Backfilled: ${today})\n\n**Note:** Synthesized from archive snapshot by \`${slash('health')} --backfill\`. Original completion date unknown.\n\n---\n\n`;
const milestonesContent = fs.existsSync(milestonesPath)
? fs.readFileSync(milestonesPath, 'utf-8')
: '';
if (!milestonesContent.trim()) {
platformWriteSync(milestonesPath, `# Milestones\n\n${entry}`);
} else {
const headerMatch = milestonesContent.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
if (headerMatch) {
const header = headerMatch[1];
const rest = milestonesContent.slice(header.length);
platformWriteSync(milestonesPath, header + entry + rest);
} else {
platformWriteSync(milestonesPath, entry + milestonesContent);
}
}
backfilled++;
} catch {
/* intentionally empty — partial backfill is acceptable */
}
}
return { success: true, detail: `Backfilled ${backfilled} milestone(s) into MILESTONES.md` };
}
default:
return { success: false, error: `no repair handler registered for action "${action}"` };
}
}
/**
* `--repair`/`--backfill` dispatcher (design doc "`--repair` behavior
* change" section; §8.3 rule 3). For each diagnostic whose remedy is not
* `ADVISE`:
*
* - Not requested — `repair` is false, and for `backfillMilestones`
* specifically `backfill` is also false (mirrors `cmdValidateHealth`'s
* existing `backfillMilestones` gate, `verify.cts:2504`:
* `if (!options['backfill'] && !options['repair']) break;`) — skipped
* entirely, recorded in neither `applied` nor `refused`.
* - Requested and `remedy.risk === DESTRUCTIVE` — pushed onto `refused`,
* handler never invoked. This is the §8.3 rule 3 breaking-change
* enforcement point: a DESTRUCTIVE remedy is describable but is never
* applied by `--repair`. A `details` row is still recorded, so the
* refusal is VISIBLE in `cmdValidateHealth`'s `repairs_performed` output,
* not silently dropped.
* - Requested and `remedy.risk === NONE` — the real handler is invoked,
* pushed onto `applied`.
*
* `applied`/`refused` are unchanged in shape from the pre-existing skeleton
* (locked by `tests/health-diagnostic.test.cjs`, rows 11-12): arrays of
* diagnostic `code`s. `details` is ADDITIVE — every real action maps 1:1 to
* exactly one code in this rule table (confirmed: no `REMEDY_ACTION` other
* than `ADVISE` is used by more than one rule), so `cmdValidateHealth` can
* rebuild the legacy action-keyed `repairs_performed` shape directly from
* it.
*/
function applyRepairs(
cwd: string,
diagnostics: Diagnostic[],
repair: boolean,
backfill: boolean,
): { applied: string[]; refused: string[]; details: RepairDetail[] } {
const applied: string[] = [];
const refused: string[] = [];
const details: RepairDetail[] = [];
const paths = repairPaths(cwd);
for (const diagnostic of diagnostics) {
const { remedy, code } = diagnostic;
if (remedy.action === REMEDY_ACTION.ADVISE) continue;
const requested =
remedy.action === REMEDY_ACTION.BACKFILL_MILESTONES ? repair || backfill : repair;
if (!requested) continue;
if (remedy.risk === REMEDY_RISK.DESTRUCTIVE) {
refused.push(code);
details.push({
code,
action: remedy.action,
success: false,
error: `refused: '${remedy.action}' is a destructive remedy and is not auto-applied by --repair`,
});
continue;
}
try {
const outcome = runRepairAction(cwd, remedy.action, paths);
if (outcome.extraDetails) {
for (const extra of outcome.extraDetails) {
details.push({ code, action: extra.action, success: extra.success, ...(extra.path ? { path: extra.path } : {}) });
}
}
details.push({
code,
action: remedy.action,
success: outcome.success,
...(outcome.path ? { path: outcome.path } : {}),
...(outcome.detail ? { detail: outcome.detail } : {}),
...(outcome.error ? { error: outcome.error } : {}),
});
} catch (err) {
details.push({
code,
action: remedy.action,
success: false,
error: err instanceof Error ? err.message : String(err),
});
}
applied.push(code);
}
return { applied, refused, details };
}
// ─── Exports ────────────────────────────────────────────────────────────────
const healthDiagnostic = {
SEVERITY,
REMEDY_ACTION,
REMEDY_RISK,
RULES,
evaluateRules,
// Additive beyond the phase's required-exports list — exposed so the
// duplicate-code guard (row 13) is directly unit-testable against a fake
// rule array without mutating the real, still-empty `RULES` export.
evaluateRuleTable,
applyRepairs,
};
// Namespace merge (same binding name as the value above) is how a CommonJS
// `export =` module exposes a type alongside its runtime export — `export
// type` is rejected by TS2309 ("An export assignment cannot be used in a
// module with other exported elements") when combined with `export =`, so
// these types ride along on the exported object via declaration merging
// instead. Mirrors `src/planning-scope.cts`'s exact mechanism. Consumers
// doing `import x = require('./health-diagnostic.cjs')` can reference the
// types as `x.Severity`, `x.RemedyAction`, etc.
// eslint-disable-next-line @typescript-eslint/no-namespace
declare namespace healthDiagnostic {
export { Severity, RemedyAction, RemedyRisk, Remedy, Diagnostic, Rule };
}
export = healthDiagnostic;