fix(#1455): resolve project-code-prefixed roadmap headings
* fix: remove hardcoded phase project-code prefix cap
Centralize project-code prefix stripping/matching and replace fixed {1,6} caps so long codes (for example MANIFOLD-117) resolve across phase, roadmap parser, roadmap upgrade, and validate flows.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: address review feedback on prefix parsing
- allow project_code prefixes with digits and underscores while preserving milestone parsing
- use shared optional project-code prefix source in phase dir parsing
- extend regression coverage for APP1/APP_1 prefixed phases
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: resolve review nit in phase-id test comment
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix: resolve project-code-prefixed roadmap headings
Make getRoadmapPhaseInternal recover from drifted project-code-prefixed ROADMAP headings while preserving canonical bare-heading preference. Add init.phase-op and parser regressions for #1455 and guard gsd-roadmapper against emitting project_code in headings.
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: add changeset for project-code phase fix
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* chore: update roadmapper agent size baseline
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
* fix(#1455): tighten project-code prefix regex to [A-Z] start; add boundary test; document source order
Resolves blockers from review:
- Regex changed from [A-Z_][A-Z0-9_]* to [A-Z][A-Z0-9_]* so leading
underscores (_FOO-7, _-7) are never misread as project-code prefixes;
adds boundary test asserting both do NOT strip.
- Adds comment to roadmapPhaseLookupSources explaining why 3 sources are
needed (order-dependent canonical-heading preference).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Solvely-Colin <211764741+Solvely-Colin@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -16,10 +16,27 @@ function escapeRegex(value: unknown): string {
|
||||
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
// project_code values start with an uppercase letter (e.g. PROJ, APP_CODE);
|
||||
// leading underscores are not valid project codes per .planning/config.json.
|
||||
const PROJECT_CODE_PREFIX_STRIP_RE = /^[A-Z][A-Z0-9_]*-(?=\d)/;
|
||||
const PROJECT_CODE_PREFIX_STRIP_RE_I = /^[A-Z][A-Z0-9_]*-(?=\d)/i;
|
||||
const PROJECT_CODE_PREFIX_CAPTURE_RE_I = /^([A-Z][A-Z0-9_]*)-(\d.*)/i;
|
||||
const OPTIONAL_PROJECT_CODE_PREFIX_SOURCE = '(?:[A-Z][A-Z0-9_]*-)?';
|
||||
|
||||
function stripProjectCodePrefix(value: unknown, caseInsensitive = true): string {
|
||||
const input = String(value);
|
||||
const re = caseInsensitive ? PROJECT_CODE_PREFIX_STRIP_RE_I : PROJECT_CODE_PREFIX_STRIP_RE;
|
||||
return input.replace(re, '');
|
||||
}
|
||||
|
||||
function hasProjectCodePrefix(value: unknown): boolean {
|
||||
return PROJECT_CODE_PREFIX_STRIP_RE_I.test(String(value));
|
||||
}
|
||||
|
||||
function normalizePhaseName(phase: unknown): string {
|
||||
const str = String(phase);
|
||||
// Strip optional project_code prefix (e.g., 'CK-01' → '01')
|
||||
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/, '');
|
||||
const stripped = stripProjectCodePrefix(str, false);
|
||||
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
|
||||
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
||||
if (milestoneMatch) {
|
||||
@@ -42,8 +59,7 @@ function normalizePhaseName(phase: unknown): string {
|
||||
}
|
||||
|
||||
function getMilestoneFromPhaseId(phaseId: unknown): string | null {
|
||||
const str = String(phaseId);
|
||||
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const stripped = stripProjectCodePrefix(phaseId);
|
||||
const m = stripped.match(/^0*(\d+)-\d/);
|
||||
if (!m) return null;
|
||||
const major = parseInt(m[1], 10);
|
||||
@@ -52,8 +68,7 @@ function getMilestoneFromPhaseId(phaseId: unknown): string | null {
|
||||
}
|
||||
|
||||
function getPhaseDirFromPhaseId(phaseId: unknown, phaseName: string | null | undefined, projectCode: string | null | undefined): string | null {
|
||||
const str = String(phaseId);
|
||||
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const stripped = stripProjectCodePrefix(phaseId);
|
||||
const m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
|
||||
if (!m) return null;
|
||||
const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
|
||||
@@ -72,7 +87,7 @@ function getPhaseDirFromPhaseId(phaseId: unknown, phaseName: string | null | und
|
||||
* prose regardless of zero-padding on either side.
|
||||
*/
|
||||
function phaseMarkdownRegexSource(phaseNum: unknown): string {
|
||||
const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const stripped = stripProjectCodePrefix(phaseNum);
|
||||
|
||||
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
|
||||
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
|
||||
@@ -104,14 +119,14 @@ function phaseMarkdownRegexSource(phaseNum: unknown): string {
|
||||
*/
|
||||
function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null {
|
||||
const raw = String(phaseNum);
|
||||
if (!/^[A-Z]{1,6}-(?=\d)/i.test(raw)) return null;
|
||||
if (!hasProjectCodePrefix(raw)) return null;
|
||||
return escapeRegex(raw);
|
||||
}
|
||||
|
||||
function comparePhaseNum(a: unknown, b: unknown): number {
|
||||
// Strip optional project_code prefix before comparing
|
||||
const sa = String(a).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const sb = String(b).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const sa = stripProjectCodePrefix(a);
|
||||
const sb = stripProjectCodePrefix(b);
|
||||
|
||||
const milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
||||
const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
|
||||
@@ -162,7 +177,7 @@ function comparePhaseNum(a: unknown, b: unknown): number {
|
||||
* Extract the phase token from a directory name.
|
||||
*/
|
||||
function extractPhaseToken(dirName: string): string {
|
||||
const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
|
||||
const codePrefixMatch = dirName.match(PROJECT_CODE_PREFIX_CAPTURE_RE_I);
|
||||
let prefix = '';
|
||||
let rest = dirName;
|
||||
if (codePrefixMatch) {
|
||||
@@ -194,7 +209,7 @@ function extractPhaseToken(dirName: string): string {
|
||||
function phaseTokenMatches(dirName: string, normalized: string): boolean {
|
||||
const token = extractPhaseToken(dirName);
|
||||
if (token.toUpperCase() === normalized.toUpperCase()) return true;
|
||||
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const stripped = stripProjectCodePrefix(dirName);
|
||||
if (stripped !== dirName) {
|
||||
const strippedToken = extractPhaseToken(stripped);
|
||||
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
|
||||
@@ -204,6 +219,8 @@ function phaseTokenMatches(dirName: string, normalized: string): boolean {
|
||||
|
||||
export = {
|
||||
escapeRegex,
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
stripProjectCodePrefix,
|
||||
normalizePhaseName,
|
||||
getMilestoneFromPhaseId,
|
||||
getPhaseDirFromPhaseId,
|
||||
|
||||
@@ -29,7 +29,14 @@ import coreUtilsMod = require('./core-utils.cjs');
|
||||
const { toPosixPath, generateSlugInternal, readSubdirectories } = coreUtilsMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-id.cjs is an export= CommonJS module
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { escapeRegex, normalizePhaseName, phaseMarkdownRegexSource, comparePhaseNum, phaseTokenMatches } = phaseIdMod;
|
||||
const {
|
||||
escapeRegex,
|
||||
normalizePhaseName,
|
||||
phaseMarkdownRegexSource,
|
||||
comparePhaseNum,
|
||||
phaseTokenMatches,
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
} = phaseIdMod;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports -- phase-locator.cjs is an export= CommonJS module
|
||||
import phaseLocatorMod = require('./phase-locator.cjs');
|
||||
const { findPhaseInternal, getArchivedPhaseDirs } = phaseLocatorMod;
|
||||
@@ -194,7 +201,7 @@ function cmdPhaseNextDecimal(cwd: string, basePhase: string, raw: boolean): void
|
||||
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
baseExists = dirs.some((d) => phaseTokenMatches(d, normalized));
|
||||
|
||||
const dirPattern = new RegExp(`^(?:[A-Z]{1,6}-)?${escapeRegex(normalized)}\\.(\\d+)`);
|
||||
const dirPattern = new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalized)}\\.(\\d+)`);
|
||||
for (const dir of dirs) {
|
||||
const match = dir.match(dirPattern);
|
||||
if (match) decimalSet.add(parseInt(match[1], 10));
|
||||
@@ -360,7 +367,7 @@ function cmdFindPhase(cwd: string, phase: string, raw: boolean): void {
|
||||
if (!match) continue;
|
||||
|
||||
const dirMatch =
|
||||
match.match(/^(?:[A-Z]{1,6}-)(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i) ||
|
||||
match.match(new RegExp(`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+[A-Z]?(?:\\.\\d+)*)-?(.*)`, 'i')) ||
|
||||
match.match(/^(\d+[A-Z]?(?:\.\d+)*)-?(.*)/i);
|
||||
const phaseNumber = dirMatch ? dirMatch[1] : normalized;
|
||||
const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
|
||||
@@ -908,7 +915,7 @@ function cmdPhaseInsert(cwd: string, afterPhase: string, description: string, ra
|
||||
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
|
||||
const dirs = entries.filter((e) => e.isDirectory()).map((e) => e.name);
|
||||
const decimalPattern = new RegExp(
|
||||
`^(?:[A-Z]{1,6}-)?${escapeRegex(normalizedBase)}\\.(\\d+)`,
|
||||
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${escapeRegex(normalizedBase)}\\.(\\d+)`,
|
||||
);
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(decimalPattern);
|
||||
|
||||
@@ -19,7 +19,13 @@ import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdModule = require('./phase-id.cjs');
|
||||
const { escapeRegex, phaseMarkdownRegexSource } = phaseIdModule;
|
||||
const {
|
||||
escapeRegex,
|
||||
phaseMarkdownRegexSource,
|
||||
phaseMarkdownRegexSourceExact,
|
||||
stripProjectCodePrefix,
|
||||
OPTIONAL_PROJECT_CODE_PREFIX_SOURCE,
|
||||
} = phaseIdModule;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
@@ -202,9 +208,9 @@ interface RoadmapPhaseResult {
|
||||
section: string;
|
||||
}
|
||||
|
||||
function findRoadmapPhaseInContent(content: string, phaseNum: unknown): RoadmapPhaseResult | null {
|
||||
function findRoadmapPhaseInContent(content: string, phaseNum: unknown, phaseSource?: string): RoadmapPhaseResult | null {
|
||||
const phasePattern = new RegExp(
|
||||
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
|
||||
`#{2,4}\\s*(?:\\[[^\\]]+\\]\\s*)?Phase\\s+${phaseSource ?? phaseMarkdownRegexSource(phaseNum)}:\\s*([^\\n]+)`,
|
||||
'i'
|
||||
);
|
||||
const headerMatch = content.match(phasePattern);
|
||||
@@ -229,6 +235,23 @@ function findRoadmapPhaseInContent(content: string, phaseNum: unknown): RoadmapP
|
||||
};
|
||||
}
|
||||
|
||||
function roadmapPhaseLookupSources(phaseNum: unknown): string[] {
|
||||
const sources: string[] = [];
|
||||
const exactSource = phaseMarkdownRegexSourceExact(phaseNum);
|
||||
if (exactSource) sources.push(exactSource);
|
||||
|
||||
const numericSource = phaseMarkdownRegexSource(phaseNum);
|
||||
// Source order matters: the bare numeric source is tried before the
|
||||
// prefix-tolerant form so that a canonical bare heading ("Phase 117:") is
|
||||
// preferred over a drifted prefixed heading ("Phase MANIFOLD-117:") when
|
||||
// both exist in the same ROADMAP. The prefix-tolerant form is the fallback
|
||||
// that handles the drifted-only case.
|
||||
sources.push(numericSource);
|
||||
sources.push(`${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}${numericSource}`);
|
||||
|
||||
return [...new Set(sources)];
|
||||
}
|
||||
|
||||
function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseResult | null {
|
||||
if (!phaseNum) return null;
|
||||
const roadmapPath = path.join(planningDir(cwd), 'ROADMAP.md');
|
||||
@@ -238,10 +261,17 @@ function getRoadmapPhaseInternal(cwd: string, phaseNum: unknown): RoadmapPhaseRe
|
||||
const roadmapRaw = platformReadSync(roadmapPath);
|
||||
if (roadmapRaw === null) throw new Error('missing');
|
||||
const content = extractCurrentMilestone(roadmapRaw, cwd);
|
||||
const scopedResult = findRoadmapPhaseInContent(content, phaseNum);
|
||||
if (scopedResult) return scopedResult;
|
||||
const fullContent = stripShippedMilestones(roadmapRaw);
|
||||
|
||||
return findRoadmapPhaseInContent(stripShippedMilestones(roadmapRaw), phaseNum);
|
||||
for (const source of roadmapPhaseLookupSources(phaseNum)) {
|
||||
const scopedResult = findRoadmapPhaseInContent(content, phaseNum, source);
|
||||
if (scopedResult) return scopedResult;
|
||||
|
||||
const fullResult = findRoadmapPhaseInContent(fullContent, phaseNum, source);
|
||||
if (fullResult) return fullResult;
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
@@ -438,7 +468,7 @@ function getMilestonePhaseFilter(cwd: string, versionOverride?: string | null, p
|
||||
if (m2 && normalized.has(normalizePhaseIdSegments(m2[1]).toLowerCase())) return true;
|
||||
const customMatch = dirName.match(/^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)*)/);
|
||||
if (customMatch && normalized.has(customMatch[1].toLowerCase())) return true;
|
||||
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const stripped = stripProjectCodePrefix(dirName);
|
||||
if (stripped !== dirName) {
|
||||
const sm = stripped.match(numericRe);
|
||||
if (sm && normalized.has(normalizePhaseIdSegments(sm[1]).toLowerCase())) return true;
|
||||
|
||||
@@ -12,7 +12,10 @@ import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import planningWorkspace = require('./planning-workspace.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { planningDir } = planningWorkspace;
|
||||
const { stripProjectCodePrefix } = phaseIdMod;
|
||||
|
||||
// ─── Regex helpers ────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -165,7 +168,7 @@ function assignSubIndices(phaseEntries: ParsedPhaseEntry[]): Map<number, Assigne
|
||||
*/
|
||||
function extractPhaseNumFromDir(dirName: string): string | null {
|
||||
// Strip optional project_code prefix: "GSD-01-setup" → "01-setup"
|
||||
const stripped = dirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const stripped = stripProjectCodePrefix(dirName);
|
||||
// Matches: digits + optional letter + optional decimal suffix, followed by '-' or end.
|
||||
// e.g. "02.1-hotfix" → "02.1", "01-setup" → "01"
|
||||
const m = stripped.match(/^(\d+[A-Z]?(?:\.\d+)*)(?:-|$)/i);
|
||||
@@ -181,7 +184,7 @@ function extractPhaseNumFromDir(dirName: string): string | null {
|
||||
*/
|
||||
function buildNewDirName(oldDirName: string, newId: string, projectCode: string | null): string {
|
||||
// Strip existing project_code prefix
|
||||
const stripped = oldDirName.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
|
||||
const stripped = stripProjectCodePrefix(oldDirName);
|
||||
|
||||
// Extract slug: everything after "NN-" (the old phase num, including decimal like 02.1)
|
||||
const slugMatch = stripped.match(/^\d+[A-Z]?(?:\.\d+)*-(.*)/i);
|
||||
|
||||
@@ -31,14 +31,24 @@
|
||||
* - PR #156 (issue #6) — validate.ts generator that #26 extends
|
||||
*/
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import phaseIdMod = require('./phase-id.cjs');
|
||||
const { OPTIONAL_PROJECT_CODE_PREFIX_SOURCE } = phaseIdMod;
|
||||
|
||||
// ── Issue #26: regex constants (W005, W006-archived) ────────────────────────
|
||||
// Matches legacy numeric dirs (01-setup), milestone-prefixed dirs (02-01-setup),
|
||||
// deep dirs (02-04-01-deep), and project-code-prefixed variants (GSD-02-01-setup).
|
||||
export const phaseDirNameRe = /^(?:[A-Z]{1,6}-)?\d{2,}(?:-\d+)*(?:\.\d+)*-[\w-]+$/i;
|
||||
export const phaseDirNameRe = new RegExp(
|
||||
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}\\d{2,}(?:-\\d+)*(?:\\.\\d+)*-[\\w-]+$`,
|
||||
'i',
|
||||
);
|
||||
// Extracts the full phase token from a directory name, including milestone-prefixed
|
||||
// multi-segment tokens like "02-01" from "02-01-setup" or "GSD-02-01-setup".
|
||||
// Greedily captures all leading all-digit segments before the first letter-start segment.
|
||||
export const PHASE_TOKEN_FROM_DIR_RE = /^(?:[A-Z]{1,6}-)?(\d+(?:-\d+)*[A-Z]?(?:\.\d+)*)(?:-[a-z]|$)/i;
|
||||
export const PHASE_TOKEN_FROM_DIR_RE = new RegExp(
|
||||
`^${OPTIONAL_PROJECT_CODE_PREFIX_SOURCE}(\\d+(?:-\\d+)*[A-Z]?(?:\\.\\d+)*)(?:-[a-z]|$)`,
|
||||
'i',
|
||||
);
|
||||
export const MILESTONE_ARCHIVE_DIR_RE = /^v\d+.*-phases$/i;
|
||||
|
||||
// ── Issue #26: I001 canonicalization ────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user