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:
Tom Boucher
2026-06-20 18:45:41 -04:00
committed by GitHub
parent 3091ce007e
commit 2dedbdd11c
13 changed files with 237 additions and 29 deletions

View File

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 1456
---
Phase-aware commands now resolve project-code-prefixed ROADMAP headings such as MANIFOLD-117, while the roadmapper is instructed to keep project_code out of phase headings.

View File

@@ -226,6 +226,10 @@ current milestone number and a two-digit phase index within that milestone
active milestone context (default: `1` for new projects). This ensures downstream tools that
parse `### Phase N-NN:` headers for milestone-scoped workflows receive correctly prefixed IDs.
`project_code` is only a phase-directory prefix. Never include `project_code` in ROADMAP phase
checklist entries or detail headers. For example, even when `project_code: "PROJ"` is configured,
write `Phase 7` for `sequential` and `Phase 1-07` for `milestone-prefixed`, not `Phase PROJ-7`.
## Granularity Calibration
Read granularity from config.json. Granularity controls compression tolerance.
@@ -328,6 +332,7 @@ After roadmap creation, REQUIREMENTS.md gets updated with phase mappings:
### 1. Summary Checklist (under `## Phases`)
Use the form matching `phase_id_convention` from config.
Do not include `project_code` in checklist phase IDs.
**Sequential (default — when absent or `"sequential"`):**
@@ -348,6 +353,7 @@ Use the form matching `phase_id_convention` from config.
### 2. Detail Sections (under `## Phase Details`)
Use the header form matching `phase_id_convention` from config.
Do not include `project_code` in detail header phase IDs.
**Sequential (default):**

View File

@@ -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,

View File

@@ -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);

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 ────────────────────────────────────────

View File

@@ -103,6 +103,9 @@ describe('Drift item W005 — phaseDirNameRe: 999.X-name dirs must not trigger W
assert.ok(re.test('01-setup'), 'should accept 01-setup');
assert.ok(re.test('999-longphase'), 'should accept 999-longphase (3-digit prefix)');
assert.ok(re.test('999.1-foo'), 'should accept 999.1-foo (sub-phase)');
assert.ok(re.test('MANIFOLD-999.1-foo'), 'should accept long project-code prefixes');
assert.ok(re.test('APP1-999.1-foo'), 'should accept numeric characters in project-code prefixes');
assert.ok(re.test('APP_1-999.1-foo'), 'should accept underscore characters in project-code prefixes');
assert.ok(!re.test('1-shortname'), 'should reject single-digit prefix');
});
});
@@ -192,6 +195,9 @@ describe('Drift item W006-archived — MILESTONE_ARCHIVE_DIR_RE and PHASE_TOKEN_
assert.strictEqual(re.exec('03B-feature')?.[1], '03B');
assert.strictEqual(re.exec('999.1-foo')?.[1], '999.1');
assert.strictEqual(re.exec('CK-64-auth')?.[1], '64');
assert.strictEqual(re.exec('MANIFOLD-64-auth')?.[1], '64');
assert.strictEqual(re.exec('APP1-64-auth')?.[1], '64');
assert.strictEqual(re.exec('APP_1-64-auth')?.[1], '64');
});
});

View File

@@ -26,7 +26,7 @@
"gsd-planner.md": 49306,
"gsd-project-researcher.md": 22014,
"gsd-research-synthesizer.md": 13653,
"gsd-roadmapper.md": 21781,
"gsd-roadmapper.md": 22183,
"gsd-security-auditor.md": 6226,
"gsd-ui-auditor.md": 17159,
"gsd-ui-checker.md": 11088,

View File

