fix(#3309): W020 fires on any degraded worktree scan, not just real failures

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.
This commit is contained in:
sim
2026-08-13 04:30:57 -04:00
parent 96c7ea9b35
commit eae2b52e4a
2 changed files with 47 additions and 36 deletions

View File

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

View File

@@ -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');