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)); + }); +});