fix(#2855): scope the phase-locator archived-milestone fallback to the active workstream (#3008)

* test(#2855): add failing-first regression test for cross-workstream archive leak

Covers findPhaseInternal/getArchivedPhaseDirs in src/phase-locator.cts
resolving a pending workstream phase to an unrelated workstream's (or
flat-mode's) archived phase because the archive fallback hardcodes the
project-root .planning/milestones/ tree. Fails against the current
implementation; the fix lands in a follow-up commit.

* fix(#2855): scope phase-locator archived-milestone fallback to the active workstream

findPhaseInternal and getArchivedPhaseDirs in src/phase-locator.cts hardcoded
the project-root .planning/milestones/ tree when falling back to search
archived phases, ignoring GSD_WORKSTREAM. A pending phase in one workstream
whose own phases/ directory didn't exist yet would silently resolve to a
same-numbered phase archived under an unrelated workstream's (or flat-mode's)
history, complete with stale plan/summary counts and an archived status.

Route the archive fallback through planningDir(cwd) instead — the same
workstream-aware helper the active-phase search (three lines above) and the
archive-write path (archivePhaseDirectories in milestone.cts) already use.
Flat/non-workstream projects are unaffected: planningDir(cwd) with no
GSD_WORKSTREAM resolves to the same root .planning path as before.

Also switch the reported relBase/basePath from a hardcoded
'.planning/milestones/...' literal to path.relative(cwd, archivePath), so the
paths returned to callers stay consistent with wherever the archive actually
resolved to (root or workstream-scoped).

* chore(#2855): add changeset for phase-locator workstream archive fix

* fix(#2855): normalize getArchivedPhaseDirs basePath to posix separators

Orthogonal code-review finding: findPhaseInternal's relBase/directory field
was explicitly toPosixPath-normalized, but getArchivedPhaseDirs's basePath
used a bare path.relative() call, leaving it native-separator on Windows —
an inconsistency between two sibling "relative path from cwd" report fields
introduced by the same #2855 fix. Wrap basePath in toPosixPath to match, and
update the two existing assertions that compared basePath against path.join
output (which would break on Windows now that the field is guaranteed posix)
to compare against forward-slash literals instead, matching how the sibling
`directory` field is already asserted elsewhere in this suite.

* refactor(#2855): share archive-directory resolution between findPhaseInternal and getArchivedPhaseDirs

Orthogonal code-review finding: the two functions carried independent copies
of the same resolve-milestonesDir-then-enumerate-archive-dirs logic — the
exact shape that let the original #2855 bug (hardcoded root path) exist in
one copy while the workstream-aware active-phase search sat three lines
above it. Extract listArchiveVersionDirs(cwd) as the single seam both
functions now consume, so a future change to how the archive tree is located
only needs to happen once. Byte-for-behaviour preserved: readSubdirectories
and searchPhaseInDir already self-contain their own try/catch and never
throw, so moving the iteration outside the old inline try block changes
nothing observable (verified via manual repro scripts covering leak
prevention, positive resolution, flat-mode parity, and multi-milestone
reverse-sort ordering).

* test(#2855): demonstrate ROADMAP.md presence does not affect the archive-leak guard

Orthogonal code-review (spec axis) finding: issue #2855's AC1 states the
guard must hold "regardless of whether workstream A's roadmap already lists
the phase and when it doesn't yet" — an explicit two-value dimension that
had no direct test coverage; it was only inferable by reading
findPhaseInternal's source and confirming it never touches ROADMAP.md.
Add a parametrized test creating the workstream's ROADMAP.md with and
without a matching Phase heading, asserting the archive-leak guard resolves
identically (null) either way.

* chore(#2855): backfill changeset PR number to 3008

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-02 19:24:53 -04:00
committed by GitHub
parent 51f32d2d40
commit 67e2ff7b25
4 changed files with 323 additions and 48 deletions

View File

@@ -54,8 +54,45 @@ interface ArchivedPhaseDir {
fullPath: string;
}
interface ArchiveVersionDir {
version: string;
archivePath: string;
}
// ─── Phase search helpers ─────────────────────────────────────────────────────
/**
* #2855: single source of truth for resolving and enumerating a project's
* (or, when a workstream is active, that workstream's OWN) archived-milestone
* directories — `<planningDir(cwd)>/milestones/vX.Y-phases/`. Both
* `findPhaseInternal`'s archive fallback and `getArchivedPhaseDirs` used to
* carry independent copies of this resolve-then-enumerate logic, which is
* exactly the shape that let the original #2855 bug (hardcoded root path)
* exist in one copy and not the other. Sharing this seam means a future
* change to how the archive tree is located only needs to happen once.
* Most-recent-milestone-first order (reverse-sorted directory names).
* Never throws: an absent/unreadable milestones/ dir yields [].
*/
function listArchiveVersionDirs(cwd: string): ArchiveVersionDir[] {
const milestonesDir = path.join(planningDir(cwd), 'milestones');
if (!fs.existsSync(milestonesDir)) return [];
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
return milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse()
.map(archiveName => ({
version: archiveName.match(/^(v[\d.]+)-phases$/)![1],
archivePath: path.join(milestonesDir, archiveName),
}));
} catch {
return [];
}
}
function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): PhaseSearchResult | null {
try {
const dirs = readSubdirectories(baseDir, true);
@@ -136,63 +173,46 @@ function findPhaseInternal(cwd: string, phase: unknown): PhaseSearchResult | nul
const current = searchPhaseInDir(phasesDir, relPhasesDir, normalized);
if (current) return current;
const milestonesDir = path.join(cwd, '.planning', 'milestones');
if (!fs.existsSync(milestonesDir)) return null;
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const archiveDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of archiveDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const relBase = '.planning/milestones/' + archiveName;
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
return result;
}
// #2855: scope the archived-milestone fallback to the SAME workstream as the
// active-phase search above (planningDir(cwd) resolves GSD_WORKSTREAM/GSD_PROJECT
// the identical way both places), not the hardcoded project-root tree. Archived
// phases genuinely live under a workstream's own `.planning/workstreams/<ws>/
// milestones/` — that is where archivePhaseDirectories (milestone.cts) writes
// them via the same planningDir(cwd) resolution. Hardcoding root here let a
// pending workstream phase resolve to an unrelated workstream's (or a flat-mode
// project's) archived phase that merely shares a phase number. Shared with
// getArchivedPhaseDirs via listArchiveVersionDirs (see its doc comment).
for (const { version, archivePath } of listArchiveVersionDirs(cwd)) {
const relBase = toPosixPath(path.relative(cwd, archivePath));
const result = searchPhaseInDir(archivePath, relBase, normalized);
if (result) {
result.archived = version;
return result;
}
} catch { /* intentionally empty */ }
}
return null;
}
function getArchivedPhaseDirs(cwd: string): ArchivedPhaseDir[] {
const milestonesDir = path.join(cwd, '.planning', 'milestones');
// #2855: same workstream-scoped resolution as findPhaseInternal above, via
// the shared listArchiveVersionDirs helper. `phase.list --include-archived`
// (the primary non-init consumer) must not leak a different workstream's
// archive either.
const results: ArchivedPhaseDir[] = [];
if (!fs.existsSync(milestonesDir)) return results;
for (const { version, archivePath } of listArchiveVersionDirs(cwd)) {
const dirs = readSubdirectories(archivePath, true);
try {
const milestoneEntries = fs.readdirSync(milestonesDir, { withFileTypes: true });
const phaseDirs = milestoneEntries
.filter(e => e.isDirectory() && /^v[\d.]+-phases$/.test(e.name))
.map(e => e.name)
.sort()
.reverse();
for (const archiveName of phaseDirs) {
const versionMatch = archiveName.match(/^(v[\d.]+)-phases$/);
const version = versionMatch![1];
const archivePath = path.join(milestonesDir, archiveName);
const dirs = readSubdirectories(archivePath, true);
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
basePath: path.join('.planning', 'milestones', archiveName),
fullPath: path.join(archivePath, dir),
});
}
for (const dir of dirs) {
results.push({
name: dir,
milestone: version,
basePath: toPosixPath(path.relative(cwd, archivePath)),
fullPath: path.join(archivePath, dir),
});
}
} catch { /* intentionally empty */ }
}
return results;
}