* fix: resolve init phase details fallback * chore: add changeset for phase details fallback
This commit is contained in:
5
.changeset/humble-cats-rally.md
Normal file
5
.changeset/humble-cats-rally.md
Normal file
@@ -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.
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary>✅ v0.3.0 Foundations (Phases 1-9) - SHIPPED 2026-01-01</summary>',
|
||||
'',
|
||||
'- [x] **Phase 1: Bootstrap**',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'### 🚧 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',
|
||||
'<details open>',
|
||||
'<summary>🔄 v1.11 A06 (Phases 86-91) — IN PROGRESS</summary>',
|
||||
'',
|
||||
'- [ ] **Phase 86: Details Block Regression** — Parser should resolve this (DATA-01)',
|
||||
'- [ ] **Phase 87: Other Work** — Later phase',
|
||||
'</details>',
|
||||
'',
|
||||
'## 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');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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 <temp-plugin-root> --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);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user