fix(#3309): W006/W007 miss archived phases and phase-id variant normalization

gsd-test found two real regressions in the migrated W006/W007:

1. A phase whose directory lives under an archived milestone
   (.planning/milestones/v*-phases/<phase>/) instead of the active
   phases/ dir read as "in ROADMAP but no directory on disk" — the
   original forEachArchivedPhaseToken(planBase, ...) fed archived
   tokens into the same existence check (verify.cts:2038); the
   migrated rule's allPhaseDirNames never included them.

2. Comparing a ROADMAP-declared phase id against a disk directory name
   dropped phaseVariants() normalization the original ran as a second,
   independent check (verify.cts:2071-2073/2092-2093) — a ROADMAP
   "01A" and a disk "1A-..." read as mismatched instead of the same
   phase, since matchPhaseDirs's own token comparison never unifies
   that padding/letter-suffix difference.

Adds planning-snapshot.cts's archivedPhaseTokens field (mirrors
forEachArchivedPhaseToken/listMilestoneArchiveDirs exactly, no new
regex derivation) and a phaseVariants()-based fallback in
dirsForPhase when matchPhaseDirs finds nothing.
This commit is contained in:
sim
2026-08-13 04:30:19 -04:00
parent 23884a6448
commit ce57e60098
2 changed files with 201 additions and 16 deletions

View File

