diff --git a/.gitignore b/.gitignore index 8780e681a..54264c09a 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CONTEXT.md b/CONTEXT.md index 8b1b00750..623dbfb0c 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index ca01f0bdb..03e1b26dc 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -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", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 11933ed18..724ddf114 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -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` | diff --git a/eslint.config.mjs b/eslint.config.mjs index b76354c7c..398c0a0ff 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/src/health-diagnostic.cts b/src/health-diagnostic.cts new file mode 100644 index 000000000..aac125121 --- /dev/null +++ b/src/health-diagnostic.cts @@ -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` 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; + +// ─── 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; +} + +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(); + 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; diff --git a/tests/health-diagnostic.test.cjs b/tests/health-diagnostic.test.cjs new file mode 100644 index 000000000..a2e9171f8 --- /dev/null +++ b/tests/health-diagnostic.test.cjs @@ -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({}), []); + }); +});