refactor(#3309): add worktree-health health-diagnostic rules

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.
This commit is contained in:
sim
2026-08-13 01:37:42 -04:00
parent 9e74f00ba0
commit a56679a4ca
2 changed files with 522 additions and 0 deletions

View File

@@ -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 <path> 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 <path> --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));
});
});