fix(#2056): guard init plan-phase against foreign-prefix numeric collapse
normalizePhaseName() strips any [A-Z][A-Z0-9_]*- prefix as a project code, so a foreign-prefixed query like MEM-01 collapsed to 01 and resolved to the unrelated numeric Phase 01 via the dir/roadmap fallback. Add a guard in cmdInitPlanPhase: when the query carries a prefix that is not the configured project_code, require exact prefixed evidence (a phase dir whose token literally IS the prefixed query, or a roadmap entry literally headed with it) before accepting a match; otherwise report phase_found:false. The project_code's own prefixed phases pass through unchanged.
This commit is contained in:
5
.changeset/2056-plan-phase-foreign-prefix.md
Normal file
5
.changeset/2056-plan-phase-foreign-prefix.md
Normal file
@@ -0,0 +1,5 @@
|
||||
---
|
||||
type: Fixed
|
||||
pr: 0
|
||||
---
|
||||
**`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)
|
||||
52
src/init.cts
52
src/init.cts
@@ -73,7 +73,7 @@ const {
|
||||
extractCurrentMilestone,
|
||||
} = roadmapParser;
|
||||
const { pathExistsInternal, generateSlugInternal, toPosixPath } = coreUtils;
|
||||
const { normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = phaseId;
|
||||
const { escapeRegex, normalizePhaseName, phaseTokenMatches, stripProjectCodePrefix } = 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;
|
||||
|
||||
Reference in New Issue
Block a user