@@ -41,11 +41,26 @@
* (`src/planning-snapshot.cts`) instead: every directory actually present
* under the active `phases/` root, unfiltered by roadmap declaration.
* Archived-milestone directory names (`verify.cts:2050`,
* `collectArchivedPhaseDirNames`) are still not part of `PlanningSnapshot`
* and remain a disclosed fidelity reduction (a phase whose only directory
* lives in a shipped-milestone archive can read as W006-missing; a shipped
* archived dir is never scanned so it cannot spuriously read as
* W007-orphaned either) — unchanged by this fix.
* `collectArchivedPhaseDirNames`) are still not exposed as directory NAMES
* on `PlanningSnapshot`, but the equivalent TOKEN set is: `checkW006` below
* additionally consults `snapshot.archivedPhaseTokens` (added for the
* W002/state-consistency group's #3652 fix, reused verbatim here — see that
* field's own doc comment) so a phase whose only directory lives in a
* milestone archive (shipped OR the current milestone's own archive layout)
* no longer reads as W006-missing (Bug 1, found post-migration: the archived
* fixtures under `tests/milestone-archive.test.cjs` and
* `tests/verify-health.test.cjs` regressed against the pre-migration
* `verify.cts` behavior). W007 still never scans archived dirs (its loop is
* `allPhaseDirNames.value`, the active `phases/` root only), so an archived
* dir still cannot spuriously read as W007-orphaned either — unchanged.
*
* Bug 2 (found alongside Bug 1): `dirsForPhase` below also runs a
* `phaseVariants()`-based fallback when `matchPhaseDirs` finds nothing — see
* its own doc comment. Pre-migration, `verify.cts:2071-2073`/`2092-2093` ran
* this as a SECOND, independent check the migrated matchPhaseDirs-only path
* had dropped, causing a false W006/W007 whenever ROADMAP and disk spelled
* the same phase with a different zero-padding (e.g. ROADMAP "01A" vs disk
* "1A-...").
*
* Not-started exclusion (verify.cts:2065/2075-2076,
* `buildNotStartedPhaseVariants`, `src/validate.cts:160`): the design doc's
@@ -114,7 +129,34 @@ const { phaseVariants } = validateMod;
* file-level comment.
*/
function dirsForPhase(dirs: string[], phaseId: string): string[] {
return matchPhaseDirs(dirs, normalizePhaseName(phaseId)).matches;
const canonical = matchPhaseDirs(dirs, normalizePhaseName(phaseId)).matches;
if (canonical.length > 0) return canonical;
// Bug 2 (#3309 W006/W007 migration cluster, found while fixing the
// originally-reported archived-directory gap): `matchPhaseDirs`'s
// `phaseTokenMatches` compares `extractPhaseToken(dir)` (the directory's
// LITERAL, un-normalized digit run — e.g. "1A" for `1A-suffix-phase`)
// against `normalizePhaseName(phaseId)` (which PADS — "01A") case-
// insensitively, but never unifies a padding/letter-suffix mismatch
// BETWEEN the two sides: "1A" !== "01A" even though they name the same
// phase. Pre-migration, `verify.cts:2071-2073`/`2092-2093` ran a SECOND,
// independent check here — `[...phaseVariants(p)].some((v) =>
// diskPhases.has(v))` — that the migrated matchPhaseDirs-only path
// dropped. `phaseVariants` (`validate.cts:101`) is symmetric
// (padded<->unpadded, letter-suffix preserved both ways), so generating
// variants from `phaseId` and checking raw-disk-token membership is
// equivalent to intersecting `phaseVariants(phaseId)` with
// `phaseVariants(diskToken)` — variants always include their own input
// verbatim, so this fallback catches exactly the cases `matchPhaseDirs`
// alone misses without re-deriving a second matcher.
const variants = phaseVariants(phaseId);
return dirs.filter((d) => {
const token = extractPhaseToken(d).toUpperCase();
for (const variant of variants) {
if (token === variant.toUpperCase()) return true;
}
return false;
});
}
/**
@@ -145,6 +187,7 @@ function checkW006(snapshot: PlanningSnapshot): Diagnostic[] {
if (snapshot.roadmapDeclaredPhases.scope !== SCOPE.COMPLETE) return [];
const dirs = snapshot.allPhaseDirNames.value;
const archivedTokens = new Set(snapshot.archivedPhaseTokens.value);
const checkboxes = snapshot.roadmapPhaseCheckboxes.value;
const diagnostics: Diagnostic[] = [];
@@ -153,6 +196,17 @@ function checkW006(snapshot: PlanningSnapshot): Diagnostic[] {
// convention; a sentinel heading shouldn't demand a directory.
if (isSentinelPhaseId(phaseId)) continue;
if (dirsForPhase(dirs, phaseId).length > 0) continue;
// Bug 1 (#3309 W006/W007 migration cluster): a phase whose ONLY
// directory lives under a milestone archive
// (`.planning/milestones/vX.Y-phases/<phase>/`, shipped OR the current
// milestone's own archive layout) must not read as "no directory on
// disk" — mirrors `verify.cts:2038`'s
// `forEachArchivedPhaseToken(planBase, (token) => diskPhases.add(token))`
// feeding the archived-phase token set into this exact existence check.
// `snapshot.archivedPhaseTokens` (`src/planning-snapshot.cts`) is the
// same token set, reused verbatim from the W002/state-consistency
// group's own fix for the analogous gap — not a re-derivation.
if ([...phaseVariants(phaseId)].some((v) => archivedTokens.has(v))) continue;
if (isPhaseNotStarted(phaseId, checkboxes)) continue;
diagnostics.push({
code: 'W006',

View File

@@ -23,7 +23,7 @@ import fs from 'node:fs';
import path from 'node:path';
// eslint-disable-next-line @typescript-eslint/no-require-imports
import roadmapParserMod = require('./roadmap-parser.cjs');
const { getMilestoneInfo } = roadmapParserMod;
const { getMilestoneInfo, extractCurrentMilestone } = roadmapParserMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseLocatorMod = require('./phase-locator.cjs');
const { listMilestonePhaseDirs } = phaseLocatorMod;
@@ -56,8 +56,8 @@ import worktreeSafetyMod = require('./worktree-safety.cjs');
const { inspectWorktreeHealth } = worktreeSafetyMod;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdMod = require('./phase-id.cjs');
const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE } = phaseIdMod;
import { buildRoadmapPhaseVariants } from './validate.cjs';
const { PHASE_NUMBER_TOKEN_SOURCE, OPTIONAL_PHASE_TAG_SOURCE, stripProjectCodePrefix } = phaseIdMod;
import { buildRoadmapPhaseVariants, PHASE_TOKEN_FROM_DIR_RE, MILESTONE_ARCHIVE_DIR_RE } from './validate.cjs';
// ─── worstScope — the one new piece of coordination logic ───────────────────
@@ -117,7 +117,7 @@ interface PlanningSnapshot {
// names, "the snapshot".
config: { value: Record<string, unknown> | null; scope: Scope; exists: boolean };
agentInstall: { value: ReturnType<typeof checkAgentsInstalled>; scope: Scope };
worktreeHealth: { value: ReturnType<typeof inspectWorktreeHealth>['findings']; scope: Scope };
worktreeHealth: { value: ReturnType<typeof inspectWorktreeHealth>['findings']; scope: Scope; reason: string };
// ─── Phase 11 (#3309) "Rule table organization" additions ─────────────────
// The design doc's own "Rule table organization" table and prose disagree
// on the count: the table lists EIGHT rows (through `planningRootFiles`,
@@ -163,6 +163,40 @@ interface PlanningSnapshot {
// they already are for `phaseDirs` (see this batch's own disclosed
// fidelity reduction for that).
allPhaseDirNames: { value: string[]; scope: Scope };
// W002 (STATE.md-consistency group) fidelity fix, found while implementing
// `src/health-diagnostic-rules/state-consistency.cts`. The original
// `cmdValidateHealth` W002 check unions THREE sources into its "valid
// phase" set — disk dirs, ROADMAP headings, and
// `forEachArchivedPhaseToken(planBase, ...)` (`verify.cts:1748`, every
// phase-token-shaped subdirectory under `.planning/milestones/*-phases/`,
// via the same `MILESTONE_ARCHIVE_DIR_RE`/`PHASE_TOKEN_FROM_DIR_RE`
// `listMilestoneArchiveDirs`/`forEachArchivedPhaseToken` use, both already
// exported from `validate.cjs` — no new regex derivation here). Without the
// third source, a STATE.md reference to a phase whose only directory lives
// in a shipped-milestone archive reads as an undeclared phase (#3652).
// Additive-only per this batch's own field-table constraint. Also now reused
// by `src/health-diagnostic-rules/roadmap-disk-consistency.cts`'s `checkW006`
// (Bug 1, #3309 W006/W007 migration cluster) for the same "was this token
// archived" question a ROADMAP *entry* needs answered, not just a STATE.md
// *reference* — same token set, two independent consumers, no re-derivation.
archivedPhaseTokens: { value: string[]; scope: Scope };
// W026 (STATE.md-consistency group) fidelity fix, found while implementing
// `src/health-diagnostic-rules/state-consistency.cts`. W026's original
// logic (`verify.cts:2356-2399`, the second `addIssue('warning', 'W021',
// ...)` call site before the #3309 code split) scopes ROADMAP.md to the
// CURRENT milestone via `extractCurrentMilestone(roadmapRaw, cwd)` — the
// same shared, `<details>`/`<summary>`-tolerant scoping owner every other
// milestone-aware consumer uses (`roadmap-parser.cts`) — then scans
// `#{2,4}\s*Phase\s+(TOKEN)...` headings within that scoped slice.
// `roadmapDeclaredPhases`'s `milestone` attribution (above) is NOT a fit
// here even though it looks adjacent: it exists to relocate
// `checkMilestonePrefixMismatches`'s OWN narrower `sectionRx`
// (`verify.cts:1429-1459`, `^#{1,3}\s+...vX.Y`, no `<details>` support) —
// faithful for W021 (which never supported `<details>` either), but
// reusing it for W026 would regress W026's ALREADY-`<details>`-tolerant
// original behavior. This field is W026's own, independently-scoped
// phase-id list — additive-only, no change to `roadmapDeclaredPhases`.
currentMilestoneRoadmapPhaseIds: { value: string[]; scope: Scope };
}
/**
@@ -260,9 +294,23 @@ function buildStateFields(statePath: string): StateFields {
const section = stateCurrentPositionSlice(body);
const currentPositionScope = section === null ? SCOPE.TRUNCATED : SCOPE.COMPLETE;
const currentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Phase', {
// #1760 fallback ladder (mirrors `state.cts:1499-1500`'s `resolveStatePhase`
// exactly, same `section ?? body` scope for both reads): the legacy bold
// `**Current Phase:**` field (what `verify.cts:2109-2111` originally
// matched, and what pre-template-migration STATE.md fixtures still use)
// takes priority over the current template's bare `Phase: [X] of [Y]`
// field — a document carrying both is read the same way `resolveStatePhase`
// reads it elsewhere.
const legacyCurrentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Current Phase', {
scope: currentPositionScope,
});
const templateCurrentPhaseLabel = stateFieldValue(frontmatter, section ?? body, null, 'Phase', {
scope: currentPositionScope,
});
const currentPhaseLabel = {
value: legacyCurrentPhaseLabel.value ?? templateCurrentPhaseLabel.value,
scope: legacyCurrentPhaseLabel.value !== null ? legacyCurrentPhaseLabel.scope : templateCurrentPhaseLabel.scope,
};
const stateStatus = stateFieldValue(frontmatter, section ?? body, 'status', 'Status', {
scope: currentPositionScope,
});
@@ -350,9 +398,17 @@ function buildAgentInstallField(cwd: string): { value: ReturnType<typeof checkAg
* report) or a thrown exception (mirrors cmdValidateHealth's own
* "git worktree not available or not a git repo — skip silently" catch)
* both degrade to `UNREADABLE` with an empty findings array, since neither
* case has real per-worktree data to report.
* case has real per-worktree data to report. `reason` carries
* `inspectWorktreeHealth`'s own discriminator ('ok' | 'git_timed_out' |
* 'git_list_failed' | 'not_a_git_repo') straight through — NOT discarded —
* so `checkW020` (`src/health-diagnostic-rules/worktree-health.cts`) can
* reproduce `verify.cts:2202-2217`'s exact branching: it warns on
* 'git_timed_out' or 'git_list_failed' but stays silent on 'not_a_git_repo'
* (a `.planning/`-only fixture/tmp dir with no git repo at all is not a
* degraded scan). A thrown exception reports 'exception', which also stays
* silent, matching the original's catch-all "skip silently" comment.
*/
function buildWorktreeHealthField(cwd: string): { value: ReturnType<typeof inspectWorktreeHealth>['findings']; scope: Scope } {
function buildWorktreeHealthField(cwd: string): { value: ReturnType<typeof inspectWorktreeHealth>['findings']; scope: Scope; reason: string } {
try {
const result = inspectWorktreeHealth(
cwd,
@@ -360,11 +416,11 @@ function buildWorktreeHealthField(cwd: string): { value: ReturnType<typeof inspe
{ execGit, existsSync: fs.existsSync, statSync: fs.statSync },
);
if (!result.ok) {
return { value: [], scope: SCOPE.UNREADABLE };
return { value: [], scope: SCOPE.UNREADABLE, reason: result.reason };
}
return { value: result.findings, scope: SCOPE.COMPLETE };
return { value: result.findings, scope: SCOPE.COMPLETE, reason: result.reason };
} catch {
return { value: [], scope: SCOPE.UNREADABLE };
return { value: [], scope: SCOPE.UNREADABLE, reason: 'exception' };
}
}
@@ -658,6 +714,79 @@ function buildAllPhaseDirNamesField(phasesDir: string): { value: string[]; scope
}
}
/**
* Resolve `archivedPhaseTokens` — every phase-number token belonging to a
* directory directly under any `.planning/milestones/*-phases/` archive.
* Backs W002's archived-phase exemption (#3652); see the field's own doc
* comment on `PlanningSnapshot`. Mirrors `verify.cts`'s
* `forEachArchivedPhaseToken` + `listMilestoneArchiveDirs` exactly — same
* `MILESTONE_ARCHIVE_DIR_RE` archive-dir filter, same `PHASE_TOKEN_FROM_DIR_RE`
* per-entry match, same `stripProjectCodePrefix` normalization — just
* collecting into a value array instead of an `onPhase` callback. An absent
* `milestones/` dir is a real empty (no archives yet), not a failure; a
* present-but-unreadable per-archive-dir entry is silently skipped, mirroring
* `forEachArchivedPhaseToken`'s own per-directory `catch { /* absent/unreadable *\/ }`.
*/
function buildArchivedPhaseTokensField(planBase: string): { value: string[]; scope: Scope } {
const milestonesDir = path.join(planBase, 'milestones');
let archiveDirs: string[];
try {
archiveDirs = fs
.readdirSync(milestonesDir, { withFileTypes: true })
.filter((e) => e.isDirectory() && MILESTONE_ARCHIVE_DIR_RE.test(e.name))
.map((e) => path.join(milestonesDir, e.name));
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return { value: [], scope: SCOPE.COMPLETE };
return { value: [], scope: SCOPE.UNREADABLE };
}
const value: string[] = [];
for (const archiveDir of archiveDirs) {
try {
const entries = fs.readdirSync(archiveDir, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const m = e.name.match(PHASE_TOKEN_FROM_DIR_RE);
if (m) value.push(stripProjectCodePrefix(m[1]));
}
} catch {
/* archive dir absent/unreadable — mirrors forEachArchivedPhaseToken */
}
}
return { value, scope: SCOPE.COMPLETE };
}
/**
* Resolve `currentMilestoneRoadmapPhaseIds` — every phase-number token found
* in ROADMAP.md's content once scoped to the CURRENT milestone via
* `extractCurrentMilestone(content, cwd)`. Backs W026's archive-tolerant
* unstarted-phase scan; see the field's own doc comment on `PlanningSnapshot`
* for why `roadmapDeclaredPhases` cannot serve this. An absent/unreadable
* ROADMAP.md degrades to an empty list, mirroring every other
* ROADMAP-sourced field's absent-file handling.
*/
function buildCurrentMilestoneRoadmapPhaseIdsField(
cwd: string,
roadmapPath: string,
): { value: string[]; scope: Scope } {
if (!fs.existsSync(roadmapPath)) return { value: [], scope: SCOPE.UNREADABLE };
let content: string;
try {
content = fs.readFileSync(roadmapPath, 'utf-8');
} catch {
return { value: [], scope: SCOPE.UNREADABLE };
}
const scoped = extractCurrentMilestone(content, cwd);
// #1729: `(?:\s*\([^)\n]{0,200}\))?` tolerates a pre-colon ( ) tag (literal
// mirror of OPTIONAL_PHASE_TAG_SOURCE) — verbatim from `verify.cts:2366`.
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})(?:\\s*\\([^)\\n]{0,200}\\))?\\s*:`,
'gi',
);
const value = [...scoped.matchAll(phasePattern)].map((m) => m[1]);
return { value, scope: SCOPE.COMPLETE };
}
/**
* Build the full `.planning/` projection for `cwd`. Composes the six §7
* owners named in the design doc's "Owners consumed" table, plus (Phase 11,
@@ -695,6 +824,8 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot {
milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd),
planningRootFiles: buildPlanningRootFilesField(cwd),
allPhaseDirNames: buildAllPhaseDirNamesField(paths.phases),
archivedPhaseTokens: buildArchivedPhaseTokensField(paths.planning),
currentMilestoneRoadmapPhaseIds: buildCurrentMilestoneRoadmapPhaseIdsField(cwd, paths.roadmap),
};
}