Files
msd-core/tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs
Tom Boucher 2336c27141 fix(3599): preserve project-code prefix when looking up roadmap phases
`roadmap get-phase PROJ-42` returned `{found: false}` because
phaseMarkdownRegexSource() unconditionally strips the project-code prefix
(`^[A-Z]{1,6}-(?=\d)`) before matching, building the regex `0*42` which
matches `### Phase 42:` but never `### Phase PROJ-42:`. The function's
own docstring promised a fallback to escapeRegex(phaseNum) for custom
IDs, but the line-680 regex match consumes the stripped-numeric form
before that branch is reachable.

Fix: add phaseMarkdownRegexSourceExact() that returns the exact-escaped
source for project-code-prefixed inputs (or null for un-prefixed). Update
cmdRoadmapGetPhase to do a two-pass search — try the exact-prefixed form
first, only fall back to the existing padding-tolerant numeric form if
the exact heading is not present.

Two-pass at the call site (rather than alternation inside the regex
source) is required: a roadmap containing both `### Phase 42:` and
`### Phase PROJ-42:` cannot be disambiguated by a single alternation
because regex match-position is leftmost-wins, so the bare numeric
heading at line N would always intercept the match intended for the
prefixed sibling at line M.

The #3537 contract is preserved: `roadmap get-phase CK-01` against a
roadmap that uses `### Phase 1:` prose still resolves correctly via the
numeric fallback, because the exact-prefixed pass returns null and the
existing padded-numeric pass runs unchanged.

Tests added (tests/bug-3599-roadmap-get-phase-project-code-prefix.test.cjs):
1. PROJ-42 query against `### Phase PROJ-42:` heading — found
2. Counter-test: bare `42` query against `### Phase PROJ-42:` — NOT found
3. #3537 contract preserved: CK-01 query → `### Phase 1:` heading
4. Disambiguation: both `### Phase 42:` and `### Phase PROJ-42:` in
   one roadmap; each query resolves to its specific match

All assertions go through runGsdTools + JSON parse — typed payload,
no raw text matching on stdout.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 22:08:28 -04:00

168 lines
5.5 KiB
JavaScript

/**
* Bug #3599: roadmap.get-phase no longer matches custom phase IDs with
* project-code prefixes like `PROJ-42`.
*
* `phaseMarkdownRegexSource(phaseNum)` in get-shit-done/bin/lib/core.cjs
* (and its SDK twin in sdk/src/query/roadmap-update-plan-progress.ts) strips
* the `PROJ-` prefix before building the padding-tolerant numeric regex.
* Result: `roadmap get-phase PROJ-42` produces a regex of `0*42`, which
* matches `### Phase 42:` instead of (or in addition to) the intended
* `### Phase PROJ-42:`. The function's own docstring promises a fallback to
* `escapeRegex(phaseNum)` for non-numeric custom IDs, but that branch is
* unreachable for project-code-prefixed numeric IDs.
*
* Fix: the emitted regex must match BOTH the stripped numeric form (so
* `CK-01-name` directory inputs still resolve to `Phase 1:` in prose, the
* #3537 contract) AND the full prefixed form (so `PROJ-42` resolves to
* `Phase PROJ-42:`).
*/
'use strict';
const { describe, test, beforeEach, afterEach } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { runGsdTools, createTempProject, cleanup } = require('./helpers.cjs');
function writeRoadmap(tmpDir, body) {
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), body);
}
function writeState(tmpDir, version) {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---\nmilestone: ${version}\n---\n`,
);
}
describe('bug #3599: roadmap get-phase preserves project-code prefix in lookup', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject('bug-3599-');
});
afterEach(() => {
cleanup(tmpDir);
});
test('finds ### Phase PROJ-42: when queried as PROJ-42', () => {
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase PROJ-42: Custom phase',
'**Goal:** Verify project-code-prefixed lookup',
'',
].join('\n'),
);
const result = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
assert.ok(result.success, `command failed: ${result.error || result.output}`);
const payload = JSON.parse(result.output);
assert.strictEqual(payload.found, true, `expected found=true, got: ${result.output}`);
assert.strictEqual(payload.phase_name, 'Custom phase');
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.
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase PROJ-42: Should not be returned for `42`',
'**Goal:** Counter-test',
'',
].join('\n'),
);
const result = runGsdTools('roadmap get-phase 42 --json', tmpDir);
assert.ok(result.success);
const payload = JSON.parse(result.output);
assert.strictEqual(
payload.found,
false,
`bare numeric '42' must not match 'Phase PROJ-42:'; got ${result.output}`,
);
});
test('preserves #3537 contract: CK-01 directory form resolves to Phase 1 prose', () => {
// Existing contract: phase directory names like `CK-01-name` carry the
// project_code prefix and a zero-padded number, but ROADMAP prose is
// typically un-padded (`### Phase 1:`). The padding-tolerant lookup must
// still bridge those two surfaces.
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 1: Numeric prose',
'**Goal:** #3537 contract — CK-01 dir → Phase 1 prose',
'',
].join('\n'),
);
const result = runGsdTools('roadmap get-phase CK-01 --json', tmpDir);
assert.ok(result.success);
const payload = JSON.parse(result.output);
assert.strictEqual(
payload.found,
true,
`CK-01 must still resolve to 'Phase 1:' prose (#3537 contract); got ${result.output}`,
);
assert.strictEqual(payload.phase_name, 'Numeric prose');
});
test('finds the right phase when both prefixed and bare forms coexist', () => {
// Disambiguation test: a roadmap that contains BOTH `### Phase 42:` and
// `### Phase PROJ-42:` must resolve each query to its specific match.
writeState(tmpDir, 'v1.0.0');
writeRoadmap(
tmpDir,
[
'# Roadmap',
'',
'## Current Milestone: v1.0.0 - Test',
'',
'### Phase 42: Bare numeric',
'**Goal:** Bare',
'',
'### Phase PROJ-42: Prefixed',
'**Goal:** Prefixed',
'',
].join('\n'),
);
const r42 = runGsdTools('roadmap get-phase 42 --json', tmpDir);
const rProj = runGsdTools('roadmap get-phase PROJ-42 --json', tmpDir);
const p42 = JSON.parse(r42.output);
const pProj = JSON.parse(rProj.output);
assert.strictEqual(p42.found, true);
assert.strictEqual(p42.phase_name, 'Bare numeric');
assert.strictEqual(p42.goal, 'Bare');
assert.strictEqual(pProj.found, true);
assert.strictEqual(pProj.phase_name, 'Prefixed');
assert.strictEqual(pProj.goal, 'Prefixed');
});
});