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.
This commit is contained in:
77
sdk/src/query/phase-roadmap-mutation.ts
Normal file
77
sdk/src/query/phase-roadmap-mutation.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { planningPaths } from './helpers.js';
|
||||
import { acquireStateLock, releaseStateLock } from './state-mutation.js';
|
||||
|
||||
/**
|
||||
* Replace a pattern only in the current milestone section of ROADMAP.md.
|
||||
*
|
||||
* Port of replaceInCurrentMilestone from core.cjs line 1197-1206.
|
||||
*/
|
||||
export function replaceInCurrentMilestone(
|
||||
content: string,
|
||||
pattern: string | RegExp,
|
||||
replacement: string,
|
||||
): string {
|
||||
const lastDetailsClose = content.lastIndexOf('</details>');
|
||||
if (lastDetailsClose === -1) {
|
||||
return content.replace(pattern, replacement);
|
||||
}
|
||||
const offset = lastDetailsClose + '</details>'.length;
|
||||
const before = content.slice(0, offset);
|
||||
const after = content.slice(offset);
|
||||
|
||||
const replacedAfter = after.replace(pattern, replacement);
|
||||
if (replacedAfter !== after) {
|
||||
return before + replacedAfter;
|
||||
}
|
||||
|
||||
const detailsBlockRe = /<details>[\s\S]*?<\/details>/gi;
|
||||
const spans: { start: number; end: number; text: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = detailsBlockRe.exec(content)) !== null) {
|
||||
spans.push({ start: m.index, end: m.index + m[0].length, text: m[0] });
|
||||
}
|
||||
|
||||
if (spans.length === 0) {
|
||||
return content.replace(pattern, replacement);
|
||||
}
|
||||
|
||||
const lastSpan = spans[spans.length - 1];
|
||||
const updatedLastBlock = lastSpan.text.replace(pattern, replacement);
|
||||
return (
|
||||
content.slice(0, lastSpan.start) +
|
||||
updatedLastBlock +
|
||||
content.slice(lastSpan.end)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write for ROADMAP.md.
|
||||
*
|
||||
* Holds a lockfile across the entire read -> transform -> write cycle.
|
||||
*/
|
||||
export async function readModifyWriteRoadmapMd(
|
||||
projectDir: string,
|
||||
modifier: (content: string) => string | Promise<string>,
|
||||
workstream?: string,
|
||||
): Promise<string> {
|
||||
const roadmapPath = planningPaths(projectDir, workstream).roadmap;
|
||||
const lockPath = await acquireStateLock(roadmapPath);
|
||||
try {
|
||||
let content: string;
|
||||
try {
|
||||
content = await readFile(roadmapPath, 'utf-8');
|
||||
} catch (err) {
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
content = '';
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
const modified = await modifier(content);
|
||||
await writeFile(roadmapPath, modified, 'utf-8');
|
||||
return modified;
|
||||
} finally {
|
||||
await releaseStateLock(lockPath);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user