* fix(#1729): resolve phase headers with a pre-colon parenthetical tag A phase header may carry a parenthetical tag between the number and the colon, e.g. `### Phase 26 (Cluster B): Title`. Every phase-header regex built `Phase\s+<num>` immediately against the colon delimiter, so the tagged phase was invisible: the resolver returned found:false and, just as bad, the capture-all enumeration/parse paths (roadmap analyze, milestone listing + milestone-scope filter, verify, init/import, state total_phases, validate, the command router, preamble stripping, and the phase-remove renumbering rewrite) silently dropped, miscounted, or failed to renumber it — wrong phase_count, progress_percent, next_phase, or corrupt numbering after a removal. The fix tolerates the tag at the header seam. Parameterized resolver sites compose the exported OPTIONAL_PHASE_TAG_SOURCE fragment; literal enumeration sites inline its character-for-character mirror `(?:\s*\([^)\n]*\))?`, placed immediately before the colon so it cannot alter an existing match (optional, single-line, one paren pair, no capture-group shift). In the renumber-on-removal rewrite the tag is folded into the re-emitted suffix capture so it survives verbatim. Both forms are documented to change together and a drift-guard test asserts their behavioral equivalence over a header corpus. Deliberately excluded: roadmap-upgrade.cts (legacy one-time migration), where tolerating the tag would silently drop it on header rewrite — that needs its own data-preserving treatment. Known boundaries left for follow-up: checklist/bullet-style phase entries (`- [ ] Phase N (tag):`) and a malformed space-before-colon variant, both pre-existing. Validated empirically against the issue's reproduction: `roadmap get-phase 26` resolves and `roadmap analyze` lists Phase 26 with the tag excluded from the name (phase_count 2, next 26); an all-tagged versioned roadmap now scopes correctly instead of falling back to a pass-all filter. Regression coverage in tests/phase.test.cjs asserts resolver parity (pre- vs post-colon), padding tolerance (#3537), decimal sub-phases, no cross-phase false match, the shared seam, enumeration coherence, renumber-preserves-tag, and seam/mirror drift. Full unit suite green (7291 pass, 0 fail); eslint + regression-name + resolution-provenance + changeset lints pass. Reviewed by Codex (no critical/high; the two enumeration misses it surfaced are folded in). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(#1729): add changeset for pre-colon phase-tag fix Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
parent
d96c7ef865
commit
12b35eeeaf
@@ -239,6 +239,200 @@ describe('phases list command', () => {
|
||||
// roadmap get-phase command
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// #1729 regression: a phase header may carry a parenthetical tag BEFORE the
|
||||
// colon — `### Phase 26 (Cluster B): Title`. The header regexes built
|
||||
// `Phase\s+<num>` immediately followed by the colon delimiter, so the resolver
|
||||
// returned found:false and phase commands silently no-op'd (the failure is
|
||||
// silent — not-found, not an error — so an author can lose work without a
|
||||
// signal). The fix injects a shared OPTIONAL_PHASE_TAG_SOURCE fragment at every
|
||||
// header call site, mirroring how `[...]` is already tolerated before `Phase`.
|
||||
//
|
||||
// Parity assertion (per the #3537/#3599 generative-fix discipline): a pre-colon
|
||||
// tag must resolve the SAME phase as the equivalent post-colon tag, padding
|
||||
// tolerance must survive, and the optional tag must not enable cross-phase
|
||||
// false matches. A shared seam + parity test keeps the next call site from
|
||||
// drifting back undetected.
|
||||
describe('#1729 regression: parenthetical tag before the colon in a phase header', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
function writeRoadmap(lines) {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
['# Roadmap', ''].concat(lines, ['']).join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
function getPhase(query) {
|
||||
const result = runGsdTools(['roadmap', 'get-phase', String(query)], tmpDir);
|
||||
assert.ok(result.success, `get-phase ${query} failed: ${result.error}`);
|
||||
return JSON.parse(result.output);
|
||||
}
|
||||
|
||||
test('resolves a header whose tag sits before the colon (the reported bug)', () => {
|
||||
writeRoadmap([
|
||||
'### Phase 26 (Cluster B): Engine-adapter caveats',
|
||||
'Plans: 2',
|
||||
]);
|
||||
const phase = getPhase(26);
|
||||
assert.equal(phase.found, true, 'pre-colon-tagged phase must resolve');
|
||||
assert.equal(
|
||||
phase.phase_name,
|
||||
'Engine-adapter caveats',
|
||||
'the pre-colon tag must be excluded from the resolved name',
|
||||
);
|
||||
});
|
||||
|
||||
test('a pre-colon tag resolves the same phase as a post-colon tag', () => {
|
||||
// Post-colon placement is the documented workaround and already worked.
|
||||
// Both placements must resolve found:true for the same phase number; the
|
||||
// only contracted difference is that a post-colon tag is part of the title.
|
||||
writeRoadmap(['### Phase 26: Engine-adapter caveats (Cluster B)', 'Plans: 2']);
|
||||
const post = getPhase(26);
|
||||
assert.equal(post.found, true);
|
||||
assert.equal(post.phase_name, 'Engine-adapter caveats (Cluster B)');
|
||||
|
||||
writeRoadmap(['### Phase 26 (Cluster B): Engine-adapter caveats', 'Plans: 2']);
|
||||
const pre = getPhase(26);
|
||||
assert.equal(pre.found, true, 'pre-colon placement must resolve like post-colon');
|
||||
assert.equal(pre.phase_number, post.phase_number);
|
||||
});
|
||||
|
||||
test('padding tolerance (#3537) survives: both `6` and `06` resolve a tagged header', () => {
|
||||
writeRoadmap(['### Phase 6 (Cluster B): Padded test', 'Plans: 1']);
|
||||
const unpadded = getPhase(6);
|
||||
const padded = getPhase('06');
|
||||
assert.equal(unpadded.found, true, 'unpadded query must resolve');
|
||||
assert.equal(padded.found, true, 'padded query must resolve');
|
||||
assert.equal(padded.phase_name, unpadded.phase_name, 'padded/unpadded must agree');
|
||||
assert.equal(unpadded.phase_name, 'Padded test');
|
||||
});
|
||||
|
||||
test('decimal sub-phase headers tolerate a pre-colon tag', () => {
|
||||
writeRoadmap([
|
||||
'### Phase 26 (Cluster B): Base',
|
||||
'Plans: 1',
|
||||
'',
|
||||
'### Phase 26.1 (Sub tag): Decimal subphase',
|
||||
'Plans: 1',
|
||||
]);
|
||||
const sub = getPhase('26.1');
|
||||
assert.equal(sub.found, true, 'decimal sub-phase with pre-colon tag must resolve');
|
||||
assert.equal(sub.phase_name, 'Decimal subphase');
|
||||
});
|
||||
|
||||
test('the optional tag does not enable a cross-phase false match', () => {
|
||||
// `0*2` must not latch onto `Phase 26 (...)`: querying phase 2 against a
|
||||
// roadmap that only has phase 26 must still report not-found.
|
||||
writeRoadmap(['### Phase 26 (Cluster B): Engine caveats', 'Plans: 1']);
|
||||
assert.equal(getPhase(2).found, false, 'phase 2 must not match phase 26');
|
||||
assert.equal(getPhase(26).found, true, 'sanity: phase 26 still resolves');
|
||||
});
|
||||
|
||||
test('exposes a shared OPTIONAL_PHASE_TAG_SOURCE seam to prevent call-site drift', () => {
|
||||
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
assert.equal(
|
||||
typeof phaseId.OPTIONAL_PHASE_TAG_SOURCE,
|
||||
'string',
|
||||
'the shared tag fragment must be exported so every header site composes it',
|
||||
);
|
||||
const re = new RegExp(`Phase\\s+0*26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}:`);
|
||||
assert.ok(re.test('### Phase 26 (Cluster B): X'), 'seam matches a pre-colon tag');
|
||||
assert.ok(re.test('### Phase 26: X'), 'seam stays optional when no tag is present');
|
||||
});
|
||||
|
||||
test('enumeration (roadmap analyze) lists a pre-colon-tagged phase, not just the resolver', () => {
|
||||
// The resolver (get-phase) and the capture-all enumeration regexes are
|
||||
// separate code paths. Fixing only the resolver left `roadmap analyze`
|
||||
// silently dropping a tagged phase from its phase list — wrong phase_count,
|
||||
// progress_percent, and next_phase. Both a tagged and an untagged phase must
|
||||
// appear so the enumeration is coherent with the resolver.
|
||||
writeRoadmap([
|
||||
'### Phase 26 (Cluster B): Engine-adapter caveats',
|
||||
'Plans: 1',
|
||||
'',
|
||||
'### Phase 27: Coordinator playbook',
|
||||
'Plans: 1',
|
||||
]);
|
||||
const result = runGsdTools(['roadmap', 'analyze'], tmpDir);
|
||||
assert.ok(result.success, `roadmap analyze failed: ${result.error}`);
|
||||
const analysis = JSON.parse(result.output);
|
||||
const numbers = analysis.phases.map((p) => p.number);
|
||||
assert.ok(numbers.includes('26'), 'tagged phase 26 must appear in enumeration');
|
||||
assert.ok(numbers.includes('27'), 'untagged phase 27 must appear in enumeration');
|
||||
assert.equal(analysis.phase_count, 2, 'tagged phase must count toward phase_count');
|
||||
const p26 = analysis.phases.find((p) => p.number === '26');
|
||||
assert.equal(p26.name, 'Engine-adapter caveats', 'pre-colon tag must be excluded from the enumerated name');
|
||||
});
|
||||
|
||||
test('phase remove renumbers a later tagged header and preserves its tag', () => {
|
||||
// The renumber-on-removal rewrite (phase.cts) captured `(num)(\s*:)`, so a
|
||||
// later pre-colon-tagged header was skipped — leaving a stale/duplicate
|
||||
// number after an earlier phase was removed. The tag must survive the
|
||||
// rewrite (it is folded into the re-emitted suffix, not dropped).
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
[
|
||||
'# Roadmap',
|
||||
'',
|
||||
'### Phase 1: Foundation',
|
||||
'**Goal:** Setup',
|
||||
'',
|
||||
'### Phase 2: Auth',
|
||||
'**Goal:** Authentication',
|
||||
'',
|
||||
'### Phase 3 (Cluster B): Features',
|
||||
'**Goal:** Core features',
|
||||
'',
|
||||
].join('\n'),
|
||||
);
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '01-foundation'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-auth'), { recursive: true });
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '03-features'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase remove 2', tmpDir);
|
||||
assert.ok(result.success, `phase remove failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(
|
||||
/###\s*Phase 2 \(Cluster B\): Features/.test(roadmap),
|
||||
`tagged header must renumber 3->2 and keep its tag; got:\n${roadmap}`,
|
||||
);
|
||||
assert.ok(!/Phase 3 \(Cluster B\)/.test(roadmap), 'old number 3 must be gone');
|
||||
});
|
||||
|
||||
test('the literal enumeration mirror stays equivalent to the exported seam (drift guard)', () => {
|
||||
// Resolver sites compose OPTIONAL_PHASE_TAG_SOURCE; literal enumeration sites
|
||||
// inline `(?:\s*\([^)\n]*\))?`. If one is edited without the other the two
|
||||
// header families silently diverge. Assert behavioral equivalence over a
|
||||
// representative header corpus so the split cannot drift undetected.
|
||||
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
|
||||
const LITERAL_MIRROR = '(?:\\s*\\([^)\\n]*\\))?';
|
||||
const seam = new RegExp(`^Phase\\s+26${phaseId.OPTIONAL_PHASE_TAG_SOURCE}\\s*:`);
|
||||
const mirror = new RegExp(`^Phase\\s+26${LITERAL_MIRROR}\\s*:`);
|
||||
for (const sample of [
|
||||
'Phase 26: X',
|
||||
'Phase 26 (Cluster B): X',
|
||||
'Phase 26 (a) (b): X',
|
||||
'Phase 26 (unterminated: X',
|
||||
'Phase 26 : X',
|
||||
]) {
|
||||
assert.equal(
|
||||
seam.test(sample),
|
||||
mirror.test(sample),
|
||||
`seam and literal mirror must agree on: ${JSON.stringify(sample)}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
describe('phase next-decimal command', () => {
|
||||
let tmpDir;
|
||||
|
||||
Reference in New Issue
Block a user