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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -196,6 +196,7 @@ build/
|
||||
/gsd-core/bin/lib/planning-workspace.cjs
|
||||
/gsd-core/bin/lib/planning-scope.cjs
|
||||
/gsd-core/bin/lib/planning-snapshot.cjs
|
||||
/gsd-core/bin/lib/health-diagnostic.cjs
|
||||
/gsd-core/bin/lib/command-roster.cjs
|
||||
/gsd-core/bin/lib/runtime-artifact-conversion.cjs
|
||||
/gsd-core/bin/lib/runtime-artifact-layout.cjs
|
||||
|
||||
@@ -106,6 +106,9 @@ Leaf module owning the frozen `SCOPE` discriminator (`COMPLETE` / `TRUNCATED` /
|
||||
### Planning Snapshot Module
|
||||
Module owning the parsed projection of `.planning/` that a diagnostic rule may read, per ADR-3180 §8.1 (Decision 8, Phase 10, #3308). `buildPlanningSnapshot(cwd) → PlanningSnapshot` is composed EXCLUSIVELY from the already-consolidated §7 owners — `getMilestoneInfo` (Roadmap Parser Module), `listMilestonePhaseDirs` (Phase Locator Module), `isPhaseComplete` (Verification Module), `scanPhasePlans` (Plan Scan Module), `stateFieldValue`/`stateCurrentPositionSlice` (STATE.md Document Module), `planningPaths` (Planning Workspace Module) — and introduces no new semantic derivation of its own. `PlanningSnapshot` exposes `milestone`/`phaseDirs`/`phases`/`currentPhaseLabel`, each a `{value, scope}` pair per the Planning Scope Module's frozen `SCOPE` enum; `phases` additionally carries a `PhaseSnapshot[]` (`dir`, `complete`, `verificationStatus`, `planCount`, `summaryCount`, `scope`). The one new piece of logic this module adds is `worstScope(...scopes) → Scope`, a pure severity-ordered combinator (`UNREADABLE` > `UNSCOPED` > `TRUNCATED` > `COMPLETE`) that folds several independently-scoped owner answers about the same phase directory into one composite signal — NOT a re-derivation of any owner (each owner's own algorithm is untouched; only their already-computed `scope` verdicts are combined), but new coordination logic no single owner has the visibility to express. Every exposed field carries PARSED values only, never raw document text — this is structural, not advisory: a diagnostic rule given only the parsed value cannot re-derive a field's location the way `#3162`'s three inert `Current Phase` literal-search predicates did. Read failures on STATE.md (exists-but-unreadable, distinct from absent) are reported via the Unusable Input Diagnostic Module's `warnUnusableInput(UNUSABLE_REASON.STATE_UNREADABLE)`. Guarded by `scripts/lint-planning-snapshot-bypass-drift.cjs` (ratcheted per Decision 4(e), scoped to `DIAGNOSTIC_RULE_FUNCTIONS` — currently `cmdValidateHealth` in `src/verify.cts` only, acknowledging its existing raw `.planning/` reads as debt owned by Phase 11, #3309, which migrates it onto this snapshot). Source of truth: `gsd-core/bin/lib/planning-snapshot.cjs` (generated from `src/planning-snapshot.cts`). Design: `.gsd/phase/refactor-3308-planning-snapshot-parsed-projection/40-design.md`.
|
||||
|
||||
### Health Diagnostic Module
|
||||
Module owning the frozen rule-table contract for `validate health`, per ADR-3180 §8.2/§8.3/§8.5 (Phase 11, #3309). Exposes three frozen enums — `SEVERITY` (`error`/`warning`/`info`), `REMEDY_ACTION` (the six real repair actions harvested from `cmdValidateHealth`'s existing `--repair` implementation — `createConfig`, `resetConfig`, `regenerateState`, `addNyquistKey`, `addAiIntegrationPhaseKey`, `backfillMilestones` — plus `advise`, the non-repairable payload every non-actionable finding's fix text becomes), and `REMEDY_RISK` (`none`/`destructive`) — plus the `Diagnostic`/`Remedy`/`Rule` shapes every rule's `check(snapshot: PlanningSnapshot) → Diagnostic[]` signature and every finding's `remedy` conform to. `RULES: Rule[]` is the rule table a later migration batch appends the 32 rules extracted from `cmdValidateHealth` (`src/verify.cts:1616-2577`) onto; this phase ships it EMPTY, establishing only the container and its type. `evaluateRules(snapshot) → Diagnostic[]` runs every rule in `RULES` against one `PlanningSnapshot` and flattens the results, throwing on any two rules sharing a `code` — defense in depth beside the future static 1:1 lint guard (§8.2 rule 1). `applyRepairs(cwd, diagnostics, repair, backfill) → {applied, refused}` is the `--repair`/`--backfill` dispatcher: a `DESTRUCTIVE` remedy (`resetConfig`/`regenerateState` — health.md's own published table: "loses custom settings" / "loses session history") is reported but never executed by `--repair`, a deliberate, disclosed breaking change (§8.3 rule 3) from `cmdValidateHealth`'s current unconditional application; `backfillMilestones` alone among the `NONE`-risk actions is requested by `--backfill` without `--repair`, mirroring `cmdValidateHealth`'s existing gate (`src/verify.cts:2504`). Per-action repair handlers are stubs in this phase — they land alongside the rules that need them. Source of truth: `gsd-core/bin/lib/health-diagnostic.cjs` (generated from `src/health-diagnostic.cts`). Design: `.gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md`.
|
||||
|
||||
### Planning Workspace Module
|
||||
Module owning `.planning` path resolution, active workstream pointer policy (`session-scoped > shared`), pointer self-heal behavior, and planning lock semantics for workstream-aware execution.
|
||||
|
||||
|
||||
@@ -378,6 +378,7 @@
|
||||
"graphify.cjs",
|
||||
"gsd2-import.cjs",
|
||||
"handshake-serialized.cjs",
|
||||
"health-diagnostic.cjs",
|
||||
"hook-bus.cjs",
|
||||
"host-integration-sdk.cjs",
|
||||
"host-integration.cjs",
|
||||
|
||||
@@ -493,6 +493,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
|
||||
| `graphify.cjs` | Knowledge-graph build/query/status/diff for `/gsd-graphify` |
|
||||
| `graphify-command-router.cjs` | ADR-959 capability command router for `gsd-tools graphify` — dispatches build/query/status/diff subcommands; first real capability command cutover (phase 4d-impl-2) |
|
||||
| `gsd2-import.cjs` | External-plan ingest for `/gsd-import --from-gsd2` |
|
||||
| `health-diagnostic.cjs` | Frozen rule-table contract for `validate health` — `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums, `Diagnostic`/`Remedy`/`Rule` shapes, the `RULES` table (empty in this phase; a later migration batch appends the 32 rules extracted from `cmdValidateHealth`), `evaluateRules` (throws on duplicate rule codes), and `applyRepairs` (the `--repair`/`--backfill` dispatcher — refuses `DESTRUCTIVE`-risk remedies) (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) |
|
||||
| `host-integration.cjs` | Host-Integration Interface (ADR-1239 Phase A) — negotiated capability contract over the six host-integration points; `negotiateHostCapabilities` fail-closes on undeclared/unknown/`undocumented` values, typed degradation ladder, host-capability profiles; the 8 `runtime.hostIntegration` axes are validated in `capability-validator.cjs` and sourced per-CLI in `docs/reference/host-integration-capability-matrix.md` |
|
||||
| `host-runtime-detection.cjs` | Host Runtime Detection Module (ADR-2313 Phase 5, #3245) — the detection rung beneath `GSD_RUNTIME` and `.planning/config.json` `runtime` that lets `init` report `agent_runtime: codex` inside a Codex session instead of the hardcoded `claude` default; `detectHostRuntime` returns the typed `{runtime, source, signal}` from citation-backed Codex signals (`CODEX_SANDBOX`/`CODEX_SANDBOX_NETWORK_DISABLED`, else `CODEX_HOME` + `config.toml`), `resolveReportedRuntime` composes the full ladder. Pure, injectable, never writes, never shells out |
|
||||
| `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` |
|
||||
|
||||
@@ -135,6 +135,7 @@ export default tseslint.config(
|
||||
'gsd-core/bin/lib/configuration.cjs',
|
||||
'gsd-core/bin/lib/state-document.cjs',
|
||||
'gsd-core/bin/lib/planning-snapshot.cjs',
|
||||
'gsd-core/bin/lib/health-diagnostic.cjs',
|
||||
'gsd-core/bin/lib/shell-command-projection.cjs',
|
||||
'gsd-core/bin/lib/security.cjs',
|
||||
'gsd-core/bin/lib/command-aliases.cjs',
|
||||
|
||||
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;
|
||||
226
tests/health-diagnostic.test.cjs
Normal file
226
tests/health-diagnostic.test.cjs
Normal file
@@ -0,0 +1,226 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for `src/health-diagnostic.cts` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5).
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* This file covers ONLY the skeleton's own contract — test-matrix section 2,
|
||||
* rows 9-14. `RULES` starts EMPTY in this phase (later batches append the 32
|
||||
* extracted rules); rows 15-16 (the DESTRUCTIVE-refusal proof against REAL
|
||||
* diagnostics emitted by real rules) and section 3 (per-rule fixtures) are
|
||||
* deferred to the migration step that adds rules. This file DOES prove
|
||||
* `applyRepairs`'s risk-gating logic directly against hand-constructed fake
|
||||
* `Diagnostic` objects, independent of whether any real rule produces them
|
||||
* yet — per this phase's brief.
|
||||
*
|
||||
* TDD RED: `src/health-diagnostic.cts` does not exist yet — this file's
|
||||
* `require('../gsd-core/bin/lib/health-diagnostic.cjs')` throws
|
||||
* MODULE_NOT_FOUND until this phase's implementation lands. That is the
|
||||
* intended starting state.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const healthDiagnostic = require('../gsd-core/bin/lib/health-diagnostic.cjs');
|
||||
|
||||
const {
|
||||
SEVERITY,
|
||||
REMEDY_ACTION,
|
||||
REMEDY_RISK,
|
||||
RULES,
|
||||
evaluateRules,
|
||||
evaluateRuleTable,
|
||||
applyRepairs,
|
||||
} = healthDiagnostic;
|
||||
|
||||
// ─── Row 9 — REMEDY_ACTION locks exactly 7 members ─────────────────────────
|
||||
|
||||
describe('REMEDY_ACTION', () => {
|
||||
test('row 9: locks exactly 7 members (6 real repair actions + ADVISE)', () => {
|
||||
assert.deepEqual(Object.keys(REMEDY_ACTION).sort(), [
|
||||
'ADD_AI_INTEGRATION_PHASE_KEY',
|
||||
'ADD_NYQUIST_KEY',
|
||||
'ADVISE',
|
||||
'BACKFILL_MILESTONES',
|
||||
'CREATE_CONFIG',
|
||||
'REGENERATE_STATE',
|
||||
'RESET_CONFIG',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
Object.values(REMEDY_ACTION).sort(),
|
||||
[
|
||||
'addAiIntegrationPhaseKey',
|
||||
'addNyquistKey',
|
||||
'advise',
|
||||
'backfillMilestones',
|
||||
'createConfig',
|
||||
'regenerateState',
|
||||
'resetConfig',
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('is frozen', () => {
|
||||
assert.equal(Object.isFrozen(REMEDY_ACTION), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 10 — REMEDY_RISK locks exactly 2 members ──────────────────────────
|
||||
|
||||
describe('REMEDY_RISK', () => {
|
||||
test('row 10: locks exactly 2 members (NONE, DESTRUCTIVE)', () => {
|
||||
assert.deepEqual(Object.keys(REMEDY_RISK).sort(), ['DESTRUCTIVE', 'NONE']);
|
||||
assert.deepEqual(Object.values(REMEDY_RISK).sort(), ['destructive', 'none']);
|
||||
});
|
||||
|
||||
test('is frozen', () => {
|
||||
assert.equal(Object.isFrozen(REMEDY_RISK), true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('SEVERITY', () => {
|
||||
test('locks exactly 3 members (ERROR, WARNING, INFO)', () => {
|
||||
assert.deepEqual(Object.keys(SEVERITY).sort(), ['ERROR', 'INFO', 'WARNING']);
|
||||
assert.deepEqual(Object.values(SEVERITY).sort(), ['error', 'info', 'warning']);
|
||||
});
|
||||
|
||||
test('is frozen', () => {
|
||||
assert.equal(Object.isFrozen(SEVERITY), true);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Rows 11-12 — applyRepairs risk-gating, hand-constructed diagnostics ───
|
||||
//
|
||||
// No real rule exists yet to emit these remedies (RULES is empty in this
|
||||
// skeleton). These diagnostics are hand-built using the risk harvested from
|
||||
// health.md's published table (design doc, "Risk assignment" section):
|
||||
// resetConfig/regenerateState are DESTRUCTIVE; every other real action is
|
||||
// NONE. This proves applyRepairs's gating logic is correct independent of
|
||||
// whether any real rule exists to produce these shapes yet.
|
||||
|
||||
function fakeDiagnostic(code, action, risk) {
|
||||
return {
|
||||
code,
|
||||
severity: SEVERITY.WARNING,
|
||||
message: `fake diagnostic for ${code}`,
|
||||
remedy: { action, risk, args: {} },
|
||||
};
|
||||
}
|
||||
|
||||
describe('applyRepairs — risk gating (hand-constructed diagnostics)', () => {
|
||||
test('row 11: resetConfig/regenerateState (DESTRUCTIVE) are refused, never applied, when --repair is requested', () => {
|
||||
const diagnostics = [
|
||||
fakeDiagnostic('E005', REMEDY_ACTION.RESET_CONFIG, REMEDY_RISK.DESTRUCTIVE),
|
||||
fakeDiagnostic('E004', REMEDY_ACTION.REGENERATE_STATE, REMEDY_RISK.DESTRUCTIVE),
|
||||
];
|
||||
const result = applyRepairs('/fake/cwd', diagnostics, true, false);
|
||||
assert.deepEqual(result.applied, []);
|
||||
assert.deepEqual(result.refused.sort(), ['E004', 'E005']);
|
||||
});
|
||||
|
||||
test('row 12: every other real action (NONE risk) is applied, not refused, when --repair is requested', () => {
|
||||
const diagnostics = [
|
||||
fakeDiagnostic('W003', REMEDY_ACTION.CREATE_CONFIG, REMEDY_RISK.NONE),
|
||||
fakeDiagnostic('W008', REMEDY_ACTION.ADD_NYQUIST_KEY, REMEDY_RISK.NONE),
|
||||
fakeDiagnostic('W016', REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY, REMEDY_RISK.NONE),
|
||||
fakeDiagnostic('W018', REMEDY_ACTION.BACKFILL_MILESTONES, REMEDY_RISK.NONE),
|
||||
];
|
||||
const result = applyRepairs('/fake/cwd', diagnostics, true, false);
|
||||
assert.deepEqual(result.applied.sort(), ['W003', 'W008', 'W016', 'W018']);
|
||||
assert.deepEqual(result.refused, []);
|
||||
});
|
||||
|
||||
test('ADVISE-action diagnostics are never applied nor refused, regardless of --repair', () => {
|
||||
const diagnostics = [fakeDiagnostic('W001', REMEDY_ACTION.ADVISE, REMEDY_RISK.NONE)];
|
||||
const result = applyRepairs('/fake/cwd', diagnostics, true, true);
|
||||
assert.deepEqual(result.applied, []);
|
||||
assert.deepEqual(result.refused, []);
|
||||
});
|
||||
|
||||
test('non-backfillMilestones NONE-risk diagnostics are skipped (not applied) when --repair is not requested', () => {
|
||||
const diagnostics = [fakeDiagnostic('W003', REMEDY_ACTION.CREATE_CONFIG, REMEDY_RISK.NONE)];
|
||||
const result = applyRepairs('/fake/cwd', diagnostics, false, false);
|
||||
assert.deepEqual(result.applied, []);
|
||||
assert.deepEqual(result.refused, []);
|
||||
});
|
||||
|
||||
test('DESTRUCTIVE-risk diagnostics are skipped (not refused) when --repair is not requested — refusal only fires when actually requested', () => {
|
||||
const diagnostics = [fakeDiagnostic('E005', REMEDY_ACTION.RESET_CONFIG, REMEDY_RISK.DESTRUCTIVE)];
|
||||
const result = applyRepairs('/fake/cwd', diagnostics, false, false);
|
||||
assert.deepEqual(result.applied, []);
|
||||
assert.deepEqual(result.refused, []);
|
||||
});
|
||||
|
||||
test('backfillMilestones applies on --backfill alone, without --repair (mirrors verify.cts:2504 intent)', () => {
|
||||
const diagnostics = [fakeDiagnostic('W018', REMEDY_ACTION.BACKFILL_MILESTONES, REMEDY_RISK.NONE)];
|
||||
const result = applyRepairs('/fake/cwd', diagnostics, false, true);
|
||||
assert.deepEqual(result.applied, ['W018']);
|
||||
assert.deepEqual(result.refused, []);
|
||||
});
|
||||
|
||||
test('backfillMilestones is skipped when neither --repair nor --backfill is set', () => {
|
||||
const diagnostics = [fakeDiagnostic('W018', REMEDY_ACTION.BACKFILL_MILESTONES, REMEDY_RISK.NONE)];
|
||||
const result = applyRepairs('/fake/cwd', diagnostics, false, false);
|
||||
assert.deepEqual(result.applied, []);
|
||||
assert.deepEqual(result.refused, []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 13 — duplicate-code detection, LOCAL fake rule array ──────────────
|
||||
//
|
||||
// `RULES` is still empty in this skeleton, so the duplicate check cannot be
|
||||
// exercised through the real exported table yet. Proven here instead against
|
||||
// a small, locally-constructed fake rule array — per this phase's brief.
|
||||
|
||||
describe('evaluateRuleTable — duplicate-code guard (row 13)', () => {
|
||||
test('throws when two rules share the same code', () => {
|
||||
const fakeRules = [
|
||||
{ code: 'W999', severity: SEVERITY.WARNING, check: () => [] },
|
||||
{ code: 'W999', severity: SEVERITY.WARNING, check: () => [] },
|
||||
];
|
||||
assert.throws(() => evaluateRuleTable(fakeRules, {}), /W999/);
|
||||
});
|
||||
|
||||
test('does not throw, and flattens all diagnostics, when codes are unique', () => {
|
||||
const fakeRules = [
|
||||
{
|
||||
code: 'W997',
|
||||
severity: SEVERITY.WARNING,
|
||||
check: () => [
|
||||
{ code: 'W997', severity: SEVERITY.WARNING, message: 'a', remedy: { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: {} } },
|
||||
],
|
||||
},
|
||||
{
|
||||
code: 'W998',
|
||||
severity: SEVERITY.WARNING,
|
||||
check: () => [
|
||||
{ code: 'W998', severity: SEVERITY.WARNING, message: 'b', remedy: { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: {} } },
|
||||
{ code: 'W998', severity: SEVERITY.WARNING, message: 'c', remedy: { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: {} } },
|
||||
],
|
||||
},
|
||||
];
|
||||
const diagnostics = evaluateRuleTable(fakeRules, {});
|
||||
assert.equal(diagnostics.length, 3);
|
||||
assert.deepEqual(diagnostics.map((d) => d.message), ['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
test('empty rule array never throws and returns []', () => {
|
||||
assert.deepEqual(evaluateRuleTable([], {}), []);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Row 14 — evaluator against an all-clean (here: rule-less) snapshot ───
|
||||
|
||||
describe('evaluateRules (row 14)', () => {
|
||||
test('RULES starts empty in this skeleton', () => {
|
||||
assert.deepEqual(RULES, []);
|
||||
assert.equal(Array.isArray(RULES), true);
|
||||
});
|
||||
|
||||
test('returns [] against any snapshot, since RULES is empty', () => {
|
||||
assert.deepEqual(evaluateRules({}), []);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user