fix(#2126): route roadmap.cts CLI resolvers through shared lookup sources (drives #2114)

Phase 3 of epic #2121. cmdRoadmapGetPhase and getRoadmapPhaseWithFallback now
iterate the shared roadmapPhaseLookupSources (exact -> numeric -> prefix-tolerant,
owned by phase-id.cts since Phase 1) instead of a hand-rolled 2-source lookup, so
all three roadmap resolvers share one resolution contract.

Drives #2114: `roadmap get-phase <bare-N>` now resolves a drifted
`### Phase AB-29:` heading (matching getRoadmapPhaseInternal / init.phase-op),
previously EMPTY from the CLI. The malformed_roadmap checklist-fallback and the
milestone-then-full precedence are preserved (a milestone checklist never blocks
a full-roadmap header match).

Behavior reversal (approved in-session): a bare query now also resolves a
*drifted-only* prefixed heading when no bare sibling exists, reversing the #3599
counter-test's expectation. #3599's real anti-steal intent (a bare sibling wins
over a distinct prefixed one) is preserved by the exact->numeric->prefix-tolerant
ordering and re-asserted in the updated test; a new #2114 block covers the
drifted-only case. Fail-first demonstrated.

Closes #2126
Refs #2121

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-07-09 19:56:10 -04:00
parent b9c8ea143b
commit 61f3cafc70
2 changed files with 92 additions and 60 deletions

View File

@@ -1918,11 +1918,13 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup',
assert.strictEqual(payload.goal, 'Verify project-code-prefixed lookup');
});
test('does NOT cross-match: querying 42 must not match ### Phase PROJ-42:', () => {
// Counter-test: if the regex erroneously matches both forms in both
// directions, this catches it. `42` must only match `Phase 42:` — not
// `Phase PROJ-42:` — otherwise integer phase lookups silently steal
// matches from prefixed siblings.
test('bare numeric prefers a bare sibling over a prefixed one (#3599 anti-steal, updated for #2114)', () => {
// #3599's real guard is anti-STEALING: when BOTH a bare `Phase 42:` and a
// distinct prefixed `Phase PROJ-42:` exist, a bare `42` query must resolve
// the BARE one — the numeric source is tried before the prefix-tolerant
// fallback, so a bare query never steals a distinct prefixed sibling.
// (Since #2114/#2121, a bare query DOES resolve a *drifted-only* prefixed
// heading when no bare sibling exists — see the bug #2114 block below.)
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
@@ -1931,8 +1933,11 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase PROJ-42: Should not be returned for `42`',
'**Goal:** Counter-test',
'### Phase 42: Bare',
'**Goal:** Canonical bare heading',
'',
'### Phase PROJ-42: Prefixed',
'**Goal:** Distinct prefixed sibling',
'',
].join('\n'),
);
@@ -1940,10 +1945,11 @@ describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup',
const result = runGsdTools('roadmap get-phase 42 --json', tmpDir);
assert.ok(result.success);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`);
assert.strictEqual(
payload.found,
false,
`bare numeric '42' must not match 'Phase PROJ-42:'; got ${result.output}`,
payload.phase_name,
'Bare',
`bare '42' must resolve the bare 'Phase 42:', not steal 'Phase PROJ-42:'; got ${result.output}`,
);
});
@@ -2071,6 +2077,52 @@ function makePlanProject(files = {}) {
return dir;
}
describe('bug #2114: roadmap get-phase resolves drifted prefixed headings by bare number', () => {
let tmpDir;
beforeEach(() => { tmpDir = createTempProject('bug-2114-'); });
afterEach(() => { cleanup(tmpDir); });
test('bare-number query resolves a drifted project-code-prefixed heading', () => {
// Before the fix, bare `29` did NOT match `### Phase AB-29:` from the CLI
// (2-source lookup), even though getRoadmapPhaseInternal (init.phase-op) did
// — the #2114 divergence. Now all three resolvers share the 3-source list.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'---\nmilestone: v1.0.0\n---\n# State\n\n**Status:** In progress\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 30: Plain',
'**Goal:** Canonical bare heading',
'',
'### Phase AB-29: Prefixed',
'**Goal:** Drifted prefixed heading',
'',
].join('\n'),
);
const resultAB29 = runGsdTools('roadmap get-phase 29 --json', tmpDir);
assert.ok(resultAB29.success, `command failed: ${resultAB29.error || resultAB29.output}`);
const payloadAB29 = JSON.parse(resultAB29.output);
assert.strictEqual(payloadAB29.found, true, `expected found=true for drifted AB-29, got: ${resultAB29.output}`);
assert.strictEqual(payloadAB29.phase_name, 'Prefixed');
assert.strictEqual(payloadAB29.goal, 'Drifted prefixed heading');
// The canonical bare heading still resolves.
const result30 = runGsdTools('roadmap get-phase 30 --json', tmpDir);
assert.ok(result30.success);
const payload30 = JSON.parse(result30.output);
assert.strictEqual(payload30.found, true);
assert.strictEqual(payload30.phase_name, 'Plain');
});
});
describe('roadmap annotate-dependencies', () => {
let tmpDir;