fix(#2408): fold colliding phase statuses + add W023 collision warning (#2461)

* fix(#2408): fold colliding phase statuses + add W023 collision warning

Two coupled bugs from #2408:

1. cmdStats last-write-wins status (src/commands.cts:1610-1618): when two
   on-disk phase directories normalize to the same phase key (e.g.
   `05-real/` and `05-real-stray/`), the directory-scan merge overwrote
   `status` with whatever the *current* directory in scan order computed,
   discarding `existing?.status` entirely. fs.readdirSync order is not
   stable across platforms, so /gsd-stats could silently report `Not
   Started` for a phase that is actually `Complete`. Plan/summary counts
   were already additively merged; only `status` was wrong.

   Fix: introduced a `foldPhaseStatus(a, b)` helper that returns whichever
   status is further along the precedence ladder
   `Complete > Needs Review > Executed > In Progress > Planned > Not Started`
   (with `Pending` and unrecognized statuses ranked after). The merge site
   now calls `existing ? foldPhaseStatus(existing.status, status) : status`.
   The fold is commutative, so the result is identical regardless of read
   order.

2. cmdValidateHealth had no collision-detection pass (src/verify.cts):
   codes W001-W022 cover every condition except normalized-key collisions,
   so an operator got zero signal that anything was wrong.

   Fix: added W023 — groups phaseDirEntries by their normalized phase key
   (via the existing normalizePhaseName + extractPhaseToken helpers from
   phase-id.cjs — same normalization cmdStats uses) and emits a warning
   for any group with ≥2 dirs. The warning names the normalized key, both
   directory names (sorted by comparePhaseNum for stable output), and each
   directory's independently-computed status (via determinePhaseStatus
   imported from commands.cjs). Wording is deliberately neutral — never
   guesses which directory is the real one. The optional --repair path
   from the issue is intentionally NOT implemented in this PR (the issue
   marked it lower priority and acceptance criterion 4 is vacuously
   satisfied by omission).

Triage correction applied: the issue proposed W022, but that code is
already in use for config.json model-tier validation (src/verify.cts
:1372-1391). The next free code is W023, used here.

Tests:
- tests/commands.test.cjs: integration test that 05-real/ (Complete) +
  05-real-stray/ (empty/Not Started) collide and stats reports the merged
  phase as Complete regardless of read order; plus a direct unit test of
  foldPhaseStatus asserting commutativity + correct precedence for every
  status pair + correct handling of unrecognized statuses.
- tests/health-validation.test.cjs: integration test that W023 fires on
  the collision naming both dirs + their statuses (and uses neutral
  wording), plus a negative test that no W023 fires when only one dir
  exists per key.

References: #2408; reporter's three-layer triage + acceptance criteria;
triage correction that W022 is already in use (model-tier validation).

* chore(#2408): backfill pr:2461 in .changeset/graceful-koalas-forage.md
This commit is contained in:
Tom Boucher
2026-07-20 15:49:57 -04:00
committed by GitHub
parent 455ad49ae3
commit be5113abfe
5 changed files with 279 additions and 2 deletions

View File

@@ -40,7 +40,7 @@ import configLoaderMod = require('./config-loader.cjs');
const { loadConfig, CONFIG_DEFAULTS } = configLoaderMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE } = phaseIdMod;
const { normalizePhaseName, phaseTokenMatches, escapeRegex, getMilestoneFromPhaseId, OPTIONAL_PHASE_TAG_SOURCE, PHASE_NUMBER_TOKEN_SOURCE, extractPhaseToken, comparePhaseNum } = phaseIdMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { findPhaseInternal } = phaseLocatorMod;
@@ -50,6 +50,9 @@ const { getMilestoneInfo, stripShippedMilestones, extractCurrentMilestone } = ro
// 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 { planningDir, planningRoot } = planningWorkspace;
const { extractFrontmatter, parseMustHavesBlock } = frontmatterMod;
@@ -1460,6 +1463,52 @@ function cmdValidateHealth(
}
}
// 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".
{
const groups = new Map<string, string[]>();
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) => {
const files = phaseDirFiles.get(d) || [];
const plans = files.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
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',
);
}
}
for (const e of phaseDirEntries) {
const phaseFiles = phaseDirFiles.get(e.name) || [];
const plans = phaseFiles.filter((f) => f.endsWith('-PLAN.md') || f === 'PLAN.md');