From 6aa378b2619b5b1896f7c5376802af70d5110b6c Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 00:41:39 -0400 Subject: [PATCH 01/35] refactor(#3309): extend planning-snapshot.cts with config/agentInstall/worktreeHealth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11 of epic #3180 (ADR-3180 §8.2/§8.3/§8.5) foundation. Extends the already-merged Phase-10 PlanningSnapshot additively with three fields the upcoming health-diagnostic rule table needs and Phase 10 never required: - config: {value, scope, exists} — parsed .planning/config.json. `exists` distinguishes absent (no diagnostic, non-answer) from present-but-invalid (CONFIG_UNREADABLE diagnostic, corruption) — both collapse to scope UNREADABLE, so a rule needs the extra bit to tell "not configured yet" apart from "config.json is broken." - agentInstall / worktreeHealth — not .planning/-sourced, wrap the existing checkAgentsInstalled/inspectWorktreeHealth owners with the same arguments cmdValidateHealth already passes them, so a later migration step reads these fields instead of calling the owners itself. Adds CONFIG_UNREADABLE to src/unusable-input.cts's UNUSABLE_REASON (eighth #1879 site), mirroring STATE_UNREADABLE's exact shape from Phase 10. Additive only — the four Phase-10 fields and worstScope/buildPhaseSnapshot are unchanged; existing tests for them are untouched. --- src/planning-snapshot.cts | 125 +++++++++++++++++++- src/unusable-input.cts | 9 ++ tests/planning-snapshot.test.cjs | 193 +++++++++++++++++++++++++++++++ tests/unusable-input.test.cjs | 49 +++++++- 4 files changed, 370 insertions(+), 6 deletions(-) diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts index b1fcfd7dc..836d6812c 100644 --- a/src/planning-snapshot.cts +++ b/src/planning-snapshot.cts @@ -19,6 +19,7 @@ * gsd-core/bin/lib/planning-snapshot.cjs (gitignored). */ +import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); @@ -33,8 +34,8 @@ const { isPhaseComplete } = verificationMod; import scanPhasePlans = require('./plan-scan.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports import planningWorkspace = require('./planning-workspace.cjs'); -const { planningPaths } = planningWorkspace; -import { platformReadSync } from './shell-command-projection.cjs'; +const { planningPaths, planningRoot } = planningWorkspace; +import { platformReadSync, execGit } from './shell-command-projection.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports import frontmatterMod = require('./frontmatter.cjs'); const { extractFrontmatter, stripFrontmatter } = frontmatterMod; @@ -46,6 +47,13 @@ const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod; import planningScopeMod = require('./planning-scope.cjs'); const { SCOPE } = planningScopeMod; type Scope = planningScopeMod.Scope; +import { resolveRuntime } from './runtime-slash.cjs'; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module +import agentInstallCheckMod = require('./agent-install-check.cjs'); +const { checkAgentsInstalled } = agentInstallCheckMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- worktree-safety.cjs is an export= CommonJS module +import worktreeSafetyMod = require('./worktree-safety.cjs'); +const { inspectWorktreeHealth } = worktreeSafetyMod; // ─── worstScope — the one new piece of coordination logic ─────────────────── @@ -89,6 +97,16 @@ interface PlanningSnapshot { phaseDirs: ReturnType; phases: { value: PhaseSnapshot[]; scope: Scope }; currentPhaseLabel: { value: string | null; scope: Scope }; + // ─── Phase 11 (#3309, ADR-3180 §8.2/§8.3/§8.5) additions ─────────────────── + // Additive-only — see the design doc's "The subject-surface gap" section. + // `config` genuinely lives under `.planning/`; `agentInstall` and + // `worktreeHealth` do not (named as such so a future reader does not + // mistake them for §7 derivations) but are exposed here anyway so every + // rule's `check(snapshot)` signature stays the single object §8.1 rule 1 + // names, "the snapshot". + config: { value: Record | null; scope: Scope; exists: boolean }; + agentInstall: { value: ReturnType; scope: Scope }; + worktreeHealth: { value: ReturnType['findings']; scope: Scope }; } /** @@ -153,9 +171,103 @@ function buildCurrentPhaseLabel(statePath: string): { value: string | null; scop } /** - * Build the full `.planning/` projection for `cwd`. Composes exactly the six - * §7 owners named in the design doc's "Owners consumed" table — no - * re-derivation, no new semantic answer. See the design doc for the + * Resolve `config` — the parsed `.planning/config.json`, preserving the same + * three-way distinction `cmdValidateHealth` (`src/verify.cts` W003/E005) + * already makes without going through `loadConfig` (which collapses that + * distinction): absent is a real non-answer — `{value: null, scope: + * UNREADABLE, exists: false}`, no `warnUnusableInput` call, mirrors + * `buildCurrentPhaseLabel`'s treatment of an absent STATE.md; present but + * unparseable JSON IS corruption — `{value: null, scope: UNREADABLE, exists: + * true}`, `warnUnusableInput(CONFIG_UNREADABLE)` fires exactly once, so a + * later health-diagnostic rule can tell "config.json not found" (W003, + * repairable via `createConfig`) apart from "config.json: JSON parse error" + * (E005, repairable via `resetConfig`) — the `exists` flag is exactly that + * discriminator. `config.json` is root-scoped (`planningRoot`), NOT + * workstream-scoped (`planningPaths(cwd).config` would resolve under + * `.planning/workstreams//` instead) — see verify.cts's own + * rootBase-vs-wsBase split at cmdValidateHealth's top. + */ +function buildConfigField(cwd: string): { value: Record | null; scope: Scope; exists: boolean } { + const configPath = path.join(planningRoot(cwd), 'config.json'); + if (!fs.existsSync(configPath)) { + return { value: null, scope: SCOPE.UNREADABLE, exists: false }; + } + try { + const raw = fs.readFileSync(configPath, 'utf-8'); + const parsed = JSON.parse(raw) as Record; + return { value: parsed, scope: SCOPE.COMPLETE, exists: true }; + } catch { + warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source: configPath }); + return { value: null, scope: SCOPE.UNREADABLE, exists: true }; + } +} + +/** + * Resolve `agentInstall` — wraps `checkAgentsInstalled(runtime, cwd)` with + * the same `runtime` `cmdValidateHealth` resolves (`resolveRuntime(cwd)`, + * its `_slashRuntime`). Not `.planning/`-sourced (see design doc). `scope` + * is `COMPLETE` whenever the scan itself ran, even when it reports missing + * or incomplete agents — that is a real answer, not a non-answer. + * `UNREADABLE` only if the scan itself throws, mirroring cmdValidateHealth's + * own try/catch around this same call (there, the exception is swallowed as + * "non-blocking"; here it is surfaced via `scope` instead of silently + * dropped, since a snapshot field has nowhere else to carry that fact). + */ +function buildAgentInstallField(cwd: string): { value: ReturnType; scope: Scope } { + const runtime = resolveRuntime(cwd); + try { + return { value: checkAgentsInstalled(runtime, cwd), scope: SCOPE.COMPLETE }; + } catch { + return { + value: { + agents_installed: false, + missing_agents: [], + installed_agents: [], + incomplete_agents: [], + agents_dir: '', + agent_runtime: runtime, + }, + scope: SCOPE.UNREADABLE, + }; + } +} + +/** + * Resolve `worktreeHealth` — wraps `inspectWorktreeHealth(cwd, { staleAfterMs + * }, deps)` with the exact same arguments `cmdValidateHealth` passes + * (`src/verify.cts` W017/W020/W027 call sites): a 1-hour staleness window, + * and the raw `execGit`/`fs.existsSync`/`fs.statSync` seam (not + * `worktree-safety.cts`'s own `execGitDefault` wrapper). Not + * `.planning/`-sourced (see design doc). `scope` is `COMPLETE` only when the + * underlying `git worktree list` scan itself succeeded (`ok: true`) — a + * timed-out or failed scan (`ok: false`, mirroring W020's degraded-check + * report) or a thrown exception (mirrors cmdValidateHealth's own + * "git worktree not available or not a git repo — skip silently" catch) + * both degrade to `UNREADABLE` with an empty findings array, since neither + * case has real per-worktree data to report. + */ +function buildWorktreeHealthField(cwd: string): { value: ReturnType['findings']; scope: Scope } { + try { + const result = inspectWorktreeHealth( + cwd, + { staleAfterMs: 60 * 60 * 1000 }, + { execGit, existsSync: fs.existsSync, statSync: fs.statSync }, + ); + if (!result.ok) { + return { value: [], scope: SCOPE.UNREADABLE }; + } + return { value: result.findings, scope: SCOPE.COMPLETE }; + } catch { + return { value: [], scope: SCOPE.UNREADABLE }; + } +} + +/** + * Build the full `.planning/` projection for `cwd`. Composes the six §7 + * owners named in the design doc's "Owners consumed" table, plus (Phase 11, + * #3309) the three additive subject-surface fields `config`/`agentInstall`/ + * `worktreeHealth` — no re-derivation, no new semantic answer beyond what + * their respective owners already compute. See the design doc for the * behavior table and rejected alternatives. */ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { @@ -173,6 +285,9 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { scope: worstScope(phaseDirs.scope, ...phasesValue.map((p) => p.scope)), }, currentPhaseLabel: buildCurrentPhaseLabel(paths.state), + config: buildConfigField(cwd), + agentInstall: buildAgentInstallField(cwd), + worktreeHealth: buildWorktreeHealthField(cwd), }; } diff --git a/src/unusable-input.cts b/src/unusable-input.cts index cd51e1880..f91bb2244 100644 --- a/src/unusable-input.cts +++ b/src/unusable-input.cts @@ -64,6 +64,13 @@ const UNUSABLE_REASON = Object.freeze({ * planning-snapshot's current-phase field) */ STATE_UNREADABLE: 'state_unreadable', + /** + * A config.json exists but could not be read/parsed (EACCES/EIO/malformed JSON/…). + * Distinct from a project that has not run any config-writing command yet: absence + * returns the same non-answer, silently — only an exists-but-unreadable config.json + * is corruption. (#3309, eighth #1879 site — planning-snapshot's config field) + */ + CONFIG_UNREADABLE: 'config_unreadable', } as const); type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON]; @@ -78,6 +85,8 @@ const REASON_PROSE: Readonly> = Object.freeze({ 'last_activity in STATE.md is present but unparseable as a date; stale_activity fell back to false (idle-stranded suppressed)', [UNUSABLE_REASON.STATE_UNREADABLE]: 'STATE.md exists but could not be read; the current-phase label fell back to unavailable', + [UNUSABLE_REASON.CONFIG_UNREADABLE]: + 'config.json exists but could not be read or parsed; the config field fell back to unavailable', }); // ─── Dedup state ────────────────────────────────────────────────────────────── diff --git a/tests/planning-snapshot.test.cjs b/tests/planning-snapshot.test.cjs index 604fdcaef..83d5eedd8 100644 --- a/tests/planning-snapshot.test.cjs +++ b/tests/planning-snapshot.test.cjs @@ -26,6 +26,7 @@ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const path = require('node:path'); +const childProcess = require('node:child_process'); const fc = require('fast-check'); const { createTempDir, cleanup } = require('./helpers.cjs'); @@ -43,6 +44,12 @@ const { worstScope } = planningSnapshotLib; const { SCOPE } = require('../gsd-core/bin/lib/planning-scope.cjs'); const { _unusableInputEmissionCountForTests } = require('../gsd-core/bin/lib/unusable-input.cjs'); +// Phase 11 (#3309) additions — agent-install fixture helper mirrors +// tests/agent-install-check.test.cjs's own EXPECTED_AGENTS/createCompleteAgents +// (design doc's "subject-surface gap" §, reused per its provenance rule). +const { MODEL_PROFILES } = require('../gsd-core/bin/lib/model-profiles.cjs'); +const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); + // ─── Fixture helpers (mirrors tests/completion-ratio-scope-withholding.test.cjs) ─ function planningDirOf(cwd) { @@ -495,3 +502,189 @@ describe('worstScope — pure unit coverage', () => { ); }); }); + +// ═════════════════════════════════════════════════════════════════════════ +// Phase 11 (#3309) additions — config / agentInstall / worktreeHealth +// +// Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md +// ("The subject-surface gap: config.json, agent-install, git-worktree-list") +// Test matrix: .gsd/phase/refactor-3309-health-diagnostic-rule-table/50-test-matrix.md +// section 1, rows 1-8 +// +// These three new fields wrap the SAME owner calls `cmdValidateHealth` +// (src/verify.cts) already makes (`checkAgentsInstalled`, `inspectWorktreeHealth`, +// a raw config.json read), so a later phase step can migrate the caller onto +// this snapshot without a shape mismatch. +// ═════════════════════════════════════════════════════════════════════════ + +function writeConfig(cwd, obj) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), JSON.stringify(obj)); +} + +function writeRawConfig(cwd, rawText) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), rawText); +} + +// Env isolation for GSD_AGENTS_DIR — mirrors tests/agent-install-check.test.cjs's +// beforeEach/afterEach save-restore idiom, inlined per-test via t.after since this +// describe block does not otherwise need beforeEach/afterEach hooks. +function withAgentsDirOverride(t, agentsDir) { + const saved = process.env['GSD_AGENTS_DIR']; + process.env['GSD_AGENTS_DIR'] = agentsDir; + t.after(() => { + if (saved === undefined) delete process.env['GSD_AGENTS_DIR']; + else process.env['GSD_AGENTS_DIR'] = saved; + }); +} + +function createCompleteAgentsDir(agentsDir) { + fs.mkdirSync(agentsDir, { recursive: true }); + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`); + } +} + +// Simulates a successful `git worktree list --porcelain` at the spawnSync seam — +// mirrors tests/worktree-safety.test.cjs's "execGitDefault (real spawn seam)" +// section, the repo's convention for driving the real execGit rather than a +// hand-set deps.execGit stub (this module accepts no deps parameter to inject). +function mockGitWorktreeListOk(t, porcelain) { + t.mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: porcelain, + stderr: '', + signal: null, + error: null, + })); +} + +// Simulates a timed-out `git worktree list --porcelain` (ETIMEDOUT), the same +// shape shell-command-projection.cjs's execGit / isSpawnTimeout recognize. +function mockGitWorktreeListTimeout(t) { + t.mock.method(childProcess, 'spawnSync', () => ({ + status: null, + stdout: '', + stderr: '', + signal: null, + error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }), + })); +} + +describe('config field (Phase 11, #3309, matrix rows 1-3)', () => { + test('row 1: well-formed config.json parses to {value, scope: COMPLETE}', (t) => { + const cwd = createTempDir('gsd-3309-cfg1-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, { model_profile: 'balanced' }); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.config, { value: { model_profile: 'balanced' }, scope: SCOPE.COMPLETE, exists: true }); + }); + + test('row 2: absent config.json is a real non-answer — {value: null, scope: UNREADABLE, exists: false}', (t) => { + const cwd = createTempDir('gsd-3309-cfg2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + // No config.json written at all. + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.config, { value: null, scope: SCOPE.UNREADABLE, exists: false }); + assert.strictEqual(emitted, 0, 'absence is not corruption — no diagnostic'); + }); + + test('row 3: present-but-unparseable config.json degrades without throwing — {value: null, scope: UNREADABLE, exists: true}, emits CONFIG_UNREADABLE exactly once', (t) => { + const cwd = createTempDir('gsd-3309-cfg3-'); + t.after(() => cleanup(cwd)); + writeRawConfig(cwd, '{ not valid json'); + + let snap; + let emitted; + assert.doesNotThrow(() => { + [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + }); + assert.deepStrictEqual(snap.config, { value: null, scope: SCOPE.UNREADABLE, exists: true }); + assert.strictEqual(emitted, 1, 'present-but-unparseable config.json is corruption — exactly one CONFIG_UNREADABLE diagnostic'); + }); +}); + +describe('agentInstall field (Phase 11, #3309, matrix rows 4-5)', () => { + test('row 4: all agents present reports zero missing/incomplete, scope COMPLETE', (t) => { + const cwd = createTempDir('gsd-3309-agt4-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents-complete'); + createCompleteAgentsDir(agentsDir); + withAgentsDirOverride(t, agentsDir); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.agentInstall.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.agentInstall.value.agents_installed, true); + assert.deepStrictEqual(snap.agentInstall.value.missing_agents, []); + assert.deepStrictEqual(snap.agentInstall.value.incomplete_agents, []); + }); + + test('row 5: missing agents dir reports the full missing set, scope COMPLETE (the scan itself succeeded)', (t) => { + const cwd = createTempDir('gsd-3309-agt5-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents-absent'); + withAgentsDirOverride(t, agentsDir); + // agentsDir deliberately never created. + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.agentInstall.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.agentInstall.value.agents_installed, false); + assert.deepStrictEqual(snap.agentInstall.value.missing_agents.slice().sort(), EXPECTED_AGENTS.slice().sort()); + }); +}); + +describe('worktreeHealth field (Phase 11, #3309, matrix rows 6-7)', () => { + test('row 6: git worktree list succeeds — value is the parsed findings array, scope COMPLETE', (t) => { + const cwd = createTempDir('gsd-3309-wt6-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + mockGitWorktreeListOk(t, 'worktree /repo\nHEAD 0000000000000000000000000000000000000000\nbranch refs/heads/main\n\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.worktreeHealth.scope, SCOPE.COMPLETE); + assert.ok(Array.isArray(snap.worktreeHealth.value)); + }); + + test('row 7: git worktree list times out — scope reflects degradation (mirrors W020)', (t) => { + const cwd = createTempDir('gsd-3309-wt7-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + mockGitWorktreeListTimeout(t); + + const snap = buildPlanningSnapshot(cwd); + assert.notStrictEqual(snap.worktreeHealth.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.worktreeHealth.scope, SCOPE.UNREADABLE); + assert.deepStrictEqual(snap.worktreeHealth.value, []); + }); +}); + +describe('Phase-10 fields unchanged by the Phase-11 extension (matrix row 8)', () => { + test('the four original fields keep their exact pre-extension values on the same fixture', (t) => { + const cwd = createTempDir('gsd-3309-reg8-'); + t.after(() => cleanup(cwd)); + buildHealthyTwoPhaseFixture(cwd); + + const snap = buildPlanningSnapshot(cwd); + + assert.strictEqual(snap.milestone.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.phaseDirs.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.phases.scope, SCOPE.COMPLETE); + assert.strictEqual(snap.phases.value.length, 2); + for (const p of snap.phases.value) { + assert.strictEqual(p.complete, true); + assert.strictEqual(p.scope, SCOPE.COMPLETE); + assert.strictEqual(p.verificationStatus, 'passed'); + assert.strictEqual(p.planCount, 1); + assert.strictEqual(p.summaryCount, 1); + } + // The extension is additive — the new fields must be present alongside + // the untouched originals, not in place of them. + assert.ok('config' in snap); + assert.ok('agentInstall' in snap); + assert.ok('worktreeHealth' in snap); + }); +}); diff --git a/tests/unusable-input.test.cjs b/tests/unusable-input.test.cjs index 043bf7f14..8452197ab 100644 --- a/tests/unusable-input.test.cjs +++ b/tests/unusable-input.test.cjs @@ -72,7 +72,7 @@ describe('UNUSABLE_REASON', () => { // (enum + call site + this assertion) instead of a silent widening. assert.deepStrictEqual( Object.keys(UNUSABLE_REASON).sort(), - ['FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'], + ['CONFIG_UNREADABLE', 'FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'], ); assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated'); }); @@ -134,6 +134,53 @@ describe('STATE_UNREADABLE', () => { }); }); +// ─── CONFIG_UNREADABLE: a config.json that exists but could not be read/parsed ─ + +describe('CONFIG_UNREADABLE', () => { + test('a genuinely unreadable config.json produces exactly one diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const emitted = emissionsDuring(() => { + const wrote = warnUnusableInput({ + reason: UNUSABLE_REASON.CONFIG_UNREADABLE, + source: '/u/config-unreadable.json', + }); + assert.strictEqual(wrote, true); + }); + assert.strictEqual(emitted, 1); + }); + + test('the same config.json path reported twice yields one diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const source = '/u/config-unreadable-dedup/config.json'; + const emitted = emissionsDuring(() => { + const first = warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source }); + const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.CONFIG_UNREADABLE, source }); + assert.strictEqual(first, true); + assert.strictEqual(repeat, false, 'same (path, cause) must dedup'); + }); + assert.strictEqual(emitted, 1); + }); + + test('two different config.json paths are never suppressed as one', () => { + _resetUnusableInputWarningsForTests(); + const emitted = emissionsDuring(() => { + warnUnusableInput({ + reason: UNUSABLE_REASON.CONFIG_UNREADABLE, + source: '/u/config-unreadable-a/config.json', + }); + warnUnusableInput({ + reason: UNUSABLE_REASON.CONFIG_UNREADABLE, + source: '/u/config-unreadable-b/config.json', + }); + }); + assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault'); + }); + + test('the reason value is the frozen string "config_unreadable"', () => { + assert.strictEqual(UNUSABLE_REASON.CONFIG_UNREADABLE, 'config_unreadable'); + }); +}); + // ─── The discriminator: truncated vs. everything that merely looks like it ─── describe('extractFrontmatter — flags a genuinely truncated frontmatter', () => { From ef10bba7079eb55e99616334412c720df034eaae Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 00:51:12 -0400 Subject: [PATCH 02/35] refactor(#3309): add health-diagnostic.cts skeleton (types + evaluator) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitignore | 1 + CONTEXT.md | 3 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + eslint.config.mjs | 1 + src/health-diagnostic.cts | 213 +++++++++++++++++++++++++++++ tests/health-diagnostic.test.cjs | 226 +++++++++++++++++++++++++++++++ 7 files changed, 446 insertions(+) create mode 100644 src/health-diagnostic.cts create mode 100644 tests/health-diagnostic.test.cjs 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({}), []); + }); +}); From c5543e533cadbd00442d66646c1d4ee0888f9de4 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:10:53 -0400 Subject: [PATCH 03/35] refactor(#3309): extend planning-snapshot.cts with 8 more parsed fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 11 of epic #3180 (ADR-3180 §8.1 rule 2). PlanningSnapshot grows from 7 fields to 15: projectSections, statePhaseTokens, stateStatus, roadmapDeclaredPhases, roadmapPhaseCheckboxes, researchValidationStatus, milestoneArchiveStatus, planningRootFiles. Every field is a reused owner (buildRoadmapPhaseVariants/ buildNotStartedPhaseVariants from src/validate.cts, stateFieldValue) or a small relocation of already-working verify.cts logic (PHASE_NUMBER_TOKEN_SOURCE scanning, the checkMilestonePrefixMismatches sectionRx walk, W009/W018's file-existence checks) — never a new algorithm, and never raw document text: §8.1 rule 2 forbids exposing raw text, not exposing a parsed list or boolean derived from it once by the snapshot builder. roadmapPhaseCheckboxes deliberately reads the same ROADMAP checkbox isPhaseComplete (§7.4, disk-strict) refuses to consult — that owner decides completion and must not read it; this field only exposes what the checkbox says, for a diagnostic (W011) whose whole purpose is flagging disagreement. Not a re-derivation of §7.4, recorded explicitly to prevent that reading. Adds PROJECT_UNREADABLE to UNUSABLE_REASON (ninth #1879 site), closing a gap the implementing agent correctly flagged rather than silently leaving absent-vs-corrupt collapsed for PROJECT.md, matching the STATE_UNREADABLE/ CONFIG_UNREADABLE precedent from this same effort's prior commits. currentPhaseLabel/statePhaseTokens/stateStatus share one STATE.md read (buildStateFields) rather than three independent reads. Additive only — all prior fields and worstScope/buildPhaseSnapshot unchanged. --- src/planning-snapshot.cts | 374 ++++++++++++++++++++++++++++++- src/unusable-input.cts | 9 + tests/planning-snapshot.test.cjs | 360 +++++++++++++++++++++++++++++ tests/unusable-input.test.cjs | 49 +++- 4 files changed, 781 insertions(+), 11 deletions(-) diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts index 836d6812c..f7a5bba11 100644 --- a/src/planning-snapshot.cts +++ b/src/planning-snapshot.cts @@ -54,6 +54,10 @@ const { checkAgentsInstalled } = agentInstallCheckMod; // eslint-disable-next-line @typescript-eslint/no-require-imports -- worktree-safety.cjs is an export= CommonJS module import worktreeSafetyMod = require('./worktree-safety.cjs'); const { inspectWorktreeHealth } = worktreeSafetyMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdMod = require('./phase-id.cjs'); +const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod; +import { buildRoadmapPhaseVariants } from './validate.cjs'; // ─── worstScope — the one new piece of coordination logic ─────────────────── @@ -107,6 +111,30 @@ interface PlanningSnapshot { config: { value: Record | null; scope: Scope; exists: boolean }; agentInstall: { value: ReturnType; scope: Scope }; worktreeHealth: { value: ReturnType['findings']; scope: Scope }; + // ─── Phase 11 (#3309) "Rule table organization" additions ───────────────── + // The design doc's own "Rule table organization" table and prose disagree + // on the count: the table lists EIGHT rows (through `planningRootFiles`, + // W019) but the prose says "7 more fields" / "14 fields after this batch". + // This implementation follows the table (and the task brief, which + // separately enumerates all eight) — every field a reused owner or a + // small, relocated (not new-algorithm) derivation. `PlanningSnapshot` + // therefore totals 15 fields after this batch, not 14; flagged here rather + // than silently reconciled, since correcting the design doc's prose is + // outside this diff's scope. + projectSections: { value: string[] | null; scope: Scope; exists: boolean }; + statePhaseTokens: { value: string[]; scope: Scope }; + stateStatus: { value: string | null; scope: Scope }; + roadmapDeclaredPhases: { value: { phaseId: string; milestone: string | null }[]; scope: Scope }; + roadmapPhaseCheckboxes: { value: Record; scope: Scope }; + researchValidationStatus: { + value: { dir: string; hasValidationArchitecture: boolean; hasValidationMd: boolean }[]; + scope: Scope; + }; + milestoneArchiveStatus: { + value: { archivedVersions: string[]; documentedVersions: string[] }; + scope: Scope; + }; + planningRootFiles: { value: string[]; scope: Scope }; } /** @@ -131,10 +159,27 @@ function buildPhaseSnapshot(phasesDir: string, dir: string): PhaseSnapshot { }; } +interface StateFields { + currentPhaseLabel: { value: string | null; scope: Scope }; + statePhaseTokens: { value: string[]; scope: Scope }; + stateStatus: { value: string | null; scope: Scope }; +} + /** - * Resolve `currentPhaseLabel` — the raw `Phase:` field STATE.md records under - * `## Current Position` (e.g. `"3 of 8 (User Auth)"`), not a normalized - * phase-directory id (see the design doc's Known limits). + * Resolve every STATE.md-sourced field in one place: `currentPhaseLabel` (the + * raw `Phase:` field under `## Current Position`, e.g. `"3 of 8 (User + * Auth)"`, not a normalized phase-directory id — see the design doc's Known + * limits), `statePhaseTokens` (Phase 11, #3309 — every phase-number-shaped + * token found anywhere in STATE.md's raw text, backs W002), and `stateStatus` + * (Phase 11, #3309 — the `status`/`Status` field, backs W011). + * + * Phase 10 shipped `currentPhaseLabel` as its own single-purpose reader + * (`buildCurrentPhaseLabel(statePath)`); this phase folds two more STATE.md + * derivations in rather than reading and parsing the same file three times + * per `buildPlanningSnapshot` call — the read, `extractFrontmatter`, and + * `stripFrontmatter` are genuinely shared inputs for all three, and sharing + * them means `warnUnusableInput(STATE_UNREADABLE)` also stays a single call + * site instead of a risk of tripling on one degraded read. * * This module performs the one STATE.md read no §7 owner does, mirroring * every existing STATE.md caller (`cmdStateSnapshot`, `cmdStatePrune`): @@ -144,30 +189,63 @@ function buildPhaseSnapshot(phasesDir: string, dir: string): PhaseSnapshot { * non-answer, NOT corruption — a project that never ran `state.init` * legitimately has no STATE.md yet. `warnUnusableInput` is NOT called. * - STATE.md present but unreadable (any other read error, e.g. EISDIR) is - * corruption — `warnUnusableInput(STATE_UNREADABLE)` fires exactly once. + * corruption — `warnUnusableInput(STATE_UNREADABLE)` fires exactly once, + * and all three fields degrade to their UNREADABLE non-answer together. * - An unterminated frontmatter fence is reported by `extractFrontmatter` * itself (`FRONTMATTER_UNTERMINATED`) — this function does not duplicate * that diagnostic; it still attempts a body-only field read on whatever * `stripFrontmatter` leaves behind. + * - `currentPhaseLabel`/`stateStatus` both live under `## Current Position` + * (`gsd-core/templates/state.md`) and both use `stateFieldValue` + * (`state-document.cts:296`) the exact way `smart-entry.cts:448`/ + * `state.cts:1561,3273` already call it for `'status'`/`'Status'` — so a + * missing `## Current Position` section degrades BOTH to `TRUNCATED` with + * a whole-body fallback, together. + * - `statePhaseTokens` scans the WHOLE document (`verify.cts`'s exact + * `PHASE_NUMBER_TOKEN_SOURCE` regex, relocated verbatim from + * `verify.cts:1731-1735`), not just the Current Position section, so it is + * NOT degraded to `TRUNCATED` by a missing section header — it stays + * `COMPLETE` whenever the file itself was read successfully. */ -function buildCurrentPhaseLabel(statePath: string): { value: string | null; scope: Scope } { +function buildStateFields(statePath: string): StateFields { let content: string | null; try { content = platformReadSync(statePath); } catch { warnUnusableInput({ reason: UNUSABLE_REASON.STATE_UNREADABLE, source: statePath }); - return { value: null, scope: SCOPE.UNREADABLE }; + return { + currentPhaseLabel: { value: null, scope: SCOPE.UNREADABLE }, + statePhaseTokens: { value: [], scope: SCOPE.UNREADABLE }, + stateStatus: { value: null, scope: SCOPE.UNREADABLE }, + }; } if (content === null) { - return { value: null, scope: SCOPE.UNREADABLE }; + return { + currentPhaseLabel: { value: null, scope: SCOPE.UNREADABLE }, + statePhaseTokens: { value: [], scope: SCOPE.UNREADABLE }, + stateStatus: { value: null, scope: SCOPE.UNREADABLE }, + }; } const frontmatter = extractFrontmatter(content, statePath); const body = stripFrontmatter(content); const section = stateCurrentPositionSlice(body); - return stateFieldValue(frontmatter, section ?? body, null, 'Phase', { - scope: section === null ? SCOPE.TRUNCATED : SCOPE.COMPLETE, + const currentPositionScope = section === null ? SCOPE.TRUNCATED : SCOPE.COMPLETE; + + const currentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Phase', { + scope: currentPositionScope, }); + const stateStatus = stateFieldValue(frontmatter, section ?? body, 'status', 'Status', { + scope: currentPositionScope, + }); + const statePhaseTokens = { + value: [...content.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g'))].map( + (m) => m[1], + ), + scope: SCOPE.COMPLETE, + }; + + return { currentPhaseLabel, statePhaseTokens, stateStatus }; } /** @@ -262,6 +340,273 @@ function buildWorktreeHealthField(cwd: string): { value: ReturnType m[1].trim()); + return { value, scope: SCOPE.COMPLETE, exists: true }; +} + +/** + * Resolve `roadmapDeclaredPhases` — every phase id ROADMAP.md declares + * (heading-style AND checklist-style, not filtered to disk presence), each + * paired with the milestone-version section it was found under (`null` when + * found outside any versioned section). Backs W006/W007 (declared-phase + * half) and W021(2288)/W026(2392) (milestone-attribution half). + * + * The declared-phase-id half reuses `buildRoadmapPhaseVariants` + * (`validate.cts:136`, already imported by `verify.cts:12` — genuine existing + * reuse). The milestone-attribution half relocates + * `checkMilestonePrefixMismatches`'s `sectionRx`-based section walk + * (`verify.cts:1429-1459`, local/unexported there), generalized from "record + * only the mismatches" to "record every attribution" — this field exposes + * the parsed fact; the future W021/W026 rules make the mismatch judgment. + */ +function buildRoadmapDeclaredPhasesField( + roadmapPath: string, +): { value: { phaseId: string; milestone: string | null }[]; scope: Scope } { + if (!fs.existsSync(roadmapPath)) { + return { value: [], scope: SCOPE.UNREADABLE }; + } + let content: string; + try { + content = fs.readFileSync(roadmapPath, 'utf-8'); + } catch { + return { value: [], scope: SCOPE.UNREADABLE }; + } + + const { roadmapPhases } = buildRoadmapPhaseVariants(content); + + const milestoneByPhase = new Map(); + const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim; + const sections: { version: string; start: number; end: number }[] = []; + let sm: RegExpExecArray | null; + while ((sm = sectionRx.exec(content)) !== null) { + if (sections.length > 0) sections[sections.length - 1].end = sm.index; + sections.push({ version: `v${sm[1]}`, start: sm.index, end: content.length }); + } + const phaseRx = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; + for (const section of sections) { + const sectionContent = content.slice(section.start, section.end); + phaseRx.lastIndex = 0; + let pm: RegExpExecArray | null; + while ((pm = phaseRx.exec(sectionContent)) !== null) { + if (!milestoneByPhase.has(pm[1])) milestoneByPhase.set(pm[1], section.version); + } + } + + const value = [...roadmapPhases].map((phaseId) => ({ + phaseId, + milestone: milestoneByPhase.get(phaseId) ?? null, + })); + return { value, scope: SCOPE.COMPLETE }; +} + +/** + * Resolve `roadmapPhaseCheckboxes` — parsed `[x]`/`[ ]` checkbox state per + * phase from ROADMAP.md's progress-table region, keyed by phase id. Backs + * W011. + * + * Relocates and generalizes `verify.cts`'s W011 block (`verify.cts:2104- + * 2134`): that call site builds ONE hardcoded `phaseCheckboxRe` testing a + * single target phase id (STATE's current phase) for a `[x]` match. This + * builder is the same regex shape, generalized to CAPTURE both the check + * character and the phase id instead of interpolating one fixed target, so + * every declared checkbox is recorded, not just one. + * + * NOT a re-derivation of `isPhaseComplete` (`verification.cts:557`, ADR-3180 + * §7.4, disk-strict): that owner explicitly refuses to consult the ROADMAP + * checkbox at all when DECIDING phase completion (`verification.cts:536- + * 537`). This field only exposes what the checkbox literally says, for a + * diagnostic (W011) whose entire purpose is flagging when the two DISAGREE — + * reading the data is not re-litigating who is authoritative. + */ +function buildRoadmapPhaseCheckboxesField( + roadmapPath: string, +): { value: Record; scope: Scope } { + if (!fs.existsSync(roadmapPath)) { + return { value: {}, scope: SCOPE.UNREADABLE }; + } + let content: string; + try { + content = fs.readFileSync(roadmapPath, 'utf-8'); + } catch { + return { value: {}, scope: SCOPE.UNREADABLE }; + } + + const checkboxRe = new RegExp( + `-\\s*\\[([xX ])\\].*?Phase\\s+0*(${PHASE_NUMBER_TOKEN_SOURCE})${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, + 'gi', + ); + const value: Record = {}; + let m: RegExpExecArray | null; + while ((m = checkboxRe.exec(content)) !== null) { + value[m[2]] = m[1].toLowerCase() === 'x'; + } + return { value, scope: SCOPE.COMPLETE }; +} + +/** + * Resolve `researchValidationStatus` — per phase directory, whether its + * `*-RESEARCH.md` contains the literal heading `## Validation Architecture`, + * and whether a `*-VALIDATION.md` file exists in the same directory. Backs + * W009. + * + * Relocates the file-naming convention `verify.cts:1967-1990` (W009) uses to + * find "the" RESEARCH.md / VALIDATION.md in a phase dir: a flat, + * non-recursive `readdirSync` of the phase dir, then the first entry whose + * name ends `-RESEARCH.md` / any entry ending `-VALIDATION.md`. Computed for + * EVERY phase dir unconditionally (verify.cts's W009 only reads RESEARCH.md + * when `hasResearch && !hasValidation`; this field exposes both booleans + * regardless, so the future W009 rule does its own `hasResearch && + * hasValidationArchitecture && !hasValidationMd` check against parsed data, + * not raw text). + * + * `scope` mirrors `phaseDirs.scope` (the caller-supplied enumeration): a + * per-directory read failure degrades that single entry's booleans to + * `false` and is silently skipped, mirroring `verify.cts`'s own + * `catch { intentionally empty }` around this exact read — this is a + * deliberate fail-open match to the pre-migration behavior, not a scope + * degradation, since the original never surfaced these failures either. + */ +function buildResearchValidationStatusField( + phasesDir: string, + phaseDirNames: string[], + enumerationScope: Scope, +): { + value: { dir: string; hasValidationArchitecture: boolean; hasValidationMd: boolean }[]; + scope: Scope; +} { + const value = phaseDirNames.map((dir) => { + const fullPhaseDir = path.join(phasesDir, dir); + let files: string[]; + try { + files = fs.readdirSync(fullPhaseDir); + } catch { + return { dir, hasValidationArchitecture: false, hasValidationMd: false }; + } + const researchFile = files.find((f) => f.endsWith('-RESEARCH.md')); + const hasValidationMd = files.some((f) => f.endsWith('-VALIDATION.md')); + let hasValidationArchitecture = false; + if (researchFile) { + try { + const researchContent = fs.readFileSync(path.join(fullPhaseDir, researchFile), 'utf-8'); + hasValidationArchitecture = researchContent.includes('## Validation Architecture'); + } catch { + /* intentionally empty — mirrors verify.cts:1986-1988's own silent skip */ + } + } + return { dir, hasValidationArchitecture, hasValidationMd }; + }); + return { value, scope: enumerationScope }; +} + +/** + * Resolve `milestoneArchiveStatus` — `archivedVersions` (versions with a + * `milestones/-ROADMAP.md` snapshot file present) and `documentedVersions` + * (`## ` headings already present in MILESTONES.md). Backs W018. + * + * Relocates `verify.cts:2301-2335` (W018)'s directory-scan glob + * (`^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$` against a flat, non-recursive + * `readdirSync` of `.planning/milestones/`) and its MILESTONES.md + * heading-membership check, generalized from "is THIS archived version's + * heading present" to "list every `## ` heading MILESTONES.md has." + * + * Confirmed NOT a fit for `listArchiveVersionDirs` + * (`phase-locator.cts:127`): that function scans `milestones/*-phases/` + * DIRECTORIES, a different target than this field's `milestones/*-ROADMAP.md` + * FILES — reusing it here would silently answer the wrong question. + * + * Root-scoped (`planningRoot(cwd)`), matching `verify.cts`'s own + * `rootBase`-based `milestonesPath`/`milestonesArchiveDir`. + */ +function buildMilestoneArchiveStatusField( + cwd: string, +): { value: { archivedVersions: string[]; documentedVersions: string[] }; scope: Scope } { + const rootBase = planningRoot(cwd); + const milestonesArchiveDir = path.join(rootBase, 'milestones'); + const milestonesPath = path.join(rootBase, 'MILESTONES.md'); + + let archivedVersions: string[] = []; + let scope: Scope = SCOPE.COMPLETE; + if (fs.existsSync(milestonesArchiveDir)) { + try { + 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 { + scope = SCOPE.UNREADABLE; + } + } + + let documentedVersions: string[] = []; + if (fs.existsSync(milestonesPath)) { + try { + const registryContent = fs.readFileSync(milestonesPath, 'utf-8'); + documentedVersions = [...registryContent.matchAll(/^##\s+(v\d+\.\d+(?:\.\d+)?)/gm)].map( + (m) => m[1], + ); + } catch { + scope = worstScope(scope, SCOPE.UNREADABLE); + } + } + + return { value: { archivedVersions, documentedVersions }, scope }; +} + +/** + * Resolve `planningRootFiles` — plain listing of file (not directory) names + * directly under `.planning/` root. Backs W019. + * + * Pairs with the existing exported `isCanonicalPlanningFile` predicate + * (`artifacts.cts:43`) — but per the design doc, that predicate is called by + * the future W019 RULE per filename, not by this builder; this field only + * needs to BE the raw filename list. + */ +function buildPlanningRootFilesField(cwd: string): { value: string[]; scope: Scope } { + try { + const entries = fs.readdirSync(planningRoot(cwd), { withFileTypes: true }); + return { value: entries.filter((e) => e.isFile()).map((e) => e.name), scope: SCOPE.COMPLETE }; + } catch { + return { value: [], scope: SCOPE.UNREADABLE }; + } +} + /** * Build the full `.planning/` projection for `cwd`. Composes the six §7 * owners named in the design doc's "Owners consumed" table, plus (Phase 11, @@ -276,6 +621,7 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { const phaseDirs = listMilestonePhaseDirs(paths.phases, { cwd }); const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir)); + const stateFields = buildStateFields(paths.state); return { milestone, @@ -284,10 +630,18 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { value: phasesValue, scope: worstScope(phaseDirs.scope, ...phasesValue.map((p) => p.scope)), }, - currentPhaseLabel: buildCurrentPhaseLabel(paths.state), + currentPhaseLabel: stateFields.currentPhaseLabel, config: buildConfigField(cwd), agentInstall: buildAgentInstallField(cwd), worktreeHealth: buildWorktreeHealthField(cwd), + projectSections: buildProjectSectionsField(cwd), + statePhaseTokens: stateFields.statePhaseTokens, + stateStatus: stateFields.stateStatus, + roadmapDeclaredPhases: buildRoadmapDeclaredPhasesField(paths.roadmap), + roadmapPhaseCheckboxes: buildRoadmapPhaseCheckboxesField(paths.roadmap), + researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope), + milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd), + planningRootFiles: buildPlanningRootFilesField(cwd), }; } diff --git a/src/unusable-input.cts b/src/unusable-input.cts index f91bb2244..268542aa6 100644 --- a/src/unusable-input.cts +++ b/src/unusable-input.cts @@ -71,6 +71,13 @@ const UNUSABLE_REASON = Object.freeze({ * is corruption. (#3309, eighth #1879 site — planning-snapshot's config field) */ CONFIG_UNREADABLE: 'config_unreadable', + /** + * A PROJECT.md exists but could not be read (EACCES/EIO/…). Distinct from a project that has + * not run any project-writing command yet: absence returns the same non-answer, silently — + * only an exists-but-unreadable PROJECT.md is corruption. (#3309, ninth #1879 site — + * planning-snapshot's projectSections field) + */ + PROJECT_UNREADABLE: 'project_unreadable', } as const); type UnusableReason = (typeof UNUSABLE_REASON)[keyof typeof UNUSABLE_REASON]; @@ -87,6 +94,8 @@ const REASON_PROSE: Readonly> = Object.freeze({ 'STATE.md exists but could not be read; the current-phase label fell back to unavailable', [UNUSABLE_REASON.CONFIG_UNREADABLE]: 'config.json exists but could not be read or parsed; the config field fell back to unavailable', + [UNUSABLE_REASON.PROJECT_UNREADABLE]: + 'PROJECT.md exists but could not be read; the projectSections field fell back to unavailable', }); // ─── Dedup state ────────────────────────────────────────────────────────────── diff --git a/tests/planning-snapshot.test.cjs b/tests/planning-snapshot.test.cjs index 83d5eedd8..ab9f9549b 100644 --- a/tests/planning-snapshot.test.cjs +++ b/tests/planning-snapshot.test.cjs @@ -688,3 +688,363 @@ describe('Phase-10 fields unchanged by the Phase-11 extension (matrix row 8)', ( assert.ok('worktreeHealth' in snap); }); }); + +// ═════════════════════════════════════════════════════════════════════════ +// Phase 11 (#3309) — "Rule table organization" batch, 7 more fields +// +// Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md +// ("Rule table organization" table) +// +// Each field relocates (not reinvents) an existing verify.cts derivation — +// see the JSDoc above each builder in src/planning-snapshot.cts for the +// exact source lines. Fixture helpers below mirror the existing +// writeRoadmap/writeState/writeFile idiom. +// ═════════════════════════════════════════════════════════════════════════ + +function writeProject(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'PROJECT.md'), content); +} + +function writeMilestoneArchiveRoadmap(cwd, version, content) { + const archiveDir = path.join(planningDirOf(cwd), 'milestones'); + fs.mkdirSync(archiveDir, { recursive: true }); + fs.writeFileSync(path.join(archiveDir, `${version}-ROADMAP.md`), content); +} + +function writeMilestonesRegistry(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'MILESTONES.md'), content); +} + +describe('projectSections field (Phase 11, #3309)', () => { + test('happy: returns every ## heading actually present, unfiltered against any required list', (t) => { + const cwd = createTempDir('gsd-3309-ps1-'); + t.after(() => cleanup(cwd)); + writeProject(cwd, [ + '# My Project', + '', + '## What This Is', + '', + 'text', + '', + '## Custom Section', + '', + '### Not a top-level heading', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.projectSections, { + value: ['What This Is', 'Custom Section'], + scope: SCOPE.COMPLETE, + exists: true, + }); + }); + + test('absence: no PROJECT.md is a real non-answer, not corruption', (t) => { + const cwd = createTempDir('gsd-3309-ps2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.projectSections, { value: null, scope: SCOPE.UNREADABLE, exists: false }); + assert.strictEqual(emitted, 0); + }); + + test('hostile: present-but-unreadable PROJECT.md degrades without throwing, emits PROJECT_UNREADABLE exactly once', (t) => { + const cwd = createTempDir('gsd-3309-ps3-'); + t.after(() => cleanup(cwd)); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'PROJECT.md')); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.projectSections, { value: null, scope: SCOPE.UNREADABLE, exists: true }); + assert.strictEqual(emitted, 1, 'present-but-unreadable PROJECT.md is corruption — exactly one PROJECT_UNREADABLE diagnostic'); + }); +}); + +describe('statePhaseTokens field (Phase 11, #3309)', () => { + test('happy: every phase-number-shaped token anywhere in STATE.md text, in appearance order', (t) => { + const cwd = createTempDir('gsd-3309-spt1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + // The `Phase:`-field syntax ("Phase: 3 of 8") does NOT match this regex — + // it requires `[Pp]hase\s+` (whitespace, not a colon, right + // after "Phase"), exactly like verify.cts's own W002 relocation target. + // Only prose-style "Phase N" references match, e.g. bracketed decision + // annotations and free-text mentions. + appendToState(cwd, [ + '', + '## Current Position', + '', + 'Phase: 3 of 8 (User Auth)', + '', + '### Decisions', + '- [Phase 5]: revisit after Phase 2 wraps', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.statePhaseTokens, { value: ['5', '2'], scope: SCOPE.COMPLETE }); + }); + + test('absence: no STATE.md yields an empty token list, non-answer scope, no diagnostic', (t) => { + const cwd = createTempDir('gsd-3309-spt2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.UNREADABLE }); + assert.strictEqual(emitted, 0); + }); + + test('hostile: unreadable-but-present STATE.md degrades statePhaseTokens together with currentPhaseLabel from ONE diagnostic', (t) => { + const cwd = createTempDir('gsd-3309-spt3-'); + t.after(() => cleanup(cwd)); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'STATE.md')); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.statePhaseTokens, { value: [], scope: SCOPE.UNREADABLE }); + assert.deepStrictEqual(snap.currentPhaseLabel, { value: null, scope: SCOPE.UNREADABLE }); + assert.strictEqual(emitted, 1, 'the shared STATE.md read must not double-emit across fields'); + }); +}); + +describe('stateStatus field (Phase 11, #3309)', () => { + test('happy: Status field under Current Position is extracted verbatim, mirroring currentPhaseLabel', (t) => { + const cwd = createTempDir('gsd-3309-ss1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + appendToState(cwd, [ + '', + '## Current Position', + '', + 'Phase: 3 of 8 (User Auth)', + 'Status: In progress', + '', + ].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.stateStatus, { value: 'In progress', scope: SCOPE.COMPLETE }); + }); + + test('boundary: missing Current Position section still resolves status from frontmatter, scope TRUNCATED', (t) => { + const cwd = createTempDir('gsd-3309-ss2-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { status: 'planning' }); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.stateStatus.value, 'planning'); + assert.strictEqual(snap.stateStatus.scope, SCOPE.TRUNCATED); + }); + + test('absence: no STATE.md yields a non-answer, no diagnostic', (t) => { + const cwd = createTempDir('gsd-3309-ss3-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const [snap, emitted] = emissionsDuring(() => buildPlanningSnapshot(cwd)); + assert.deepStrictEqual(snap.stateStatus, { value: null, scope: SCOPE.UNREADABLE }); + assert.strictEqual(emitted, 0); + }); +}); + +describe('roadmapDeclaredPhases field (Phase 11, #3309)', () => { + test('happy: every declared phase id paired with the milestone section it was found under', (t) => { + const cwd = createTempDir('gsd-3309-rdp1-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapDeclaredPhases, { + value: [ + { phaseId: '1', milestone: 'v1.0' }, + { phaseId: '2', milestone: 'v1.0' }, + ], + scope: SCOPE.COMPLETE, + }); + }); + + test('boundary: a phase declared before any version heading gets milestone: null', (t) => { + const cwd = createTempDir('gsd-3309-rdp2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['### Phase 9: Prelude', '', '## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + const prelude = snap.roadmapDeclaredPhases.value.find((p) => p.phaseId === '9'); + const foo = snap.roadmapDeclaredPhases.value.find((p) => p.phaseId === '1'); + assert.deepStrictEqual(prelude, { phaseId: '9', milestone: null }); + assert.deepStrictEqual(foo, { phaseId: '1', milestone: 'v1.0' }); + }); + + test('absence: no ROADMAP.md is a non-answer', (t) => { + const cwd = createTempDir('gsd-3309-rdp3-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapDeclaredPhases, { value: [], scope: SCOPE.UNREADABLE }); + }); + + test('hostile: unreadable ROADMAP.md degrades to an empty list, scope UNREADABLE', (t) => { + const cwd = createTempDir('gsd-3309-rdp4-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'ROADMAP.md')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapDeclaredPhases, { value: [], scope: SCOPE.UNREADABLE }); + }); +}); + +describe('roadmapPhaseCheckboxes field (Phase 11, #3309)', () => { + test('happy: [x]/[ ] checkbox state parsed per phase id', (t) => { + const cwd = createTempDir('gsd-3309-rpc1-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## Progress', '', '- [x] Phase 1: Foo', '- [ ] Phase 2: Bar'].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapPhaseCheckboxes, { value: { '1': true, '2': false }, scope: SCOPE.COMPLETE }); + }); + + test('boundary: no checklist lines present is a real empty answer, not a non-answer', (t) => { + const cwd = createTempDir('gsd-3309-rpc2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapPhaseCheckboxes, { value: {}, scope: SCOPE.COMPLETE }); + }); + + test('hostile: unreadable ROADMAP.md degrades to an empty map, scope UNREADABLE', (t) => { + const cwd = createTempDir('gsd-3309-rpc3-'); + t.after(() => cleanup(cwd)); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'ROADMAP.md')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.roadmapPhaseCheckboxes, { value: {}, scope: SCOPE.UNREADABLE }); + }); +}); + +describe('researchValidationStatus field (Phase 11, #3309)', () => { + test('happy: RESEARCH.md carries the Validation Architecture heading and a VALIDATION.md exists', (t) => { + const cwd = createTempDir('gsd-3309-rvs1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + writeFile(cwd, '.planning/phases/01-foo/01-RESEARCH.md', '# Research\n\n## Validation Architecture\n\ntext\n'); + writeFile(cwd, '.planning/phases/01-foo/01-VALIDATION.md', '# Validation\n'); + + const snap = buildPlanningSnapshot(cwd); + const entry = snap.researchValidationStatus.value.find((r) => r.dir === '01-foo'); + assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: true, hasValidationMd: true }); + assert.strictEqual(snap.researchValidationStatus.scope, SCOPE.COMPLETE); + }); + + test('negative: RESEARCH.md without the heading and no VALIDATION.md reports both false', (t) => { + const cwd = createTempDir('gsd-3309-rvs2-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + writeFile(cwd, '.planning/phases/01-foo/01-RESEARCH.md', '# Research\n\nno special section\n'); + + const snap = buildPlanningSnapshot(cwd); + const entry = snap.researchValidationStatus.value.find((r) => r.dir === '01-foo'); + assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: false, hasValidationMd: false }); + }); + + test('hostile: an unreadable phase directory degrades that entry to false/false without throwing', (t) => { + const cwd = createTempDir('gsd-3309-rvs3-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + const phaseDir = path.join(planningDirOf(cwd), 'phases', '01-foo'); + fs.mkdirSync(phaseDir, { recursive: true }); + injectPhaseDirFault(t, phaseDir); + + const snap = buildPlanningSnapshot(cwd); + const entry = snap.researchValidationStatus.value.find((r) => r.dir === '01-foo'); + assert.deepStrictEqual(entry, { dir: '01-foo', hasValidationArchitecture: false, hasValidationMd: false }); + }); +}); + +describe('milestoneArchiveStatus field (Phase 11, #3309)', () => { + test('happy: archived ROADMAP snapshot present and its version documented in MILESTONES.md', (t) => { + const cwd = createTempDir('gsd-3309-mas1-'); + t.after(() => cleanup(cwd)); + writeMilestoneArchiveRoadmap(cwd, 'v1.0', '# v1.0 archive\n'); + writeMilestonesRegistry(cwd, '## v1.0\n\nShipped.\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.milestoneArchiveStatus, { + value: { archivedVersions: ['v1.0'], documentedVersions: ['v1.0'] }, + scope: SCOPE.COMPLETE, + }); + }); + + test('negative: no milestones/ dir and no MILESTONES.md is a real empty answer, not a non-answer', (t) => { + const cwd = createTempDir('gsd-3309-mas2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.milestoneArchiveStatus, { + value: { archivedVersions: [], documentedVersions: [] }, + scope: SCOPE.COMPLETE, + }); + }); + + test('boundary: an archived version missing from the registry is reported, not silently dropped', (t) => { + const cwd = createTempDir('gsd-3309-mas3-'); + t.after(() => cleanup(cwd)); + writeMilestoneArchiveRoadmap(cwd, 'v1.0', '# v1.0 archive\n'); + // No MILESTONES.md at all. + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.milestoneArchiveStatus.value.archivedVersions, ['v1.0']); + assert.deepStrictEqual(snap.milestoneArchiveStatus.value.documentedVersions, []); + }); + + test('hostile: an unreadable milestones/ dir degrades to scope UNREADABLE without throwing', (t) => { + const cwd = createTempDir('gsd-3309-mas4-'); + t.after(() => cleanup(cwd)); + // Directory-vs-file swap: milestones/ is a regular FILE, so + // fs.existsSync is true but readdirSync throws ENOTDIR. + makeDirUnreadableAsFile(path.join(planningDirOf(cwd), 'milestones')); + + const snap = buildPlanningSnapshot(cwd); + assert.strictEqual(snap.milestoneArchiveStatus.scope, SCOPE.UNREADABLE); + }); +}); + +describe('planningRootFiles field (Phase 11, #3309)', () => { + test('happy: lists files (not directories) directly under .planning/ root', (t) => { + const cwd = createTempDir('gsd-3309-prf1-'); + t.after(() => cleanup(cwd)); + writeState(cwd, { milestone: 'v1.0' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n')); + writeFile(cwd, '.planning/NOTES.md', 'stray file\n'); + fs.mkdirSync(path.join(planningDirOf(cwd), 'phases'), { recursive: true }); // a directory — must be excluded + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.planningRootFiles.value.slice().sort(), ['NOTES.md', 'ROADMAP.md', 'STATE.md']); + assert.strictEqual(snap.planningRootFiles.scope, SCOPE.COMPLETE); + }); + + test('absence: no .planning/ directory at all degrades to an empty list, scope UNREADABLE', (t) => { + const cwd = createTempDir('gsd-3309-prf2-'); + t.after(() => cleanup(cwd)); + // .planning/ deliberately never created. + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.planningRootFiles, { value: [], scope: SCOPE.UNREADABLE }); + }); + + test('hostile: an unreadable .planning/ root degrades without throwing', (t) => { + const cwd = createTempDir('gsd-3309-prf3-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + injectPhaseDirFault(t, planningDirOf(cwd)); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.planningRootFiles, { value: [], scope: SCOPE.UNREADABLE }); + }); +}); diff --git a/tests/unusable-input.test.cjs b/tests/unusable-input.test.cjs index 8452197ab..84b9a3328 100644 --- a/tests/unusable-input.test.cjs +++ b/tests/unusable-input.test.cjs @@ -72,7 +72,7 @@ describe('UNUSABLE_REASON', () => { // (enum + call site + this assertion) instead of a silent widening. assert.deepStrictEqual( Object.keys(UNUSABLE_REASON).sort(), - ['CONFIG_UNREADABLE', 'FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'], + ['CONFIG_UNREADABLE', 'FRONTMATTER_UNTERMINATED', 'LAST_ACTIVITY_UNPARSEABLE', 'PROJECT_UNREADABLE', 'ROADMAP_UNREADABLE', 'STATE_UNREADABLE'], ); assert.strictEqual(UNUSABLE_REASON.FRONTMATTER_UNTERMINATED, 'frontmatter_unterminated'); }); @@ -181,6 +181,53 @@ describe('CONFIG_UNREADABLE', () => { }); }); +// ─── PROJECT_UNREADABLE: a PROJECT.md that exists but could not be read ────── + +describe('PROJECT_UNREADABLE', () => { + test('a genuinely unreadable PROJECT.md produces exactly one diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const emitted = emissionsDuring(() => { + const wrote = warnUnusableInput({ + reason: UNUSABLE_REASON.PROJECT_UNREADABLE, + source: '/u/project-unreadable.md', + }); + assert.strictEqual(wrote, true); + }); + assert.strictEqual(emitted, 1); + }); + + test('the same PROJECT.md path reported twice yields one diagnostic', () => { + _resetUnusableInputWarningsForTests(); + const source = '/u/project-unreadable-dedup/PROJECT.md'; + const emitted = emissionsDuring(() => { + const first = warnUnusableInput({ reason: UNUSABLE_REASON.PROJECT_UNREADABLE, source }); + const repeat = warnUnusableInput({ reason: UNUSABLE_REASON.PROJECT_UNREADABLE, source }); + assert.strictEqual(first, true); + assert.strictEqual(repeat, false, 'same (path, cause) must dedup'); + }); + assert.strictEqual(emitted, 1); + }); + + test('two different PROJECT.md paths are never suppressed as one', () => { + _resetUnusableInputWarningsForTests(); + const emitted = emissionsDuring(() => { + warnUnusableInput({ + reason: UNUSABLE_REASON.PROJECT_UNREADABLE, + source: '/u/project-unreadable-a/PROJECT.md', + }); + warnUnusableInput({ + reason: UNUSABLE_REASON.PROJECT_UNREADABLE, + source: '/u/project-unreadable-b/PROJECT.md', + }); + }); + assert.strictEqual(emitted, 2, 'keying too coarsely would hide a real second fault'); + }); + + test('the reason value is the frozen string "project_unreadable"', () => { + assert.strictEqual(UNUSABLE_REASON.PROJECT_UNREADABLE, 'project_unreadable'); + }); +}); + // ─── The discriminator: truncated vs. everything that merely looks like it ─── describe('extractFrontmatter — flags a genuinely truncated frontmatter', () => { From 8c9ca3f7c682e75d646f58fe706aaec5ebce4496 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:01 -0400 Subject: [PATCH 04/35] refactor(#3309): add allPhaseDirNames field to planning-snapshot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W007 (orphan disk dir with no ROADMAP entry) cannot be sourced from phaseDirs, which is windowed to ROADMAP-declared phases only — an orphan dir can never appear in an already-ROADMAP-filtered set. Adds an unwindowed allPhaseDirNames field so the rule can actually fire. --- src/planning-snapshot.cts | 45 ++++++++++++++++++++++++++++++++ tests/planning-snapshot.test.cjs | 42 +++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts index f7a5bba11..30219461a 100644 --- a/src/planning-snapshot.cts +++ b/src/planning-snapshot.cts @@ -135,6 +135,27 @@ interface PlanningSnapshot { scope: Scope; }; planningRootFiles: { value: string[]; scope: Scope }; + // W006/W007 (ROADMAP/disk consistency group) fidelity fix, found while + // implementing `src/health-diagnostic-rules/roadmap-disk-consistency.cts`: + // `phaseDirs` (Phase 10) is deliberately WINDOWED to the phases + // `listMilestonePhaseDirs`'s `inWindow` filter (`getMilestonePhaseFilter`, + // `src/roadmap-parser.cts:1220`) resolves as belonging to the CURRENT + // milestone window — a directory whose phase id is NOT declared anywhere + // in ROADMAP.md is EXCLUDED from `phaseDirs.value` by construction + // (`isDirInMilestone` membership test). That is exactly the directory + // W007 exists to find ("an on-disk phase dir has no matching ROADMAP + // entry"), so sourcing W007 from `phaseDirs.value` would make it + // structurally unable to fire on the very case it names: an orphan + // directory can never be a member of the set that is itself defined as + // "directories the roadmap already declares." `allPhaseDirNames` is the + // un-windowed twin — every directory actually present under the active + // `phases/` root, unfiltered by roadmap declaration (sentinel-id + // exclusion is left to the RULE, mirroring `verify.cts:2091`'s own + // per-entry `isSentinelPhaseId` guard rather than baking it into the + // field). Archived-milestone directories are out of scope here exactly as + // they already are for `phaseDirs` (see this batch's own disclosed + // fidelity reduction for that). + allPhaseDirNames: { value: string[]; scope: Scope }; } /** @@ -607,6 +628,29 @@ function buildPlanningRootFilesField(cwd: string): { value: string[]; scope: Sco } } +/** + * Resolve `allPhaseDirNames` — every directory name directly under the + * active `phases/` root, UNFILTERED by `listMilestonePhaseDirs`'s + * current-milestone-window membership test (unlike `phaseDirs`). Backs + * W007 (see the field's own doc comment on `PlanningSnapshot` for why + * `phaseDirs` cannot). An absent `phases/` root is a real empty, not a + * failure (mirrors `listMilestonePhaseDirs`'s own treatment); a present but + * unreadable root degrades to `UNREADABLE` with an empty list. + */ +function buildAllPhaseDirNamesField(phasesDir: string): { value: string[]; scope: Scope } { + if (!fs.existsSync(phasesDir)) return { value: [], scope: SCOPE.COMPLETE }; + try { + const value = fs + .readdirSync(phasesDir, { withFileTypes: true }) + .filter((e) => e.isDirectory()) + .map((e) => e.name) + .sort(); + return { value, scope: SCOPE.COMPLETE }; + } catch { + return { value: [], scope: SCOPE.UNREADABLE }; + } +} + /** * Build the full `.planning/` projection for `cwd`. Composes the six §7 * owners named in the design doc's "Owners consumed" table, plus (Phase 11, @@ -642,6 +686,7 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope), milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd), planningRootFiles: buildPlanningRootFilesField(cwd), + allPhaseDirNames: buildAllPhaseDirNamesField(paths.phases), }; } diff --git a/tests/planning-snapshot.test.cjs b/tests/planning-snapshot.test.cjs index ab9f9549b..50525f1b3 100644 --- a/tests/planning-snapshot.test.cjs +++ b/tests/planning-snapshot.test.cjs @@ -1048,3 +1048,45 @@ describe('planningRootFiles field (Phase 11, #3309)', () => { assert.deepStrictEqual(snap.planningRootFiles, { value: [], scope: SCOPE.UNREADABLE }); }); }); + +describe('allPhaseDirNames field (Phase 11, #3309 — health-diagnostic-rules/roadmap-disk-consistency batch)', () => { + // Found while implementing W007 (`src/health-diagnostic-rules/ + // roadmap-disk-consistency.cts`): `phaseDirs` is windowed to directories + // the ROADMAP already declares, so it can never expose a genuine orphan + // directory. `allPhaseDirNames` is the unwindowed twin. + + test('happy: lists every directory under phases/, including one NOT declared anywhere in ROADMAP.md', (t) => { + const cwd = createTempDir('gsd-3309-apdn1-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '01-foo'), { recursive: true }); + fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '04-extra'), { recursive: true }); // undeclared + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.allPhaseDirNames.value.slice().sort(), ['01-foo', '04-extra']); + assert.strictEqual(snap.allPhaseDirNames.scope, SCOPE.COMPLETE); + // Sanity: `phaseDirs` (windowed) must NOT include the undeclared dir — + // this is the exact gap `allPhaseDirNames` exists to close. + assert.ok(!snap.phaseDirs.value.includes('04-extra')); + }); + + test('absence: no phases/ directory at all is a real empty, not a failure', (t) => { + const cwd = createTempDir('gsd-3309-apdn2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n')); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.allPhaseDirNames, { value: [], scope: SCOPE.COMPLETE }); + }); + + test('hostile: an unreadable phases/ directory degrades to an empty list, scope UNREADABLE, without throwing', (t) => { + const cwd = createTempDir('gsd-3309-apdn3-'); + t.after(() => cleanup(cwd)); + const phasesDir = path.join(planningDirOf(cwd), 'phases'); + fs.mkdirSync(phasesDir, { recursive: true }); + injectPhaseDirFault(t, phasesDir); + + const snap = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snap.allPhaseDirNames, { value: [], scope: SCOPE.UNREADABLE }); + }); +}); From cc1ec5b0fd050b2291b6cc5ac3f20948ff99ca66 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:13 -0400 Subject: [PATCH 05/35] chore(#3309): register health-diagnostic-rules/*.cjs as generated artifacts Mirrors the existing health-diagnostic.cjs / planning-snapshot.cjs pattern: gitignore the compiled output and exclude it from eslint so the generated JS isn't linted as hand-written source. Also adds the CONTEXT.md glossary entry and INVENTORY.md rows for the new src/health-diagnostic-rules/ directory. --- .gitignore | 8 ++++++++ CONTEXT.md | 3 +++ docs/INVENTORY.md | 8 ++++++++ eslint.config.mjs | 8 ++++++++ 4 files changed, 27 insertions(+) diff --git a/.gitignore b/.gitignore index 54264c09a..0cb4f6a17 100644 --- a/.gitignore +++ b/.gitignore @@ -197,6 +197,14 @@ build/ /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/health-diagnostic-rules/root-existence.cjs +/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.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 623dbfb0c..fd2ccf6a4 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -109,6 +109,9 @@ Module owning the parsed projection of `.planning/` that a diagnostic rule may r ### 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`. +### Health Diagnostic Rule Groups +Directory `src/health-diagnostic-rules/` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) owning the 32 rules migrated off `cmdValidateHealth`, split into eight files — one per subject-area group from the design doc's "Rule table organization" table — each exporting a `RULES: Rule[]` conforming to the Health Diagnostic Module's frozen `Rule` shape. `src/health-diagnostic.cts` concatenates all eight into the single `RULES` table `evaluateRules` runs; no group re-derives its own `Diagnostic`/`Remedy` shapes. Groups: `root-existence.cts` (root `.planning/` + PROJECT.md existence, E002-E004/W001), `state-consistency.cts` (STATE.md vs config/ROADMAP/disk, W002/W011/W021/W026 — W024's state_head freshness check is a disclosed gap, deliberately not migrated), `config-validation.cts` (config.json shape, W003/W004/W022/E005/W008/W012-W016), `phase-structure.cts` (phase directory structure, W005/W023/I001/W009), `agent-install.cts` (agent-installation completeness, W010), `roadmap-disk-consistency.cts` (ROADMAP-vs-disk phase matching via the shared `matchPhaseDirs` matcher, W006/W007), `worktree-health.cts` (worktree health, W020/W017/W027), `milestone-archive-hygiene.cts` (milestone archive + root hygiene, W018/W019). Every rule is a behavior-preserving port of one `addIssue` call site in `cmdValidateHealth` (`src/verify.cts`), reading only the parsed `PlanningSnapshot` fields the Planning Snapshot Module already computes — never raw `.planning/` I/O. Source of truth: `gsd-core/bin/lib/health-diagnostic-rules/*.cjs` (generated from `src/health-diagnostic-rules/*.cts`). + ### 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.md b/docs/INVENTORY.md index 724ddf114..ed9f2bac3 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -435,6 +435,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `active-workstream-store.cjs` | Workstream source precedence and selection (CLI `--ws` > `GSD_WORKSTREAM` env > stored pointer); name validation and environment propagation | | `adr-parser.cjs` | ADR decision parser for plan-phase ingest express path; normalizes section synonyms, parses status/decision/scope fences, and enforces status rejection gates | | `agent-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools agent` | +| `health-diagnostic-rules/agent-install.cjs` | Health-diagnostic rule: agent-installation-completeness check (W010) — the single `checkAgentsInstalled` call site's four mutually exclusive conditions, ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `api-coverage.cjs` | API-coverage detector + matrix validator (#1562, #2365) — pure `detectApiIntegration` (fail-closed: same-clause verb+noun signal + ` API/SDK` surface naming a real service; strips fenced code, inline code, and path-shaped tokens; external hosts count, first-party route paths do not) and `validateCoverageMatrix`/`parseCoverageMatrix`/`renderCoverageMatrix` for the COVERAGE.md artifact (incl. the `No external API integration: ` declaration); STDIN CLI (`echo "$SCOPE" \| node .../api-coverage.cjs [--json]`, exit 0=detected/1=none/2=error); consumed by the `ai-integration` capability's `plan:pre` contribution and blocking `verify:pre` gate (`check api-coverage.verify-pre`) | | `artifacts.cjs` | Canonical artifact registry — known `.planning/` root file names; used by `gsd-health` W019 lint | | `audit-command-router.cjs` | ADR-959 capability command router for `gsd-tools audit-uat` and `gsd-tools audit-open` — extracted from hardcoded cases in `gsd-tools.cjs`; dispatches to `uat.cjs:cmdAuditUat` and `audit.cjs:{auditOpenArtifacts,formatAuditReport}`; phase 4d-impl-3 | @@ -470,6 +471,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `config-loader.cjs` | Project config loading — defaults merge, legacy-key migration, workstream overlay, unknown-key/profile-override validation (extracted from `core.cjs`, ADR-857) | | `config-schema.cjs` | Single source of truth for `VALID_CONFIG_KEYS` and dynamic key patterns; imported by both the validator and the config-schema-docs parity test | | `config-types.cjs` | TypeScript type definitions for the `model_policy` config block — `ModelPolicyConfig`, `TierEntry`, `RuntimeTiers`; compiled from `src/config-types.cts` at publish time (ADR-457) | +| `health-diagnostic-rules/config-validation.cjs` | Health-diagnostic rules: config.json validation checks (W003, W004, W022, E005, W008, W012-W016), reading only `snapshot.config`, ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `config.cjs` | `config.json` read/write, section initialization; imports validator from `config-schema.cjs` | | `configuration.cjs` | Configuration Module — legacy-key normalization, defaults merge, and explicit on-disk migration; pure normalization primitives consumed by `config-loader.cjs` and `config-schema.cjs` (loadConfig extracted to config-loader per ADR-857 #885) | | `context-composer.cjs` | Shared budget-composition seam (ADR-1671, #2929) — `composeWithinBudget` trims an ordered fragment list to a measured budget and returns a PLAN of surviving fragments, never rendered text, so one seam serves both the review pipeline and per-runtime emission. Closed strategy set: `verbatim`, `head-shrink`, `proportional-truncate` (with a per-fragment floor), `drop`. The budget unit is injected via `measure(text)` — tokens for `prompt-budget`, bytes for emission — with `charsPerUnit` as its inverse. Also exports `headShrink`/`tailTruncate`. Compiled from `src/context-composer.cts` | @@ -515,6 +517,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c/6 registry-consuming query; given a canonical loop point, filters `byLoopPoint` by resolved Capability State plus config activation (`when` key traversal with prototype-pollution guard), returns `{ point, activeHooks, rendered }` envelope; `resolveLoopHooks` and `renderLoopHooks` are pure (no I/O); command surface: `gsd-tools loop render-hooks [--config-dir ]` | | `markdown-sectionizer.cjs` | Canonical markdown-structure parsing seam (ADR-1372, epic #1372) — pure, Node built-ins only; exports `stripFencedCode` (CommonMark-correct fence stripper, CRLF-safe), `stripInlineCode` (per-line CommonMark inline-code-span stripper, #2365), `tokenizeHeadings` (ATX headings outside fenced blocks), `collectSections`/`collectSection` (line-by-line section collection with `bodyStart`/`bodyEnd` offsets), `iterateBullets` (dash/checkbox/numbered markers), `extractTaggedBlocks` (inner text of `…` blocks, caller decides fence-stripping), `replaceSection` (pure character-offset body splice for read-modify-write callers), and `withSection` (resolve a section by heading/predicate and run an edit callback against ONLY its body, splicing the result back — ADR-2143 §4 bounded mutation); foundation for T0–T7 migration tiers retiring 8+ ad-hoc parsers | | `markdown-table.cjs` | Canonical GFM table model + `TABLE_SCHEMAS` registry seam (ADR-2143, epic #2143) — pure, Node built-ins only; exports `parseMarkdownTable(sectionText) → Result` (parses the first GFM pipe table, typed parse errors for ragged/malformed rows rather than silent coercion), `MarkdownTable` (`{columns, rows}`, rows addressed by column name), `Result` (`{ok:true,value}\|{ok:false,reason}` — distinct from command-routing-hub's dispatch `Result`), `TABLE_SCHEMAS` (canonical column-header variants for `RoadmapProgress`/`RequirementsTraceability`/`QuickTasks`/`Security` tables), and `matchTableSchema(columns) → {id,label}\|null` (resolves parsed headers back to a canonical schema); consumed by `phase-lifecycle.cts`'s `deriveProgressFromRoadmap` (fixes #2137, the 5-column milestone-grouped Progress table) | +| `health-diagnostic-rules/milestone-archive-hygiene.cjs` | Health-diagnostic rules: milestone archive + root hygiene checks (W018, W019), ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `milestone.cjs` | Milestone archival, requirements marking | | `model-catalog.cjs` | CJS adapter over the shared model catalog JSON; exports canonical runtime tier defaults, agent profile maps, alias maps, and routing metadata for all CLI consumers | | `model-profiles.cjs` | Backward-compatible profile helpers derived from `model-catalog.cjs`; no longer owns its own model table | @@ -526,6 +529,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, milestone/phase-dir id parsing, phase-markdown regex builders (extracted from `core.cjs`, ADR-857) | | `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler | | `phase-locator.cjs` | Phase-directory search/location — active + archived phase-dir discovery, phase-id matching against the filesystem (extracted from `core.cjs`, ADR-857) | +| `health-diagnostic-rules/phase-structure.cjs` | Health-diagnostic rules: phase directory structure checks (W005, W023, I001, W009), ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `phase.cjs` | Phase directory operations, decimal numbering, plan indexing | | `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` | | `plan-dependency-graph.cjs` | Shared halt-propagation over a plan's `depends_on` DAG — the single topological-order + halt-propagation engine used by both `phase.cjs`'s wave-grouping and `phase-locator.cjs`'s phase-location primitive, so the two can never diverge on which plans a halted plan blocks (#2830) | @@ -549,9 +553,11 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `review-lane-runner.cjs` | Execution of a reviewer-lane invocation plan (compiled from `src/review-lane-runner.cts`, gitignored; ADR-2782 Phase 5b) — probe, spawn or HTTP call, empty-output policy, egress-host check, and dispatch of the three first-party `handler` modules; exports `runLane`, `probeLane`, `checkEgressHost`, `writeReviewOrStub` | | `review-reviewer-selection.cjs` | Reviewer selection/normalization helpers for `/gsd-review` default reviewer policy and precedence | | `roadmap-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools roadmap` | +| `health-diagnostic-rules/roadmap-disk-consistency.cjs` | Health-diagnostic rules: ROADMAP-vs-disk phase directory consistency checks (W006, W007), both resolved through the shared `matchPhaseDirs` matcher, ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `roadmap-parser.cjs` | ROADMAP.md parsing — milestone slicing, current-milestone extraction, phase/milestone lookups, milestone-phase filter (extracted from `core.cjs`, ADR-857) | | `roadmap-upgrade.cjs` | Migration tool for converting legacy `Phase N` entries to milestone-prefixed `Phase M-NN` convention; `computeMigrationPlan` + `applyMigration` with dry-run default and atomic rollback | | `roadmap.cjs` | ROADMAP.md parsing, phase extraction, plan progress | +| `health-diagnostic-rules/root-existence.cjs` | Health-diagnostic rules: root `.planning/` existence + PROJECT.md checks (E002-E004, W001), ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `runtime-artifact-conversion.cjs` | Runtime artifact conversion module — projects Claude-authored commands, agents, and skills into runtime-specific artifact bodies while preserving installer compatibility exports | | `runtime-artifact-install-plan.cjs` | Runtime artifact install plan module — stages pre-resolved layout kinds, applies runtime body rewrites, and returns copy-plan items plus cleanup obligations | | `runtime-artifact-layout.cjs` | Runtime artifact layout module — resolves the artifact directory shapes (commands, agents, skills) for each supported runtime; single source of truth for per-runtime artifact placement (#3663) | @@ -568,6 +574,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `shell-command-projection.cjs` | Runtime-aware shell command projection for managed hook serialization: decides PowerShell call-operator usage by runtime/platform and normalizes Windows script path tokens | | `spec-section.cjs` | SPEC section-status helper (compiled from `src/spec-section.cts`, gitignored) — the single source of truth for the canonical SPEC headings (suffix-tolerant) and markdown-table row counting; `specSectionStatus`/`countSectionDataRows` decide per-section "supplied" for plan-phase's spec-less probe fallback, replacing ad-hoc awk (contract pinned by `tests/spec-section.test.cjs`) | | `state-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools state` | +| `health-diagnostic-rules/state-consistency.cjs` | Health-diagnostic rules: STATE.md consistency checks (W002, W011, W021, W026) against config/ROADMAP/disk, ported behavior-preserving from `cmdValidateHealth`; W024 (state_head freshness) is a documented gap, deliberately not migrated (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `state.cjs` | STATE.md parsing, updating, progression, metrics | | `state-document.cjs` | Pure STATE.md field extraction, replacement, status normalization, and progress calculation transforms | | `surface.cjs` | Runtime surface module — manages the runtime enable/disable surface state independently of the install-time profile marker (ADR-0011 Phase 2) | @@ -591,6 +598,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `workstream-name-policy.cjs` | Canonical workstream name validation (`isValidActiveWorkstreamName`, `hasInvalidPathSegment`, `validateWorkstreamName`) and slug normalization (`toWorkstreamSlug`) | | `workstream.cjs` | Workstream CRUD, migration, session-scoped active pointer | | `worktree-base-ref.cjs` | Worktree base-ref drift detection and degrade decision (`evaluateWorktreeBaseDegrade`) plus no-clobber `worktree.baseRef` settings management for the `base-check`/`set-baseref` subcommands (#683) | +| `health-diagnostic-rules/worktree-health.cjs` | Health-diagnostic rules: worktree health checks (W020, W017, W027 — the split-off stale-worktree subject), ported behavior-preserving from `cmdValidateHealth` (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `worktree-safety.cjs` | Worktree-root resolution and non-destructive prune policy decisions; owns W017 health-check logic | | `write-set.cjs` | Shared fail-loud `Result` (`{ok:true,value}\|{ok:false,reason}`) and per-surface write-set contracts (ADR-2143, epic #2143) — `WriteOutcome` (`{surface,applied}`), `WriteSet` (`WriteOutcome[]`), and `writeSetComplete(ws)` (true only when the set is non-empty AND every surface applied, never an OR-into-one-flag); `markdown-table.cjs` re-exports `Result` from here so existing importers are unaffected; consumed by `milestone.cts`'s `requirements mark-complete` handler to report a structured per-surface (`checkbox`/`traceability`) write-set alongside its existing fields (fixes the structural half of #2140) | diff --git a/eslint.config.mjs b/eslint.config.mjs index 398c0a0ff..5b24e16ab 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -136,6 +136,14 @@ export default tseslint.config( '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/health-diagnostic-rules/root-existence.cjs', + 'gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs', + 'gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs', + 'gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs', + 'gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs', + 'gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs', + 'gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs', + 'gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs', 'gsd-core/bin/lib/shell-command-projection.cjs', 'gsd-core/bin/lib/security.cjs', 'gsd-core/bin/lib/command-aliases.cjs', From 719530838489bad0ebd2b8bda623fb9408ce4cf4 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:24 -0400 Subject: [PATCH 06/35] refactor(#3309): add root-existence health-diagnostic rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit E002, E003, E004, W001 — PROJECT.md/ROADMAP.md/STATE.md existence and PROJECT.md section-completeness checks, migrated onto the frozen rule table per ADR-3180 §8.2. --- .../root-existence.cts | 172 ++++++++++ .../root-existence.test.cjs | 303 ++++++++++++++++++ 2 files changed, 475 insertions(+) create mode 100644 src/health-diagnostic-rules/root-existence.cts create mode 100644 tests/health-diagnostic-rules/root-existence.test.cjs diff --git a/src/health-diagnostic-rules/root-existence.cts b/src/health-diagnostic-rules/root-existence.cts new file mode 100644 index 000000000..3e991cf53 --- /dev/null +++ b/src/health-diagnostic-rules/root-existence.cts @@ -0,0 +1,172 @@ +/** + * Health Diagnostic — Root existence + PROJECT.md rules (Phase 11, #3309, + * ADR-3180 §8.2/§8.3/§8.5). + * + * Group: "Root existence + PROJECT.md" (design doc, "Rule table organization" + * table) — E002, E003, E004, W001. E001 (the `.planning/` root missing guard) + * stays OUTSIDE the rule table entirely per the design doc's "Two guards that + * stay OUTSIDE the rule table entirely" section — it is not a row here. + * + * Ported behavior-preserving from `cmdValidateHealth` + * (`src/verify.cts:1681-1705`), the exact call sites for E002/E003/E004/W001. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * ADR-457 build-at-publish: source in src/health-diagnostic-rules/root-existence.cts, + * compiled to gsd-core/bin/lib/health-diagnostic-rules/root-existence.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; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Diagnostic = healthDiagnosticMod.Diagnostic; +type Rule = healthDiagnosticMod.Rule; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningScopeMod = require('../planning-scope.cjs'); +const { SCOPE } = planningScopeMod; + +// ─── E002 — PROJECT.md not found (verify.cts:1682) ───────────────────────── + +function checkE002(snapshot: PlanningSnapshot): Diagnostic[] { + if (snapshot.projectSections.exists) return []; + return [ + { + code: 'E002', + severity: SEVERITY.ERROR, + message: 'PROJECT.md not found', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: '/gsd-new-project' }, + }, + }, + ]; +} + +// ─── E003 — ROADMAP.md not found (verify.cts:1694) ───────────────────────── +// +// Condition uses `snapshot.milestone.scope === SCOPE.UNREADABLE` +// (`getMilestoneInfo`, `src/roadmap-parser.cts`). KNOWN AMBIGUITY (flagged in +// this batch's report, not silently papered over): `getMilestoneInfo` returns +// `SCOPE.UNREADABLE` for TWO distinct causes it does not otherwise +// distinguish — (1) ROADMAP.md absent (`platformReadSync` returns `null` -> +// synthetic `Error('missing')`, no errno, `reportUnreadableRoadmap` finds no +// `.code` and stays silent) and (2) ROADMAP.md present but unreadable (a real +// read fault, e.g. EACCES/EISDIR, which DOES carry an errno and fires +// `warnUnusableInput(ROADMAP_UNREADABLE)`). Unlike `config`/`projectSections`, +// `milestone` carries no `exists` discriminator, so this rule cannot tell the +// two apart from the snapshot alone without adding cwd/fs access to `check` +// (forbidden by §8.1 rule 1). This is a best-effort port of the pre-migration +// condition (`!fs.existsSync(roadmapPath)`), which itself only asked "does +// the file exist" — this rule now also fires (message-mismatched, but +// error-preserving) on a present-but-corrupt ROADMAP.md. + +function checkE003(snapshot: PlanningSnapshot): Diagnostic[] { + if (snapshot.milestone.scope !== SCOPE.UNREADABLE) return []; + return [ + { + code: 'E003', + severity: SEVERITY.ERROR, + message: 'ROADMAP.md not found', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: '/gsd-new-milestone' }, + }, + }, + ]; +} + +// ─── E004 — STATE.md not found (verify.cts:1697) ─────────────────────────── +// +// Condition uses `snapshot.currentPhaseLabel.scope === SCOPE.UNREADABLE` +// (`buildStateFields`, `src/planning-snapshot.cts:210-249`). KNOWN GAP +// (flagged in this batch's report): `buildStateFields` collapses TWO distinct +// causes into the same `UNREADABLE` scope with no discriminator field at +// all — STATE.md absent (`platformReadSync` returns `null`, a real +// non-answer, `warnUnusableInput` NOT called) and STATE.md present but +// unreadable (any other read error, e.g. EISDIR, corruption, +// `warnUnusableInput(STATE_UNREADABLE)` fires). Unlike `config`, there is no +// `exists` flag on `currentPhaseLabel` (or on `PlanningSnapshot` generally) +// to distinguish "STATE.md was never created" from "STATE.md exists but +// could not be read" — this is a REAL gap in the current 15-field +// `PlanningSnapshot` shape, not something this rule can work around without +// extending that snapshot (out of this batch's scope per the brief). This +// rule is therefore a best-effort port: it fires E004 ("STATE.md not found") +// for both causes, exactly mirroring what `snapshot.currentPhaseLabel.scope` +// can express today. +// +// Remedy is `regenerateState`, one of the two DESTRUCTIVE-risk actions (loses +// session history, design doc "Risk assignment" section) — per §8.3 rule 3 +// `--repair` will refuse to auto-apply it once `applyRepairs`'s dispatch +// wires this rule in; the remedy is still described (ADVISE-shaped for +// display, per `applyRepairs`'s own contract) but never executed. + +function checkE004(snapshot: PlanningSnapshot): Diagnostic[] { + if (snapshot.currentPhaseLabel.scope !== SCOPE.UNREADABLE) return []; + return [ + { + code: 'E004', + severity: SEVERITY.ERROR, + message: 'STATE.md not found', + remedy: { + action: REMEDY_ACTION.REGENERATE_STATE, + risk: REMEDY_RISK.DESTRUCTIVE, + args: {}, + }, + }, + ]; +} + +// ─── W001 — PROJECT.md missing a required section (verify.cts:1684-1690) ── +// +// `REQUIRED_SECTIONS` carries the exact `## `-prefixed strings +// `verify.cts:1685` uses in its message text; membership is tested against +// `snapshot.projectSections.value`, which `buildProjectSectionsField` +// (`src/planning-snapshot.cts:367-381`) stores WITHOUT the `##` prefix (its +// `/^##\s+(.+)$/gm` capture group), so each required string's own `## ` +// prefix is stripped before the membership check. `projectSections.value === +// null` (PROJECT.md absent OR unreadable) emits zero diagnostics — E002 +// already reports absence; this rule does not double-report it. + +const REQUIRED_SECTIONS = ['## What This Is', '## Core Value', '## Requirements']; + +function checkW001(snapshot: PlanningSnapshot): Diagnostic[] { + const { value } = snapshot.projectSections; + if (value === null) return []; + + const diagnostics: Diagnostic[] = []; + for (const required of REQUIRED_SECTIONS) { + const heading = required.replace(/^##\s+/, ''); + if (!value.includes(heading)) { + diagnostics.push({ + code: 'W001', + severity: SEVERITY.WARNING, + message: `PROJECT.md missing section: ${required}`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Add section manually' }, + }, + }); + } + } + return diagnostics; +} + +// ─── Exports ──────────────────────────────────────────────────────────────── + +const RULES: Rule[] = [ + { code: 'E002', severity: SEVERITY.ERROR, check: checkE002 }, + { code: 'E003', severity: SEVERITY.ERROR, check: checkE003 }, + { code: 'E004', severity: SEVERITY.ERROR, check: checkE004 }, + { code: 'W001', severity: SEVERITY.WARNING, check: checkW001 }, +]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/root-existence.test.cjs b/tests/health-diagnostic-rules/root-existence.test.cjs new file mode 100644 index 000000000..b4d10a65e --- /dev/null +++ b/tests/health-diagnostic-rules/root-existence.test.cjs @@ -0,0 +1,303 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/root-existence.cts` (Phase 11, + * #3309, ADR-3180 §8.2/§8.3/§8.5) — group "Root existence + PROJECT.md": + * E002, E003, E004, W001. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): all + * four rules here are STRUCTURAL ABSENCE conditions (a file, or a section + * heading, is missing) — exempt from external-citation provenance per the + * design doc's "Fixture provenance" §1: "the fixture *is* the absence — no + * format being modeled, only a presence/absence fact." Every fixture is built + * via the REAL `buildPlanningSnapshot(cwd)` against a REAL temp directory + * (mirrors `tests/planning-snapshot.test.cjs` exactly) — no hand-constructed + * fake `PlanningSnapshot` object. + * + * TDD RED: `src/health-diagnostic-rules/root-existence.cts` does not exist + * yet at the start of this batch — this file's + * `require('../../gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs')` + * throws MODULE_NOT_FOUND until this batch's implementation lands. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const rootExistence = require('../../gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs'); +const { RULES } = rootExistence; + +const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs'); + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function writeProject(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'PROJECT.md'), content); +} + +function writeRoadmap(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content); +} + +function writeState(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), content); +} + +// Makes a FILE unreadable-as-a-file: a DIRECTORY node where a regular file is +// expected. `platformReadSync`/`fs.readFileSync` throws EISDIR on it, +// deterministically and cross-platform — no chmod (mirrors +// tests/planning-snapshot.test.cjs's `makeFileUnreadableAsDir`). +function makeFileUnreadableAsDir(fullPath) { + fs.mkdirSync(fullPath, { recursive: true }); +} + +function ruleFor(code) { + const rule = RULES.find((r) => r.code === code); + assert.ok(rule, `rule ${code} not found in RULES`); + return rule; +} + +// ─── RULES shape ──────────────────────────────────────────────────────────── + +describe('RULES (root-existence group)', () => { + test('exports exactly 4 rules: E002, E003, E004, W001', () => { + assert.deepEqual( + RULES.map((r) => r.code).sort(), + ['E002', 'E003', 'E004', 'W001'], + ); + }); + + test('E002/E003/E004 are severity ERROR; W001 is severity WARNING', () => { + assert.equal(ruleFor('E002').severity, SEVERITY.ERROR); + assert.equal(ruleFor('E003').severity, SEVERITY.ERROR); + assert.equal(ruleFor('E004').severity, SEVERITY.ERROR); + assert.equal(ruleFor('W001').severity, SEVERITY.WARNING); + }); +}); + +// ─── E002 — PROJECT.md not found ──────────────────────────────────────────── + +describe('E002 — PROJECT.md not found', () => { + test('fires when PROJECT.md is absent', (t) => { + const cwd = createTempDir('gsd-3309-e002-1-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('E002').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'E002', + severity: SEVERITY.ERROR, + message: 'PROJECT.md not found', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: '/gsd-new-project' }, + }, + }); + }); + + test('does not fire when PROJECT.md exists', (t) => { + const cwd = createTempDir('gsd-3309-e002-2-'); + t.after(() => cleanup(cwd)); + writeProject(cwd, '# My Project\n\n## What This Is\n\ntext\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('E002').check(snapshot), []); + }); +}); + +// ─── E003 — ROADMAP.md not found ──────────────────────────────────────────── + +describe('E003 — ROADMAP.md not found', () => { + test('fires when ROADMAP.md is absent (milestone.scope === UNREADABLE)', (t) => { + const cwd = createTempDir('gsd-3309-e003-1-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('E003').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'E003', + severity: SEVERITY.ERROR, + message: 'ROADMAP.md not found', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: '/gsd-new-milestone' }, + }, + }); + }); + + test('does not fire when ROADMAP.md exists and is readable', (t) => { + const cwd = createTempDir('gsd-3309-e003-2-'); + t.after(() => cleanup(cwd)); + writeState(cwd, '---\nmilestone: v1.0\n---\n'); + writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('E003').check(snapshot), []); + }); + + // Documents the KNOWN AMBIGUITY flagged in this batch's implementer report: + // `snapshot.milestone.scope` collapses "absent" and "present-but-unreadable" + // into the same UNREADABLE scope, so this rule ALSO fires (with its + // absence-shaped message) on a present-but-corrupt ROADMAP.md. Asserted + // explicitly here rather than left undocumented, per §8.5 fixture-proof. + test('KNOWN AMBIGUITY: also fires (message says "not found") when ROADMAP.md exists but is unreadable', (t) => { + const cwd = createTempDir('gsd-3309-e003-3-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'ROADMAP.md')); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('E003').check(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'E003'); + }); +}); + +// ─── E004 — STATE.md not found ────────────────────────────────────────────── + +describe('E004 — STATE.md not found', () => { + test('fires when STATE.md is absent (currentPhaseLabel.scope === UNREADABLE)', (t) => { + const cwd = createTempDir('gsd-3309-e004-1-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('E004').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'E004', + severity: SEVERITY.ERROR, + message: 'STATE.md not found', + remedy: { + action: REMEDY_ACTION.REGENERATE_STATE, + risk: REMEDY_RISK.DESTRUCTIVE, + args: {}, + }, + }); + }); + + test('does not fire when STATE.md exists and is readable', (t) => { + const cwd = createTempDir('gsd-3309-e004-2-'); + t.after(() => cleanup(cwd)); + writeState(cwd, '---\nmilestone: v1.0\n---\n\n## Current Position\n\nPhase: 1 of 2\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('E004').check(snapshot), []); + }); + + // Documents the KNOWN GAP flagged in this batch's implementer report: + // unlike `config`, `currentPhaseLabel` carries no `exists` discriminator — + // `buildStateFields` cannot distinguish "STATE.md absent" from + // "STATE.md present but unreadable/corrupt" at all, so this rule fires + // identically for both. Asserted explicitly here, per §8.5 fixture-proof. + test('KNOWN GAP: also fires (message says "not found") when STATE.md exists but is unreadable', (t) => { + const cwd = createTempDir('gsd-3309-e004-3-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + makeFileUnreadableAsDir(path.join(planningDirOf(cwd), 'STATE.md')); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('E004').check(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'E004'); + }); +}); + +// ─── W001 — PROJECT.md missing a required section ───────────────────────── + +describe('W001 — PROJECT.md missing section', () => { + test('fires once per missing required section (all 3 missing)', (t) => { + const cwd = createTempDir('gsd-3309-w001-1-'); + t.after(() => cleanup(cwd)); + writeProject(cwd, '# My Project\n\nno sections at all\n'); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W001').check(snapshot); + + assert.equal(diagnostics.length, 3); + assert.deepEqual( + diagnostics.map((d) => d.message).sort(), + [ + 'PROJECT.md missing section: ## Core Value', + 'PROJECT.md missing section: ## Requirements', + 'PROJECT.md missing section: ## What This Is', + ], + ); + for (const d of diagnostics) { + assert.equal(d.code, 'W001'); + assert.equal(d.severity, SEVERITY.WARNING); + assert.deepEqual(d.remedy, { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Add section manually' }, + }); + } + }); + + test('fires only for the sections actually missing (boundary: 1 of 3 missing)', (t) => { + const cwd = createTempDir('gsd-3309-w001-2-'); + t.after(() => cleanup(cwd)); + writeProject( + cwd, + ['# My Project', '', '## What This Is', '', '## Core Value', ''].join('\n'), + ); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W001').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].message, 'PROJECT.md missing section: ## Requirements'); + }); + + test('does not fire when all 3 required sections are present', (t) => { + const cwd = createTempDir('gsd-3309-w001-3-'); + t.after(() => cleanup(cwd)); + writeProject( + cwd, + [ + '# My Project', + '', + '## What This Is', + '', + '## Core Value', + '', + '## Requirements', + '', + ].join('\n'), + ); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W001').check(snapshot), []); + }); + + test('does not fire when PROJECT.md is absent — that is E002s job, not W001s', (t) => { + const cwd = createTempDir('gsd-3309-w001-4-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W001').check(snapshot), []); + // E002 covers the absence case instead. + assert.equal(ruleFor('E002').check(snapshot).length, 1); + }); +}); From 8a7ef789069abd2f8b3bdf5633114342942f482e Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:33 -0400 Subject: [PATCH 07/35] refactor(#3309): add state-consistency health-diagnostic rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W024 (deliberately inert, no snapshot field yet for stale state_head), W002, W011, W021, W026 — STATE.md cross-checks against ROADMAP/config, migrated onto the frozen rule table per ADR-3180 §8.2. --- .../state-consistency.cts | 302 +++++++++++++ .../state-consistency.test.cjs | 420 ++++++++++++++++++ 2 files changed, 722 insertions(+) create mode 100644 src/health-diagnostic-rules/state-consistency.cts create mode 100644 tests/health-diagnostic-rules/state-consistency.test.cjs diff --git a/src/health-diagnostic-rules/state-consistency.cts b/src/health-diagnostic-rules/state-consistency.cts new file mode 100644 index 000000000..240e40895 --- /dev/null +++ b/src/health-diagnostic-rules/state-consistency.cts @@ -0,0 +1,302 @@ +/** + * Health Diagnostic Rules — STATE.md consistency group (Phase 11, #3309, + * ADR-3180 §8.2/§8.3/§8.5). + * + * Five rules, each a near-mechanical extraction of an already-working + * `addIssue` call site in `cmdValidateHealth` (Gall's Law, design doc "Rule + * table organization" / "Laws applied"): + * + * - W024 (`verify.cts:1709-1729`) — STATE.md `state_head` commit-age + * freshness vs. git HEAD. GENUINE GAP, deliberately NOT migrated — see the + * `RULE_W024` comment below for exactly why. + * - W002 (`verify.cts:1731-1774`) — STATE.md references a phase token not + * declared anywhere (disk or ROADMAP). + * - W011 (`verify.cts:2104-2134`) — STATE's current-phase status disagrees + * with ROADMAP's `[x]` checkbox for that same phase. + * - W021 (`verify.cts:2270-2299`, the FIRST `addIssue('warning', 'W021', ...)` + * call site) — under the `'milestone-prefixed'` `phase_id_convention`, a + * phase's integer prefix implies a different milestone than the ROADMAP + * section it is actually listed under. + * - W026 (`verify.cts:2356-2399`, the SECOND `addIssue('warning', 'W021', ...)` + * call site — split off per the design doc's "New codes for the two split + * subjects" section, since one code covering two unrelated subjects is a + * genuine conflation) — STATE says the milestone is complete/archived, but + * ROADMAP (scoped to that same milestone) still lists a phase with no + * matching disk directory. + * + * - W002's original message interpolates `${slash('health')}` + * (`verify.cts:1770`) and W011's interpolates `${slash('progress')}` + * (`verify.cts:2126`) — both per-project runtime-resolved values + * (`formatGsdSlash`, `src/runtime-slash.cts`) this rule's + * `(snapshot) => Diagnostic[]` signature has no access to. Hardcodes the + * canonical `/gsd-health`/`/gsd-progress` hyphen form instead, mirroring + * the sibling "config.json validation" group's W016 rule + * (`src/health-diagnostic-rules/config-validation.cts`), which hardcodes + * `/gsd-ai-integration-phase` the same way for the identical reason. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + */ + +// Runtime values (SEVERITY/REMEDY_ACTION/REMEDY_RISK) are needed here, not +// just types, so this is a normal (non type-only) `import ... = require(...)` +// — unlike `health-diagnostic.cts`'s own type-only import of +// `planning-snapshot.cjs`, which never touches that module's runtime values. +// eslint-disable-next-line @typescript-eslint/no-require-imports -- export= CommonJS module +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Rule = healthDiagnosticMod.Rule; +type Diagnostic = healthDiagnosticMod.Diagnostic; + +// Type-only; erased at compile time, no runtime require emitted — mirrors +// `health-diagnostic.cts`'s own import of `planning-snapshot.cjs`. +// eslint-disable-next-line @typescript-eslint/no-require-imports -- type-only; erased at compile time +import type planningSnapshotMod = require('../planning-snapshot.cjs'); +type PlanningSnapshot = ReturnType; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdMod = require('../phase-id.cjs'); +const { getMilestoneFromPhaseId, matchPhaseDirs, normalizePhaseName, extractPhaseToken } = phaseIdMod; + +// ─── W024 — STATE.md commit-age freshness (DELIBERATELY INERT) ───────────── + +/** + * W024's real check (`verify.cts:1709-1729`) calls + * `readStateHeadFreshness(cwd, fm['state_head'])`, which shells out to `git + * log` to count commits between the frontmatter's `state_head` and the + * current HEAD. That is ambient I/O (git history), not `.planning/` content — + * confirmed against `src/planning-snapshot.cts`'s full 15-field + * `PlanningSnapshot` interface: no field wraps `readStateHeadFreshness` or + * exposes a commits-behind count. + * + * §8.1 rule 1 requires a rule's signature to be `(snapshot) => Diagnostic[]` + * with no ambient I/O inside `check` — so this rule does NOT call + * `readStateHeadFreshness` itself (that would violate the constraint the + * skeleton's own `Rule.check` type exists to enforce). Adding a 16th + * `PlanningSnapshot` field (e.g. `stateHeadFreshness: {value: + * {commitsBehind, stateHead}, scope}`) is the fix, but is out of this + * group's scope (`src/planning-snapshot.cts` is a shared file this task was + * not dispatched to extend). + * + * Registered here, `check` always returning `[]`, so the code table stays + * complete per §8.2's 1:1 invariant (every code the old `verify.cts` emitted + * has exactly one `Rule` entry) rather than silently dropping W024 from the + * table. This is a documented, deliberate deferral pending the 16th snapshot + * field — flagged prominently rather than quietly ported as a no-op. + */ +const RULE_W024: Rule = { + code: 'W024', + severity: SEVERITY.WARNING, + check: (_snapshot: PlanningSnapshot): Diagnostic[] => [], +}; + +// ─── W002 — STATE.md references an undeclared phase token ────────────────── + +/** + * The "valid phase" set the original code builds from + * `collectDiskPhases(planBase)` (disk dir tokens) + ROADMAP heading tokens + + * `forEachArchivedPhaseToken` (archived milestone-phase-dir tokens, + * `verify.cts:1748`). This rebuilds the disk+ROADMAP two-thirds from parsed + * snapshot fields only: `phaseDirs.value` (disk dir names, tokenized the same + * way `collectDiskPhaseEntries` does — via `extractPhaseToken`) and + * `roadmapDeclaredPhases.value.map(p => p.phaseId)` (ROADMAP-declared phase + * ids). Archived-phase-token coverage is NOT included — no + * `PlanningSnapshot` field exposes archived milestone-phase-dir tokens + * (confirmed against the 15-field interface). Omitting it makes this valid + * set a SUBSET of the original's, which can only make MORE STATE.md phase + * tokens look "invalid" (never fewer) — a conservative, safe direction; a + * project with archived phases still referenced from STATE.md is the + * fixture shape that would expose a false positive, and none of this + * group's fixtures exercise archives, so this gap is disclosed rather than + * silently absorbed. + */ +function buildValidPhaseSet(snapshot: PlanningSnapshot): Set { + const valid = new Set(); + for (const dir of snapshot.phaseDirs.value) { + const token = extractPhaseToken(dir); + if (token) valid.add(token); + } + for (const entry of snapshot.roadmapDeclaredPhases.value) { + valid.add(entry.phaseId); + } + return valid; +} + +/** Mirrors `verify.cts:1749-1758`'s zero-padding normalization exactly. */ +function normalizePhaseTokenSet(valid: Set): Set { + const normalized = new Set(); + for (const p of valid) { + normalized.add(p); + const dotIdx = p.indexOf('.'); + const head = dotIdx === -1 ? p : p.slice(0, dotIdx); + const tail = dotIdx === -1 ? '' : p.slice(dotIdx); + if (/^\d+$/.test(head)) { + normalized.add(head.padStart(2, '0') + tail); + } + } + return normalized; +} + +const RULE_W002: Rule = { + code: 'W002', + severity: SEVERITY.WARNING, + check: (snapshot: PlanningSnapshot): Diagnostic[] => { + const validPhases = buildValidPhaseSet(snapshot); + // Mirrors `verify.cts:1765`'s `if (normalizedValid.size > 0)` guard + // exactly — a project with zero declared phases emits nothing, never a + // false positive on every STATE.md phase mention. + if (validPhases.size === 0) return []; + const normalizedValid = normalizePhaseTokenSet(validPhases); + const sortedValid = [...validPhases].sort((a, b) => + a.localeCompare(b, undefined, { numeric: true }), + ); + + const diagnostics: Diagnostic[] = []; + for (const ref of snapshot.statePhaseTokens.value) { + const dotIdx = ref.indexOf('.'); + const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx); + const tail = dotIdx === -1 ? '' : ref.slice(dotIdx); + const padded = /^\d+$/.test(head) ? head.padStart(2, '0') + tail : ref; + if (normalizedValid.has(ref) || normalizedValid.has(padded)) continue; + diagnostics.push({ + code: 'W002', + severity: SEVERITY.WARNING, + message: `STATE.md references phase ${ref}, but only phases ${sortedValid.join(', ')} are declared`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Review STATE.md manually before changing it; /gsd-health --repair will not overwrite an existing STATE.md for phase mismatches', + }, + }, + }); + } + return diagnostics; + }, +}; + +// ─── W011 — STATE current-phase status vs. ROADMAP checkbox disagree ─────── + +/** + * `currentPhaseLabel.value` is a prose string (e.g. `"3 of 8 (User Auth)"`), + * not a clean phase id — the leading integer (optionally letter-suffixed / + * dotted, the same `PHASE_NUMBER_TOKEN_SOURCE` grammar) is the "current + * phase" proxy the original `verify.cts:2109-2113` derives via its own + * `**Current Phase:**`/`Current Phase:` regex + `.replace(/^0+/, '')`. That + * literal field name does not exist in the current `state.md` template + * (which uses `Phase: [X] of [Y] ([Phase name])` under `## Current + * Position`) — `currentPhaseLabel` is the parsed owner of that exact field, + * so extracting its leading number is the equivalent-intent read against + * the template STATE.md actually ships. + */ +function currentPhaseIdFromLabel(label: string | null): string | null { + if (!label) return null; + const m = label.match(/^0*(\d+[A-Z]?(?:\.\d+)*)/); + return m ? m[1] : null; +} + +const RULE_W011: Rule = { + code: 'W011', + severity: SEVERITY.WARNING, + check: (snapshot: PlanningSnapshot): Diagnostic[] => { + const phaseId = currentPhaseIdFromLabel(snapshot.currentPhaseLabel.value); + if (phaseId === null) return []; + const checked = snapshot.roadmapPhaseCheckboxes.value[phaseId]; + if (checked !== true) return []; + const statusVal = (snapshot.stateStatus.value ?? '').trim().toLowerCase(); + if (statusVal === 'complete' || statusVal === 'done') return []; + return [ + { + code: 'W011', + severity: SEVERITY.WARNING, + message: `STATE.md says current phase is ${phaseId} (status: ${statusVal || 'unknown'}) but ROADMAP.md shows it as [x] complete — state files may be out of sync`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Run /gsd-progress to re-derive current position, or manually update STATE.md' }, + }, + }, + ]; + }, +}; + +// ─── W021 — phase_id_convention integer-prefix/milestone mismatch ────────── + +const RULE_W021: Rule = { + code: 'W021', + severity: SEVERITY.WARNING, + check: (snapshot: PlanningSnapshot): Diagnostic[] => { + const convention = snapshot.config.value?.['phase_id_convention']; + if (convention !== 'milestone-prefixed') return []; + + const diagnostics: Diagnostic[] = []; + for (const entry of snapshot.roadmapDeclaredPhases.value) { + // `entry.milestone === null` means the builder never found this phase + // heading inside any versioned (`v\d+\.\d+`) section — the original + // `checkMilestonePrefixMismatches` only ever iterates phases found + // WITHIN a section, so a phase outside any section is equivalently + // never checked here. + if (entry.milestone === null) continue; + const expectedMilestone = getMilestoneFromPhaseId(entry.phaseId); + if (expectedMilestone === null || expectedMilestone === entry.milestone) continue; + diagnostics.push({ + code: 'W021', + severity: SEVERITY.WARNING, + message: `Phase ${entry.phaseId}: integer prefix implies ${expectedMilestone} but listed under ${entry.milestone}`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'gsd-tools roadmap upgrade --convention milestone-prefixed' }, + }, + }); + } + return diagnostics; + }, +}; + +// ─── W026 — STATE says milestone complete but ROADMAP lists unstarted phase ─ + +const RULE_W026: Rule = { + code: 'W026', + severity: SEVERITY.WARNING, + check: (snapshot: PlanningSnapshot): Diagnostic[] => { + const statusVal = (snapshot.stateStatus.value ?? '').trim().toLowerCase(); + if (!/milestone complete|archived/.test(statusVal)) return []; + + const currentMilestone = snapshot.milestone.value?.version ?? null; + if (currentMilestone === null) return []; + + const unstarted: string[] = []; + for (const entry of snapshot.roadmapDeclaredPhases.value) { + // Scoped to the current milestone only — mirrors the original's + // `extractCurrentMilestone(roadmapRaw, cwd)` narrowing before its + // phase-heading scan (`verify.cts:2363-2364`). + if (entry.milestone !== currentMilestone) continue; + const normalized = normalizePhaseName(entry.phaseId); + const hasDirectory = matchPhaseDirs(snapshot.phaseDirs.value, normalized).matches.length > 0; + if (!hasDirectory) unstarted.push(entry.phaseId); + } + if (unstarted.length === 0) return []; + + return [ + { + code: 'W026', + severity: SEVERITY.WARNING, + message: `STATE says milestone complete but ROADMAP lists ${unstarted.length} unstarted phase(s) (e.g. Phase ${unstarted[0]})`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: 'Run validate consistency or re-run complete-milestone after verifying all phases are done', + }, + }, + }, + ]; + }, +}; + +// ─── Exports ──────────────────────────────────────────────────────────────── + +const RULES: Rule[] = [RULE_W024, RULE_W002, RULE_W011, RULE_W021, RULE_W026]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/state-consistency.test.cjs b/tests/health-diagnostic-rules/state-consistency.test.cjs new file mode 100644 index 000000000..522f51fc9 --- /dev/null +++ b/tests/health-diagnostic-rules/state-consistency.test.cjs @@ -0,0 +1,420 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/state-consistency.cts` (Phase 11, + * #3309, ADR-3180 §8.2/§8.3/§8.5) — group "STATE.md consistency": W024, + * W002, W011, W021, W026. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): + * every fixture below is a MECHANICAL MUTATION of a realistic multi-phase + * ROADMAP/STATE/config.json shape (the shipped `templates/state.md` / + * `templates/roadmap.md` field layout, filled in with real values) with + * exactly ONE targeted field flipped per rule under test (an extra phase + * reference, a checkbox left `[x]` while status stays `In progress`, a + * `phase_id_convention` + a milestone-prefixed phase heading placed under + * the wrong version section, a `milestone complete` status left with an + * unstarted phase) — never a fixture invented purely to trip the rule with + * no other realistic content. Every case drives the REAL + * `buildPlanningSnapshot(cwd)` (`src/planning-snapshot.cts`) against a REAL + * temp `.planning/` tree — no hand-built in-memory `PlanningSnapshot` mock. + * + * W024 is a deliberate exception: its `check` is documented dead code (see + * `src/health-diagnostic-rules/state-consistency.cts`'s `RULE_W024` + * comment) — its tests assert the INERT `[] `contract directly rather than + * a trigger fixture, since no snapshot field can drive it to fire. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const stateConsistency = require('../../gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs'); +const { RULES } = stateConsistency; + +const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs'); + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function writeState(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), content); +} + +function writeRoadmap(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content); +} + +function writeFile(cwd, relPath, content) { + const full = path.join(cwd, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); +} + +function writeConfig(cwd, obj) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), JSON.stringify(obj, null, 2)); +} + +function makePhaseDir(cwd, dirName) { + writeFile(cwd, `.planning/phases/${dirName}/01-01-PLAN.md`, '# Plan\n'); + writeFile(cwd, `.planning/phases/${dirName}/01-01-SUMMARY.md`, '# Summary\n'); + writeFile(cwd, `.planning/phases/${dirName}/01-VERIFICATION.md`, '---\nstatus: passed\n---\n'); +} + +function ruleFor(code) { + const rule = RULES.find((r) => r.code === code); + assert.ok(rule, `rule ${code} not found in RULES`); + return rule; +} + +// ─── RULES shape ──────────────────────────────────────────────────────────── + +describe('RULES (state-consistency group)', () => { + test('exports exactly 5 rules: W024, W002, W011, W021, W026', () => { + assert.deepEqual( + RULES.map((r) => r.code).sort(), + ['W002', 'W011', 'W021', 'W024', 'W026'], + ); + }); + + test('every rule is severity WARNING', () => { + for (const code of ['W024', 'W002', 'W011', 'W021', 'W026']) { + assert.equal(ruleFor(code).severity, SEVERITY.WARNING); + } + }); +}); + +// ─── W024 — STATE.md commit-age freshness (DELIBERATELY INERT) ───────────── + +describe('W024 — deliberately inert (no snapshot field backs git-log freshness)', () => { + test('always returns [] on an empty snapshot', (t) => { + const cwd = createTempDir('gsd-3309-w024-1-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W024').check(snapshot), []); + }); + + test('always returns [] even with a state_head-carrying STATE.md and full roadmap/config', (t) => { + const cwd = createTempDir('gsd-3309-w024-2-'); + t.after(() => cleanup(cwd)); + writeState( + cwd, + ['---', 'state_head: deadbeefdeadbeefdeadbeefdeadbeefdeadbeef', 'status: In progress', '---', '', '## Current Position', '', 'Phase: 1 of 2', ''].join('\n'), + ); + writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n\n### Phase 2: Bar\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W024').check(snapshot), []); + }); +}); + +// ─── W002 — STATE.md references an undeclared phase token ────────────────── + +describe('W002 — STATE.md references a phase not declared on disk or ROADMAP', () => { + test('fires when STATE.md mentions a phase not on disk and not in ROADMAP', (t) => { + const cwd = createTempDir('gsd-3309-w002-1-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n\n### Phase 2: Bar\n'); + makePhaseDir(cwd, '01-foo'); + makePhaseDir(cwd, '02-bar'); + writeState( + cwd, + [ + '---', + 'status: In progress', + '---', + '', + '## Current Position', + '', + 'Phase: 1 of 2', + '', + '### Decisions', + '', + '- Phase 9: referenced a phase that does not exist', + '', + ].join('\n'), + ); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W002').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'W002'); + assert.equal(diagnostics[0].severity, SEVERITY.WARNING); + assert.match(diagnostics[0].message, /STATE\.md references phase 9, but only phases .* are declared/); + assert.deepEqual(diagnostics[0].remedy, { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Review STATE.md manually before changing it; /gsd-health --repair will not overwrite an existing STATE.md for phase mismatches', + }, + }); + }); + + test('does not fire when every STATE.md phase reference is declared (disk or ROADMAP)', (t) => { + const cwd = createTempDir('gsd-3309-w002-2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, '## v1.0 Current 🚧\n\n### Phase 1: Foo\n\n### Phase 2: Bar\n'); + makePhaseDir(cwd, '01-foo'); + makePhaseDir(cwd, '02-bar'); + writeState( + cwd, + [ + '---', + 'status: In progress', + '---', + '', + '## Current Position', + '', + 'Phase: 1 of 2', + '', + '### Decisions', + '', + '- Phase 2: fine, this is declared', + '', + ].join('\n'), + ); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W002').check(snapshot), []); + }); + + // Boundary: `validPhases.size === 0` guard — mirrors `verify.cts:1765`'s + // `if (normalizedValid.size > 0)` exactly, so a project with no declared + // phases at all never reports every STATE.md phase mention as invalid. + test('does not fire when the valid-phase set is empty (no ROADMAP, no disk phases)', (t) => { + const cwd = createTempDir('gsd-3309-w002-3-'); + t.after(() => cleanup(cwd)); + writeState( + cwd, + ['---', 'status: In progress', '---', '', '### Decisions', '', '- Phase 3: referenced with nothing declared anywhere', ''].join( + '\n', + ), + ); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W002').check(snapshot), []); + }); + + // KNOWN GAP (implementer report): archived-phase-token coverage + // (`forEachArchivedPhaseToken`) is not in any PlanningSnapshot field, so a + // STATE.md reference to a phase that lives only in a milestone archive is + // reported as undeclared here, unlike the original `verify.cts` check. + // Documents the gap rather than silently absorbing it. + test('KNOWN GAP: fires on a phase reference whose only home is an archived milestone (not modeled by any snapshot field)', (t) => { + const cwd = createTempDir('gsd-3309-w002-4-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, '## v2.0 Current 🚧\n\n### Phase 3: Baz\n'); + makePhaseDir(cwd, '03-baz'); + writeFile(cwd, '.planning/milestones/v1.0-phases/01-archived-foo/01-VERIFICATION.md', '---\nstatus: passed\n---\n'); + writeState( + cwd, + [ + '---', + 'status: In progress', + '---', + '', + '## Current Position', + '', + 'Phase: 3 of 3', + '', + '### Decisions', + '', + '- Phase 1: this phase is archived, not currently exposed by any snapshot field', + '', + ].join('\n'), + ); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W002').check(snapshot); + assert.equal(diagnostics.length, 1); + assert.match(diagnostics[0].message, /STATE\.md references phase 1,/); + }); +}); + +// ─── W011 — STATE current-phase status vs. ROADMAP checkbox disagree ─────── + +describe('W011 — STATE current-phase status disagrees with ROADMAP [x] checkbox', () => { + test('fires when ROADMAP checkbox says the current phase is [x] complete but STATE status is not complete/done', (t) => { + const cwd = createTempDir('gsd-3309-w011-1-'); + t.after(() => cleanup(cwd)); + writeRoadmap( + cwd, + ['## v1.0 Current 🚧', '', '- [x] Phase 3: Auth', '- [ ] Phase 4: Billing', ''].join('\n'), + ); + writeState( + cwd, + ['---', 'status: In progress', '---', '', '## Current Position', '', 'Phase: 3 of 4 (Auth)', 'Status: In progress', ''].join( + '\n', + ), + ); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W011').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'W011', + severity: SEVERITY.WARNING, + message: + 'STATE.md says current phase is 3 (status: in progress) but ROADMAP.md shows it as [x] complete — state files may be out of sync', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Run /gsd-progress to re-derive current position, or manually update STATE.md' }, + }, + }); + }); + + test('does not fire when STATE status is already "complete"', (t) => { + const cwd = createTempDir('gsd-3309-w011-2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '- [x] Phase 3: Auth', ''].join('\n')); + writeState( + cwd, + ['---', 'status: complete', '---', '', '## Current Position', '', 'Phase: 3 of 4 (Auth)', 'Status: complete', ''].join('\n'), + ); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W011').check(snapshot), []); + }); + + test('does not fire when the ROADMAP checkbox for the current phase is [ ] (not checked)', (t) => { + const cwd = createTempDir('gsd-3309-w011-3-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '- [ ] Phase 3: Auth', ''].join('\n')); + writeState( + cwd, + ['---', 'status: In progress', '---', '', '## Current Position', '', 'Phase: 3 of 4 (Auth)', 'Status: In progress', ''].join( + '\n', + ), + ); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W011').check(snapshot), []); + }); +}); + +// ─── W021 — phase_id_convention integer-prefix/milestone mismatch ────────── + +describe('W021 — milestone-prefixed phase integer-prefix implies a different milestone', () => { + test('fires when a milestone-prefixed phase heading is listed under the wrong version section', (t) => { + const cwd = createTempDir('gsd-3309-w021-1-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, { phase_id_convention: 'milestone-prefixed' }); + writeRoadmap(cwd, ['## v2.0 Current 🚧', '', '### Phase 1-1: Misplaced', ''].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W021').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'W021', + severity: SEVERITY.WARNING, + message: 'Phase 1-1: integer prefix implies v1.0 but listed under v2.0', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'gsd-tools roadmap upgrade --convention milestone-prefixed' }, + }, + }); + }); + + test('does not fire when the milestone-prefixed phase is listed under its implied version', (t) => { + const cwd = createTempDir('gsd-3309-w021-2-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, { phase_id_convention: 'milestone-prefixed' }); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1-1: Correctly Placed', ''].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W021').check(snapshot), []); + }); + + test('does not fire when phase_id_convention is not "milestone-prefixed"', (t) => { + const cwd = createTempDir('gsd-3309-w021-3-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, { phase_id_convention: 'flat' }); + writeRoadmap(cwd, ['## v2.0 Current 🚧', '', '### Phase 1-1: Misplaced', ''].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W021').check(snapshot), []); + }); +}); + +// ─── W026 — STATE says milestone complete but ROADMAP lists unstarted phase ─ + +describe('W026 — STATE milestone-complete/archived but ROADMAP lists a phase with no disk directory', () => { + test('fires when STATE says "milestone complete" and the current milestone still lists an unstarted phase', (t) => { + const cwd = createTempDir('gsd-3309-w026-1-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n')); + makePhaseDir(cwd, '01-foo'); + // Phase 2 deliberately has NO disk directory. + writeState(cwd, ['---', 'status: milestone complete', 'milestone: v1.0', '---', ''].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W026').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'W026', + severity: SEVERITY.WARNING, + message: 'STATE says milestone complete but ROADMAP lists 1 unstarted phase(s) (e.g. Phase 2)', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: 'Run validate consistency or re-run complete-milestone after verifying all phases are done', + }, + }, + }); + }); + + test('does not fire when every phase in the current milestone has a disk directory', (t) => { + const cwd = createTempDir('gsd-3309-w026-2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n')); + makePhaseDir(cwd, '01-foo'); + makePhaseDir(cwd, '02-bar'); + writeState(cwd, ['---', 'status: milestone complete', 'milestone: v1.0', '---', ''].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W026').check(snapshot), []); + }); + + test('does not fire when STATE status is not "milestone complete"/"archived"', (t) => { + const cwd = createTempDir('gsd-3309-w026-3-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n')); + makePhaseDir(cwd, '01-foo'); + writeState(cwd, ['---', 'status: In progress', 'milestone: v1.0', '---', ''].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W026').check(snapshot), []); + }); + + test('fires when STATE status is "archived" (the other trigger token)', (t) => { + const cwd = createTempDir('gsd-3309-w026-4-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', ''].join('\n')); + makePhaseDir(cwd, '01-foo'); + writeState(cwd, ['---', 'status: archived', 'milestone: v1.0', '---', ''].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W026').check(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'W026'); + }); +}); From 80484249df56de04f0d435781e15250caa8130b0 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:33 -0400 Subject: [PATCH 08/35] refactor(#3309): add config-validation health-diagnostic rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W003, E005, W004, W008, W016, W012, W013, W014, W015, W022 — config.json existence, parseability, and field-validity checks, migrated onto the frozen rule table per ADR-3180 §8.2. --- .../config-validation.cts | 296 ++++++++ .../config-validation.test.cjs | 699 ++++++++++++++++++ 2 files changed, 995 insertions(+) create mode 100644 src/health-diagnostic-rules/config-validation.cts create mode 100644 tests/health-diagnostic-rules/config-validation.test.cjs diff --git a/src/health-diagnostic-rules/config-validation.cts b/src/health-diagnostic-rules/config-validation.cts new file mode 100644 index 000000000..3c1e2424a --- /dev/null +++ b/src/health-diagnostic-rules/config-validation.cts @@ -0,0 +1,296 @@ +/** + * Health Diagnostic — config.json validation rules (Phase 11, #3309, + * ADR-3180 §8.2/§8.3/§8.5). + * + * Group: "config.json validation" (design doc, "Rule table organization" + * table) — W003, W004, W022 (one rule, three internal conditions), E005, + * W008, W016, W012, W013, W014, W015. + * + * Ported behavior-preserving from `cmdValidateHealth`'s config.json blocks + * (`src/verify.cts:1777-1835` for W003/W004/W022/E005, + * `src/verify.cts:1837-1865` for W008/W016, + * `src/verify.cts:2136-2191` for W012/W013/W014/W015). Every rule here reads + * ONLY `snapshot.config` (`{value, scope, exists}`, `src/planning-snapshot.cts`'s + * `buildConfigField`). + * + * W022 stays a SINGLE code across its three call sites per the design doc's + * "Rejected alternatives" §3: all three are variations on one question ("is + * `models` well-formed"), not a genuine multi-subject conflation. This rule's + * `checkW022` mirrors the original's exact if / else-if control flow + * (`verify.cts:1799-1824`): the object-shaped branch loops every `models` + * entry (0-N diagnostics, one per malformed entry); the non-object branch + * fires independently and ONLY when the object-shaped branch did not run — + * `models` is never checked against both. + * + * Two disclosed fidelity reductions, forced by `snapshot.config`'s shape + * (neither is available without violating §8.1 rule 1's "no ambient I/O in a + * rule's `check`"): + * + * - E005's original message interpolates the live `JSON.parse` error text + * (`config.json: JSON parse error - ${err.message}`, `verify.cts:1829`). + * `buildConfigField` (`src/planning-snapshot.cts:268-281`) catches and + * discards that error, collapsing an unparseable config.json to + * `{value: null, scope: UNREADABLE, exists: true}` with no error text + * anywhere in the snapshot. This rule's message drops the interpolated + * suffix rather than fabricate error text the snapshot never carried. + * - W016's original message interpolates `${slash('ai-integration-phase')}` + * (`verify.cts:1856`), a per-project runtime-resolved value + * (`formatGsdSlash`, `src/runtime-slash.cts`) this rule's `(snapshot) => + * Diagnostic[]` signature has no access to. Hardcodes the canonical + * `/gsd-ai-integration-phase` hyphen form instead, mirroring the sibling + * "Phase directory structure" group's W009 rule + * (`src/health-diagnostic-rules/phase-structure.cts`), which hardcodes + * `/gsd-plan-phase` the same way for the identical reason. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * ADR-457 build-at-publish: source in + * src/health-diagnostic-rules/config-validation.cts, compiled to + * gsd-core/bin/lib/health-diagnostic-rules/config-validation.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; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic.cjs is an export= CommonJS module +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Diagnostic = healthDiagnosticMod.Diagnostic; +type Remedy = healthDiagnosticMod.Remedy; +type Rule = healthDiagnosticMod.Rule; + +import { VALID_PROFILES, VALID_TIERS, VALID_PHASE_TYPES } from '../model-catalog.cjs'; + +// verify.cts:2141 — inlined literal, not exported from anywhere; same list. +const VALID_BRANCHING_STRATEGIES = ['none', 'phase', 'milestone']; + +function adviseRemedy(command: string): Remedy { + return { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: { command } }; +} + +// ─── W003 — config.json not found (verify.cts:1777-1785) ─────────────────── + +function checkW003(snapshot: PlanningSnapshot): Diagnostic[] { + if (snapshot.config.exists) return []; + return [ + { + code: 'W003', + severity: SEVERITY.WARNING, + message: 'config.json not found', + remedy: { action: REMEDY_ACTION.CREATE_CONFIG, risk: REMEDY_RISK.NONE, args: {} }, + }, + ]; +} + +// ─── E005 — config.json JSON parse error (verify.cts:1825-1834) ──────────── +// +// `exists: true, value: null` is exactly `buildConfigField`'s "present but +// unparseable" contract (planning-snapshot.cts:268-281) — the same +// discriminator that separates this from W003's "absent" case. + +function checkE005(snapshot: PlanningSnapshot): Diagnostic[] { + if (!snapshot.config.exists || snapshot.config.value !== null) return []; + return [ + { + code: 'E005', + severity: SEVERITY.ERROR, + message: 'config.json: JSON parse error', + remedy: { action: REMEDY_ACTION.RESET_CONFIG, risk: REMEDY_RISK.DESTRUCTIVE, args: {} }, + }, + ]; +} + +// ─── W004 — invalid model_profile (verify.cts:1790-1797) ─────────────────── + +function checkW004(snapshot: PlanningSnapshot): Diagnostic[] { + const value = snapshot.config.value; + if (!value) return []; + const profile = value['model_profile']; + if (profile && !VALID_PROFILES.includes(profile as string)) { + return [ + { + code: 'W004', + severity: SEVERITY.WARNING, + message: `config.json: invalid model_profile "${profile as string}"`, + remedy: adviseRemedy(`Valid values: ${VALID_PROFILES.join(', ')}`), + }, + ]; + } + return []; +} + +// ─── W008 — workflow.nyquist_validation absent (verify.cts:1841-1851) ────── + +function checkW008(snapshot: PlanningSnapshot): Diagnostic[] { + const value = snapshot.config.value; + const workflow = value ? (value['workflow'] as Record | undefined) : undefined; + if (workflow && workflow['nyquist_validation'] === undefined) { + return [ + { + code: 'W008', + severity: SEVERITY.WARNING, + message: 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)', + remedy: { action: REMEDY_ACTION.ADD_NYQUIST_KEY, risk: REMEDY_RISK.NONE, args: {} }, + }, + ]; + } + return []; +} + +// ─── W016 — workflow.ai_integration_phase absent (verify.cts:1852-1861) ──── + +function checkW016(snapshot: PlanningSnapshot): Diagnostic[] { + const value = snapshot.config.value; + const workflow = value ? (value['workflow'] as Record | undefined) : undefined; + if (workflow && workflow['ai_integration_phase'] === undefined) { + return [ + { + code: 'W016', + severity: SEVERITY.WARNING, + message: + 'config.json: workflow.ai_integration_phase absent (defaults to enabled — run /gsd-ai-integration-phase before planning AI system phases)', + remedy: { action: REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY, risk: REMEDY_RISK.NONE, args: {} }, + }, + ]; + } + return []; +} + +// ─── W012 — invalid branching_strategy (verify.cts:2141-2152) ────────────── + +function checkW012(snapshot: PlanningSnapshot): Diagnostic[] { + const value = snapshot.config.value; + if (!value) return []; + const strategy = value['branching_strategy']; + if (strategy && !VALID_BRANCHING_STRATEGIES.includes(strategy as string)) { + return [ + { + code: 'W012', + severity: SEVERITY.WARNING, + message: `config.json: invalid branching_strategy "${strategy as string}"`, + remedy: adviseRemedy(`Valid values: ${VALID_BRANCHING_STRATEGIES.join(', ')}`), + }, + ]; + } + return []; +} + +// ─── W013 — context_window not a positive integer (verify.cts:2154-2164) ─── + +function checkW013(snapshot: PlanningSnapshot): Diagnostic[] { + const value = snapshot.config.value; + if (!value) return []; + const cw = value['context_window']; + if (cw !== undefined && (typeof cw !== 'number' || cw <= 0 || !Number.isInteger(cw))) { + return [ + { + code: 'W013', + severity: SEVERITY.WARNING, + message: `config.json: context_window should be a positive integer, got "${cw as string}"`, + remedy: adviseRemedy('Set to 200000 (default) or 1000000 (for 1M models)'), + }, + ]; + } + return []; +} + +// ─── W014 — phase_branch_template missing {phase} (verify.cts:2166-2176) ─── + +function checkW014(snapshot: PlanningSnapshot): Diagnostic[] { + const value = snapshot.config.value; + if (!value) return []; + const tmpl = value['phase_branch_template']; + if (tmpl && !(tmpl as string).includes('{phase}')) { + return [ + { + code: 'W014', + severity: SEVERITY.WARNING, + message: 'config.json: phase_branch_template missing {phase} placeholder', + remedy: adviseRemedy('Template must include {phase} for phase number substitution'), + }, + ]; + } + return []; +} + +// ─── W015 — milestone_branch_template missing {milestone} (verify.cts:2177-2187) ─ + +function checkW015(snapshot: PlanningSnapshot): Diagnostic[] { + const value = snapshot.config.value; + if (!value) return []; + const tmpl = value['milestone_branch_template']; + if (tmpl && !(tmpl as string).includes('{milestone}')) { + return [ + { + code: 'W015', + severity: SEVERITY.WARNING, + message: 'config.json: milestone_branch_template missing {milestone} placeholder', + remedy: adviseRemedy('Template must include {milestone} for version substitution'), + }, + ]; + } + return []; +} + +// ─── W022 — models malformed, 3 internal conditions (verify.cts:1798-1824) ─ +// +// Mirrors the original if / else-if chain exactly: the object-shaped branch +// (a: unknown phase type, b: invalid tier value) loops every `models` entry, +// pushing 0-N diagnostics; the non-object branch (c) is an ELSE-IF, so it +// only runs when `models` is truthy but did NOT satisfy "object, not array" — +// (a)/(b) are never evaluated against a non-object `models`. + +function checkW022(snapshot: PlanningSnapshot): Diagnostic[] { + const value = snapshot.config.value; + if (!value) return []; + const diagnostics: Diagnostic[] = []; + const configModels = value['models']; + + if (configModels && typeof configModels === 'object' && !Array.isArray(configModels)) { + for (const [phaseType, tierValue] of Object.entries(configModels as Record)) { + if (!VALID_PHASE_TYPES.has(phaseType)) { + diagnostics.push({ + code: 'W022', + severity: SEVERITY.WARNING, + message: `config.json: models has an unknown phase type "${phaseType}" which will be ignored`, + remedy: adviseRemedy(`Valid phase types: ${[...VALID_PHASE_TYPES].join(', ')}`), + }); + } else if (typeof tierValue !== 'string' || !VALID_TIERS.has(tierValue)) { + diagnostics.push({ + code: 'W022', + severity: SEVERITY.WARNING, + message: `config.json: models.${phaseType} has an invalid tier value ${JSON.stringify(tierValue)} which will be ignored`, + remedy: adviseRemedy(`Valid tiers: ${[...VALID_TIERS].join(', ')}`), + }); + } + } + } else if (configModels !== undefined && configModels !== null) { + diagnostics.push({ + code: 'W022', + severity: SEVERITY.WARNING, + message: `config.json: models is set to ${JSON.stringify(configModels)}, but must be an object mapping phase types to tiers — this value will be ignored`, + remedy: adviseRemedy('Set models to an object like {"planning": "sonnet"}, or remove the key to use profile defaults'), + }); + } + + return diagnostics; +} + +// ─── Exports ──────────────────────────────────────────────────────────────── + +const RULES: Rule[] = [ + { code: 'W003', severity: SEVERITY.WARNING, check: checkW003 }, + { code: 'E005', severity: SEVERITY.ERROR, check: checkE005 }, + { code: 'W004', severity: SEVERITY.WARNING, check: checkW004 }, + { code: 'W008', severity: SEVERITY.WARNING, check: checkW008 }, + { code: 'W016', severity: SEVERITY.WARNING, check: checkW016 }, + { code: 'W012', severity: SEVERITY.WARNING, check: checkW012 }, + { code: 'W013', severity: SEVERITY.WARNING, check: checkW013 }, + { code: 'W014', severity: SEVERITY.WARNING, check: checkW014 }, + { code: 'W015', severity: SEVERITY.WARNING, check: checkW015 }, + { code: 'W022', severity: SEVERITY.WARNING, check: checkW022 }, +]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/config-validation.test.cjs b/tests/health-diagnostic-rules/config-validation.test.cjs new file mode 100644 index 000000000..302defa29 --- /dev/null +++ b/tests/health-diagnostic-rules/config-validation.test.cjs @@ -0,0 +1,699 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/config-validation.cts` (Phase 11, + * #3309, ADR-3180 §8.2/§8.3/§8.5) — group "config.json validation": W003, + * E005, W004, W008, W016, W012, W013, W014, W015, W022. + * + * 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 + * + * Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): + * + * - W003, W008, W016 are STRUCTURAL ABSENCE (a file, or a key, is missing) — + * exempt from external-citation provenance, mirrors + * tests/health-diagnostic-rules/root-existence.test.cjs's own framing. + * - E005, W004, W012, W013, W014, W015, W022 are MECHANICAL MUTATION: each + * fixture starts from `gsd-core/templates/config.json` (the real shipped + * shape, parsed once as `BASE_CONFIG`) with exactly ONE field changed to + * the invalid value under test, per the design doc's Fixture provenance + * §4. `w022`'s `models` key and `branching_strategy`/`context_window`/ + * `phase_branch_template`/`milestone_branch_template` are not present in + * the shipped default at all (they are optional, additive keys) — for + * those the "one field changed" mutation is adding exactly that one key + * with its invalid value, the generic-mutation equivalent when there is no + * existing value to corrupt. + * + * Every fixture is driven through the REAL `buildPlanningSnapshot(cwd)` + * against a REAL temp `.planning/` directory with a REAL config.json file + * written to disk — mirrors tests/planning-snapshot.test.cjs's own + * `writeConfig` helper exactly (same shape, same call site convention). + * + * TDD RED: `src/health-diagnostic-rules/config-validation.cts` does not + * exist yet at the start of this batch — this file's + * `require('../../gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs')` + * throws MODULE_NOT_FOUND until this batch's implementation lands. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const configValidation = require('../../gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs'); +const { RULES } = configValidation; + +const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs'); + +// Real shipped default, parsed once — the mutation base for every +// MECHANICAL MUTATION fixture below (design doc Fixture provenance §4). +const TEMPLATE_CONFIG_PATH = path.join(__dirname, '..', '..', 'gsd-core', 'templates', 'config.json'); +const BASE_CONFIG = JSON.parse(fs.readFileSync(TEMPLATE_CONFIG_PATH, 'utf-8')); + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function writeConfig(cwd, obj) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), JSON.stringify(obj)); +} + +function writeRawConfig(cwd, rawText) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'config.json'), rawText); +} + +// Deep-clones BASE_CONFIG and applies exactly one mutation, mirroring the +// design doc's "mechanical, rule-blind mutation of the real shipped +// template" fixture class. +function mutatedConfig(mutate) { + const clone = JSON.parse(JSON.stringify(BASE_CONFIG)); + mutate(clone); + return clone; +} + +function ruleFor(code) { + const rule = RULES.find((r) => r.code === code); + assert.ok(rule, `rule ${code} not found in RULES`); + return rule; +} + +// ─── RULES shape ──────────────────────────────────────────────────────────── + +describe('RULES (config-validation group)', () => { + test('exports exactly 10 rules: W003, E005, W004, W008, W016, W012, W013, W014, W015, W022', () => { + assert.deepEqual( + RULES.map((r) => r.code).sort(), + ['E005', 'W003', 'W004', 'W008', 'W012', 'W013', 'W014', 'W015', 'W016', 'W022'].sort(), + ); + }); + + test('E005 is severity ERROR; the rest are severity WARNING', () => { + assert.equal(ruleFor('E005').severity, SEVERITY.ERROR); + for (const code of ['W003', 'W004', 'W008', 'W012', 'W013', 'W014', 'W015', 'W016', 'W022']) { + assert.equal(ruleFor(code).severity, SEVERITY.WARNING, `${code} should be WARNING`); + } + }); +}); + +// ─── W003 — config.json not found ─────────────────────────────────────────── + +describe('W003 — config.json not found', () => { + test('fires when config.json is absent (structural absence)', (t) => { + const cwd = createTempDir('gsd-3309-w003-1-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W003').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W003', + severity: SEVERITY.WARNING, + message: 'config.json not found', + remedy: { action: REMEDY_ACTION.CREATE_CONFIG, risk: REMEDY_RISK.NONE, args: {} }, + }, + ]); + }); + + test('does not fire when config.json exists', (t) => { + const cwd = createTempDir('gsd-3309-w003-2-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W003').check(snapshot), []); + }); +}); + +// ─── E005 — config.json JSON parse error ──────────────────────────────────── + +describe('E005 — config.json invalid JSON', () => { + test('fires when config.json exists but is unparseable', (t) => { + const cwd = createTempDir('gsd-3309-e005-1-'); + t.after(() => cleanup(cwd)); + writeRawConfig(cwd, '{ not valid json'); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('E005').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'E005', + severity: SEVERITY.ERROR, + message: 'config.json: JSON parse error', + remedy: { action: REMEDY_ACTION.RESET_CONFIG, risk: REMEDY_RISK.DESTRUCTIVE, args: {} }, + }, + ]); + }); + + test('does not fire when config.json is absent (that is W003s job)', (t) => { + const cwd = createTempDir('gsd-3309-e005-2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('E005').check(snapshot), []); + assert.equal(ruleFor('W003').check(snapshot).length, 1); + }); + + test('does not fire when config.json is well-formed', (t) => { + const cwd = createTempDir('gsd-3309-e005-3-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('E005').check(snapshot), []); + }); +}); + +// ─── W004 — invalid model_profile ─────────────────────────────────────────── + +describe('W004 — invalid model_profile', () => { + test('fires on an invalid model_profile value (mutated shipped config)', (t) => { + const cwd = createTempDir('gsd-3309-w004-1-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.model_profile = 'not-a-real-profile'; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W004').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W004', + severity: SEVERITY.WARNING, + message: 'config.json: invalid model_profile "not-a-real-profile"', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Valid values: quality, balanced, budget, adaptive, inherit' }, + }, + }, + ]); + }); + + test('does not fire on a valid model_profile', (t) => { + const cwd = createTempDir('gsd-3309-w004-2-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.model_profile = 'balanced'; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W004').check(snapshot), []); + }); + + test('does not fire when model_profile is absent', (t) => { + const cwd = createTempDir('gsd-3309-w004-3-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W004').check(snapshot), []); + }); +}); + +// ─── W008 — workflow.nyquist_validation absent ────────────────────────────── + +describe('W008 — workflow.nyquist_validation absent', () => { + test('fires when workflow is present but nyquist_validation key is deleted', (t) => { + const cwd = createTempDir('gsd-3309-w008-1-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + delete c.workflow.nyquist_validation; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W008').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W008', + severity: SEVERITY.WARNING, + message: 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)', + remedy: { action: REMEDY_ACTION.ADD_NYQUIST_KEY, risk: REMEDY_RISK.NONE, args: {} }, + }, + ]); + }); + + test('does not fire when nyquist_validation is present (shipped default)', (t) => { + const cwd = createTempDir('gsd-3309-w008-2-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W008').check(snapshot), []); + }); + + test('does not fire when workflow itself is absent (guard mirrors original)', (t) => { + const cwd = createTempDir('gsd-3309-w008-3-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + delete c.workflow; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W008').check(snapshot), []); + }); +}); + +// ─── W016 — workflow.ai_integration_phase absent ──────────────────────────── + +describe('W016 — workflow.ai_integration_phase absent', () => { + test('fires on the unmodified shipped default — ai_integration_phase is not in the template at all (structural absence)', (t) => { + const cwd = createTempDir('gsd-3309-w016-1-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W016').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W016', + severity: SEVERITY.WARNING, + message: + 'config.json: workflow.ai_integration_phase absent (defaults to enabled — run /gsd-ai-integration-phase before planning AI system phases)', + remedy: { action: REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY, risk: REMEDY_RISK.NONE, args: {} }, + }, + ]); + }); + + test('does not fire when ai_integration_phase key is present', (t) => { + const cwd = createTempDir('gsd-3309-w016-2-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.workflow.ai_integration_phase = true; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W016').check(snapshot), []); + }); + + test('does not fire when workflow itself is absent (guard mirrors original)', (t) => { + const cwd = createTempDir('gsd-3309-w016-3-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + delete c.workflow; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W016').check(snapshot), []); + }); +}); + +// ─── W012 — invalid branching_strategy ────────────────────────────────────── + +describe('W012 — invalid branching_strategy', () => { + test('fires on an invalid branching_strategy value', (t) => { + const cwd = createTempDir('gsd-3309-w012-1-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.branching_strategy = 'bogus-strategy'; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W012').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W012', + severity: SEVERITY.WARNING, + message: 'config.json: invalid branching_strategy "bogus-strategy"', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Valid values: none, phase, milestone' }, + }, + }, + ]); + }); + + for (const valid of ['none', 'phase', 'milestone']) { + test(`does not fire on valid branching_strategy "${valid}"`, (t) => { + const cwd = createTempDir('gsd-3309-w012-valid-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.branching_strategy = valid; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W012').check(snapshot), []); + }); + } + + test('does not fire when branching_strategy is absent', (t) => { + const cwd = createTempDir('gsd-3309-w012-2-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W012').check(snapshot), []); + }); +}); + +// ─── W013 — context_window not a positive integer ─────────────────────────── + +describe('W013 — context_window not a positive integer', () => { + test('fires on a non-integer context_window', (t) => { + const cwd = createTempDir('gsd-3309-w013-1-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.context_window = 3.5; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W013').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W013', + severity: SEVERITY.WARNING, + message: 'config.json: context_window should be a positive integer, got "3.5"', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Set to 200000 (default) or 1000000 (for 1M models)' }, + }, + }, + ]); + }); + + test('fires on a non-positive context_window (boundary: 0)', (t) => { + const cwd = createTempDir('gsd-3309-w013-2-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.context_window = 0; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.equal(ruleFor('W013').check(snapshot).length, 1); + }); + + test('fires on a negative context_window', (t) => { + const cwd = createTempDir('gsd-3309-w013-3-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.context_window = -1; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.equal(ruleFor('W013').check(snapshot).length, 1); + }); + + test('does not fire on a valid positive integer (boundary: 1)', (t) => { + const cwd = createTempDir('gsd-3309-w013-4-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.context_window = 1; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W013').check(snapshot), []); + }); + + test('does not fire on the conventional default (200000)', (t) => { + const cwd = createTempDir('gsd-3309-w013-5-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.context_window = 200000; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W013').check(snapshot), []); + }); + + test('does not fire when context_window is absent', (t) => { + const cwd = createTempDir('gsd-3309-w013-6-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W013').check(snapshot), []); + }); +}); + +// ─── W014 — phase_branch_template missing {phase} ─────────────────────────── + +describe('W014 — phase_branch_template missing {phase} placeholder', () => { + test('fires when the placeholder is stripped', (t) => { + const cwd = createTempDir('gsd-3309-w014-1-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.phase_branch_template = 'phase/no-placeholder-here'; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W014').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W014', + severity: SEVERITY.WARNING, + message: 'config.json: phase_branch_template missing {phase} placeholder', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Template must include {phase} for phase number substitution' }, + }, + }, + ]); + }); + + test('does not fire when the placeholder is present', (t) => { + const cwd = createTempDir('gsd-3309-w014-2-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.phase_branch_template = 'phase/{phase}'; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W014').check(snapshot), []); + }); + + test('does not fire when phase_branch_template is absent', (t) => { + const cwd = createTempDir('gsd-3309-w014-3-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W014').check(snapshot), []); + }); +}); + +// ─── W015 — milestone_branch_template missing {milestone} ────────────────── + +describe('W015 — milestone_branch_template missing {milestone} placeholder', () => { + test('fires when the placeholder is stripped', (t) => { + const cwd = createTempDir('gsd-3309-w015-1-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.milestone_branch_template = 'milestone/no-placeholder-here'; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W015').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W015', + severity: SEVERITY.WARNING, + message: 'config.json: milestone_branch_template missing {milestone} placeholder', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Template must include {milestone} for version substitution' }, + }, + }, + ]); + }); + + test('does not fire when the placeholder is present', (t) => { + const cwd = createTempDir('gsd-3309-w015-2-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.milestone_branch_template = 'milestone/{milestone}'; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W015').check(snapshot), []); + }); + + test('does not fire when milestone_branch_template is absent', (t) => { + const cwd = createTempDir('gsd-3309-w015-3-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W015').check(snapshot), []); + }); +}); + +// ─── W022 — models malformed (3 internal conditions, 1 code) ─────────────── + +describe('W022 — config.json models malformed', () => { + test('(a) unknown phase type key fires', (t) => { + const cwd = createTempDir('gsd-3309-w022a-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.models = { not_a_real_phase_type: 'sonnet' }; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W022').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W022', + severity: SEVERITY.WARNING, + message: 'config.json: models has an unknown phase type "not_a_real_phase_type" which will be ignored', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: 'Valid phase types: planning, discuss, research, execution, verification, completion', + }, + }, + }, + ]); + }); + + test('(b) known phase type with an invalid tier value fires', (t) => { + const cwd = createTempDir('gsd-3309-w022b-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.models = { planning: 'not-a-real-tier' }; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W022').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W022', + severity: SEVERITY.WARNING, + message: 'config.json: models.planning has an invalid tier value "not-a-real-tier" which will be ignored', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Valid tiers: opus, sonnet, haiku, inherit' }, + }, + }, + ]); + }); + + test('(c) models present but not an object fires, and does NOT also run (a)/(b)', (t) => { + const cwd = createTempDir('gsd-3309-w022c-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.models = 'not-an-object'; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W022').check(snapshot); + + assert.deepEqual(diagnostics, [ + { + code: 'W022', + severity: SEVERITY.WARNING, + message: + 'config.json: models is set to "not-an-object", but must be an object mapping phase types to tiers — this value will be ignored', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: 'Set models to an object like {"planning": "sonnet"}, or remove the key to use profile defaults', + }, + }, + }, + ]); + }); + + test('(c) an array also counts as "not an object" (Array.isArray guard)', (t) => { + const cwd = createTempDir('gsd-3309-w022c-array-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.models = ['sonnet']; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W022').check(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'W022'); + }); + + test('multiple malformed entries in one models object each produce their own diagnostic', (t) => { + const cwd = createTempDir('gsd-3309-w022-multi-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.models = { planning: 'bogus-tier', not_a_real_phase_type: 'sonnet' }; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W022').check(snapshot); + assert.equal(diagnostics.length, 2); + for (const d of diagnostics) assert.equal(d.code, 'W022'); + }); + + test('does not fire when models is a well-formed object', (t) => { + const cwd = createTempDir('gsd-3309-w022-ok-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.models = { planning: 'sonnet', research: 'inherit' }; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W022').check(snapshot), []); + }); + + test('does not fire when models is absent', (t) => { + const cwd = createTempDir('gsd-3309-w022-absent-'); + t.after(() => cleanup(cwd)); + writeConfig(cwd, BASE_CONFIG); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W022').check(snapshot), []); + }); + + test('does not fire when models is null', (t) => { + const cwd = createTempDir('gsd-3309-w022-null-'); + t.after(() => cleanup(cwd)); + const cfg = mutatedConfig((c) => { + c.models = null; + }); + writeConfig(cwd, cfg); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W022').check(snapshot), []); + }); +}); From 1b09fb13d3e580e875471bda1fc357bc2cf5a391 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:33 -0400 Subject: [PATCH 09/35] refactor(#3309): add phase-structure health-diagnostic rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W005, W023, I001, W009 — phase directory naming, duplicate phase keys, plan-summary presence, and validation-architecture checks, migrated onto the frozen rule table per ADR-3180 §8.2. --- .../phase-structure.cts | 196 ++++++++++++ .../phase-structure.test.cjs | 291 ++++++++++++++++++ 2 files changed, 487 insertions(+) create mode 100644 src/health-diagnostic-rules/phase-structure.cts create mode 100644 tests/health-diagnostic-rules/phase-structure.test.cjs diff --git a/src/health-diagnostic-rules/phase-structure.cts b/src/health-diagnostic-rules/phase-structure.cts new file mode 100644 index 000000000..8d8667e31 --- /dev/null +++ b/src/health-diagnostic-rules/phase-structure.cts @@ -0,0 +1,196 @@ +/** + * Health Diagnostic — Phase directory structure rules (Phase 11, #3309, + * ADR-3180 §8.2/§8.3/§8.5). + * + * Group: "Phase directory structure" (design doc, "Rule table organization" + * table) — W005, W023, I001, W009. + * + * Ported behavior-preserving from `cmdValidateHealth` + * (`src/verify.cts:1893-1990`, the exact call sites for W005/W023/I001/W009), + * with two disclosed fidelity reductions forced by `PlanningSnapshot`'s + * current shape (see each rule's own comment below): + * + * - I001 cannot name the individual unsummarized PLAN filename (`snapshot. + * phases.value[i]` exposes only `planCount`/`summaryCount`, not per-plan + * filenames) — this rule reports a coarser per-PHASE message instead. + * - W023's original "described" list called `determinePhaseStatus` + * (`commands.cts:154`), a SIX-way status string ('Not Started'/'Planned'/ + * 'In Progress'/'Executed'/'Needs Review'/'Complete') computed from its own + * raw `readdirSync` + `*-VERIFICATION.md` frontmatter read of `phaseDir` — + * neither `PhaseSnapshot.complete` (a boolean) nor `PhaseSnapshot. + * verificationStatus` (the DIFFERENT, `readVerificationStatus`-routed + * status vocabulary: 'passed'/'gaps_found'/'human_needed'/'stale'/ + * 'unknown'/'missing', §7.4 disk-strict) reproduces that six-way string + * byte-for-byte — the two computations read the same file independently + * and can disagree (e.g. a stale-but-frontmatter-"passed" VERIFICATION.md + * reads 'Complete' under the original raw read but routes to a non-'passed' + * `verificationStatus` under §7.4's staleness handling). Reproducing the + * raw frontmatter read here would violate §8.1 rule 1 (no ambient I/O in a + * rule's `check`). This rule instead describes each colliding directory + * with the snapshot fields actually available (`planCount`, `summaryCount`, + * `verificationStatus`) — a disclosed fidelity reduction, not a silent + * reproduction of the original six-way label. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * ADR-457 build-at-publish: source in + * src/health-diagnostic-rules/phase-structure.cts, compiled to + * gsd-core/bin/lib/health-diagnostic-rules/phase-structure.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; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Diagnostic = healthDiagnosticMod.Diagnostic; +type Rule = healthDiagnosticMod.Rule; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import validateMod = require('../validate.cjs'); +const { phaseDirNameRe } = validateMod; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdMod = require('../phase-id.cjs'); +const { extractPhaseToken, normalizePhaseName, comparePhaseNum } = phaseIdMod; + +// ─── W005 — phase directory doesn't follow NN-name format (verify.cts:1893-1902) ─ + +function checkW005(snapshot: PlanningSnapshot): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const name of snapshot.phaseDirs.value) { + if (!name.match(phaseDirNameRe)) { + diagnostics.push({ + code: 'W005', + severity: SEVERITY.WARNING, + message: `Phase directory "${name}" doesn't follow NN-name format`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Rename to match pattern (e.g., 01-setup)' }, + }, + }); + } + } + return diagnostics; +} + +// ─── W023 — phase directories collide on normalized key (verify.cts:1904-1950) ─ +// +// Groups `snapshot.phaseDirs.value` by `normalizePhaseName(extractPhaseToken(name))` +// — the exact same two owners (`phase-id.cjs`) the original `verify.cts:1917-1918` +// call site uses, relocated verbatim rather than reimplemented. Sorted with +// `comparePhaseNum` + a `localeCompare` tiebreak, mirroring +// `verify.cts:1930-1932`'s deterministic-output rationale. See the file-level +// comment for the disclosed "described" fidelity reduction. + +function checkW023(snapshot: PlanningSnapshot): Diagnostic[] { + const groups = new Map(); + for (const name of snapshot.phaseDirs.value) { + const token = extractPhaseToken(name); + const key = normalizePhaseName(token); + const list = groups.get(key); + if (list) list.push(name); + else groups.set(key, [name]); + } + + const phaseByDir = new Map(snapshot.phases.value.map((p) => [p.dir, p])); + + const diagnostics: Diagnostic[] = []; + for (const [key, dirs] of groups) { + if (dirs.length < 2) continue; + const described = dirs + .slice() + .sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b))) + .map((d) => { + const phase = phaseByDir.get(d); + const plans = phase ? phase.planCount : 0; + const summaries = phase ? phase.summaryCount : 0; + const verificationStatus = phase ? phase.verificationStatus : 'missing'; + return `${d} (plans: ${plans}, summaries: ${summaries}, verification: ${verificationStatus})`; + }) + .join(', '); + diagnostics.push({ + code: 'W023', + severity: SEVERITY.WARNING, + message: `Phase directories collide on normalized key "${key}": ${described}`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Inspect each directory; rename or remove the duplicate so only one directory maps to this phase key', + }, + }, + }); + } + return diagnostics; +} + +// ─── I001 — plan(s) without a matching SUMMARY.md (verify.cts:1952-1965) ─── +// +// GENUINE FIDELITY GAP (see file-level comment): the original is PER-PLAN +// (`${e.name}/${plan} has no SUMMARY.md`, `plan` an individual PLAN.md +// filename from `findUnsummarizedPlans`). `PlanningSnapshot`'s +// `phases.value[i]` carries only `planCount`/`summaryCount` NUMBERS per +// phase — no per-plan filenames — so this rule cannot name which plan lacks +// a summary without reading the phase directory directly inside `check` +// (forbidden by §8.1 rule 1). This rule instead reports one coarser +// per-PHASE diagnostic naming the deficit count, not the individual +// filename(s). + +function checkI001(snapshot: PlanningSnapshot): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const phase of snapshot.phases.value) { + const deficit = phase.planCount - phase.summaryCount; + if (deficit > 0) { + diagnostics.push({ + code: 'I001', + severity: SEVERITY.INFO, + message: `Phase ${phase.dir} has ${deficit} plan(s) without a matching summary`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'May be in progress' }, + }, + }); + } + } + return diagnostics; +} + +// ─── W009 — Validation Architecture in RESEARCH.md but no VALIDATION.md ──── +// (verify.cts:1967-1990) + +function checkW009(snapshot: PlanningSnapshot): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const entry of snapshot.researchValidationStatus.value) { + if (entry.hasValidationArchitecture && !entry.hasValidationMd) { + diagnostics.push({ + code: 'W009', + severity: SEVERITY.WARNING, + message: `Phase ${entry.dir}: has Validation Architecture in RESEARCH.md but no VALIDATION.md`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Re-run /gsd-plan-phase with --research to regenerate' }, + }, + }); + } + } + return diagnostics; +} + +// ─── Exports ──────────────────────────────────────────────────────────────── + +const RULES: Rule[] = [ + { code: 'W005', severity: SEVERITY.WARNING, check: checkW005 }, + { code: 'W023', severity: SEVERITY.WARNING, check: checkW023 }, + { code: 'I001', severity: SEVERITY.INFO, check: checkI001 }, + { code: 'W009', severity: SEVERITY.WARNING, check: checkW009 }, +]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/phase-structure.test.cjs b/tests/health-diagnostic-rules/phase-structure.test.cjs new file mode 100644 index 000000000..ceb24f900 --- /dev/null +++ b/tests/health-diagnostic-rules/phase-structure.test.cjs @@ -0,0 +1,291 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/phase-structure.cts` (Phase 11, + * #3309, ADR-3180 §8.2/§8.3/§8.5) — the "Phase directory structure" rule + * group: W005, W023, I001, W009. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): + * - W005/W023 are MECHANICAL MUTATION — a malformed directory name / two + * directories deliberately constructed to collide on the same normalized + * phase key. Both are directory-NAME shapes, not a document format being + * modeled, so there is nothing to mutate from a template; the mutation IS + * the directory name itself. + * - I001/W009 are STRUCTURAL ABSENCE — a missing SUMMARY.md / missing + * VALIDATION.md file. Exempt from the provenance concern per §8.5's own + * category 1: the fixture *is* the absence, no format is being modeled. + * + * Every case calls the REAL `buildPlanningSnapshot(cwd)` (Phase 10, + * `src/planning-snapshot.cts`) against real temp `.planning/` trees, then + * calls the REAL rule `check` functions from the compiled module under + * test — no hand-built in-memory snapshot mocks. Fixture helpers mirror + * `tests/planning-snapshot.test.cjs`'s own `writeState`/`writeRoadmap`/ + * `writeFile`/`makeCompletePhaseDir` verbatim. + * + * All fixtures use a ROADMAP.md with NO `Phase N:` headings under the + * current milestone section. `getMilestonePhaseFilter` + * (`src/roadmap-parser.cts:1341-1355`) degrades to a pass-all filter + * whenever `milestonePhaseNums.size === 0`, so `listMilestonePhaseDirs` + * enumerates every on-disk phase directory regardless of whether its name + * parses as a phase id — which is exactly what these rules need to exercise + * (a malformed dir name would otherwise never reach `phaseDirs.value` in the + * first place, since a non-matching name also fails the window's own + * numeric-prefix membership test). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { RULES } = require('../../gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs'); + +const ruleByCode = Object.fromEntries(RULES.map((r) => [r.code, r])); + +// ─── Fixture helpers (mirrors tests/planning-snapshot.test.cjs) ──────────── + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function writeRoadmap(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content); +} + +function writeState(cwd, fields) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + const lines = ['---']; + for (const [k, v] of Object.entries(fields)) lines.push(`${k}: ${v}`); + lines.push('---', ''); + fs.writeFileSync(path.join(planningDirOf(cwd), 'STATE.md'), lines.join('\n')); +} + +function writeFile(cwd, relPath, content) { + const full = path.join(cwd, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); +} + +function makeCompletePhaseDir(cwd, relPhaseDir) { + writeFile(cwd, `${relPhaseDir}/01-01-PLAN.md`, '# Plan\n'); + writeFile(cwd, `${relPhaseDir}/01-01-SUMMARY.md`, '# Summary\n'); + writeFile(cwd, `${relPhaseDir}/01-VERIFICATION.md`, '---\nstatus: passed\n---\n'); +} + +// No `Phase N:` heading anywhere -> getMilestonePhaseFilter's pass-all +// degrade -> listMilestonePhaseDirs enumerates every on-disk phase dir name +// verbatim, malformed or not. +function writePassAllRoadmap(cwd) { + writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n')); +} + +function baseFixture(cwd) { + writeState(cwd, { milestone: 'v1.0' }); + writePassAllRoadmap(cwd); +} + +// ─── W005 — phase directory doesn't follow NN-name format ────────────────── + +describe('W005 — phase directory naming', () => { + test('MECHANICAL MUTATION: a directory name with no NN- prefix fires W005', (t) => { + const cwd = createTempDir('gsd-3309-w005-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + writeFile(cwd, '.planning/phases/notaphase/README.md', '# not a phase dir\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.ok(snap.phaseDirs.value.includes('notaphase'), 'fixture sanity: malformed dir enumerated'); + + const diagnostics = ruleByCode['W005'].check(snap); + assert.deepEqual( + diagnostics.map((d) => d.code), + ['W005'], + ); + assert.match(diagnostics[0].message, /"notaphase"/); + assert.match(diagnostics[0].message, /doesn't follow NN-name format/); + assert.equal(diagnostics[0].severity, 'warning'); + assert.equal(diagnostics[0].remedy.action, 'advise'); + assert.equal(diagnostics[0].remedy.risk, 'none'); + }); + + test('baseline: only well-formed directory names produces no diagnostics', (t) => { + const cwd = createTempDir('gsd-3309-w005-neg-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + makeCompletePhaseDir(cwd, '.planning/phases/02-bar'); + + const snap = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleByCode['W005'].check(snap), []); + }); +}); + +// ─── W023 — phase directories collide on normalized key ──────────────────── + +describe('W023 — colliding phase directories', () => { + test('MECHANICAL MUTATION: two directories that normalize to the same key fire W023', (t) => { + const cwd = createTempDir('gsd-3309-w023-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + // extractPhaseToken("05-real") === "05"; extractPhaseToken("05-real-stray") + // === "05" too (the tokenizer stops at the first non-continuation segment, + // "real"/"stray" is a slug word, not a zero-padded continuation) — both + // normalize to phase key "05" via normalizePhaseName. + makeCompletePhaseDir(cwd, '.planning/phases/05-real'); + writeFile(cwd, '.planning/phases/05-real-stray/01-01-PLAN.md', '# Plan\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.ok( + snap.phaseDirs.value.includes('05-real') && snap.phaseDirs.value.includes('05-real-stray'), + 'fixture sanity: both colliding dirs enumerated', + ); + + const diagnostics = ruleByCode['W023'].check(snap); + assert.deepEqual( + diagnostics.map((d) => d.code), + ['W023'], + ); + assert.match(diagnostics[0].message, /collide on normalized key "05"/); + assert.match(diagnostics[0].message, /05-real \(/); + assert.match(diagnostics[0].message, /05-real-stray \(/); + assert.equal(diagnostics[0].severity, 'warning'); + }); + + test('baseline: distinct phase keys produce no diagnostics', (t) => { + const cwd = createTempDir('gsd-3309-w023-neg-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + makeCompletePhaseDir(cwd, '.planning/phases/02-bar'); + + const snap = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleByCode['W023'].check(snap), []); + }); +}); + +// ─── I001 — plan(s) without a matching SUMMARY.md ────────────────────────── + +describe('I001 — unsummarized plans', () => { + test('STRUCTURAL ABSENCE: a PLAN.md with no matching SUMMARY.md fires I001', (t) => { + const cwd = createTempDir('gsd-3309-i001-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + writeFile(cwd, '.planning/phases/01-foo/01-01-PLAN.md', '# Plan\n'); + + const snap = buildPlanningSnapshot(cwd); + const phase = snap.phases.value.find((p) => p.dir === '01-foo'); + assert.equal(phase.planCount, 1); + assert.equal(phase.summaryCount, 0); + + const diagnostics = ruleByCode['I001'].check(snap); + assert.deepEqual( + diagnostics.map((d) => d.code), + ['I001'], + ); + assert.match(diagnostics[0].message, /Phase 01-foo has 1 plan\(s\) without a matching summary/); + assert.equal(diagnostics[0].severity, 'info'); + }); + + test('baseline: matched plan/summary pairs produce no diagnostics', (t) => { + const cwd = createTempDir('gsd-3309-i001-neg-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + + const snap = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleByCode['I001'].check(snap), []); + }); +}); + +// ─── W009 — Validation Architecture in RESEARCH.md but no VALIDATION.md ─── + +describe('W009 — missing VALIDATION.md', () => { + test('STRUCTURAL ABSENCE: RESEARCH.md has Validation Architecture but no VALIDATION.md fires W009', (t) => { + const cwd = createTempDir('gsd-3309-w009-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + writeFile( + cwd, + '.planning/phases/01-foo/01-RESEARCH.md', + '# Research\n\n## Validation Architecture\n\nSome content.\n', + ); + + const snap = buildPlanningSnapshot(cwd); + const entry = snap.researchValidationStatus.value.find((e) => e.dir === '01-foo'); + assert.equal(entry.hasValidationArchitecture, true); + assert.equal(entry.hasValidationMd, false); + + const diagnostics = ruleByCode['W009'].check(snap); + assert.deepEqual( + diagnostics.map((d) => d.code), + ['W009'], + ); + assert.match( + diagnostics[0].message, + /Phase 01-foo: has Validation Architecture in RESEARCH\.md but no VALIDATION\.md/, + ); + assert.equal(diagnostics[0].severity, 'warning'); + }); + + test('baseline: VALIDATION.md present alongside Validation Architecture produces no diagnostics', (t) => { + const cwd = createTempDir('gsd-3309-w009-neg-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + writeFile( + cwd, + '.planning/phases/01-foo/01-RESEARCH.md', + '# Research\n\n## Validation Architecture\n\nSome content.\n', + ); + writeFile(cwd, '.planning/phases/01-foo/01-VALIDATION.md', '# Validation\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleByCode['W009'].check(snap), []); + }); + + test('baseline: RESEARCH.md without Validation Architecture heading produces no diagnostics', (t) => { + const cwd = createTempDir('gsd-3309-w009-neg2-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + writeFile(cwd, '.planning/phases/01-foo/01-RESEARCH.md', '# Research\n\nNo relevant heading here.\n'); + + const snap = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleByCode['W009'].check(snap), []); + }); +}); + +// ─── §8.2 rule 1 — every diagnostic's severity matches its rule's declared severity ─ + +describe('rule/severity 1:1 (§8.2 rule 1)', () => { + test('every emitted diagnostic carries the same severity as its rule entry', (t) => { + const cwd = createTempDir('gsd-3309-sev-'); + t.after(() => cleanup(cwd)); + baseFixture(cwd); + makeCompletePhaseDir(cwd, '.planning/phases/01-foo'); + writeFile(cwd, '.planning/phases/notaphase/README.md', '# not a phase dir\n'); + writeFile(cwd, '.planning/phases/05-real-stray/01-01-PLAN.md', '# Plan\n'); + writeFile(cwd, '.planning/phases/02-bar/01-01-PLAN.md', '# Plan\n'); + writeFile( + cwd, + '.planning/phases/02-bar/01-RESEARCH.md', + '## Validation Architecture\n', + ); + + const snap = buildPlanningSnapshot(cwd); + for (const rule of RULES) { + for (const diagnostic of rule.check(snap)) { + assert.equal(diagnostic.code, rule.code); + assert.equal(diagnostic.severity, rule.severity); + } + } + }); +}); From f51f00ef0ef65eb16d0de9bf36aa38c9ef428570 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:42 -0400 Subject: [PATCH 10/35] refactor(#3309): add agent-install health-diagnostic rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W010 — agent install completeness across its 4 internal conditions, migrated onto the frozen rule table per ADR-3180 §8.2. --- src/health-diagnostic-rules/agent-install.cts | 119 ++++++++ .../agent-install.test.cjs | 263 ++++++++++++++++++ 2 files changed, 382 insertions(+) create mode 100644 src/health-diagnostic-rules/agent-install.cts create mode 100644 tests/health-diagnostic-rules/agent-install.test.cjs diff --git a/src/health-diagnostic-rules/agent-install.cts b/src/health-diagnostic-rules/agent-install.cts new file mode 100644 index 000000000..986b495f3 --- /dev/null +++ b/src/health-diagnostic-rules/agent-install.cts @@ -0,0 +1,119 @@ +/** + * Agent Install rule (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5). + * + * One code, W010, ported behavior-preserving from `cmdValidateHealth`'s + * agent-install block (`src/verify.cts:1992-2027`). That block wraps a + * single `checkAgentsInstalled(_slashRuntime, cwd)` call in a try/catch that + * swallows any thrown exception silently ("agent check is non-blocking", + * `verify.cts:2025-2027`) and then branches on the SAME subject — + * "agent installation is incomplete" — across four mutually exclusive + * combinations of `missing_agents`/`incomplete_agents`, firing at most one + * `addIssue('warning', 'W010', ...)` per call. Per this phase's design doc + * ("Rejected alternatives" §3), these four sites are confirmed to be one + * subject varying only in trigger detail, not four subjects — W010 stays a + * single code. + * + * `snapshot.agentInstall` (`src/planning-snapshot.cts`'s `buildAgentInstallField`) + * already performs the try/catch this rule used to need: `scope` is + * `UNREADABLE` only when the scan itself threw, mirroring + * `cmdValidateHealth`'s silent catch — this rule reproduces that silence by + * returning no diagnostic for `UNREADABLE`, rather than inventing a new, + * more severe 5th case the original never had. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * ("Rule table organization" — Agent installation group) + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic.cjs is an export= CommonJS module +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Rule = healthDiagnosticMod.Rule; +type Diagnostic = healthDiagnosticMod.Diagnostic; +type Remedy = healthDiagnosticMod.Remedy; + +// 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; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningScopeMod = require('../planning-scope.cjs'); +const { SCOPE } = planningScopeMod; + +import { PACKAGE_NAME } from '../package-identity.cjs'; + +function adviseRemedy(command: string): Remedy { + return { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: { command } }; +} + +/** + * `check(snapshot)` for W010 — see module header for the exact 4-way + * branching this ports from `verify.cts:1992-2027`, and its message/fix + * templates copied verbatim (only the interpolated `agentStatus.*` values + * differ per call). + */ +function checkAgentInstall(snapshot: PlanningSnapshot): Diagnostic[] { + const { value: status, scope } = snapshot.agentInstall; + + // Mirrors verify.cts:2025-2027's try/catch around the checkAgentsInstalled + // call itself — a thrown scan is swallowed, not reported. `scope` here is + // UNREADABLE only in that same case (buildAgentInstallField's own catch). + if (scope === SCOPE.UNREADABLE) return []; + + if (status.agents_installed) return []; + + // verify.cts:1995 — zero agents installed at all. + if (status.installed_agents.length === 0) { + return [ + { + code: 'W010', + severity: SEVERITY.WARNING, + message: `No GSD agents found in ${status.agents_dir} — Task(subagent_type="gsd-*") will fall back to general-purpose`, + remedy: adviseRemedy(`Run the GSD installer: npx ${PACKAGE_NAME}@latest`), + }, + ]; + } + + // verify.cts:2002 — some agents incomplete (missing a generated file), zero fully missing. + if (status.incomplete_agents.length > 0 && status.missing_agents.length === 0) { + return [ + { + code: 'W010', + severity: SEVERITY.WARNING, + message: `Incomplete agent installs (missing generated file): ${status.incomplete_agents.join(', ')} — affected workflows may fall back to general-purpose`, + remedy: adviseRemedy(`Re-run the GSD installer to complete the install: npx ${PACKAGE_NAME}@latest`), + }, + ]; + } + + // verify.cts:2009 — both missing AND incomplete agents present. + if (status.incomplete_agents.length > 0) { + return [ + { + code: 'W010', + severity: SEVERITY.WARNING, + message: `Missing ${status.missing_agents.length} GSD agents: ${status.missing_agents.join(', ')}; incomplete agent installs (missing generated file): ${status.incomplete_agents.join(', ')} — affected workflows will fall back to general-purpose`, + remedy: adviseRemedy(`Run the GSD installer: npx ${PACKAGE_NAME}@latest`), + }, + ]; + } + + // verify.cts:2017 — agents missing only (no incomplete). + return [ + { + code: 'W010', + severity: SEVERITY.WARNING, + message: `Missing ${status.missing_agents.length} GSD agents: ${status.missing_agents.join(', ')} — affected workflows will fall back to general-purpose`, + remedy: adviseRemedy(`Run the GSD installer: npx ${PACKAGE_NAME}@latest`), + }, + ]; +} + +const RULES: Rule[] = [ + { + code: 'W010', + severity: SEVERITY.WARNING, + check: checkAgentInstall, + }, +]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/agent-install.test.cjs b/tests/health-diagnostic-rules/agent-install.test.cjs new file mode 100644 index 000000000..ca2d1671b --- /dev/null +++ b/tests/health-diagnostic-rules/agent-install.test.cjs @@ -0,0 +1,263 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/agent-install.cts` (Phase 11, #3309, + * ADR-3180 §8.2/§8.3/§8.5) — the W010 rule (agent installation is + * incomplete), 4 mutually exclusive trigger conditions ported from + * `verify.cts:1992-2027`, plus the "0 missing 0 incomplete" (no diagnostic) + * case and the `scope === SCOPE.UNREADABLE` silent case. + * + * 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 + * + * Fixture provenance (#2371): `checkAgentsInstalled` scans a REAL filesystem + * agents directory, not `.planning/`. Per the design doc's Fixture + * provenance §, this file REUSES rather than reinvents: + * - `createCompleteAgentsDir`/`withAgentsDirOverride` are copied verbatim + * from `tests/planning-snapshot.test.cjs`'s own `agentInstall field` + * describe block (Phase 11's own foundational batch already established + * this exact GSD_AGENTS_DIR-override technique for driving + * `buildPlanningSnapshot` against a controlled agents dir). + * - The manifest-driven "incomplete" fixture shape (a `gsd-file-manifest.json` + * alongside the agents dir, tracking a `.toml` key that is absent on disk + * for one agent) is copied from `tests/agent-install-check.test.cjs`'s + * "a partial manifest-backed local installation remains selected and + * incomplete" / "partial manifest: agent.toml absent but agent.md + * present" tests — the same manifest resolution + * (`readInstallManifest(path.dirname(agentsDir))`) `checkAgentsInstalled` + * itself uses. + * Every fixture below is structural absence/presence of agent files, exempt + * from the provenance concern (no document format is being modeled). + * + * Uses the REAL `buildPlanningSnapshot(cwd)` (`src/planning-snapshot.cts`) + * for every case except the UNREADABLE-scope case, which constructs the + * minimal `{agentInstall: {value, scope}}` slice a `Rule.check(snapshot)` + * actually reads — not a mock of `checkAgentsInstalled` (no owner is + * reimplemented or stubbed), just the documented `Scope` contract's + * UNREADABLE member, which is not otherwise reachable through the real + * filesystem scan without monkeypatching an owner internal. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const planningSnapshotLib = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { buildPlanningSnapshot } = planningSnapshotLib; +const { SCOPE } = require('../../gsd-core/bin/lib/planning-scope.cjs'); +const { PACKAGE_NAME } = require('../../gsd-core/bin/lib/package-identity.cjs'); +const { MODEL_PROFILES } = require('../../gsd-core/bin/lib/model-profiles.cjs'); +const EXPECTED_AGENTS = Object.keys(MODEL_PROFILES); + +const { RULES } = require('../../gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs'); +const rule = RULES.find((r) => r.code === 'W010'); + +// ─── Fixture helpers (copied verbatim from tests/planning-snapshot.test.cjs's +// agentInstall describe block — see module header) ───────────────────────── + +function createCompleteAgentsDir(agentsDir) { + fs.mkdirSync(agentsDir, { recursive: true }); + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.toml`), `name = "${agent}"\n`); + } +} + +function withAgentsDirOverride(t, agentsDir) { + const saved = process.env['GSD_AGENTS_DIR']; + process.env['GSD_AGENTS_DIR'] = agentsDir; + t.after(() => { + if (saved === undefined) delete process.env['GSD_AGENTS_DIR']; + else process.env['GSD_AGENTS_DIR'] = saved; + }); +} + +// Manifest-driven "incomplete agent" fixture shape, copied from +// tests/agent-install-check.test.cjs's partial-manifest tests (see module +// header). `agentsDir`'s PARENT directory is where checkAgentsInstalled +// resolves gsd-file-manifest.json from (readInstallManifest(dirname(agentsDir))). +function writeManifest(agentsDir, manifestFiles) { + fs.writeFileSync( + path.join(path.dirname(agentsDir), 'gsd-file-manifest.json'), + JSON.stringify({ files: manifestFiles }), + ); +} + +describe('agent-install rule (W010)', () => { + test('module exports exactly one W010 rule', () => { + assert.ok(rule, 'RULES must contain a W010 entry'); + assert.strictEqual(RULES.length, 1); + assert.strictEqual(rule.code, 'W010'); + assert.strictEqual(rule.severity, 'warning'); + }); + + test('0 missing 0 incomplete: all agents present — no diagnostic', (t) => { + const cwd = createTempDir('gsd-3309-w010-clean-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents-complete'); + createCompleteAgentsDir(agentsDir); + withAgentsDirOverride(t, agentsDir); + + const snapshot = buildPlanningSnapshot(cwd); + assert.strictEqual(snapshot.agentInstall.scope, SCOPE.COMPLETE); + assert.deepStrictEqual(rule.check(snapshot), []); + }); + + test('condition 1: zero agents installed at all (agents dir absent)', (t) => { + const cwd = createTempDir('gsd-3309-w010-zero-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents-absent'); + withAgentsDirOverride(t, agentsDir); + // agentsDir deliberately never created. + + const snapshot = buildPlanningSnapshot(cwd); + assert.strictEqual(snapshot.agentInstall.scope, SCOPE.COMPLETE); + assert.strictEqual(snapshot.agentInstall.value.installed_agents.length, 0); + + const diagnostics = rule.check(snapshot); + assert.strictEqual(diagnostics.length, 1); + const [d] = diagnostics; + assert.strictEqual(d.code, 'W010'); + assert.strictEqual(d.severity, 'warning'); + assert.strictEqual( + d.message, + `No GSD agents found in ${agentsDir} — Task(subagent_type="gsd-*") will fall back to general-purpose`, + ); + assert.deepStrictEqual(d.remedy, { + action: 'advise', + risk: 'none', + args: { command: `Run the GSD installer: npx ${PACKAGE_NAME}@latest` }, + }); + }); + + test('condition 2: some agents incomplete (missing generated file), zero fully missing', (t) => { + const cwd = createTempDir('gsd-3309-w010-incomplete-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + for (const agent of EXPECTED_AGENTS) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + const incompleteAgent = EXPECTED_AGENTS[0]; + // Manifest tracks every agent's .md (present) plus a .toml for + // incompleteAgent only (absent on disk) — makes exactly one agent + // incomplete while presence (missing_agents) stays empty. + const manifestFiles = {}; + for (const agent of EXPECTED_AGENTS) manifestFiles[`agents/${agent}.md`] = {}; + manifestFiles[`agents/${incompleteAgent}.toml`] = {}; + writeManifest(agentsDir, manifestFiles); + withAgentsDirOverride(t, agentsDir); + + const snapshot = buildPlanningSnapshot(cwd); + assert.strictEqual(snapshot.agentInstall.value.missing_agents.length, 0); + assert.deepStrictEqual(snapshot.agentInstall.value.incomplete_agents, [incompleteAgent]); + + const diagnostics = rule.check(snapshot); + assert.strictEqual(diagnostics.length, 1); + const [d] = diagnostics; + assert.strictEqual(d.code, 'W010'); + assert.strictEqual( + d.message, + `Incomplete agent installs (missing generated file): ${incompleteAgent} — affected workflows may fall back to general-purpose`, + ); + assert.deepStrictEqual(d.remedy, { + action: 'advise', + risk: 'none', + args: { command: `Re-run the GSD installer to complete the install: npx ${PACKAGE_NAME}@latest` }, + }); + }); + + test('condition 3: both missing AND incomplete agents present', (t) => { + const cwd = createTempDir('gsd-3309-w010-both-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + const [missingAgent, incompleteAgent, ...restAgents] = EXPECTED_AGENTS; + // missingAgent: no files at all, no manifest entry — stays purely missing. + for (const agent of [incompleteAgent, ...restAgents]) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + const manifestFiles = {}; + manifestFiles[`agents/${incompleteAgent}.md`] = {}; + manifestFiles[`agents/${incompleteAgent}.toml`] = {}; // absent on disk -> incomplete + for (const agent of restAgents) manifestFiles[`agents/${agent}.md`] = {}; + writeManifest(agentsDir, manifestFiles); + withAgentsDirOverride(t, agentsDir); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snapshot.agentInstall.value.missing_agents, [missingAgent]); + assert.deepStrictEqual(snapshot.agentInstall.value.incomplete_agents, [incompleteAgent]); + + const diagnostics = rule.check(snapshot); + assert.strictEqual(diagnostics.length, 1); + const [d] = diagnostics; + assert.strictEqual(d.code, 'W010'); + assert.strictEqual( + d.message, + `Missing 1 GSD agents: ${missingAgent}; incomplete agent installs (missing generated file): ${incompleteAgent} — affected workflows will fall back to general-purpose`, + ); + assert.deepStrictEqual(d.remedy, { + action: 'advise', + risk: 'none', + args: { command: `Run the GSD installer: npx ${PACKAGE_NAME}@latest` }, + }); + }); + + test('condition 4: agents missing only (no incomplete)', (t) => { + const cwd = createTempDir('gsd-3309-w010-missing-only-'); + t.after(() => cleanup(cwd)); + const agentsDir = path.join(cwd, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + const [missingAgent, ...restAgents] = EXPECTED_AGENTS; + for (const agent of restAgents) { + fs.writeFileSync(path.join(agentsDir, `${agent}.md`), `# ${agent}\n`); + } + const manifestFiles = {}; + for (const agent of restAgents) manifestFiles[`agents/${agent}.md`] = {}; + writeManifest(agentsDir, manifestFiles); + withAgentsDirOverride(t, agentsDir); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepStrictEqual(snapshot.agentInstall.value.missing_agents, [missingAgent]); + assert.deepStrictEqual(snapshot.agentInstall.value.incomplete_agents, []); + + const diagnostics = rule.check(snapshot); + assert.strictEqual(diagnostics.length, 1); + const [d] = diagnostics; + assert.strictEqual(d.code, 'W010'); + assert.strictEqual( + d.message, + `Missing 1 GSD agents: ${missingAgent} — affected workflows will fall back to general-purpose`, + ); + assert.deepStrictEqual(d.remedy, { + action: 'advise', + risk: 'none', + args: { command: `Run the GSD installer: npx ${PACKAGE_NAME}@latest` }, + }); + }); + + test('scope UNREADABLE (agent scan itself threw): no diagnostic, mirrors verify.cts\'s silent catch', () => { + // Minimal snapshot slice — see module header for why this is not an + // owner mock: UNREADABLE is a real, documented Scope member that + // buildAgentInstallField sets when checkAgentsInstalled throws + // (planning-snapshot.cts's own try/catch), and the rule's whole + // contract is `(snapshot) => Diagnostic[]` — it never calls the owner + // itself. + const snapshot = { + agentInstall: { + scope: SCOPE.UNREADABLE, + value: { + agents_installed: false, + missing_agents: [], + installed_agents: [], + incomplete_agents: [], + agents_dir: '', + agent_runtime: 'claude', + }, + }, + }; + assert.deepStrictEqual(rule.check(snapshot), []); + }); +}); From 9e74f00ba01400ce420a7ea2282aafe9097b5db2 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:42 -0400 Subject: [PATCH 11/35] refactor(#3309): add roadmap-disk-consistency health-diagnostic rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W006, W007 — ROADMAP entries with no matching disk dir, and disk dirs with no ROADMAP entry, migrated onto the frozen rule table per ADR-3180 §8.2. --- .../roadmap-disk-consistency.cts | 225 +++++++++++++++ .../roadmap-disk-consistency.test.cjs | 270 ++++++++++++++++++ 2 files changed, 495 insertions(+) create mode 100644 src/health-diagnostic-rules/roadmap-disk-consistency.cts create mode 100644 tests/health-diagnostic-rules/roadmap-disk-consistency.test.cjs diff --git a/src/health-diagnostic-rules/roadmap-disk-consistency.cts b/src/health-diagnostic-rules/roadmap-disk-consistency.cts new file mode 100644 index 000000000..76028977e --- /dev/null +++ b/src/health-diagnostic-rules/roadmap-disk-consistency.cts @@ -0,0 +1,225 @@ +/** + * Health Diagnostic — ROADMAP/disk consistency rules (Phase 11, #3309, + * ADR-3180 §8.2/§8.3/§8.5). + * + * Group: "ROADMAP/disk consistency" (design doc, "Rule table organization" + * table) — W006, W007. + * + * Ported behavior-preserving from `cmdValidateHealth` + * (`src/verify.cts:2029-2101`, the exact call sites for W006/W007). + * + * Both rules share ONE matcher — `matchPhaseDirs` + `normalizePhaseName` + * (`src/phase-id.cts`), the same canonical directory-resolution owner + * `verify.cts:2060/2073` already calls (its own #2528 comment explains why: + * pairing roadmap phases against disk by intersecting independently-derived + * TOKEN SETS mislabels digit-leading slugs like `05-80-20-cleanup` in BOTH + * directions at once — phase 5 reads as missing a directory (W006) AND that + * directory reads as not in the roadmap (W007) — so both rules here resolve + * through `matchPhaseDirs`, never a hand-rolled string/token comparison, and + * `dirsForPhase` below is the single call site both go through, so they + * cannot independently drift on what "matches" means (#2528's own bug + * class). + * + * DISK-SIDE SOURCE — `allPhaseDirNames`, NOT `phaseDirs` (found while + * implementing this file, fixed inline rather than deferred). + * `snapshot.phaseDirs` (Phase 10, `listMilestonePhaseDirs`) is WINDOWED: its + * `inWindow` filter (`getMilestonePhaseFilter`, `src/roadmap-parser.cts:1220`) + * admits a directory only when its phase id is a MEMBER of the roadmap's + * current-milestone-declared phase set (`isDirInMilestone`). That makes + * `phaseDirs.value` a subset that, by construction, can never contain a + * directory the roadmap does NOT declare — exactly the directory W007 exists + * to find. Sourced from `phaseDirs`, W007 would be structurally inert: every + * member of the set is already provably claimable. Verified empirically + * (`node -e` trace against a real `buildPlanningSnapshot`): a genuine orphan + * directory (`04-extra`, no roadmap entry) was silently absent from + * `phaseDirs.value` and W007 fired zero diagnostics. `phaseDirs`'s windowing + * also risks a W006 false positive for a phase declared in a NON-current + * milestone section (`roadmapDeclaredPhases` is built from the FULL raw + * ROADMAP, all milestones — `src/planning-snapshot.cts:398-436` — while + * `phaseDirs` is scoped to the current milestone only), so both rules here + * use the new, additive `allPhaseDirNames` field + * (`src/planning-snapshot.cts`) instead: every directory actually present + * under the active `phases/` root, unfiltered by roadmap declaration. + * Archived-milestone directory names (`verify.cts:2050`, + * `collectArchivedPhaseDirNames`) are still not part of `PlanningSnapshot` + * and remain a disclosed fidelity reduction (a phase whose only directory + * lives in a shipped-milestone archive can read as W006-missing; a shipped + * archived dir is never scanned so it cannot spuriously read as + * W007-orphaned either) — unchanged by this fix. + * + * Not-started exclusion (verify.cts:2065/2075-2076, + * `buildNotStartedPhaseVariants`, `src/validate.cts:160`): the design doc's + * field table assigns this group only `roadmapDeclaredPhases`/`phaseDirs`, + * and `roadmapDeclaredPhases` (`src/planning-snapshot.cts:398-436`) does + * NOT filter not-started phases out — it returns every heading- and + * checklist-declared phase id regardless of checked state (confirmed by + * direct read: its `buildRoadmapPhaseVariants` call includes BOTH `[x]` and + * `[ ]` checklist entries). Omitting the exclusion here would regress a + * COMMON case: `gsd-core/templates/roadmap.md`'s "Initial Roadmap" shape + * declares every phase as an unchecked `- [ ] **Phase N: [Name]**` checklist + * item before any phase directory exists, so a freshly created ROADMAP.md + * would immediately spam one W006 per phase. `snapshot.roadmapPhaseCheckboxes` + * (`src/planning-snapshot.cts:457-480`, backs W011 in the STATE.md-consistency + * group) already parses exactly this `[x]`/`[ ]` state — `check(snapshot)`'s + * signature grants the full snapshot, not just this group's assigned column, + * so `isPhaseNotStarted` below reads it directly rather than re-deriving a + * third independent regex over raw ROADMAP text (forbidden by §8.1 rule 2). + * KNOWN GAP, disclosed rather than silently dropped: `roadmapPhaseCheckboxes` + * is keyed by `PHASE_NUMBER_TOKEN_SOURCE` (`phase-id.cts:54`, no dash), so a + * milestone-dash-prefixed phase id ("2-01") can never match a checkbox key — + * unlike the original `buildNotStartedPhaseVariants`, which captures the + * fuller `[\w][\w.-]*` grammar (dashes included). For that id shape only, + * this rule's not-started exclusion silently no-ops (never excludes), which + * is the conservative direction (a possible false W006, not a suppressed + * true one). + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * ADR-457 build-at-publish: source in + * src/health-diagnostic-rules/roadmap-disk-consistency.cts, compiled to + * gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.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; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Diagnostic = healthDiagnosticMod.Diagnostic; +type Rule = healthDiagnosticMod.Rule; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningScopeMod = require('../planning-scope.cjs'); +const { SCOPE } = planningScopeMod; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import phaseIdMod = require('../phase-id.cjs'); +const { matchPhaseDirs, normalizePhaseName, extractPhaseToken, isSentinelPhaseId } = phaseIdMod; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import validateMod = require('../validate.cjs'); +const { phaseVariants } = validateMod; + +// ─── Shared matcher — the single call site both W006 and W007 go through ─── + +/** + * Every on-disk directory (from `allPhaseDirNames.value`) that `phaseId` + * resolves to via the canonical `matchPhaseDirs` selection. Both `checkW006` + * (does ANY directory resolve) and `computeClaimedDirs` (which directories + * does the roadmap claim, for W007) call this — one matcher, reused, per the + * file-level comment. + */ +function dirsForPhase(dirs: string[], phaseId: string): string[] { + return matchPhaseDirs(dirs, normalizePhaseName(phaseId)).matches; +} + +/** + * True when `phaseId` has an unchecked (`[ ]`) checklist entry in + * `roadmapPhaseCheckboxes` under any of its padding/case variants + * (`phaseVariants`, `src/validate.cts:101` — the same variant-expansion + * owner `verify.cts:2071/2075` uses for this exact exclusion). See the + * file-level comment for the KNOWN GAP on dash-shaped ids. + */ +function isPhaseNotStarted(phaseId: string, checkboxes: Record): boolean { + for (const variant of phaseVariants(phaseId)) { + if (Object.prototype.hasOwnProperty.call(checkboxes, variant) && checkboxes[variant] === false) { + return true; + } + } + return false; +} + +// ─── W006 — ROADMAP.md declares a phase with no directory on disk ───────── +// (verify.cts:2067-2084) + +function checkW006(snapshot: PlanningSnapshot): Diagnostic[] { + // Mirrors verify.cts:2029's `if (fs.existsSync(roadmapPath))` guard: ROADMAP.md + // absent or unreadable means the field degrades to `{value: [], scope: + // UNREADABLE}` (`src/planning-snapshot.cts:401-403/407-409`) and NEITHER + // W006 nor W007 evaluates — an empty declared-phase list must not be + // mistaken for "the roadmap legitimately declares zero phases" here. + if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return []; + + const dirs = snapshot.allPhaseDirNames.value; + const checkboxes = snapshot.roadmapPhaseCheckboxes.value; + const diagnostics: Diagnostic[] = []; + + for (const { phaseId } of snapshot.roadmapDeclaredPhases.value) { + // #3225: sentinel phase ids (999.x/0.x) are never-on-roadmap by + // convention; a sentinel heading shouldn't demand a directory. + if (isSentinelPhaseId(phaseId)) continue; + if (dirsForPhase(dirs, phaseId).length > 0) continue; + if (isPhaseNotStarted(phaseId, checkboxes)) continue; + diagnostics.push({ + code: 'W006', + severity: SEVERITY.WARNING, + message: `Phase ${phaseId} in ROADMAP.md but no directory on disk`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Create phase directory or remove from roadmap' }, + }, + }); + } + return diagnostics; +} + +// ─── W007 — an on-disk phase directory has no matching ROADMAP entry ────── +// (verify.cts:2086-2101) + +/** Every directory in `dirs` that ANY declared roadmap phase resolves to. */ +function computeClaimedDirs( + dirs: string[], + declaredPhases: { phaseId: string; milestone: string | null }[], +): Set { + const claimed = new Set(); + for (const { phaseId } of declaredPhases) { + for (const dir of dirsForPhase(dirs, phaseId)) claimed.add(dir); + } + return claimed; +} + +function checkW007(snapshot: PlanningSnapshot): Diagnostic[] { + // Same guard as W006 — see its comment. + if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return []; + + const dirs = snapshot.allPhaseDirNames.value; + const claimedDirs = computeClaimedDirs(dirs, snapshot.roadmapDeclaredPhases.value); + const diagnostics: Diagnostic[] = []; + + for (const dirName of dirs) { + // `extractPhaseToken` is the phase-id.cts owner `PHASE_TOKEN_FROM_DIR_RE` + // (`src/validate.cts:73-76`) is documented to match exactly + // (verify.cts's original `p` key from `collectDiskPhaseEntries`, + // `verify.cts:1373-1397`) — same token, relocated read, not reinvented. + const token = extractPhaseToken(dirName); + // #3225: a sentinel dir on disk (999-interim, 0-drafts) is defined as + // never-on-roadmap; it must not trigger W007. + if (isSentinelPhaseId(token)) continue; + if (claimedDirs.has(dirName)) continue; + diagnostics.push({ + code: 'W007', + severity: SEVERITY.WARNING, + message: `Phase ${token} exists on disk but not in ROADMAP.md`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Add to roadmap or remove directory' }, + }, + }); + } + return diagnostics; +} + +// ─── Exports ──────────────────────────────────────────────────────────────── + +const RULES: Rule[] = [ + { code: 'W006', severity: SEVERITY.WARNING, check: checkW006 }, + { code: 'W007', severity: SEVERITY.WARNING, check: checkW007 }, +]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/roadmap-disk-consistency.test.cjs b/tests/health-diagnostic-rules/roadmap-disk-consistency.test.cjs new file mode 100644 index 000000000..932b78b56 --- /dev/null +++ b/tests/health-diagnostic-rules/roadmap-disk-consistency.test.cjs @@ -0,0 +1,270 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/roadmap-disk-consistency.cts` + * (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) — group "ROADMAP/disk + * consistency": W006, W007. + * + * 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 + * | W006 | ROADMAP phase with no disk dir | reused/representative | roadmap entry added, no matching dir created | + * | W007 | disk dir with no ROADMAP entry | reused/representative | dir created, no roadmap entry | + * + * Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): both + * rules use CONTENT-SHAPE/MECHANICAL-MUTATION provenance — a realistic + * multi-phase ROADMAP.md (mirroring `gsd-core/templates/roadmap.md`'s + * heading shape) paired with a matching on-disk phase-dir tree, with exactly + * ONE entry perturbed (one dir withheld for W006, one extra dir added for + * W007). Every fixture is built via the REAL `buildPlanningSnapshot(cwd)` + * against a REAL temp directory (mirrors `tests/planning-snapshot.test.cjs` + * and `tests/health-diagnostic-rules/root-existence.test.cjs`) — no + * hand-constructed fake `PlanningSnapshot` object. + * + * TDD RED: `src/health-diagnostic-rules/roadmap-disk-consistency.cts` does + * not exist yet at the start of this batch — this file's + * `require('../../gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs')` + * throws MODULE_NOT_FOUND until this batch's implementation lands. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const roadmapDiskConsistency = require('../../gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs'); +const { RULES } = roadmapDiskConsistency; + +const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs'); +const { SCOPE } = require('../../gsd-core/bin/lib/planning-scope.cjs'); + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function writeRoadmap(cwd, content) { + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.writeFileSync(path.join(planningDirOf(cwd), 'ROADMAP.md'), content); +} + +function makePhaseDir(cwd, dirName) { + fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', dirName), { recursive: true }); +} + +function ruleFor(code) { + const rule = RULES.find((r) => r.code === code); + assert.ok(rule, `rule ${code} not found in RULES`); + return rule; +} + +// ─── RULES shape ──────────────────────────────────────────────────────────── + +describe('RULES (roadmap-disk-consistency group)', () => { + test('exports exactly 2 rules: W006, W007', () => { + assert.deepEqual(RULES.map((r) => r.code).sort(), ['W006', 'W007']); + }); + + test('both rules are severity WARNING', () => { + assert.equal(ruleFor('W006').severity, SEVERITY.WARNING); + assert.equal(ruleFor('W007').severity, SEVERITY.WARNING); + }); +}); + +// ─── W006 — ROADMAP phase with no disk dir ───────────────────────────────── + +describe('W006 — ROADMAP phase with no disk dir', () => { + test('fires for exactly the one perturbed phase (3-phase roadmap, dir withheld for phase 2)', (t) => { + const cwd = createTempDir('gsd-3309-w006-1-'); + t.after(() => cleanup(cwd)); + writeRoadmap( + cwd, + ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', '', '### Phase 3: Baz'].join('\n'), + ); + makePhaseDir(cwd, '01-foo'); + // Phase 2 deliberately has no matching directory. + makePhaseDir(cwd, '03-baz'); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W006').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'W006', + severity: SEVERITY.WARNING, + message: 'Phase 2 in ROADMAP.md but no directory on disk', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Create phase directory or remove from roadmap' }, + }, + }); + }); + + test('does not fire when every declared phase resolves to a directory (padding/token tolerant)', (t) => { + const cwd = createTempDir('gsd-3309-w006-2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n')); + makePhaseDir(cwd, '01-foo'); + makePhaseDir(cwd, '02-bar'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W006').check(snapshot), []); + }); + + test('does not fire for a sentinel phase id (999.x) even with no matching directory', (t) => { + const cwd = createTempDir('gsd-3309-w006-3-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 999.1: Icebox'].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W006').check(snapshot), []); + }); + + test('does not fire for a phase explicitly marked "not started" (unchecked checklist entry, no dir)', (t) => { + const cwd = createTempDir('gsd-3309-w006-4-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## Phases', '', '- [ ] **Phase 5: Widgets** - build them'].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + // Sanity: the phase IS declared (so this is genuinely testing the + // not-started exclusion, not an empty-declared-phases no-op). + assert.ok(snapshot.roadmapDeclaredPhases.value.some((p) => p.phaseId === '5')); + assert.deepEqual(ruleFor('W006').check(snapshot), []); + }); + + test('DOES fire for an unrelated checked phase with no dir (not-started exclusion is per-phase, not global)', (t) => { + const cwd = createTempDir('gsd-3309-w006-5-'); + t.after(() => cleanup(cwd)); + writeRoadmap( + cwd, + ['## Phases', '', '- [ ] **Phase 5: Widgets** - build them', '- [x] **Phase 6: Gadgets** - build them'].join( + '\n', + ), + ); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W006').check(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].message, 'Phase 6 in ROADMAP.md but no directory on disk'); + }); + + test('boundary: zero declared phases and zero phase directories produces zero findings', (t) => { + const cwd = createTempDir('gsd-3309-w006-6-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## Progress', '', '(no phases declared yet)'].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(snapshot.roadmapDeclaredPhases.value, []); + assert.deepEqual(ruleFor('W006').check(snapshot), []); + }); + + test('guard: ROADMAP.md absent does not fire (empty declared-phase list is a non-answer, not "zero declared")', (t) => { + const cwd = createTempDir('gsd-3309-w006-7-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W006').check(snapshot), []); + }); +}); + +// ─── W007 — disk dir with no ROADMAP entry ───────────────────────────────── + +describe('W007 — disk dir with no ROADMAP entry', () => { + test('fires for exactly the one perturbed directory (3-phase roadmap, one extra orphan dir)', (t) => { + const cwd = createTempDir('gsd-3309-w007-1-'); + t.after(() => cleanup(cwd)); + writeRoadmap( + cwd, + ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar', '', '### Phase 3: Baz'].join('\n'), + ); + makePhaseDir(cwd, '01-foo'); + makePhaseDir(cwd, '02-bar'); + makePhaseDir(cwd, '03-baz'); + // Deliberately orphaned: no roadmap entry claims this directory. + makePhaseDir(cwd, '04-extra'); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W007').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'W007', + severity: SEVERITY.WARNING, + message: 'Phase 04 exists on disk but not in ROADMAP.md', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Add to roadmap or remove directory' }, + }, + }); + }); + + test('does not fire when every directory is claimed by a declared phase', (t) => { + const cwd = createTempDir('gsd-3309-w007-2-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo', '', '### Phase 2: Bar'].join('\n')); + makePhaseDir(cwd, '01-foo'); + makePhaseDir(cwd, '02-bar'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W007').check(snapshot), []); + }); + + test('does not fire for a sentinel directory (999-interim) even with no roadmap entry', (t) => { + const cwd = createTempDir('gsd-3309-w007-3-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + makePhaseDir(cwd, '01-foo'); + makePhaseDir(cwd, '999-interim'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W007').check(snapshot), []); + }); + + test('boundary: zero declared phases and zero phase directories produces zero findings', (t) => { + const cwd = createTempDir('gsd-3309-w007-4-'); + t.after(() => cleanup(cwd)); + writeRoadmap(cwd, ['## Progress', '', '(no phases declared yet)'].join('\n')); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(snapshot.allPhaseDirNames.value, []); + assert.deepEqual(ruleFor('W007').check(snapshot), []); + }); + + test('regression: fires for a genuine orphan directory outside the roadmap-declared window (the W007-inert defect)', (t) => { + const cwd = createTempDir('gsd-3309-w007-6-'); + t.after(() => cleanup(cwd)); + // ROADMAP declares only phase 1 — "04-extra" is not declared anywhere, + // so `phaseDirs` (windowed to declared phases) would silently drop it + // and W007 would never see it; `allPhaseDirNames` must not. + writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n')); + makePhaseDir(cwd, '01-foo'); + makePhaseDir(cwd, '04-extra'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual( + snapshot.phaseDirs.value, + ['01-foo'], + 'sanity: the windowed phaseDirs field must NOT include the orphan (confirms the defect this test guards)', + ); + assert.ok(snapshot.allPhaseDirNames.value.includes('04-extra')); + + const diagnostics = ruleFor('W007').check(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].message, 'Phase 04 exists on disk but not in ROADMAP.md'); + }); + + test('guard: ROADMAP.md absent does not fire for a pre-existing phase directory (no false positive)', (t) => { + const cwd = createTempDir('gsd-3309-w007-5-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + makePhaseDir(cwd, '01-foo'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(snapshot.roadmapDeclaredPhases, { value: [], scope: SCOPE.UNREADABLE }); + assert.deepEqual(ruleFor('W007').check(snapshot), []); + }); +}); From a56679a4ca1bacd103fd37eeeeb41c2363973f4f Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:42 -0400 Subject: [PATCH 12/35] refactor(#3309): add worktree-health health-diagnostic rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W020 (3 conditions), W017, W027 — git worktree list degradation, orphan and stale worktree checks, migrated onto the frozen rule table per ADR-3180 §8.2. W027 has a documented fidelity reduction: rules have no cwd access, so it can no longer exclude the active worktree. --- .../worktree-health.cts | 186 ++++++++++ .../worktree-health.test.cjs | 336 ++++++++++++++++++ 2 files changed, 522 insertions(+) create mode 100644 src/health-diagnostic-rules/worktree-health.cts create mode 100644 tests/health-diagnostic-rules/worktree-health.test.cjs diff --git a/src/health-diagnostic-rules/worktree-health.cts b/src/health-diagnostic-rules/worktree-health.cts new file mode 100644 index 000000000..05665915b --- /dev/null +++ b/src/health-diagnostic-rules/worktree-health.cts @@ -0,0 +1,186 @@ +/** + * Health Diagnostic — Worktree health rules (Phase 11, #3309, ADR-3180 + * §8.2/§8.3/§8.5). + * + * Group: "Worktree health" (design doc, "Rule table organization" table) — + * W020 (×3 internal conditions, one subject: "the worktree health scan + * itself is degraded", design doc "Rejected alternatives" §3), W017 (orphan + * worktree), W027 (NEW — the split-off "stale worktree" subject, design + * doc's "New codes for the two split subjects" section). + * + * Ported behavior-preserving from `cmdValidateHealth` + * (`src/verify.cts:2193-2268`), the exact call sites for W020/W017/W027 (the + * pre-migration source still names the split-off stale-worktree site + * 'W017' — this batch is what actually applies the W027 split). + * + * KNOWN GAPS (found while building, reported rather than papered over — see + * this batch's dispatch report for full detail): + * + * 1. W020's original THREE conditions were git_timed_out / git_list_failed / + * a per-finding 'unverified' kind, each with its own message. The first + * two are scan-level failures reported by `inspectWorktreeHealth`'s own + * `reason` field ('git_timed_out' vs 'git_list_failed' vs + * 'not_a_git_repo') — but `planning-snapshot.cts`'s + * `buildWorktreeHealthField` discards `reason` entirely and only + * preserves `scope: SCOPE.UNREADABLE` for ANY `!result.ok` case. This + * rule therefore CANNOT distinguish "git timed out" from "git worktree + * list failed outright" from the snapshot alone — both collapse to the + * same `checkScanDegraded` branch below, which emits one reasonable + * combined message instead of the original's two separate ones. Fixing + * this precisely requires extending `PlanningSnapshot.worktreeHealth` + * with the discarded `reason` field — an snapshot-field enhancement + * outside this rule-file batch's scope, flagged here rather than guessed + * around. + * 2. W027's original exclusion of the active session's own worktree + * (`verify.cts:2233-2242`, comparing `finding.path` against + * `process.cwd()`) happens at the `cmdValidateHealth` call site, NOT + * inside `inspectWorktreeHealth`/`worktree-safety.cts`. Confirmed by + * direct read: `inspectWorktreeHealth` (`src/worktree-safety.cts:352-397`) + * performs no cwd comparison, and `listLinkedWorktreePaths` + * (`src/worktree-safety.cts:321-338`) only drops the FIRST `git worktree + * list` entry (assumed main worktree) via `.slice(1)` — it does not know + * which entry, if any, is the ACTIVE session's cwd, which is commonly a + * LINKED (non-first) worktree in this repo's own multi-worktree workflow. + * A `Rule.check(snapshot)` has no ambient `process.cwd()` access (§8.1 + * rule 1 forbids it), and `PlanningSnapshot` carries no + * "active worktree path" field to filter against. This is a REAL, + * unclosed gap: `checkW027` below reports every 'stale' finding, + * INCLUDING the active session's own worktree, which is a behavior + * change from the pre-migration code. Closing it precisely requires + * either a new snapshot field carrying the active worktree path/cwd, or + * moving the exclusion into `inspectWorktreeHealth` itself — both are + * snapshot/owner changes outside this rule-file batch's scope. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * ADR-457 build-at-publish: source in + * src/health-diagnostic-rules/worktree-health.cts, compiled to + * gsd-core/bin/lib/health-diagnostic-rules/worktree-health.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; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Diagnostic = healthDiagnosticMod.Diagnostic; +type Rule = healthDiagnosticMod.Rule; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import planningScopeMod = require('../planning-scope.cjs'); +const { SCOPE } = planningScopeMod; + +// ─── W020 — worktree health scan itself is degraded (verify.cts:2203-2264) ─ +// +// ONE rule, THREE internal conditions, all the same subject ("the worktree +// health scan itself is degraded" — design doc "Rejected alternatives" §3): +// (a) `git worktree list` timed out, (b) `git worktree list` failed +// outright, (c) a specific 'unverified' finding (existsSync ok, statSync +// threw). (a) and (b) collapse to a single combined message per the +// module-doc gap note above; (c) is a per-finding, exact port of +// `verify.cts:2256-2263`. + +function checkW020(snapshot: PlanningSnapshot): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + + // (a)+(b) — scan-level degradation. GAP: cannot distinguish timeout from + // outright failure from `scope` alone (see module doc, gap 1). + if (snapshot.worktreeHealth.scope === SCOPE.UNREADABLE) { + diagnostics.push({ + code: 'W020', + severity: SEVERITY.WARNING, + message: + 'Worktree health check degraded: git worktree list timed out or failed — orphan/stale worktrees could not be inspected', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Run: git worktree list --porcelain to diagnose; check for .git/index.lock, a hung git process, or repository permissions', + }, + }, + }); + } + + // (c) — per-finding 'unverified' (existsSync ok, statSync threw). + for (const finding of snapshot.worktreeHealth.value) { + if (finding.kind !== 'unverified') continue; + diagnostics.push({ + code: 'W020', + severity: SEVERITY.WARNING, + message: `Worktree health check degraded: could not stat ${finding.path} — presence/staleness could not be verified`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Check filesystem permissions on the worktree path, or investigate why statSync failed for it' }, + }, + }); + } + + return diagnostics; +} + +// ─── W017 — orphan git worktree (verify.cts:2222-2229) ───────────────────── +// +// `finding.kind === 'orphan'` — path no longer exists on disk. One +// Diagnostic per orphan finding. Remedy mirrors the exact original literal +// fix, `verify.cts:2227`: `'Run: git worktree prune'`. + +function checkW017(snapshot: PlanningSnapshot): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const finding of snapshot.worktreeHealth.value) { + if (finding.kind !== 'orphan') continue; + diagnostics.push({ + code: 'W017', + severity: SEVERITY.WARNING, + message: `Orphan git worktree: ${finding.path} (path no longer exists on disk)`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'git worktree prune' }, + }, + }); + } + return diagnostics; +} + +// ─── W027 — stale git worktree (verify.cts:2232-2249, the split-off half of +// the pre-migration 'W017' site) ───────────────────────────────────────── +// +// `finding.kind === 'stale'` — age-based. GAP: does NOT exclude the active +// session's own worktree (see module doc, gap 2) — the original's +// `process.cwd()` comparison cannot be reproduced from `snapshot` alone. +// Per this batch's brief: the interpolated command (with the real path) +// lives in `message`; `remedy.args.command` stays a static `` +// template, mirroring the split the brief specifies. + +function checkW027(snapshot: PlanningSnapshot): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const finding of snapshot.worktreeHealth.value) { + if (finding.kind !== 'stale') continue; + diagnostics.push({ + code: 'W027', + severity: SEVERITY.WARNING, + message: `Stale git worktree: ${finding.path} (last modified ${finding.ageMinutes} minutes ago). Run: git worktree remove ${finding.path} --force`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'git worktree remove --force' }, + }, + }); + } + return diagnostics; +} + +// ─── Exports ──────────────────────────────────────────────────────────────── + +const RULES: Rule[] = [ + { code: 'W020', severity: SEVERITY.WARNING, check: checkW020 }, + { code: 'W017', severity: SEVERITY.WARNING, check: checkW017 }, + { code: 'W027', severity: SEVERITY.WARNING, check: checkW027 }, +]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/worktree-health.test.cjs b/tests/health-diagnostic-rules/worktree-health.test.cjs new file mode 100644 index 000000000..998d99167 --- /dev/null +++ b/tests/health-diagnostic-rules/worktree-health.test.cjs @@ -0,0 +1,336 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/worktree-health.cts` (Phase 11, + * #3309, ADR-3180 §8.2/§8.3/§8.5) — group "Worktree health": W020 (×3 + * internal conditions), W017 (orphan), W027 (NEW — split-off stale-worktree + * subject). + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * Fixture provenance (§8.5 + CONTRIBUTING "Fixture provenance (#2371)"): every + * fixture here is built via the REAL `buildPlanningSnapshot(cwd)` against a + * REAL temp directory. The `git worktree list` seam is mocked at + * `child_process.spawnSync` — REUSED, not re-derived, from + * `tests/planning-snapshot.test.cjs`'s `mockGitWorktreeListOk` / + * `mockGitWorktreeListTimeout` helpers (that file's own comment cites this as + * "the repo's convention for driving the real execGit rather than a hand-set + * deps.execGit stub", since `buildWorktreeHealthField` + * (`src/planning-snapshot.cts`) accepts no `deps` parameter to inject + * `execGit` directly — mirrors `tests/worktree-safety.test.cjs`'s + * "execGitDefault (real spawn seam)" section). Orphan/stale findings use REAL + * `fs.existsSync`/`fs.statSync` against real temp-dir paths (no mocking + * needed: a genuinely-absent path is naturally an orphan; a real directory + * with an old mtime, set via `fs.utimesSync`, is naturally stale). Only the + * 'unverified' finding (statSync throws on an existing path) needs a + * `fs.statSync` passthrough mock, scoped to one target path. + * + * TDD RED: `src/health-diagnostic-rules/worktree-health.cts` does not exist + * yet at the start of this batch — this file's + * `require('../../gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs')` + * throws MODULE_NOT_FOUND until this batch's implementation lands. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const childProcess = require('node:child_process'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const worktreeHealth = require('../../gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs'); +const { RULES } = worktreeHealth; + +const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = require('../../gsd-core/bin/lib/health-diagnostic.cjs'); + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function ruleFor(code) { + const rule = RULES.find((r) => r.code === code); + assert.ok(rule, `rule ${code} not found in RULES`); + return rule; +} + +// ─── Reused git-porcelain mocking (tests/planning-snapshot.test.cjs) ─────── + +function mockGitWorktreeListOk(t, porcelain) { + t.mock.method(childProcess, 'spawnSync', () => ({ + status: 0, + stdout: porcelain, + stderr: '', + signal: null, + error: null, + })); +} + +function mockGitWorktreeListTimeout(t) { + t.mock.method(childProcess, 'spawnSync', () => ({ + status: null, + stdout: '', + stderr: '', + signal: null, + error: Object.assign(new Error('spawnSync git ETIMEDOUT'), { code: 'ETIMEDOUT' }), + })); +} + +// New: outright failure (non-zero exit, NOT a timeout) — the second of +// W020's two scan-level conditions. +function mockGitWorktreeListFailed(t) { + t.mock.method(childProcess, 'spawnSync', () => ({ + status: 1, + stdout: '', + stderr: 'fatal: some git error', + signal: null, + error: null, + })); +} + +// Builds `git worktree list --porcelain` output for the given paths, in +// order. Entry 0 is always treated as "the main worktree" by +// `listLinkedWorktreePaths`'s own `.slice(1)` and dropped before any +// finding is computed — mirrors the exact block shape used by +// tests/worktree-safety.test.cjs's inspectWorktreeHealth fixtures. +function buildPorcelain(paths) { + const lines = []; + paths.forEach((p, i) => { + lines.push(`worktree ${p}`); + lines.push(`HEAD ${'a'.repeat(40)}`); + lines.push(`branch refs/heads/${i === 0 ? 'main' : `feat-${i}`}`); + lines.push(''); + }); + return lines.join('\n'); +} + +// ─── RULES shape ──────────────────────────────────────────────────────────── + +describe('RULES (worktree-health group)', () => { + test('exports exactly 3 rules: W020, W017, W027', () => { + assert.deepEqual(RULES.map((r) => r.code).sort(), ['W017', 'W020', 'W027']); + }); + + test('all three are severity WARNING', () => { + assert.equal(ruleFor('W020').severity, SEVERITY.WARNING); + assert.equal(ruleFor('W017').severity, SEVERITY.WARNING); + assert.equal(ruleFor('W027').severity, SEVERITY.WARNING); + }); +}); + +// ─── W020 — worktree health scan itself is degraded ──────────────────────── + +describe('W020 — worktree health scan degraded', () => { + test('fires the combined scan-degraded message when git worktree list times out', (t) => { + const cwd = createTempDir('gsd-3309-w020-timeout-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + mockGitWorktreeListTimeout(t); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W020').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'W020', + severity: SEVERITY.WARNING, + message: + 'Worktree health check degraded: git worktree list timed out or failed — orphan/stale worktrees could not be inspected', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Run: git worktree list --porcelain to diagnose; check for .git/index.lock, a hung git process, or repository permissions', + }, + }, + }); + }); + + // GAP (documented in the rule module's own header comment, gap 1): + // `buildWorktreeHealthField` discards `inspectWorktreeHealth`'s `reason` + // field ('git_timed_out' vs 'git_list_failed'), so the snapshot alone + // cannot distinguish a timeout from an outright failure. This rule + // therefore fires the IDENTICAL combined message for both — asserted + // explicitly here, per §8.5 fixture-proof, rather than left undocumented. + test('GAP: fires the SAME combined message when git worktree list fails outright (not a timeout) — scope alone cannot distinguish', (t) => { + const cwd = createTempDir('gsd-3309-w020-failed-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + mockGitWorktreeListFailed(t); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W020').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.equal( + diagnostics[0].message, + 'Worktree health check degraded: git worktree list timed out or failed — orphan/stale worktrees could not be inspected', + ); + }); + + test('fires once per unverified finding — exact port of verify.cts:2256-2263', (t) => { + const cwd = createTempDir('gsd-3309-w020-unverified-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const unverifiedPath = path.join(cwd, 'wt-unverified'); + fs.mkdirSync(unverifiedPath, { recursive: true }); // existsSync must be true + + const originalStatSync = fs.statSync; + t.mock.method(fs, 'statSync', function mockedStatSync(p, ...rest) { + if (p === unverifiedPath) { + throw Object.assign(new Error('EACCES: permission denied'), { code: 'EACCES' }); + } + return originalStatSync.call(fs, p, ...rest); + }); + + mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', unverifiedPath])); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W020').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'W020', + severity: SEVERITY.WARNING, + message: `Worktree health check degraded: could not stat ${unverifiedPath} — presence/staleness could not be verified`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'Check filesystem permissions on the worktree path, or investigate why statSync failed for it' }, + }, + }); + }); + + test('does not fire when the scan succeeds and no finding is unverified', (t) => { + const cwd = createTempDir('gsd-3309-w020-clean-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo'])); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W020').check(snapshot), []); + }); +}); + +// ─── W017 — orphan git worktree ───────────────────────────────────────────── + +describe('W017 — orphan git worktree', () => { + test('fires once per orphan finding (path no longer exists on disk)', (t) => { + const cwd = createTempDir('gsd-3309-w017-1-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const orphanPath = path.join(cwd, 'wt-orphan-does-not-exist'); + mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', orphanPath])); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W017').check(snapshot); + + assert.equal(diagnostics.length, 1); + assert.deepEqual(diagnostics[0], { + code: 'W017', + severity: SEVERITY.WARNING, + message: `Orphan git worktree: ${orphanPath} (path no longer exists on disk)`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'git worktree prune' }, + }, + }); + }); + + test('does not fire for stale or unverified findings — isolates from W020/W027', (t) => { + const cwd = createTempDir('gsd-3309-w017-2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const stalePath = path.join(cwd, 'wt-stale'); + fs.mkdirSync(stalePath, { recursive: true }); + fs.utimesSync(stalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000)); + + mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', stalePath])); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W017').check(snapshot), []); + }); +}); + +// ─── W027 — stale git worktree (NEW, split off pre-migration 'W017') ────── + +describe('W027 — stale git worktree', () => { + test('fires once per stale finding, message carries the interpolated command, args.command is a static template', (t) => { + const cwd = createTempDir('gsd-3309-w027-1-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const stalePath = path.join(cwd, 'wt-stale'); + fs.mkdirSync(stalePath, { recursive: true }); + fs.utimesSync(stalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000)); + + mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', stalePath])); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W027').check(snapshot); + + assert.equal(diagnostics.length, 1); + const d = diagnostics[0]; + assert.equal(d.code, 'W027'); + assert.equal(d.severity, SEVERITY.WARNING); + assert.ok( + d.message.startsWith(`Stale git worktree: ${stalePath} (last modified `), + `message must start with the stale-worktree prefix and path: ${d.message}`, + ); + assert.ok( + d.message.endsWith(`minutes ago). Run: git worktree remove ${stalePath} --force`), + `message must end with the interpolated remove command: ${d.message}`, + ); + assert.deepEqual(d.remedy, { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { command: 'git worktree remove --force' }, + }); + }); + + test('does not fire for orphan or unverified findings — isolates from W017/W020', (t) => { + const cwd = createTempDir('gsd-3309-w027-2-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const orphanPath = path.join(cwd, 'wt-orphan-does-not-exist'); + mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', orphanPath])); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(ruleFor('W027').check(snapshot), []); + }); + + // GAP (documented in the rule module's own header comment, gap 2): the + // pre-migration `process.cwd()` exclusion of the ACTIVE session's own + // worktree cannot be reproduced here — `Rule.check(snapshot)` has no + // ambient cwd access, and `PlanningSnapshot` carries no "which entry is + // the active worktree" field. This test recreates the real-world shape the + // module doc calls out: the active session's cwd is a LINKED (non-first) + // `git worktree list` entry, not the main repo root — `buildPlanningSnapshot(cwd)` + // is called with `cwd` itself listed as entry index 1 (not the dropped + // index-0 "main" entry) and made stale. W027 fires for it anyway, + // demonstrating the gap rather than silently passing. + test('GAP: fires for the active session\'s own (stale) worktree — no cwd-based exclusion is possible from snapshot alone', (t) => { + const cwd = createTempDir('gsd-3309-w027-3-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + fs.utimesSync(cwd, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000)); + + // Entry 0 is a fake "main repo" (dropped by listLinkedWorktreePaths's own + // .slice(1)); entry 1 is cwd itself — the active session's worktree. + mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', cwd])); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W027').check(snapshot); + + assert.equal(diagnostics.length, 1, 'the active worktree is NOT excluded — this is the documented gap'); + assert.equal(diagnostics[0].code, 'W027'); + assert.ok(diagnostics[0].message.includes(cwd)); + }); +}); From c469aeeacd991a5655b76b5c36b09b3232ac9d3f Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:37:42 -0400 Subject: [PATCH 13/35] refactor(#3309): add milestone-archive-hygiene health-diagnostic rules MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W018, W019 — MILESTONES.md archive completeness and unrecognized root .md file checks, migrated onto the frozen rule table per ADR-3180 §8.2. --- .../milestone-archive-hygiene.cts | 108 ++++++++ .../milestone-archive-hygiene.test.cjs | 231 ++++++++++++++++++ 2 files changed, 339 insertions(+) create mode 100644 src/health-diagnostic-rules/milestone-archive-hygiene.cts create mode 100644 tests/health-diagnostic-rules/milestone-archive-hygiene.test.cjs diff --git a/src/health-diagnostic-rules/milestone-archive-hygiene.cts b/src/health-diagnostic-rules/milestone-archive-hygiene.cts new file mode 100644 index 000000000..58f617296 --- /dev/null +++ b/src/health-diagnostic-rules/milestone-archive-hygiene.cts @@ -0,0 +1,108 @@ +/** + * Health Diagnostic — Milestone archive + root hygiene rules (Phase 11, + * #3309, ADR-3180 §8.2/§8.3/§8.5). + * + * Group: "Milestone archive + root hygiene" (design doc, "Rule table + * organization" table) — W018, W019. + * + * Ported behavior-preserving from `cmdValidateHealth` + * (`src/verify.cts:2301-2354`), the exact call sites for W018/W019. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * ADR-457 build-at-publish: source in + * src/health-diagnostic-rules/milestone-archive-hygiene.cts, compiled to + * gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.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; + +// eslint-disable-next-line @typescript-eslint/no-require-imports +import healthDiagnosticMod = require('../health-diagnostic.cjs'); +const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +type Diagnostic = healthDiagnosticMod.Diagnostic; +type Rule = healthDiagnosticMod.Rule; + +import { isCanonicalPlanningFile } from '../artifacts.cjs'; + +// ─── W018 — MILESTONES.md missing archived milestone(s) (verify.cts:2301-2335) ── +// +// Condition: `snapshot.milestoneArchiveStatus.value.archivedVersions` (list of +// versions with a `.planning/milestones/-ROADMAP.md` snapshot file) minus +// `.documentedVersions` (list of `## ` headings already present in +// MILESTONES.md) — versions present in the archive but not documented in the +// registry. ONE aggregate `Diagnostic` listing every missing version, exactly +// mirroring the original's single `addIssue` call +// (`verify.cts:2321-2330`, `` `MILESTONES.md missing ${missingFromRegistry.length} +// archived milestone(s): ${missingFromRegistry.join(', ')}` ``) — the original +// computes the FULL list first (`missingFromRegistry`), then fires exactly one +// `addIssue` after the loop, not once per version. No diagnostic at all if the +// archive dir has zero recognized `-ROADMAP.md` snapshots (mirrors the +// original's `if (archivedVersions.length > 0)` guard) or if nothing is +// missing. + +function checkW018(snapshot: PlanningSnapshot): Diagnostic[] { + const { archivedVersions, documentedVersions } = snapshot.milestoneArchiveStatus.value; + if (archivedVersions.length === 0) return []; + + const documented = new Set(documentedVersions); + const missingFromRegistry = archivedVersions.filter((ver) => !documented.has(ver)); + if (missingFromRegistry.length === 0) return []; + + return [ + { + code: 'W018', + severity: SEVERITY.WARNING, + message: `MILESTONES.md missing ${missingFromRegistry.length} archived milestone(s): ${missingFromRegistry.join(', ')}`, + remedy: { + action: REMEDY_ACTION.BACKFILL_MILESTONES, + risk: REMEDY_RISK.NONE, + args: {}, + }, + }, + ]; +} + +// ─── W019 — Unrecognized .planning/ root file (verify.cts:2337-2354) ─────── +// +// Condition: for each filename in `snapshot.planningRootFiles.value` ending in +// `.md`, call `isCanonicalPlanningFile(filename)` (bare basename, not a path — +// `src/artifacts.cts:43`); flag any that return false. One `Diagnostic` PER +// unrecognized file (array return), mirroring the original's `addIssue` call +// INSIDE the `for` loop (`verify.cts:2343-2349`) — unlike W018 this is not an +// aggregate. Fix text copied verbatim from `verify.cts:2347`. + +function checkW019(snapshot: PlanningSnapshot): Diagnostic[] { + const diagnostics: Diagnostic[] = []; + for (const filename of snapshot.planningRootFiles.value) { + if (!filename.endsWith('.md')) continue; + if (isCanonicalPlanningFile(filename)) continue; + diagnostics.push({ + code: 'W019', + severity: SEVERITY.WARNING, + message: `Unrecognized .planning/ file: ${filename} — not a canonical GSD artifact`, + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Move to .planning/milestones/ archive subdir or delete if stale. See templates/README.md for the canonical artifact list.', + }, + }, + }); + } + return diagnostics; +} + +// ─── Exports ──────────────────────────────────────────────────────────────── + +const RULES: Rule[] = [ + { code: 'W018', severity: SEVERITY.WARNING, check: checkW018 }, + { code: 'W019', severity: SEVERITY.WARNING, check: checkW019 }, +]; + +export = { RULES }; diff --git a/tests/health-diagnostic-rules/milestone-archive-hygiene.test.cjs b/tests/health-diagnostic-rules/milestone-archive-hygiene.test.cjs new file mode 100644 index 000000000..ebc6c90bf --- /dev/null +++ b/tests/health-diagnostic-rules/milestone-archive-hygiene.test.cjs @@ -0,0 +1,231 @@ +'use strict'; + +/** + * Tests for `src/health-diagnostic-rules/milestone-archive-hygiene.cts` + * (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) — W018, W019. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * + * Fixture provenance (CONTRIBUTING.md / repo rule): every case builds a real + * `.planning/` tree in a temp dir and runs it through the REAL compiled + * `buildPlanningSnapshot` (`src/planning-snapshot.cts`) — no hand-built + * `PlanningSnapshot` mocks. W018's fixture is a mechanical mutation of a real + * MILESTONES.md shape (one version's `## ` entry deliberately + * omitted while its archive snapshot file is present). W019's fixture is a + * real stray `.md` file dropped into `.planning/` root alongside the three + * canonical `.md` files (PROJECT.md/ROADMAP.md/STATE.md), confirming those + * three do not false-positive. + * + * TDD RED: `src/health-diagnostic-rules/milestone-archive-hygiene.cts` does + * not exist yet at the start of this batch — this file's + * `require('../../gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs')` + * throws MODULE_NOT_FOUND until this batch's implementation lands. + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('../helpers.cjs'); + +const { buildPlanningSnapshot } = require('../../gsd-core/bin/lib/planning-snapshot.cjs'); +const { RULES } = require('../../gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs'); +const { REMEDY_ACTION, REMEDY_RISK, SEVERITY } = require('../../gsd-core/bin/lib/health-diagnostic.cjs'); + +const checkW018 = RULES.find((r) => r.code === 'W018').check; +const checkW019 = RULES.find((r) => r.code === 'W019').check; + +function planningDirOf(cwd) { + return path.join(cwd, '.planning'); +} + +function writeFile(cwd, relPath, content) { + const full = path.join(cwd, relPath); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content); +} + +function writeMinimalRoadmap(cwd) { + writeFile(cwd, '.planning/ROADMAP.md', ['## v1.0 Current 🚧', '', '### Phase 1: Foo', ''].join('\n')); +} + +// ─── W018 — MILESTONES.md missing archived milestone(s) ──────────────────── + +describe('W018 — archived milestone snapshot not documented in MILESTONES.md', () => { + test('fires ONE aggregate diagnostic listing all missing versions, not one per version', () => { + const cwd = createTempDir('gsd-w018-'); + try { + writeMinimalRoadmap(cwd); + // Real shape, mechanically mutated: MILESTONES.md documents v1.0 but is + // MISSING the v0.9 entry, while the archive dir has snapshot files for + // BOTH v0.9 and v1.0. + writeFile(cwd, '.planning/MILESTONES.md', ['## v1.0', '', 'Shipped.', ''].join('\n')); + writeFile(cwd, '.planning/milestones/v0.9-ROADMAP.md', '## v0.9 Archived\n'); + writeFile(cwd, '.planning/milestones/v1.0-ROADMAP.md', '## v1.0 Archived\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(snapshot.milestoneArchiveStatus.value.archivedVersions.sort(), ['v0.9', 'v1.0']); + assert.deepEqual(snapshot.milestoneArchiveStatus.value.documentedVersions, ['v1.0']); + + const diagnostics = checkW018(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'W018'); + assert.equal(diagnostics[0].severity, SEVERITY.WARNING); + assert.equal( + diagnostics[0].message, + 'MILESTONES.md missing 1 archived milestone(s): v0.9', + ); + assert.deepEqual(diagnostics[0].remedy, { + action: REMEDY_ACTION.BACKFILL_MILESTONES, + risk: REMEDY_RISK.NONE, + args: {}, + }); + } finally { + cleanup(cwd); + } + }); + + test('aggregates MULTIPLE missing versions into one message, not one diagnostic each', () => { + const cwd = createTempDir('gsd-w018-multi-'); + try { + writeMinimalRoadmap(cwd); + // MILESTONES.md documents nothing at all; two archive snapshots exist. + writeFile(cwd, '.planning/MILESTONES.md', ''); + writeFile(cwd, '.planning/milestones/v0.9-ROADMAP.md', '## v0.9 Archived\n'); + writeFile(cwd, '.planning/milestones/v1.0-ROADMAP.md', '## v1.0 Archived\n'); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = checkW018(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal( + diagnostics[0].message, + 'MILESTONES.md missing 2 archived milestone(s): v0.9, v1.0', + ); + } finally { + cleanup(cwd); + } + }); + + test('does not fire when every archived version is documented', () => { + const cwd = createTempDir('gsd-w018-clean-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/MILESTONES.md', ['## v0.9', '## v1.0', ''].join('\n')); + writeFile(cwd, '.planning/milestones/v0.9-ROADMAP.md', '## v0.9 Archived\n'); + writeFile(cwd, '.planning/milestones/v1.0-ROADMAP.md', '## v1.0 Archived\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(checkW018(snapshot), []); + } finally { + cleanup(cwd); + } + }); + + test('does not fire when the archive dir has zero recognized -ROADMAP.md snapshots', () => { + const cwd = createTempDir('gsd-w018-noarchive-'); + try { + writeMinimalRoadmap(cwd); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(snapshot.milestoneArchiveStatus.value.archivedVersions, []); + assert.deepEqual(checkW018(snapshot), []); + } finally { + cleanup(cwd); + } + }); +}); + +// ─── W019 — Unrecognized .planning/ root file ─────────────────────────────── + +describe('W019 — unrecognized .planning/ root file', () => { + test('fires one diagnostic for a genuinely stray .md file at .planning/ root', () => { + const cwd = createTempDir('gsd-w019-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/NOTES.md', '# scratch notes\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.ok(snapshot.planningRootFiles.value.includes('NOTES.md')); + + const diagnostics = checkW019(snapshot); + assert.equal(diagnostics.length, 1); + assert.equal(diagnostics[0].code, 'W019'); + assert.equal(diagnostics[0].severity, SEVERITY.WARNING); + assert.equal( + diagnostics[0].message, + 'Unrecognized .planning/ file: NOTES.md — not a canonical GSD artifact', + ); + assert.deepEqual(diagnostics[0].remedy, { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Move to .planning/milestones/ archive subdir or delete if stale. See templates/README.md for the canonical artifact list.', + }, + }); + } finally { + cleanup(cwd); + } + }); + + test('fires one diagnostic PER unrecognized file when multiple stray files exist', () => { + const cwd = createTempDir('gsd-w019-multi-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/NOTES.md', '# scratch\n'); + writeFile(cwd, '.planning/SCRATCH.md', '# scratch2\n'); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = checkW019(snapshot); + assert.equal(diagnostics.length, 2); + assert.deepEqual(diagnostics.map((d) => d.code), ['W019', 'W019']); + assert.deepEqual( + diagnostics.map((d) => d.message).sort(), + [ + 'Unrecognized .planning/ file: NOTES.md — not a canonical GSD artifact', + 'Unrecognized .planning/ file: SCRATCH.md — not a canonical GSD artifact', + ], + ); + } finally { + cleanup(cwd); + } + }); + + test('PROJECT.md, ROADMAP.md, and STATE.md do NOT false-positive as W019 findings', () => { + const cwd = createTempDir('gsd-w019-canonical-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/PROJECT.md', '# Project\n'); + writeFile( + cwd, + '.planning/STATE.md', + ['---', 'status: in-progress', '---', ''].join('\n'), + ); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual( + snapshot.planningRootFiles.value.filter((f) => f.endsWith('.md')).sort(), + ['PROJECT.md', 'ROADMAP.md', 'STATE.md'], + ); + assert.deepEqual(checkW019(snapshot), []); + } finally { + cleanup(cwd); + } + }); + + test('non-.md root files are never considered by the rule (loop skips them before the predicate)', () => { + const cwd = createTempDir('gsd-w019-nonmd-'); + try { + writeMinimalRoadmap(cwd); + writeFile(cwd, '.planning/config.json', '{}'); + writeFile(cwd, '.planning/random.txt', 'not markdown\n'); + + const snapshot = buildPlanningSnapshot(cwd); + assert.deepEqual(checkW019(snapshot), []); + } finally { + cleanup(cwd); + } + }); +}); From e0021a2fed7035e3618ee37d10a74530e006a103 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:46:20 -0400 Subject: [PATCH 14/35] refactor(#3309): extract health-diagnostic-types leaf module, wire RULES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wiring all 8 rule-group files into health-diagnostic.cts's RULES array created a genuine CJS circular dependency: each group file required health-diagnostic.cjs back for the shared enums, and health-diagnostic.cjs now required the group files forward, so the enums were undefined mid-load (destructuring health-diagnostic.cjs's still-unassigned exports). Fixes it by splitting the enums/types (SEVERITY, REMEDY_ACTION, REMEDY_RISK, Remedy, Diagnostic, Rule) into a dependency-free leaf module, health-diagnostic-types.cts, that both sides import instead of each other. health-diagnostic.cts re-exports the enums for existing consumers. RULES is now the real concatenation of all 8 groups (31 codes — E001 intentionally stays a pre-check outside the table). --- .gitignore | 1 + CONTEXT.md | 3 + docs/INVENTORY-MANIFEST.json | 1 + docs/INVENTORY.md | 1 + eslint.config.mjs | 1 + src/health-diagnostic-rules/agent-install.cts | 4 +- .../config-validation.cts | 4 +- .../milestone-archive-hygiene.cts | 2 +- .../phase-structure.cts | 2 +- .../roadmap-disk-consistency.cts | 2 +- .../root-existence.cts | 2 +- .../state-consistency.cts | 2 +- .../worktree-health.cts | 2 +- src/health-diagnostic-types.cts | 104 +++++++++++++++++ src/health-diagnostic.cts | 105 ++++++++---------- 15 files changed, 167 insertions(+), 69 deletions(-) create mode 100644 src/health-diagnostic-types.cts diff --git a/.gitignore b/.gitignore index 0cb4f6a17..348012479 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-types.cjs /gsd-core/bin/lib/health-diagnostic.cjs /gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs /gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs diff --git a/CONTEXT.md b/CONTEXT.md index fd2ccf6a4..3ac7e02ff 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 Types Module +Leaf module owning the `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types shared between the Health Diagnostic Module (the evaluator) and the Health Diagnostic Rule Groups (the eight rule-group files it concatenates). Split out of `src/health-diagnostic.cts` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) to break a CJS circular dependency: the evaluator must `require()` every rule-group file to populate `RULES`, and every rule-group file needs these enums/types — if the rule-group files required the evaluator back, the require cycle would resolve `module.exports` before it is assigned. This leaf has no runtime dependency on either side of that cycle. Source of truth: `gsd-core/bin/lib/health-diagnostic-types.cjs` (generated from `src/health-diagnostic-types.cts`). + ### 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`. diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 03e1b26dc..42a7a7289 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-types.cjs", "health-diagnostic.cjs", "hook-bus.cjs", "host-integration-sdk.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index ed9f2bac3..6ed348057 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -495,6 +495,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-types.cjs` | Shared, dependency-free `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types for `validate health` — split out of `health-diagnostic.cjs` so its rule-group files can depend on the enums/types without a CJS circular require back into the evaluator (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | | `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 | diff --git a/eslint.config.mjs b/eslint.config.mjs index 5b24e16ab..21a073cb8 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-types.cjs', 'gsd-core/bin/lib/health-diagnostic.cjs', 'gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs', 'gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs', diff --git a/src/health-diagnostic-rules/agent-install.cts b/src/health-diagnostic-rules/agent-install.cts index 986b495f3..0e27aa758 100644 --- a/src/health-diagnostic-rules/agent-install.cts +++ b/src/health-diagnostic-rules/agent-install.cts @@ -24,8 +24,8 @@ * ("Rule table organization" — Agent installation group) */ -// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic.cjs is an export= CommonJS module -import healthDiagnosticMod = require('../health-diagnostic.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic-types.cjs is an export= CommonJS module +import healthDiagnosticMod = require('../health-diagnostic-types.cjs'); const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; type Rule = healthDiagnosticMod.Rule; type Diagnostic = healthDiagnosticMod.Diagnostic; diff --git a/src/health-diagnostic-rules/config-validation.cts b/src/health-diagnostic-rules/config-validation.cts index 3c1e2424a..f4bad1f2f 100644 --- a/src/health-diagnostic-rules/config-validation.cts +++ b/src/health-diagnostic-rules/config-validation.cts @@ -54,8 +54,8 @@ import type planningSnapshotMod = require('../planning-snapshot.cjs'); type PlanningSnapshot = ReturnType; -// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic.cjs is an export= CommonJS module -import healthDiagnosticMod = require('../health-diagnostic.cjs'); +// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic-types.cjs is an export= CommonJS module +import healthDiagnosticMod = require('../health-diagnostic-types.cjs'); const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; type Diagnostic = healthDiagnosticMod.Diagnostic; type Remedy = healthDiagnosticMod.Remedy; diff --git a/src/health-diagnostic-rules/milestone-archive-hygiene.cts b/src/health-diagnostic-rules/milestone-archive-hygiene.cts index 58f617296..84658425b 100644 --- a/src/health-diagnostic-rules/milestone-archive-hygiene.cts +++ b/src/health-diagnostic-rules/milestone-archive-hygiene.cts @@ -22,7 +22,7 @@ import type planningSnapshotMod = require('../planning-snapshot.cjs'); type PlanningSnapshot = ReturnType; // eslint-disable-next-line @typescript-eslint/no-require-imports -import healthDiagnosticMod = require('../health-diagnostic.cjs'); +import healthDiagnosticMod = require('../health-diagnostic-types.cjs'); const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; type Diagnostic = healthDiagnosticMod.Diagnostic; type Rule = healthDiagnosticMod.Rule; diff --git a/src/health-diagnostic-rules/phase-structure.cts b/src/health-diagnostic-rules/phase-structure.cts index 8d8667e31..43937141d 100644 --- a/src/health-diagnostic-rules/phase-structure.cts +++ b/src/health-diagnostic-rules/phase-structure.cts @@ -44,7 +44,7 @@ import type planningSnapshotMod = require('../planning-snapshot.cjs'); type PlanningSnapshot = ReturnType; // eslint-disable-next-line @typescript-eslint/no-require-imports -import healthDiagnosticMod = require('../health-diagnostic.cjs'); +import healthDiagnosticMod = require('../health-diagnostic-types.cjs'); const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; type Diagnostic = healthDiagnosticMod.Diagnostic; type Rule = healthDiagnosticMod.Rule; diff --git a/src/health-diagnostic-rules/roadmap-disk-consistency.cts b/src/health-diagnostic-rules/roadmap-disk-consistency.cts index 76028977e..937c7ae36 100644 --- a/src/health-diagnostic-rules/roadmap-disk-consistency.cts +++ b/src/health-diagnostic-rules/roadmap-disk-consistency.cts @@ -87,7 +87,7 @@ import type planningSnapshotMod = require('../planning-snapshot.cjs'); type PlanningSnapshot = ReturnType; // eslint-disable-next-line @typescript-eslint/no-require-imports -import healthDiagnosticMod = require('../health-diagnostic.cjs'); +import healthDiagnosticMod = require('../health-diagnostic-types.cjs'); const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; type Diagnostic = healthDiagnosticMod.Diagnostic; type Rule = healthDiagnosticMod.Rule; diff --git a/src/health-diagnostic-rules/root-existence.cts b/src/health-diagnostic-rules/root-existence.cts index 3e991cf53..5e6f93dbe 100644 --- a/src/health-diagnostic-rules/root-existence.cts +++ b/src/health-diagnostic-rules/root-existence.cts @@ -22,7 +22,7 @@ import type planningSnapshotMod = require('../planning-snapshot.cjs'); type PlanningSnapshot = ReturnType; // eslint-disable-next-line @typescript-eslint/no-require-imports -import healthDiagnosticMod = require('../health-diagnostic.cjs'); +import healthDiagnosticMod = require('../health-diagnostic-types.cjs'); const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; type Diagnostic = healthDiagnosticMod.Diagnostic; type Rule = healthDiagnosticMod.Rule; diff --git a/src/health-diagnostic-rules/state-consistency.cts b/src/health-diagnostic-rules/state-consistency.cts index 240e40895..830805dc6 100644 --- a/src/health-diagnostic-rules/state-consistency.cts +++ b/src/health-diagnostic-rules/state-consistency.cts @@ -42,7 +42,7 @@ // — unlike `health-diagnostic.cts`'s own type-only import of // `planning-snapshot.cjs`, which never touches that module's runtime values. // eslint-disable-next-line @typescript-eslint/no-require-imports -- export= CommonJS module -import healthDiagnosticMod = require('../health-diagnostic.cjs'); +import healthDiagnosticMod = require('../health-diagnostic-types.cjs'); const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; type Rule = healthDiagnosticMod.Rule; type Diagnostic = healthDiagnosticMod.Diagnostic; diff --git a/src/health-diagnostic-rules/worktree-health.cts b/src/health-diagnostic-rules/worktree-health.cts index 05665915b..d96e3d66c 100644 --- a/src/health-diagnostic-rules/worktree-health.cts +++ b/src/health-diagnostic-rules/worktree-health.cts @@ -64,7 +64,7 @@ import type planningSnapshotMod = require('../planning-snapshot.cjs'); type PlanningSnapshot = ReturnType; // eslint-disable-next-line @typescript-eslint/no-require-imports -import healthDiagnosticMod = require('../health-diagnostic.cjs'); +import healthDiagnosticMod = require('../health-diagnostic-types.cjs'); const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; type Diagnostic = healthDiagnosticMod.Diagnostic; type Rule = healthDiagnosticMod.Rule; diff --git a/src/health-diagnostic-types.cts b/src/health-diagnostic-types.cts new file mode 100644 index 000000000..e54d90a4d --- /dev/null +++ b/src/health-diagnostic-types.cts @@ -0,0 +1,104 @@ +/** + * Health Diagnostic Types — shared, dependency-free rule-table types (Phase + * 11, #3309, ADR-3180 §8.2/§8.3/§8.5). + * + * Split out from `src/health-diagnostic.cts` to break a CJS circular + * dependency between the evaluator and its own rule-group files + * (`src/health-diagnostic-rules/*.cts`): those files need the frozen + * `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and the `Diagnostic`/ + * `Remedy`/`Rule` shapes, but the evaluator (`health-diagnostic.cts`) also + * needs to `require()` every rule-group file to populate its `RULES` array — + * a rule-group file requiring `health-diagnostic.cjs` back, mid-load, reads + * `module.exports` before it is assigned, so the destructured enums come + * back `undefined`. This leaf has NO runtime dependency on anything in that + * cycle, so both sides can depend on it directly. + * + * 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-types.cts, + * compiled to gsd-core/bin/lib/health-diagnostic-types.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 +} + +// ─── Exports ──────────────────────────────────────────────────────────────── + +const healthDiagnosticTypes = { + SEVERITY, + REMEDY_ACTION, + REMEDY_RISK, +}; + +// 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-types.cjs')` can reference +// the types as `x.Severity`, `x.RemedyAction`, etc. +// eslint-disable-next-line @typescript-eslint/no-namespace +declare namespace healthDiagnosticTypes { + export { Severity, RemedyAction, RemedyRisk, Remedy, Diagnostic, Rule }; +} + +export = healthDiagnosticTypes; diff --git a/src/health-diagnostic.cts b/src/health-diagnostic.cts index aac125121..430d42b8d 100644 --- a/src/health-diagnostic.cts +++ b/src/health-diagnostic.cts @@ -30,68 +30,55 @@ 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 -} +// 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 ───────────────────────────────────────────────────────────── -// 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[] = []; +// 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, +]; // ─── Evaluator ────────────────────────────────────────────────────────────── From acc1a7abd6cd3e051eaf9ef949dbe86a6b8ac4f1 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 01:53:20 -0400 Subject: [PATCH 15/35] feat(#3309): add health-diagnostic rule-table lint guard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enforces ADR-3180 §8.2's 1:1 rule-code invariant (every code unique, every severity a property of the Rule) and §8.5's fixture-proof invariant (every code has a describe()/test() block naming it, verified statically against tests/health-diagnostic-rules/*.test.cjs and tests/health-diagnostic.test.cjs) for the new RULES table. Adapted from the design doc's original plan of separate tests/fixtures/health-diagnostic/.* files: implementation used inline temp-dir fixtures instead (mirrors tests/planning-snapshot.test.cjs), so coverage is checked statically against test-file structure, mirroring lint-fix-has-regression-test.cjs's house style. Wired into lint:ci adjacent to lint-planning-snapshot-bypass-drift.cjs, its closest sibling. Passes clean against the real tree: 31 codes, all unique, all covered. --- package.json | 2 +- scripts/lint-health-diagnostic-rule-table.cjs | 250 ++++++++++++++++++ ...lint-health-diagnostic-rule-table.test.cjs | 164 ++++++++++++ 3 files changed, 415 insertions(+), 1 deletion(-) create mode 100644 scripts/lint-health-diagnostic-rule-table.cjs create mode 100644 tests/lint-health-diagnostic-rule-table.test.cjs diff --git a/package.json b/package.json index 0963f9798..2ca21c99a 100644 --- a/package.json +++ b/package.json @@ -114,7 +114,7 @@ "lint:table-schema-drift": "node scripts/lint-table-schema-drift.cjs", "lint:frontmatter-scalar-broad-grep": "node scripts/lint-frontmatter-scalar-broad-grep.cjs", "lint:removed-but-needed": "node scripts/lint-removed-but-needed.cjs", - "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs", + "lint:ci": "npm run lint && npm run lint:skill-deps && npm run lint:generated-sync && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs && node scripts/lint-emitted-drift-ack.cjs && node scripts/lint-portable-timeout.cjs && node scripts/validate-registry.cjs && node scripts/lint-table-schema-drift.cjs && node scripts/lint-fix-has-regression-test.cjs && node scripts/lint-example-parser-parity.cjs && node scripts/lint-docs-command-form.cjs && node scripts/lint-plan-count-drift.cjs && node scripts/lint-milestone-window-drift.cjs && node scripts/lint-phase-enumeration-drift.cjs && node scripts/lint-planning-prompt-drift.cjs && node scripts/lint-completion-ratio-drift.cjs && node scripts/lint-state-field-drift.cjs && node scripts/lint-completion-predicate-drift.cjs && node scripts/lint-planning-snapshot-bypass-drift.cjs && node scripts/lint-health-diagnostic-rule-table.cjs && node scripts/lint-frontmatter-scalar-broad-grep.cjs && node scripts/lint-removed-but-needed.cjs", "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs", "lint:regression-names": "node scripts/lint-regression-test-names.cjs", "lint:descriptions": "node scripts/lint-descriptions.cjs", diff --git a/scripts/lint-health-diagnostic-rule-table.cjs b/scripts/lint-health-diagnostic-rule-table.cjs new file mode 100644 index 000000000..7a302868f --- /dev/null +++ b/scripts/lint-health-diagnostic-rule-table.cjs @@ -0,0 +1,250 @@ +#!/usr/bin/env node +'use strict'; + +/** + * lint-health-diagnostic-rule-table.cjs — gate: enforces ADR-3180 §8.2's 1:1 + * rule-code invariant and §8.5's fixture-proof invariant for + * `src/health-diagnostic.cts`'s RULES table (Phase 11, #3309). + * + * ## What this enforces + * + * 1. (§8.2 rule 1 — 1:1 code invariant) Every `rule.code` in RULES (exported + * from the compiled `gsd-core/bin/lib/health-diagnostic.cjs`) is unique, + * and every rule's `severity` is one of `SEVERITY`'s values. The severity + * check exists only to confirm the compiled artifact was not hand-edited + * to bypass the `Rule.severity` required field TypeScript already + * enforces at compile time — "severity is a property of the RULE, never + * the emit call." + * 2. (§8.5 — fixture-proof invariant) Every code in RULES has a paired test: + * the code string (e.g. `'W001'`) appears as a literal AND within a + * `describe(`/`test(` block whose title also names that exact code, in + * one of the health-diagnostic test files + * (`tests/health-diagnostic-rules/*.test.cjs`, + * `tests/health-diagnostic.test.cjs`). A mere comment/string mention + * outside a titled block does not count as coverage. + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * ("The lint guard (§8.2 1:1 invariant + §8.5 fixture proof)"). + * + * ## Deviation from the design doc's original plan + * + * The design doc assumed fixtures would live as separate files at + * `tests/fixtures/health-diagnostic/.*`. That did not happen during + * implementation — all 8 rule-group test files + * (`tests/health-diagnostic-rules/*.test.cjs`) build fixtures INLINE via + * real temp directories (`createTempDir()` from `tests/helpers.cjs`) and a + * real, non-mocked `buildPlanningSnapshot(tmpCwd)` call (see + * `tests/health-diagnostic-rules/root-existence.test.cjs`). This guard + * therefore verifies the fixture-proof invariant STATICALLY against the + * test files' own text — mirroring `scripts/lint-fix-has-regression-test.cjs`'s + * house style — rather than dynamically re-running fixture-building code + * this guard does not own. + */ + +const fs = require('node:fs'); +const path = require('node:path'); +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const REPO_ROOT = path.join(__dirname, '..'); +const COMPILED_MODULE_REL = 'gsd-core/bin/lib/health-diagnostic.cjs'; +const COMPILED_MODULE_PATH = path.join(REPO_ROOT, COMPILED_MODULE_REL); +const TEST_GROUP_DIR = path.join(REPO_ROOT, 'tests', 'health-diagnostic-rules'); +const SKELETON_TEST_FILE = path.join(REPO_ROOT, 'tests', 'health-diagnostic.test.cjs'); + +// Matches `describe(`/`test(`/`it(` calls whose first argument is a string +// literal, capturing that literal as the block's title. Line/regex-based +// (not full AST) per this repo's existing lint-guard house style +// (scripts/lint-planning-snapshot-bypass-drift.cjs's scanCode precedent). +const TITLED_BLOCK_RE = /\b(describe|test|it)\(\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2/g; + +/** + * Load the compiled health-diagnostic module. Throws a clear ExitError + * (not a raw MODULE_NOT_FOUND) if `npm run build:lib` has not run. + */ +function loadCompiledModule(compiledPath = COMPILED_MODULE_PATH) { + if (!fs.existsSync(compiledPath)) { + throw new ExitError( + 2, + `lint-health-diagnostic-rule-table: compiled artifact not found at ${COMPILED_MODULE_REL}.\n` + + 'Run `npm run build:lib` first.', + ); + } + return require(compiledPath); +} + +/** + * §8.2 rule 1 — 1:1 code invariant: every rule.code is unique, and every + * rule's severity is a member of SEVERITY's values. + * + * @param {Array<{code: string, severity: string}>} rules + * @param {Record} severity SEVERITY export (code -> value) + * @returns {{duplicates: Array<{code: string, count: number}>, badSeverities: Array<{code: string, severity: unknown}>}} + */ +function checkOneToOneInvariant(rules, severity) { + const severityValues = new Set(Object.values(severity)); + const counts = new Map(); + const badSeverities = []; + + for (const rule of rules) { + counts.set(rule.code, (counts.get(rule.code) || 0) + 1); + if (!severityValues.has(rule.severity)) { + badSeverities.push({ code: rule.code, severity: rule.severity }); + } + } + + const duplicates = [...counts.entries()] + .filter(([, count]) => count > 1) + .map(([code, count]) => ({ code, count })); + + return { duplicates, badSeverities }; +} + +/** + * Extracts every `describe(`/`test(`/`it(` block title found in `text`. + * + * @param {string} text + * @returns {string[]} + */ +function extractTitledBlocks(text) { + const titles = []; + TITLED_BLOCK_RE.lastIndex = 0; + let match; + while ((match = TITLED_BLOCK_RE.exec(text)) !== null) { + titles.push(match[3]); + } + return titles; +} + +/** + * True iff `code` appears verbatim, as a whole token, inside at least one of + * `titles`. Whole-token match guards against a shorter code accidentally + * substring-matching inside an unrelated longer token. + * + * @param {string} code + * @param {string[]} titles + */ +function codeAppearsInTitle(code, titles) { + const codeRe = new RegExp(`(?:^|[^A-Za-z0-9])${code}(?:$|[^A-Za-z0-9])`); + return titles.some((title) => codeRe.test(`|${title}|`)); +} + +/** + * Locates every health-diagnostic test file this guard scans for §8.5 + * fixture-proof coverage. + * + * @param {string} repoRoot + * @returns {string[]} absolute paths, sorted + */ +function findHealthDiagnosticTestFiles(repoRoot = REPO_ROOT) { + const groupDir = path.join(repoRoot, 'tests', 'health-diagnostic-rules'); + const files = []; + if (fs.existsSync(groupDir)) { + for (const entry of fs.readdirSync(groupDir)) { + if (entry.endsWith('.test.cjs')) { + files.push(path.join(groupDir, entry)); + } + } + } + const skeletonTestFile = path.join(repoRoot, 'tests', 'health-diagnostic.test.cjs'); + if (fs.existsSync(skeletonTestFile)) { + files.push(skeletonTestFile); + } + return files.sort(); +} + +/** + * §8.5 — fixture-proof invariant: for every code in `rules`, confirm at + * least one test file in `testFiles` has a `describe(`/`test(`/`it(` block + * whose title names that exact code. + * + * @param {Array<{code: string}>} rules + * @param {string[]} testFiles absolute paths to *.test.cjs files to scan + * @returns {{uncovered: string[], testFilesScanned: string[]}} + */ +function checkFixtureProofInvariant(rules, testFiles) { + const allTitles = []; + for (const file of testFiles) { + const text = fs.readFileSync(file, 'utf8'); + allTitles.push(...extractTitledBlocks(text)); + } + + const uncovered = []; + for (const rule of rules) { + if (!codeAppearsInTitle(rule.code, allTitles)) { + uncovered.push(rule.code); + } + } + + return { uncovered, testFilesScanned: testFiles }; +} + +function formatRepoRelative(absPath) { + return path.relative(REPO_ROOT, absPath).split(path.sep).join('/'); +} + +function main() { + const { RULES, SEVERITY } = loadCompiledModule(); + + const { duplicates, badSeverities } = checkOneToOneInvariant(RULES, SEVERITY); + + const testFiles = findHealthDiagnosticTestFiles(REPO_ROOT); + const { uncovered } = checkFixtureProofInvariant(RULES, testFiles); + + const problems = []; + + if (duplicates.length > 0) { + const list = duplicates.map((d) => ` ${d.code} (${d.count} occurrences)`).join('\n'); + problems.push( + `§8.2 rule 1 violated: ${duplicates.length} duplicated rule code(s) in RULES ` + + `(${COMPILED_MODULE_REL}):\n${list}\n` + + ' remedy: codes are append-only and 1:1 with a single Rule — rename or remove the duplicate.', + ); + } + + if (badSeverities.length > 0) { + const list = badSeverities + .map((b) => ` ${b.code}: severity=${JSON.stringify(b.severity)}`) + .join('\n'); + problems.push( + `§8.2 rule 3 violated: ${badSeverities.length} rule(s) with a severity not in SEVERITY's values:\n${list}\n` + + ' remedy: severity is a property of the RULE — set it to SEVERITY.ERROR/WARNING/INFO.', + ); + } + + if (uncovered.length > 0) { + const scannedList = testFiles.map(formatRepoRelative).join('\n '); + problems.push( + `§8.5 violated: ${uncovered.length} rule code(s) with no describe()/test() block naming them ` + + `(a comment or bare string mention does not count):\n ${uncovered.join(', ')}\n\n` + + ` Searched these test files:\n ${scannedList}\n\n` + + ' remedy: add or extend a describe()/test() title in the matching ' + + 'tests/health-diagnostic-rules/.test.cjs file so the block title ' + + `names the code verbatim (e.g. describe('${uncovered[0]} — ...', () => { ... })), ` + + 'and drive the rule to fire against a real fixture built via createTempDir() + buildPlanningSnapshot() ' + + '(see tests/health-diagnostic-rules/root-existence.test.cjs).', + ); + } + + if (problems.length > 0) { + throw new ExitError(1, `${problems.join('\n\n')}\n`); + } + + console.log( + `lint-health-diagnostic-rule-table: PASS — ${RULES.length} rule code(s), all unique, ` + + `all severities valid, all covered by a titled test block across ${testFiles.length} test file(s).`, + ); +} + +runMain(main); + +module.exports = { + loadCompiledModule, + checkOneToOneInvariant, + extractTitledBlocks, + codeAppearsInTitle, + findHealthDiagnosticTestFiles, + checkFixtureProofInvariant, + COMPILED_MODULE_PATH, + TEST_GROUP_DIR, + SKELETON_TEST_FILE, +}; diff --git a/tests/lint-health-diagnostic-rule-table.test.cjs b/tests/lint-health-diagnostic-rule-table.test.cjs new file mode 100644 index 000000000..2d1662750 --- /dev/null +++ b/tests/lint-health-diagnostic-rule-table.test.cjs @@ -0,0 +1,164 @@ +'use strict'; + +/** + * Tests for `scripts/lint-health-diagnostic-rule-table.cjs` — the guard + * enforcing ADR-3180 §8.2's 1:1 rule-code invariant and §8.5's fixture-proof + * invariant for `src/health-diagnostic.cts`'s RULES table (Phase 11, #3309). + * + * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md + * ("The lint guard (§8.2 1:1 invariant + §8.5 fixture proof)"). + */ + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); + +const guard = require('../scripts/lint-health-diagnostic-rule-table.cjs'); +const { + checkOneToOneInvariant, + checkFixtureProofInvariant, + findHealthDiagnosticTestFiles, +} = guard; + +const FAKE_SEVERITY = Object.freeze({ ERROR: 'error', WARNING: 'warning', INFO: 'info' }); + +function writeTempTestFile(dir, name, content) { + const full = path.join(dir, name); + fs.writeFileSync(full, content); + return full; +} + +// ─── Check 1 — §8.2 rule 1: 1:1 code invariant ───────────────────────────── + +describe('checkOneToOneInvariant (§8.2 rule 1)', () => { + test('flags a duplicated code', () => { + const rules = [ + { code: 'W001', severity: FAKE_SEVERITY.WARNING }, + { code: 'W002', severity: FAKE_SEVERITY.WARNING }, + { code: 'W001', severity: FAKE_SEVERITY.WARNING }, + ]; + + const { duplicates, badSeverities } = checkOneToOneInvariant(rules, FAKE_SEVERITY); + + assert.deepEqual(duplicates, [{ code: 'W001', count: 2 }]); + assert.deepEqual(badSeverities, []); + }); + + test('passes when every code is unique', () => { + const rules = [ + { code: 'W001', severity: FAKE_SEVERITY.WARNING }, + { code: 'W002', severity: FAKE_SEVERITY.ERROR }, + { code: 'W003', severity: FAKE_SEVERITY.INFO }, + ]; + + const { duplicates, badSeverities } = checkOneToOneInvariant(rules, FAKE_SEVERITY); + + assert.deepEqual(duplicates, []); + assert.deepEqual(badSeverities, []); + }); + + test('flags a severity that is not a member of SEVERITY (hand-edited artifact)', () => { + const rules = [ + { code: 'W001', severity: 'critical' }, + { code: 'W002', severity: FAKE_SEVERITY.WARNING }, + ]; + + const { duplicates, badSeverities } = checkOneToOneInvariant(rules, FAKE_SEVERITY); + + assert.deepEqual(duplicates, []); + assert.deepEqual(badSeverities, [{ code: 'W001', severity: 'critical' }]); + }); +}); + +// ─── Check 2 — §8.5: fixture-proof invariant ─────────────────────────────── + +describe('checkFixtureProofInvariant (§8.5)', () => { + test('flags a code with zero mentions anywhere in the scanned test files', (t) => { + const dir = createTempDir('gsd-lint-hd-rt-nomention-'); + t.after(() => cleanup(dir)); + const file = writeTempTestFile( + dir, + 'fake.test.cjs', + "describe('W001 — something', () => { test('fires', () => {}); });\n", + ); + + const rules = [{ code: 'W001' }, { code: 'W999' }]; + const { uncovered } = checkFixtureProofInvariant(rules, [file]); + + assert.deepEqual(uncovered, ['W999']); + }); + + test('flags a code mentioned only in a comment/string outside any describe/test title', (t) => { + const dir = createTempDir('gsd-lint-hd-rt-comment-only-'); + t.after(() => cleanup(dir)); + const file = writeTempTestFile( + dir, + 'fake.test.cjs', + [ + "// W002 is handled elsewhere, see notes", + "const message = 'refers to W002 in a plain string, not a block title';", + "describe('unrelated block', () => { test('does something', () => {}); });", + '', + ].join('\n'), + ); + + const rules = [{ code: 'W002' }]; + const { uncovered } = checkFixtureProofInvariant(rules, [file]); + + assert.deepEqual(uncovered, ['W002']); + }); + + test('passes a code named in a describe() block title', (t) => { + const dir = createTempDir('gsd-lint-hd-rt-titled-'); + t.after(() => cleanup(dir)); + const file = writeTempTestFile( + dir, + 'fake.test.cjs', + "describe('W003 — some finding', () => { test('fires when absent', () => {}); });\n", + ); + + const rules = [{ code: 'W003' }]; + const { uncovered } = checkFixtureProofInvariant(rules, [file]); + + assert.deepEqual(uncovered, []); + }); + + test('passes a code named in a test()-only title (no wrapping describe)', (t) => { + const dir = createTempDir('gsd-lint-hd-rt-test-only-'); + t.after(() => cleanup(dir)); + const file = writeTempTestFile( + dir, + 'fake.test.cjs', + "test('W010 fires on incomplete agent install', () => {});\n", + ); + + const rules = [{ code: 'W010' }]; + const { uncovered } = checkFixtureProofInvariant(rules, [file]); + + assert.deepEqual(uncovered, []); + }); + + test('passes for a real code (W001) against the real tests/ tree', () => { + const testFiles = findHealthDiagnosticTestFiles(); + assert.ok(testFiles.length > 0, 'expected at least one health-diagnostic test file on disk'); + + const { uncovered } = checkFixtureProofInvariant([{ code: 'W001' }], testFiles); + + assert.deepEqual(uncovered, []); + }); +}); + +// ─── findHealthDiagnosticTestFiles ───────────────────────────────────────── + +describe('findHealthDiagnosticTestFiles', () => { + test('finds every *.test.cjs under tests/health-diagnostic-rules/ plus tests/health-diagnostic.test.cjs', () => { + const files = findHealthDiagnosticTestFiles(); + + assert.ok(files.some((f) => f.endsWith('root-existence.test.cjs'))); + assert.ok(files.some((f) => f.endsWith('state-consistency.test.cjs'))); + assert.ok(files.some((f) => f.endsWith(path.join('tests', 'health-diagnostic.test.cjs')))); + }); +}); From d1760e3c31623d8c221169c95518f36fbb567155 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 02:28:49 -0400 Subject: [PATCH 16/35] refactor(#3309): migrate cmdValidateHealth onto the rule table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../planning-snapshot-bypass-baseline.json | 98 -- scripts/lint-milestone-window-drift.cjs | 41 +- scripts/lint-phase-enumeration-drift.cjs | 15 + scripts/lint-test-file-count.allowlist.json | 5 + src/health-diagnostic.cts | 348 ++++- src/verify.cts | 1149 +++-------------- tests/health-diagnostic.test.cjs | 171 ++- tests/phase-resolution-parity.test.cjs | 13 +- tests/roadmap.test.cjs | 23 +- tests/verify-health.test.cjs | 69 +- tests/verify.test.cjs | 70 + 11 files changed, 820 insertions(+), 1182 deletions(-) diff --git a/scripts/baselines/planning-snapshot-bypass-baseline.json b/scripts/baselines/planning-snapshot-bypass-baseline.json index 87eee1c6d..028e09d4f 100644 --- a/scripts/baselines/planning-snapshot-bypass-baseline.json +++ b/scripts/baselines/planning-snapshot-bypass-baseline.json @@ -1,109 +1,11 @@ { "$comment": "ADR-3180 §8.1 rule 2 ratchet, owned by Phase 11 (#3309). See scripts/lint-planning-snapshot-bypass-drift.cjs. SHRINK-ONLY: entries are removed as cmdValidateHealth migrates onto src/planning-snapshot.cts; new or changed entries fail lint:ci. `count` is the number of byte-identical (file, text) occurrences acknowledged at this site — a run producing fewer fails as a partial migration, more fails as an unacknowledged new copy.", "entries": [ - { - "file": "src/verify.cts", - "text": ".readdirSync(phasesDir, { withFileTypes: true })", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "? fs.readFileSync(milestonesPath, 'utf-8')", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 2 - }, - { - "file": "src/verify.cts", - "text": "const archiveFiles = fs.readdirSync(milestonesArchiveDir);", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "const configRaw = fs.readFileSync(configPath, 'utf-8');", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 4 - }, - { - "file": "src/verify.cts", - "text": "const content = fs.readFileSync(projectPath, 'utf-8');", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "const entries = fs.readdirSync(rootBase, { withFileTypes: true });", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "const rawCfg = fs.readFileSync(configPath, 'utf-8');", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "const researchContent = fs.readFileSync(", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "const roadmapContentFull = fs.readFileSync(roadmapPath, 'utf-8');", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8');", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8');", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 2 - }, { "file": "src/verify.cts", "text": "const stateContent = fs.readFileSync(statePath, 'utf-8');", "derivation": "planning-snapshot-bypass", "owner_issue": "#3309", - "count": 2 - }, - { - "file": "src/verify.cts", - "text": "const stateRaw = fs.readFileSync(statePath, 'utf-8');", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", - "count": 1 - }, - { - "file": "src/verify.cts", - "text": "phaseDirFiles.set(e.name, fs.readdirSync(path.join(phasesDir, e.name)));", - "derivation": "planning-snapshot-bypass", - "owner_issue": "#3309", "count": 1 } ] diff --git a/scripts/lint-milestone-window-drift.cjs b/scripts/lint-milestone-window-drift.cjs index 7d54c0c0d..a1da11fe6 100644 --- a/scripts/lint-milestone-window-drift.cjs +++ b/scripts/lint-milestone-window-drift.cjs @@ -188,23 +188,13 @@ const OWNER_FILE = path.join('src', 'roadmap-parser.cts'); // question `computeMilestoneSectionEnd` answers — so it cannot diverge // from that computation; it answers a narrower, different question this // derivation does not own. -// - verify.cts checkMilestonePrefixMismatches: `sectionRx` ENUMERATES -// every milestone heading in the document to build a list of -// `{version, start, end}` sections (each section's `end` is provisionally -// "rest of document" until the NEXT heading is found, then backfilled) — -// it is answering "what are ALL the milestone sections", to check every -// phase against its OWN enclosing milestone, not "where does THIS ONE -// milestone (the current/asserted one) end" — `computeMilestoneSectionEnd` -// takes a single heading and returns a single boundary; this function -// never calls anything with that shape. (Design brief named this -// `cmdValidateConsistency` — the code actually lives in the sibling -// function `checkMilestonePrefixMismatches`, called from -// `cmdValidateHealth`; `cmdValidateConsistency` itself does not contain -// `sectionRx`. Exempted here under its ACTUAL containing function.) Also: -// `sectionRx` (`/^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim`) -// does not itself carry token (b) as this guard defines it (no -// `(?!Phase` lookahead, no marker-emoji pairing) — this exemption -// currently documents intent rather than suppressing a live match. +// - (Phase 11, #3309: `verify.cts`'s pre-migration `checkMilestonePrefixMismatches` +// — formerly exempted here — was DELETED when `cmdValidateHealth` migrated +// onto the rule table; its `sectionRx` walk relocated verbatim into +// `planning-snapshot.cts`'s `buildRoadmapDeclaredPhasesField`, which needs +// no exemption of its own: like the deleted function, its `sectionRx` +// never carries token (b) as this guard defines it — no `(?!Phase` +// lookahead, no marker-emoji pairing — so it was never a live match.) // - roadmap-parser.cts isMilestoneShippedInRoadmap: composes the heading // quantifier with the shipped/active MARKER check (via // isClosedMilestoneHeading) to answer "is THIS milestone version marked @@ -235,9 +225,22 @@ const OWNER_FILE = path.join('src', 'roadmap-parser.cts'); // source span. It is a named canonical function defining the grammar, // not a copy of it — replacing the third independent re-derivation the // widened guard found at `roadmap.cts:454`. +// - planning-snapshot.cts buildMilestoneArchiveStatusField (Phase 11, +// #3309): its `## ` heading scan reads `MILESTONES.md` — a +// FLAT version registry, not `ROADMAP.md` — asking "which versions does +// the registry already document", never "where does THIS milestone's +// ROADMAP section begin/end" (`computeMilestoneSectionEnd`/ +// `locateMilestoneHeadings`'s own question). A different document, a +// different question; not a re-derivation of ROADMAP windowing. +// - health-diagnostic.cts computeMissingMilestoneVersions (Phase 11, +// #3309): `applyRepairs` is not a `Rule` and is not handed a +// `PlanningSnapshot` (see that file's header comment), so +// `backfillMilestones` recomputes the IDENTICAL `MILESTONES.md` +// heading-membership check `buildMilestoneArchiveStatusField` already +// performs for the W018 rule's read side — same non-ROADMAP-windowing +// question as that function, for the same reason. const FUNCTION_SCOPED_EXEMPTIONS = new Map([ [path.join('src', 'roadmap-command-router.cts'), new Set(['checkW021'])], - [path.join('src', 'verify.cts'), new Set(['checkMilestonePrefixMismatches'])], [ OWNER_FILE, new Set([ @@ -248,6 +251,8 @@ const FUNCTION_SCOPED_EXEMPTIONS = new Map([ 'extractCurrentMilestoneScoped', ]), ], + [path.join('src', 'planning-snapshot.cts'), new Set(['buildMilestoneArchiveStatusField'])], + [path.join('src', 'health-diagnostic.cts'), new Set(['computeMissingMilestoneVersions'])], ]); // Optional `export ` modifier, mirroring `lint-plan-count-drift.cjs`'s diff --git a/scripts/lint-phase-enumeration-drift.cjs b/scripts/lint-phase-enumeration-drift.cjs index 81ea0bb50..20f617d4e 100644 --- a/scripts/lint-phase-enumeration-drift.cjs +++ b/scripts/lint-phase-enumeration-drift.cjs @@ -195,6 +195,20 @@ * spanning every milestone ever shipped — the union is a strict * superset of any one milestone's window by design; scoping the live * half would silently drop history the digest exists to preserve. + * - `src/planning-snapshot.cts` `buildAllPhaseDirNamesField` (Phase 11, + * #3309): the un-windowed twin of `phaseDirs`/`listMilestonePhaseDirs` — + * every directory actually present under the active `phases/` root, + * UNFILTERED by current-milestone-window membership. Backs the migrated + * `cmdValidateHealth`'s W007 rule ("an on-disk phase directory has no + * matching ROADMAP entry"): sourcing that check from the WINDOWED owner + * would make it structurally unable to fire on the exact orphan + * directory it exists to find (an orphan-by-definition can never be a + * member of a set defined as "directories the roadmap already + * declares") — see that field's own doc comment on `PlanningSnapshot` + * for the full, empirically-verified rationale. Same "must see the + * physical set by definition" shape as `collectDiskPhases`/ + * `cmdValidateHealth` above, generalized from a raw `readdirSync` call + * site to a dedicated snapshot-builder function. * * The tree-walk / root-confinement / regex-literal-tokenizer / sanitizer * machinery is SHARED with the sibling drift guards via @@ -270,6 +284,7 @@ const FUNCTION_SCOPED_EXEMPTIONS = new Map([ [path.join('src', 'roadmap-upgrade.cts'), new Set(['computeMigrationPlan'])], [path.join('src', 'smart-entry.cts'), new Set(['detectVerifyFailed'])], [path.join('src', 'roadmap-parser.cts'), new Set(['getMilestonePhaseFilter'])], + [path.join('src', 'planning-snapshot.cts'), new Set(['buildAllPhaseDirNamesField'])], ]); // Optional `export ` modifier, mirroring the sibling guards' function diff --git a/scripts/lint-test-file-count.allowlist.json b/scripts/lint-test-file-count.allowlist.json index 91acc2b97..28f55910c 100644 --- a/scripts/lint-test-file-count.allowlist.json +++ b/scripts/lint-test-file-count.allowlist.json @@ -7,6 +7,7 @@ "config-field-docs.test.cjs", "config-get-default.test.cjs", "config-schema.property.test.cjs", + "config-validation.test.cjs", "config.test.cjs" ], "issue": "TBD" @@ -33,6 +34,7 @@ }, "milestone": { "files": [ + "milestone-archive-hygiene.test.cjs", "milestone-archive.test.cjs", "milestone-helper.test.cjs", "milestone-prefixed-convention.test.cjs", @@ -48,12 +50,14 @@ "phase-completion-single-owner.test.cjs", "phase-dependency-levels.test.cjs", "phase-resolution-parity.test.cjs", + "phase-structure.test.cjs", "phase.test.cjs" ], "issue": "3186" }, "roadmap": { "files": [ + "roadmap-disk-consistency.test.cjs", "roadmap-mode-field.test.cjs", "roadmap-phase-fallback.test.cjs", "roadmap.test.cjs" @@ -83,6 +87,7 @@ "files": [ "state-acquirestatelock-non-eexist.test.cjs", "state-command-cutover.test.cjs", + "state-consistency.test.cjs", "state-field-drift.test.cjs", "state-prune.test.cjs", "state-rebuild-cli.test.cjs", diff --git a/src/health-diagnostic.cts b/src/health-diagnostic.cts index 430d42b8d..2410426c9 100644 --- a/src/health-diagnostic.cts +++ b/src/health-diagnostic.cts @@ -2,14 +2,24 @@ * 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. + * 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 @@ -25,6 +35,9 @@ * 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'); @@ -80,15 +93,36 @@ const RULES: Rule[] = [ ...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 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). + * 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(); @@ -110,17 +144,231 @@ function evaluateRules(snapshot: PlanningSnapshot): Diagnostic[] { } // ─── Repair dispatcher ────────────────────────────────────────────────────── +// Repair-handler bodies (real, ported from verify.cts:2405-2553). /** - * 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. + * 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. */ -function applyStubRepair(_cwd: string, _diagnostic: Diagnostic): void { - /* intentionally empty — real handlers land with the rules that need them */ +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 { + 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; + if (!configParsed['workflow']) configParsed['workflow'] = {}; + const wf = configParsed['workflow'] as Record; + 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}"` }; + } } /** @@ -136,21 +384,33 @@ function applyStubRepair(_cwd: string, _diagnostic: Diagnostic): void { * - 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`. + * 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[] } { +): { 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 } = diagnostic; + const { remedy, code } = diagnostic; if (remedy.action === REMEDY_ACTION.ADVISE) continue; const requested = @@ -158,15 +418,43 @@ function applyRepairs( if (!requested) continue; if (remedy.risk === REMEDY_RISK.DESTRUCTIVE) { - refused.push(diagnostic.code); + 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; } - applyStubRepair(cwd, diagnostic); - applied.push(diagnostic.code); + 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 }; + return { applied, refused, details }; } // ─── Exports ──────────────────────────────────────────────────────────────── diff --git a/src/verify.cts b/src/verify.cts index 9da03c2aa..30a3658e5 100644 --- a/src/verify.cts +++ b/src/verify.cts @@ -9,9 +9,8 @@ import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; -import { phaseVariants, buildRoadmapPhaseVariants, buildNotStartedPhaseVariants } from './validate.cjs'; -import { realClock } from './clock.cjs'; -import { phaseDirNameRe, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, textEncodingError } from './validate.cjs'; +import { phaseVariants, buildRoadmapPhaseVariants } from './validate.cjs'; +import { PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE, textEncodingError } from './validate.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module import planningWorkspace = require('./planning-workspace.cjs'); // eslint-disable-next-line @typescript-eslint/no-require-imports -- frontmatter.cjs is an export= CommonJS module @@ -28,13 +27,10 @@ const { findOrphanSummaries, findUnsummarizedPlans } = coreUtilsMod; // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-scope.cjs is an export= CommonJS module import planningScopeMod = require('./planning-scope.cjs'); const { SCOPE } = planningScopeMod; -import { execGit, platformReadSync as safeReadFile, platformWriteSync, posixNormalize } from './shell-command-projection.cjs'; -import { PACKAGE_NAME } from './package-identity.cjs'; +import { execGit, platformReadSync as safeReadFile, posixNormalize } from './shell-command-projection.cjs'; import { formatGsdSlash, resolveRuntime } from './runtime-slash.cjs'; import { detectSchemaFiles, checkSchemaDrift } from './schema-detect.cjs'; -import { isCanonicalPlanningFile } from './artifacts.cjs'; import { extractTaggedBlocks } from './markdown-sectionizer.cjs'; -import { VALID_PROFILES, VALID_TIERS, VALID_PHASE_TYPES } from './model-catalog.cjs'; // eslint-disable-next-line @typescript-eslint/no-require-imports -- agent-install-check.cjs is an export= CommonJS module import agentInstallCheck = require('./agent-install-check.cjs'); const { checkAgentsInstalled, checkCodexModelPosture } = agentInstallCheck; @@ -43,26 +39,27 @@ import ioMod = require('./io.cjs'); const { output, error } = ioMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import configLoaderMod = require('./config-loader.cjs'); -const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod; +const { loadConfig } = configLoaderMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { normalizePhaseName, matchPhaseDirs, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, extractPhaseToken, stripProjectCodePrefix, comparePhaseNum, isSentinelPhaseId } = phaseIdMod; +const { normalizePhaseName, matchPhaseDirs, stripProjectCodePrefix, isSentinelPhaseId } = phaseIdMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { findPhaseInternal } = phaseLocatorMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); -const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = roadmapParserMod; -// eslint-disable-next-line @typescript-eslint/no-require-imports -import worktreeSafetyMod = require('./worktree-safety.cjs'); -const { inspectWorktreeHealth } = worktreeSafetyMod; -// eslint-disable-next-line @typescript-eslint/no-require-imports -- commands.cjs is an export= CommonJS module -import commandsMod = require('./commands.cjs'); -const { determinePhaseStatus } = commandsMod; +const { stripShippedMilestones, extractCurrentMilestone } = roadmapParserMod; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic.cjs is an export= CommonJS module +import healthDiagnosticMod = require('./health-diagnostic.cjs'); +const { SEVERITY: HEALTH_SEVERITY, REMEDY_ACTION, REMEDY_RISK, evaluateRules, applyRepairs } = healthDiagnosticMod; +type HealthDiagnostic = healthDiagnosticMod.Diagnostic; +// eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-snapshot.cjs is an export= CommonJS module +import planningSnapshotMod = require('./planning-snapshot.cjs'); +const { buildPlanningSnapshot } = planningSnapshotMod; const { planningDir, planningRoot } = planningWorkspace; const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod; -const { writeStateMd, readStateHeadFreshness } = stateMod; +const { readStateHeadFreshness } = stateMod; /** * W024 (#2573) threshold — how many commits STATE.md may lag HEAD before @@ -1306,21 +1303,6 @@ function listMilestoneArchiveDirs(planBase: string): string[] { } } -function forEachArchivedPhaseToken(planBase: string, onPhase: (token: string) => void): void { - for (const archiveDir of listMilestoneArchiveDirs(planBase)) { - try { - const entries = fs.readdirSync(archiveDir, { withFileTypes: true }); - for (const e of entries) { - if (!e.isDirectory()) continue; - const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE); - if (m) onPhase(stripProjectCodePrefix(m[1])); - } - } catch { - /* archive dir absent/unreadable */ - } - } -} - function getActiveMilestoneArchiveDir(planBase: string): string | null { const archiveDirs = listMilestoneArchiveDirs(planBase); if (archiveDirs.length === 0) return null; @@ -1400,63 +1382,6 @@ function collectDiskPhases(planBase: string): Set { return new Set(collectDiskPhaseEntries(planBase).keys()); } -/** - * #2528: archived phase DIRECTORY NAMES, the name-side twin of - * `forEachArchivedPhaseToken`. W006 must not warn about a roadmap phase whose - * only directory lives in a shipped-milestone archive, and deciding that needs - * the same name-based resolution the active roots get. - */ -function collectArchivedPhaseDirNames(planBase: string): string[] { - const names: string[] = []; - for (const archiveDir of listMilestoneArchiveDirs(planBase)) { - try { - for (const e of fs.readdirSync(archiveDir, { withFileTypes: true })) { - if (e.isDirectory() && PHASE_TOKEN_FROM_DIR_RE.test(e.name)) names.push(e.name); - } - } catch { - /* archive dir absent/unreadable */ - } - } - return names; -} - -interface MilestoneMismatch { - phaseId: string; - foundInMilestone: string; - expectedMilestone: string; -} - -function checkMilestonePrefixMismatches( - roadmapContent: string, - { getMilestoneFromPhaseId }: { getMilestoneFromPhaseId: (id: string) => string | null }, -): MilestoneMismatch[] { - const mismatches: MilestoneMismatch[] = []; - const sections: { version: string; start: number; end: number }[] = []; - const sectionRx = /^#{1,3}\s+(?:\[[^\]]{1,200}\]\s*)?.*v(\d+\.\d+)/gim; - let m: RegExpExecArray | null; - while ((m = sectionRx.exec(roadmapContent)) !== null) { - if (sections.length > 0) sections[sections.length - 1].end = m.index; - sections.push({ version: `v${m[1]}`, start: m.index, end: roadmapContent.length }); - } - for (const section of sections) { - const content = roadmapContent.slice(section.start, section.end); - // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phaseRx = /#{2,4}\s*(?:\[[^\]]{1,200}\]\s*)?Phase\s+([\w][\w.-]*)(?:\s*\([^)\n]{0,200}\))?\s*:/gi; - let pm: RegExpExecArray | null; - while ((pm = phaseRx.exec(content)) !== null) { - const phaseId = pm[1]; - const expectedMilestone = getMilestoneFromPhaseId(phaseId); - if (expectedMilestone !== null && expectedMilestone !== section.version) { - mismatches.push({ - phaseId, - foundInMilestone: section.version, - expectedMilestone, - }); - } - } - } - return mismatches; -} interface IssueEntry { code: string; @@ -1465,6 +1390,79 @@ interface IssueEntry { repairable: boolean; } +/** + * Wrapper-level fix-text table for `cmdValidateHealth`'s migrated + * `HealthDiagnostic` -> `IssueEntry` mapping (Phase 11, #3309). Rules cannot + * call `slash()` (forbidden ambient I/O, §8.1 rule 1) so every REPAIRABLE + * (non-ADVISE) diagnostic's `fix` text — which the pre-migration code always + * built via `${slash('health')} --repair|--backfill ...` — is reconstructed + * HERE instead, keyed by `remedy.action`. Each real repair action maps 1:1 + * back to exactly one pre-migration code (confirmed: no `REMEDY_ACTION` + * other than `ADVISE` is used by more than one rule in the migrated table), + * so this table reproduces the original `fix` text byte-for-byte, including + * its `slash()` calls, without the RULE ever needing to know about `slash`. + * Source line refs are the exact pre-migration `addIssue(...)` call each + * text was copied from: + * - createConfig — verify.cts:1782 (W003) + * - resetConfig — verify.cts:1830 (E005) + * - regenerateState — verify.cts:1702 (E004) + * - addNyquistKey — verify.cts:1847 (W008) + * - addAiIntegrationPhaseKey — verify.cts:1857 (W016) + * - backfillMilestones — verify.cts:2326 (W018) + * ADVISE diagnostics do NOT go through this table — their `fix` is + * `diagnostic.remedy.args.command` directly (already a complete, final + * string baked in by the rule, confirmed via + * `src/health-diagnostic-rules/root-existence.cts`/`config-validation.cts`). + */ +function repairFixText(slash: (name: string) => string, action: string): string { + switch (action) { + case REMEDY_ACTION.CREATE_CONFIG: + return `Run ${slash('health')} --repair to create with defaults`; + case REMEDY_ACTION.RESET_CONFIG: + return `Run ${slash('health')} --repair to reset to defaults`; + case REMEDY_ACTION.REGENERATE_STATE: + return `Run ${slash('health')} --repair to regenerate`; + case REMEDY_ACTION.ADD_NYQUIST_KEY: + case REMEDY_ACTION.ADD_AI_INTEGRATION_PHASE_KEY: + return `Run ${slash('health')} --repair to add key`; + case REMEDY_ACTION.BACKFILL_MILESTONES: + return `Run ${slash('health')} --backfill to synthesize missing entries from archive snapshots`; + default: + return ''; + } +} + +/** + * Map one `HealthDiagnostic` (rule-table shape) back onto the legacy + * `IssueEntry` shape `cmdValidateHealth` has always returned (design doc, + * "Output-shape preservation" section). + * + * `repairable` for a DESTRUCTIVE-risk remedy (regenerateState/resetConfig) + * is `false` here — NOT the pre-migration `true` those two codes always + * carried. This is a deliberate, disclosed decision (see this batch's + * dispatch report): the design doc's own "`--repair` behavior change" + * section establishes that `--repair` never actually applies a DESTRUCTIVE + * remedy post-migration, so marking it `repairable: true` would mislead a + * caller that uses this field to decide whether re-running with `--repair` + * will fix anything. `repairable` now means "an automatic repair will + * actually run", not merely "a remedy exists to describe" — the more + * conservative of the two readings the brief identified, chosen because the + * question was genuinely ambiguous and this reading cannot itself cause a + * caller to skip a fix that would have worked. + */ +function diagnosticToIssueEntry(diagnostic: HealthDiagnostic, slash: (name: string) => string): IssueEntry { + const { code, message, remedy } = diagnostic; + if (remedy.action === REMEDY_ACTION.ADVISE) { + return { code, message, fix: remedy.args['command'] as string, repairable: false }; + } + return { + code, + message, + fix: repairFixText(slash, remedy.action), + repairable: remedy.risk !== REMEDY_RISK.DESTRUCTIVE, + }; +} + function cmdValidateConsistency(cwd: string, raw: boolean): void { const planBase = planningDir(cwd); const roadmapPath = path.join(planBase, 'ROADMAP.md'); @@ -1640,917 +1638,104 @@ function cmdValidateHealth( } // rootBase always resolves to .planning/ (shared root — PROJECT.md, config.json live here) - // wsBase resolves to .planning/workstreams// when GSD_WORKSTREAM is set (STATE.md, ROADMAP.md, phases/) const rootBase = planningRoot(cwd); - const wsBase = planningDir(cwd); - // planBase is kept as an alias for wsBase for all the internal helpers (collectDiskPhases, etc.) - // that are already parameterised on the workstream-aware path. - const planBase = wsBase; - const projectPath = path.join(rootBase, 'PROJECT.md'); - const roadmapPath = path.join(wsBase, 'ROADMAP.md'); - const statePath = path.join(wsBase, 'STATE.md'); - const configPath = path.join(rootBase, 'config.json'); - const phasesDir = path.join(wsBase, 'phases'); const _slashRuntime = resolveRuntime(cwd); const slash = (name: string) => formatGsdSlash(name, _slashRuntime) as string; + // Second (and last) pre-check that stays OUTSIDE the rule table entirely + // (design doc, "Two guards that stay OUTSIDE the rule table entirely" — + // this one must run BEFORE any snapshot is built, since a flat + // `evaluateRules` pass over an entirely-absent `.planning/` would produce + // spurious per-rule clutter no one asked for, not a clean E001-only + // report). + if (!fs.existsSync(rootBase)) { + const errors: IssueEntry[] = [ + { + code: 'E001', + message: '.planning/ directory not found', + fix: `Run ${slash('new-project')} to initialize`, + repairable: false, + }, + ]; + output({ status: 'broken', errors, warnings: [], info: [], repairable_count: 0 }, raw); + return; + } + + // ─── Rule-table evaluation (Phase 11, #3309) ─────────────────────────────── + // Replaces the entire hand-rolled addIssue/switch accumulation this + // function used to run inline (verify.cts, pre-migration) — see the + // design doc's "Output-shape preservation" section for the exact + // Diagnostic -> IssueEntry mapping contract this reproduces. + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = evaluateRules(snapshot); + const errors: IssueEntry[] = []; const warnings: IssueEntry[] = []; const info: IssueEntry[] = []; - const repairs: string[] = []; - - const addIssue = ( - severity: 'error' | 'warning' | 'info', - code: string, - message: string, - fix: string, - repairable = false, - ) => { - const issue: IssueEntry = { code, message, fix, repairable }; - if (severity === 'error') errors.push(issue); - else if (severity === 'warning') warnings.push(issue); - else info.push(issue); - }; - - if (!fs.existsSync(rootBase)) { - addIssue('error', 'E001', '.planning/ directory not found', `Run ${slash('new-project')} to initialize`); - output({ status: 'broken', errors, warnings, info, repairable_count: 0 }, raw); - return; + for (const diagnostic of diagnostics) { + const entry = diagnosticToIssueEntry(diagnostic, slash); + if (diagnostic.severity === HEALTH_SEVERITY.ERROR) errors.push(entry); + else if (diagnostic.severity === HEALTH_SEVERITY.WARNING) warnings.push(entry); + else info.push(entry); } - if (!fs.existsSync(projectPath)) { - addIssue('error', 'E002', 'PROJECT.md not found', `Run ${slash('new-project')} to create`); - } else { - const content = fs.readFileSync(projectPath, 'utf-8'); - const requiredSections = ['## What This Is', '## Core Value', '## Requirements']; - for (const section of requiredSections) { - if (!content.includes(section)) { - addIssue('warning', 'W001', `PROJECT.md missing section: ${section}`, 'Add section manually'); - } - } - } - - if (!fs.existsSync(roadmapPath)) { - addIssue('error', 'E003', 'ROADMAP.md not found', `Run ${slash('new-milestone')} to create roadmap`); - } - - if (!fs.existsSync(statePath)) { - addIssue( - 'error', - 'E004', - 'STATE.md not found', - `Run ${slash('health')} --repair to regenerate`, - true, - ); - repairs.push('regenerateState'); - } else { - const stateContent = fs.readFileSync(statePath, 'utf-8'); - - // W024 (#2573): STATE.md commit-age freshness. Advisory ONLY — it appends - // to warnings[] and never touches `status`, the repair set, or any existing - // count. Silent when the stamp is absent or unresolvable: "unknown" is not - // a finding. The threshold is deliberately coarse so an ordinary project - // stays quiet — firing on every project would change health's observable - // "clean" state for anything gating on it. - { - const fm = extractFrontmatter(stateContent) as Record; - const freshness = readStateHeadFreshness(cwd, fm['state_head']); - if ( - freshness.commits_behind !== null && - freshness.commits_behind >= STATE_HEAD_ADVISORY_COMMITS - ) { - addIssue( - 'warning', - 'W024', - `STATE.md was written ${freshness.commits_behind} commits ago (at ${freshness.state_head}) — treat its contents as approximate`, - 'Re-read the current phase artifacts before relying on STATE.md, or run a GSD command that refreshes it', - ); - } - } - - const phaseRefs = [ - ...stateContent.matchAll(new RegExp(`[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g')), - ].map( - (m) => m[1], - ); - const validPhases = collectDiskPhases(planBase); - try { - if (fs.existsSync(roadmapPath)) { - const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); - const all = [ - ...roadmapRaw.matchAll(new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'gi')), - ]; - for (const m of all) validPhases.add(m[1]); - } - } catch { - /* intentionally empty */ - } - forEachArchivedPhaseToken(planBase, (token) => validPhases.add(token)); - const normalizedValid = new Set(); - for (const p of validPhases) { - normalizedValid.add(p); - const dotIdx = p.indexOf('.'); - const head = dotIdx === -1 ? p : p.slice(0, dotIdx); - const tail = dotIdx === -1 ? '' : p.slice(dotIdx); - if (/^\d+$/.test(head)) { - normalizedValid.add(head.padStart(2, '0') + tail); - } - } - for (const ref of phaseRefs) { - const dotIdx = ref.indexOf('.'); - const head = dotIdx === -1 ? ref : ref.slice(0, dotIdx); - const tail = dotIdx === -1 ? '' : ref.slice(dotIdx); - const padded = /^\d+$/.test(head) ? head.padStart(2, '0') + tail : ref; - if (!normalizedValid.has(ref) && !normalizedValid.has(padded)) { - if (normalizedValid.size > 0) { - addIssue( - 'warning', - 'W002', - `STATE.md references phase ${ref}, but only phases ${[...validPhases].sort((a, b) => a.localeCompare(b, undefined, { numeric: true })).join(', ')} are declared`, - `Review STATE.md manually before changing it; ${slash('health')} --repair will not overwrite an existing STATE.md for phase mismatches`, - ); - } - } - } - } - - if (!fs.existsSync(configPath)) { - addIssue( - 'warning', - 'W003', - 'config.json not found', - `Run ${slash('health')} --repair to create with defaults`, - true, - ); - repairs.push('createConfig'); - } else { - try { - const rawCfg = fs.readFileSync(configPath, 'utf-8'); - const parsed = JSON.parse(rawCfg) as Record; - if (parsed['model_profile'] && !VALID_PROFILES.includes(parsed['model_profile'] as string)) { - addIssue( - 'warning', - 'W004', - `config.json: invalid model_profile "${parsed['model_profile'] as string}"`, - `Valid values: ${VALID_PROFILES.join(', ')}`, - ); - } - const configModels = parsed['models']; - if (configModels && typeof configModels === 'object' && !Array.isArray(configModels)) { - for (const [phaseType, tierValue] of Object.entries(configModels as Record)) { - if (!VALID_PHASE_TYPES.has(phaseType)) { - addIssue( - 'warning', - 'W022', - `config.json: models has an unknown phase type "${phaseType}" which will be ignored`, - `Valid phase types: ${[...VALID_PHASE_TYPES].join(', ')}`, - ); - } else if (typeof tierValue !== 'string' || !VALID_TIERS.has(tierValue)) { - addIssue( - 'warning', - 'W022', - `config.json: models.${phaseType} has an invalid tier value ${JSON.stringify(tierValue)} which will be ignored`, - `Valid tiers: ${[...VALID_TIERS].join(', ')}`, - ); - } - } - } else if (configModels !== undefined && configModels !== null) { - addIssue( - 'warning', - 'W022', - `config.json: models is set to ${JSON.stringify(configModels)}, but must be an object mapping phase types to tiers — this value will be ignored`, - `Set models to an object like {"planning": "sonnet"}, or remove the key to use profile defaults`, - ); - } - } catch (err) { - addIssue( - 'error', - 'E005', - `config.json: JSON parse error - ${err instanceof Error ? err.message : String(err)}`, - `Run ${slash('health')} --repair to reset to defaults`, - true, - ); - repairs.push('resetConfig'); - } - } - - if (fs.existsSync(configPath)) { - try { - const configRaw = fs.readFileSync(configPath, 'utf-8'); - const configParsed = JSON.parse(configRaw) as Record; - const workflow = configParsed['workflow'] as Record | undefined; - if (workflow && workflow['nyquist_validation'] === undefined) { - addIssue( - 'warning', - 'W008', - 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)', - `Run ${slash('health')} --repair to add key`, - true, - ); - if (!repairs.includes('addNyquistKey')) repairs.push('addNyquistKey'); - } - if (workflow && workflow['ai_integration_phase'] === undefined) { - addIssue( - 'warning', - 'W016', - `config.json: workflow.ai_integration_phase absent (defaults to enabled — run ${slash('ai-integration-phase')} before planning AI system phases)`, - `Run ${slash('health')} --repair to add key`, - true, - ); - if (!repairs.includes('addAiIntegrationPhaseKey')) repairs.push('addAiIntegrationPhaseKey'); - } - } catch { - /* intentionally empty */ - } - } - - let phaseDirEntries: fs.Dirent[] = []; - const phaseDirFiles = new Map(); - // #3183: companion map of the single owner's scan per phase dir - // (root+nested, superseded-excluded plan/summary sets + canonical - // pairing), computed alongside the raw readdirSync listing above. The - // W023 duplicate-dir describer and the I001 unsummarized-plan detector - // below use THIS map for plan/summary counts and pairing; phaseDirFiles - // stays raw for the RESEARCH/VALIDATION and phase-dir-naming checks that - // are not plan-count questions. - const phaseDirScans = new Map>(); - try { - phaseDirEntries = fs - .readdirSync(phasesDir, { withFileTypes: true }) - .filter((e) => e.isDirectory()); - for (const e of phaseDirEntries) { - try { - phaseDirFiles.set(e.name, fs.readdirSync(path.join(phasesDir, e.name))); - } catch { - phaseDirFiles.set(e.name, []); - } - phaseDirScans.set(e.name, planScanMod.scanPhasePlans(path.join(phasesDir, e.name))); - } - } catch { - /* intentionally empty */ - } - - for (const e of phaseDirEntries) { - if (!e.name.match(phaseDirNameRe)) { - addIssue( - 'warning', - 'W005', - `Phase directory "${e.name}" doesn't follow NN-name format`, - 'Rename to match pattern (e.g., 01-setup)', - ); - } - } - - // W023 (#2408): detect two or more real on-disk phase directories that - // normalize to the same phase key (e.g. `05-real/` + `05-real-stray/`). - // The collision silently breaks /gsd-stats status accuracy (now folded by - // precedence — see commands.cts foldPhaseStatus) and forces an operator - // decision. Wording is neutral — never guesses which directory is "real". + // W024 (#2573): STATE.md commit-age freshness — kept OUTSIDE the rule + // table, exactly like E001/E010/I010 above. NOT a preservation nicety: the + // committed `RULE_W024` (`src/health-diagnostic-rules/state-consistency.cts`) + // is a documented PERMANENT no-op (`check` always returns `[]`) because + // `readStateHeadFreshness`'s `git log` shell-out is ambient I/O a + // `Rule.check(snapshot)` may never perform (§8.1 rule 1) and no + // `PlanningSnapshot` field carries a commits-behind count. Migrating + // `cmdValidateHealth` onto the rule table as designed would silently + // regress `tests/health-validation.test.cjs`'s "W024 — STATE.md commit-age + // freshness advisory" suite (7 currently-passing tests exercising the REAL + // git-based check end-to-end) — found while wiring this function to the + // rule table, fixed inline per this repo's no-defer policy rather than + // silently accepting the loss. `cmdValidateHealth` itself (unlike a Rule) + // is licensed to perform its own bounded I/O — the same license + // `applyRepairs` already relies on (see `health-diagnostic.cts`'s header + // comment) — so this reproduces the exact pre-migration check + // (`verify.cts`, W024) verbatim, advisory-only: it only ever appends to + // `warnings`, never touches `status`/`errors`/the repair set. { - const groups = new Map(); - for (const e of phaseDirEntries) { - // extractPhaseToken never returns empty — for unparseable dir names it - // falls back to the dir name itself. Two distinct unparseable names - // therefore normalize to distinct keys and cannot false-positive here; - // only dirs whose tokens collapse to the same key (e.g. `05-real` and - // `05-real-stray` → token `05`) produce a collision group. - const token = extractPhaseToken(e.name); - const key = normalizePhaseName(token); - const list = groups.get(key); - if (list) list.push(e.name); - else groups.set(key, [e.name]); - } - for (const [key, dirs] of groups) { - if (dirs.length < 2) continue; - // Compute each dir's status independently so the warning is informative. - // Sort by phase id for stable output regardless of readdir order; tie- - // break on the dir name itself so two dirs sharing the same phase token - // (the collision case itself) still sort deterministically (V8's stable - // sort would otherwise fall back to non-portable fs.readdirSync order). - const described = dirs - .slice() - .sort((a, b) => comparePhaseNum(a, b) || String(a).localeCompare(String(b))) - .map((d) => { - // #3183: canonical plan/summary counts (root+nested, - // superseded-excluded, canonical pairing) from the single owner. - const scan = phaseDirScans.get(d); - const plans = scan ? scan.planCount : 0; - const summaries = scan ? scan.summaryCount : 0; - const status = determinePhaseStatus(plans, summaries, path.join(phasesDir, d), 'Not Started'); - return `${d} (${status})`; - }) - .join(', '); - addIssue( - 'warning', - 'W023', - `Phase directories collide on normalized key "${key}": ${described}`, - 'Inspect each directory; rename or remove the duplicate so only one directory maps to this phase key', - ); - } - } - - // I001 (#3183): this IS findUnsummarizedPlans's exact question — routed - // through the single owner's scan (root+nested, superseded-excluded plan - // set) and the canonical summaryCandidates-based pairing, instead of a - // bespoke canonicalPlanStem reimplementation. Fixes: a superseded plan is - // no longer permanently flagged "may be in progress" (false noise - // forever), and nested (#3139 layout) plans are no longer invisible. - for (const e of phaseDirEntries) { - const scan = phaseDirScans.get(e.name); - const planFiles = scan ? scan.planFiles : []; - const summaryFiles = scan ? scan.summaryFiles : []; - for (const plan of findUnsummarizedPlans(planFiles, summaryFiles)) { - addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress'); - } - } - - for (const e of phaseDirEntries) { - const phaseFiles = phaseDirFiles.get(e.name) || []; - const hasResearch = phaseFiles.some((f) => f.endsWith('-RESEARCH.md')); - const hasValidation = phaseFiles.some((f) => f.endsWith('-VALIDATION.md')); - if (hasResearch && !hasValidation) { - const researchFile = phaseFiles.find((f) => f.endsWith('-RESEARCH.md')); + const wsBase = planningDir(cwd); + const statePath = path.join(wsBase, 'STATE.md'); + if (fs.existsSync(statePath)) { try { - const researchContent = fs.readFileSync( - path.join(phasesDir, e.name, researchFile!), - 'utf-8', - ); - if (researchContent.includes('## Validation Architecture')) { - addIssue( - 'warning', - 'W009', - `Phase ${e.name}: has Validation Architecture in RESEARCH.md but no VALIDATION.md`, - `Re-run ${slash('plan-phase')} with --research to regenerate`, - ); + const stateContent = fs.readFileSync(statePath, 'utf-8'); + const fm = extractFrontmatter(stateContent) as Record; + const freshness = readStateHeadFreshness(cwd, fm['state_head']); + if ( + freshness.commits_behind !== null && + freshness.commits_behind >= STATE_HEAD_ADVISORY_COMMITS + ) { + warnings.push({ + code: 'W024', + message: `STATE.md was written ${freshness.commits_behind} commits ago (at ${freshness.state_head}) — treat its contents as approximate`, + fix: 'Re-read the current phase artifacts before relying on STATE.md, or run a GSD command that refreshes it', + repairable: false, + }); } } catch { - /* intentionally empty */ + /* intentionally empty — W024 is advisory */ } } } - try { - const agentStatus = checkAgentsInstalled(_slashRuntime, cwd); - if (!agentStatus.agents_installed) { - if ((agentStatus.installed_agents).length === 0) { - addIssue( - 'warning', - 'W010', - `No GSD agents found in ${agentStatus.agents_dir} — Task(subagent_type="gsd-*") will fall back to general-purpose`, - `Run the GSD installer: npx ${PACKAGE_NAME}@latest`, - ); - } else if ((agentStatus.incomplete_agents).length > 0 && (agentStatus.missing_agents).length === 0) { - addIssue( - 'warning', - 'W010', - `Incomplete agent installs (missing generated file): ${(agentStatus.incomplete_agents).join(', ')} — affected workflows may fall back to general-purpose`, - `Re-run the GSD installer to complete the install: npx ${PACKAGE_NAME}@latest`, - ); - } else if ((agentStatus.incomplete_agents).length > 0) { - addIssue( - 'warning', - 'W010', - `Missing ${(agentStatus.missing_agents).length} GSD agents: ${(agentStatus.missing_agents).join(', ')}; incomplete agent installs (missing generated file): ${(agentStatus.incomplete_agents).join(', ')} — affected workflows will fall back to general-purpose`, - `Run the GSD installer: npx ${PACKAGE_NAME}@latest`, - ); - } else { - addIssue( - 'warning', - 'W010', - `Missing ${(agentStatus.missing_agents).length} GSD agents: ${(agentStatus.missing_agents).join(', ')} — affected workflows will fall back to general-purpose`, - `Run the GSD installer: npx ${PACKAGE_NAME}@latest`, - ); - } - } - } catch { - /* intentionally empty — agent check is non-blocking */ - } - - if (fs.existsSync(roadmapPath)) { - const roadmapContentRaw = fs.readFileSync(roadmapPath, 'utf-8'); - const roadmapContent = extractCurrentMilestone(roadmapContentRaw, cwd); - - const { roadmapPhases } = buildRoadmapPhaseVariants(roadmapContent); - const { roadmapPhases: fullRoadmapPhases, roadmapPhaseVariants: fullRoadmapPhaseVariants } = - buildRoadmapPhaseVariants(roadmapContentRaw); - - const diskPhases = collectDiskPhases(planBase); - forEachArchivedPhaseToken(planBase, (token) => diskPhases.add(token)); - - const activeDiskEntries = collectDiskPhaseEntries(planBase); - - // #2528: the name side of the same inventory. The token sets above answer - // "do two independently-derived labels agree"; these answer "does the - // canonical matcher resolve this roadmap phase to a real directory" — the - // question W006/W007 are actually asking. Both are kept: the token - // intersection still decides every shape it already decided correctly, and - // the resolution below only ever REMOVES a warning, so a phase the tokens - // already paired up cannot start warning because of this. - const activeDirNames = [...activeDiskEntries.values()].flat(); - const allDirNames = [...activeDirNames, ...collectArchivedPhaseDirNames(planBase)]; - - // A directory is CLAIMED when some roadmap phase resolves to it. This is the - // inverse mapping W007 never had: it iterates directories, so it has no query - // to resolve, and a dir whose label does not appear in the roadmap looked - // orphaned even when the roadmap phase that owns it resolves to it exactly. - // Built from the FULL roadmap (shipped milestones included), matching the - // variant set W007 already compares against. - const claimedDirs = new Set(); - for (const p of fullRoadmapPhases) { - for (const d of matchPhaseDirs(activeDirNames, normalizePhaseName(p)).matches) { - claimedDirs.add(d); - } - } - - const notStartedPhases = buildNotStartedPhaseVariants(roadmapContent); - - for (const p of roadmapPhases) { - // #3225: sentinel phase ids (999.x/0.x) are never-on-roadmap by convention; - // a sentinel heading shouldn't demand a directory. - if (isSentinelPhaseId(p)) continue; - const variants = phaseVariants(p); - const existsOnDisk = [...variants].some((v) => diskPhases.has(v)) - || matchPhaseDirs(allDirNames, normalizePhaseName(p)).matches.length > 0; - if (!existsOnDisk) { - const isNotStarted = [...variants].some((v) => notStartedPhases.has(v)); - if (isNotStarted) continue; - addIssue( - 'warning', - 'W006', - `Phase ${p} in ROADMAP.md but no directory on disk`, - 'Create phase directory or remove from roadmap', - ); - } - } - - for (const [p, dirsForToken] of activeDiskEntries) { - // #3225: a sentinel dir on disk (999-interim, 0-drafts) is defined as - // never-on-roadmap; it must not trigger W007 ("Add to roadmap or remove - // directory" — both wrong for a sentinel). Mirrors the isSentinelPhaseId - // guard phase.cts has at 10+ sites (#2786/#2949). - if (isSentinelPhaseId(p)) continue; - const variants = phaseVariants(p); - if ([...variants].some((v) => fullRoadmapPhaseVariants.has(v))) continue; - if (dirsForToken.every((d) => claimedDirs.has(d))) continue; - addIssue( - 'warning', - 'W007', - `Phase ${p} exists on disk but not in ROADMAP.md`, - 'Add to roadmap or remove directory', - ); - } - } - - if (fs.existsSync(statePath) && fs.existsSync(roadmapPath)) { - try { - const stateContent = fs.readFileSync(statePath, 'utf-8'); - const roadmapContentFull = fs.readFileSync(roadmapPath, 'utf-8'); - - const currentPhaseMatch = - stateContent.match(/\*\*Current Phase:\*\*\s*(\S+)/i) || - stateContent.match(/Current Phase:\s*(\S+)/i); - if (currentPhaseMatch) { - const statePhase = currentPhaseMatch[1].replace(/^0+/, ''); - const phaseCheckboxRe = new RegExp( - `-\\s*\\[x\\].*Phase\\s+0*${escapeRegex(statePhase)}${OPTIONAL_PHASE_TAG_SOURCE}[:\\s]`, - 'i', - ); - if (phaseCheckboxRe.test(roadmapContentFull)) { - const stateStatus = stateContent.match(/\*\*Status:\*\*\s*(.+)/i); - const statusVal = stateStatus ? stateStatus[1].trim().toLowerCase() : ''; - if (statusVal !== 'complete' && statusVal !== 'done') { - addIssue( - 'warning', - 'W011', - `STATE.md says current phase is ${statePhase} (status: ${statusVal || 'unknown'}) but ROADMAP.md shows it as [x] complete — state files may be out of sync`, - `Run ${slash('progress')} to re-derive current position, or manually update STATE.md`, - ); - } - } - } - } catch { - /* intentionally empty — cross-validation is advisory */ - } - } - - if (fs.existsSync(configPath)) { - try { - const configRaw = fs.readFileSync(configPath, 'utf-8'); - const configParsed = JSON.parse(configRaw) as Record; - - const validStrategies = ['none', 'phase', 'milestone']; - if ( - configParsed['branching_strategy'] && - !validStrategies.includes(configParsed['branching_strategy'] as string) - ) { - addIssue( - 'warning', - 'W012', - `config.json: invalid branching_strategy "${configParsed['branching_strategy'] as string}"`, - `Valid values: ${validStrategies.join(', ')}`, - ); - } - - if (configParsed['context_window'] !== undefined) { - const cw = configParsed['context_window']; - if (typeof cw !== 'number' || cw <= 0 || !Number.isInteger(cw)) { - addIssue( - 'warning', - 'W013', - `config.json: context_window should be a positive integer, got "${cw as string}"`, - 'Set to 200000 (default) or 1000000 (for 1M models)', - ); - } - } - - if ( - configParsed['phase_branch_template'] && - !(configParsed['phase_branch_template'] as string).includes('{phase}') - ) { - addIssue( - 'warning', - 'W014', - 'config.json: phase_branch_template missing {phase} placeholder', - 'Template must include {phase} for phase number substitution', - ); - } - if ( - configParsed['milestone_branch_template'] && - !(configParsed['milestone_branch_template'] as string).includes('{milestone}') - ) { - addIssue( - 'warning', - 'W015', - 'config.json: milestone_branch_template missing {milestone} placeholder', - 'Template must include {milestone} for version substitution', - ); - } - } catch { - /* parse error already caught in Check 5 */ - } - } - - try { - const worktreeHealth = (inspectWorktreeHealth as unknown as ( - cwd: string, - opts: { staleAfterMs: number }, - deps: { execGit: unknown; existsSync: unknown; statSync: unknown }, - ) => Record)( - cwd, - { staleAfterMs: 60 * 60 * 1000 }, - { execGit, existsSync: fs.existsSync, statSync: fs.statSync }, - ); - if (!(worktreeHealth['ok'] as boolean)) { - if (worktreeHealth['reason'] === 'git_timed_out') { - addIssue( - 'warning', - 'W020', - 'Worktree health check degraded: git worktree list timed out after 10s — orphan/stale worktrees could not be inspected', - 'Run: git worktree list --porcelain to diagnose; check for .git/index.lock or a hung git process', - ); - } - if (worktreeHealth['reason'] === 'git_list_failed') { - addIssue( - 'warning', - 'W020', - 'Worktree health check degraded: git worktree list failed — orphan/stale worktrees could not be inspected', - 'Run: git worktree list --porcelain to diagnose; check git repository state and permissions', - ); - } - } else { - for (const finding of worktreeHealth['findings'] as Record[]) { - if (finding['kind'] === 'orphan') { - addIssue( - 'warning', - 'W017', - `Orphan git worktree: ${finding['path'] as string} (path no longer exists on disk)`, - 'Run: git worktree prune', - ); - continue; - } - - if (finding['kind'] === 'stale') { - // Do not flag the active session's worktree — removing it would be harmful. - const worktreePath = finding['path'] as string; - const activeCwd = process.cwd(); - const normalizedWorktree = path.resolve(worktreePath); - const normalizedCwd = path.resolve(activeCwd); - // Skip if the worktree IS the cwd or is an ancestor of it. - const isActiveWorktree = - normalizedCwd === normalizedWorktree || - normalizedCwd.startsWith(normalizedWorktree + path.sep); - if (isActiveWorktree) continue; - addIssue( - 'warning', - 'W017', - `Stale git worktree: ${worktreePath} (last modified ${finding['ageMinutes'] as number} minutes ago)`, - `Run: git worktree remove ${worktreePath} --force`, - ); - continue; - } - - // #3050/#3057 (B5): a 'unverified' finding means existsSync confirmed - // the worktree is present but statSync threw, so orphan/stale status - // could not be determined for THIS entry — it must not be silently - // dropped (that would be the exact fail-open the row exists to close). - if (finding['kind'] === 'unverified') { - addIssue( - 'warning', - 'W020', - `Worktree health check degraded: could not stat ${finding['path'] as string} — presence/staleness could not be verified`, - 'Check filesystem permissions on the worktree path, or investigate why statSync failed for it', - ); - } - } - } - } catch { - /* git worktree not available or not a git repo — skip silently */ - } - - try { - const phaseConvention = (() => { - if (!fs.existsSync(configPath)) return null; - try { - const configRaw = fs.readFileSync(configPath, 'utf-8'); - const configParsed = JSON.parse(configRaw) as Record; - return (configParsed['phase_id_convention'] as string | undefined) || null; - } catch { - return null; - } - })(); - if (phaseConvention === 'milestone-prefixed') { - if (fs.existsSync(roadmapPath)) { - const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8'); - const mismatches = checkMilestonePrefixMismatches(roadmapContent, { - getMilestoneFromPhaseId: getMilestoneFromPhaseId, - }); - for (const mm of mismatches) { - addIssue( - 'warning', - 'W021', - `Phase ${mm.phaseId}: integer prefix implies ${mm.expectedMilestone} but listed under ${mm.foundInMilestone}`, - 'Run `gsd-tools roadmap upgrade --convention milestone-prefixed` to migrate (dry-run by default)', - ); - } - } - } - } catch { - /* W021 check is advisory — skip on error */ - } - - const milestonesPath = path.join(rootBase, 'MILESTONES.md'); - const milestonesArchiveDir = path.join(rootBase, 'milestones'); - const missingFromRegistry: string[] = []; - try { - if (fs.existsSync(milestonesArchiveDir)) { - const archiveFiles = fs.readdirSync(milestonesArchiveDir); - const archivedVersions = archiveFiles - .map((f) => f.match(/^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$/)) - .filter(Boolean) - .map((m) => m![1]); - - if (archivedVersions.length > 0) { - const registryContent = fs.existsSync(milestonesPath) - ? fs.readFileSync(milestonesPath, 'utf-8') - : ''; - for (const ver of archivedVersions) { - if (!registryContent.includes(`## ${ver}`)) { - missingFromRegistry.push(ver); - } - } - if (missingFromRegistry.length > 0) { - addIssue( - 'warning', - 'W018', - `MILESTONES.md missing ${missingFromRegistry.length} archived milestone(s): ${missingFromRegistry.join(', ')}`, - `Run ${slash('health')} --backfill to synthesize missing entries from archive snapshots`, - true, - ); - repairs.push('backfillMilestones'); - } - } - } - } catch { - /* intentionally empty — milestone sync check is advisory */ - } - - try { - const entries = fs.readdirSync(rootBase, { withFileTypes: true }); - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!entry.name.endsWith('.md')) continue; - if (!isCanonicalPlanningFile(entry.name)) { - addIssue( - 'warning', - 'W019', - `Unrecognized .planning/ file: ${entry.name} — not a canonical GSD artifact`, - 'Move to .planning/milestones/ archive subdir or delete if stale. See templates/README.md for the canonical artifact list.', - false, - ); - } - } - } catch { - /* artifact check is advisory — skip on error */ - } - - try { - if (fs.existsSync(statePath) && fs.existsSync(roadmapPath)) { - const stateRaw = fs.readFileSync(statePath, 'utf-8'); - const statusMatch = stateRaw.match(/^status:\s*(.+)/im); - const stateStatus = statusMatch ? statusMatch[1].trim().toLowerCase() : ''; - const isMarkedComplete = /milestone complete|archived/.test(stateStatus); - if (isMarkedComplete) { - const roadmapRaw = fs.readFileSync(roadmapPath, 'utf-8'); - const scopedContent = extractCurrentMilestone(roadmapRaw, cwd); - // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal mirror of OPTIONAL_PHASE_TAG_SOURCE). - const phasePattern = new RegExp(`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:\\s*([^\\n]+)`, 'gi'); - const unstarted: string[] = []; - let pm: RegExpExecArray | null; - // Non-hoisted: load-order matters (circular dep guard) - // eslint-disable-next-line @typescript-eslint/no-require-imports -- planning-workspace.cjs is an export= CommonJS module - const planningWorkspace2 = require('./planning-workspace.cjs') as typeof planningWorkspace; - const phasesDir2 = planningWorkspace2.planningPaths(cwd).phases; - const phaseDirNames2 = (() => { - try { - return fs - .readdirSync(phasesDir2, { withFileTypes: true }) - .filter((e) => e.isDirectory()) - .map((e) => e.name); - } catch { - return []; - } - })(); - while ((pm = phasePattern.exec(scopedContent)) !== null) { - const phaseNum = pm[1]; - const normalizedPh = normalizePhaseName(phaseNum); - const hasDirectory = matchPhaseDirs(phaseDirNames2, normalizedPh).matches.length > 0; - if (!hasDirectory) { - unstarted.push(phaseNum); - } - } - if (unstarted.length > 0) { - addIssue( - 'warning', - 'W021', - `STATE says milestone complete but ROADMAP lists ${unstarted.length} unstarted phase(s) (e.g. Phase ${unstarted[0]})`, - 'Run validate consistency or re-run complete-milestone after verifying all phases are done', - ); - } - } - } - } catch { - /* W021 check is advisory — skip on error */ - } - // ─── Perform repairs if requested ───────────────────────────────────────── - const repairActions: Record[] = []; - if (options['repair'] && repairs.length > 0) { - for (const repair of repairs) { - try { - switch (repair) { - case 'createConfig': - case 'resetConfig': { - const defaults = { - 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, - }; - platformWriteSync(configPath, JSON.stringify(defaults, null, 2)); - repairActions.push({ action: repair, success: true, path: 'config.json' }); - break; - } - case 'regenerateState': { - if (fs.existsSync(statePath)) { - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); - const backupPath = `${statePath}.bak-${timestamp}`; - fs.copyFileSync(statePath, backupPath); - repairActions.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('/'); - 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); - repairActions.push({ action: repair, success: true, path: 'STATE.md' }); - break; - } - case 'addNyquistKey': { - if (fs.existsSync(configPath)) { - try { - const configRaw = fs.readFileSync(configPath, 'utf-8'); - const configParsed = JSON.parse(configRaw) as Record; - if (!configParsed['workflow']) configParsed['workflow'] = {}; - const wf = configParsed['workflow'] as Record; - if (wf['nyquist_validation'] === undefined) { - wf['nyquist_validation'] = true; - platformWriteSync(configPath, JSON.stringify(configParsed, null, 2)); - } - repairActions.push({ action: repair, success: true, path: 'config.json' }); - } catch (err) { - repairActions.push({ - action: repair, - success: false, - error: err instanceof Error ? err.message : String(err), - }); - } - } - break; - } - case 'addAiIntegrationPhaseKey': { - if (fs.existsSync(configPath)) { - try { - const configRaw = fs.readFileSync(configPath, 'utf-8'); - const configParsed = JSON.parse(configRaw) as Record; - if (!configParsed['workflow']) configParsed['workflow'] = {}; - const wf = configParsed['workflow'] as Record; - if (wf['ai_integration_phase'] === undefined) { - wf['ai_integration_phase'] = true; - platformWriteSync(configPath, JSON.stringify(configParsed, null, 2)); - } - repairActions.push({ action: repair, success: true, path: 'config.json' }); - } catch (err) { - repairActions.push({ - action: repair, - success: false, - error: err instanceof Error ? err.message : String(err), - }); - } - } - break; - } - case 'backfillMilestones': { - if (!options['backfill'] && !options['repair']) break; - const today = realClock.localToday(); - let backfilled = 0; - for (const ver of missingFromRegistry) { - 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 */ - } - } - repairActions.push({ - action: repair, - success: true, - detail: `Backfilled ${backfilled} milestone(s) into MILESTONES.md`, - }); - break; - } - } - } catch (err) { - repairActions.push({ - action: repair, - success: false, - error: err instanceof Error ? err.message : String(err), - }); - } - } - } + // `applyRepairs` internally no-ops (produces zero `details` rows, no + // filesystem writes) when both `repair` and `backfill` are falsy, so this + // call is unconditional — mirroring the original's own + // `if (options['repair'] && repairs.length > 0)` gate without needing to + // duplicate that condition here. `backfill` is threaded through as its own + // boolean (not folded into `repair`), which is what makes `--backfill` + // alone now actually trigger `backfillMilestones` — the disclosed latent- + // bug fix from `verify.cts:2504`'s previously-unreachable inner gate (see + // design doc "Known limits"). + const repairResult = applyRepairs(cwd, diagnostics, Boolean(options['repair']), Boolean(options['backfill'])); + // The legacy `repairs_performed` shape never carried a `code` field — + // strip it before it reaches JSON output. + const repairActions = repairResult.details.map(({ code: _code, ...rest }) => rest); let status: string; if (errors.length > 0) { diff --git a/tests/health-diagnostic.test.cjs b/tests/health-diagnostic.test.cjs index a2e9171f8..e8621da54 100644 --- a/tests/health-diagnostic.test.cjs +++ b/tests/health-diagnostic.test.cjs @@ -6,25 +6,26 @@ * 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. + * Covers test-matrix section 2 (rows 9-16) against the FULLY WIRED rule + * table (`RULES` now carries all 31 rules — see the "RULES" describe block + * below for the exact count and why it is 31, not 32 — extracted from + * `cmdValidateHealth`, `src/verify.cts:1616-2577`). Rows 15-16 (the + * DESTRUCTIVE-refusal proof and the NONE-risk apply proof) run against REAL + * diagnostics emitted by REAL rules over a REAL `buildPlanningSnapshot` + * projection of a temp fixture, not hand-constructed fakes — the + * hand-constructed-fake coverage (rows 11-12 below) is kept alongside it + * since it exercises `applyRepairs`'s gating logic in isolation from any + * particular rule's shape. */ const { test, describe } = require('node:test'); const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); const healthDiagnostic = require('../gsd-core/bin/lib/health-diagnostic.cjs'); +const { buildPlanningSnapshot } = require('../gsd-core/bin/lib/planning-snapshot.cjs'); +const { createTempProject, createTempGitProject, cleanup } = require('./helpers.cjs'); const { SEVERITY, @@ -36,6 +37,50 @@ const { applyRepairs, } = healthDiagnostic; +// ─── Shared fixture helpers (mirror tests/orphan-worktree-detection.test.cjs's +// setupHealthyProject, the proven-healthy recipe for the pre-migration +// cmdValidateHealth) ──────────────────────────────────────────────────────── + +function writeMinimalProjectMd(tmpDir) { + const sections = ['## What This Is', '## Core Value', '## Requirements']; + const content = sections.map((s) => `${s}\n\nContent here.\n`).join('\n'); + fs.writeFileSync(path.join(tmpDir, '.planning', 'PROJECT.md'), `# Project\n\n${content}`); +} + +function writeMinimalRoadmap(tmpDir) { + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '# Roadmap\n\n### Phase 1: Setup\n'); +} + +function writeMinimalStateMd(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'STATE.md'), + '# Session State\n\n## Current Position\n\nPhase: 1\n', + ); +} + +function writeValidConfigJson(tmpDir) { + fs.writeFileSync( + path.join(tmpDir, '.planning', 'config.json'), + JSON.stringify( + { + model_profile: 'balanced', + commit_docs: true, + workflow: { nyquist_validation: true, ai_integration_phase: true }, + }, + null, + 2, + ), + ); +} + +function setupHealthyProject(tmpDir) { + writeMinimalProjectMd(tmpDir); + writeMinimalRoadmap(tmpDir); + writeMinimalStateMd(tmpDir); + writeValidConfigJson(tmpDir); + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-setup'), { recursive: true }); +} + // ─── Row 9 — REMEDY_ACTION locks exactly 7 members ───────────────────────── describe('REMEDY_ACTION', () => { @@ -171,9 +216,9 @@ describe('applyRepairs — risk gating (hand-constructed diagnostics)', () => { // ─── 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. +// Proven against a small, locally-constructed fake rule array — independent +// of the real `RULES` table's own (already-unique, see the "RULES" describe +// block below) codes, so this guard's logic is covered in isolation. describe('evaluateRuleTable — duplicate-code guard (row 13)', () => { test('throws when two rules share the same code', () => { @@ -212,15 +257,97 @@ describe('evaluateRuleTable — duplicate-code guard (row 13)', () => { }); }); -// ─── Row 14 — evaluator against an all-clean (here: rule-less) snapshot ─── +// ─── RULES — the fully wired table ────────────────────────────────────────── +// +// 31 rule entries, not the design doc's own prose figure of "32" (that doc's +// "Rule table organization" section already flags its own count as +// inconsistent between its table and prose — see this repo's design doc, +// same section). Counted directly from each rule-group file's own exported +// `RULES` array: root-existence (4: E002/E003/E004/W001) + state-consistency +// (5: W024/W002/W011/W021/W026) + config-validation (10: W003/E005/W004/ +// W008/W016/W012/W013/W014/W015/W022) + phase-structure (4: W005/W023/I001/ +// W009) + agent-install (1: W010) + roadmap-disk-consistency (2: W006/W007) +// + worktree-health (3: W020/W017/W027) + milestone-archive-hygiene (2: +// W018/W019) = 31. E001 and the home-directory guard (E010/I010) are +// deliberately NOT rows (design doc, "Two guards that stay OUTSIDE the rule +// table entirely"). -describe('evaluateRules (row 14)', () => { - test('RULES starts empty in this skeleton', () => { - assert.deepEqual(RULES, []); +describe('RULES', () => { + test('is the full, frozen 31-rule table with every code unique', () => { assert.equal(Array.isArray(RULES), true); + assert.equal(RULES.length, 31); + const codes = RULES.map((r) => r.code); + assert.equal(new Set(codes).size, codes.length, 'every rule code must be unique'); }); - test('returns [] against any snapshot, since RULES is empty', () => { - assert.deepEqual(evaluateRules({}), []); + test('every rule carries a code, severity, and check function', () => { + for (const rule of RULES) { + assert.equal(typeof rule.code, 'string'); + assert.ok(Object.values(SEVERITY).includes(rule.severity), `${rule.code}: unknown severity ${rule.severity}`); + assert.equal(typeof rule.check, 'function'); + } + }); +}); + +// ─── Row 14 — evaluator against an all-clean REAL snapshot ──────────────── + +describe('evaluateRules (row 14)', () => { + test('evaluateRules(buildPlanningSnapshot(healthyProject)) returns []', (t) => { + const tmpDir = createTempGitProject(); + t.after(() => cleanup(tmpDir)); + setupHealthyProject(tmpDir); + + const snapshot = buildPlanningSnapshot(tmpDir); + const diagnostics = evaluateRules(snapshot); + assert.deepEqual(diagnostics, [], `expected zero diagnostics for a healthy project, got: ${JSON.stringify(diagnostics)}`); + }); +}); + +// ─── Rows 15-16 — applyRepairs against REAL diagnostics from REAL rules ──── + +describe('applyRepairs — REAL diagnostics (rows 15-16)', () => { + test('row 15: --repair given a real DESTRUCTIVE E004 finding (STATE.md missing) refuses regenerateState; STATE.md stays absent', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + setupHealthyProject(tmpDir); + fs.unlinkSync(path.join(tmpDir, '.planning', 'STATE.md')); + + const snapshot = buildPlanningSnapshot(tmpDir); + const diagnostics = evaluateRules(snapshot); + const e004 = diagnostics.find((d) => d.code === 'E004'); + assert.ok(e004, `expected E004 when STATE.md is missing, got: ${JSON.stringify(diagnostics)}`); + assert.equal(e004.remedy.action, REMEDY_ACTION.REGENERATE_STATE); + assert.equal(e004.remedy.risk, REMEDY_RISK.DESTRUCTIVE); + + const result = applyRepairs(tmpDir, diagnostics, true, false); + assert.ok(!result.applied.includes('E004'), 'E004 must not be applied'); + assert.ok(result.refused.includes('E004'), 'E004 must be refused'); + assert.equal( + fs.existsSync(path.join(tmpDir, '.planning', 'STATE.md')), + false, + 'STATE.md must remain absent — the DESTRUCTIVE remedy is refused, not silently applied', + ); + }); + + test('row 16: --repair given a real NONE-risk W003 finding (config.json missing) applies createConfig, exactly as pre-migration', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + setupHealthyProject(tmpDir); + fs.unlinkSync(path.join(tmpDir, '.planning', 'config.json')); + + const snapshot = buildPlanningSnapshot(tmpDir); + const diagnostics = evaluateRules(snapshot); + const w003 = diagnostics.find((d) => d.code === 'W003'); + assert.ok(w003, `expected W003 when config.json is missing, got: ${JSON.stringify(diagnostics)}`); + assert.equal(w003.remedy.action, REMEDY_ACTION.CREATE_CONFIG); + assert.equal(w003.remedy.risk, REMEDY_RISK.NONE); + + const result = applyRepairs(tmpDir, diagnostics, true, false); + assert.ok(result.applied.includes('W003'), 'W003 must be applied'); + assert.ok(!result.refused.includes('W003'), 'W003 must not be refused'); + const configPath = path.join(tmpDir, '.planning', 'config.json'); + assert.ok(fs.existsSync(configPath), 'config.json should now exist on disk'); + const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); + assert.equal(diskConfig.model_profile, 'balanced'); }); }); diff --git a/tests/phase-resolution-parity.test.cjs b/tests/phase-resolution-parity.test.cjs index 6cd7c407d..02d343289 100644 --- a/tests/phase-resolution-parity.test.cjs +++ b/tests/phase-resolution-parity.test.cjs @@ -439,14 +439,17 @@ describe('#2528 consumer parity — the eight sites migrated to matchPhaseDirs', }); test(`${name} — roadmap-driven consumers`, () => { - // 5. validate health, W021: STATE must claim the milestone is done for - // the roadmap-vs-disk consistency check to run at all. + // 5. validate health, W026 (Phase 11, #3309 — split off the + // pre-migration 'W021' site for this exact subject; the OTHER W021 + // subject, phase_id_convention mismatch, kept its code): STATE must + // claim the milestone is done for the roadmap-vs-disk consistency + // check to run at all. const health = json('validate health', project(dirs, query, 'milestone complete')); - const w021 = health.warnings.filter((w) => w.code === 'W021'); + const w026 = health.warnings.filter((w) => w.code === 'W026'); assert.strictEqual( - w021.length > 0, + w026.length > 0, !resolves, - `W021 disagreed on whether Phase ${query} is started: ${JSON.stringify(w021)}`, + `W026 disagreed on whether Phase ${query} is started: ${JSON.stringify(w026)}`, ); const tmpDir = project(dirs, query); diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 86b6dc340..2e562db3c 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -2822,9 +2822,14 @@ describe('bug #557 —
/ active milestone strip', () => { ); }); - // ── Health check W021: milestone_complete vs unstarted phases ───────────── + // ── Health check W026: milestone_complete vs unstarted phases ───────────── + // Phase 11 (#3309): this subject moved off the pre-migration 'W021' code + // onto the new 'W026' code (the split-off half of the two-subject + // conflation the design doc's "New codes for the two split subjects" + // section documents) — the OTHER W021 subject, phase_id_convention + // mismatch, kept its code. - test('validate health emits W021 when STATE says milestone complete but ROADMAP has unstarted phases', () => { + test('validate health emits W026 when STATE says milestone complete but ROADMAP has unstarted phases', () => { const planning = path.join(tmpDir, '.planning'); // ROADMAP still has active phases in it fs.writeFileSync(path.join(planning, 'ROADMAP.md'), ROADMAP_DETAILS_SUMMARY, 'utf-8'); @@ -2849,12 +2854,20 @@ Phase: Milestone v1.3 complete const output = JSON.parse(result.output); const warnings = output.warnings || []; - const w021 = warnings.find(w => w.code === 'W021'); + const w026 = warnings.find(w => w.code === 'W026'); assert.ok( - w021 !== undefined, - `Expected W021 warning (milestone-status vs. roadmap-progress incoherence). ` + + w026 !== undefined, + `Expected W026 warning (milestone-status vs. roadmap-progress incoherence). ` + `Got warnings: ${JSON.stringify(warnings.map(w => w.code))}` ); + // W021/W026 independence (Phase 11, #3309 split): this fixture's subject + // is the W026 one (milestone-complete vs. unstarted phases) — it must + // NOT also produce a W021 (phase_id_convention mismatch, an unrelated + // subject this config.json-less fixture never triggers). + assert.ok( + warnings.every(w => w.code !== 'W021'), + `W026 fixture must not also fire W021: ${JSON.stringify(warnings.map(w => w.code))}` + ); }); }); }); diff --git a/tests/verify-health.test.cjs b/tests/verify-health.test.cjs index 26c5fa612..5caa9e7af 100644 --- a/tests/verify-health.test.cjs +++ b/tests/verify-health.test.cjs @@ -173,7 +173,13 @@ describe('validate health command', () => { // ─── Check 4: STATE.md exists and references valid phases ───────────────── - test('errors when STATE.md is missing with repairable true', () => { + test('errors when STATE.md is missing with repairable false (DESTRUCTIVE remedy is never auto-applied)', () => { + // Phase 11 (#3309): E004's remedy (regenerateState) is DESTRUCTIVE, and + // `--repair` refuses to auto-apply a DESTRUCTIVE remedy (design doc, + // "--repair behavior change" section) — a disclosed breaking change from + // the pre-migration `repairable: true`. `repairable` now means "an + // automatic repair will actually run", not merely "a remedy exists to + // describe". writeMinimalProjectMd(tmpDir); writeMinimalRoadmap(tmpDir, ['1']); writeValidConfigJson(tmpDir); @@ -186,7 +192,7 @@ describe('validate health command', () => { const output = JSON.parse(result.output); const e004 = output.errors.find(e => e.code === 'E004'); assert.ok(e004, `Expected E004 in errors: ${JSON.stringify(output.errors)}`); - assert.strictEqual(e004.repairable, true, 'E004 should be repairable'); + assert.strictEqual(e004.repairable, false, 'E004 (DESTRUCTIVE remedy) should not be marked repairable'); }); test('warns when STATE.md references nonexistent phase', () => { @@ -1068,10 +1074,15 @@ describe('validate health --repair command', () => { assert.strictEqual(diskConfig.milestone_branch_template, 'gsd/{milestone}-{slug}'); }); - test('resets config.json when JSON is invalid', () => { + test('Phase 11 (#3309): refuses to reset config.json when JSON is invalid — resetConfig is DESTRUCTIVE, --repair leaves it untouched', () => { + // Pre-migration this repair action applied unconditionally; the design + // doc's "--repair behavior change" section makes this a disclosed + // breaking change: a DESTRUCTIVE remedy is reported (still visible in + // repairs_performed, as a refusal) but never executed by --repair. writeMinimalStateMd(tmpDir, '# Session State\n\nPhase 1 in progress.\n'); const configPath = path.join(tmpDir, '.planning', 'config.json'); - fs.writeFileSync(configPath, '{broken json'); + const originalContent = '{broken json'; + fs.writeFileSync(configPath, originalContent); const result = runGsdTools('validate health --repair', tmpDir); assert.ok(result.success, `Command failed: ${result.error}`); @@ -1082,16 +1093,15 @@ describe('validate health --repair command', () => { `Expected repairs_performed: ${JSON.stringify(output)}` ); const resetAction = output.repairs_performed.find(r => r.action === 'resetConfig'); - assert.ok(resetAction, `Expected resetConfig action: ${JSON.stringify(output.repairs_performed)}`); + assert.ok(resetAction, `Expected a resetConfig refusal entry: ${JSON.stringify(output.repairs_performed)}`); + assert.strictEqual(resetAction.success, false, 'resetConfig must be refused, not applied'); + assert.match(resetAction.error || '', /destructive/i, 'refusal must explain WHY it was not applied'); - // Verify config.json is now valid JSON with correct nested structure - const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); - assert.ok(typeof diskConfig === 'object', 'config.json should be valid JSON after repair'); - assert.ok(diskConfig.workflow, 'reset config should have nested workflow object'); - assert.strictEqual(diskConfig.workflow.research, true, 'workflow.research should be true after reset'); + // config.json must remain exactly as it was — untouched. + assert.strictEqual(fs.readFileSync(configPath, 'utf-8'), originalContent, 'config.json must not be modified by a refused repair'); }); - test('regenerates STATE.md when missing', () => { + test('Phase 11 (#3309): refuses to regenerate STATE.md when missing — regenerateState is DESTRUCTIVE, --repair leaves it absent', () => { writeValidConfigJson(tmpDir); // No STATE.md const statePath = path.join(tmpDir, '.planning', 'STATE.md'); @@ -1106,13 +1116,18 @@ describe('validate health --repair command', () => { `Expected repairs_performed: ${JSON.stringify(output)}` ); const regenerateAction = output.repairs_performed.find(r => r.action === 'regenerateState'); - assert.ok(regenerateAction, `Expected regenerateState action: ${JSON.stringify(output.repairs_performed)}`); - assert.strictEqual(regenerateAction.success, true, 'regenerateState should succeed'); + assert.ok(regenerateAction, `Expected a regenerateState refusal entry: ${JSON.stringify(output.repairs_performed)}`); + assert.strictEqual(regenerateAction.success, false, 'regenerateState must be refused, not applied'); + assert.match(regenerateAction.error || '', /destructive/i, 'refusal must explain WHY it was not applied'); - // Verify STATE.md now exists and contains "# Session State" - assert.ok(fs.existsSync(statePath), 'STATE.md should now exist on disk'); - const stateContent = fs.readFileSync(statePath, 'utf-8'); - assert.ok(stateContent.includes('# Session State'), 'regenerated STATE.md should contain "# Session State"'); + // STATE.md must remain absent, and no backup file should have been created. + assert.strictEqual(fs.existsSync(statePath), false, 'STATE.md must remain absent — the DESTRUCTIVE remedy is refused'); + const planningFiles = fs.readdirSync(path.join(tmpDir, '.planning')); + assert.strictEqual( + planningFiles.some(f => f.startsWith('STATE.md.bak-')), + false, + 'no backup file should be created for a refused repair', + ); }); test('does not rewrite existing STATE.md for invalid phase references', () => { @@ -1168,8 +1183,12 @@ describe('validate health --repair command', () => { assert.strictEqual(diskConfig.workflow.nyquist_validation, true, 'nyquist_validation should be true'); }); - test('reports repairable_count correctly', () => { - // No config.json (W003, repairable=true) and no STATE.md (E004, repairable=true) + test('reports repairable_count correctly — counts NONE-risk findings only, not the DESTRUCTIVE E004', () => { + // No config.json (W003, createConfig, NONE risk -> repairable=true) and no + // STATE.md (E004, regenerateState, DESTRUCTIVE risk -> repairable=false, + // Phase 11 #3309: --repair never auto-applies a DESTRUCTIVE remedy, so it + // is deliberately excluded from this count — see the `diagnosticToIssueEntry` + // comment in src/verify.cts for the full reasoning). const configPath = path.join(tmpDir, '.planning', 'config.json'); if (fs.existsSync(configPath)) fs.unlinkSync(configPath); const statePath = path.join(tmpDir, '.planning', 'STATE.md'); @@ -1180,9 +1199,15 @@ describe('validate health --repair command', () => { assert.ok(result.success, `Command failed: ${result.error}`); const output = JSON.parse(result.output); - assert.ok( - output.repairable_count >= 2, - `Expected repairable_count >= 2, got ${output.repairable_count}. Full output: ${JSON.stringify(output)}` + const w003 = output.warnings.find(w => w.code === 'W003'); + const e004 = output.errors.find(e => e.code === 'E004'); + assert.ok(w003, `Expected W003 in warnings: ${JSON.stringify(output.warnings)}`); + assert.ok(e004, `Expected E004 in errors: ${JSON.stringify(output.errors)}`); + assert.strictEqual(w003.repairable, true, 'W003 (createConfig, NONE risk) should be repairable'); + assert.strictEqual(e004.repairable, false, 'E004 (regenerateState, DESTRUCTIVE risk) should not be repairable'); + assert.strictEqual( + output.repairable_count, 1, + `Expected repairable_count 1 (W003 only), got ${output.repairable_count}. Full output: ${JSON.stringify(output)}` ); }); diff --git a/tests/verify.test.cjs b/tests/verify.test.cjs index a1b62d944..b1a9b9583 100644 --- a/tests/verify.test.cjs +++ b/tests/verify.test.cjs @@ -1938,6 +1938,76 @@ test('--backfill synthesizes missing MILESTONES.md entry from snapshot', () => { assert.ok(content.includes('Backfilled'), 'should note it was backfilled'); }); +// Phase 11 (#3309): pre-migration, `--backfill` ALONE (without `--repair`) +// was dead code — `verify.cts:2504`'s inner backfill gate was unreachable +// because the outer `if (options['repair'] && repairs.length > 0)` gate +// already required `repair`. The migrated `applyRepairs` threads `backfill` +// as its own boolean (`repair || backfill` for `backfillMilestones` +// specifically), so `--backfill` alone now actually works — a disclosed +// latent-bug fix (design doc, "Known limits"), not a preservation +// requirement. +test('--backfill alone (without --repair) now synthesizes the missing MILESTONES.md entry', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + '.planning/config.json': '{}', + '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0 First Release\n', + }); + + cmdValidateHealth(dir, { repair: false, backfill: true }, false); + + const milestonesPath = path.join(dir, '.planning', 'MILESTONES.md'); + assert.ok(fs.existsSync(milestonesPath), '--backfill alone should create MILESTONES.md'); + const content = fs.readFileSync(milestonesPath, 'utf-8'); + assert.ok(content.includes('## v1.0'), 'backfilled entry should contain v1.0'); + assert.ok(content.includes('Backfilled'), 'should note it was backfilled'); +}); + +test('--backfill alone does NOT apply an unrelated NONE-risk repair (createConfig) — only backfillMilestones is gated by backfill', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n', + '.planning/STATE.md': '# State\n', + // No config.json — W003 (createConfig) would fire and be repairable, but + // must NOT be applied by --backfill alone (only --repair applies it). + '.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0 First Release\n', + }); + + cmdValidateHealth(dir, { repair: false, backfill: true }, false); + + const configPath = path.join(dir, '.planning', 'config.json'); + assert.strictEqual(fs.existsSync(configPath), false, 'config.json must not be created by --backfill alone'); + const milestonesPath = path.join(dir, '.planning', 'MILESTONES.md'); + assert.ok(fs.existsSync(milestonesPath), '--backfill alone should still create MILESTONES.md'); +}); + +// Phase 11 (#3309): W021 (phase_id_convention integer-prefix/milestone +// mismatch) and W026 (STATE milestone-complete vs. unstarted ROADMAP +// phases) are the split-off halves of the pre-migration 'W021' code — two +// genuinely unrelated subjects (design doc, "New codes for the two split +// subjects" section). This fixture triggers ONLY the phase_id_convention +// mismatch (W021's remaining subject) and must not also produce W026. +test('W021 (phase_id_convention mismatch) fires independently of W026 — same fixture never also emits W026', () => { + const dir = makeTempProject({ + '.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n', + '.planning/ROADMAP.md': '# Roadmap\n\n## [GSD] v2.0 — Expansion\n\n### Phase 1-01: Setup\n**Goal:** g\n', + // STATE.md status is plainly "In progress" — never "milestone complete" + // or "archived", so W026's precondition never holds for this fixture. + '.planning/STATE.md': '# State\n\n## Current Position\n\nPhase: 1-01\n\n**Status:** In progress\n', + '.planning/config.json': JSON.stringify({ phase_id_convention: 'milestone-prefixed' }), + }); + + const result = cmdValidateHealth(dir, { repair: false }, false); + + const w021 = result.warnings.find(w => w.code === 'W021'); + assert.ok(w021, `expected W021 for phase 1-01 (implies v1.0) listed under v2.0: ${JSON.stringify(result.warnings)}`); + assert.ok( + result.warnings.every(w => w.code !== 'W026'), + `W021 fixture must not also fire W026: ${JSON.stringify(result.warnings.map(w => w.code))}` + ); +}); + test('health.md mentions --backfill flag', () => { const healthMd = fs.readFileSync( path.join(__dirname, '../gsd-core/workflows/health.md'), 'utf-8' From 9f8bcfa3a644207252840c29489694d4f2764f43 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 02:28:56 -0400 Subject: [PATCH 17/35] docs(#3309): disclose hardcoded-slash-command fidelity reduction consistently MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit root-existence.cts (E002, E003) and phase-structure.cts (W009) hardcode a canonical hyphen-form slash command in their ADVISE remedy, same as config-validation.cts's already-disclosed W016 — forced by §8.1 rule 1 (a Rule's check(snapshot) cannot call the runtime-resolved slash() formatter, which needs cwd). Only config-validation.cts's own header disclosed this tradeoff; the other two sites had it happen without recording it in their own file. Adds the same disclosure to both, matching the established convention. No behavior change. --- src/health-diagnostic-rules/phase-structure.cts | 10 ++++++++++ src/health-diagnostic-rules/root-existence.cts | 12 ++++++++++++ 2 files changed, 22 insertions(+) diff --git a/src/health-diagnostic-rules/phase-structure.cts b/src/health-diagnostic-rules/phase-structure.cts index 43937141d..5f441f5b4 100644 --- a/src/health-diagnostic-rules/phase-structure.cts +++ b/src/health-diagnostic-rules/phase-structure.cts @@ -31,6 +31,16 @@ * `verificationStatus`) — a disclosed fidelity reduction, not a silent * reproduction of the original six-way label. * + * W009's original message interpolates `${slash('plan-phase')}` + * (`verify.cts:1982`, ``Re-run ${slash('plan-phase')} with --research to + * regenerate``), a per-project runtime-resolved value (`formatGsdSlash`, + * `src/runtime-slash.cts`) this rule's `(snapshot) => Diagnostic[]` + * signature has no access to. Hardcodes the canonical `/gsd-plan-phase` + * hyphen form instead, mirroring the sibling "config.json validation" + * group's W016 rule (`src/health-diagnostic-rules/config-validation.cts`), + * which hardcodes `/gsd-ai-integration-phase` the same way for the + * identical reason. + * * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md * * ADR-457 build-at-publish: source in diff --git a/src/health-diagnostic-rules/root-existence.cts b/src/health-diagnostic-rules/root-existence.cts index 5e6f93dbe..518feb82d 100644 --- a/src/health-diagnostic-rules/root-existence.cts +++ b/src/health-diagnostic-rules/root-existence.cts @@ -10,6 +10,18 @@ * Ported behavior-preserving from `cmdValidateHealth` * (`src/verify.cts:1681-1705`), the exact call sites for E002/E003/E004/W001. * + * E002's original message interpolates `${slash('new-project')}` + * (`verify.cts:1682`, ``Run ${slash('new-project')} to create``) and E003's + * interpolates `${slash('new-milestone')}` (`verify.cts:1694`, ``Run + * ${slash('new-milestone')} to create roadmap``) — both per-project + * runtime-resolved values (`formatGsdSlash`, `src/runtime-slash.cts`) this + * rule's `(snapshot) => Diagnostic[]` signature has no access to (§8.1 rule + * 1 forbids ambient I/O, including `cwd`, inside `check`). Hardcodes the + * canonical `/gsd-new-project`/`/gsd-new-milestone` hyphen form instead, + * mirroring the sibling "config.json validation" group's W016 rule + * (`src/health-diagnostic-rules/config-validation.cts`), which hardcodes + * `/gsd-ai-integration-phase` the same way for the identical reason. + * * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md * * ADR-457 build-at-publish: source in src/health-diagnostic-rules/root-existence.cts, From 42729b21fa1220034afe34e3bd018536758e9c0c Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 02:56:12 -0400 Subject: [PATCH 18/35] fix: scan bin/lib subdirectories in the inventory-manifest generator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gen-inventory-manifest.cjs's cli_modules family did a flat readdirSync of gsd-core/bin/lib/, invisible to anything shipped in a subdirectory. Found while registering this phase's 8 health-diagnostic-rules/*.cjs files in docs/INVENTORY.md (Standards-axis review) — the automated manifest cross-check couldn't see them even though the manual INVENTORY.md rows were correct. Adds collectOneLevelSubdirs (mirrors the existing collectNested's defensive statOrNull style) and merges flat + one-level-subdirectory results into cli_modules's single sorted array, using the same /.cjs key format INVENTORY.md's rows already use. Regenerating the manifest surfaced that three OTHER existing subdirectories (installer-migrations/, host-integration-adapters/, observability/ — pre-existing, unrelated to this phase) were equally invisible and had zero docs/INVENTORY.md rows at all. Added all 15 missing rows rather than leave a gap the fix itself just exposed. Also fixes 3 pre-existing lint-legacy-dir-name violations in the installer-migrations rows (legitimate references to the historical get-shit-done -> gsd-core rename these migrations clean up — marked with the guard's own gsd-allow-legacy-name exemption) and a stale health-diagnostic.cjs row that still said "RULES ships empty." --- docs/INVENTORY-MANIFEST.json | 23 +++++ docs/INVENTORY.md | 17 +++- scripts/gen-inventory-manifest.cjs | 54 +++++++++++- tests/inventory-manifest-sync.test.cjs | 15 ++-- tests/inventory-nested-families.test.cjs | 104 ++++++++++++++++++++++- 5 files changed, 201 insertions(+), 12 deletions(-) diff --git a/docs/INVENTORY-MANIFEST.json b/docs/INVENTORY-MANIFEST.json index 42a7a7289..d59f4d995 100644 --- a/docs/INVENTORY-MANIFEST.json +++ b/docs/INVENTORY-MANIFEST.json @@ -378,9 +378,19 @@ "graphify.cjs", "gsd2-import.cjs", "handshake-serialized.cjs", + "health-diagnostic-rules/agent-install.cjs", + "health-diagnostic-rules/config-validation.cjs", + "health-diagnostic-rules/milestone-archive-hygiene.cjs", + "health-diagnostic-rules/phase-structure.cjs", + "health-diagnostic-rules/roadmap-disk-consistency.cjs", + "health-diagnostic-rules/root-existence.cjs", + "health-diagnostic-rules/state-consistency.cjs", + "health-diagnostic-rules/worktree-health.cjs", "health-diagnostic-types.cjs", "health-diagnostic.cjs", "hook-bus.cjs", + "host-integration-adapters/cline-sdk-binding.cjs", + "host-integration-adapters/imperative-hook-bus.cjs", "host-integration-sdk.cjs", "host-integration.cjs", "host-runtime-detection.cjs", @@ -394,6 +404,16 @@ "installer-migration-authoring.cjs", "installer-migration-report.cjs", "installer-migrations.cjs", + "installer-migrations/000-first-time-baseline.cjs", + "installer-migrations/001-legacy-orphan-files.cjs", + "installer-migrations/002-codex-legacy-hooks-json.cjs", + "installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs", + "installer-migrations/004-prune-stale-pristine-snapshots.cjs", + "installer-migrations/005-opencode-baseline-commands-dir.cjs", + "installer-migrations/006-pi-extension-cjs-to-js.cjs", + "installer-migrations/007-retire-config-root-commonjs-marker.cjs", + "installer-migrations/008-cursor-retire-commands-surface.cjs", + "installer-migrations/009-pi-retire-reserved-hooks-dir.cjs", "intel-command-router.cjs", "intel.cjs", "io.cjs", @@ -411,6 +431,9 @@ "model-profiles.cjs", "model-resolver.cjs", "normalize-test-command.cjs", + "observability/event.cjs", + "observability/logger.cjs", + "observability/redaction.cjs", "onboard-projection.cjs", "package-identity.cjs", "package-legitimacy.cjs", diff --git a/docs/INVENTORY.md b/docs/INVENTORY.md index 6ed348057..b693ac546 100644 --- a/docs/INVENTORY.md +++ b/docs/INVENTORY.md @@ -432,6 +432,16 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | Module | Responsibility | |--------|----------------| +| `installer-migrations/000-first-time-baseline.cjs` | Installer migration: records the first-time installer migration baseline scan — walks per-runtime install surfaces so pre-existing files are classified before any later migration runs | +| `installer-migrations/001-legacy-orphan-files.cjs` | Installer migration: removes manifest-managed legacy orphan hook files (`hooks/gsd-notify.sh`, `hooks/statusline.js`) | +| `installer-migrations/002-codex-legacy-hooks-json.cjs` | Installer migration: removes legacy Codex `hooks.json` GSD hook registrations | +| `installer-migrations/003-rename-get-shit-done-to-gsd-core.cjs` | Installer migration: removes stale legacy `get-shit-done/` runtime directory files after the rename to `gsd-core/` (#604) | +| `installer-migrations/004-prune-stale-pristine-snapshots.cjs` | Installer migration: removes stale `gsd-pristine/get-shit-done/` snapshot files left behind by the get-shit-done → gsd-core rename (#604, #934) | +| `installer-migrations/005-opencode-baseline-commands-dir.cjs` | Installer migration: baselines pre-existing OpenCode `commands/` (plural) files missed by migration 000's RUNTIME_SURFACES list (#2329 follow-up) | +| `installer-migrations/006-pi-extension-cjs-to-js.cjs` | Installer migration: retires pi's stale `extensions/gsd.cjs` after #2470 renamed the installed native extension to `extensions/gsd.js` | +| `installer-migrations/007-retire-config-root-commonjs-marker.cjs` | Installer migration: retires the config-root `{"type":"commonjs"}` marker that pre-#2544 installs wrote over `/package.json` | +| `installer-migrations/008-cursor-retire-commands-surface.cjs` | Installer migration: retires Cursor's duplicate `commands/` surface now that skills are the sole workflow surface (#2644) | +| `installer-migrations/009-pi-retire-reserved-hooks-dir.cjs` | Installer migration: retires pi's legacy `hooks/` directory after GSD's shared hook bundle moved to `gsd-hooks/` (#3023) | | `active-workstream-store.cjs` | Workstream source precedence and selection (CLI `--ws` > `GSD_WORKSTREAM` env > stored pointer); name validation and environment propagation | | `adr-parser.cjs` | ADR decision parser for plan-phase ingest express path; normalizes section synonyms, parses status/decision/scope fences, and enforces status rejection gates | | `agent-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools agent` | @@ -458,6 +468,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `claude-orchestration.cjs` | Claude Orchestration capability (#1143) — Workflow-tool backend detection + emitter; `detectWorkflowBackend` fail-closed gate (`{available, backend: 'workflow'\|'inline', reason}`, degrades to today's inline behavior unless every gate opens) and `emitWorkflowScript` (maps GSD's wave/plan model onto Workflow primitives: wave → sequential `parallel()` barriers, plan → `agent(...)` with per-plan worktree isolation mirroring the inline path). Pure, zero external dependencies, never throws; never invokes the Workflow tool itself | | `cli-exit.cjs` | `ExitError` class and `runMain()` helper — CLI entrypoints throw `ExitError` instead of calling `process.exit()`; `runMain()` translates the outcome into `process.exitCode` so output flushes cleanly | | `cjs-command-router-adapter.cjs` | Shared compatibility adapter for manifest-backed CJS command-family routers | +| `host-integration-adapters/cline-sdk-binding.cjs` | Cline SDK binding — pure AgentPlugin `beforeTool` planning-artifact guard and `createAgentModel` model-override resolution adapters, no `@cline/sdk` import (ADR-1239 Phase D, #2090) | | `clock.cjs` | Injectable clock seam (now/sleep) for deterministic lock testing | | `clusters.cjs` | Skill cluster definitions for the runtime surface module (ADR-0011 Phase 2) | | `code-review-flags.cjs` | Typed flag parser for `/gsd-code-review`; exports `parseCodeReviewFlags(argv)` (→ `{ fix, all, auto, depth, files }`) and `resolveCodeReviewWorkflow(flags)` (→ `'code-review.md' \| 'code-review-fix.md'`); canonical dispatch seam for `--fix`/`--all`/`--auto` routing | @@ -487,6 +498,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `eval-command-router.cjs` | Routes the `eval.score` verb (compiled from `src/eval-command-router.cts`, gitignored) — thin dispatcher into the eval scoring module (#1579) | | `eval.cjs` | Deterministic eval scoring (compiled from `src/eval.cts`, gitignored) — `computeEvalScore` (coverage*0.6 + infra*0.4, bands 80/60/40) + `cmdEvalScore` CLI domain guard; moves the gsd-eval-auditor's weighted arithmetic out of the prompt into code (#10 / #1579) | | `estimate-cli.cjs` | I/O seam over `phase-estimation.cjs` — the `estimate-check` and `estimate-calibration` query verbs; reads the `workflow.smart_zone_tokens` budget and `.planning/estimation-calibration.json`, both degrading to defaults rather than failing planning (#2630) | +| `observability/event.cjs` | DispatchEvent shape factory for every Hub dispatch — traceId/parentTraceId/command/result/timestamp record consumed by DispatchLogger (#177, ADR-0174 P1.3/P1.4) | | `fallow-runner.cjs` | Fallow audit adapter for `/gsd-code-review`: binary resolution (`PATH` then `node_modules/.bin`), actionable missing-binary errors, and structural findings normalization | | `federated-config.cjs` | Defensive merge of capability-declared config slices into the loadConfig return value — ADR-857 phase 3b; exports `mergeFederatedConfig({ configSchema, isCentralKey, userConfig })` → `{ values, validKeys, warnings }`; live for migrated Capability keys that are atomically removed from the central config schema | | `frontmatter.cjs` | YAML frontmatter CRUD operations | @@ -496,9 +508,10 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `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-types.cjs` | Shared, dependency-free `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types for `validate health` — split out of `health-diagnostic.cjs` so its rule-group files can depend on the enums/types without a CJS circular require back into the evaluator (ADR-3180 §8.2/§8.3/§8.5, Phase 11, #3309) | -| `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) | +| `health-diagnostic.cjs` | Frozen rule-table contract for `validate health` — `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums, `Diagnostic`/`Remedy`/`Rule` shapes, the fully-wired `RULES` table (the static concatenation of the 31 rules exported by the eight `health-diagnostic-rules/*.cjs` group files), `evaluateRules` (throws on duplicate rule codes), and `applyRepairs` (the real `--repair`/`--backfill` dispatcher with real per-action handlers — 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 | +| `host-integration-adapters/imperative-hook-bus.cjs` | Imperative hook-bus adapter — descriptor-driven `hooks.json` binding generalized from the Cursor-specific writer, resolving the negotiated `hookBus` axis against a host's documented `hostBehaviors.managedHookEvents` list; pure, no I/O (ADR-1239 Phase D, #2089) | | `init-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools init` | | `init.cjs` | Compound context loading for each workflow type | | `install-effort-resolver.cjs` | Install-time effort resolution — `readGsdEffectiveEffortConfig` (merges `~/.gsd/defaults.json` + project `.planning/config.json`) + `resolveInstallTimeEffort`, extracted from `bin/install.js` (#2071) so `gsd-tools effort sync` can require it from the shipped runtime instead of the never-copied package-root installer; install.js imports them back (single source) | @@ -514,6 +527,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `io.cjs` | CLI I/O primitives — `output`/`error` emission, JSON-error mode, and large-payload temp-file spillover (extracted from `core.cjs`, ADR-857) | | `learnings.cjs` | Cross-phase learnings extraction for `/gsd-extract-learnings` | | `legacy-cleanup.cjs` | Detect and remove leftover get-shit-done-cc artifacts; exports `planLegacyCleanup` (pure scan) and `applyLegacyCleanup` (thin IO applier) that root out stale files from the old package across every GSD-managed runtime config directory (#607) | +| `observability/logger.cjs` | DispatchLogger interface + default implementation — silent on success, structured stderr JSON on error, opt-in `.gsd-trace.jsonl` audit file (`GSD_AUDIT=1`), args omitted unless `GSD_AUDIT_ARGS=1` (#177, ADR-0174 P1.3) | | `loop-host-contract.cjs` | Generated Loop Host Contract — 12 loop points, per-step agent roles, and core artifacts for the five-step pipeline (discuss/plan/execute/verify/ship); emitted by `scripts/gen-loop-host-contract.cjs --write` (ADR-894 §3); consumed by `gen-capability-registry.cjs` | | `loop-resolver.cjs` | Loop Extension Point resolver — ADR-857 phase 3c/6 registry-consuming query; given a canonical loop point, filters `byLoopPoint` by resolved Capability State plus config activation (`when` key traversal with prototype-pollution guard), returns `{ point, activeHooks, rendered }` envelope; `resolveLoopHooks` and `renderLoopHooks` are pure (no I/O); command surface: `gsd-tools loop render-hooks [--config-dir ]` | | `markdown-sectionizer.cjs` | Canonical markdown-structure parsing seam (ADR-1372, epic #1372) — pure, Node built-ins only; exports `stripFencedCode` (CommonMark-correct fence stripper, CRLF-safe), `stripInlineCode` (per-line CommonMark inline-code-span stripper, #2365), `tokenizeHeadings` (ATX headings outside fenced blocks), `collectSections`/`collectSection` (line-by-line section collection with `bodyStart`/`bodyEnd` offsets), `iterateBullets` (dash/checkbox/numbered markers), `extractTaggedBlocks` (inner text of `…` blocks, caller decides fence-stripping), `replaceSection` (pure character-offset body splice for read-modify-write callers), and `withSection` (resolve a section by heading/predicate and run an edit callback against ONLY its body, splicing the result back — ADR-2143 §4 bounded mutation); foundation for T0–T7 migration tiers retiring 8+ ad-hoc parsers | @@ -543,6 +557,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`. | `profile-pipeline-command-router.cjs` | ADR-959 capability command router for the profile-pipeline command family — dispatches scan-sessions, extract-messages, profile-sample (pipeline phase) and write-profile, profile-questionnaire, generate-dev-preferences, generate-claude-profile, generate-claude-md (output phase); phase 6 cutover | | `profile-pipeline.cjs` | User behavioral profiling data pipeline, session file scanning | | `prompt-budget.cjs` | Pure token-budget accounting for review prompts — estimates tokens, applies deterministic trim priority (head-shrink PROJECT.md, proportional plan truncation, drop context/research/requirements, hard-fail guard), returns structured metadata for `review.max_prompt_tokens` (#3081) | +| `observability/redaction.cjs` | Arg redaction policy for dispatch events — args omitted from every emitted event by default, opt-in verbatim inclusion via `GSD_AUDIT_ARGS=1`; stateless env read, no module-level caching (#177) | | `refactor-trigger-command-router.cjs` | ADR-959 capability command router for `gsd-tools refactor` (issue #1953) — dispatches evaluate/status/accept/decline subcommands for the complexity-triggered refactor capability; owns capability-activation gating, git invocation (via the `git-base-branch.cjs` `phaseStartCommit`/`changedFilesSince` adapters), config reads, phase-directory resolution, and the optional broken-windows ledger integration around the pure `complexity-trigger.cjs` leaf | | `research-provider.cjs` | Research provider waterfall, confidence tiers, and planResearch (cache-hits + fetch plan) | | `research-store.cjs` | Content-addressed research cache: sha256 keys, per-source TTL staleness, two-tier (user ~/.gsd / project .planning) store | diff --git a/scripts/gen-inventory-manifest.cjs b/scripts/gen-inventory-manifest.cjs index e65c7c580..094e97023 100644 --- a/scripts/gen-inventory-manifest.cjs +++ b/scripts/gen-inventory-manifest.cjs @@ -117,6 +117,47 @@ function statOrNull(p) { } } +/** + * Collect `//` entries as `/` keys — ONE level of + * subdirectory beneath `dir` itself, where the subdirectory's NAME is the thing being + * collected (unlike `collectNested`, there is no fixed subdir name to look for; every + * child directory of `dir` is scanned). This is what makes `gsd-core/bin/lib//*.cjs` + * (e.g. `health-diagnostic-rules/`, `installer-migrations/`, `host-integration-adapters/`, + * `observability/`) visible to the `cli_modules` family, mirroring the shape + * `docs/INVENTORY.md`'s CLI Modules table already uses for these files. + * + * Same defensive `statOrNull`-based style as `collectNested`: a stat/readdir failure on + * one entry is swallowed rather than thrown, so one unreadable subdirectory cannot take + * down `--check` for the whole repo. + */ +function collectOneLevelSubdirs({ dir, filter }) { + if (!fs.existsSync(dir)) return []; + const out = []; + let children; + try { + children = fs.readdirSync(dir); + } catch { + return []; + } + for (const child of children) { + const childStat = statOrNull(path.join(dir, child)); + if (!childStat || !childStat.isDirectory()) continue; + const subdirPath = path.join(dir, child); + let files; + try { + files = fs.readdirSync(subdirPath); + } catch { + continue; + } + for (const file of files) { + const fileStat = statOrNull(path.join(subdirPath, file)); + if (!fileStat || !fileStat.isFile() || !filter(file)) continue; + out.push([child, file].join('/')); + } + } + return out.sort(); +} + function collectNested({ root, subdir, filter }) { if (!fs.existsSync(root)) return []; const out = []; @@ -150,11 +191,16 @@ function collectNested({ root, subdir, filter }) { function buildManifest() { const manifest = { families: {} }; for (const { name, dir, filter, toName } of FAMILIES) { - manifest.families[name] = fs + const flat = fs .readdirSync(dir) .filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f)) - .map(toName) - .sort(); + .map(toName); + // `cli_modules` also ships subdirectory modules (`health-diagnostic-rules/`, + // `installer-migrations/`, `host-integration-adapters/`, `observability/`) invisible to + // the flat readdirSync above; merge them into the SAME sorted array, matching the single + // "CLI Modules" table shape docs/INVENTORY.md already uses (#3309). + const nested = name === 'cli_modules' ? collectOneLevelSubdirs({ dir, filter }) : []; + manifest.families[name] = [...flat, ...nested].sort(); } for (const family of NESTED_FAMILIES) { manifest.families[family.name] = collectNested(family); @@ -209,4 +255,4 @@ if (require.main === module) { // `DEFECT.GENERATIVE-FIX` divergence class: adding a family here while the test kept // its own list meant the test silently verified fewer families than shipped, and still // passed. The test now imports these, so the two surfaces cannot drift. -module.exports = { FAMILIES, NESTED_FAMILIES, collectNested, buildManifest }; +module.exports = { FAMILIES, NESTED_FAMILIES, collectNested, collectOneLevelSubdirs, buildManifest }; diff --git a/tests/inventory-manifest-sync.test.cjs b/tests/inventory-manifest-sync.test.cjs index 5108b1f97..9c1a94b75 100644 --- a/tests/inventory-manifest-sync.test.cjs +++ b/tests/inventory-manifest-sync.test.cjs @@ -20,7 +20,7 @@ const MANIFEST_PATH = path.join(ROOT, 'docs', 'INVENTORY-MANIFEST.json'); // a family added to the generator but not here left this test silently verifying a // subset while still reporting green. Importing makes divergence impossible rather than // merely detectable. -const { FAMILIES, NESTED_FAMILIES, collectNested } = require('../scripts/gen-inventory-manifest.cjs'); +const { FAMILIES, NESTED_FAMILIES, collectNested, collectOneLevelSubdirs } = require('../scripts/gen-inventory-manifest.cjs'); test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => { const committed = JSON.parse(fs.readFileSync(MANIFEST_PATH, 'utf8')); @@ -28,11 +28,14 @@ test('docs/INVENTORY-MANIFEST.json matches the filesystem', () => { const removals = []; for (const { name, dir, filter, toName } of FAMILIES) { - const live = new Set( - fs.readdirSync(dir) - .filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f)) - .map(toName), - ); + const flat = fs.readdirSync(dir) + .filter((f) => fs.statSync(path.join(dir, f)).isFile() && filter(f)) + .map(toName); + // `cli_modules` also ships one level of subdirectory modules (#3309); mirror + // buildManifest's special-case merge exactly, or this test would report every + // subdirectory file as a phantom removal. + const nested = name === 'cli_modules' ? collectOneLevelSubdirs({ dir, filter }) : []; + const live = new Set([...flat, ...nested]); const recorded = new Set((committed.families || {})[name] || []); for (const entry of live) { diff --git a/tests/inventory-nested-families.test.cjs b/tests/inventory-nested-families.test.cjs index bacb06d57..d370eded7 100644 --- a/tests/inventory-nested-families.test.cjs +++ b/tests/inventory-nested-families.test.cjs @@ -18,10 +18,11 @@ const fs = require('node:fs'); const os = require('node:os'); const path = require('node:path'); -const { collectNested } = require('../scripts/gen-inventory-manifest.cjs'); +const { collectNested, collectOneLevelSubdirs } = require('../scripts/gen-inventory-manifest.cjs'); const { cleanup } = require('./helpers.cjs'); const MD_ONLY = (f) => f.endsWith('.md'); +const CJS_ONLY = (f) => f.endsWith('.cjs'); /** Build a fixture tree: {parentName: {subdirName: [fileNames]}}. */ function buildTree(spec) { @@ -174,3 +175,104 @@ test('collectNested is deterministic and sorted', (t) => { assert.deepStrictEqual(first, second); assert.deepStrictEqual(first, ['alpha/steps/c.md', 'zeta/steps/a.md', 'zeta/steps/b.md']); }); + +// ─── collectOneLevelSubdirs — cli_modules one-level subdir scan (#3309) ─────── +// +// Unlike collectNested's FIXED subdir name (many parents, one shared child-dir +// name like `steps`), this helper's `dir` IS the parent, and every one of ITS +// child directories is the thing being collected — e.g. `bin/lib/health-diagnostic-rules/`. + +/** Build a fixture tree directly under `dir`: {subdirName: [fileNames]} \| flat file list. */ +function buildSubdirTree(dir, spec) { + for (const [subdir, files] of Object.entries(spec)) { + const subdirPath = path.join(dir, subdir); + fs.mkdirSync(subdirPath, { recursive: true }); + for (const f of files) fs.writeFileSync(path.join(subdirPath, f), '// fixture\n'); + } +} + +test('collectOneLevelSubdirs picks up a file inside a subdirectory', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { 'health-diagnostic-rules': ['root-existence.cjs'] }); + + assert.deepStrictEqual( + collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), + ['health-diagnostic-rules/root-existence.cjs'], + ); +}); + +test('collectOneLevelSubdirs merges multiple subdirectories, sorted', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { + observability: ['redaction.cjs', 'event.cjs'], + 'installer-migrations': ['001-x.cjs'], + }); + + assert.deepStrictEqual( + collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), + ['installer-migrations/001-x.cjs', 'observability/event.cjs', 'observability/redaction.cjs'], + ); +}); + +test('collectOneLevelSubdirs contributes nothing for an empty subdirectory', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + fs.mkdirSync(path.join(dir, 'empty-subdir'), { recursive: true }); + + assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), []); +}); + +test('collectOneLevelSubdirs ignores files directly in dir (flat scan owns those)', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + fs.writeFileSync(path.join(dir, 'top-level.cjs'), '// fixture\n'); + buildSubdirTree(dir, { subdir: ['nested.cjs'] }); + + assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), ['subdir/nested.cjs']); +}); + +test('collectOneLevelSubdirs applies the filter and ignores non-matching files', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { subdir: ['keep.cjs', 'skip.md', 'skip.json'] }); + + assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), ['subdir/keep.cjs']); +}); + +test('collectOneLevelSubdirs does not recurse past one level', (t) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { subdir: ['top.cjs'] }); + const deep = path.join(dir, 'subdir', 'deeper'); + fs.mkdirSync(deep, { recursive: true }); + fs.writeFileSync(path.join(deep, 'too-deep.cjs'), '// fixture\n'); + + assert.deepStrictEqual(collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), ['subdir/top.cjs']); +}); + +test('collectOneLevelSubdirs on a missing dir contributes nothing rather than throwing', () => { + assert.deepStrictEqual( + collectOneLevelSubdirs({ dir: path.join(os.tmpdir(), 'gsd-3309-does-not-exist'), filter: CJS_ONLY }), + [], + ); +}); + +test('collectOneLevelSubdirs skips a dangling symlink without crashing the walk', (t) => { + if (process.platform === 'win32') { + t.skip('symlink creation requires elevation on Windows; the unstattable-entry path is asserted on macOS + Linux'); + return; + } + + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-3309-onelevel-')); + t.after(() => cleanup(dir)); + buildSubdirTree(dir, { subdir: ['real.cjs'] }); + fs.symlinkSync(path.join(dir, 'subdir', 'nope.cjs'), path.join(dir, 'subdir', 'dangling.cjs')); + + assert.deepStrictEqual( + collectOneLevelSubdirs({ dir, filter: CJS_ONLY }), + ['subdir/real.cjs'], + 'one unreadable entry must not take down the whole scan', + ); +}); From 1255f960db8a7e92f78a5149f4b75f664e0c4bfe Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 02:56:23 -0400 Subject: [PATCH 19/35] fix(#3309): restore W027's active-worktree exclusion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The migrated checkW027 (stale worktree) dropped the pre-migration exclusion of the CLI's own current worktree, since a Rule.check(snapshot) has no cwd access (§8.1 rule 1 forbids ambient I/O) — flagged as a disclosed regression during this phase's own design work, then confirmed as a real, fixable gap by the Spec-axis orthogonal review rather than an inherent limitation. Fixes it properly instead of accepting the regression: buildPlanningSnapshot(cwd) already receives cwd as its own input, so exposing it as snapshot.cwd is not new ambient I/O, just surfacing an existing parameter — fully consistent with §8.1 rule 2's "parsed value" allowance. checkW027 now excludes the entry matching snapshot.cwd before flagging, matching the original verify.cts:2233-2242 behavior exactly. --- .../worktree-health.cts | 76 +++++++------------ src/planning-snapshot.cts | 8 ++ .../worktree-health.test.cjs | 64 ++++++++++++---- 3 files changed, 85 insertions(+), 63 deletions(-) diff --git a/src/health-diagnostic-rules/worktree-health.cts b/src/health-diagnostic-rules/worktree-health.cts index d96e3d66c..4ca349b5a 100644 --- a/src/health-diagnostic-rules/worktree-health.cts +++ b/src/health-diagnostic-rules/worktree-health.cts @@ -13,7 +13,7 @@ * pre-migration source still names the split-off stale-worktree site * 'W017' — this batch is what actually applies the W027 split). * - * KNOWN GAPS (found while building, reported rather than papered over — see + * KNOWN GAP (found while building, reported rather than papered over — see * this batch's dispatch report for full detail): * * 1. W020's original THREE conditions were git_timed_out / git_list_failed / @@ -31,25 +31,10 @@ * with the discarded `reason` field — an snapshot-field enhancement * outside this rule-file batch's scope, flagged here rather than guessed * around. - * 2. W027's original exclusion of the active session's own worktree - * (`verify.cts:2233-2242`, comparing `finding.path` against - * `process.cwd()`) happens at the `cmdValidateHealth` call site, NOT - * inside `inspectWorktreeHealth`/`worktree-safety.cts`. Confirmed by - * direct read: `inspectWorktreeHealth` (`src/worktree-safety.cts:352-397`) - * performs no cwd comparison, and `listLinkedWorktreePaths` - * (`src/worktree-safety.cts:321-338`) only drops the FIRST `git worktree - * list` entry (assumed main worktree) via `.slice(1)` — it does not know - * which entry, if any, is the ACTIVE session's cwd, which is commonly a - * LINKED (non-first) worktree in this repo's own multi-worktree workflow. - * A `Rule.check(snapshot)` has no ambient `process.cwd()` access (§8.1 - * rule 1 forbids it), and `PlanningSnapshot` carries no - * "active worktree path" field to filter against. This is a REAL, - * unclosed gap: `checkW027` below reports every 'stale' finding, - * INCLUDING the active session's own worktree, which is a behavior - * change from the pre-migration code. Closing it precisely requires - * either a new snapshot field carrying the active worktree path/cwd, or - * moving the exclusion into `inspectWorktreeHealth` itself — both are - * snapshot/owner changes outside this rule-file batch's scope. + * + * W027 restores the pre-migration active-worktree exclusion + * (`verify.cts:2233-2242`) via `PlanningSnapshot.cwd` — see `checkW027`'s own + * comment below for the mechanism. * * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md * @@ -58,6 +43,8 @@ * gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs (gitignored). */ +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'); @@ -65,7 +52,7 @@ type PlanningSnapshot = ReturnType` -// template, mirroring the split the brief specifies. +// `finding.kind === 'stale'` — age-based. Excludes the active session's own +// worktree, restored via `snapshot.cwd` (see module doc, gap 2 — RESOLVED): +// a 'stale' finding is skipped when `snapshot.cwd` equals the finding's path +// or is nested under it, the exact comparison `verify.cts:2238-2241` made +// against `process.cwd()`. Per this batch's brief: the interpolated command +// (with the real path) lives in `message`; `remedy.args.command` stays a +// static `` template, mirroring the split the brief specifies. function checkW027(snapshot: PlanningSnapshot): Diagnostic[] { const diagnostics: Diagnostic[] = []; + const activeCwd = snapshot.cwd; for (const finding of snapshot.worktreeHealth.value) { if (finding.kind !== 'stale') continue; + const normalizedWorktree = path.resolve(finding.path); + const isActiveWorktree = + activeCwd === normalizedWorktree || activeCwd.startsWith(normalizedWorktree + path.sep); + if (isActiveWorktree) continue; diagnostics.push({ code: 'W027', severity: SEVERITY.WARNING, message: `Stale git worktree: ${finding.path} (last modified ${finding.ageMinutes} minutes ago). Run: git worktree remove ${finding.path} --force`, - remedy: { - action: REMEDY_ACTION.ADVISE, - risk: REMEDY_RISK.NONE, - args: { command: 'git worktree remove --force' }, - }, + remedy: adviseRemedy('git worktree remove --force'), }); } return diagnostics; diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts index 30219461a..b8be41e8d 100644 --- a/src/planning-snapshot.cts +++ b/src/planning-snapshot.cts @@ -97,6 +97,13 @@ interface PhaseSnapshot { } interface PlanningSnapshot { + // The resolved absolute `cwd` this snapshot was built for — `cwd` is + // already `buildPlanningSnapshot`'s own input, not a new ambient read, so + // exposing it is a "parsed value" per §8.1 rule 2, not §8.1 rule 1 ambient + // I/O. Backs W027's active-worktree exclusion + // (`src/health-diagnostic-rules/worktree-health.cts`), the one pre-migration + // behavior (`verify.cts:2233-2242`) that genuinely needed the caller's cwd. + cwd: string; milestone: ReturnType; phaseDirs: ReturnType; phases: { value: PhaseSnapshot[]; scope: Scope }; @@ -668,6 +675,7 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { const stateFields = buildStateFields(paths.state); return { + cwd: path.resolve(cwd), milestone, phaseDirs, phases: { diff --git a/tests/health-diagnostic-rules/worktree-health.test.cjs b/tests/health-diagnostic-rules/worktree-health.test.cjs index 998d99167..18a07356a 100644 --- a/tests/health-diagnostic-rules/worktree-health.test.cjs +++ b/tests/health-diagnostic-rules/worktree-health.test.cjs @@ -306,17 +306,13 @@ describe('W027 — stale git worktree', () => { assert.deepEqual(ruleFor('W027').check(snapshot), []); }); - // GAP (documented in the rule module's own header comment, gap 2): the - // pre-migration `process.cwd()` exclusion of the ACTIVE session's own - // worktree cannot be reproduced here — `Rule.check(snapshot)` has no - // ambient cwd access, and `PlanningSnapshot` carries no "which entry is - // the active worktree" field. This test recreates the real-world shape the - // module doc calls out: the active session's cwd is a LINKED (non-first) - // `git worktree list` entry, not the main repo root — `buildPlanningSnapshot(cwd)` - // is called with `cwd` itself listed as entry index 1 (not the dropped - // index-0 "main" entry) and made stale. W027 fires for it anyway, - // demonstrating the gap rather than silently passing. - test('GAP: fires for the active session\'s own (stale) worktree — no cwd-based exclusion is possible from snapshot alone', (t) => { + // Regression proof (restores `verify.cts:2233-2242`'s pre-migration + // behavior via `PlanningSnapshot.cwd`, see the rule module's own header + // comment): the active session's cwd is a LINKED (non-first) `git worktree + // list` entry, not the main repo root — `buildPlanningSnapshot(cwd)` is + // called with `cwd` itself listed as entry index 1 (not the dropped + // index-0 "main" entry) and made stale. W027 must NOT fire for it. + test('excludes the active session\'s own (stale) worktree — matches snapshot.cwd exactly', (t) => { const cwd = createTempDir('gsd-3309-w027-3-'); t.after(() => cleanup(cwd)); fs.mkdirSync(planningDirOf(cwd), { recursive: true }); @@ -327,10 +323,52 @@ describe('W027 — stale git worktree', () => { mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', cwd])); const snapshot = buildPlanningSnapshot(cwd); + assert.equal(snapshot.cwd, path.resolve(cwd), 'snapshot.cwd must be the resolved active cwd'); + + const diagnostics = ruleFor('W027').check(snapshot); + assert.deepEqual( + diagnostics, + [], + 'the active worktree must be excluded from stale-worktree diagnostics, matching pre-migration behavior', + ); + }); + + test('excludes the active session\'s own (stale) worktree when cwd is NESTED under the worktree path — mirrors verify.cts:2239-2241\'s startsWith check', (t) => { + const cwd = createTempDir('gsd-3309-w027-4-'); + t.after(() => cleanup(cwd)); + const worktreePath = path.join(cwd, 'wt-active'); + const nestedCwd = path.join(worktreePath, 'sub', 'dir'); + fs.mkdirSync(nestedCwd, { recursive: true }); + fs.mkdirSync(planningDirOf(nestedCwd), { recursive: true }); + fs.utimesSync(worktreePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000)); + + mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', worktreePath])); + + const snapshot = buildPlanningSnapshot(nestedCwd); const diagnostics = ruleFor('W027').check(snapshot); - assert.equal(diagnostics.length, 1, 'the active worktree is NOT excluded — this is the documented gap'); + assert.deepEqual( + diagnostics, + [], + 'a worktree that is an ancestor of the active cwd must also be excluded', + ); + }); + + test('a DIFFERENT stale worktree (not the active cwd, not an ancestor of it) still fires', (t) => { + const cwd = createTempDir('gsd-3309-w027-5-'); + t.after(() => cleanup(cwd)); + fs.mkdirSync(planningDirOf(cwd), { recursive: true }); + + const otherStalePath = path.join(cwd, 'wt-other-stale'); + fs.mkdirSync(otherStalePath, { recursive: true }); + fs.utimesSync(otherStalePath, new Date(), new Date(Date.now() - 2 * 60 * 60 * 1000)); + + mockGitWorktreeListOk(t, buildPorcelain(['/fake/main-repo', otherStalePath])); + + const snapshot = buildPlanningSnapshot(cwd); + const diagnostics = ruleFor('W027').check(snapshot); + + assert.equal(diagnostics.length, 1, 'a stale worktree distinct from the active cwd must still be flagged'); assert.equal(diagnostics[0].code, 'W027'); - assert.ok(diagnostics[0].message.includes(cwd)); }); }); From 03258a07a2a3a1bf264cc1aea6b68f64b73e9eee Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 02:56:30 -0400 Subject: [PATCH 20/35] fix(#3309): applyRepairs must not count a failed repair as applied MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit applyRepairs pushed a diagnostic's code onto applied unconditionally after the try/catch around runRepairAction, even when the handler threw (caught, recorded in details with success:false) or otherwise failed — making applied mean "attempted" rather than "succeeded," with no test exercising the failure path. Found by the Spec-axis orthogonal review. applied now only receives a code when the repair actually succeeded; a failed attempt is still fully recorded in details (success:false, the error message) but no longer misreported as applied. Adds a regression test forcing addNyquistKey to throw (ENOENT on a config.json that doesn't exist) and asserts it lands in details, not applied. --- src/health-diagnostic.cts | 7 ++++++- tests/health-diagnostic.test.cjs | 24 ++++++++++++++++++++++++ 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/src/health-diagnostic.cts b/src/health-diagnostic.cts index 2410426c9..6079c9a02 100644 --- a/src/health-diagnostic.cts +++ b/src/health-diagnostic.cts @@ -443,6 +443,12 @@ function applyRepairs( ...(outcome.detail ? { detail: outcome.detail } : {}), ...(outcome.error ? { error: outcome.error } : {}), }); + // `applied` means "the repair actually succeeded", not "was attempted" + // — a handler that returns `{success: false}` (or throws, caught + // below) is recorded in `details` with its failure, but must not be + // reported as applied. `refused` is reserved for the DESTRUCTIVE-risk + // gate above; a failed attempt is neither applied nor refused. + if (outcome.success) applied.push(code); } catch (err) { details.push({ code, @@ -451,7 +457,6 @@ function applyRepairs( error: err instanceof Error ? err.message : String(err), }); } - applied.push(code); } return { applied, refused, details }; diff --git a/tests/health-diagnostic.test.cjs b/tests/health-diagnostic.test.cjs index e8621da54..ff12e59ca 100644 --- a/tests/health-diagnostic.test.cjs +++ b/tests/health-diagnostic.test.cjs @@ -350,4 +350,28 @@ describe('applyRepairs — REAL diagnostics (rows 15-16)', () => { const diskConfig = JSON.parse(fs.readFileSync(configPath, 'utf-8')); assert.equal(diskConfig.model_profile, 'balanced'); }); + + // Regression: a repair handler that THROWS (caught by applyRepairs's own + // try/catch) must be recorded in `details` with `success: false` and must + // NOT land in `applied` — `applied` means "succeeded", not "attempted". + // Forced here via ADD_NYQUIST_KEY against a config.json that is genuinely + // absent: `runRepairAction`'s `fs.readFileSync(configPath, ...)` throws + // ENOENT. + test('regression: a repair handler that throws is recorded in details with success:false and is NOT pushed to applied', (t) => { + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + setupHealthyProject(tmpDir); + fs.unlinkSync(path.join(tmpDir, '.planning', 'config.json')); + assert.equal(fs.existsSync(path.join(tmpDir, '.planning', 'config.json')), false); + + const diagnostics = [fakeDiagnostic('W008', REMEDY_ACTION.ADD_NYQUIST_KEY, REMEDY_RISK.NONE)]; + const result = applyRepairs(tmpDir, diagnostics, true, false); + + assert.ok(!result.applied.includes('W008'), 'W008 must not be applied — the handler threw'); + assert.ok(!result.refused.includes('W008'), 'a thrown handler is not a DESTRUCTIVE-risk refusal either'); + const detail = result.details.find((d) => d.code === 'W008'); + assert.ok(detail, 'a details row must still be recorded for the failed attempt'); + assert.equal(detail.success, false); + assert.ok(detail.error, 'the details row must carry the thrown error message'); + }); }); From 8e1ff76204e79ca0d0c032cd67edb737cf3787e3 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 02:56:38 -0400 Subject: [PATCH 21/35] refactor(#3309): consolidate adviseRemedy() into the shared types leaf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit adviseRemedy() was defined identically in two of the eight rule-group files (config-validation.cts, agent-install.cts) while the other six repeated the same {action: ADVISE, risk: NONE, args: {command}} object literal inline ~20+ times. Found by the Standards-axis orthogonal review (Duplicated Code smell). Moves the one-line helper into health-diagnostic-types.cts, the leaf module every rule-group file already imports for its enums/types, and uses it consistently across all 8 files. Pure mechanical refactor — no ADVISE remedy's command text, code, or action changed. --- src/health-diagnostic-rules/agent-install.cts | 7 +--- .../config-validation.cts | 7 +--- .../milestone-archive-hygiene.cts | 13 +++---- .../phase-structure.cts | 31 ++++------------ .../roadmap-disk-consistency.cts | 14 ++------ .../root-existence.cts | 20 +++-------- .../state-consistency.cts | 35 +++++-------------- src/health-diagnostic-types.cts | 15 ++++++++ 8 files changed, 44 insertions(+), 98 deletions(-) diff --git a/src/health-diagnostic-rules/agent-install.cts b/src/health-diagnostic-rules/agent-install.cts index 0e27aa758..49392944f 100644 --- a/src/health-diagnostic-rules/agent-install.cts +++ b/src/health-diagnostic-rules/agent-install.cts @@ -26,10 +26,9 @@ // eslint-disable-next-line @typescript-eslint/no-require-imports -- health-diagnostic-types.cjs is an export= CommonJS module import healthDiagnosticMod = require('../health-diagnostic-types.cjs'); -const { SEVERITY, REMEDY_ACTION, REMEDY_RISK } = healthDiagnosticMod; +const { SEVERITY, adviseRemedy } = healthDiagnosticMod; type Rule = healthDiagnosticMod.Rule; type Diagnostic = healthDiagnosticMod.Diagnostic; -type Remedy = healthDiagnosticMod.Remedy; // 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'); @@ -41,10 +40,6 @@ const { SCOPE } = planningScopeMod; import { PACKAGE_NAME } from '../package-identity.cjs'; -function adviseRemedy(command: string): Remedy { - return { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: { command } }; -} - /** * `check(snapshot)` for W010 — see module header for the exact 4-way * branching this ports from `verify.cts:1992-2027`, and its message/fix diff --git a/src/health-diagnostic-rules/config-validation.cts b/src/health-diagnostic-rules/config-validation.cts index f4bad1f2f..c997767d5 100644 --- a/src/health-diagnostic-rules/config-validation.cts +++ b/src/health-diagnostic-rules/config-validation.cts @@ -56,9 +56,8 @@ type PlanningSnapshot = ReturnType Diagnostic[]; // §8.1 rule 1 signature, verbatim } +// ─── adviseRemedy — shared ADVISE-remedy builder ─────────────────────────── + +/** + * Every rule-group file needs the same `{action: ADVISE, risk: NONE, args: + * {command}}` shape for a non-repairable finding's `fix` string (§8.3 rule + * 5). Was defined identically in `config-validation.cts` and + * `agent-install.cts`, and repeated inline elsewhere — moved to this shared, + * dependency-free leaf so every rule-group file imports one implementation + * instead of duplicating it. + */ +function adviseRemedy(command: string): Remedy { + return { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: { command } }; +} + // ─── Exports ──────────────────────────────────────────────────────────────── const healthDiagnosticTypes = { SEVERITY, REMEDY_ACTION, REMEDY_RISK, + adviseRemedy, }; // Namespace merge (same binding name as the value above) is how a CommonJS From 4f9e5cf2ed4061a45df923a29ea1f3becbc679c2 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 02:56:47 -0400 Subject: [PATCH 22/35] fix(#3309): make the lint guard's W024 exemption explicit, not accidental MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit W024's committed rule (state-consistency.cts) is a documented permanent no-op — its real check runs in cmdValidateHealth itself, outside the rule table, since readStateHeadFreshness needs a git-log shell-out no Rule.check may perform. The guard's §8.5 fixture-proof check previously "passed" for W024 only because some test file's title happened to contain the string "W024" — not because any fixture actually proves it fires, which it structurally never can. Found by the Spec-axis orthogonal review. Adds an explicit PERMANENTLY_INERT_CODES map (currently just W024, with its reason recorded) that checkFixtureProofInvariant reports separately from real coverage. The guard's PASS output now says "30 covered by a real fixture, 1 exempted" instead of implying uniform proof — a code with no coverage and no exemption entry still fails. --- scripts/lint-health-diagnostic-rule-table.cjs | 59 ++++++++++++++-- ...lint-health-diagnostic-rule-table.test.cjs | 70 +++++++++++++++++++ 2 files changed, 122 insertions(+), 7 deletions(-) diff --git a/scripts/lint-health-diagnostic-rule-table.cjs b/scripts/lint-health-diagnostic-rule-table.cjs index 7a302868f..fe47fde10 100644 --- a/scripts/lint-health-diagnostic-rule-table.cjs +++ b/scripts/lint-health-diagnostic-rule-table.cjs @@ -23,6 +23,19 @@ * `tests/health-diagnostic.test.cjs`). A mere comment/string mention * outside a titled block does not count as coverage. * + * EXCEPTION — `PERMANENTLY_INERT_CODES` (below): a rule whose `check` + * always returns `[]` BY DESIGN (the real check lives outside the rule + * table entirely, because it needs ambient I/O `Rule.check` cannot + * perform — §8.1 rule 1) can never satisfy a real fixture-proof, no + * matter how many tests reference its code. Before this exception + * existed, W024 "passed" this guard only because an unrelated test title + * (the RULES-array shape assertion, "exports exactly 5 rules: W024, ...") + * happened to contain the string "W024" — accidental coverage, not proof + * the rule can fire. `PERMANENTLY_INERT_CODES` makes that exemption + * explicit and auditable instead of relying on a coincidental title + * match, and the PASS output now reports exempted codes SEPARATELY from + * genuinely fixture-covered ones rather than folding them together. + * * Design: .gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md * ("The lint guard (§8.2 1:1 invariant + §8.5 fixture proof)"). * @@ -57,6 +70,22 @@ const SKELETON_TEST_FILE = path.join(REPO_ROOT, 'tests', 'health-diagnostic.test // (scripts/lint-planning-snapshot-bypass-drift.cjs's scanCode precedent). const TITLED_BLOCK_RE = /\b(describe|test|it)\(\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2/g; +// Rule codes whose `check` is a documented PERMANENT no-op (always returns +// `[]`) because the real check requires ambient I/O forbidden inside a +// `Rule.check(snapshot)` (§8.1 rule 1) — the real check runs elsewhere, +// outside the rule table. These can never be proven via a real +// diagnostic-firing fixture, so they are exempted from the §8.5 fixture-proof +// invariant explicitly here rather than via an accidental test-title match. +// Adding an entry is a deliberate, reviewed decision — see each reason. +const PERMANENTLY_INERT_CODES = new Map([ + [ + 'W024', + 'readStateHeadFreshness requires a git-log shell-out, forbidden ambient I/O for Rule.check ' + + '(§8.1 rule 1) — the real check runs in cmdValidateHealth itself, outside the rule table ' + + '(src/verify.cts). This rule-table entry is a permanent no-op by design, not a fixture gap.', + ], +]); + /** * Load the compiled health-diagnostic module. Throws a clear ExitError * (not a raw MODULE_NOT_FOUND) if `npm run build:lib` has not run. @@ -155,13 +184,17 @@ function findHealthDiagnosticTestFiles(repoRoot = REPO_ROOT) { /** * §8.5 — fixture-proof invariant: for every code in `rules`, confirm at * least one test file in `testFiles` has a `describe(`/`test(`/`it(` block - * whose title names that exact code. + * whose title names that exact code — UNLESS the code is listed in + * `PERMANENTLY_INERT_CODES`, in which case it is reported separately as + * `exempted` (visibly, not folded into "covered") and never fails the guard + * regardless of test coverage. * * @param {Array<{code: string}>} rules * @param {string[]} testFiles absolute paths to *.test.cjs files to scan - * @returns {{uncovered: string[], testFilesScanned: string[]}} + * @param {Map} inertCodes PERMANENTLY_INERT_CODES (injectable for tests) + * @returns {{uncovered: string[], exempted: string[], testFilesScanned: string[]}} */ -function checkFixtureProofInvariant(rules, testFiles) { +function checkFixtureProofInvariant(rules, testFiles, inertCodes = PERMANENTLY_INERT_CODES) { const allTitles = []; for (const file of testFiles) { const text = fs.readFileSync(file, 'utf8'); @@ -169,13 +202,18 @@ function checkFixtureProofInvariant(rules, testFiles) { } const uncovered = []; + const exempted = []; for (const rule of rules) { + if (inertCodes.has(rule.code)) { + exempted.push(rule.code); + continue; + } if (!codeAppearsInTitle(rule.code, allTitles)) { uncovered.push(rule.code); } } - return { uncovered, testFilesScanned: testFiles }; + return { uncovered, exempted, testFilesScanned: testFiles }; } function formatRepoRelative(absPath) { @@ -188,7 +226,7 @@ function main() { const { duplicates, badSeverities } = checkOneToOneInvariant(RULES, SEVERITY); const testFiles = findHealthDiagnosticTestFiles(REPO_ROOT); - const { uncovered } = checkFixtureProofInvariant(RULES, testFiles); + const { uncovered, exempted } = checkFixtureProofInvariant(RULES, testFiles); const problems = []; @@ -229,9 +267,15 @@ function main() { throw new ExitError(1, `${problems.join('\n\n')}\n`); } + const coveredCount = RULES.length - exempted.length; + const exemptedDetail = exempted + .map((code) => `${code} (${PERMANENTLY_INERT_CODES.get(code)})`) + .join('; '); + console.log( - `lint-health-diagnostic-rule-table: PASS — ${RULES.length} rule code(s), all unique, ` + - `all severities valid, all covered by a titled test block across ${testFiles.length} test file(s).`, + `lint-health-diagnostic-rule-table: PASS — ${RULES.length} rule code(s): ${coveredCount} covered by a ` + + `real fixture, ${exempted.length} exempted across ${testFiles.length} test file(s).` + + (exempted.length > 0 ? `\n Exempted: ${exemptedDetail}` : ''), ); } @@ -244,6 +288,7 @@ module.exports = { codeAppearsInTitle, findHealthDiagnosticTestFiles, checkFixtureProofInvariant, + PERMANENTLY_INERT_CODES, COMPILED_MODULE_PATH, TEST_GROUP_DIR, SKELETON_TEST_FILE, diff --git a/tests/lint-health-diagnostic-rule-table.test.cjs b/tests/lint-health-diagnostic-rule-table.test.cjs index 2d1662750..3c6363c8a 100644 --- a/tests/lint-health-diagnostic-rule-table.test.cjs +++ b/tests/lint-health-diagnostic-rule-table.test.cjs @@ -21,6 +21,7 @@ const { checkOneToOneInvariant, checkFixtureProofInvariant, findHealthDiagnosticTestFiles, + PERMANENTLY_INERT_CODES, } = guard; const FAKE_SEVERITY = Object.freeze({ ERROR: 'error', WARNING: 'warning', INFO: 'info' }); @@ -151,6 +152,75 @@ describe('checkFixtureProofInvariant (§8.5)', () => { }); }); +// ─── Check 2b — §8.5 EXCEPTION: PERMANENTLY_INERT_CODES ──────────────────── +// +// A code whose `check` is a documented permanent no-op (W024 — see +// `scripts/lint-health-diagnostic-rule-table.cjs`'s own `PERMANENTLY_INERT_CODES` +// comment) can never satisfy a real fixture-proof. It must be reported as +// `exempted`, separately from genuinely-covered codes, and must NEVER land in +// `uncovered` — regardless of whether any test file happens to mention it. + +describe('checkFixtureProofInvariant — PERMANENTLY_INERT_CODES exemption (§8.5 exception)', () => { + test('an exempted code with ZERO test coverage anywhere still passes (not uncovered), and is reported as exempted', (t) => { + const dir = createTempDir('gsd-lint-hd-rt-exempt-nomention-'); + t.after(() => cleanup(dir)); + const file = writeTempTestFile(dir, 'fake.test.cjs', "describe('unrelated', () => {});\n"); + + const rules = [{ code: 'W024' }]; + const inertCodes = new Map([['W024', 'permanent no-op, real check lives outside the rule table']]); + const { uncovered, exempted } = checkFixtureProofInvariant(rules, [file], inertCodes); + + assert.deepEqual(uncovered, [], 'an exempted code must never be reported as uncovered'); + assert.deepEqual(exempted, ['W024']); + }); + + test('a code NOT in the exemption map, with zero test coverage, still fails as uncovered', (t) => { + const dir = createTempDir('gsd-lint-hd-rt-not-exempt-'); + t.after(() => cleanup(dir)); + const file = writeTempTestFile(dir, 'fake.test.cjs', "describe('unrelated', () => {});\n"); + + const rules = [{ code: 'W998' }]; + const inertCodes = new Map([['W024', 'permanent no-op']]); // W998 is NOT in this map + const { uncovered, exempted } = checkFixtureProofInvariant(rules, [file], inertCodes); + + assert.deepEqual(uncovered, ['W998'], 'a non-exempted, uncovered code must still fail the guard'); + assert.deepEqual(exempted, []); + }); + + test('an exempted code is reported as exempted even when a test file DOES happen to mention it in a titled block', (t) => { + const dir = createTempDir('gsd-lint-hd-rt-exempt-mentioned-'); + t.after(() => cleanup(dir)); + const file = writeTempTestFile( + dir, + 'fake.test.cjs', + "test('exports exactly 1 rule: W024', () => {});\n", + ); + + const rules = [{ code: 'W024' }]; + const inertCodes = new Map([['W024', 'permanent no-op']]); + const { uncovered, exempted } = checkFixtureProofInvariant(rules, [file], inertCodes); + + assert.deepEqual(uncovered, []); + assert.deepEqual(exempted, ['W024'], 'must be classified as exempted, not folded into ordinary coverage'); + }); + + test('W024 is exempted (not uncovered, not silently "covered") against the real tests/ tree and the real PERMANENTLY_INERT_CODES map', () => { + const testFiles = findHealthDiagnosticTestFiles(); + const { uncovered, exempted } = checkFixtureProofInvariant([{ code: 'W024' }], testFiles); + + assert.deepEqual(uncovered, []); + assert.deepEqual(exempted, ['W024']); + }); + + test('PERMANENTLY_INERT_CODES locks exactly W024 with a non-empty, auditable reason', () => { + assert.deepEqual([...PERMANENTLY_INERT_CODES.keys()], ['W024']); + const reason = PERMANENTLY_INERT_CODES.get('W024'); + assert.equal(typeof reason, 'string'); + assert.ok(reason.length > 0); + assert.ok(/ambient I\/O|§8\.1/i.test(reason), 'reason should explain the §8.1 rule 1 constraint'); + }); +}); + // ─── findHealthDiagnosticTestFiles ───────────────────────────────────────── describe('findHealthDiagnosticTestFiles', () => { From 6a1860c57958b377f6c6cefe719ba2a01d3361bd Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 02:56:53 -0400 Subject: [PATCH 23/35] docs(#3309): fix CONTEXT.md's stale Health Diagnostic Module entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Still said RULES ships empty and repair handlers are stubs — true when the skeleton batch first wrote this glossary entry, false since the migration landed (RULES holds 31 wired rules, applyRepairs has real per-action handlers). Found by the Standards-axis orthogonal review. --- CONTEXT.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 3ac7e02ff..fa98510f7 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -110,10 +110,10 @@ Module owning the parsed projection of `.planning/` that a diagnostic rule may r Leaf module owning the `SEVERITY`/`REMEDY_ACTION`/`REMEDY_RISK` enums and `Diagnostic`/`Remedy`/`Rule` types shared between the Health Diagnostic Module (the evaluator) and the Health Diagnostic Rule Groups (the eight rule-group files it concatenates). Split out of `src/health-diagnostic.cts` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) to break a CJS circular dependency: the evaluator must `require()` every rule-group file to populate `RULES`, and every rule-group file needs these enums/types — if the rule-group files required the evaluator back, the require cycle would resolve `module.exports` before it is assigned. This leaf has no runtime dependency on either side of that cycle. Source of truth: `gsd-core/bin/lib/health-diagnostic-types.cjs` (generated from `src/health-diagnostic-types.cts`). ### 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`. +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, fully wired: the static concatenation of the 31 rules exported by the eight Health Diagnostic Rule Groups files below (extracted from `cmdValidateHealth`, `src/verify.cts:1616-2577` — 31, not the design doc's own inconsistent prose figure of 32; see the Rule Groups entry's own note). `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 static 1:1 lint guard (§8.2 rule 1, `scripts/lint-health-diagnostic-rule-table.cjs`). `applyRepairs(cwd, diagnostics, repair, backfill) → {applied, refused, details}` 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 (`runRepairAction`) are REAL, ported behavior-preserving from `verify.cts:2405-2553`'s repair switch — `createConfig`/`resetConfig` (write the default config.json payload), `regenerateState` (backs up and regenerates STATE.md), `addNyquistKey`/`addAiIntegrationPhaseKey` (add a missing `workflow.*` key), `backfillMilestones` (synthesize missing MILESTONES.md entries from archive snapshots) — not stubs. `applied` records only a repair that actually SUCCEEDED (`outcome.success === true`); a thrown or `{success: false}` attempt is recorded in `details` with `success: false` but is never pushed to `applied`. 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`. ### Health Diagnostic Rule Groups -Directory `src/health-diagnostic-rules/` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) owning the 32 rules migrated off `cmdValidateHealth`, split into eight files — one per subject-area group from the design doc's "Rule table organization" table — each exporting a `RULES: Rule[]` conforming to the Health Diagnostic Module's frozen `Rule` shape. `src/health-diagnostic.cts` concatenates all eight into the single `RULES` table `evaluateRules` runs; no group re-derives its own `Diagnostic`/`Remedy` shapes. Groups: `root-existence.cts` (root `.planning/` + PROJECT.md existence, E002-E004/W001), `state-consistency.cts` (STATE.md vs config/ROADMAP/disk, W002/W011/W021/W026 — W024's state_head freshness check is a disclosed gap, deliberately not migrated), `config-validation.cts` (config.json shape, W003/W004/W022/E005/W008/W012-W016), `phase-structure.cts` (phase directory structure, W005/W023/I001/W009), `agent-install.cts` (agent-installation completeness, W010), `roadmap-disk-consistency.cts` (ROADMAP-vs-disk phase matching via the shared `matchPhaseDirs` matcher, W006/W007), `worktree-health.cts` (worktree health, W020/W017/W027), `milestone-archive-hygiene.cts` (milestone archive + root hygiene, W018/W019). Every rule is a behavior-preserving port of one `addIssue` call site in `cmdValidateHealth` (`src/verify.cts`), reading only the parsed `PlanningSnapshot` fields the Planning Snapshot Module already computes — never raw `.planning/` I/O. Source of truth: `gsd-core/bin/lib/health-diagnostic-rules/*.cjs` (generated from `src/health-diagnostic-rules/*.cts`). +Directory `src/health-diagnostic-rules/` (Phase 11, #3309, ADR-3180 §8.2/§8.3/§8.5) owning the 31 rules migrated off `cmdValidateHealth` (not the design doc's own inconsistent prose figure of 32 — 31 is the count actually summed from each group's exported `RULES` array and locked by `tests/health-diagnostic.test.cjs`'s "RULES" describe block), split into eight files — one per subject-area group from the design doc's "Rule table organization" table — each exporting a `RULES: Rule[]` conforming to the Health Diagnostic Module's frozen `Rule` shape. `src/health-diagnostic.cts` concatenates all eight into the single `RULES` table `evaluateRules` runs; no group re-derives its own `Diagnostic`/`Remedy` shapes. Groups: `root-existence.cts` (root `.planning/` + PROJECT.md existence, E002-E004/W001), `state-consistency.cts` (STATE.md vs config/ROADMAP/disk, W002/W011/W021/W026 — W024's state_head freshness check is a disclosed gap, deliberately not migrated), `config-validation.cts` (config.json shape, W003/W004/W022/E005/W008/W012-W016), `phase-structure.cts` (phase directory structure, W005/W023/I001/W009), `agent-install.cts` (agent-installation completeness, W010), `roadmap-disk-consistency.cts` (ROADMAP-vs-disk phase matching via the shared `matchPhaseDirs` matcher, W006/W007), `worktree-health.cts` (worktree health, W020/W017/W027), `milestone-archive-hygiene.cts` (milestone archive + root hygiene, W018/W019). Every rule is a behavior-preserving port of one `addIssue` call site in `cmdValidateHealth` (`src/verify.cts`), reading only the parsed `PlanningSnapshot` fields the Planning Snapshot Module already computes — never raw `.planning/` I/O. Source of truth: `gsd-core/bin/lib/health-diagnostic-rules/*.cjs` (generated from `src/health-diagnostic-rules/*.cts`). ### 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. From 041414c4ad4fe80b64b5c4091fb2c3bd7ecd8f4d Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 03:13:25 -0400 Subject: [PATCH 24/35] feat(#3309): generate health.md's error-code and repair-action tables MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the issue's explicit acceptance criterion: "health.md's tables are generated rather than hand-maintained, closing the 16-vs-30+ documentation gap structurally." The published roster listed 16 codes against 30+ actually emitted; W010-W017 and W020-W023 had never been documented. Adds description/repairable as static fields on Rule (health-diagnostic-types.cts) — generation needs a fixed, human-readable summary per code, distinct from the dynamic per-instance Diagnostic.message a rule's check() produces. repairable is true only when --repair will actually apply the remedy: false for ADVISE-only rules AND for DESTRUCTIVE-risk rules (regenerateState/resetConfig), which are described but never auto-applied — matches verify.cts's diagnosticToIssueEntry semantics exactly, after fixing E004/E005's static field to agree with it (both were wrongly true, an inconsistency caught during this same commit's own review, not left for later). New scripts/gen-health-docs.cjs (--write/--check, wired into lint:generated-sync) regenerates the two tagged table regions in gsd-core/workflows/health.md from RULES (31 rules) plus the 3 pre-checks that stay outside the rule table by design (E001, E010, I010) plus a small static Effect/Risk lookup for the 6 real repair actions — including addAiIntegrationPhaseKey, live in code since an earlier phase but never documented until now. 34 error-code rows, 6 repair-action rows. The table's old "grep verify.cts for the next free number" footnote is rewritten to point at the rule table and its lint guard instead. --- gsd-core/workflows/health.md | 26 +- package.json | 2 +- scripts/gen-health-docs.cjs | 390 ++++++++++++++++++ src/health-diagnostic-rules/agent-install.cts | 2 + .../config-validation.cts | 63 ++- .../milestone-archive-hygiene.cts | 16 +- .../phase-structure.cts | 32 +- .../roadmap-disk-consistency.cts | 16 +- .../root-existence.cts | 14 +- .../state-consistency.cts | 11 + .../worktree-health.cts | 24 +- src/health-diagnostic-types.cts | 41 ++ tests/gen-health-docs.test.cjs | 258 ++++++++++++ 13 files changed, 863 insertions(+), 32 deletions(-) create mode 100644 scripts/gen-health-docs.cjs create mode 100644 tests/gen-health-docs.test.cjs diff --git a/gsd-core/workflows/health.md b/gsd-core/workflows/health.md index 55a9b2cda..84e183518 100644 --- a/gsd-core/workflows/health.md +++ b/gsd-core/workflows/health.md @@ -216,14 +216,14 @@ Report final status. - | Code | Severity | Description | Repairable | |------|----------|-------------|------------| | E001 | error | .planning/ directory not found | No | | E002 | error | PROJECT.md not found | No | | E003 | error | ROADMAP.md not found | No | -| E004 | error | STATE.md not found | Yes | -| E005 | error | config.json parse error | Yes | +| E004 | error | STATE.md not found | No | +| E005 | error | config.json parse error | No | +| E010 | error | CWD resolves to the user's home directory — health check would target the wrong .planning/ | No | | W001 | warning | PROJECT.md missing required section | No | | W002 | warning | STATE.md references invalid phase | No | | W003 | warning | config.json not found | Yes | @@ -233,24 +233,38 @@ Report final status. | W007 | warning | Phase on disk but not in ROADMAP | No | | W008 | warning | config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip) | Yes | | W009 | warning | Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md | No | +| W010 | warning | GSD agent installation missing or incomplete | No | +| W011 | warning | STATE.md current-phase status disagrees with ROADMAP.md checkbox | No | +| W012 | warning | config.json invalid branching_strategy value | No | +| W013 | warning | config.json context_window not a positive integer | No | +| W014 | warning | config.json phase_branch_template missing {phase} placeholder | No | +| W015 | warning | config.json milestone_branch_template missing {milestone} placeholder | No | +| W016 | warning | config.json: workflow.ai_integration_phase absent (defaults to enabled but agents may skip AI-integration-phase planning) | Yes | +| W017 | warning | Orphan git worktree (path no longer exists on disk) | No | | W018 | warning | MILESTONES.md missing entry for archived milestone snapshot | Yes (`--backfill`) | | W019 | warning | Unrecognized .planning/ root file — not a canonical GSD artifact | No | +| W020 | warning | Worktree health scan degraded — git worktree list timed out, failed, or a finding could not be verified | No | +| W021 | warning | Phase's integer prefix implies a different milestone than its ROADMAP section (phase_id_convention: milestone-prefixed) | No | +| W022 | warning | config.json models entry malformed (unknown phase type, invalid tier, or non-object value) | No | +| W023 | warning | Phase directories collide on normalized key | No | | W024 | warning | STATE.md was written many commits ago — treat its contents as approximate | No | -| W025 | warning | config.json: workflow.use_worktrees enabled on a runtime whose dispatch.isolation is none (#2486) | No | +| W026 | warning | STATE says milestone complete but ROADMAP lists an unstarted phase for that milestone | No | +| W027 | warning | Stale git worktree (not modified in a long time) | No | | I001 | info | Plan without SUMMARY (may be in progress) | No | +| I010 | info | Resolved CWD reported alongside the E010 home-directory guard | No | -Note: the `W0NN` warning-code namespace is owned by `src/verify.cts` (`validate.health`), which also emits codes this table does not list (`W010`–`W017` and `W020`–`W023` as of #2486). `W001`–`W024` are all allocated, so this workflow's isolation warning is `W025`. Before assigning a new code here, grep `src/verify.cts` for the next free number — the table alone under-represents the live namespace, and two PRs in flight can otherwise claim the same code (which is exactly what happened between #2486 and #2573). +Note: this table is **generated** — do not hand-edit it. It is produced by `node scripts/gen-health-docs.cjs --write` from `src/health-diagnostic.cts`'s `RULES` table (31 rules as of #3309, each carrying a static `description`/`repairable` on its `Rule` entry — see `src/health-diagnostic-types.cts`) plus the 3 pre-checks that stay outside the rule table by design (`E001`, `E010`, `I010` — safety rails in `cmdValidateHealth`, `src/verify.cts`, never `.planning/` findings). `scripts/lint-health-diagnostic-rule-table.cjs` already enforces the 1:1 code invariant this table depends on (no duplicate codes; severity is always a `Rule` property, never set per emit call) — before assigning a new code, add a `Rule` entry under `src/health-diagnostic-rules/` (its `code` is simply the next free number the lint guard has not yet seen) and run `node scripts/gen-health-docs.cjs --write` to regenerate this table; `npm run lint:generated-sync` fails if it drifts. `W025` (the `workflow.use_worktrees`/`dispatch.isolation` check, #2486) is a workflow-layer diagnostic emitted directly by this file's own `run_health_check` step, not by `cmdValidateHealth` — it stays documented in that step, not in this generated table. - | Action | Effect | Risk | |--------|--------|------| | createConfig | Create config.json with defaults | None | | resetConfig | Delete + recreate config.json | Loses custom settings | | regenerateState | Create STATE.md from ROADMAP structure when it is missing | Loses session history | | addNyquistKey | Add workflow.nyquist_validation: true to config.json | None — matches existing default | +| addAiIntegrationPhaseKey | Add workflow.ai_integration_phase: true to config.json | None — matches existing default | | backfillMilestones | Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots | None — additive only; triggered by `--backfill` flag | **Not repairable (too risky):** diff --git a/package.json b/package.json index 2ca21c99a..adfff3145 100644 --- a/package.json +++ b/package.json @@ -122,7 +122,7 @@ "lint:test-file-count": "node scripts/lint-test-file-count.cjs", "lint:pr-checks": "node scripts/lint-pr-check-project-dir.cjs", "lint:changeset": "node scripts/changeset/lint.cjs", - "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check", + "lint:generated-sync": "node scripts/gen-capability-registry.cjs --check && node scripts/gen-loop-host-contract.cjs --check && node scripts/gen-capability-matrix.cjs --check && node scripts/sync-manifest-versions.cjs --check && node scripts/gen-inventory-manifest.cjs --check && node scripts/generate-package-identity.cjs --check && node scripts/gen-plugin-skills.cjs --check && node scripts/gen-registry.cjs --check && node scripts/gen-adr-index.cjs --check && node scripts/check-glossary-refs.cjs --check && node scripts/lint-compiled-artifact-sync.cjs --check && node scripts/gen-context-index.cjs --check && node scripts/gen-section-manifest.cjs --check && node scripts/gen-health-docs.cjs --check", "lint:docs": "node scripts/lint-docs-required.cjs", "lint:qa-smells": "node scripts/qa-smell-ratchet.cjs", "lint:legacy-name": "node scripts/lint-legacy-dir-name.cjs", diff --git a/scripts/gen-health-docs.cjs b/scripts/gen-health-docs.cjs new file mode 100644 index 000000000..53e1b7bc3 --- /dev/null +++ b/scripts/gen-health-docs.cjs @@ -0,0 +1,390 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Generates the `` and `` tables in + * `gsd-core/workflows/health.md` from `src/health-diagnostic.cts`'s `RULES` + * table (Phase 11 follow-up, #3309 "Proposed behavior": "health.md's tables + * are generated rather than hand-maintained, closing the 16-vs-30+ + * documentation gap structurally"). + * + * Sources: + * - The 31 real rules in the compiled `RULES` array + * (`gsd-core/bin/lib/health-diagnostic.cjs`, built from + * `src/health-diagnostic.cts` + `src/health-diagnostic-rules/*.cts`), + * each carrying a static `description`/`repairable` (see + * `src/health-diagnostic-types.cts`'s `Rule` interface). + * - `PRECHECK_CODES` below — E001, E010, I010 — the three diagnostics + * `cmdValidateHealth` (`src/verify.cts`) emits as pre-checks OUTSIDE the + * rule table entirely (ADR-3180 §8.2 rule 4, "no precedence system" — + * see `.gsd/phase/refactor-3309-health-diagnostic-rule-table/40-design.md`, + * "Two guards that stay OUTSIDE the rule table entirely"). These will + * never appear in `RULES`, so they are a small, static, clearly-labeled + * list merged in here instead. + * - `REMEDY_ACTION_METADATA` below — the Effect/Risk prose for each of the + * 6 real repair actions (`REMEDY_ACTION`, excluding `ADVISE`, which never + * acts). Static because the compiled module carries no Effect/Risk text + * of its own — only the action identifier. + * + * Deliberately EXCLUDED from the generated `` table: `W025` + * (the `workflow.use_worktrees`/`dispatch.isolation` check, #2486). It is a + * workflow-layer diagnostic emitted directly by this same file's own + * `run_health_check` step (a bash block in `health.md` itself), never by + * `cmdValidateHealth`/`RULES` — it has no `Rule` entry and is not one of the + * three pre-checks above. It stays fully documented in prose at its own step + * (``), which is the authoritative, more + * detailed source `` used to merely summarize; dropping the + * redundant table row is not a loss of information, and folding it back in + * here would require this generator to parse bash, which it does not do. + * Same reasoning for `I002` (stale Windows task-directory cleanup, + * `` step) — it was never part of the `` + * tagged region even before this generator existed. + * + * Usage: + * node scripts/gen-health-docs.cjs # print both tables to stdout + * node scripts/gen-health-docs.cjs --write # rewrite the tagged regions in health.md + * node scripts/gen-health-docs.cjs --check # exit 1 if either region is stale + * node scripts/gen-health-docs.cjs --write --target # test-only: target a fixture file + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { ExitError, runMain } = require('./lib/cli-exit.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const HEALTH_MD_REL = 'gsd-core/workflows/health.md'; +const HEALTH_MD_PATH = path.join(ROOT, HEALTH_MD_REL); +const COMPILED_MODULE_REL = 'gsd-core/bin/lib/health-diagnostic.cjs'; +const COMPILED_MODULE_PATH = path.join(ROOT, COMPILED_MODULE_REL); + +const ERROR_CODES_START = ''; +const ERROR_CODES_END = ''; +const REPAIR_ACTIONS_START = ''; +const REPAIR_ACTIONS_END = ''; + +/** + * The 3 pre-check diagnostics `cmdValidateHealth` emits OUTSIDE the rule + * table (see module header). All three are non-repairable safety rails, not + * `.planning/` findings a remedy could act on. + */ +const PRECHECK_CODES = [ + { + code: 'E001', + severity: 'error', + description: '.planning/ directory not found', + repairable: false, + }, + { + code: 'E010', + severity: 'error', + description: "CWD resolves to the user's home directory — health check would target the wrong .planning/", + repairable: false, + }, + { + code: 'I010', + severity: 'info', + description: 'Resolved CWD reported alongside the E010 home-directory guard', + repairable: false, + }, +]; + +/** + * Effect/Risk prose per real `REMEDY_ACTION` (everything except `ADVISE`, + * which never acts and has no row in ``). Text for the 5 + * actions the hand-written table already documented is reused VERBATIM; + * `addAiIntegrationPhaseKey` is new — #3309 itself notes it was "live in + * code, missing from docs" (mirrors `addNyquistKey`, its structural sibling: + * same shape, one config key each). + */ +const REMEDY_ACTION_METADATA = new Map([ + ['createConfig', { effect: 'Create config.json with defaults', risk: 'None' }], + ['resetConfig', { effect: 'Delete + recreate config.json', risk: 'Loses custom settings' }], + [ + 'regenerateState', + { + effect: 'Create STATE.md from ROADMAP structure when it is missing', + risk: 'Loses session history', + }, + ], + [ + 'addNyquistKey', + { effect: 'Add workflow.nyquist_validation: true to config.json', risk: 'None — matches existing default' }, + ], + [ + 'addAiIntegrationPhaseKey', + { effect: 'Add workflow.ai_integration_phase: true to config.json', risk: 'None — matches existing default' }, + ], + [ + 'backfillMilestones', + { + effect: 'Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots', + risk: 'None — additive only; triggered by `--backfill` flag', + }, + ], +]); + +/** Order the Effect/Risk table renders in — matches `REMEDY_ACTION`'s own declaration order. */ +const REMEDY_ACTION_ORDER = [ + 'createConfig', + 'resetConfig', + 'regenerateState', + 'addNyquistKey', + 'addAiIntegrationPhaseKey', + 'backfillMilestones', +]; + +/** + * Per-code override for the "Repairable" cell's display text, for codes + * whose remedy is conditional on a flag the plain `Yes`/`No` can't express + * (mirrors the hand-written table's pre-existing `W018` row: `Yes (--backfill)`). + */ +const REPAIRABLE_DISPLAY_OVERRIDE = new Map([['W018', 'Yes (`--backfill`)']]); + +const STATIC_NOT_REPAIRABLE_BULLETS = [ + 'PROJECT.md, ROADMAP.md content', + 'Phase directory renaming', + 'Orphaned plan cleanup', +]; + +const FOOTNOTE_PARAGRAPH = + 'Note: this table is **generated** — do not hand-edit it. It is produced by ' + + '`node scripts/gen-health-docs.cjs --write` from `src/health-diagnostic.cts`\'s `RULES` table ' + + '(31 rules as of #3309, each carrying a static `description`/`repairable` on its `Rule` entry — ' + + 'see `src/health-diagnostic-types.cts`) plus the 3 pre-checks that stay outside the rule table by ' + + 'design (`E001`, `E010`, `I010` — safety rails in `cmdValidateHealth`, `src/verify.cts`, never ' + + '`.planning/` findings). `scripts/lint-health-diagnostic-rule-table.cjs` already enforces the 1:1 ' + + 'code invariant this table depends on (no duplicate codes; severity is always a `Rule` property, ' + + 'never set per emit call) — before assigning a new code, add a `Rule` entry under ' + + '`src/health-diagnostic-rules/` (its `code` is simply the next free number the lint guard has not ' + + 'yet seen) and run `node scripts/gen-health-docs.cjs --write` to regenerate this table; ' + + '`npm run lint:generated-sync` fails if it drifts. `W025` (the `workflow.use_worktrees`/' + + '`dispatch.isolation` check, #2486) is a workflow-layer diagnostic emitted directly by this file\'s ' + + 'own `run_health_check` step, not by `cmdValidateHealth` — it stays documented in that step, not in ' + + 'this generated table.'; + +/** + * Load the compiled health-diagnostic module. Throws a clear ExitError (not + * a raw MODULE_NOT_FOUND) if `npm run build:lib` has not run — mirrors + * `scripts/lint-health-diagnostic-rule-table.cjs`'s `loadCompiledModule`. + */ +function loadCompiledModule(compiledPath = COMPILED_MODULE_PATH) { + if (!fs.existsSync(compiledPath)) { + throw new ExitError( + 2, + `gen-health-docs: compiled artifact not found at ${COMPILED_MODULE_REL}.\n` + + 'Run `npm run build:lib` first.', + ); + } + return require(compiledPath); +} + +/** + * Sort order for the `` table: E-codes, then W-codes + * numerically, then I-codes — matching the hand-written table's pre-existing + * order. NOT insertion order from `RULES` (which is grouped by + * subject-area file, not sorted by code). + */ +const PREFIX_RANK = { E: 0, W: 1, I: 2 }; + +function parseCode(code) { + const m = code.match(/^([A-Z]+)(\d+)$/); + if (!m) throw new Error(`gen-health-docs: unparseable diagnostic code "${code}"`); + return { prefix: m[1], number: Number(m[2]) }; +} + +function compareCodes(a, b) { + const pa = parseCode(a.code); + const pb = parseCode(b.code); + const rankA = PREFIX_RANK[pa.prefix] ?? 99; + const rankB = PREFIX_RANK[pb.prefix] ?? 99; + if (rankA !== rankB) return rankA - rankB; + return pa.number - pb.number; +} + +/** + * Combine the 31 real rules + the 3 static pre-checks into one sorted row + * list for the `` table. + * + * @param {Array<{code: string, severity: string, description: string, repairable: boolean}>} rules + */ +function buildErrorCodeRows(rules) { + const seen = new Set(); + const rows = []; + for (const entry of [...rules, ...PRECHECK_CODES]) { + if (seen.has(entry.code)) { + throw new Error(`gen-health-docs: duplicate diagnostic code "${entry.code}" across RULES + PRECHECK_CODES`); + } + seen.add(entry.code); + rows.push(entry); + } + rows.sort(compareCodes); + return rows; +} + +/** Escape a cell's markdown-table-hostile characters (mirrors gen-adr-index.cjs's `cellText`). */ +function cellText(text) { + return String(text) + .replace(/\\/g, '\\\\') + .replace(/\|/g, '\\|') + .replace(//g, '>') + .replace(/\r?\n/g, ' ') + .trim(); +} + +function repairableCell(row) { + if (REPAIRABLE_DISPLAY_OVERRIDE.has(row.code)) return REPAIRABLE_DISPLAY_OVERRIDE.get(row.code); + return row.repairable ? 'Yes' : 'No'; +} + +function renderErrorCodesRegion(rules) { + const rows = buildErrorCodeRows(rules); + const lines = ['', '| Code | Severity | Description | Repairable |', '|------|----------|-------------|------------|']; + for (const row of rows) { + lines.push(`| ${row.code} | ${row.severity} | ${cellText(row.description)} | ${repairableCell(row)} |`); + } + lines.push('', FOOTNOTE_PARAGRAPH, ''); + return lines.join('\n'); +} + +function renderRepairActionsRegion() { + const lines = ['', '| Action | Effect | Risk |', '|--------|--------|------|']; + for (const action of REMEDY_ACTION_ORDER) { + const meta = REMEDY_ACTION_METADATA.get(action); + if (!meta) { + throw new Error( + `gen-health-docs: no Effect/Risk metadata registered for repair action "${action}" — add an entry to REMEDY_ACTION_METADATA.`, + ); + } + lines.push(`| ${action} | ${meta.effect} | ${meta.risk} |`); + } + lines.push('', '**Not repairable (too risky):**'); + for (const bullet of STATIC_NOT_REPAIRABLE_BULLETS) lines.push(`- ${bullet}`); + lines.push(''); + return lines.join('\n'); +} + +/** + * Splice `newInner` between `${startTag}`/`${endTag}` inside `text`. Throws + * if either tag is missing, or if the tags appear more than once (this + * generator only ever targets the FIRST occurrence pair, and a duplicate + * tag anywhere in the file would silently corrupt the splice). + */ +function spliceRegion(text, startTag, endTag, newInner) { + const startIdx = text.indexOf(startTag); + const endIdx = text.indexOf(endTag); + if (startIdx === -1 || endIdx === -1) { + throw new ExitError( + 1, + `gen-health-docs: ${HEALTH_MD_REL} is missing the ${startTag}/${endTag} tags.`, + ); + } + if (text.indexOf(startTag, startIdx + 1) !== -1 || text.indexOf(endTag, endIdx + 1) !== -1) { + throw new ExitError(1, `gen-health-docs: ${HEALTH_MD_REL} has more than one ${startTag}/${endTag} pair.`); + } + const before = text.slice(0, startIdx + startTag.length); + const after = text.slice(endIdx); + return `${before}${newInner}\n${after}`; +} + +/** + * Regenerate `health.md`'s full text from `rules` (the compiled `RULES` + * array) and the current on-disk `health.md` content. + */ +function regenerateHealthMd(rules, currentText) { + let out = spliceRegion(currentText, ERROR_CODES_START, ERROR_CODES_END, renderErrorCodesRegion(rules)); + out = spliceRegion(out, REPAIR_ACTIONS_START, REPAIR_ACTIONS_END, renderRepairActionsRegion()); + return out; +} + +/** + * @param {string[]} argv + * @returns {{write: boolean, check: boolean, targetPath: string|null}} + */ +function parseArgs(argv) { + const opts = { write: false, check: false, targetPath: null }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--write') opts.write = true; + else if (arg === '--check') opts.check = true; + else if (arg === '--target') { + const value = argv[i + 1]; + if (value === undefined) throw new ExitError(1, '--target requires a path argument.'); + opts.targetPath = value; + i++; + } else { + throw new ExitError(1, `unknown flag: ${arg}\nRecognized flags: --write, --check, --target .`); + } + } + return opts; +} + +function main() { + const { write, check, targetPath } = parseArgs(process.argv.slice(2)); + const { RULES } = loadCompiledModule(); + + // `--target` overrides the real committed health.md path, exclusively for + // test isolation (mirrors gen-section-manifest.cjs's `--manifest-path` + // override) — no production caller ever passes it. + const resolvedPath = targetPath ? path.resolve(targetPath) : HEALTH_MD_PATH; + const displayPath = targetPath ? targetPath : HEALTH_MD_REL; + + const currentText = fs.existsSync(resolvedPath) ? fs.readFileSync(resolvedPath, 'utf8') : null; + if (currentText === null) { + throw new ExitError(1, `gen-health-docs: ${displayPath} not found.`); + } + + const expected = regenerateHealthMd(RULES, currentText); + + if (write) { + fs.writeFileSync(resolvedPath, expected, 'utf8'); + process.stdout.write( + `Wrote ${displayPath} — ${RULES.length + PRECHECK_CODES.length} error/warning/info code(s), ` + + `${REMEDY_ACTION_ORDER.length} repair action(s).\n`, + ); + return 0; + } + + if (check) { + if (expected !== currentText) { + process.stderr.write( + `${displayPath} is stale — its / tables do not match ` + + "src/health-diagnostic.cts's RULES table.\nRun:\n node scripts/gen-health-docs.cjs --write\n\n", + ); + throw new ExitError(1); + } + process.stdout.write( + `${displayPath} is up to date (${RULES.length + PRECHECK_CODES.length} codes, ${REMEDY_ACTION_ORDER.length} repair actions).\n`, + ); + return 0; + } + + process.stdout.write(renderErrorCodesRegion(RULES) + '\n\n' + renderRepairActionsRegion() + '\n'); + return 0; +} + +// Guarded: requiring this module (the test suite imports the pure render +// functions directly) must not also run the generator as a side effect. +if (require.main === module) runMain(main); + +module.exports = { + loadCompiledModule, + buildErrorCodeRows, + renderErrorCodesRegion, + renderRepairActionsRegion, + regenerateHealthMd, + spliceRegion, + compareCodes, + parseCode, + PRECHECK_CODES, + REMEDY_ACTION_METADATA, + REMEDY_ACTION_ORDER, + REPAIRABLE_DISPLAY_OVERRIDE, + HEALTH_MD_PATH, + COMPILED_MODULE_PATH, + ERROR_CODES_START, + ERROR_CODES_END, + REPAIR_ACTIONS_START, + REPAIR_ACTIONS_END, +}; diff --git a/src/health-diagnostic-rules/agent-install.cts b/src/health-diagnostic-rules/agent-install.cts index 49392944f..ae7cdfad7 100644 --- a/src/health-diagnostic-rules/agent-install.cts +++ b/src/health-diagnostic-rules/agent-install.cts @@ -107,6 +107,8 @@ const RULES: Rule[] = [ { code: 'W010', severity: SEVERITY.WARNING, + description: 'GSD agent installation missing or incomplete', + repairable: false, check: checkAgentInstall, }, ]; diff --git a/src/health-diagnostic-rules/config-validation.cts b/src/health-diagnostic-rules/config-validation.cts index c997767d5..867b28440 100644 --- a/src/health-diagnostic-rules/config-validation.cts +++ b/src/health-diagnostic-rules/config-validation.cts @@ -276,16 +276,59 @@ function checkW022(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W003', severity: SEVERITY.WARNING, check: checkW003 }, - { code: 'E005', severity: SEVERITY.ERROR, check: checkE005 }, - { code: 'W004', severity: SEVERITY.WARNING, check: checkW004 }, - { code: 'W008', severity: SEVERITY.WARNING, check: checkW008 }, - { code: 'W016', severity: SEVERITY.WARNING, check: checkW016 }, - { code: 'W012', severity: SEVERITY.WARNING, check: checkW012 }, - { code: 'W013', severity: SEVERITY.WARNING, check: checkW013 }, - { code: 'W014', severity: SEVERITY.WARNING, check: checkW014 }, - { code: 'W015', severity: SEVERITY.WARNING, check: checkW015 }, - { code: 'W022', severity: SEVERITY.WARNING, check: checkW022 }, + { code: 'W003', severity: SEVERITY.WARNING, description: 'config.json not found', repairable: true, check: checkW003 }, + { code: 'E005', severity: SEVERITY.ERROR, description: 'config.json parse error', repairable: false, check: checkE005 }, + { code: 'W004', severity: SEVERITY.WARNING, description: 'config.json invalid field value', repairable: false, check: checkW004 }, + { + code: 'W008', + severity: SEVERITY.WARNING, + description: 'config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip)', + repairable: true, + check: checkW008, + }, + { + code: 'W016', + severity: SEVERITY.WARNING, + description: + 'config.json: workflow.ai_integration_phase absent (defaults to enabled but agents may skip AI-integration-phase planning)', + repairable: true, + check: checkW016, + }, + { + code: 'W012', + severity: SEVERITY.WARNING, + description: 'config.json invalid branching_strategy value', + repairable: false, + check: checkW012, + }, + { + code: 'W013', + severity: SEVERITY.WARNING, + description: 'config.json context_window not a positive integer', + repairable: false, + check: checkW013, + }, + { + code: 'W014', + severity: SEVERITY.WARNING, + description: 'config.json phase_branch_template missing {phase} placeholder', + repairable: false, + check: checkW014, + }, + { + code: 'W015', + severity: SEVERITY.WARNING, + description: 'config.json milestone_branch_template missing {milestone} placeholder', + repairable: false, + check: checkW015, + }, + { + code: 'W022', + severity: SEVERITY.WARNING, + description: 'config.json models entry malformed (unknown phase type, invalid tier, or non-object value)', + repairable: false, + check: checkW022, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/milestone-archive-hygiene.cts b/src/health-diagnostic-rules/milestone-archive-hygiene.cts index f7751ac0f..99a02c305 100644 --- a/src/health-diagnostic-rules/milestone-archive-hygiene.cts +++ b/src/health-diagnostic-rules/milestone-archive-hygiene.cts @@ -96,8 +96,20 @@ function checkW019(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W018', severity: SEVERITY.WARNING, check: checkW018 }, - { code: 'W019', severity: SEVERITY.WARNING, check: checkW019 }, + { + code: 'W018', + severity: SEVERITY.WARNING, + description: 'MILESTONES.md missing entry for archived milestone snapshot', + repairable: true, + check: checkW018, + }, + { + code: 'W019', + severity: SEVERITY.WARNING, + description: 'Unrecognized .planning/ root file — not a canonical GSD artifact', + repairable: false, + check: checkW019, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/phase-structure.cts b/src/health-diagnostic-rules/phase-structure.cts index 504f0413b..7b78f1ce4 100644 --- a/src/health-diagnostic-rules/phase-structure.cts +++ b/src/health-diagnostic-rules/phase-structure.cts @@ -180,10 +180,34 @@ function checkW009(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W005', severity: SEVERITY.WARNING, check: checkW005 }, - { code: 'W023', severity: SEVERITY.WARNING, check: checkW023 }, - { code: 'I001', severity: SEVERITY.INFO, check: checkI001 }, - { code: 'W009', severity: SEVERITY.WARNING, check: checkW009 }, + { + code: 'W005', + severity: SEVERITY.WARNING, + description: 'Phase directory naming mismatch', + repairable: false, + check: checkW005, + }, + { + code: 'W023', + severity: SEVERITY.WARNING, + description: 'Phase directories collide on normalized key', + repairable: false, + check: checkW023, + }, + { + code: 'I001', + severity: SEVERITY.INFO, + description: 'Plan without SUMMARY (may be in progress)', + repairable: false, + check: checkI001, + }, + { + code: 'W009', + severity: SEVERITY.WARNING, + description: 'Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md', + repairable: false, + check: checkW009, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/roadmap-disk-consistency.cts b/src/health-diagnostic-rules/roadmap-disk-consistency.cts index 7265fdeb8..83ec18898 100644 --- a/src/health-diagnostic-rules/roadmap-disk-consistency.cts +++ b/src/health-diagnostic-rules/roadmap-disk-consistency.cts @@ -210,8 +210,20 @@ function checkW007(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W006', severity: SEVERITY.WARNING, check: checkW006 }, - { code: 'W007', severity: SEVERITY.WARNING, check: checkW007 }, + { + code: 'W006', + severity: SEVERITY.WARNING, + description: 'Phase in ROADMAP but no directory', + repairable: false, + check: checkW006, + }, + { + code: 'W007', + severity: SEVERITY.WARNING, + description: 'Phase on disk but not in ROADMAP', + repairable: false, + check: checkW007, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/root-existence.cts b/src/health-diagnostic-rules/root-existence.cts index 8208116b1..0e34689d9 100644 --- a/src/health-diagnostic-rules/root-existence.cts +++ b/src/health-diagnostic-rules/root-existence.cts @@ -163,10 +163,16 @@ function checkW001(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'E002', severity: SEVERITY.ERROR, check: checkE002 }, - { code: 'E003', severity: SEVERITY.ERROR, check: checkE003 }, - { code: 'E004', severity: SEVERITY.ERROR, check: checkE004 }, - { code: 'W001', severity: SEVERITY.WARNING, check: checkW001 }, + { code: 'E002', severity: SEVERITY.ERROR, description: 'PROJECT.md not found', repairable: false, check: checkE002 }, + { code: 'E003', severity: SEVERITY.ERROR, description: 'ROADMAP.md not found', repairable: false, check: checkE003 }, + { code: 'E004', severity: SEVERITY.ERROR, description: 'STATE.md not found', repairable: false, check: checkE004 }, + { + code: 'W001', + severity: SEVERITY.WARNING, + description: 'PROJECT.md missing required section', + repairable: false, + check: checkW001, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-rules/state-consistency.cts b/src/health-diagnostic-rules/state-consistency.cts index e908b992d..b0a24e85c 100644 --- a/src/health-diagnostic-rules/state-consistency.cts +++ b/src/health-diagnostic-rules/state-consistency.cts @@ -86,6 +86,8 @@ const { getMilestoneFromPhaseId, matchPhaseDirs, normalizePhaseName, extractPhas const RULE_W024: Rule = { code: 'W024', severity: SEVERITY.WARNING, + description: 'STATE.md was written many commits ago — treat its contents as approximate', + repairable: false, check: (_snapshot: PlanningSnapshot): Diagnostic[] => [], }; @@ -139,6 +141,8 @@ function normalizePhaseTokenSet(valid: Set): Set { const RULE_W002: Rule = { code: 'W002', severity: SEVERITY.WARNING, + description: 'STATE.md references invalid phase', + repairable: false, check: (snapshot: PlanningSnapshot): Diagnostic[] => { const validPhases = buildValidPhaseSet(snapshot); // Mirrors `verify.cts:1765`'s `if (normalizedValid.size > 0)` guard @@ -193,6 +197,8 @@ function currentPhaseIdFromLabel(label: string | null): string | null { const RULE_W011: Rule = { code: 'W011', severity: SEVERITY.WARNING, + description: 'STATE.md current-phase status disagrees with ROADMAP.md checkbox', + repairable: false, check: (snapshot: PlanningSnapshot): Diagnostic[] => { const phaseId = currentPhaseIdFromLabel(snapshot.currentPhaseLabel.value); if (phaseId === null) return []; @@ -216,6 +222,9 @@ const RULE_W011: Rule = { const RULE_W021: Rule = { code: 'W021', severity: SEVERITY.WARNING, + description: + "Phase's integer prefix implies a different milestone than its ROADMAP section (phase_id_convention: milestone-prefixed)", + repairable: false, check: (snapshot: PlanningSnapshot): Diagnostic[] => { const convention = snapshot.config.value?.['phase_id_convention']; if (convention !== 'milestone-prefixed') return []; @@ -246,6 +255,8 @@ const RULE_W021: Rule = { const RULE_W026: Rule = { code: 'W026', severity: SEVERITY.WARNING, + description: 'STATE says milestone complete but ROADMAP lists an unstarted phase for that milestone', + repairable: false, check: (snapshot: PlanningSnapshot): Diagnostic[] => { const statusVal = (snapshot.stateStatus.value ?? '').trim().toLowerCase(); if (!/milestone complete|archived/.test(statusVal)) return []; diff --git a/src/health-diagnostic-rules/worktree-health.cts b/src/health-diagnostic-rules/worktree-health.cts index 4ca349b5a..b5613f359 100644 --- a/src/health-diagnostic-rules/worktree-health.cts +++ b/src/health-diagnostic-rules/worktree-health.cts @@ -154,9 +154,27 @@ function checkW027(snapshot: PlanningSnapshot): Diagnostic[] { // ─── Exports ──────────────────────────────────────────────────────────────── const RULES: Rule[] = [ - { code: 'W020', severity: SEVERITY.WARNING, check: checkW020 }, - { code: 'W017', severity: SEVERITY.WARNING, check: checkW017 }, - { code: 'W027', severity: SEVERITY.WARNING, check: checkW027 }, + { + code: 'W020', + severity: SEVERITY.WARNING, + description: 'Worktree health scan degraded — git worktree list timed out, failed, or a finding could not be verified', + repairable: false, + check: checkW020, + }, + { + code: 'W017', + severity: SEVERITY.WARNING, + description: 'Orphan git worktree (path no longer exists on disk)', + repairable: false, + check: checkW017, + }, + { + code: 'W027', + severity: SEVERITY.WARNING, + description: 'Stale git worktree (not modified in a long time)', + repairable: false, + check: checkW027, + }, ]; export = { RULES }; diff --git a/src/health-diagnostic-types.cts b/src/health-diagnostic-types.cts index e3de6eb7b..c6a397364 100644 --- a/src/health-diagnostic-types.cts +++ b/src/health-diagnostic-types.cts @@ -77,6 +77,47 @@ interface Diagnostic { interface Rule { code: string; severity: Severity; + /** + * Short, static, human-readable summary of what this rule checks — the + * source of `gsd-core/workflows/health.md`'s generated `` + * table (`scripts/gen-health-docs.cjs`). Deliberately distinct from a + * fired `Diagnostic`'s `message`, which is dynamic/per-instance (e.g. + * W001's message names the specific PROJECT.md section that is missing); + * `description` is exactly one fixed sentence per code, matching the + * hand-written table's pre-existing style for the codes it already + * documented (E001-E005, W001-W009, W018, W019, W024, I001). + */ + description: string; + /** + * Whether `--repair` will actually apply this rule's remedy (`true`) or + * never will (`false`) — the source of the generated table's "Repairable" + * column. This MUST match `diagnosticToIssueEntry`'s (`src/verify.cts`) + * per-diagnostic semantics: `remedy.action !== ADVISE && remedy.risk !== + * REMEDY_RISK.DESTRUCTIVE`. `false` covers TWO distinct cases, and both + * must map to `false` here: + * + * 1. ADVISE-only rules — no real `REMEDY_ACTION` exists to apply. + * 2. DESTRUCTIVE-risk rules (`regenerateState`, `resetConfig`) — a real + * action exists and is described, but `applyRepairs`'s dispatcher + * (`src/health-diagnostic.cts`) refuses to auto-apply any + * DESTRUCTIVE-risk remedy (§8.3 rule 3), so `--repair` never applies it + * either. "A remedy exists to describe" is NOT sufficient for `true` — + * only "an unattended `--repair` run will actually apply it" is. + * + * STATIC field, not derived by executing `check` against a fixture at + * doc-gen time: confirmed by direct read of all 8 + * `src/health-diagnostic-rules/*.cts` files that every rule in this + * codebase uses exactly ONE `remedy.action` (and therefore one + * `remedy.risk`) across every `Diagnostic` it can ever emit — no rule mixes + * ADVISE with a real action, or NONE-risk with DESTRUCTIVE-risk, depending + * on the triggering condition (the design doc's "primary remedy" ambiguity + * this field's doc comment was asked to consider does not arise in + * practice). A single static boolean is therefore a faithful, + * execution-free summary, and cheaper/simpler than adding a second + * `primaryRemedyAction` field or having the generator import and execute + * every rule against a synthetic snapshot. + */ + repairable: boolean; check: (snapshot: PlanningSnapshot) => Diagnostic[]; // §8.1 rule 1 signature, verbatim } diff --git a/tests/gen-health-docs.test.cjs b/tests/gen-health-docs.test.cjs new file mode 100644 index 000000000..9653e7d07 --- /dev/null +++ b/tests/gen-health-docs.test.cjs @@ -0,0 +1,258 @@ +'use strict'; + +/** + * gen-health-docs.cjs regression tests (#3309, "health.md's tables are + * generated rather than hand-maintained, closing the 16-vs-30+ documentation + * gap structurally"). + * + * Every CLI-level test spawns the real generator (execFileSync) against a + * temp copy of the shipped `gsd-core/workflows/health.md`, using the + * generator's `--target ` override — never mutates the real committed + * file. No fs monkeypatching is needed for these cases. + */ + +const { describe, test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { execFileSync } = require('node:child_process'); + +const { createTempDir, cleanup } = require('./helpers.cjs'); +const { + buildErrorCodeRows, + renderErrorCodesRegion, + renderRepairActionsRegion, + regenerateHealthMd, + spliceRegion, + compareCodes, + PRECHECK_CODES, + REMEDY_ACTION_ORDER, + ERROR_CODES_START, + ERROR_CODES_END, +} = require('../scripts/gen-health-docs.cjs'); + +const ROOT = path.resolve(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'gen-health-docs.cjs'); +const SHIPPED_HEALTH_MD = path.join(ROOT, 'gsd-core', 'workflows', 'health.md'); +const COMPILED_MODULE_PATH = path.join(ROOT, 'gsd-core', 'bin', 'lib', 'health-diagnostic.cjs'); + +function loadRealRules() { + // Real compiled RULES — build:lib is a pretest dependency for the whole + // suite (package.json `pretest`), so this is always present by the time + // node:test runs these files. + return require(COMPILED_MODULE_PATH).RULES; +} + +/** + * @param {string[]} args + * @param {string} cwd + * @returns {{code: number, stdout: string, stderr: string}} + */ +function runGenHealthDocs(args, cwd = ROOT) { + try { + const stdout = execFileSync(process.execPath, [SCRIPT, ...args], { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + timeout: 30000, + }); + return { code: 0, stdout, stderr: '' }; + } catch (err) { + return { + code: err.status ?? 1, + stdout: err.stdout ? err.stdout.toString() : '', + stderr: err.stderr ? err.stderr.toString() : '', + }; + } +} + +function copyShippedHealthMd(destDir) { + const dest = path.join(destDir, 'health.md'); + fs.copyFileSync(SHIPPED_HEALTH_MD, dest); + return dest; +} + +// ─── CLI: --check / --write round trip ───────────────────────────────────── + +describe('gen-health-docs.cjs --check / --write (CLI, --target fixture)', () => { + test('--check passes on a freshly-written file', (t) => { + const tmpRoot = createTempDir('gen-health-docs-'); + t.after(() => cleanup(tmpRoot)); + + const target = copyShippedHealthMd(tmpRoot); + + const w = runGenHealthDocs(['--write', '--target', target]); + assert.equal(w.code, 0, `stderr: ${w.stderr}`); + + const c = runGenHealthDocs(['--check', '--target', target]); + assert.equal(c.code, 0, `--check must be clean immediately after --write; stderr: ${c.stderr}`); + assert.match(c.stdout, /up to date/); + }); + + test('--check fails when the tagged region is stale (mutate a temp copy)', (t) => { + const tmpRoot = createTempDir('gen-health-docs-'); + t.after(() => cleanup(tmpRoot)); + + const target = copyShippedHealthMd(tmpRoot); + + // Mutate the committed, already-up-to-date table so it drifts from what + // the generator would produce — a single row edit is enough. + let content = fs.readFileSync(target, 'utf8'); + assert.ok(content.includes('| E001 | error |'), 'sanity: shipped health.md must carry the E001 row'); + content = content.replace('| E001 | error |', '| E001 | error-STALE-MUTATION |'); + fs.writeFileSync(target, content, 'utf8'); + + const c = runGenHealthDocs(['--check', '--target', target]); + assert.equal(c.code, 1, 'a hand-mutated table must fail --check'); + assert.match(c.stderr, /is stale/); + assert.match(c.stderr, /gen-health-docs\.cjs --write/); + }); + + test('--write on a stale copy regenerates it back to a clean --check', (t) => { + const tmpRoot = createTempDir('gen-health-docs-'); + t.after(() => cleanup(tmpRoot)); + + const target = copyShippedHealthMd(tmpRoot); + let content = fs.readFileSync(target, 'utf8'); + content = content.replace('| W010 |', '| W010-DRIFTED |'); + fs.writeFileSync(target, content, 'utf8'); + + const failedCheck = runGenHealthDocs(['--check', '--target', target]); + assert.equal(failedCheck.code, 1, 'sanity: the mutated copy must fail --check first'); + + const w = runGenHealthDocs(['--write', '--target', target]); + assert.equal(w.code, 0, `stderr: ${w.stderr}`); + + const c = runGenHealthDocs(['--check', '--target', target]); + assert.equal(c.code, 0, `stderr: ${c.stderr}`); + }); + + test('plain invocation (no flag) prints both tables to stdout and exits 0', () => { + const r = runGenHealthDocs([]); + assert.equal(r.code, 0, `stderr: ${r.stderr}`); + assert.match(r.stdout, /\| Code \| Severity \| Description \| Repairable \|/); + assert.match(r.stdout, /\| Action \| Effect \| Risk \|/); + }); + + test('an unrecognized flag exits 1 rather than silently falling through', () => { + const r = runGenHealthDocs(['--bogus']); + assert.equal(r.code, 1); + assert.match(r.stderr, /unknown flag/); + }); + + test('the shipped gsd-core/workflows/health.md already passes --check against the real repo', () => { + const r = runGenHealthDocs(['--check']); + assert.equal(r.code, 0, `the committed health.md must already be up to date; stderr: ${r.stderr}`); + }); +}); + +// ─── Row content: representative codes, including previously-undocumented ─ + +describe('gen-health-docs.cjs row content (representative codes)', () => { + const rules = loadRealRules(); + + test('produces a 34-row table: 31 rules + 3 pre-checks (E001, E010, I010)', () => { + const rows = buildErrorCodeRows(rules); + assert.equal(rows.length, 34); + const codes = rows.map((r) => r.code); + for (const precheck of PRECHECK_CODES) { + assert.ok(codes.includes(precheck.code), `missing pre-check code ${precheck.code}`); + } + }); + + test('W010 (previously-undocumented, agent-install) renders with its Rule-sourced description and Repairable=No', () => { + const region = renderErrorCodesRegion(rules); + const row = region.split('\n').find((line) => line.startsWith('| W010 |')); + assert.ok(row, 'W010 row must be present'); + const w010Rule = rules.find((r) => r.code === 'W010'); + assert.ok(row.includes(w010Rule.description)); + assert.match(row, /\| No \|$/); + }); + + test('W026 (previously-undocumented, new post-migration split code) renders with its Rule-sourced description', () => { + const region = renderErrorCodesRegion(rules); + const row = region.split('\n').find((line) => line.startsWith('| W026 |')); + assert.ok(row, 'W026 row must be present'); + const w026Rule = rules.find((r) => r.code === 'W026'); + assert.ok(row.includes(w026Rule.description)); + }); + + test('E004 (already-documented, DESTRUCTIVE-risk remedy) renders with Repairable=No — --repair refuses to auto-apply regenerateState', () => { + const region = renderErrorCodesRegion(rules); + const row = region.split('\n').find((line) => line.startsWith('| E004 |')); + assert.ok(row); + assert.match(row, /\| No \|$/); + }); + + test('W018 renders the --backfill-qualified Repairable override, not a bare "Yes"', () => { + const region = renderErrorCodesRegion(rules); + const row = region.split('\n').find((line) => line.startsWith('| W018 |')); + assert.ok(row); + assert.match(row, /Yes \(`--backfill`\)/); + }); + + test('W025 (workflow-layer diagnostic, not a Rule) is absent from the generated table', () => { + const region = renderErrorCodesRegion(rules); + assert.ok( + !region.split('\n').some((line) => line.startsWith('| W025 |')), + 'W025 must not appear as a generated row — it is documented in its own workflow step, not the RULES table', + ); + }); + + test(' rows are sorted E-codes, then W-codes numerically, then I-codes', () => { + const rows = buildErrorCodeRows(rules); + const sorted = [...rows].sort(compareCodes); + assert.deepEqual(rows, sorted, 'buildErrorCodeRows must already return its rows in sorted order'); + // Spot-check the three-group boundary explicitly. + const codes = rows.map((r) => r.code); + const lastE = codes.lastIndexOf(codes.filter((c) => c.startsWith('E')).at(-1)); + const firstW = codes.findIndex((c) => c.startsWith('W')); + const lastW = codes.lastIndexOf(codes.filter((c) => c.startsWith('W')).at(-1)); + const firstI = codes.findIndex((c) => c.startsWith('I')); + assert.ok(lastE < firstW, 'every E-code must sort before every W-code'); + assert.ok(lastW < firstI, 'every W-code must sort before every I-code'); + }); + + test('renderRepairActionsRegion lists all 6 real repair actions, including the previously-undocumented addAiIntegrationPhaseKey', () => { + const region = renderRepairActionsRegion(); + for (const action of REMEDY_ACTION_ORDER) { + assert.ok(region.includes(`| ${action} |`), `missing repair action row: ${action}`); + } + assert.equal(REMEDY_ACTION_ORDER.length, 6); + assert.ok(region.includes('addAiIntegrationPhaseKey'), '#3309: this action was "live in code, missing from docs"'); + }); +}); + +// ─── spliceRegion / regenerateHealthMd — pure-function edge cases ───────── + +describe('gen-health-docs.cjs spliceRegion (pure function)', () => { + test('throws when a tag is missing', () => { + assert.throws( + () => spliceRegion('no tags here', ERROR_CODES_START, ERROR_CODES_END, 'x'), + /missing the .*tags/, + ); + }); + + test('throws when a tag appears more than once', () => { + const text = `${ERROR_CODES_START}a${ERROR_CODES_END}${ERROR_CODES_START}b${ERROR_CODES_END}`; + assert.throws(() => spliceRegion(text, ERROR_CODES_START, ERROR_CODES_END, 'x'), /more than one/); + }); + + test('preserves content strictly outside the tags, byte-for-byte', () => { + const before = 'PROSE BEFORE\n'; + const after = '\nPROSE AFTER'; + const text = `${before}${ERROR_CODES_START}old inner${ERROR_CODES_END}${after}`; + const out = spliceRegion(text, ERROR_CODES_START, ERROR_CODES_END, 'new inner'); + assert.ok(out.startsWith(before + ERROR_CODES_START)); + assert.ok(out.endsWith(ERROR_CODES_END + after)); + assert.ok(!out.includes('old inner')); + assert.ok(out.includes('new inner')); + }); + + test('regenerateHealthMd is idempotent: regenerating an already-generated document is a no-op', () => { + const rules = loadRealRules(); + const shipped = fs.readFileSync(SHIPPED_HEALTH_MD, 'utf8'); + const regenerated = regenerateHealthMd(rules, shipped); + assert.equal(regenerated, shipped); + }); +}); From 23884a6448dd6123349d4bc2eba1e4f35079e00a Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 03:13:34 -0400 Subject: [PATCH 25/35] docs(#3309): add changeset fragments for the breaking changes Three user-visible changes disclosed per CONTRIBUTING.md's changeset convention: --repair no longer auto-applies DESTRUCTIVE remedies (Changed), W021/W017 split into W026/W027 for their previously- conflated second subjects (Changed), and --backfill alone now actually works (Fixed, a latent-bug fix). pr:0 placeholder, backfilled once the PR number is known. --- .changeset/fierce-eagles-roam.md | 5 +++++ .changeset/gallant-otters-fly.md | 5 +++++ .changeset/happy-jaguars-roar.md | 5 +++++ 3 files changed, 15 insertions(+) create mode 100644 .changeset/fierce-eagles-roam.md create mode 100644 .changeset/gallant-otters-fly.md create mode 100644 .changeset/happy-jaguars-roar.md diff --git a/.changeset/fierce-eagles-roam.md b/.changeset/fierce-eagles-roam.md new file mode 100644 index 000000000..84a9a192c --- /dev/null +++ b/.changeset/fierce-eagles-roam.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 0 +--- +**`validate health --backfill` now works without also passing `--repair`** — previously it silently did nothing unless `--repair` was also set, due to an unreachable internal gate. diff --git a/.changeset/gallant-otters-fly.md b/.changeset/gallant-otters-fly.md new file mode 100644 index 000000000..69af1f431 --- /dev/null +++ b/.changeset/gallant-otters-fly.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 0 +--- +**`validate health` splits two previously-conflated warning codes into their own codes** — W021 now covers only the phase-id-convention mismatch it originally meant; the STATE-vs-ROADMAP milestone-complete mismatch it used to also report moves to the new W026. Likewise W017 now covers only orphan worktrees; the stale-worktree case moves to the new W027. diff --git a/.changeset/happy-jaguars-roar.md b/.changeset/happy-jaguars-roar.md new file mode 100644 index 000000000..9add7c2c1 --- /dev/null +++ b/.changeset/happy-jaguars-roar.md @@ -0,0 +1,5 @@ +--- +type: Changed +pr: 0 +--- +**`validate health --repair` no longer resets config.json or regenerates STATE.md automatically** — these two repairs are destructive (they lose custom settings or session history), so they're now reported with their fix described but never auto-applied; run the suggested command yourself to apply them. From ce57e60098622edc790ba6163bd28d64ea4d51f5 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 04:30:19 -0400 Subject: [PATCH 26/35] fix(#3309): W006/W007 miss archived phases and phase-id variant normalization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gsd-test found two real regressions in the migrated W006/W007: 1. A phase whose directory lives under an archived milestone (.planning/milestones/v*-phases//) instead of the active phases/ dir read as "in ROADMAP but no directory on disk" — the original forEachArchivedPhaseToken(planBase, ...) fed archived tokens into the same existence check (verify.cts:2038); the migrated rule's allPhaseDirNames never included them. 2. Comparing a ROADMAP-declared phase id against a disk directory name dropped phaseVariants() normalization the original ran as a second, independent check (verify.cts:2071-2073/2092-2093) — a ROADMAP "01A" and a disk "1A-..." read as mismatched instead of the same phase, since matchPhaseDirs's own token comparison never unifies that padding/letter-suffix difference. Adds planning-snapshot.cts's archivedPhaseTokens field (mirrors forEachArchivedPhaseToken/listMilestoneArchiveDirs exactly, no new regex derivation) and a phaseVariants()-based fallback in dirsForPhase when matchPhaseDirs finds nothing. --- .../roadmap-disk-consistency.cts | 66 +++++++- src/planning-snapshot.cts | 151 ++++++++++++++++-- 2 files changed, 201 insertions(+), 16 deletions(-) diff --git a/src/health-diagnostic-rules/roadmap-disk-consistency.cts b/src/health-diagnostic-rules/roadmap-disk-consistency.cts index 83ec18898..5b92de5bd 100644 --- a/src/health-diagnostic-rules/roadmap-disk-consistency.cts +++ b/src/health-diagnostic-rules/roadmap-disk-consistency.cts @@ -41,11 +41,26 @@ * (`src/planning-snapshot.cts`) instead: every directory actually present * under the active `phases/` root, unfiltered by roadmap declaration. * Archived-milestone directory names (`verify.cts:2050`, - * `collectArchivedPhaseDirNames`) are still not part of `PlanningSnapshot` - * and remain a disclosed fidelity reduction (a phase whose only directory - * lives in a shipped-milestone archive can read as W006-missing; a shipped - * archived dir is never scanned so it cannot spuriously read as - * W007-orphaned either) — unchanged by this fix. + * `collectArchivedPhaseDirNames`) are still not exposed as directory NAMES + * on `PlanningSnapshot`, but the equivalent TOKEN set is: `checkW006` below + * additionally consults `snapshot.archivedPhaseTokens` (added for the + * W002/state-consistency group's #3652 fix, reused verbatim here — see that + * field's own doc comment) so a phase whose only directory lives in a + * milestone archive (shipped OR the current milestone's own archive layout) + * no longer reads as W006-missing (Bug 1, found post-migration: the archived + * fixtures under `tests/milestone-archive.test.cjs` and + * `tests/verify-health.test.cjs` regressed against the pre-migration + * `verify.cts` behavior). W007 still never scans archived dirs (its loop is + * `allPhaseDirNames.value`, the active `phases/` root only), so an archived + * dir still cannot spuriously read as W007-orphaned either — unchanged. + * + * Bug 2 (found alongside Bug 1): `dirsForPhase` below also runs a + * `phaseVariants()`-based fallback when `matchPhaseDirs` finds nothing — see + * its own doc comment. Pre-migration, `verify.cts:2071-2073`/`2092-2093` ran + * this as a SECOND, independent check the migrated matchPhaseDirs-only path + * had dropped, causing a false W006/W007 whenever ROADMAP and disk spelled + * the same phase with a different zero-padding (e.g. ROADMAP "01A" vs disk + * "1A-..."). * * Not-started exclusion (verify.cts:2065/2075-2076, * `buildNotStartedPhaseVariants`, `src/validate.cts:160`): the design doc's @@ -114,7 +129,34 @@ const { phaseVariants } = validateMod; * file-level comment. */ function dirsForPhase(dirs: string[], phaseId: string): string[] { - return matchPhaseDirs(dirs, normalizePhaseName(phaseId)).matches; + const canonical = matchPhaseDirs(dirs, normalizePhaseName(phaseId)).matches; + if (canonical.length > 0) return canonical; + + // Bug 2 (#3309 W006/W007 migration cluster, found while fixing the + // originally-reported archived-directory gap): `matchPhaseDirs`'s + // `phaseTokenMatches` compares `extractPhaseToken(dir)` (the directory's + // LITERAL, un-normalized digit run — e.g. "1A" for `1A-suffix-phase`) + // against `normalizePhaseName(phaseId)` (which PADS — "01A") case- + // insensitively, but never unifies a padding/letter-suffix mismatch + // BETWEEN the two sides: "1A" !== "01A" even though they name the same + // phase. Pre-migration, `verify.cts:2071-2073`/`2092-2093` ran a SECOND, + // independent check here — `[...phaseVariants(p)].some((v) => + // diskPhases.has(v))` — that the migrated matchPhaseDirs-only path + // dropped. `phaseVariants` (`validate.cts:101`) is symmetric + // (padded<->unpadded, letter-suffix preserved both ways), so generating + // variants from `phaseId` and checking raw-disk-token membership is + // equivalent to intersecting `phaseVariants(phaseId)` with + // `phaseVariants(diskToken)` — variants always include their own input + // verbatim, so this fallback catches exactly the cases `matchPhaseDirs` + // alone misses without re-deriving a second matcher. + const variants = phaseVariants(phaseId); + return dirs.filter((d) => { + const token = extractPhaseToken(d).toUpperCase(); + for (const variant of variants) { + if (token === variant.toUpperCase()) return true; + } + return false; + }); } /** @@ -145,6 +187,7 @@ function checkW006(snapshot: PlanningSnapshot): Diagnostic[] { if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return []; const dirs = snapshot.allPhaseDirNames.value; + const archivedTokens = new Set(snapshot.archivedPhaseTokens.value); const checkboxes = snapshot.roadmapPhaseCheckboxes.value; const diagnostics: Diagnostic[] = []; @@ -153,6 +196,17 @@ function checkW006(snapshot: PlanningSnapshot): Diagnostic[] { // convention; a sentinel heading shouldn't demand a directory. if (isSentinelPhaseId(phaseId)) continue; if (dirsForPhase(dirs, phaseId).length > 0) continue; + // Bug 1 (#3309 W006/W007 migration cluster): a phase whose ONLY + // directory lives under a milestone archive + // (`.planning/milestones/vX.Y-phases//`, shipped OR the current + // milestone's own archive layout) must not read as "no directory on + // disk" — mirrors `verify.cts:2038`'s + // `forEachArchivedPhaseToken(planBase, (token) => diskPhases.add(token))` + // feeding the archived-phase token set into this exact existence check. + // `snapshot.archivedPhaseTokens` (`src/planning-snapshot.cts`) is the + // same token set, reused verbatim from the W002/state-consistency + // group's own fix for the analogous gap — not a re-derivation. + if ([...phaseVariants(phaseId)].some((v) => archivedTokens.has(v))) continue; if (isPhaseNotStarted(phaseId, checkboxes)) continue; diagnostics.push({ code: 'W006', diff --git a/src/planning-snapshot.cts b/src/planning-snapshot.cts index b8be41e8d..3c42f6099 100644 --- a/src/planning-snapshot.cts +++ b/src/planning-snapshot.cts @@ -23,7 +23,7 @@ import fs from 'node:fs'; import path from 'node:path'; // eslint-disable-next-line @typescript-eslint/no-require-imports import roadmapParserMod = require('./roadmap-parser.cjs'); -const { getMilestoneInfo } = roadmapParserMod; +const { getMilestoneInfo, extractCurrentMilestone } = roadmapParserMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseLocatorMod = require('./phase-locator.cjs'); const { listMilestonePhaseDirs } = phaseLocatorMod; @@ -56,8 +56,8 @@ import worktreeSafetyMod = require('./worktree-safety.cjs'); const { inspectWorktreeHealth } = worktreeSafetyMod; // eslint-disable-next-line @typescript-eslint/no-require-imports import phaseIdMod = require('./phase-id.cjs'); -const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod; -import { buildRoadmapPhaseVariants } from './validate.cjs'; +const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix } = phaseIdMod; +import { buildRoadmapPhaseVariants, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE } from './validate.cjs'; // ─── worstScope — the one new piece of coordination logic ─────────────────── @@ -117,7 +117,7 @@ interface PlanningSnapshot { // names, "the snapshot". config: { value: Record | null; scope: Scope; exists: boolean }; agentInstall: { value: ReturnType; scope: Scope }; - worktreeHealth: { value: ReturnType['findings']; scope: Scope }; + worktreeHealth: { value: ReturnType['findings']; scope: Scope; reason: string }; // ─── Phase 11 (#3309) "Rule table organization" additions ───────────────── // The design doc's own "Rule table organization" table and prose disagree // on the count: the table lists EIGHT rows (through `planningRootFiles`, @@ -163,6 +163,40 @@ interface PlanningSnapshot { // they already are for `phaseDirs` (see this batch's own disclosed // fidelity reduction for that). allPhaseDirNames: { value: string[]; scope: Scope }; + // W002 (STATE.md-consistency group) fidelity fix, found while implementing + // `src/health-diagnostic-rules/state-consistency.cts`. The original + // `cmdValidateHealth` W002 check unions THREE sources into its "valid + // phase" set — disk dirs, ROADMAP headings, and + // `forEachArchivedPhaseToken(planBase, ...)` (`verify.cts:1748`, every + // phase-token-shaped subdirectory under `.planning/milestones/*-phases/`, + // via the same `MILESTONE_ARCHIVE_DIR_RE`/`PHASE_TOKEN_FROM_DIR_RE` + // `listMilestoneArchiveDirs`/`forEachArchivedPhaseToken` use, both already + // exported from `validate.cjs` — no new regex derivation here). Without the + // third source, a STATE.md reference to a phase whose only directory lives + // in a shipped-milestone archive reads as an undeclared phase (#3652). + // Additive-only per this batch's own field-table constraint. Also now reused + // by `src/health-diagnostic-rules/roadmap-disk-consistency.cts`'s `checkW006` + // (Bug 1, #3309 W006/W007 migration cluster) for the same "was this token + // archived" question a ROADMAP *entry* needs answered, not just a STATE.md + // *reference* — same token set, two independent consumers, no re-derivation. + archivedPhaseTokens: { value: string[]; scope: Scope }; + // W026 (STATE.md-consistency group) fidelity fix, found while implementing + // `src/health-diagnostic-rules/state-consistency.cts`. W026's original + // logic (`verify.cts:2356-2399`, the second `addIssue('warning', 'W021', + // ...)` call site before the #3309 code split) scopes ROADMAP.md to the + // CURRENT milestone via `extractCurrentMilestone(roadmapRaw, cwd)` — the + // same shared, `
`/``-tolerant scoping owner every other + // milestone-aware consumer uses (`roadmap-parser.cts`) — then scans + // `#{2,4}\s*Phase\s+(TOKEN)...` headings within that scoped slice. + // `roadmapDeclaredPhases`'s `milestone` attribution (above) is NOT a fit + // here even though it looks adjacent: it exists to relocate + // `checkMilestonePrefixMismatches`'s OWN narrower `sectionRx` + // (`verify.cts:1429-1459`, `^#{1,3}\s+...vX.Y`, no `
` support) — + // faithful for W021 (which never supported `
` either), but + // reusing it for W026 would regress W026's ALREADY-`
`-tolerant + // original behavior. This field is W026's own, independently-scoped + // phase-id list — additive-only, no change to `roadmapDeclaredPhases`. + currentMilestoneRoadmapPhaseIds: { value: string[]; scope: Scope }; } /** @@ -260,9 +294,23 @@ function buildStateFields(statePath: string): StateFields { const section = stateCurrentPositionSlice(body); const currentPositionScope = section === null ? SCOPE.TRUNCATED : SCOPE.COMPLETE; - const currentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Phase', { + // #1760 fallback ladder (mirrors `state.cts:1499-1500`'s `resolveStatePhase` + // exactly, same `section ?? body` scope for both reads): the legacy bold + // `**Current Phase:**` field (what `verify.cts:2109-2111` originally + // matched, and what pre-template-migration STATE.md fixtures still use) + // takes priority over the current template's bare `Phase: [X] of [Y]` + // field — a document carrying both is read the same way `resolveStatePhase` + // reads it elsewhere. + const legacyCurrentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Current Phase', { scope: currentPositionScope, }); + const templateCurrentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Phase', { + scope: currentPositionScope, + }); + const currentPhaseLabel = { + value: legacyCurrentPhaseLabel.value ?? templateCurrentPhaseLabel.value, + scope: legacyCurrentPhaseLabel.value !== null ? legacyCurrentPhaseLabel.scope : templateCurrentPhaseLabel.scope, + }; const stateStatus = stateFieldValue(frontmatter, section ?? body, 'status', 'Status', { scope: currentPositionScope, }); @@ -350,9 +398,17 @@ function buildAgentInstallField(cwd: string): { value: ReturnType['findings']; scope: Scope } { +function buildWorktreeHealthField(cwd: string): { value: ReturnType['findings']; scope: Scope; reason: string } { try { const result = inspectWorktreeHealth( cwd, @@ -360,11 +416,11 @@ function buildWorktreeHealthField(cwd: string): { value: ReturnType e.isDirectory() && MILESTONE_ARCHIVE_DIR_RE.test(e.name)) + .map((e) => path.join(milestonesDir, e.name)); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { value: [], scope: SCOPE.COMPLETE }; + return { value: [], scope: SCOPE.UNREADABLE }; + } + + const value: string[] = []; + for (const archiveDir of archiveDirs) { + try { + const entries = fs.readdirSync(archiveDir, { withFileTypes: true }); + for (const e of entries) { + if (!e.isDirectory()) continue; + const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE); + if (m) value.push(stripProjectCodePrefix(m[1])); + } + } catch { + /* archive dir absent/unreadable — mirrors forEachArchivedPhaseToken */ + } + } + return { value, scope: SCOPE.COMPLETE }; +} + +/** + * Resolve `currentMilestoneRoadmapPhaseIds` — every phase-number token found + * in ROADMAP.md's content once scoped to the CURRENT milestone via + * `extractCurrentMilestone(content, cwd)`. Backs W026's archive-tolerant + * unstarted-phase scan; see the field's own doc comment on `PlanningSnapshot` + * for why `roadmapDeclaredPhases` cannot serve this. An absent/unreadable + * ROADMAP.md degrades to an empty list, mirroring every other + * ROADMAP-sourced field's absent-file handling. + */ +function buildCurrentMilestoneRoadmapPhaseIdsField( + cwd: string, + roadmapPath: string, +): { value: string[]; scope: Scope } { + if (!fs.existsSync(roadmapPath)) return { value: [], scope: SCOPE.UNREADABLE }; + let content: string; + try { + content = fs.readFileSync(roadmapPath, 'utf-8'); + } catch { + return { value: [], scope: SCOPE.UNREADABLE }; + } + const scoped = extractCurrentMilestone(content, cwd); + // #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal + // mirror of OPTIONAL_PHASE_TAG_SOURCE) — verbatim from `verify.cts:2366`. + const phasePattern = new RegExp( + `#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`, + 'gi', + ); + const value = [...scoped.matchAll(phasePattern)].map((m) => m[1]); + return { value, scope: SCOPE.COMPLETE }; +} + /** * Build the full `.planning/` projection for `cwd`. Composes the six §7 * owners named in the design doc's "Owners consumed" table, plus (Phase 11, @@ -695,6 +824,8 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot { milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd), planningRootFiles: buildPlanningRootFilesField(cwd), allPhaseDirNames: buildAllPhaseDirNamesField(paths.phases), + archivedPhaseTokens: buildArchivedPhaseTokensField(paths.planning), + currentMilestoneRoadmapPhaseIds: buildCurrentMilestoneRoadmapPhaseIdsField(cwd, paths.roadmap), }; } From a0c82f2bd800b21a914a703847599bb723b7f2e3 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 04:30:40 -0400 Subject: [PATCH 27/35] fix(#3309): W002/W011/W026 state-consistency regressions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gsd-test found three real regressions in the migrated STATE.md checks: - W002 didn't exempt phase refs whose only directory lives in an archived milestone (#3652) — now consults planning-snapshot.cts's archivedPhaseTokens field (added alongside this fix, shared with W006's identical need). - W011 (STATE/ROADMAP cross-validation) never fired: currentPhaseLabel only read the current template's bare "Phase:" field, silently missing legacy STATE.md fixtures that use the older bold "**Current Phase:**" field (mirrors state.cts's own resolveStatePhase fallback ladder, which the migration didn't carry over). - W026 (STATE milestone-complete vs. unstarted ROADMAP phases) had two independent defects: roadmapDeclaredPhases's milestone attribution can't see
/-shaped ROADMAP sections, and current- milestone resolution could go null — both silently emptied the "unstarted" set every time. Fixed by scoping ROADMAP.md to the current milestone via the same
-tolerant extractCurrentMilestone every other milestone-aware consumer uses, in a new dedicated planning-snapshot.cts field (currentMilestoneRoadmapPhaseIds) rather than reusing roadmapDeclaredPhases, which exists for a narrower,
-blind derivation (W021's own original logic) and would have regressed it if repurposed. Also fixes an unrelated drift-guard violation this same rule file introduced: its own phase-token regex was independently re-derived instead of built from the canonical PHASE_NUMBER_TOKEN_SOURCE. --- .../state-consistency.cts | 41 +++++++++++++------ 1 file changed, 28 insertions(+), 13 deletions(-) diff --git a/src/health-diagnostic-rules/state-consistency.cts b/src/health-diagnostic-rules/state-consistency.cts index b0a24e85c..1f88b43ff 100644 --- a/src/health-diagnostic-rules/state-consistency.cts +++ b/src/health-diagnostic-rules/state-consistency.cts @@ -55,7 +55,8 @@ type PlanningSnapshot = ReturnType { const valid = new Set(); @@ -120,6 +128,9 @@ function buildValidPhaseSet(snapshot: PlanningSnapshot): Set { for (const entry of snapshot.roadmapDeclaredPhases.value) { valid.add(entry.phaseId); } + for (const token of snapshot.archivedPhaseTokens.value) { + valid.add(token); + } return valid; } @@ -190,7 +201,7 @@ const RULE_W002: Rule = { */ function currentPhaseIdFromLabel(label: string | null): string | null { if (!label) return null; - const m = label.match(/^0*(\d+[A-Z]?(?:\.\d+)*)/); + const m = label.match(new RegExp(`^0*(${PHASE_NUMBER_TOKEN_SOURCE})`)); return m ? m[1] : null; } @@ -261,18 +272,22 @@ const RULE_W026: Rule = { const statusVal = (snapshot.stateStatus.value ?? '').trim().toLowerCase(); if (!/milestone complete|archived/.test(statusVal)) return []; - const currentMilestone = snapshot.milestone.value?.version ?? null; - if (currentMilestone === null) return []; - + // `currentMilestoneRoadmapPhaseIds` is already scoped to the current + // milestone (`extractCurrentMilestone(roadmapRaw, cwd)`, the same + // `
`/``-tolerant owner `verify.cts:2364` used) — no + // separate `currentMilestone` resolution/filter needed here (see the + // field's own doc comment on `PlanningSnapshot` for why + // `roadmapDeclaredPhases`'s `milestone` attribution is the wrong fit). const unstarted: string[] = []; - for (const entry of snapshot.roadmapDeclaredPhases.value) { - // Scoped to the current milestone only — mirrors the original's - // `extractCurrentMilestone(roadmapRaw, cwd)` narrowing before its - // phase-heading scan (`verify.cts:2363-2364`). - if (entry.milestone !== currentMilestone) continue; - const normalized = normalizePhaseName(entry.phaseId); - const hasDirectory = matchPhaseDirs(snapshot.phaseDirs.value, normalized).matches.length > 0; - if (!hasDirectory) unstarted.push(entry.phaseId); + for (const phaseId of snapshot.currentMilestoneRoadmapPhaseIds.value) { + const normalized = normalizePhaseName(phaseId); + // `allPhaseDirNames` — every directory under `phases/`, UNWINDOWED by + // ROADMAP-declaration membership — mirrors the original's own + // unwindowed `phaseDirNames2` (`verify.cts:2372-2382`, a direct + // `readdirSync` of the phases dir), not the current-milestone-windowed + // `phaseDirs`. + const hasDirectory = matchPhaseDirs(snapshot.allPhaseDirNames.value, normalized).matches.length > 0; + if (!hasDirectory) unstarted.push(phaseId); } if (unstarted.length === 0) return []; From 96c7ea9b355299b72dfc3afe179c4b5a7a3bfc1c Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 04:30:48 -0400 Subject: [PATCH 28/35] fix(#3309): W023's message drops each colliding directory's status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gsd-test found the migrated W023 dropped a piece of information the original message included: each colliding phase directory's overall status (e.g. "Complete"), not just its raw plan/summary/verification counts. Adds derivePhaseStatusLabel, reconstructing the status label from already-exposed PhaseSnapshot fields (planCount/summaryCount/ complete/verificationStatus) — no new ambient I/O, no new snapshot field. Also fixes a non-conforming test fixture found while verifying: tests/health-validation.test.cjs's "05-real" fixture used a bare VERIFICATION.md, which readVerificationStatus never matches (the real convention, and every other fixture in this repo, use the *-VERIFICATION.md suffix) — the status was always reading as "missing" regardless of message formatting. Renamed to 05-real-VERIFICATION.md. --- .../phase-structure.cts | 61 ++++++++++++++----- tests/health-validation.test.cjs | 7 ++- 2 files changed, 52 insertions(+), 16 deletions(-) diff --git a/src/health-diagnostic-rules/phase-structure.cts b/src/health-diagnostic-rules/phase-structure.cts index 7b78f1ce4..e9d4274ce 100644 --- a/src/health-diagnostic-rules/phase-structure.cts +++ b/src/health-diagnostic-rules/phase-structure.cts @@ -16,20 +16,23 @@ * - W023's original "described" list called `determinePhaseStatus` * (`commands.cts:154`), a SIX-way status string ('Not Started'/'Planned'/ * 'In Progress'/'Executed'/'Needs Review'/'Complete') computed from its own - * raw `readdirSync` + `*-VERIFICATION.md` frontmatter read of `phaseDir` — - * neither `PhaseSnapshot.complete` (a boolean) nor `PhaseSnapshot. - * verificationStatus` (the DIFFERENT, `readVerificationStatus`-routed - * status vocabulary: 'passed'/'gaps_found'/'human_needed'/'stale'/ - * 'unknown'/'missing', §7.4 disk-strict) reproduces that six-way string - * byte-for-byte — the two computations read the same file independently - * and can disagree (e.g. a stale-but-frontmatter-"passed" VERIFICATION.md - * reads 'Complete' under the original raw read but routes to a non-'passed' - * `verificationStatus` under §7.4's staleness handling). Reproducing the - * raw frontmatter read here would violate §8.1 rule 1 (no ambient I/O in a - * rule's `check`). This rule instead describes each colliding directory - * with the snapshot fields actually available (`planCount`, `summaryCount`, - * `verificationStatus`) — a disclosed fidelity reduction, not a silent - * reproduction of the original six-way label. + * raw `readdirSync` + `*-VERIFICATION.md` frontmatter read of `phaseDir`. + * This rule cannot re-run that raw read (§8.1 rule 1 forbids ambient I/O in + * `check`), so `derivePhaseStatusLabel` below reconstructs the same + * six-way label from fields `PlanningSnapshot` already exposes — + * `PhaseSnapshot.planCount`/`summaryCount` (the plan/no-plan and + * in-progress/planned branches, identical inputs to the original) and + * `PhaseSnapshot.complete`/`verificationStatus` (`isPhaseComplete`'s own + * §7.4 disk-strict routing of the SAME `*-VERIFICATION.md` file) for the + * verification-gated branches. This is a disclosed fidelity reduction, not + * a byte-for-byte guarantee: `verificationStatus` is the DIFFERENT, + * `readVerificationStatus`-routed vocabulary ('passed'/'gaps_found'/ + * 'human_needed'/'stale'/'unknown'/'missing') and can disagree with a raw + * frontmatter re-read in edge cases (e.g. a stale-but-frontmatter-"passed" + * VERIFICATION.md routes to 'stale', not 'passed', under §7.4's staleness + * handling — this rule reports 'Executed' there, not 'Complete'). No new + * ambient I/O and no new `PlanningSnapshot` field were needed — every input + * was already on `PhaseSnapshot`. * * W009's original message interpolates `${slash('plan-phase')}` * (`verify.cts:1982`, ``Re-run ${slash('plan-phase')} with --research to @@ -93,6 +96,32 @@ function checkW005(snapshot: PlanningSnapshot): Diagnostic[] { // `verify.cts:1930-1932`'s deterministic-output rationale. See the file-level // comment for the disclosed "described" fidelity reduction. +/** + * Reconstruct `commands.cts:154`'s `determinePhaseStatus` six-way label from + * fields `PhaseSnapshot` already exposes (no ambient I/O, no new snapshot + * field — see file-level comment). `complete`/`verificationStatus` come from + * `isPhaseComplete`'s §7.4 disk-strict routing of the same `*-VERIFICATION.md` + * file the original raw read targeted. + */ +function derivePhaseStatusLabel( + planCount: number, + summaryCount: number, + complete: boolean, + verificationStatus: string, +): string { + if (planCount === 0) return 'Not Started'; + if (summaryCount < planCount && summaryCount > 0) return 'In Progress'; + if (summaryCount < planCount) return 'Planned'; + // summaryCount >= planCount > 0 — verification-gated, same as the original's + // post-count fall-through. + if (complete) return 'Complete'; + if (verificationStatus === 'human_needed') return 'Needs Review'; + // gaps_found / stale / missing / unknown all land here, same as the + // original's "verification exists but unrecognized" and "no verification + // file" branches both returning 'Executed'. + return 'Executed'; +} + function checkW023(snapshot: PlanningSnapshot): Diagnostic[] { const groups = new Map(); for (const name of snapshot.phaseDirs.value) { @@ -115,8 +144,10 @@ function checkW023(snapshot: PlanningSnapshot): Diagnostic[] { const phase = phaseByDir.get(d); const plans = phase ? phase.planCount : 0; const summaries = phase ? phase.summaryCount : 0; + const complete = phase ? phase.complete : false; const verificationStatus = phase ? phase.verificationStatus : 'missing'; - return `${d} (plans: ${plans}, summaries: ${summaries}, verification: ${verificationStatus})`; + const status = derivePhaseStatusLabel(plans, summaries, complete, verificationStatus); + return `${d} (${status})`; }) .join(', '); diagnostics.push({ diff --git a/tests/health-validation.test.cjs b/tests/health-validation.test.cjs index f442eba3a..06b1ed66d 100644 --- a/tests/health-validation.test.cjs +++ b/tests/health-validation.test.cjs @@ -1075,7 +1075,12 @@ describe('W023 — colliding phase directories (issue #2408)', () => { fs.mkdirSync(realDir, { recursive: true }); fs.writeFileSync(path.join(realDir, '01-01-PLAN.md'), '# Plan'); fs.writeFileSync(path.join(realDir, '01-01-SUMMARY.md'), '# Summary'); - fs.writeFileSync(path.join(realDir, 'VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified'); + // Real convention is `*-VERIFICATION.md` (gsd-core/workflows/verify-work.md:573's + // `ls "${PHASE_DIR}"/*-VERIFICATION.md` glob; verification.cts:425's + // `readVerificationStatus` matches the same `-VERIFICATION.md` suffix) — a bare + // `VERIFICATION.md` with no prefix is never produced by /gsd-verify-work and is + // invisible to the canonical reader `isPhaseComplete` now sources this rule from. + fs.writeFileSync(path.join(realDir, '05-real-VERIFICATION.md'), '---\nstatus: passed\n---\n# Verified'); // 05-real-stray/ — empty → Not Started fs.mkdirSync(path.join(phasesDir, '05-real-stray'), { recursive: true }); From eae2b52e4a6d2581f00f4ede49de669d4be42a7b Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 04:30:57 -0400 Subject: [PATCH 29/35] fix(#3309): W020 fires on any degraded worktree scan, not just real failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gsd-test found buildWorktreeHealthField collapsed every inspectWorktreeHealth failure reason (git_timed_out, git_list_failed, not_a_git_repo) into one UNREADABLE scope, discarding which one. The migrated checkW020 then warned unconditionally on any UNREADABLE scope — but the original (verify.cts:2202-2217) only warned on git_timed_out/git_list_failed, staying silent on not_a_git_repo (a .planning/-only fixture with no git repo at all is not a degraded scan, just the absence of one). This spuriously degraded every test fixture that isn't a real git repo. planning-snapshot.cts's worktreeHealth field now carries `reason` through instead of discarding it; checkW020 branches on it exactly like the pre-migration code did. --- .../worktree-health.cts | 67 +++++++++++-------- tests/worktree-safety.test.cjs | 16 +++-- 2 files changed, 47 insertions(+), 36 deletions(-) diff --git a/src/health-diagnostic-rules/worktree-health.cts b/src/health-diagnostic-rules/worktree-health.cts index b5613f359..70d661cb7 100644 --- a/src/health-diagnostic-rules/worktree-health.cts +++ b/src/health-diagnostic-rules/worktree-health.cts @@ -13,24 +13,16 @@ * pre-migration source still names the split-off stale-worktree site * 'W017' — this batch is what actually applies the W027 split). * - * KNOWN GAP (found while building, reported rather than papered over — see - * this batch's dispatch report for full detail): - * - * 1. W020's original THREE conditions were git_timed_out / git_list_failed / - * a per-finding 'unverified' kind, each with its own message. The first - * two are scan-level failures reported by `inspectWorktreeHealth`'s own - * `reason` field ('git_timed_out' vs 'git_list_failed' vs - * 'not_a_git_repo') — but `planning-snapshot.cts`'s - * `buildWorktreeHealthField` discards `reason` entirely and only - * preserves `scope: SCOPE.UNREADABLE` for ANY `!result.ok` case. This - * rule therefore CANNOT distinguish "git timed out" from "git worktree - * list failed outright" from the snapshot alone — both collapse to the - * same `checkScanDegraded` branch below, which emits one reasonable - * combined message instead of the original's two separate ones. Fixing - * this precisely requires extending `PlanningSnapshot.worktreeHealth` - * with the discarded `reason` field — an snapshot-field enhancement - * outside this rule-file batch's scope, flagged here rather than guessed - * around. + * W020's original THREE conditions were git_timed_out / git_list_failed / a + * per-finding 'unverified' kind, each with its own message. The first two + * are scan-level failures reported by `inspectWorktreeHealth`'s own `reason` + * field ('git_timed_out' vs 'git_list_failed' vs 'not_a_git_repo') — + * `planning-snapshot.cts`'s `buildWorktreeHealthField` now carries `reason` + * straight through on `PlanningSnapshot.worktreeHealth`, so `checkW020` + * below reproduces the original's exact branch-per-reason messages instead + * of collapsing them (a prior version of this file collapsed both into one + * message AND, worse, warned on 'not_a_git_repo' too — a regression, since + * the original silently skips a non-git cwd; see `verify.cts:2202-2217`). * * W027 restores the pre-migration active-worktree exclusion * (`verify.cts:2233-2242`) via `PlanningSnapshot.cwd` — see `checkW027`'s own @@ -60,29 +52,46 @@ type Rule = healthDiagnosticMod.Rule; import planningScopeMod = require('../planning-scope.cjs'); const { SCOPE } = planningScopeMod; -// ─── W020 — worktree health scan itself is degraded (verify.cts:2203-2264) ─ +// ─── W020 — worktree health scan itself is degraded (verify.cts:2193-2264) ─ // // ONE rule, THREE internal conditions, all the same subject ("the worktree // health scan itself is degraded" — design doc "Rejected alternatives" §3): -// (a) `git worktree list` timed out, (b) `git worktree list` failed -// outright, (c) a specific 'unverified' finding (existsSync ok, statSync -// threw). (a) and (b) collapse to a single combined message per the -// module-doc gap note above; (c) is a per-finding, exact port of -// `verify.cts:2256-2263`. +// (a) `git worktree list` timed out (verify.cts:2202-2209), (b) `git +// worktree list` failed outright (verify.cts:2210-2217), (c) a specific +// 'unverified' finding (existsSync ok, statSync threw, +// verify.cts:2256-2263). A fourth `!ok` reason, 'not_a_git_repo', and a +// thrown exception ('exception') are DELIBERATELY silent — the original's +// `if` ladder never matches 'not_a_git_repo', and the outer try/catch around +// the whole block is commented "git worktree not available or not a git +// repo — skip silently". function checkW020(snapshot: PlanningSnapshot): Diagnostic[] { const diagnostics: Diagnostic[] = []; + const { scope, reason } = snapshot.worktreeHealth; + const degraded = scope === SCOPE.UNREADABLE; - // (a)+(b) — scan-level degradation. GAP: cannot distinguish timeout from - // outright failure from `scope` alone (see module doc, gap 1). - if (snapshot.worktreeHealth.scope === SCOPE.UNREADABLE) { + // (a) — git worktree list timed out (verify.cts:2202-2209). + if (degraded && reason === 'git_timed_out') { diagnostics.push({ code: 'W020', severity: SEVERITY.WARNING, message: - 'Worktree health check degraded: git worktree list timed out or failed — orphan/stale worktrees could not be inspected', + 'Worktree health check degraded: git worktree list timed out after 10s — orphan/stale worktrees could not be inspected', remedy: adviseRemedy( - 'Run: git worktree list --porcelain to diagnose; check for .git/index.lock, a hung git process, or repository permissions', + 'Run: git worktree list --porcelain to diagnose; check for .git/index.lock or a hung git process', + ), + }); + } + + // (b) — git worktree list failed outright (verify.cts:2210-2217). + if (degraded && reason === 'git_list_failed') { + diagnostics.push({ + code: 'W020', + severity: SEVERITY.WARNING, + message: + 'Worktree health check degraded: git worktree list failed — orphan/stale worktrees could not be inspected', + remedy: adviseRemedy( + 'Run: git worktree list --porcelain to diagnose; check git repository state and permissions', ), }); } diff --git a/tests/worktree-safety.test.cjs b/tests/worktree-safety.test.cjs index 6f7f417b1..c3076bf66 100644 --- a/tests/worktree-safety.test.cjs +++ b/tests/worktree-safety.test.cjs @@ -4526,13 +4526,15 @@ describe('bug #3384: adjacent worktree data-loss guards', () => { }); test('validate health warns when worktree inventory cannot be listed', () => { - const source = read('gsd-core/bin/lib/verify.cjs'); - // Accept both hand-written dot access and the tsc-compiled bracket form - // (ADR-457: verify.cjs is now emitted from src/verify.cts): - // hand-written: worktreeHealth.reason === 'git_list_failed' - // tsc-compiled: worktreeHealth['reason'] === 'git_list_failed' - const failureBranch = source.search(/worktreeHealth(?:\.reason|\['reason'\]) === 'git_list_failed'/); - const warning = source.indexOf("addIssue('warning', 'W020'", failureBranch); + // Phase 11 (#3309, ADR-3180): this branch moved out of verify.cts into + // the W020 rule (src/health-diagnostic-rules/worktree-health.cts), + // compiled to gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs. + // Accept both hand-written dot access and the tsc-compiled bracket form: + // hand-written: reason === 'git_list_failed' + // tsc-compiled: reason === 'git_list_failed' (unchanged shape either way) + const source = read('gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs'); + const failureBranch = source.search(/reason === 'git_list_failed'/); + const warning = source.indexOf("code: 'W020'", failureBranch); assert.ok(failureBranch > 0, 'verify health should branch on git_list_failed'); assert.ok(warning > failureBranch, 'git_list_failed should emit W020 degraded-health warning'); From 7ddcc19823f4aa14a78bd3f0d6346f330f4d4a82 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 04:31:10 -0400 Subject: [PATCH 30/35] fix(#3309): acknowledge health.md growth, fix stale doc-consistency tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gsd-test found two independent gaps around the newly-generated health.md tables: - emitted-attribution.test.cjs requires an acknowledgment for health.md's 2271-byte growth (16-code hand-maintained table -> 34-row generated table, this phase's explicit acceptance criterion). Adds tests/emitted-drift-acks/3309-health-docs-generated.json. Removes 2573-state-head-freshness.json's now-inert health.md entry (that fragment's growth already landed on origin/next, the diff base this branch is compared against, so it has nothing left to acknowledge — and the guard forbids two fragments naming the same path). - runtime-converters.test.cjs's health.md content-consistency checks asserted stale text from the old hand-written table: a regex that false-positived on the new table's own unrelated W020 row (worktree scan degradation, a different diagnostic than the W025 isolation warning it was meant to detect), and anchors expecting the old table's exact last row / footnote wording. Narrowed the regex to require the literal use_worktrees config key, and updated the anchors to the new table's real shape (I001/I010 as the last rows, the new generated-table footnote). --- .../2573-state-head-freshness.json | 6 ---- .../3309-health-docs-generated.json | 8 +++++ tests/runtime-converters.test.cjs | 33 ++++++++++++++----- 3 files changed, 33 insertions(+), 14 deletions(-) delete mode 100644 tests/emitted-drift-acks/2573-state-head-freshness.json create mode 100644 tests/emitted-drift-acks/3309-health-docs-generated.json diff --git a/tests/emitted-drift-acks/2573-state-head-freshness.json b/tests/emitted-drift-acks/2573-state-head-freshness.json deleted file mode 100644 index f87f090a9..000000000 --- a/tests/emitted-drift-acks/2573-state-head-freshness.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "version": 1, - "paths": { - "health.md": "#2573 registers W024 (STATE.md written many commits ago \u2014 treat its contents as approximate) in the health workflow's table, so the advisory health now emits is documented where every other W-code is listed. The growth is that single table row written inline, not relocated into an eagerly @-imported reference (ADR-1610 Decision 4). 12246 -> 12348 bytes (+102), DEFAULT tier, cap 40960.\n\nAlso acknowledged here because the seam permits exactly one ack source per path and #2573 landed on next first (#2486 rebase, 2026-08-11): #2486 \u2014 adds the W025 diagnostic that detects a persisted workflow.use_worktrees:true on a runtime whose declared isolation cannot honor it, resolved via the sentinel-free inspect-dispatch-isolation query. Growth is the new check, its prose, and the error_codes row." - } -} diff --git a/tests/emitted-drift-acks/3309-health-docs-generated.json b/tests/emitted-drift-acks/3309-health-docs-generated.json new file mode 100644 index 000000000..6531e406f --- /dev/null +++ b/tests/emitted-drift-acks/3309-health-docs-generated.json @@ -0,0 +1,8 @@ +{ + "version": 1, + "paths": { + "health.md": { + "reason": "#3309 (epic #3180 Phase 11, ADR-3180): the ``/`` tables and their footnote are now GENERATED by `scripts/gen-health-docs.cjs` from the full 31-rule `RULES` table, replacing a hand-maintained 16-code table. #3309 explicitly required closing the 16-vs-30+ documentation gap structurally, so the growth is the deliberate, expected result of that acceptance criterion — not accidental bloat. Regeneration is verified deterministic via `node scripts/gen-health-docs.cjs --check` (wired into `npm run lint:generated-sync`)." + } + } +} diff --git a/tests/runtime-converters.test.cjs b/tests/runtime-converters.test.cjs index a7d7fae69..80d70db2b 100644 --- a/tests/runtime-converters.test.cjs +++ b/tests/runtime-converters.test.cjs @@ -1954,11 +1954,19 @@ test('manager.md and autonomous.md no longer contain old "not claude" background test('W025 is documented consistently across health.md and both config references', () => { // The rename W020 -> W025 landed in health.md only; the two docs kept // saying W020, which collides with a code src/verify.cts already emits. + // #3309: health.md's generated `` table now carries a real, + // UNRELATED W020 row of its own (`Worktree health scan degraded` — + // git-worktree-list-inventory failure, #3384/#3652 territory), whose + // description legitimately contains the bare word "worktree" right next + // to "W020". A bare `worktree` probe can no longer tell that apart from + // the stale isolation-check naming this guard exists for, so it narrows + // to the literal config key (`use_worktrees`) the isolation warning is + // actually about — the real W020 row's text never mentions that key. for (const rel of ['gsd-core/workflows/health.md', 'docs/CONFIGURATION.md', 'gsd-core/references/planning-config.md']) { const text = fs.readFileSync(path.join(__dirname, '..', rel), 'utf8'); assert.ok(text.includes('W025'), `${rel}: must document the worktrees warning as W025`); assert.ok( - !/\bW020\b[^)]{0,80}worktree/i.test(text), + !/\bW020\b[^)]{0,120}use_worktrees/i.test(text), `${rel}: stale W020 reference for the worktrees warning`, ); } @@ -1974,16 +1982,25 @@ test('manager.md and autonomous.md no longer contain old "not claude" background test('the health.md error-codes table is not broken by the namespace note', () => { // The note was inserted BETWEEN two rows, which terminates the GFM table - // and orphans the I001 row into literal pipe-delimited text. + // and orphans the trailing row(s) into literal pipe-delimited text. + // #3309: the hand-written "Note: the `W0NN` warning-code namespace..." + // paragraph (and the `W025` row it sat under) is gone — `gen-health-docs.cjs` + // now GENERATES this table from `RULES`, and deliberately excludes W025 + // (a workflow-layer diagnostic emitted by this file's own bash step, never + // by `cmdValidateHealth`/`RULES` — see the generator's module header and its + // `FOOTNOTE_PARAGRAPH`, which still names W025 for cross-reference). The + // table's actual last row is now I010, not I001, and the footnote's own + // opening sentence replaces the old namespace note. The hazard this test + // guards — a footnote landing mid-table — still applies to the new content. const src = readWorkflow('health.md'); - const w025 = src.indexOf('| W025 |'); const i001 = src.indexOf('| I001 |'); - const note = src.indexOf('Note: the `W0NN` warning-code namespace'); - assert.ok(w025 > -1 && i001 > -1 && note > -1, 'health.md: expected W025, I001 and the namespace note'); - assert.ok(i001 > w025, 'health.md: I001 row must follow the W025 row'); + const i010 = src.indexOf('| I010 |'); + const note = src.indexOf('Note: this table is **generated**'); + assert.ok(i001 > -1 && i010 > -1 && note > -1, 'health.md: expected I001, I010 and the generated-table note'); + assert.ok(i010 > i001, 'health.md: I010 row must follow the I001 row'); assert.ok( - note > i001, - 'health.md: the namespace note must come AFTER the final table row — placing it between rows ends the table and orphans I001', + note > i010, + 'health.md: the generated-table note must come AFTER the final table row — placing it between rows ends the table and orphans trailing rows', ); }); }); From ce0999bf842d87b8470294bc89b0e177f276bf7d Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 04:53:08 -0400 Subject: [PATCH 31/35] test(#3309): update stale W002/W020 test expectations to match the fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both tests were written against the pre-fix behavior and never updated once the real fixes landed: - state-consistency.test.cjs's "KNOWN GAP" test hardcoded the expectation that W002 incorrectly fires for a STATE.md phase reference whose only home is an archived milestone — that gap is now closed (0 diagnostics, confirmed against real buildPlanningSnapshot output), so the test is renamed and its expectation flipped. - worktree-health.test.cjs's two W020 tests asserted the OLD single combined "timed out or failed" message/remedy — verified against the real pre-migration src/verify.cts:2204-2219 that git_timed_out and git_list_failed always had distinct messages; the fix that restored this distinction is correct, these tests just never caught up to it. --- .../state-consistency.test.cjs | 16 ++++---- .../worktree-health.test.cjs | 38 ++++++++++++------- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/tests/health-diagnostic-rules/state-consistency.test.cjs b/tests/health-diagnostic-rules/state-consistency.test.cjs index 522f51fc9..07d1e2c5d 100644 --- a/tests/health-diagnostic-rules/state-consistency.test.cjs +++ b/tests/health-diagnostic-rules/state-consistency.test.cjs @@ -208,12 +208,11 @@ describe('W002 — STATE.md references a phase not declared on disk or ROADMAP', assert.deepEqual(ruleFor('W002').check(snapshot), []); }); - // KNOWN GAP (implementer report): archived-phase-token coverage - // (`forEachArchivedPhaseToken`) is not in any PlanningSnapshot field, so a - // STATE.md reference to a phase that lives only in a milestone archive is - // reported as undeclared here, unlike the original `verify.cts` check. - // Documents the gap rather than silently absorbing it. - test('KNOWN GAP: fires on a phase reference whose only home is an archived milestone (not modeled by any snapshot field)', (t) => { + // `snapshot.archivedPhaseTokens` (#3652) now covers archived-milestone + // phase-dir tokens, so `buildValidPhaseSet` includes them — a STATE.md + // reference to a phase whose only home is an archived milestone is + // correctly treated as declared and does NOT fire. + test('does not fire on a phase reference whose only home is an archived milestone', (t) => { const cwd = createTempDir('gsd-3309-w002-4-'); t.after(() => cleanup(cwd)); writeRoadmap(cwd, '## v2.0 Current 🚧\n\n### Phase 3: Baz\n'); @@ -232,15 +231,14 @@ describe('W002 — STATE.md references a phase not declared on disk or ROADMAP', '', '### Decisions', '', - '- Phase 1: this phase is archived, not currently exposed by any snapshot field', + '- Phase 1: this phase is archived, covered by snapshot.archivedPhaseTokens', '', ].join('\n'), ); const snapshot = buildPlanningSnapshot(cwd); const diagnostics = ruleFor('W002').check(snapshot); - assert.equal(diagnostics.length, 1); - assert.match(diagnostics[0].message, /STATE\.md references phase 1,/); + assert.deepEqual(diagnostics, []); }); }); diff --git a/tests/health-diagnostic-rules/worktree-health.test.cjs b/tests/health-diagnostic-rules/worktree-health.test.cjs index 18a07356a..ae3e1c4c9 100644 --- a/tests/health-diagnostic-rules/worktree-health.test.cjs +++ b/tests/health-diagnostic-rules/worktree-health.test.cjs @@ -122,7 +122,7 @@ describe('RULES (worktree-health group)', () => { // ─── W020 — worktree health scan itself is degraded ──────────────────────── describe('W020 — worktree health scan degraded', () => { - test('fires the combined scan-degraded message when git worktree list times out', (t) => { + test('fires the git_timed_out-specific scan-degraded message when git worktree list times out', (t) => { const cwd = createTempDir('gsd-3309-w020-timeout-'); t.after(() => cleanup(cwd)); fs.mkdirSync(planningDirOf(cwd), { recursive: true }); @@ -136,25 +136,25 @@ describe('W020 — worktree health scan degraded', () => { code: 'W020', severity: SEVERITY.WARNING, message: - 'Worktree health check degraded: git worktree list timed out or failed — orphan/stale worktrees could not be inspected', + 'Worktree health check degraded: git worktree list timed out after 10s — orphan/stale worktrees could not be inspected', remedy: { action: REMEDY_ACTION.ADVISE, risk: REMEDY_RISK.NONE, args: { command: - 'Run: git worktree list --porcelain to diagnose; check for .git/index.lock, a hung git process, or repository permissions', + 'Run: git worktree list --porcelain to diagnose; check for .git/index.lock or a hung git process', }, }, }); }); - // GAP (documented in the rule module's own header comment, gap 1): - // `buildWorktreeHealthField` discards `inspectWorktreeHealth`'s `reason` - // field ('git_timed_out' vs 'git_list_failed'), so the snapshot alone - // cannot distinguish a timeout from an outright failure. This rule - // therefore fires the IDENTICAL combined message for both — asserted - // explicitly here, per §8.5 fixture-proof, rather than left undocumented. - test('GAP: fires the SAME combined message when git worktree list fails outright (not a timeout) — scope alone cannot distinguish', (t) => { + // `inspectWorktreeHealth`'s `reason` field ('git_timed_out' vs + // 'git_list_failed') is carried straight through on + // `PlanningSnapshot.worktreeHealth` (`planning-snapshot.cts`'s + // `buildWorktreeHealthField`), so `checkW020` distinguishes the two scan + // failures with distinct messages/remedies (verify.cts:2204-2219) — this + // asserts the git_list_failed-specific one. + test('fires the git_list_failed-specific message when git worktree list fails outright (not a timeout)', (t) => { const cwd = createTempDir('gsd-3309-w020-failed-'); t.after(() => cleanup(cwd)); fs.mkdirSync(planningDirOf(cwd), { recursive: true }); @@ -164,10 +164,20 @@ describe('W020 — worktree health scan degraded', () => { const diagnostics = ruleFor('W020').check(snapshot); assert.equal(diagnostics.length, 1); - assert.equal( - diagnostics[0].message, - 'Worktree health check degraded: git worktree list timed out or failed — orphan/stale worktrees could not be inspected', - ); + assert.deepEqual(diagnostics[0], { + code: 'W020', + severity: SEVERITY.WARNING, + message: + 'Worktree health check degraded: git worktree list failed — orphan/stale worktrees could not be inspected', + remedy: { + action: REMEDY_ACTION.ADVISE, + risk: REMEDY_RISK.NONE, + args: { + command: + 'Run: git worktree list --porcelain to diagnose; check git repository state and permissions', + }, + }, + }); }); test('fires once per unverified finding — exact port of verify.cts:2256-2263', (t) => { From c67992de876c053721999bacbfc9470033bfee05 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 05:14:01 -0400 Subject: [PATCH 32/35] docs(#3309): document --backfill and the --repair DESTRUCTIVE-refusal change docs/COMMANDS.md's /gsd-health section never documented --backfill at all, and predates this phase's breaking changes: --repair no longer auto-applies resetConfig/regenerateState (both destructive), and W021/ W017 split into W026/W027 for their previously-conflated second subjects. Required by lint:docs, which needs a docs/ touch alongside any Changed-type changeset fragment. --- docs/COMMANDS.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 675374b15..4694bda33 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -992,11 +992,13 @@ v1.40.0, [#2792](https://github.com/open-gsd/gsd-core/issues/2792)). | Flag | Description | |------|-------------| | `--repair` | Auto-fix recoverable issues | +| `--backfill` | Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots | | `--context` | Probe context-window utilization; warns at 60 %, critical at 70 % | ```bash /gsd-health # Check integrity /gsd-health --repair # Check and fix +/gsd-health --backfill # Backfill missing MILESTONES.md entries /gsd-health --context # Context-utilization triage ``` @@ -1012,6 +1014,18 @@ rather than that its contents are correct. The advisory never changes health's pass/fail status, and stays silent when the stamp is absent or the project isn't a git repo — "unknown" is reported as unknown, not as fresh. +**`--repair` does not apply destructive fixes.** Resetting config.json +(`resetConfig`) and regenerating STATE.md (`regenerateState`) are destructive +— the former loses custom settings, the latter loses session history — so +`--repair` reports these fixes as available but never applies them +automatically; the suggested command must be run by hand (ADR-3180, +[#3309](https://github.com/open-gsd/gsd-core/issues/3309)). The same migration +split two previously-conflated diagnostic codes: `W021` now covers only the +phase-id-convention mismatch, with the STATE-vs-ROADMAP milestone-complete +mismatch it used to also report moving to the new `W026`; likewise `W017` now +covers only orphan worktrees, with the stale-worktree case moving to the new +`W027`. + ### `/gsd-cleanup` Archive accumulated phase directories from completed milestones and prune local branches whose upstream has been deleted. From 580a0c95a47e6070c18ac3e6c8b9c65ee0e2c590 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 05:18:33 -0400 Subject: [PATCH 33/35] docs(#3309): update FEATURES.md's Health Validation requirements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit REQ-HEALTH-05 said --repair auto-fixes recoverable issues without qualification — now inaccurate since DESTRUCTIVE-risk remedies are reported but never auto-applied. Adds REQ-HEALTH-06 for --backfill, previously unmentioned in this requirements register. --- docs/FEATURES.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/FEATURES.md b/docs/FEATURES.md index b4477d59c..894dc496e 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -644,7 +644,7 @@ ### 19. Health Validation -**Command:** `/gsd-health [--repair]` +**Command:** `/gsd-health [--repair] [--backfill]` **Purpose:** Validate `.planning/` directory integrity and auto-repair issues. @@ -653,7 +653,8 @@ - REQ-HEALTH-02: System MUST validate configuration consistency - REQ-HEALTH-03: System MUST detect orphaned plans without summaries - REQ-HEALTH-04: System MUST check phase numbering and roadmap sync -- REQ-HEALTH-05: `--repair` flag MUST auto-fix recoverable issues +- REQ-HEALTH-05: `--repair` flag MUST auto-fix recoverable issues except DESTRUCTIVE-risk ones, which it MUST report but never auto-apply +- REQ-HEALTH-06: `--backfill` flag MUST synthesize missing MILESTONES.md entries from archived milestone snapshots --- From 1fdc953978e9142c291072dc981499c82d156595 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 05:20:38 -0400 Subject: [PATCH 34/35] docs(#3309): backfill changeset pr numbers with #3405 --- .changeset/fierce-eagles-roam.md | 2 +- .changeset/gallant-otters-fly.md | 2 +- .changeset/happy-jaguars-roar.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/fierce-eagles-roam.md b/.changeset/fierce-eagles-roam.md index 84a9a192c..2f1a08fe8 100644 --- a/.changeset/fierce-eagles-roam.md +++ b/.changeset/fierce-eagles-roam.md @@ -1,5 +1,5 @@ --- type: Fixed -pr: 0 +pr: 3405 --- **`validate health --backfill` now works without also passing `--repair`** — previously it silently did nothing unless `--repair` was also set, due to an unreachable internal gate. diff --git a/.changeset/gallant-otters-fly.md b/.changeset/gallant-otters-fly.md index 69af1f431..ecc03572d 100644 --- a/.changeset/gallant-otters-fly.md +++ b/.changeset/gallant-otters-fly.md @@ -1,5 +1,5 @@ --- type: Changed -pr: 0 +pr: 3405 --- **`validate health` splits two previously-conflated warning codes into their own codes** — W021 now covers only the phase-id-convention mismatch it originally meant; the STATE-vs-ROADMAP milestone-complete mismatch it used to also report moves to the new W026. Likewise W017 now covers only orphan worktrees; the stale-worktree case moves to the new W027. diff --git a/.changeset/happy-jaguars-roar.md b/.changeset/happy-jaguars-roar.md index 9add7c2c1..0efd897d8 100644 --- a/.changeset/happy-jaguars-roar.md +++ b/.changeset/happy-jaguars-roar.md @@ -1,5 +1,5 @@ --- type: Changed -pr: 0 +pr: 3405 --- **`validate health --repair` no longer resets config.json or regenerates STATE.md automatically** — these two repairs are destructive (they lose custom settings or session history), so they're now reported with their fix described but never auto-applied; run the suggested command yourself to apply them. From 3e70e57e37f74d84e42a41e96ee63fdce612a3c8 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 13 Aug 2026 07:22:07 -0400 Subject: [PATCH 35/35] fix(#3309): applyRepairs risk-gating test used a fake, non-writable cwd MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught what the bench run didn't: "row 12" (every NONE-risk action must actually apply) called applyRepairs('/fake/cwd', ...) — a literal path that doesn't exist on disk. This was fine when applyRepairs's handlers were stubs (pre-migration skeleton), but real handlers now read/write actual files: createConfig writes config.json, addNyquistKey/addAiIntegrationPhaseKey read it before patching. Against a genuinely non-existent path these now correctly fail (ENOENT), and an earlier fix in this same PR (applied only receives a code on real success) correctly surfaces that as a failure instead of masking it — so 3 of 4 codes stopped landing in `applied`, deterministically, on any environment that actually enforces ENOENT against /fake/cwd. Uses a real temp project (createTempProject + a valid config.json) instead. Row 11 (DESTRUCTIVE refusal) and the ADVISE-skip test are unaffected — both paths return before any handler touches the filesystem, confirmed by reading applyRepairs's dispatch order. --- tests/health-diagnostic.test.cjs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/tests/health-diagnostic.test.cjs b/tests/health-diagnostic.test.cjs index ff12e59ca..88bc83ff2 100644 --- a/tests/health-diagnostic.test.cjs +++ b/tests/health-diagnostic.test.cjs @@ -166,14 +166,26 @@ describe('applyRepairs — risk gating (hand-constructed diagnostics)', () => { assert.deepEqual(result.refused.sort(), ['E004', 'E005']); }); - test('row 12: every other real action (NONE risk) is applied, not refused, when --repair is requested', () => { + test('row 12: every other real action (NONE risk) is applied, not refused, when --repair is requested', (t) => { + // Unlike the other tests in this block, these four codes now dispatch to + // REAL handlers (runRepairAction, src/health-diagnostic.cts) that perform + // real filesystem I/O — createConfig writes config.json, addNyquistKey / + // addAiIntegrationPhaseKey read-then-patch it (throwing if absent). A + // literal '/fake/cwd' makes every one of those genuinely fail (ENOENT), + // which applyRepairs correctly reports as NOT applied. A real temp + // project with a real, valid config.json already in place is required so + // the read-then-patch handlers have something to read. + const tmpDir = createTempProject(); + t.after(() => cleanup(tmpDir)); + writeValidConfigJson(tmpDir); + 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); + const result = applyRepairs(tmpDir, diagnostics, true, false); assert.deepEqual(result.applied.sort(), ['W003', 'W008', 'W016', 'W018']); assert.deepEqual(result.refused, []); });