@@ -981,6 +981,40 @@ describe('cmdInitPhaseOp fallback', () => {
assert.strictEqual(output.has_plans, false);
});
test('fallback resolves drifted project-code-prefixed roadmap heading by bare number (#1455)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase MANIFOLD-117: Prefixed Heading\n**Goal:** Build prefixed phase\n**Plans:** TBD\n'
);
const result = runGsdTools('init phase-op 117', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_number, '117');
assert.strictEqual(output.phase_name, 'Prefixed Heading');
assert.strictEqual(output.phase_slug, 'prefixed-heading');
});
test('fallback resolves drifted project-code-prefixed roadmap heading by prefixed ID (#1455)', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'ROADMAP.md'),
'# Roadmap\n\n### Phase MANIFOLD-117: Prefixed Heading\n**Goal:** Build prefixed phase\n**Plans:** TBD\n'
);
const result = runGsdTools('init phase-op MANIFOLD-117', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.phase_found, true);
assert.strictEqual(output.phase_dir, null);
assert.strictEqual(output.phase_number, 'MANIFOLD-117');
assert.strictEqual(output.phase_name, 'Prefixed Heading');
assert.strictEqual(output.phase_slug, 'prefixed-heading');
});
test('prefers current milestone roadmap entry over archived phase with same number', () => {
const archiveDir = path.join(
tmpDir,

View File

@@ -85,6 +85,16 @@ describe('normalizePhaseName', () => {
assert.strictEqual(phaseId.normalizePhaseName('CK-01'), '01');
assert.strictEqual(phaseId.normalizePhaseName('PROJ-3'), '03');
assert.strictEqual(phaseId.normalizePhaseName('AB-12'), '12');
assert.strictEqual(phaseId.normalizePhaseName('MANIFOLD-7'), '07');
assert.strictEqual(phaseId.normalizePhaseName('APP1-7'), '07');
assert.strictEqual(phaseId.normalizePhaseName('APP_1-7'), '07');
});
test('does not strip leading-underscore pseudo-prefix (#1455)', () => {
// Valid project_code values must start with [A-Z]; leading underscores
// (_FOO-7, _-7) are not valid codes and must not be stripped.
assert.strictEqual(phaseId.normalizePhaseName('_FOO-7'), '_FOO-7');
assert.strictEqual(phaseId.normalizePhaseName('_-7'), '_-7');
});
test('handles letter suffix (preserves original case per #1962)', () => {
@@ -104,10 +114,10 @@ describe('normalizePhaseName', () => {
});
test('custom phase IDs: project_code prefix is stripped, then numeric part is normalized', () => {
// The regex /^[A-Z]{1,6}-(?=\d)/ matches 'PROJ-' and strips it, leaving '42'
// which is then normalized to '42' (no leading zero needed for 2+ digits)
// The project-code prefix is stripped, leaving a numeric token that normalizes to '42' (no leading zero needed for 2+ digits).
assert.strictEqual(phaseId.normalizePhaseName('PROJ-42'), '42');
assert.strictEqual(phaseId.normalizePhaseName('AUTH-101'), '101');
assert.strictEqual(phaseId.normalizePhaseName('MANIFOLD-117'), '117');
});
test('custom phase IDs with non-numeric remainder pass through as-is', () => {
@@ -158,6 +168,9 @@ describe('comparePhaseNum', () => {
test('strips project_code prefix before comparing', () => {
assert.strictEqual(phaseId.comparePhaseNum('CK-01', '01'), 0);
assert.ok(phaseId.comparePhaseNum('CK-01', 'CK-02') < 0);
assert.strictEqual(phaseId.comparePhaseNum('MANIFOLD-117', '117'), 0);
assert.strictEqual(phaseId.comparePhaseNum('APP1-117', '117'), 0);
assert.strictEqual(phaseId.comparePhaseNum('APP_1-117', '117'), 0);
});
test('handles non-parseable phase IDs via localeCompare fallback', () => {
@@ -183,6 +196,9 @@ describe('extractPhaseToken', () => {
test('extracts token with project_code prefix', () => {
assert.strictEqual(phaseId.extractPhaseToken('CK-01-some-phase'), 'CK-01');
assert.strictEqual(phaseId.extractPhaseToken('PROJ-12-feature'), 'PROJ-12');
assert.strictEqual(phaseId.extractPhaseToken('MANIFOLD-117-feature'), 'MANIFOLD-117');
assert.strictEqual(phaseId.extractPhaseToken('APP1-117-feature'), 'APP1-117');
assert.strictEqual(phaseId.extractPhaseToken('APP_1-117-feature'), 'APP_1-117');
});
test('extracts glued letter-prefix phase tokens (#1324)', () => {
@@ -215,6 +231,9 @@ describe('phaseTokenMatches', () => {
test('matches with project_code prefix stripped', () => {
assert.ok(phaseId.phaseTokenMatches('CK-01-phase', '01'));
assert.ok(phaseId.phaseTokenMatches('PROJ-12-feature', '12'));
assert.ok(phaseId.phaseTokenMatches('MANIFOLD-117-feature', '117'));
assert.ok(phaseId.phaseTokenMatches('APP1-117-feature', '117'));
assert.ok(phaseId.phaseTokenMatches('APP_1-117-feature', '117'));
});
test('matches glued letter-prefix phase dirs (#1324)', () => {
@@ -281,6 +300,7 @@ describe('phaseMarkdownRegexSource', () => {
const withPrefix = phaseId.phaseMarkdownRegexSource('CK-01');
const withoutPrefix = phaseId.phaseMarkdownRegexSource('01');
assert.strictEqual(withPrefix, withoutPrefix);
assert.strictEqual(phaseId.phaseMarkdownRegexSource('MANIFOLD-117'), phaseId.phaseMarkdownRegexSource('117'));
});
test('falls back to escaped literal for unparseable input', () => {
@@ -307,6 +327,9 @@ describe('phaseMarkdownRegexSourceExact', () => {
assert.strictEqual(result, 'PROJ-42');
// The result is a valid regex source
assert.doesNotThrow(() => new RegExp(result));
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('MANIFOLD-117'), 'MANIFOLD-117');
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP1-117'), 'APP1-117');
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('APP_1-117'), 'APP_1-117');
});
test('returns null for non-prefixed IDs', () => {
@@ -350,6 +373,9 @@ describe('getMilestoneFromPhaseId', () => {
test('strips project_code prefix before parsing', () => {
assert.strictEqual(phaseId.getMilestoneFromPhaseId('CK-2-01'), 'v2.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('MANIFOLD-2-01'), 'v2.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('APP1-2-01'), 'v2.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('APP_1-2-01'), 'v2.0');
});
test('coerces non-string values', () => {
@@ -384,6 +410,9 @@ describe('getPhaseDirFromPhaseId', () => {
test('strips project_code from phaseId before parsing', () => {
const result = phaseId.getPhaseDirFromPhaseId('CK-1-2', null, null);
assert.strictEqual(result, '01-02');
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('MANIFOLD-1-2', null, null), '01-02');
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('APP1-1-2', null, null), '01-02');
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('APP_1-1-2', null, null), '01-02');
});
test('handles deep decomposition IDs (M-N-N)', () => {

View File

@@ -250,6 +250,52 @@ describe('roadmap-parser: getRoadmapPhaseInternal', () => {
assert.strictEqual(result.goal, 'Set up infrastructure');
});
test('finds drifted project-code-prefixed headings by bare number (#1455)', () => {
writeRoadmap(tmpDir, [
'## v1.0: Current',
'### Phase MANIFOLD-117: Prefixed Heading',
'**Goal:** Recover from roadmapper heading drift',
].join('\n'));
const result = getRoadmapPhaseInternal(tmpDir, '117');
assert.ok(result !== null, 'bare number lookup should tolerate a prefixed heading');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, '117');
assert.strictEqual(result.phase_name, 'Prefixed Heading');
assert.strictEqual(result.goal, 'Recover from roadmapper heading drift');
});
test('finds drifted project-code-prefixed headings by prefixed query (#1455)', () => {
writeRoadmap(tmpDir, [
'## v1.0: Current',
'### Phase MANIFOLD-117: Prefixed Heading',
'**Goal:** Exact prefixed lookup works on init resolver',
].join('\n'));
const result = getRoadmapPhaseInternal(tmpDir, 'MANIFOLD-117');
assert.ok(result !== null, 'prefixed lookup should resolve the matching prefixed heading');
assert.strictEqual(result.found, true);
assert.strictEqual(result.phase_number, 'MANIFOLD-117');
assert.strictEqual(result.phase_name, 'Prefixed Heading');
assert.strictEqual(result.goal, 'Exact prefixed lookup works on init resolver');
});
test('prefers canonical bare heading before prefixed drift fallback (#1455)', () => {
writeRoadmap(tmpDir, [
'## v1.0: Current',
'### Phase MANIFOLD-117: Prefixed Heading',
'**Goal:** Drift fallback',
'',
'### Phase 117: Bare Heading',
'**Goal:** Canonical bare',
].join('\n'));
const result = getRoadmapPhaseInternal(tmpDir, '117');
assert.ok(result !== null, 'bare lookup should resolve');
assert.strictEqual(result.phase_name, 'Bare Heading');
assert.strictEqual(result.goal, 'Canonical bare');
});
test('returns null for missing phase number', () => {
writeRoadmap(tmpDir, '### Phase 1: Foo\n**Goal:** bar\n');
const result = getRoadmapPhaseInternal(tmpDir, '99');

View File

@@ -114,4 +114,19 @@ describe('gsd-roadmapper phase_id_convention support (#1205)', () => {
'phase_identification block must document that sequential is the default/fallback'
);
});
test('phase headings and checklists must not include project_code (#1455)', () => {
const phaseIdentification = extractBlock(content, 'phase_identification');
const outputFormats = extractBlock(content, 'output_formats');
const combined = `${phaseIdentification}\n${outputFormats}`;
assert.ok(
combined.includes('project_code'),
'roadmapper instructions must explicitly mention project_code'
);
assert.ok(
/project_code[\s\S]{0,120}Never include|Do not include `project_code`/.test(combined),
'roadmapper must state that project_code is not part of phase headings/checklists'
);
});
});