refactor(#865): extract pure phase-id helpers from core.cts into phase-id.cts (#868)

ADR-857 rollout phase 2a — the first cut of the core.cts decomposition.
Move the 9 pure phase-id parsing/matching helpers (escapeRegex,
normalizePhaseName, comparePhaseNum, extractPhaseToken, phaseTokenMatches,
phaseMarkdownRegexSource/Exact, getMilestoneFromPhaseId, getPhaseDirFromPhaseId)
out of core.cts into a new leaf module src/phase-id.cts. core.cts re-exports
them (behavior-preserving); its internal callers resolve the destructured
bindings.

Cycle-safe by design: phase-id depends on nothing in core, so re-export creates
no circular require (the property that made phase-1 io.cts clean). This is the
leaf-first ordering — it unblocks the roadmap-parser extraction (2b), which
imports phaseMarkdownRegexSource.

New-CLI-module checklist: .gitignore, eslint.config.mjs ignores, INVENTORY.md
count 91->92 + row, INVENTORY-MANIFEST.json, ARCHITECTURE.md row, CONTEXT.md
"Phase Id Module" glossary entry. Adds tests/phase-id.test.cjs (63 behavioral
tests incl. shim-identity + adversarial inputs).

Gates: lint, code-review, security-review, codex adversarial-review (all 0
findings), and gsd-test-both (14814 pass on Mac + Linux Docker, 0 fail).

Closes #865

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-08 11:12:08 -04:00
committed by GitHub
parent 3697e6768f
commit 2988a21c46
9 changed files with 664 additions and 193 deletions

1
.gitignore vendored
View File

@@ -128,6 +128,7 @@ build/
/gsd-core/bin/lib/command-routing-hub.cjs
/gsd-core/bin/lib/core.cjs
/gsd-core/bin/lib/io.cjs
/gsd-core/bin/lib/phase-id.cjs
/gsd-core/bin/lib/drift.cjs
/gsd-core/bin/lib/cjs-command-router-adapter.cjs
/gsd-core/bin/lib/phase-command-router.cjs

View File

@@ -14,6 +14,9 @@ Module owning `milestone complete` (archive roadmap/requirements/phases, build M
### Dispatch Pipeline Module
Module that composes Dispatch Policy Module, Query Execution Policy Module, and per-stage handlers (input-validation, plan, execution, result-builder, formatting, error-mapping, observability) into the end-to-end pipeline that produces a `QueryDispatchResult`. The SDK-era pipeline collapsed onto the Command Routing Hub per ADR-0174; current dispatch seam: `gsd-core/bin/lib/command-routing-hub.cjs` (see Command Routing Hub below).
### Phase Id Module
Module owning the pure phase-id parsing and matching helpers: phase-name normalization, phase-token extraction/matching, milestone- and phase-dir id parsing, and phase-markdown regex builders (`escapeRegex`, `normalizePhaseName`, `comparePhaseNum`, `extractPhaseToken`, `phaseTokenMatches`, `phaseMarkdownRegexSource`/`phaseMarkdownRegexSourceExact`, `getMilestoneFromPhaseId`, `getPhaseDirFromPhaseId`). Pure string/regex — no I/O, no config, no other core dependency. Extracted from the Core module per ADR-857 rollout phase 2a (#865) as the cycle-free leaf that unblocks the roadmap-parser and phase-locator extractions; `core.cjs` re-exports the helpers for back-compat. Source of truth: `gsd-core/bin/lib/phase-id.cjs` (generated from `src/phase-id.cts`).
### Phase Lifecycle Module
Module owning phase create, rename, complete, remove, list, and plan-index operations, plus phase-dir prefix validation, STATE.md staleness detection, and auto-prune behaviour. Entry point: `gsd-core/bin/lib/phase.cjs` (CJS surface). Typed phase events: `GSDPhaseStartEvent`, `GSDPhaseStepStartEvent`, `GSDPhaseStepCompleteEvent`, `GSDPhaseCompleteEvent`. (The SDK native-query surface, the `types.ts` event definitions, `phase-runner.ts`, and `phase-prompt.ts` were retired with the SDK package per ADR-0174.)

View File

@@ -342,8 +342,9 @@ Node.js CLI utility (`gsd-tools.cjs`) with domain modules split across `gsd-core
| Module | Responsibility |
| ---------------------- | --------------------------------------------------------------------------------------------------- |
| `core.cjs` | Shared utilities; compatibility re-exports for planning and I/O (`io.cjs`) helpers |
| `core.cjs` | Shared utilities; compatibility re-exports for planning, I/O (`io.cjs`), and phase-id helpers |
| `io.cjs` | CLI I/O primitives — output/error emission, JSON-error mode, large-payload temp-file spillover |
| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, regex builders (extracted from `core.cjs`, ADR-857) |
| `planning-workspace.cjs` | Planning seam (`planningDir`, `planningPaths`, active workstream routing, `.planning/.lock`) |
| `state.cjs` | STATE.md parsing, updating, progression, metrics |
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |

View File

@@ -310,6 +310,7 @@
"package-identity.cjs",
"package-legitimacy.cjs",
"phase-command-router.cjs",
"phase-id.cjs",
"phase-lifecycle.cjs",
"phase.cjs",
"phases-command-router.cjs",

View File

@@ -370,7 +370,7 @@ The `gsd-planner` agent is decomposed into a core agent plus reference modules t
---
## CLI Modules (91 shipped)
## CLI Modules (92 shipped)
Full listing: `gsd-core/bin/lib/*.cjs`.
@@ -421,6 +421,7 @@ Full listing: `gsd-core/bin/lib/*.cjs`.
| `package-identity.cjs` | Generated single source for GSD's published-package coordinates (npm name, bin name, repo slug, changelog URL, manual-install command), derived from package.json; read by the update worker, `check-latest-version`, and installer (#498) |
| `package-legitimacy.cjs` | Registry-API package legitimacy verdicts (OK/SUS/SLOP) from npm/PyPI/crates, slopcheck optional |
| `phase-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phase` |
| `phase-id.cjs` | Pure phase-id parsing/matching helpers — normalize, token match, milestone/phase-dir id parsing, phase-markdown regex builders (extracted from `core.cjs`, ADR-857) |
| `phase-lifecycle.cjs` | Pure-computation phase lifecycle helpers extracted from the phase-lifecycle SDK handler |
| `phase.cjs` | Phase directory operations, decimal numbering, plan indexing |
| `phases-command-router.cjs` | Thin CJS subcommand router adapter for `gsd-tools phases` |

View File

@@ -90,6 +90,7 @@ export default tseslint.config(
'gsd-core/bin/lib/command-routing-hub.cjs',
'gsd-core/bin/lib/core.cjs',
'gsd-core/bin/lib/io.cjs',
'gsd-core/bin/lib/phase-id.cjs',
'gsd-core/bin/lib/drift.cjs',
'gsd-core/bin/lib/cjs-command-router-adapter.cjs',
'gsd-core/bin/lib/phase-command-router.cjs',

View File

@@ -14,6 +14,9 @@ import { execGit, platformWriteSync, platformReadSync } from './shell-command-pr
import ioModule = require('./io.cjs');
const { output, error, ERROR_REASON, setJsonErrorMode, getJsonErrorMode, GSD_TEMP_DIR, reapStaleTempFiles } = ioModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import phaseIdModule = require('./phase-id.cjs');
const { escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId, phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum, extractPhaseToken, phaseTokenMatches } = phaseIdModule;
// eslint-disable-next-line @typescript-eslint/no-require-imports
import modelProfiles = require('./model-profiles.cjs');
const { MODEL_PROFILES, AGENT_TO_PHASE_TYPE, VALID_PHASE_TYPES: _VALID_PHASE_TYPES, AGENT_DEFAULT_TIERS, VALID_AGENT_TIERS, nextTier } = modelProfiles;
import { MODEL_ALIAS_MAP, RUNTIME_PROFILE_MAP, KNOWN_RUNTIMES, RUNTIMES_WITH_REASONING_EFFORT, RUNTIMES_WITH_FAST_MODE, PROVIDER_PRESETS, KNOWN_PROVIDERS } from './model-catalog.cjs';
@@ -518,197 +521,11 @@ function pruneOrphanedWorktrees(repoRoot: string): string[] {
// ─── Planning workspace (pathing + active workstream + lock) moved to planning-workspace.cjs ───
// ─── Phase utilities ──────────────────────────────────────────────────────────
function escapeRegex(value: unknown): string {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
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)/, '');
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
if (milestoneMatch) {
const major = milestoneMatch[1].padStart(2, '0');
const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0'));
const suffix = milestoneMatch[3] || '';
return `${major}-${subSegments.join('-')}${suffix}`;
}
// Standard numeric phases: 1, 01, 12A, 12.1
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
if (match) {
const padded = match[1].padStart(2, '0');
// Preserve original case of letter suffix (#1962).
const letter = match[2] || '';
const decimal = match[3] || '';
return padded + letter + decimal;
}
// Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
return str;
}
function getMilestoneFromPhaseId(phaseId: unknown): string | null {
const str = String(phaseId);
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
const m = stripped.match(/^0*(\d+)-\d/);
if (!m) return null;
const major = parseInt(m[1], 10);
if (major === 0 || major === 999) return null;
return `v${major}.0`;
}
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 m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
if (!m) return null;
const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
const sub = subParts.join('-');
const slug = phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: '';
const parts = [milestone, sub, slug].filter(Boolean);
const base = parts.join('-');
return projectCode ? `${projectCode}-${base}` : base;
}
/**
* Render a regex source fragment matching a phase number against ROADMAP/STATE
* prose regardless of zero-padding on either side.
*/
function phaseMarkdownRegexSource(phaseNum: unknown): string {
const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
if (milestoneSegments && milestoneSegments[2]) {
const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0';
const subParts = milestoneSegments[2].slice(1).split('-');
const subFragments = subParts.map(s => {
const unpadded = s.replace(/^0+/, '') || '0';
return `0*${escapeRegex(unpadded)}`;
});
const suffix = milestoneSegments[3] || '';
const suffixFragment = suffix ? escapeRegex(suffix) : '';
return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
}
// Plain numeric phase: 1, 01, 12A, 12.1
const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
if (!match) return escapeRegex(phaseNum);
const integer = match[1].replace(/^0+/, '') || '0';
const letter = match[2] ? escapeRegex(match[2]) : '';
const decimal = match[3] ? escapeRegex(match[3]) : '';
return `0*${escapeRegex(integer)}${letter}${decimal}`;
}
/**
* #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`,
* return the exact-escaped form.
*/
function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null {
const raw = String(phaseNum);
if (!/^[A-Z]{1,6}-(?=\d)/i.test(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 milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
if (milestoneA && milestoneB) {
const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))];
const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))];
const maxSegs = Math.max(segsA.length, segsB.length);
for (let i = 0; i < maxSegs; i++) {
const av = segsA[i] !== undefined ? segsA[i] : 0;
const bv = segsB[i] !== undefined ? segsB[i] : 0;
if (av !== bv) return av - bv;
}
const sufA = milestoneA[3] || '';
const sufB = milestoneB[3] || '';
if (sufA !== sufB) return sufA < sufB ? -1 : 1;
return 0;
}
if (milestoneA || milestoneB) return String(a).localeCompare(String(b));
const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
if (!pa || !pb) return String(a).localeCompare(String(b));
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
if (intDiff !== 0) return intDiff;
const la = (pa[2] || '').toUpperCase();
const lb = (pb[2] || '').toUpperCase();
if (la !== lb) {
if (!la) return -1;
if (!lb) return 1;
return la < lb ? -1 : 1;
}
const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
const maxLen = Math.max(aDecParts.length, bDecParts.length);
if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
for (let i = 0; i < maxLen; i++) {
const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
if (av !== bv) return av - bv;
}
return 0;
}
/**
* Extract the phase token from a directory name.
*/
function extractPhaseToken(dirName: string): string {
const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
let prefix = '';
let rest = dirName;
if (codePrefixMatch) {
prefix = codePrefixMatch[1] + '-';
rest = codePrefixMatch[2];
}
const segments = rest.split('-');
const tokenSegments: string[] = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (/^\d/.test(seg)) {
tokenSegments.push(seg);
} else {
break;
}
}
if (tokenSegments.length === 0) {
return dirName;
}
return prefix + tokenSegments.join('-');
}
/**
* Check if a directory name's phase token matches the normalized phase exactly.
*/
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, '');
if (stripped !== dirName) {
const strippedToken = extractPhaseToken(stripped);
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
}
return false;
}
// ─── Phase utilities (pure helpers re-exported from phase-id.cjs) ─────────────
// escapeRegex, normalizePhaseName, getMilestoneFromPhaseId, getPhaseDirFromPhaseId,
// phaseMarkdownRegexSource, phaseMarkdownRegexSourceExact, comparePhaseNum,
// extractPhaseToken, phaseTokenMatches
// — all imported via `phaseIdModule` above; internal callers use the destructured bindings.
function extractCanonicalPlanId(filename: string): string {
const base = filename.replace(/-PLAN\.md$/i, '').replace(/-SUMMARY\.md$/i, '').replace(/\.md$/i, '');

217
src/phase-id.cts Normal file
View File

@@ -0,0 +1,217 @@
/**
* Pure phase-id parsing/matching helpers — normalize, token match,
* milestone/phase-dir id parsing, phase-markdown regex builders.
*
* Extracted from core.cts (ADR-857 rollout phase 2a / issue #865).
* The hand-written bodies are preserved byte-for-behaviour; only the module
* boundary moved. core.cts re-exports every symbol here under its own
* `export =` object so existing consumers are unaffected.
*
* New imports should pull phase-id helpers from phase-id.cjs directly.
*
* Dependencies: none (pure string/regex, no Node built-ins required).
*/
// ─── Phase-id helpers ─────────────────────────────────────────────────────────
function escapeRegex(value: unknown): string {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
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)/, '');
// Milestone-prefixed phase IDs: M-NN or M-N-N (deep decomposition).
const milestoneMatch = stripped.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
if (milestoneMatch) {
const major = milestoneMatch[1].padStart(2, '0');
const subSegments = milestoneMatch[2].slice(1).split('-').map(s => s.padStart(2, '0'));
const suffix = milestoneMatch[3] || '';
return `${major}-${subSegments.join('-')}${suffix}`;
}
// Standard numeric phases: 1, 01, 12A, 12.1
const match = stripped.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
if (match) {
const padded = match[1].padStart(2, '0');
// Preserve original case of letter suffix (#1962).
const letter = match[2] || '';
const decimal = match[3] || '';
return padded + letter + decimal;
}
// Custom phase IDs (e.g. PROJ-42, AUTH-101): return as-is
return str;
}
function getMilestoneFromPhaseId(phaseId: unknown): string | null {
const str = String(phaseId);
const stripped = str.replace(/^[A-Z]{1,6}-(?=\d)/i, '');
const m = stripped.match(/^0*(\d+)-\d/);
if (!m) return null;
const major = parseInt(m[1], 10);
if (major === 0 || major === 999) return null;
return `v${major}.0`;
}
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 m = stripped.match(/^0*(\d+)-(0*(\d+(?:-\d+)*))$/);
if (!m) return null;
const milestone = String(parseInt(m[1], 10)).padStart(2, '0');
const subParts = m[2].split('-').map(p => String(parseInt(p, 10)).padStart(2, '0'));
const sub = subParts.join('-');
const slug = phaseName
? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
: '';
const parts = [milestone, sub, slug].filter(Boolean);
const base = parts.join('-');
return projectCode ? `${projectCode}-${base}` : base;
}
/**
* Render a regex source fragment matching a phase number against ROADMAP/STATE
* prose regardless of zero-padding on either side.
*/
function phaseMarkdownRegexSource(phaseNum: unknown): string {
const stripped = String(phaseNum).replace(/^[A-Z]{1,6}-(?=\d)/i, '');
// Milestone-prefixed IDs: M-NN or M-N-N (deep).
const milestoneSegments = stripped.match(/^(\d+)((?:-\d+)*)([A-Z]?(?:\.\d+)*)$/i);
if (milestoneSegments && milestoneSegments[2]) {
const majorUnpadded = milestoneSegments[1].replace(/^0+/, '') || '0';
const subParts = milestoneSegments[2].slice(1).split('-');
const subFragments = subParts.map(s => {
const unpadded = s.replace(/^0+/, '') || '0';
return `0*${escapeRegex(unpadded)}`;
});
const suffix = milestoneSegments[3] || '';
const suffixFragment = suffix ? escapeRegex(suffix) : '';
return `0*${escapeRegex(majorUnpadded)}-${subFragments.join('-')}${suffixFragment}`;
}
// Plain numeric phase: 1, 01, 12A, 12.1
const match = stripped.match(/^0*(\d+)([A-Z])?((?:\.\d+)*)$/i);
if (!match) return escapeRegex(phaseNum);
const integer = match[1].replace(/^0+/, '') || '0';
const letter = match[2] ? escapeRegex(match[2]) : '';
const decimal = match[3] ? escapeRegex(match[3]) : '';
return `0*${escapeRegex(integer)}${letter}${decimal}`;
}
/**
* #3599: when the caller passed a project-code-prefixed ID like `PROJ-42`,
* return the exact-escaped form.
*/
function phaseMarkdownRegexSourceExact(phaseNum: unknown): string | null {
const raw = String(phaseNum);
if (!/^[A-Z]{1,6}-(?=\d)/i.test(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 milestoneA = sa.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
const milestoneB = sb.match(/^(\d+)((?:-\d+)+)([A-Z]?(?:\.\d+)*)$/i);
if (milestoneA && milestoneB) {
const segsA = [parseInt(milestoneA[1], 10), ...milestoneA[2].slice(1).split('-').map(s => parseInt(s, 10))];
const segsB = [parseInt(milestoneB[1], 10), ...milestoneB[2].slice(1).split('-').map(s => parseInt(s, 10))];
const maxSegs = Math.max(segsA.length, segsB.length);
for (let i = 0; i < maxSegs; i++) {
const av = segsA[i] !== undefined ? segsA[i] : 0;
const bv = segsB[i] !== undefined ? segsB[i] : 0;
if (av !== bv) return av - bv;
}
const sufA = milestoneA[3] || '';
const sufB = milestoneB[3] || '';
if (sufA !== sufB) return sufA < sufB ? -1 : 1;
return 0;
}
if (milestoneA || milestoneB) return String(a).localeCompare(String(b));
const pa = sa.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
const pb = sb.match(/^(\d+)([A-Z])?((?:\.\d+)*)/i);
if (!pa || !pb) return String(a).localeCompare(String(b));
const intDiff = parseInt(pa[1], 10) - parseInt(pb[1], 10);
if (intDiff !== 0) return intDiff;
const la = (pa[2] || '').toUpperCase();
const lb = (pb[2] || '').toUpperCase();
if (la !== lb) {
if (!la) return -1;
if (!lb) return 1;
return la < lb ? -1 : 1;
}
const aDecParts = pa[3] ? pa[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
const bDecParts = pb[3] ? pb[3].slice(1).split('.').map(p => parseInt(p, 10)) : [];
const maxLen = Math.max(aDecParts.length, bDecParts.length);
if (aDecParts.length === 0 && bDecParts.length > 0) return -1;
if (bDecParts.length === 0 && aDecParts.length > 0) return 1;
for (let i = 0; i < maxLen; i++) {
const av = Number.isFinite(aDecParts[i]) ? aDecParts[i] : 0;
const bv = Number.isFinite(bDecParts[i]) ? bDecParts[i] : 0;
if (av !== bv) return av - bv;
}
return 0;
}
/**
* Extract the phase token from a directory name.
*/
function extractPhaseToken(dirName: string): string {
const codePrefixMatch = dirName.match(/^([A-Z]{1,6})-(\d.*)/i);
let prefix = '';
let rest = dirName;
if (codePrefixMatch) {
prefix = codePrefixMatch[1] + '-';
rest = codePrefixMatch[2];
}
const segments = rest.split('-');
const tokenSegments: string[] = [];
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (/^\d/.test(seg)) {
tokenSegments.push(seg);
} else {
break;
}
}
if (tokenSegments.length === 0) {
return dirName;
}
return prefix + tokenSegments.join('-');
}
/**
* Check if a directory name's phase token matches the normalized phase exactly.
*/
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, '');
if (stripped !== dirName) {
const strippedToken = extractPhaseToken(stripped);
if (strippedToken.toUpperCase() === normalized.toUpperCase()) return true;
}
return false;
}
export = {
escapeRegex,
normalizePhaseName,
getMilestoneFromPhaseId,
getPhaseDirFromPhaseId,
phaseMarkdownRegexSource,
phaseMarkdownRegexSourceExact,
comparePhaseNum,
extractPhaseToken,
phaseTokenMatches,
};

