fix(3691): match all Plans-block variants in annotate-dependencies

Three regex defects in cmdRoadmapAnnotateDependencies (roadmap.cjs):

1. Plans-block detection (line ~553): `Plans:\s*\n` required no text after
   the colon, silently skipping `Plans: 3 plans\n` and `**Plans:** N\n`.
   Fixed: `\*{0,2}Plans\*{0,2}:[^\n]*\n` + require `+` checklist lines so
   a bold summary line above a bare `Plans:` block doesn't consume the match.

2. Phase-section boundary (line ~542): `\d` matched only one digit, so
   `### Phase 02.3:` was not recognised as a section terminator, allowing
   plan-list content from adjacent decimal phases to bleed in. Fixed with
   `\d[\d.]*`. Same one-digit boundary also patched in roadmap.cjs (analyze
   path), phase.cjs (insert-after path), and init.cjs (section-slice path).

3. Plan-ID extraction (line ~566): `[\w-]+?` excluded `.`, capturing `02`
   from `02.3-01-PLAN.md` instead of `02.3-01`, so planData.find never
   resolved and every plan defaulted to wave 1. Fixed: `[\w.-]+?`.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-05-20 23:58:31 -04:00
parent ea67479bfb
commit 69a3427189
4 changed files with 351 additions and 7 deletions

View File

