refactor(#3309): add health-diagnostic.cts skeleton (types + evaluator)
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.
This commit is contained in:
213
src/health-diagnostic.cts
Normal file
213
src/health-diagnostic.cts
Normal file
@@ -0,0 +1,213 @@
|
||||
/**
|
||||
* 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;
|
||||
Reference in New Issue
Block a user