refactor(#3309): add allPhaseDirNames field to planning-snapshot

W007 (orphan disk dir with no ROADMAP entry) cannot be sourced from
phaseDirs, which is windowed to ROADMAP-declared phases only — an
orphan dir can never appear in an already-ROADMAP-filtered set. Adds
an unwindowed allPhaseDirNames field so the rule can actually fire.
This commit is contained in:
sim
2026-08-13 01:37:01 -04:00
parent c5543e533c
commit 8c9ca3f7c6
2 changed files with 87 additions and 0 deletions

View File

@@ -135,6 +135,27 @@ interface PlanningSnapshot {
scope: Scope;
};
planningRootFiles: { value: string[]; scope: Scope };
// W006/W007 (ROADMAP/disk consistency group) fidelity fix, found while
// implementing `src/health-diagnostic-rules/roadmap-disk-consistency.cts`:
// `phaseDirs` (Phase 10) is deliberately WINDOWED to the phases
// `listMilestonePhaseDirs`'s `inWindow` filter (`getMilestonePhaseFilter`,
// `src/roadmap-parser.cts:1220`) resolves as belonging to the CURRENT
// milestone window — a directory whose phase id is NOT declared anywhere
// in ROADMAP.md is EXCLUDED from `phaseDirs.value` by construction
// (`isDirInMilestone` membership test). That is exactly the directory
// W007 exists to find ("an on-disk phase dir has no matching ROADMAP
// entry"), so sourcing W007 from `phaseDirs.value` would make it
// structurally unable to fire on the very case it names: an orphan
// directory can never be a member of the set that is itself defined as
// "directories the roadmap already declares." `allPhaseDirNames` is the
// un-windowed twin — every directory actually present under the active
// `phases/` root, unfiltered by roadmap declaration (sentinel-id
// exclusion is left to the RULE, mirroring `verify.cts:2091`'s own
// per-entry `isSentinelPhaseId` guard rather than baking it into the
// field). Archived-milestone directories are out of scope here exactly as
// they already are for `phaseDirs` (see this batch's own disclosed
// fidelity reduction for that).
allPhaseDirNames: { value: string[]; scope: Scope };
}
/**
@@ -607,6 +628,29 @@ function buildPlanningRootFilesField(cwd: string): { value: string[]; scope: Sco
}
}
/**
* Resolve `allPhaseDirNames` — every directory name directly under the
* active `phases/` root, UNFILTERED by `listMilestonePhaseDirs`'s
* current-milestone-window membership test (unlike `phaseDirs`). Backs
* W007 (see the field's own doc comment on `PlanningSnapshot` for why
* `phaseDirs` cannot). An absent `phases/` root is a real empty, not a
* failure (mirrors `listMilestonePhaseDirs`'s own treatment); a present but
* unreadable root degrades to `UNREADABLE` with an empty list.
*/
function buildAllPhaseDirNamesField(phasesDir: string): { value: string[]; scope: Scope } {
if (!fs.existsSync(phasesDir)) return { value: [], scope: SCOPE.COMPLETE };
try {
const value = fs
.readdirSync(phasesDir, { withFileTypes: true })
.filter((e) => e.isDirectory())
.map((e) => e.name)
.sort();
return { value, scope: SCOPE.COMPLETE };
} catch {
return { value: [], scope: SCOPE.UNREADABLE };
}
}
/**
* Build the full `.planning/` projection for `cwd`. Composes the six §7
* owners named in the design doc's "Owners consumed" table, plus (Phase 11,
@@ -642,6 +686,7 @@ function buildPlanningSnapshot(cwd: string): PlanningSnapshot {
researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope),
milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd),
planningRootFiles: buildPlanningRootFilesField(cwd),
allPhaseDirNames: buildAllPhaseDirNamesField(paths.phases),
};
}

View File

@@ -1048,3 +1048,45 @@ describe('planningRootFiles field (Phase 11, #3309)', () => {
assert.deepStrictEqual(snap.planningRootFiles, { value: [], scope: SCOPE.UNREADABLE });
});
});
describe('allPhaseDirNames field (Phase 11, #3309 — health-diagnostic-rules/roadmap-disk-consistency batch)', () => {
// Found while implementing W007 (`src/health-diagnostic-rules/
// roadmap-disk-consistency.cts`): `phaseDirs` is windowed to directories
// the ROADMAP already declares, so it can never expose a genuine orphan
// directory. `allPhaseDirNames` is the unwindowed twin.
test('happy: lists every directory under phases/, including one NOT declared anywhere in ROADMAP.md', (t) => {
const cwd = createTempDir('gsd-3309-apdn1-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', '', '### Phase 1: Foo'].join('\n'));
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '01-foo'), { recursive: true });
fs.mkdirSync(path.join(planningDirOf(cwd), 'phases', '04-extra'), { recursive: true }); // undeclared
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.allPhaseDirNames.value.slice().sort(), ['01-foo', '04-extra']);
assert.strictEqual(snap.allPhaseDirNames.scope, SCOPE.COMPLETE);
// Sanity: `phaseDirs` (windowed) must NOT include the undeclared dir —
// this is the exact gap `allPhaseDirNames` exists to close.
assert.ok(!snap.phaseDirs.value.includes('04-extra'));
});
test('absence: no phases/ directory at all is a real empty, not a failure', (t) => {
const cwd = createTempDir('gsd-3309-apdn2-');
t.after(() => cleanup(cwd));
writeRoadmap(cwd, ['## v1.0 Current 🚧', ''].join('\n'));
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.allPhaseDirNames, { value: [], scope: SCOPE.COMPLETE });
});
test('hostile: an unreadable phases/ directory degrades to an empty list, scope UNREADABLE, without throwing', (t) => {
const cwd = createTempDir('gsd-3309-apdn3-');
t.after(() => cleanup(cwd));
const phasesDir = path.join(planningDirOf(cwd), 'phases');
fs.mkdirSync(phasesDir, { recursive: true });
injectPhaseDirFault(t, phasesDir);
const snap = buildPlanningSnapshot(cwd);
assert.deepStrictEqual(snap.allPhaseDirNames, { value: [], scope: SCOPE.UNREADABLE });
});
});