Phase 11 of epic #3180 (ADR-3180 §8.2/§8.3/§8.5). New src/health-diagnostic.cts: SEVERITY/REMEDY_ACTION (7 members: 6 real repair actions + ADVISE)/REMEDY_RISK (NONE/DESTRUCTIVE) frozen enums, Diagnostic/Remedy/Rule types, an empty RULES table (rules land in the next commits), evaluateRules (with a duplicate-code defense-in-depth check ahead of the lint guard), and applyRepairs (the DESTRUCTIVE-risk-refusal dispatcher — §8.3 rule 3 — with stub handlers; real repair bodies port in the migration step). Six-gate .cts ripple: .gitignore, eslint.config.mjs, docs/INVENTORY.md + manifest, CONTEXT.md glossary entry.
214 lines
9.0 KiB
TypeScript
214 lines
9.0 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).
|
|
*
|
|
* SKELETON (this phase). Establishes the exact contract every later batch of
|
|
* extracted rules builds onto: the frozen `SEVERITY`/`REMEDY_ACTION`/
|
|
* `REMEDY_RISK` enums, the `Diagnostic`/`Remedy`/`Rule` shapes, the `RULES`
|
|
* container (starts EMPTY — a later migration step appends the 32 rules
|
|
* extracted from `cmdValidateHealth`, `src/verify.cts:1616-2577`), the
|
|
* `evaluateRules` evaluator, and the `applyRepairs` `--repair`/`--backfill`
|
|
* dispatcher. `applyRepairs`'s per-action handlers are stubs in this phase —
|
|
* they land alongside the rules that need them.
|
|
*
|
|
* `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).
|
|
*/
|
|
|
|
// 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>;
|
|
|
|
// ─── Severity ───────────────────────────────────────────────────────────────
|
|
|
|
const SEVERITY = Object.freeze({
|
|
ERROR: 'error',
|
|
WARNING: 'warning',
|
|
INFO: 'info',
|
|
});
|
|
type Severity = (typeof SEVERITY)[keyof typeof SEVERITY];
|
|
|
|
// ─── Remedy action / risk ───────────────────────────────────────────────────
|
|
|
|
// Harvested from health.md's published table + the corrected 6-action
|
|
// implementation (`src/verify.cts:2405-2553`) — not 5; `addAiIntegrationPhaseKey`
|
|
// (verify.cts:1860/2481-2502) was live in code, missing from docs (design
|
|
// doc, "Ground truth vs. issue #3309's claims" section).
|
|
const REMEDY_ACTION = Object.freeze({
|
|
CREATE_CONFIG: 'createConfig',
|
|
RESET_CONFIG: 'resetConfig',
|
|
REGENERATE_STATE: 'regenerateState',
|
|
ADD_NYQUIST_KEY: 'addNyquistKey',
|
|
ADD_AI_INTEGRATION_PHASE_KEY: 'addAiIntegrationPhaseKey',
|
|
BACKFILL_MILESTONES: 'backfillMilestones',
|
|
// §8.3 rule 5 — every non-repairable finding's `fix` string becomes an
|
|
// ADVISE payload; ADVISE never acts, only describes.
|
|
ADVISE: 'advise',
|
|
});
|
|
type RemedyAction = (typeof REMEDY_ACTION)[keyof typeof REMEDY_ACTION];
|
|
|
|
const REMEDY_RISK = Object.freeze({
|
|
NONE: 'none',
|
|
DESTRUCTIVE: 'destructive',
|
|
});
|
|
type RemedyRisk = (typeof REMEDY_RISK)[keyof typeof REMEDY_RISK];
|
|
|
|
// ─── Diagnostic / Rule shapes ───────────────────────────────────────────────
|
|
|
|
interface Remedy {
|
|
action: RemedyAction;
|
|
risk: RemedyRisk;
|
|
args: Record<string, unknown>;
|
|
}
|
|
|
|
interface Diagnostic {
|
|
code: string; // e.g. 'W010' — append-only, never renumbered (§8.2 rule 2)
|
|
severity: Severity; // property of the RULE, never the emit call (§8.2 rule 3)
|
|
message: string;
|
|
remedy: Remedy;
|
|
}
|
|
|
|
interface Rule {
|
|
code: string;
|
|
severity: Severity;
|
|
check: (snapshot: PlanningSnapshot) => Diagnostic[]; // §8.1 rule 1 signature, verbatim
|
|
}
|
|
|
|
// ─── Rule table ─────────────────────────────────────────────────────────────
|
|
|
|
// Starts EMPTY. A later migration batch appends each of the 32 rule
|
|
// functions extracted from `cmdValidateHealth` (design doc, "Rule table
|
|
// organization" section) — this phase establishes only the container and its
|
|
// type.
|
|
const RULES: Rule[] = [];
|
|
|
|
// ─── Evaluator ──────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Evaluate an explicit `rules` array against `snapshot`, throwing if any two
|
|
* entries share a `code` (defense in depth beside the future static lint
|
|
* guard, §8.2 rule 1). Separated from `evaluateRules` so the duplicate-code
|
|
* guard is unit-testable against a small, locally-constructed fake rule
|
|
* array, independent of whether `RULES` itself has any entries yet (it does
|
|
* not, in this skeleton).
|
|
*/
|
|
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 ──────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Stub repair handler. Real per-action handlers (`createConfig`,
|
|
* `resetConfig`, `regenerateState`, `addNyquistKey`,
|
|
* `addAiIntegrationPhaseKey`, `backfillMilestones`) land in a later
|
|
* migration batch alongside the rules that need them — see this phase's
|
|
* brief. Applying a NONE-risk remedy is a no-op beyond recording it, in this
|
|
* skeleton.
|
|
*/
|
|
function applyStubRepair(_cwd: string, _diagnostic: Diagnostic): void {
|
|
/* intentionally empty — real handlers land with the rules that need them */
|
|
}
|
|
|
|
/**
|
|
* `--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`.
|
|
* - Requested and `remedy.risk === NONE` — stub handler invoked, pushed
|
|
* onto `applied`.
|
|
*/
|
|
function applyRepairs(
|
|
cwd: string,
|
|
diagnostics: Diagnostic[],
|
|
repair: boolean,
|
|
backfill: boolean,
|
|
): { applied: string[]; refused: string[] } {
|
|
const applied: string[] = [];
|
|
const refused: string[] = [];
|
|
|
|
for (const diagnostic of diagnostics) {
|
|
const { remedy } = 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(diagnostic.code);
|
|
continue;
|
|
}
|
|
|
|
applyStubRepair(cwd, diagnostic);
|
|
applied.push(diagnostic.code);
|
|
}
|
|
|
|
return { applied, refused };
|
|
}
|
|
|
|
// ─── 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;
|