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.
489 lines
22 KiB
TypeScript
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;
|