* 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:
@@ -94,6 +94,45 @@ interface EffortSyncChange {
|
||||
|
||||
// ─── Phase Status ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Phase-status precedence ladder — furthest-along wins (#2408).
|
||||
*
|
||||
* `cmdStats` builds `phasesByNumber` by scanning on-disk phase directories.
|
||||
* When two directories normalize to the same phase key (e.g. `05-real/` and
|
||||
* `05-real-stray/`), the status field must be folded by precedence rather
|
||||
* than overwritten last-write-wins — otherwise `/gsd-stats` reports whatever
|
||||
* directory `fs.readdirSync` happened to yield last, which is non-deterministic
|
||||
* across platforms and can silently call a `Complete` phase `Not Started`.
|
||||
*/
|
||||
const PHASE_STATUS_PRECEDENCE: ReadonlyArray<string> = [
|
||||
'Complete',
|
||||
'Needs Review',
|
||||
'Executed',
|
||||
'In Progress',
|
||||
'Planned',
|
||||
'Not Started',
|
||||
'Pending',
|
||||
];
|
||||
const PHASE_STATUS_RANK = new Map<string, number>(
|
||||
PHASE_STATUS_PRECEDENCE.map((s, i) => [s, i]),
|
||||
);
|
||||
|
||||
/**
|
||||
* Fold two phase statuses by precedence — returns whichever is further along
|
||||
* the {@link PHASE_STATUS_PRECEDENCE} ladder. Unrecognized statuses fall behind
|
||||
* every recognized one (so a recognized status always wins over an unknown one;
|
||||
* two unrecognized statuses favor `a` for determinism).
|
||||
*/
|
||||
function foldPhaseStatus(a: string, b: string): string {
|
||||
const ra = PHASE_STATUS_RANK.get(a);
|
||||
const rb = PHASE_STATUS_RANK.get(b);
|
||||
if (ra === undefined && rb === undefined) return a;
|
||||
if (ra === undefined) return b;
|
||||
if (rb === undefined) return a;
|
||||
// Lower rank = higher precedence (Complete=0 wins over Not Started=5).
|
||||
return ra <= rb ? a : b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine phase status by checking plan/summary counts AND verification state.
|
||||
* Introduces "Executed" for phases with all summaries but no passing verification.
|
||||
@@ -1632,7 +1671,12 @@ function cmdStats(cwd: string, format: string | undefined, raw: boolean): void {
|
||||
name: existing?.name || phaseName,
|
||||
plans: (existing?.plans || 0) + plans,
|
||||
summaries: (existing?.summaries || 0) + summaries,
|
||||
status,
|
||||
// #2408: fold colliding statuses by precedence rather than overwriting
|
||||
// last-write-wins. fs.readdirSync order is non-deterministic across
|
||||
// platforms, so a naive overwrite can report a Complete phase as Not
|
||||
// Started (or vice versa) depending on read order. The fold picks the
|
||||
// furthest-along status, matching what an operator expects.
|
||||
status: existing ? foldPhaseStatus(existing.status, status) : status,
|
||||
});
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
@@ -1763,6 +1807,8 @@ function cmdCheckCommit(cwd: string, raw: boolean): void {
|
||||
export = {
|
||||
groupFilesBySubrepo,
|
||||
determinePhaseStatus,
|
||||
foldPhaseStatus,
|
||||
PHASE_STATUS_PRECEDENCE,
|
||||
cmdGenerateSlug,
|
||||
cmdCurrentTimestamp,
|
||||
cmdListTodos,
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user