Merge pull request #2105 from open-gsd/fix/2056-plan-phase-foreign-prefix

fix(#2056): guard init plan-phase against foreign-prefix numeric collapse
This commit is contained in:
Tom Boucher
2026-07-10 12:35:13 -04:00
committed by GitHub
3 changed files with 142 additions and 2 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 2105
---
**`init plan-phase` no longer collapses foreign-prefixed task/workstream IDs into numeric phases** — a query like `MEM-01` (where `MEM` is not the configured `project_code`) used to have its prefix stripped and resolve to the unrelated numeric Phase 01; it now reports `phase_found: false` unless a phase directory or roadmap entry literally carries that prefix. The configured `project_code`'s own prefixed phases (e.g. `LKML-01` under `project_code: LKML`) continue to resolve as before. (#2056)

View File

@@ -73,7 +73,7 @@ const {
extractCurrentMilestone,
} = roadmapParser;
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseId;
const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix, PHASE_NUMBER_TOKEN_SOURCE } = phaseId;
const { pruneOrphanedWorktrees } = worktreeSafety;
const {
@@ -96,6 +96,44 @@ void stripShippedMilestones;
// Accept all bold/colon variants of the Requirements header (#2769)
const REQUIREMENTS_HEADER_RE = /^\*\*Requirements:?\*\*[^\S\n]*:?[^\S\n]*([^\n]*)$/m;
// #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project
// code (e.g. 'MEM-01' → '01'), so a foreign-prefixed workstream/task id would
// collapse to its numeric suffix and resolve to an unrelated numeric phase via
// the dir/roadmap fallback. init plan-phase must require EXACT prefixed
// evidence — a phase dir whose token literally IS the prefixed query, or a
// roadmap entry literally headed with it — before accepting such a match. The
// configured project_code's own prefix (e.g. LKML-01 under project_code: LKML)
// is never foreign and always passes through.
function parsePhasePrefix(phase: unknown): string | null {
const match = String(phase).match(/^([A-Z][A-Z0-9_]*)-(?=\d)/i);
return match ? match[1] : null;
}
function isForeignPrefixedPhaseQuery(phase: unknown, projectCode: unknown): boolean {
const prefix = parsePhasePrefix(phase);
if (!prefix) return false;
const configured = typeof projectCode === 'string' ? projectCode.trim() : '';
return !configured || prefix.toUpperCase() !== configured.toUpperCase();
}
function phaseInfoMatchesExactPrefix(
phaseInfo: Record<string, unknown> | null,
phase: string,
): boolean {
const num = phaseInfo?.['phase_number'];
const numStr = typeof num === 'string' ? num : (typeof num === 'number' ? String(num) : '');
return numStr.toUpperCase() === phase.toUpperCase();
}
function roadmapPhaseMatchesExactPrefix(
roadmapPhase: Record<string, unknown> | null,
phase: string,
): boolean {
const sectionRaw = roadmapPhase?.['section'];
const section = typeof sectionRaw === 'string' ? sectionRaw : '';
return new RegExp(`^#{2,4}\\s*Phase\\s+${escapeRegex(phase)}(?:\\b|\\s|:)`, 'i').test(section);
}
function listPhaseSummaryFiles(phaseDir: string): string[] {
return (scanPhasePlans(phaseDir) as unknown as Record<string, string[]>)['summaryFiles'];
}
@@ -430,9 +468,19 @@ function cmdInitPlanPhase(
}
const config = loadConfig(cwd);
const foreignPrefixedPhase = isForeignPrefixedPhaseQuery(phase, config.project_code);
let phaseInfo = findPhaseInternal(cwd, phase) as unknown as Record<string, unknown> | null;
// #2056: a foreign-prefixed query must only match a phase whose token literally
// IS the prefixed query (e.g. a real 'MEM-01-*' dir); otherwise the numeric
// fallback ('MEM-01' → '01') would resolve to the unrelated numeric phase.
if (foreignPrefixedPhase && !phaseInfoMatchesExactPrefix(phaseInfo, phase)) {
phaseInfo = null;
}
const roadmapPhase = getRoadmapPhaseInternal(cwd, phase) as unknown as Record<string, unknown> | null;
let roadmapPhase = getRoadmapPhaseInternal(cwd, phase) as unknown as Record<string, unknown> | null;
if (foreignPrefixedPhase && !roadmapPhaseMatchesExactPrefix(roadmapPhase, phase)) {
roadmapPhase = null;
}
if (phaseInfo?.['archived'] && roadmapPhase?.['found']) {
phaseInfo = null;

View File

@@ -90,6 +90,93 @@ describe('init commands', () => {
assert.strictEqual(output.uat_path, '.planning/phases/03-api/03-UAT.md');
});
// #2056: normalizePhaseName() strips ANY [A-Z][A-Z0-9_]*- prefix as a project
// code, so a foreign-prefixed workstream/task id like "MEM-01" collapsed to
// "01" and resolved to the unrelated numeric Phase 01. init plan-phase must
// require exact prefixed evidence (a phase dir/roadmap entry literally
// carrying the foreign prefix) before accepting a numeric-fallback match.
test('#2056 — init plan-phase does not collapse foreign-prefixed task IDs into numeric phases', () => {
seedPhase(tmpDir, '01-stable-baseline-on-main', {
'01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase 1: Stable Baseline On Main\n**Goal:** Establish baseline\n**Plans:** 1 plan\n',
);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init plan-phase MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false, 'MEM-01 must NOT resolve to numeric Phase 01');
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_number, null);
});
// #2056 companion: the guard must not reject the configured project_code's
// OWN prefixed phases — LKML-01 (project_code = LKML) must still resolve.
test('#2056 — init plan-phase still resolves configured project-code-prefixed phases', () => {
seedPhase(tmpDir, 'LKML-01-stable-baseline-on-main', {
'LKML-01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init plan-phase LKML-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'LKML-01 (own project code) must resolve');
assert.strictEqual(output.phase_dir, '.planning/phases/LKML-01-stable-baseline-on-main');
assert.strictEqual(output.phase_number, 'LKML-01');
});
// #2056 accept-branch: a foreign-prefixed query MUST still resolve when a phase
// directory literally carries that prefix (e.g. a real MEM-01-* workstream
// phase). Proves the guard accepts exact-prefixed evidence, not just rejects.
test('#2056 — init plan-phase resolves a real foreign-prefixed phase dir', () => {
seedPhase(tmpDir, 'MEM-01-integration', {
'MEM-01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({ project_code: 'LKML' }, null, 2),
);
const result = runGsdTools('init plan-phase MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true, 'a real MEM-01-* dir must resolve under its own prefix');
assert.strictEqual(output.phase_dir, '.planning/phases/MEM-01-integration');
assert.strictEqual(output.phase_number, 'MEM-01');
});
// #2056 edge: with NO project_code configured, any prefixed query is foreign
// and must NOT collapse to a numeric phase. Pins the strict default.
test('#2056 — init plan-phase treats prefixed queries as foreign when no project_code is configured', () => {
seedPhase(tmpDir, '01-stable-baseline-on-main', {
'01-CONTEXT.md': '# Phase Context',
});
fs.writeFileSync(
path.join(tmpDir, '.planning', 'config.json'),
JSON.stringify({}, null, 2),
);
const result = runGsdTools('init plan-phase MEM-01', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, false, 'with no project_code, MEM-01 must not resolve to numeric Phase 01');
assert.strictEqual(output.phase_number, null);
});
test('init plan-phase exposes text_mode from config (defaults false)', () => {
const result = runGsdTools('init plan-phase 03', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);