Files
msd-core/sdk/src/query/phase-lifecycle-policy.ts
Tom Boucher 65abc4fc90 refactor(query): deepen phase lifecycle seams (#3267)
* refactor(query): extract phase lifecycle policy module

* refactor(query): extract phase fs and roadmap mutation adapters

* fix(sdk): propagate non-ENOENT readdir errors in phase-filesystem-adapter (CR finding 1)

Swallow only ENOENT in listDirectories; rethrow EACCES, EIO, and other
unexpected errors so callers surface real failures rather than silently
treating a permission-denied phases dir as empty.

Also adds regression test: EACCES from readdir now propagates as thrown
error instead of returning [].

* fix(sdk): propagate non-ENOENT readFile errors in phase-roadmap-mutation (CR finding 4)

readModifyWriteRoadmapMd now falls back to empty content only on ENOENT;
EACCES, EIO, and other errors are rethrown so a subsequent write cannot
clobber real roadmap content that is temporarily unreadable.

Regression tests: EACCES propagates; absent ROADMAP.md still starts empty.

* fix(sdk): omit Depends on: Phase 0 for first sequential phase; align prefix grammar (CR findings 2+3)

Finding 2: buildPhaseRoadmapEntry now omits the "Depends on" line when
phaseId == 1 (prevPhase would be 0, which is not a valid predecessor).
The guard is `prevPhase < 1` so future phase-0 configs are also safe.

Finding 3: collectDecimalSuffixesFromDirNames regex prefix pattern
updated from `[A-Z]{1,6}` to `[A-Z][A-Z0-9]*` (case-insensitive flag
added), matching the grammar used by scanSequentialMaxPhaseFromDirs.
Prevents k014 parity drift for alphanumeric project-code prefixes longer
than six characters or containing digits.

Regression tests for both fixes included.
2026-05-09 00:14:59 -04:00

172 lines
5.8 KiB
TypeScript

import { GSDError, ErrorClassification } from '../errors.js';
import { escapeRegex } from './helpers.js';
export interface PhaseDirectoryComputation {
phaseId: number | string;
dirName: string;
}
export interface NextDecimalPhaseResult {
next: string;
existing: string[];
}
/** Reject strings containing null bytes (path traversal defense). */
export function assertNoNullBytes(value: string, label: string): void {
if (value.includes('\0')) {
throw new GSDError(`${label} contains null byte`, ErrorClassification.Validation);
}
}
/** Reject `..` or path separators in phase directory names. */
export function assertSafePhaseDirName(dirName: string, label = 'phase directory'): void {
if (/[/\\]|\.\./.test(dirName)) {
throw new GSDError(`${label} contains invalid path segments`, ErrorClassification.Validation);
}
}
export function assertSafeProjectCode(code: string): void {
if (code && /[/\\]|\.\./.test(code)) {
throw new GSDError('project_code contains invalid characters', ErrorClassification.Validation);
}
}
/** Generate kebab-case slug from description. */
export function generatePhaseSlug(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.substring(0, 60);
}
export function parseMultiwordArg(args: string[], flag: string): string | null {
const idx = args.indexOf(`--${flag}`);
if (idx === -1) return null;
const tokens: string[] = [];
for (let i = idx + 1; i < args.length; i++) {
if (args[i]!.startsWith('--')) break;
tokens.push(args[i]!);
}
return tokens.length > 0 ? tokens.join(' ') : null;
}
export function extractOneLinerFromBody(content: string): string | null {
if (!content) return null;
const body = content.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n*/, '');
const match = body.match(/^#[^\n]*\n+\*\*([^*]+)\*\*/m);
return match ? match[1]!.trim() : null;
}
/**
* Scan highest sequential phase number in milestone content.
* Skips backlog lanes (`999.x`).
*/
export function scanSequentialMaxPhaseFromMilestone(milestoneContent: string): number {
const phasePattern = /(?:^|\n)\s*(?:[-*]\s*(?:\[[x ]\]\s*)?|#{2,4}\s*|\*{1,2}\s*)Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
let maxPhase = 0;
let m: RegExpExecArray | null;
while ((m = phasePattern.exec(milestoneContent)) !== null) {
const num = parseInt(m[1], 10);
if (num >= 999) continue;
if (num > maxPhase) maxPhase = num;
}
return maxPhase;
}
/**
* Scan highest sequential phase number from phase directory names.
* Supports optional project-code prefix and optional decimal suffixes.
*/
export function scanSequentialMaxPhaseFromDirs(dirNames: string[]): number {
let maxPhase = 0;
const dirNumPattern = /^(?:[A-Z][A-Z0-9]*-)?(\d+)[A-Z]?(?:\.\d+)*-/i;
for (const dirName of dirNames) {
const match = dirNumPattern.exec(dirName);
if (!match) continue;
const num = parseInt(match[1], 10);
if (num >= 999) continue;
if (num > maxPhase) maxPhase = num;
}
return maxPhase;
}
export function computeNextSequentialPhaseId(milestoneContent: string, dirNames: string[]): number {
return Math.max(
scanSequentialMaxPhaseFromMilestone(milestoneContent),
scanSequentialMaxPhaseFromDirs(dirNames),
) + 1;
}
export function computePhaseDirectory(
namingMode: unknown,
descriptionSlug: string,
prefix: string,
nextSequentialPhaseId: number,
customId?: string | null,
): PhaseDirectoryComputation {
if (customId || namingMode === 'custom') {
const phaseId = customId || descriptionSlug.toUpperCase().replace(/-/g, '_');
if (!phaseId) {
throw new GSDError('--id required when phase_naming is "custom"', ErrorClassification.Validation);
}
assertSafePhaseDirName(String(phaseId), 'custom phase id');
const dirName = `${prefix}${phaseId}-${descriptionSlug}`;
assertSafePhaseDirName(dirName);
return { phaseId, dirName };
}
const phaseId = nextSequentialPhaseId;
const paddedNum = String(phaseId).padStart(2, '0');
const dirName = `${prefix}${paddedNum}-${descriptionSlug}`;
assertSafePhaseDirName(dirName);
return { phaseId, dirName };
}
export function buildPhaseRoadmapEntry(
phaseId: number | string,
description: string,
namingMode: unknown,
): string {
const prevPhase = typeof phaseId === 'number' ? phaseId - 1 : null;
const dependsOn = namingMode === 'custom' || prevPhase === null || prevPhase < 1
? ''
: `\n**Depends on:** Phase ${prevPhase}`;
return `\n### Phase ${phaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd-plan-phase ${phaseId} to break down)\n`;
}
export function collectDecimalSuffixesFromDirNames(basePhase: string, dirNames: string[]): Set<number> {
const decimalSet = new Set<number>();
const decimalPattern = new RegExp(`^(?:[A-Z][A-Z0-9]*-)?${escapeRegex(basePhase)}\\.(\\d+)`, 'i');
for (const dir of dirNames) {
const match = dir.match(decimalPattern);
if (match) decimalSet.add(parseInt(match[1], 10));
}
return decimalSet;
}
export function collectDecimalSuffixesFromRoadmap(basePhase: string, roadmapContent: string): Set<number> {
const decimalSet = new Set<number>();
const phasePattern = new RegExp(
`#{2,4}\\s*Phase\\s+0*${escapeRegex(basePhase)}\\.(\\d+)\\s*:`,
'gi',
);
let match: RegExpExecArray | null;
while ((match = phasePattern.exec(roadmapContent)) !== null) {
decimalSet.add(parseInt(match[1], 10));
}
return decimalSet;
}
export function computeNextDecimalPhase(basePhase: string, decimalSet: Set<number>): NextDecimalPhaseResult {
const existing = Array.from(decimalSet)
.sort((a, b) => a - b)
.map((n) => `${basePhase}.${n}`);
const next = decimalSet.size === 0
? `${basePhase}.1`
: `${basePhase}.${Math.max(...decimalSet) + 1}`;
return { next, existing };
}