@@ -1170,7 +1170,9 @@ function cmdInitManager(cwd, raw) {
const sectionStart = match.index;
const restOfContent = content.slice(sectionStart);
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
// recognised as section boundaries.
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
const section = content.slice(sectionStart, sectionEnd);

View File

@@ -882,7 +882,9 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
const headerIdx = rawContent.indexOf(headerMatch[0]);
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
// recognised as section boundaries.
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
let insertIdx;
if (nextPhaseMatch) {

View File

@@ -223,7 +223,9 @@ function cmdRoadmapAnalyze(cwd, raw) {
// Extract goal from the section
const sectionStart = match.index;
const restOfContent = content.slice(sectionStart);
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d/i);
// #3691: `\d` → `\d[\d.]*` so decimal phase headings (e.g. `### Phase 02.3:`) are
// recognised as section boundaries.
const nextHeader = restOfContent.match(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
const section = content.slice(sectionStart, sectionEnd);
@@ -539,7 +541,9 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
const phaseStart = phaseMatch.index;
const restAfterHeader = content.slice(phaseStart);
const nextPhaseOffset = restAfterHeader.slice(1).search(/\n#{2,4}\s+Phase\s+\d/i);
// #3691 Bug 2: `\d` only matches a single digit, missing decimal phase headings
// like `### Phase 02.3:`. Use `\d[\d.]*` to match integers and decimals alike.
const nextPhaseOffset = restAfterHeader.slice(1).search(/\n#{2,4}\s+Phase\s+\d[\d.]*/i);
const phaseEnd = nextPhaseOffset >= 0 ? phaseStart + 1 + nextPhaseOffset : content.length;
const phaseSection = content.slice(phaseStart, phaseEnd);
@@ -549,8 +553,16 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
/\*\*Cross-cutting constraints:\*\*/i.test(phaseSection)
) return;
// Find the Plans: section within the phase section
const plansBlockMatch = phaseSection.match(/(Plans:\s*\n)((?:\s*-\s*\[[ x]\][^\n]*\n?)*)/i);
// Find the Plans: section within the phase section.
// #3691 Bug 1: `Plans:\s*\n` required no text after the colon, missing variants like
// `Plans: 3 plans across 2 waves\n` or `**Plans:** 3 plans\n` (bold-wrapped).
// `\*{0,2}Plans\*{0,2}:[^\n]*\n` accepts any text (or none) after the colon
// and tolerates optional `**` markdown bold wrappers on either side.
// The checklist group uses `+` (not `*`) so that a bold `**Plans:**` description
// line with no immediately-following checklist items (e.g. a summary line above a
// separate bare `Plans:` block) does not consume the match and prevent the actual
// list from being found.
const plansBlockMatch = phaseSection.match(/(\*{0,2}Plans\*{0,2}:[^\n]*\n)((?:\s*-\s*\[[ x]\][^\n]*\n?)+)/i);
if (!plansBlockMatch) return;
const plansHeader = plansBlockMatch[1];
@@ -563,7 +575,10 @@ function cmdRoadmapAnnotateDependencies(cwd, phaseNum, raw) {
const linesByWave = new Map();
for (const line of listLines) {
// Match plan ID from line: "- [ ] 01-01-PLAN.md — ..." or "- [ ] 01-01: ..."
const idMatch = line.match(/\[\s*[x ]\s*\]\s*([\w-]+?)(?:-PLAN\.md|\.md|:|\s—)/i);
// #3691 Bug 3: `[\w-]+?` excluded `.`, so decimal IDs like `02.3-01` were captured
// as `02` only and never matched planData entries. `[\w.-]+?` preserves the
// terminating alternation (`-PLAN.md|.md|:|\s—`) as the boundary anchor.
const idMatch = line.match(/\[\s*[x ]\s*\]\s*([\w.-]+?)(?:-PLAN\.md|\.md|:|\s—)/i);
const planId = idMatch ? idMatch[1] : null;
const planEntry = planId ? planData.find(p => p.planId === planId) : null;
const wave = planEntry ? planEntry.wave : 1;

View File

@@ -0,0 +1,325 @@
'use strict';
// allow-test-rule: source-text-is-the-product
// Reads .md/.json/.yml product files whose deployed text IS what the
// runtime loads — testing text content tests the deployed contract.
/**
* Regression — issue #3691
*
* Three regex defects in `roadmap.cjs` function `cmdRoadmapAnnotateDependencies`:
*
* Bug 1 (line ~553) — Plans-block detection regex `/(Plans:\s*\n)/i` requires no text
* after the colon. Headers like `Plans: 3 plans across 2 waves\n` or
* `**Plans:** 3 plans\n` are silently skipped and the function early-returns.
*
* Bug 2 (line ~542) — Phase-section boundary regex `/\n#{2,4}\s+Phase\s+\d/i` uses
* `\d` (single digit) so decimal phase headings like `### Phase 02.3:` are not
* recognized as boundaries. Content from an adjacent decimal phase may bleed into
* the section being annotated.
*
* Bug 3 (line ~566) — Plan-ID extraction regex `/([\w-]+?)/` excludes `.`, so
* decimal plan IDs like `02.3-01` are captured as `02` only, never match
* the planData entry, and every plan defaults to wave 1.
*/
const { test, describe, 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');
// ---------------------------------------------------------------------------
// Shared test helpers
// ---------------------------------------------------------------------------
function makePlanProject(files = {}) {
const dir = createTempProject();
fs.mkdirSync(path.join(dir, '.planning', 'phases'), { recursive: true });
for (const [rel, content] of Object.entries(files)) {
const abs = path.join(dir, rel);
fs.mkdirSync(path.dirname(abs), { recursive: true });
fs.writeFileSync(abs, content, 'utf-8');
}
return dir;
}
/** Build a minimal PLAN.md frontmatter string */
function makePlan({ phase, plan, wave, dependsOn = [] }) {
return [
'---',
`phase: "${phase}"`,
`plan: "${plan}"`,
'type: standard',
`wave: ${wave}`,
`depends_on: [${dependsOn.map(d => `"${d}"`).join(', ')}]`,
'files_modified: []',
'autonomous: true',
'must_haves:',
' truths: []',
' artifacts: []',
' key_links: []',
'---',
'',
`<objective>Plan ${plan}</objective>`,
'',
].join('\n');
}
// ---------------------------------------------------------------------------
// Bug 1 — Plans-block detection: inline summary text after the colon
// ---------------------------------------------------------------------------
describe('bug #3691 — Bug 1: Plans-block detection with inline summary', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('Plans: N plans (inline count after colon) is detected as a Plans-block', (t) => {
// Pre-fix: `Plans:\s*\n` requires bare newline — fails for "Plans: 2 plans\n"
// Post-fix: `Plans:[^\n]*\n` accepts any text after the colon
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Goal:** Set up project',
'',
'Plans: 2 plans',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'Plans-block with inline summary must be detected and written');
assert.ok(out.waves >= 1, 'at least one wave must be written');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(written.includes('Wave'), 'wave annotation must appear in ROADMAP.md');
});
test('Plans: N plans across N waves (longer inline text) is detected', (t) => {
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans: 3 plans across 2 waves',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'- [ ] 01-03-PLAN.md — Task C',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 1 }),
'.planning/phases/01-foundation/01-03-PLAN.md': makePlan({ phase: '1', plan: '01-03', wave: 2, dependsOn: ['01-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'Plans-block with "N plans across N waves" inline text must be detected');
});
test('**Plans:** (bold markdown wrapper) is detected as a Plans-block', (t) => {
// Bold wrapper: `**Plans:** 3 plans across 2 waves`
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'**Plans:** 2 plans',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'**Plans:** bold-wrapped header must be detected as a Plans-block');
});
test('bare Plans: (no inline text, legacy format) still works after fix', (t) => {
// Regression guard: the fix must not break the working case
const roadmap = [
'# Roadmap',
'',
'### Phase 1: Foundation',
'',
'Plans:',
'- [ ] 01-01-PLAN.md — Task A',
'- [ ] 01-02-PLAN.md — Task B',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/01-foundation/01-01-PLAN.md': makePlan({ phase: '1', plan: '01-01', wave: 1 }),
'.planning/phases/01-foundation/01-02-PLAN.md': makePlan({ phase: '1', plan: '01-02', wave: 2 }),
});
const result = runGsdTools('roadmap annotate-dependencies 1', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'bare Plans: (legacy format) must still be detected after the fix');
});
});
// ---------------------------------------------------------------------------
// Bug 3 — Plan-ID extraction: decimal phase IDs like 02.3-01
// ---------------------------------------------------------------------------
describe('bug #3691 — Bug 3: decimal plan IDs (e.g. 02.3-01-PLAN.md) parse correctly', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('decimal plan ID 02.3-01 is captured fully and matched to the correct wave', (t) => {
// Pre-fix: `[\w-]+?` stops at `.` → captures `02` only → planData.find misses → wave = 1 for all
// Post-fix: `[\w.-]+?` captures `02.3-01` → planData.find resolves → correct wave written
const roadmap = [
'# Roadmap',
'',
'### Phase 02.3: Surgical edit ops',
'',
'Plans: 2 plans across 2 waves',
'- [ ] 02.3-01-PLAN.md — Path resolver',
'- [ ] 02.3-02-PLAN.md — Op handlers',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 2, dependsOn: ['02.3-01'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'decimal-phase ROADMAP with inline Plans: summary must be annotated');
assert.strictEqual(out.waves, 2,
'two distinct waves must be identified (02.3-01→wave 1, 02.3-02→wave 2)');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
assert.ok(/Wave 1/.test(written), 'Wave 1 header must appear in output');
assert.ok(/Wave 2/.test(written), 'Wave 2 header must appear in output');
});
test('combined fixture: decimal phase + bold Plans: header (both bugs together)', (t) => {
// Exercises Bug 1 (bold **Plans:** header) AND Bug 3 (decimal IDs) simultaneously.
// This is the exact ROADMAP fragment from the issue report.
const roadmap = [
'# Roadmap',
'',
'## Milestone v1.2',
'',
'### Phase 02.3: Surgical edit ops',
'',
'**Plans:** 3 plans across 2 waves',
'- [ ] 02.3-01-PLAN.md — Path resolver',
'- [ ] 02.3-02-PLAN.md — Op handlers',
'- [ ] 02.3-03-PLAN.md — Tests',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-03-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-03', wave: 2, dependsOn: ['02.3-01', '02.3-02'] }),
});
const result = runGsdTools('roadmap annotate-dependencies 02.3', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true,
'combined fixture (bold Plans: + decimal IDs) must produce updated: true');
assert.strictEqual(out.waves, 2,
'wave 2 dependency must be detected from decimal plan IDs');
});
});
// ---------------------------------------------------------------------------
// Bug 2 — Phase boundary: decimal phase headings as section terminators
// ---------------------------------------------------------------------------
describe('bug #3691 — Bug 2: decimal phase heading used as section boundary', () => {
let tmpDir;
afterEach(() => cleanup(tmpDir));
test('adjacent decimal phase heading terminates current phase section', (t) => {
// Pre-fix: `/\n#{2,4}\s+Phase\s+\d/i` uses bare `\d` → doesn't match `### Phase 02.3:`
// → phaseEnd = content.length → section includes all subsequent phases → plans from
// phase 02.3 are mistakenly processed when annotating phase 02.2.
// Post-fix: `/\n#{2,4}\s+Phase\s+\d[\d.]*/i` → matches decimal headings too
//
// Setup: phase 02.2 has 2 plans at different waves (so wave annotation is written).
// phase 02.3 has 1 unannotated plan. We annotate phase 02.2 only.
// Post-fix: phase 02.3 section must remain untouched.
const roadmap = [
'# Roadmap',
'',
'### Phase 02.2: First phase',
'',
'Plans: 2 plans',
'- [ ] 02.2-01-PLAN.md — First task',
'- [ ] 02.2-02-PLAN.md — Second task',
'',
'### Phase 02.3: Surgical edit ops',
'',
'Plans: 2 plans',
'- [ ] 02.3-01-PLAN.md — Path resolver',
'- [ ] 02.3-02-PLAN.md — Op handlers',
'',
].join('\n');
tmpDir = makePlanProject({
'.planning/ROADMAP.md': roadmap,
'.planning/phases/02.2-first/02.2-01-PLAN.md': makePlan({ phase: '02.2', plan: '02.2-01', wave: 1 }),
'.planning/phases/02.2-first/02.2-02-PLAN.md': makePlan({ phase: '02.2', plan: '02.2-02', wave: 2, dependsOn: ['02.2-01'] }),
'.planning/phases/02.3-surgical-edit-ops/02.3-01-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-01', wave: 1 }),
'.planning/phases/02.3-surgical-edit-ops/02.3-02-PLAN.md': makePlan({ phase: '02.3', plan: '02.3-02', wave: 1 }),
});
// Annotate phase 02.2 only
const result = runGsdTools('roadmap annotate-dependencies 02.2', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const out = JSON.parse(result.output);
assert.strictEqual(out.updated, true, 'phase 02.2 must be annotated (2 plans across 2 waves)');
assert.strictEqual(out.waves, 2, 'phase 02.2 must show 2 waves');
const written = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
// Wave headers must appear in phase 02.2 section
const phase022Section = written.split('### Phase 02.3:')[0];
assert.ok(/\*\*Wave/.test(phase022Section),
'wave headers must be written into the phase 02.2 section');
// Phase 02.3 section must NOT contain wave headers (boundary must stop at Phase 02.3 heading)
const phase023Section = written.split('### Phase 02.3:')[1] ?? '';
assert.ok(!/\*\*Wave/.test(phase023Section),
'phase 02.3 section must not contain wave headers when only 02.2 was annotated');
});
});