Merge pull request #3112 from gsd-build/fix/3101-plan-summary-matcher-in-core-cjs-reports

fix: canonicalize plan-summary matching for suffixless summaries
This commit is contained in:
Tom Boucher
2026-05-04 23:35:34 -04:00
committed by GitHub
6 changed files with 90 additions and 9 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 3112
---
Fixes for issue #3112 were applied to keep command/workflow behavior and SDK parity aligned with current documented usage.

View File

@@ -921,6 +921,17 @@ function phaseTokenMatches(dirName, normalized) {
return false;
}
function extractCanonicalPlanId(filename) {
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
const parts = base.split('-').filter(Boolean);
const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
const phaseIdx = parts.findIndex(p => tokenRe.test(p));
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
}
return base;
}
function searchPhaseInDir(baseDir, relBase, normalized) {
try {
const dirs = readSubdirectories(baseDir, true);
@@ -941,11 +952,16 @@ function searchPhaseInDir(baseDir, relBase, normalized) {
const summaries = unsortedSummaries.sort();
const completedPlanIds = new Set(
summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
summaries.flatMap(s => {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
const incompletePlans = plans.filter(p => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
return !completedPlanIds.has(planId);
const canonical = extractCanonicalPlanId(p);
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
});
return {

View File

@@ -50,6 +50,17 @@ function describeNonCanonicalPlans(dirFiles, matchedFiles) {
);
}
function extractCanonicalPlanId(filename) {
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
const parts = base.split('-').filter(Boolean);
const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
const phaseIdx = parts.findIndex(p => tokenRe.test(p));
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
}
return base;
}
function cmdPhasesList(cwd, options, raw) {
const phasesDir = path.join(planningDir(cwd), 'phases');
const { type, phase, includeArchived } = options;
@@ -288,7 +299,11 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
// Build set of plan IDs with summaries
const completedPlanIds = new Set(
summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
summaryFiles.flatMap(s => {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
const plans = [];
@@ -327,7 +342,7 @@ function cmdPhasePlanIndex(cwd, phase, raw) {
filesModified = Array.isArray(fmFiles) ? fmFiles : [fmFiles];
}
const hasSummary = completedPlanIds.has(planId);
const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
if (!hasSummary) {
incomplete.push(planId);
}

View File

@@ -80,6 +80,17 @@ async function getPhaseFileStats(phaseDir: string): Promise<{
*
* Port of searchPhaseInDir from core.cjs lines 956-1000.
*/
function extractCanonicalPlanId(filename: string): string {
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');
const parts = base.split('-').filter(Boolean);
const tokenRe = /^\d+[A-Z]?(?:\.\d+)*$/i;
const phaseIdx = parts.findIndex((p) => tokenRe.test(p));
if (phaseIdx >= 0 && phaseIdx + 1 < parts.length && tokenRe.test(parts[phaseIdx + 1])) {
return `${parts[phaseIdx]}-${parts[phaseIdx + 1]}`;
}
return base;
}
async function searchPhaseInDir(baseDir: string, relBase: string, normalized: string): Promise<PhaseInfo | null> {
try {
const entries = await readdir(baseDir, { withFileTypes: true });
@@ -105,11 +116,16 @@ async function searchPhaseInDir(baseDir: string, relBase: string, normalized: st
const summaries = unsortedSummaries.sort();
const completedPlanIds = new Set(
summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
summaries.flatMap((s) => {
const exact = s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
const incompletePlans = plans.filter(p => {
const incompletePlans = plans.filter((p) => {
const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
return !completedPlanIds.has(planId);
const canonical = extractCanonicalPlanId(p);
return !completedPlanIds.has(planId) && !completedPlanIds.has(canonical);
});
return {
@@ -265,7 +281,11 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
// Build set of plan IDs with summaries — match the planId derivation logic
const completedPlanIds = new Set(
summaryFiles.map(s => s === 'SUMMARY.md' ? 'PLAN' : s.replace('-SUMMARY.md', ''))
summaryFiles.flatMap((s) => {
const exact = s === 'SUMMARY.md' ? 'PLAN' : s.replace('-SUMMARY.md', '');
const canonical = extractCanonicalPlanId(s);
return canonical === exact ? [exact] : [exact, canonical];
})
);
const plans: Array<Record<string, unknown>> = [];
@@ -306,7 +326,7 @@ export const phasePlanIndex: QueryHandler = async (args, projectDir, workstream)
filesModified = Array.isArray(fmFiles) ? fmFiles : [fmFiles];
}
const hasSummary = completedPlanIds.has(planId);
const hasSummary = completedPlanIds.has(planId) || completedPlanIds.has(extractCanonicalPlanId(planFile));
if (!hasSummary) {
incomplete.push(planId);
}

View File

@@ -869,6 +869,16 @@ describe('searchPhaseInDir', () => {
assert.ok(result.incomplete_plans.includes('01-02-PLAN.md'));
});
test('treats prefix summary as complete for descriptive plan filename (#3101)', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);
fs.writeFileSync(path.join(phaseDir, '01-01-auth-hardening-PLAN.md'), '# Plan 1');
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary 1');
const result = searchPhaseInDir(phasesDir, '.planning/phases', '01');
assert.strictEqual(result.incomplete_plans.length, 0);
});
test('detects research and context files', () => {
const phaseDir = path.join(phasesDir, '01-foundation');
fs.mkdirSync(phaseDir);

View File

@@ -462,6 +462,21 @@ objective: API routes
assert.deepStrictEqual(output.incomplete, ['03-02'], 'incomplete list correct');
});
test('phase-plan-index matches descriptive plan with prefix summary (#3101)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });
fs.writeFileSync(path.join(phaseDir, '03-01-auth-hardening-PLAN.md'), `---\nwave: 1\n---\n## Task 1`);
fs.writeFileSync(path.join(phaseDir, '03-01-SUMMARY.md'), `# Summary`);
const result = runGsdTools('phase-plan-index 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.plans[0].has_summary, true, 'descriptive plan should match prefix summary');
assert.deepStrictEqual(output.incomplete, [], 'plan should not be marked incomplete');
});
test('detects checkpoints (autonomous: false)', () => {
const phaseDir = path.join(tmpDir, '.planning', 'phases', '03-api');
fs.mkdirSync(phaseDir, { recursive: true });