fix(#1588): ignore fenced and backlog roadmap phases (#1845)

* fix(#1588): ignore fenced and backlog roadmap phases

* chore(#1588): add changeset fragment

* chore(#1588): fix changeset body

* test(#1588): fold init regression into init suite
This commit is contained in:
Tom Boucher
2026-06-30 20:43:25 -04:00
committed by GitHub
parent 8161fcc667
commit 9e32462dd8
6 changed files with 159 additions and 46 deletions

View File

@@ -90,27 +90,69 @@ describe('feat-3594: roadmap parser and fenced-code-block headings (#2787)', ()
assert.match(result.parsed.phase_name, /real phase one/);
});
test('phase 999 inside a fenced block: CJS parser currently STILL matches it (open: needs fence-stripping)', (t) => {
test('phase 999 inside a fenced block is ignored', (t) => {
const projectDir = projectWithFixture(t, 'phase-heading-inside-fenced-code.md');
const result = getPhase(projectDir, '999');
assert.equal(result.hasStackTrace, false, 'no stack trace');
// The CJS regex parser does not strip fenced code blocks before
// matching. The SDK roadmap parser tracks fenced blocks (per #2787
// comment in sdk/src/query/roadmap.ts) — the CJS path has not caught
// up. This test pins the current behavior so the day someone wires
// CJS fence-stripping, flipping `found: true` to `found: false`
// becomes the regression guard.
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
assert.equal(result.parsed.found, true, 'CJS parser currently matches inside fences (known open bug)');
// The matched heading is the one INSIDE the fenced block. Match
// its distinctive substring so a future "fix" that strips fences
// and instead matches a different (real) phase 999 (which we don't
// have in this fixture, so impossible) still fails the right test.
assert.match(
result.parsed.phase_name,
/fenced code block/i,
'currently-matched heading must be the one inside the fence',
assert.equal(result.parsed.found, false, 'phase headings inside fenced blocks must not be parsed');
});
test('fenced example heading does not shadow the real phase details and backlog phase stays unresolved (#1588)', (t) => {
const projectDir = createTempProject('roadmap-1588-');
t.after(() => cleanup(projectDir));
fs.writeFileSync(
path.join(projectDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.1',
'status: planning',
'---',
'',
].join('\n')
);
fs.writeFileSync(
path.join(projectDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'<details open>',
'<summary>v1.1 Current (Phases 8-9) - PLANNED</summary>',
'',
'- [ ] **Phase 9: Real Phase**',
'',
'</details>',
'',
'## Phase Details',
'',
'```markdown',
'### Phase 9: Fenced Example Phase',
'**Goal:** This example must not be treated as roadmap structure.',
'```',
'',
'### Phase 9: Real Phase',
'**Goal:** Use the real phase details outside the fenced block.',
'**Requirements:** REAL-01',
'',
'## Backlog',
'',
'### Phase 999.1: Backlog Thing',
'**Goal:** Future backlog item.',
'',
].join('\n')
);
const phase9 = getPhase(projectDir, '9');
assert.ok(phase9.parsed, `expected JSON payload, got: ${phase9.raw}`);
assert.equal(phase9.parsed.found, true, 'phase 9 must be found');
assert.equal(phase9.parsed.phase_name, 'Real Phase');
assert.equal(phase9.parsed.goal, 'Use the real phase details outside the fenced block.');
assert.match(phase9.parsed.section, /REAL-01/, 'real phase section must be returned');
const backlog = getPhase(projectDir, '999.1');
assert.ok(backlog.parsed, `expected JSON payload, got: ${backlog.raw}`);
assert.equal(backlog.parsed.found, false, 'backlog sentinel phase must not resolve as an active roadmap phase');
});
});
@@ -205,15 +247,12 @@ describe('feat-3594: roadmap parser and HTML-commented headings', () => {
assert.equal(result.parsed.phase_name, 'real phase');
});
test('phase 999 inside an HTML comment: CJS parser currently STILL matches it (open: needs comment-stripping)', (t) => {
test('phase 999 inside an HTML comment remains ignored because backlog sentinels never resolve', (t) => {
const projectDir = projectWithFixture(t, 'markdown-headings-inside-html-comment.md');
const result = getPhase(projectDir, '999');
assert.equal(result.hasStackTrace, false, 'no stack trace');
// Same shape as the fenced-code-block case: the CJS regex parser
// doesn't strip HTML comments before matching.
assert.ok(result.parsed, `expected JSON payload, got: ${result.raw}`);
assert.equal(result.parsed.found, true, 'CJS parser currently matches inside HTML comments (known open bug)');
assert.match(result.parsed.phase_name, /HTML comment/);
assert.equal(result.parsed.found, false, 'backlog sentinel phases must not resolve');
});
});

View File

@@ -8,6 +8,7 @@ const fs = require('fs');
const path = require('path');
const { runGsdTools, cleanup } = require('./helpers.cjs');
const { createFixture, seedPhase } = require('./fixtures/index.cjs');
const { createTempProject } = require('./helpers.cjs');
describe('init commands', () => {
let tmpDir;
@@ -426,6 +427,66 @@ describe('init commands', () => {
assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03');
});
test('init plan-phase prefers real phase details outside fenced examples and ignores backlog sentinels (#1588)', () => {
const projectDir = createTempProject('init-1588-');
try {
fs.writeFileSync(
path.join(projectDir, '.planning', 'STATE.md'),
[
'---',
'gsd_state_version: 1.0',
'milestone: v1.1',
'status: planning',
'---',
'',
].join('\n')
);
fs.writeFileSync(
path.join(projectDir, '.planning', 'ROADMAP.md'),
[
'# Roadmap',
'',
'<details open>',
'<summary>v1.1 Current (Phases 8-9) - PLANNED</summary>',
'',
'- [ ] **Phase 9: Real Phase**',
'',
'</details>',
'',
'## Phase Details',
'',
'```markdown',
'### Phase 9: Fenced Example Phase',
'**Goal:** This example must not be treated as roadmap structure.',
'```',
'',
'### Phase 9: Real Phase',
'**Goal:** Use the real phase details outside the fenced block.',
'**Requirements:** REAL-01',
'',
'## Backlog',
'',
'### Phase 999.1: Backlog Thing',
'**Goal:** Future backlog item.',
'',
].join('\n')
);
const phase9 = runGsdTools('init plan-phase 9', projectDir);
assert.ok(phase9.success, `init plan-phase 9 failed: ${phase9.error}`);
const phase9Output = JSON.parse(phase9.output);
assert.equal(phase9Output.phase_name, 'Real Phase');
assert.equal(phase9Output.phase_req_ids, 'REAL-01');
const backlog = runGsdTools('init plan-phase 999.1', projectDir);
assert.ok(backlog.success, `init plan-phase 999.1 failed: ${backlog.error}`);
const backlogOutput = JSON.parse(backlog.output);
assert.equal(backlogOutput.phase_found, false);
} finally {
cleanup(projectDir);
}
});
test('init phase-op resolves a details-summary milestone phase from later flat Phase Details', () => {
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [
'milestone: v1.11',

View File

@@ -84,7 +84,7 @@ describe('roadmap get-phase fallback to full ROADMAP.md (#1634)', () => {
assert.equal(output.goal, 'Set up project infrastructure');
});
test('backlog phase outside current milestone resolves via fallback', () => {
test('backlog sentinel outside current milestone does not resolve via fallback', () => {
writeState(tmpDir, 'v1.0');
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
@@ -106,10 +106,8 @@ describe('roadmap get-phase fallback to full ROADMAP.md (#1634)', () => {
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.equal(output.found, true, 'backlog phase should be found via fallback');
assert.equal(output.found, false, 'backlog sentinel should stay unresolved');
assert.equal(output.phase_number, '999.60');
assert.equal(output.phase_name, 'Backlog Cleanup');
assert.equal(output.goal, 'Clean up technical debt from backlog');
});
test('future planned milestone phase resolves via fallback', () => {