429
tests/phase-id.test.cjs Normal file
View File

@@ -0,0 +1,429 @@
/**
* Tests for src/phase-id.cts (compiled to gsd-core/bin/lib/phase-id.cjs).
*
* Verifies behavioural contracts of the extracted pure phase-id helpers:
* - escapeRegex
* - normalizePhaseName
* - comparePhaseNum
* - extractPhaseToken
* - phaseTokenMatches
* - phaseMarkdownRegexSource
* - phaseMarkdownRegexSourceExact
* - getMilestoneFromPhaseId
* - getPhaseDirFromPhaseId
* - core.cjs re-export shims resolve to the exact same functions (single instance)
*
* ADR-857 rollout phase 2a / issue #865.
*/
'use strict';
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const phaseId = require('../gsd-core/bin/lib/phase-id.cjs');
const core = require('../gsd-core/bin/lib/core.cjs');
// ─── escapeRegex ─────────────────────────────────────────────────────────────
describe('escapeRegex', () => {
test('escapes all regex special characters', () => {
assert.strictEqual(phaseId.escapeRegex('.'), '\\.');
assert.strictEqual(phaseId.escapeRegex('*'), '\\*');
assert.strictEqual(phaseId.escapeRegex('+'), '\\+');
assert.strictEqual(phaseId.escapeRegex('?'), '\\?');
assert.strictEqual(phaseId.escapeRegex('^'), '\\^');
assert.strictEqual(phaseId.escapeRegex('$'), '\\$');
assert.strictEqual(phaseId.escapeRegex('{'), '\\{');
assert.strictEqual(phaseId.escapeRegex('}'), '\\}');
assert.strictEqual(phaseId.escapeRegex('('), '\\(');
assert.strictEqual(phaseId.escapeRegex(')'), '\\)');
assert.strictEqual(phaseId.escapeRegex('|'), '\\|');
assert.strictEqual(phaseId.escapeRegex('['), '\\[');
assert.strictEqual(phaseId.escapeRegex(']'), '\\]');
assert.strictEqual(phaseId.escapeRegex('\\'), '\\\\');
});
test('leaves alphanumeric and hyphen characters unescaped', () => {
assert.strictEqual(phaseId.escapeRegex('abc'), 'abc');
assert.strictEqual(phaseId.escapeRegex('01-02'), '01-02');
assert.strictEqual(phaseId.escapeRegex('v1.0'), 'v1\\.0');
});
test('coerces non-string values via String()', () => {
assert.strictEqual(phaseId.escapeRegex(42), '42');
assert.strictEqual(phaseId.escapeRegex(null), 'null');
assert.strictEqual(phaseId.escapeRegex(undefined), 'undefined');
});
test('adversarial: path-traversal-like inputs are treated as literals', () => {
const result = phaseId.escapeRegex('../../../etc/passwd');
// The dots get escaped; slashes and alphanumeric pass through unchanged
assert.strictEqual(result, '\\.\\./\\.\\./\\.\\./etc/passwd');
// The result forms a valid regex (no throws)
assert.doesNotThrow(() => new RegExp(result));
});
test('unicode passthrough', () => {
assert.strictEqual(phaseId.escapeRegex('Phase Name'), 'Phase Name');
assert.strictEqual(phaseId.escapeRegex('中文'), '中文');
});
});
// ─── normalizePhaseName ───────────────────────────────────────────────────────
describe('normalizePhaseName', () => {
test('zero-pads single-digit phase', () => {
assert.strictEqual(phaseId.normalizePhaseName('1'), '01');
assert.strictEqual(phaseId.normalizePhaseName('3'), '03');
});
test('leaves two-digit phase unchanged', () => {
assert.strictEqual(phaseId.normalizePhaseName('12'), '12');
});
test('strips project_code prefix before normalizing', () => {
assert.strictEqual(phaseId.normalizePhaseName('CK-01'), '01');
assert.strictEqual(phaseId.normalizePhaseName('PROJ-3'), '03');
assert.strictEqual(phaseId.normalizePhaseName('AB-12'), '12');
});
test('handles letter suffix (preserves original case per #1962)', () => {
assert.strictEqual(phaseId.normalizePhaseName('12A'), '12A');
assert.strictEqual(phaseId.normalizePhaseName('3b'), '03b');
});
test('handles decimal phase IDs', () => {
assert.strictEqual(phaseId.normalizePhaseName('12.1'), '12.1');
assert.strictEqual(phaseId.normalizePhaseName('3.10'), '03.10');
});
test('handles milestone-prefixed IDs (M-NN form)', () => {
assert.strictEqual(phaseId.normalizePhaseName('1-1'), '01-01');
assert.strictEqual(phaseId.normalizePhaseName('2-3'), '02-03');
assert.strictEqual(phaseId.normalizePhaseName('1-2-3'), '01-02-03');
});
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)
assert.strictEqual(phaseId.normalizePhaseName('PROJ-42'), '42');
assert.strictEqual(phaseId.normalizePhaseName('AUTH-101'), '101');
});
test('custom phase IDs with non-numeric remainder pass through as-is', () => {
// No project_code pattern, no numeric match → return str as-is
assert.strictEqual(phaseId.normalizePhaseName('my-phase'), 'my-phase');
});
test('coerces non-string values', () => {
assert.strictEqual(phaseId.normalizePhaseName(5), '05');
});
});
// ─── comparePhaseNum ──────────────────────────────────────────────────────────
describe('comparePhaseNum', () => {
test('sorts numeric phases in ascending order', () => {
const phases = ['03', '01', '10', '02'];
const sorted = [...phases].sort(phaseId.comparePhaseNum);
assert.deepStrictEqual(sorted, ['01', '02', '03', '10']);
});
test('compares single-digit vs two-digit correctly', () => {
assert.ok(phaseId.comparePhaseNum('1', '02') < 0);
assert.ok(phaseId.comparePhaseNum('02', '1') > 0);
assert.strictEqual(phaseId.comparePhaseNum('1', '01'), 0);
});
test('handles decimal phases', () => {
assert.ok(phaseId.comparePhaseNum('1', '1.1') < 0);
assert.ok(phaseId.comparePhaseNum('1.1', '1.2') < 0);
assert.ok(phaseId.comparePhaseNum('1.10', '1.9') > 0);
assert.strictEqual(phaseId.comparePhaseNum('1.1', '01.1'), 0);
});
test('handles letter suffix ordering (no letter < A < B)', () => {
assert.ok(phaseId.comparePhaseNum('01', '01A') < 0);
assert.ok(phaseId.comparePhaseNum('01A', '01B') < 0);
assert.ok(phaseId.comparePhaseNum('01B', '01') > 0);
});
test('handles milestone-prefixed IDs', () => {
assert.ok(phaseId.comparePhaseNum('1-1', '1-2') < 0);
assert.ok(phaseId.comparePhaseNum('2-1', '1-10') > 0);
assert.ok(phaseId.comparePhaseNum('1-2-3', '1-2-4') < 0);
assert.strictEqual(phaseId.comparePhaseNum('01-01', '1-1'), 0);
});
test('strips project_code prefix before comparing', () => {
assert.strictEqual(phaseId.comparePhaseNum('CK-01', '01'), 0);
assert.ok(phaseId.comparePhaseNum('CK-01', 'CK-02') < 0);
});
test('handles non-parseable phase IDs via localeCompare fallback', () => {
// Should not throw on non-numeric IDs
const result = phaseId.comparePhaseNum('alpha', 'beta');
assert.strictEqual(typeof result, 'number');
});
});
// ─── extractPhaseToken ────────────────────────────────────────────────────────
describe('extractPhaseToken', () => {
test('extracts simple numeric token from directory name', () => {
assert.strictEqual(phaseId.extractPhaseToken('01-some-phase-name'), '01');
assert.strictEqual(phaseId.extractPhaseToken('12A-feature'), '12A');
});
test('extracts milestone-prefixed numeric token', () => {
assert.strictEqual(phaseId.extractPhaseToken('01-02-some-name'), '01-02');
assert.strictEqual(phaseId.extractPhaseToken('02-03-04-deep'), '02-03-04');
});
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');
});
test('returns the full dirName when no numeric token found', () => {
assert.strictEqual(phaseId.extractPhaseToken('no-numeric'), 'no-numeric');
assert.strictEqual(phaseId.extractPhaseToken('alpha'), 'alpha');
});
test('stops at first non-numeric-starting segment', () => {
assert.strictEqual(phaseId.extractPhaseToken('01-02-name-03'), '01-02');
});
});
// ─── phaseTokenMatches ────────────────────────────────────────────────────────
describe('phaseTokenMatches', () => {
test('matches exact token (case-insensitive)', () => {
assert.ok(phaseId.phaseTokenMatches('01-some-phase', '01'));
assert.ok(phaseId.phaseTokenMatches('12A-feature', '12A'));
assert.ok(phaseId.phaseTokenMatches('12A-feature', '12a'));
});
test('matches with project_code prefix stripped', () => {
assert.ok(phaseId.phaseTokenMatches('CK-01-phase', '01'));
assert.ok(phaseId.phaseTokenMatches('PROJ-12-feature', '12'));
});
test('does not match when token differs', () => {
assert.ok(!phaseId.phaseTokenMatches('01-some-phase', '02'));
assert.ok(!phaseId.phaseTokenMatches('12A-feature', '12B'));
});
test('matches milestone-prefixed token', () => {
assert.ok(phaseId.phaseTokenMatches('01-02-feature', '01-02'));
assert.ok(!phaseId.phaseTokenMatches('01-02-feature', '01-03'));
});
});
// ─── phaseMarkdownRegexSource ─────────────────────────────────────────────────
describe('phaseMarkdownRegexSource', () => {
test('produces a regex source that matches zero-padded variants', () => {
const src = phaseId.phaseMarkdownRegexSource('1');
const re = new RegExp(src);
assert.ok(re.test('1'));
assert.ok(re.test('01'));
assert.ok(re.test('001'));
});
test('produces source matching a two-digit phase', () => {
const src = phaseId.phaseMarkdownRegexSource('12');
const re = new RegExp(src);
assert.ok(re.test('12'));
assert.ok(re.test('012'));
assert.ok(!re.test('13'));
});
test('handles letter suffix', () => {
const src = phaseId.phaseMarkdownRegexSource('12A');
const re = new RegExp(src, 'i');
assert.ok(re.test('12A'));
assert.ok(re.test('012A'));
});
test('handles decimal phases', () => {
const src = phaseId.phaseMarkdownRegexSource('3.1');
const re = new RegExp(src);
assert.ok(re.test('3.1'));
assert.ok(re.test('03.1'));
assert.ok(!re.test('3.2'));
});
test('handles milestone-prefixed phase IDs', () => {
const src = phaseId.phaseMarkdownRegexSource('1-2');
const re = new RegExp(src);
assert.ok(re.test('1-2'));
assert.ok(re.test('01-02'));
assert.ok(re.test('01-2'));
assert.ok(!re.test('1-3'));
});
test('strips project_code prefix before building regex', () => {
const withPrefix = phaseId.phaseMarkdownRegexSource('CK-01');
const withoutPrefix = phaseId.phaseMarkdownRegexSource('01');
assert.strictEqual(withPrefix, withoutPrefix);
});
test('falls back to escaped literal for unparseable input', () => {
const src = phaseId.phaseMarkdownRegexSource('v1.0');
assert.strictEqual(typeof src, 'string');
assert.ok(src.length > 0);
});
test('adversarial: phase num containing regex metacharacters is escaped', () => {
// e.g. some exotic value that shouldn't break regexp construction
const src = phaseId.phaseMarkdownRegexSource('3.1');
// The literal dot in "3.1" should be escaped so it only matches a real dot
const re = new RegExp(src);
assert.ok(!re.test('3X1'), 'unescaped dot would match any char — must be escaped');
});
});
// ─── phaseMarkdownRegexSourceExact ────────────────────────────────────────────
describe('phaseMarkdownRegexSourceExact', () => {
test('returns escaped form for project-code-prefixed IDs', () => {
const result = phaseId.phaseMarkdownRegexSourceExact('PROJ-42');
// hyphen is not a regex special char so it passes through unescaped
assert.strictEqual(result, 'PROJ-42');
// The result is a valid regex source
assert.doesNotThrow(() => new RegExp(result));
});
test('returns null for non-prefixed IDs', () => {
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('01'), null);
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('12A'), null);
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact('1-2'), null);
});
test('null coercion: returns null for null/undefined', () => {
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact(null), null);
assert.strictEqual(phaseId.phaseMarkdownRegexSourceExact(undefined), null);
});
test('resulting regex matches the exact prefixed ID', () => {
const src = phaseId.phaseMarkdownRegexSourceExact('AUTH-101');
assert.ok(src !== null);
const re = new RegExp(src);
assert.ok(re.test('AUTH-101'));
assert.ok(!re.test('AUTH-102'));
});
});
// ─── getMilestoneFromPhaseId ──────────────────────────────────────────────────
describe('getMilestoneFromPhaseId', () => {
test('returns vN.0 for a milestone-prefixed phase id', () => {
assert.strictEqual(phaseId.getMilestoneFromPhaseId('1-01'), 'v1.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('02-03'), 'v2.0');
assert.strictEqual(phaseId.getMilestoneFromPhaseId('10-5'), 'v10.0');
});
test('returns null for non-milestone-prefixed IDs', () => {
assert.strictEqual(phaseId.getMilestoneFromPhaseId('01'), null);
assert.strictEqual(phaseId.getMilestoneFromPhaseId('12A'), null);
});
test('returns null for special sentinel milestones 0 and 999', () => {
assert.strictEqual(phaseId.getMilestoneFromPhaseId('0-1'), null);
assert.strictEqual(phaseId.getMilestoneFromPhaseId('999-1'), null);
});
test('strips project_code prefix before parsing', () => {
assert.strictEqual(phaseId.getMilestoneFromPhaseId('CK-2-01'), 'v2.0');
});
test('coerces non-string values', () => {
// numeric doesn't match the milestone pattern — returns null
assert.strictEqual(phaseId.getMilestoneFromPhaseId(42), null);
});
});
// ─── getPhaseDirFromPhaseId ───────────────────────────────────────────────────
describe('getPhaseDirFromPhaseId', () => {
test('returns null for non-milestone-format IDs', () => {
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('01', null, null), null);
assert.strictEqual(phaseId.getPhaseDirFromPhaseId('12A', null, null), null);
});
test('constructs dir name from milestone-prefixed phase id (no name, no code)', () => {
const result = phaseId.getPhaseDirFromPhaseId('1-2', null, null);
assert.strictEqual(result, '01-02');
});
test('includes phaseName slug', () => {
const result = phaseId.getPhaseDirFromPhaseId('1-2', 'My Feature', null);
assert.strictEqual(result, '01-02-my-feature');
});
test('prepends projectCode when provided', () => {
const result = phaseId.getPhaseDirFromPhaseId('1-2', 'Auth', 'CK');
assert.strictEqual(result, 'CK-01-02-auth');
});
test('strips project_code from phaseId before parsing', () => {
const result = phaseId.getPhaseDirFromPhaseId('CK-1-2', null, null);
assert.strictEqual(result, '01-02');
});
test('handles deep decomposition IDs (M-N-N)', () => {
// m[2] is "02-03" for input "1-2-3" — split and pad each sub-part
const result = phaseId.getPhaseDirFromPhaseId('1-2-3', null, null);
assert.strictEqual(result, '01-02-03');
});
test('slug strips leading/trailing hyphens from phaseName', () => {
const result = phaseId.getPhaseDirFromPhaseId('1-1', ' --some--name-- ', null);
// normalize: replace non-alnum runs with hyphen, strip edges
assert.ok(result !== null);
assert.ok(!result.startsWith('-'));
assert.ok(!result.endsWith('-'));
});
});
// ─── core.cjs re-export shim identity assertions ──────────────────────────────
describe('core.cjs re-export shim identity (single instance)', () => {
test('core.escapeRegex === phaseId.escapeRegex', () => {
assert.strictEqual(core.escapeRegex, phaseId.escapeRegex);
});
test('core.normalizePhaseName === phaseId.normalizePhaseName', () => {
assert.strictEqual(core.normalizePhaseName, phaseId.normalizePhaseName);
});
test('core.comparePhaseNum === phaseId.comparePhaseNum', () => {
assert.strictEqual(core.comparePhaseNum, phaseId.comparePhaseNum);
});
test('core.extractPhaseToken === phaseId.extractPhaseToken', () => {
assert.strictEqual(core.extractPhaseToken, phaseId.extractPhaseToken);
});
test('core.phaseTokenMatches === phaseId.phaseTokenMatches', () => {
assert.strictEqual(core.phaseTokenMatches, phaseId.phaseTokenMatches);
});
test('core.phaseMarkdownRegexSource === phaseId.phaseMarkdownRegexSource', () => {
assert.strictEqual(core.phaseMarkdownRegexSource, phaseId.phaseMarkdownRegexSource);
});
test('core.phaseMarkdownRegexSourceExact === phaseId.phaseMarkdownRegexSourceExact', () => {
assert.strictEqual(core.phaseMarkdownRegexSourceExact, phaseId.phaseMarkdownRegexSourceExact);
});
test('core.getMilestoneFromPhaseId === phaseId.getMilestoneFromPhaseId', () => {
assert.strictEqual(core.getMilestoneFromPhaseId, phaseId.getMilestoneFromPhaseId);
});
test('core.getPhaseDirFromPhaseId === phaseId.getPhaseDirFromPhaseId', () => {
assert.strictEqual(core.getPhaseDirFromPhaseId, phaseId.getPhaseDirFromPhaseId);
});
});