From 704d7bc2a650cc52e7b131800033db8070688205 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Tue, 16 Jun 2026 11:03:47 -0400 Subject: [PATCH] fix(#1263): restore init phase requirements from flat Phase Details (#1344) * fix: resolve init phase details fallback * chore: add changeset for phase details fallback --- .changeset/humble-cats-rally.md | 5 + package-lock.json | 6 +- src/roadmap-parser.cts | 54 +++++---- tests/init.test.cjs | 136 +++++++++++++++++++++++ tests/issue-766-plugin-manifest.test.cjs | 39 ++++--- 5 files changed, 199 insertions(+), 41 deletions(-) create mode 100644 .changeset/humble-cats-rally.md diff --git a/.changeset/humble-cats-rally.md b/.changeset/humble-cats-rally.md new file mode 100644 index 000000000..5b4bd97ff --- /dev/null +++ b/.changeset/humble-cats-rally.md @@ -0,0 +1,5 @@ +--- +type: Fixed +pr: 1344 +--- +Init phase lookups now resolve active phases whose canonical details live in a flat Phase Details block outside the current milestone summary, restoring requirement coverage for plan/execute/phase-op flows. diff --git a/package-lock.json b/package-lock.json index 42e8d83f6..2826d75fa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3559,9 +3559,9 @@ } }, "node_modules/hono": { - "version": "4.12.23", - "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.23.tgz", - "integrity": "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==", + "version": "4.12.25", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.25.tgz", + "integrity": "sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==", "license": "MIT", "engines": { "node": ">=16.9.0" diff --git a/src/roadmap-parser.cts b/src/roadmap-parser.cts index 3d5fcf100..7452def93 100644 --- a/src/roadmap-parser.cts +++ b/src/roadmap-parser.cts @@ -216,6 +216,33 @@ interface RoadmapPhaseResult { section: string; } +function findRoadmapPhaseInContent(content: string, phaseNum: unknown): RoadmapPhaseResult | null { + const phasePattern = new RegExp( + `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, + 'i' + ); + const headerMatch = content.match(phasePattern); + if (!headerMatch) return null; + + const phaseName = headerMatch[1].trim(); + const headerIndex = headerMatch.index!; + const restOfContent = content.slice(headerIndex); + const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i); + const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length; + const section = content.slice(headerIndex, sectionEnd).trim(); + + const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i); + const goal = goalMatch ? goalMatch[1].trim() : null; + + return { + found: true, + phase_number: String(phaseNum), + phase_name: phaseName, + goal, + section, + }; +} + function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null { if (!phaseNum) return null; const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md'); @@ -225,31 +252,10 @@ function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseRe const roadmapRaw = platformReadSync(roadmapPath); if (roadmapRaw === null) throw new Error('missing'); const content = extractCurrentMilestone(roadmapRaw, cwd); - const phasePattern = new RegExp( - `#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`, - 'i' - ); - const headerMatch = content.match(phasePattern); - if (!headerMatch) return null; + const scopedResult = findRoadmapPhaseInContent(content, phaseNum); + if (scopedResult) return scopedResult; - const phaseName = headerMatch[1].trim(); - const headerIndex = headerMatch.index!; - const restOfContent = content.slice(headerIndex); - const nextHeaderMatch = restOfContent.match(/\n#{2,4}\s+(?:\[[^\]]+\]\s*)?Phase\s+[\w]/i); - const sectionEnd = nextHeaderMatch ? headerIndex + nextHeaderMatch.index! : content.length; - const section = content.slice(headerIndex, sectionEnd).trim(); - - const goalMatch = section.match(/\*\*Goal(?:\*\*:|\*?\*?:\*\*)\s*([^\n]+)/i); - const goal = goalMatch ? goalMatch[1].trim() : null; - - return { - found: true, - // eslint-disable-next-line @typescript-eslint/no-base-to-string - phase_number: String(phaseNum), - phase_name: phaseName, - goal, - section, - }; + return findRoadmapPhaseInContent(stripShippedMilestones(roadmapRaw), phaseNum); } catch { return null; } diff --git a/tests/init.test.cjs b/tests/init.test.cjs index cb554c2ab..e9456d36a 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -325,6 +325,142 @@ describe('init commands', () => { const output = JSON.parse(result.output); assert.strictEqual(output.phase_req_ids, null); }); + + test('init plan-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => { + fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '11-second-active-phase'), { recursive: true }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [ + '---', + 'milestone: v0.4.0', + 'current_phase: 11', + '---', + '', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap: Example', + '', + '## Milestones', + '', + '- ✅ **v0.3.0 Foundations** - Phases 1-9 (shipped 2026-01-01)', + '- 🚧 **v0.4.0 Feature Work** - Phases 10-11 (in progress)', + '', + '## Phases', + '', + '
', + '✅ v0.3.0 Foundations (Phases 1-9) - SHIPPED 2026-01-01', + '', + '- [x] **Phase 1: Bootstrap**', + '', + '
', + '', + '### 🚧 v0.4.0 Feature Work (Active)', + '', + '**Milestone Goal:** Deliver the feature set.', + '', + '- [ ] **Phase 10: First Active Phase**', + '- [ ] **Phase 11: Second Active Phase**', + '', + '### 📋 v0.5+ (Planned)', + '', + '## Phase Details', + '', + '### Phase 10: First Active Phase', + '**Goal**: Build the first piece.', + '**Requirements**: REQ-01', + '', + '### Phase 11: Second Active Phase', + '**Goal**: Build the second piece.', + '**Requirements**: REQ-02, REQ-03', + '', + '## Progress', + '', + ].join('\n')); + + const result = runGsdTools('init plan-phase 11', tmpDir); + assert.ok(result.success, `init plan-phase failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true); + assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03'); + }); + + test('init execute-phase resolves phase_req_ids from flat Phase Details after active milestone heading', () => { + seedPhase(tmpDir, '11-second-active-phase', { + '11-01-PLAN.md': '# Plan', + }); + fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), [ + '---', + 'milestone: v0.4.0', + 'current_phase: 11', + '---', + '', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap: Example', + '', + '## Phases', + '', + '### 🚧 v0.4.0 Feature Work (Active)', + '', + '- [ ] **Phase 10: First Active Phase**', + '- [ ] **Phase 11: Second Active Phase**', + '', + '### 📋 v0.5+ (Planned)', + '', + '## Phase Details', + '', + '### Phase 10: First Active Phase', + '**Goal**: Build the first piece.', + '**Requirements**: REQ-01', + '', + '### Phase 11: Second Active Phase', + '**Goal**: Build the second piece.', + '**Requirements**: REQ-02, REQ-03', + '', + ].join('\n')); + + const result = runGsdTools('init execute-phase 11', tmpDir); + assert.ok(result.success, `init execute-phase failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true); + assert.strictEqual(output.phase_req_ids, 'REQ-02, REQ-03'); + }); + + 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', + 'current_phase: 86', + '', + ].join('\n')); + fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), [ + '# Roadmap', + '', + '## Phases', + '
', + '🔄 v1.11 A06 (Phases 86-91) — IN PROGRESS', + '', + '- [ ] **Phase 86: Details Block Regression** — Parser should resolve this (DATA-01)', + '- [ ] **Phase 87: Other Work** — Later phase', + '
', + '', + '## Phase Details', + '', + '### Phase 86: Details Block Regression', + '**Goal**: Resolve phase details after collapsed milestone block', + '**Requirements**: DATA-01', + '', + '### Phase 87: Other Work', + '**Goal**: Not relevant', + '', + ].join('\n')); + + const result = runGsdTools('init phase-op 86', tmpDir); + assert.ok(result.success, `init phase-op failed: ${result.error}`); + + const output = JSON.parse(result.output); + assert.strictEqual(output.phase_found, true); + assert.strictEqual(output.phase_name, 'Details Block Regression'); + }); }); // ───────────────────────────────────────────────────────────────────────────── diff --git a/tests/issue-766-plugin-manifest.test.cjs b/tests/issue-766-plugin-manifest.test.cjs index 34cdb8a00..fbf627428 100644 --- a/tests/issue-766-plugin-manifest.test.cjs +++ b/tests/issue-766-plugin-manifest.test.cjs @@ -24,6 +24,7 @@ const ROOT = path.resolve(__dirname, '..'); const identity = require(path.join(ROOT, 'gsd-core', 'bin', 'lib', 'package-identity.cjs')); const pkg = require(path.join(ROOT, 'package.json')); const { MANAGED_HOOKS } = require(path.join(ROOT, 'hooks', 'managed-hooks-registry.cjs')); +const { cleanup } = require('./helpers.cjs'); const PLUGIN_JSON_PATH = path.join(ROOT, '.claude-plugin', 'plugin.json'); const HOOKS_JSON_PATH = path.join(ROOT, 'hooks', 'hooks.json'); @@ -235,9 +236,9 @@ describe('B: hooks/hooks.json', () => { // or changes `name` to an invalid form goes red immediately. // // C2 (OPPORTUNISTIC) — When the `claude` binary IS on PATH, also run -// `claude plugin validate . --strict` as an end-to-end smoke test. -// This tier provides defence-in-depth for schema changes Claude Code -// may introduce that the fixture hasn't yet captured. +// `claude plugin validate --strict` as an end-to-end +// smoke test. This tier provides defence-in-depth for schema changes +// Claude Code may introduce that the fixture hasn't yet captured. // describe('C: plugin.json schema validation', () => { @@ -366,19 +367,29 @@ describe('C: plugin.json schema validation', () => { })(); test( - 'C2: claude plugin validate . --strict exits 0 (opportunistic — skip when claude not on PATH)', + 'C2: claude plugin validate --strict exits 0 (opportunistic — skip when claude not on PATH)', { skip: !claudeAvailable ? 'claude binary not on PATH' : false }, () => { - const result = spawnSync('claude', ['plugin', 'validate', '.', '--strict'], { - cwd: ROOT, - encoding: 'utf-8', - timeout: 15000, - }); - assert.equal( - result.status, - 0, - `claude plugin validate . --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}` - ); + const pluginRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-plugin-validate-')); + try { + fs.mkdirSync(path.join(pluginRoot, '.claude-plugin'), { recursive: true }); + fs.copyFileSync(PLUGIN_JSON_PATH, path.join(pluginRoot, '.claude-plugin', 'plugin.json')); + fs.symlinkSync(path.join(ROOT, 'commands'), path.join(pluginRoot, 'commands'), 'dir'); + fs.symlinkSync(path.join(ROOT, 'hooks'), path.join(pluginRoot, 'hooks'), 'dir'); + + const result = spawnSync('claude', ['plugin', 'validate', pluginRoot, '--strict'], { + cwd: ROOT, + encoding: 'utf-8', + timeout: 15000, + }); + assert.equal( + result.status, + 0, + `claude plugin validate ${pluginRoot} --strict exited with ${result.status}.\nstdout: ${result.stdout}\nstderr: ${result.stderr}` + ); + } finally { + cleanup(pluginRoot); + } } ); });