fix(core): add concurrency safety and atomic state writes
Wire withPlanningLock into all ROADMAP.md write paths (phase add/insert/complete/remove, roadmap update-plan-progress) to prevent concurrent corruption when parallel agents modify planning files. Extract acquireStateLock/releaseStateLock from writeStateMd and add readModifyWriteStateMd helper that holds the lock across the entire read-modify-write cycle, preventing lost updates. Replace O(n^2) normalizeMd fence detection with single-pass O(n) pre-computed fence state array. Warn on must_haves parse failure and stateReplaceFieldWithFallback field miss. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -360,20 +360,44 @@ function normalizeMd(content) {
|
||||
const lines = text.split('\n');
|
||||
const result = [];
|
||||
|
||||
// Pre-compute fence state in a single O(n) pass instead of O(n^2) per-line scanning
|
||||
const fenceRegex = /^```/;
|
||||
const insideFence = new Array(lines.length);
|
||||
let fenceOpen = false;
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (fenceRegex.test(lines[i].trimEnd())) {
|
||||
if (fenceOpen) {
|
||||
// This is a closing fence — mark as NOT inside (it's the boundary)
|
||||
insideFence[i] = false;
|
||||
fenceOpen = false;
|
||||
} else {
|
||||
// This is an opening fence
|
||||
insideFence[i] = false;
|
||||
fenceOpen = true;
|
||||
}
|
||||
} else {
|
||||
insideFence[i] = fenceOpen;
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const prev = i > 0 ? lines[i - 1] : '';
|
||||
const prevTrimmed = prev.trimEnd();
|
||||
const trimmed = line.trimEnd();
|
||||
const isFenceLine = fenceRegex.test(trimmed);
|
||||
|
||||
// MD022: Blank line before headings (skip first line and frontmatter delimiters)
|
||||
if (/^#{1,6}\s/.test(trimmed) && i > 0 && prevTrimmed !== '' && prevTrimmed !== '---') {
|
||||
result.push('');
|
||||
}
|
||||
|
||||
// MD031: Blank line before fenced code blocks
|
||||
if (/^```/.test(trimmed) && i > 0 && prevTrimmed !== '' && !isInsideFencedBlock(lines, i)) {
|
||||
result.push('');
|
||||
// MD031: Blank line before fenced code blocks (opening fences only)
|
||||
if (isFenceLine && i > 0 && prevTrimmed !== '' && !insideFence[i] && (i === 0 || !insideFence[i - 1] || isFenceLine)) {
|
||||
// Only add blank before opening fences (not closing ones)
|
||||
if (i === 0 || !insideFence[i - 1]) {
|
||||
result.push('');
|
||||
}
|
||||
}
|
||||
|
||||
// MD032: Blank line before lists (- item, * item, N. item, - [ ] item)
|
||||
@@ -394,7 +418,7 @@ function normalizeMd(content) {
|
||||
}
|
||||
|
||||
// MD031: Blank line after closing fenced code blocks
|
||||
if (/^```\s*$/.test(trimmed) && isClosingFence(lines, i) && i < lines.length - 1) {
|
||||
if (/^```\s*$/.test(trimmed) && i > 0 && insideFence[i - 1] && i < lines.length - 1) {
|
||||
const next = lines[i + 1];
|
||||
if (next !== undefined && next.trimEnd() !== '') {
|
||||
result.push('');
|
||||
@@ -424,24 +448,6 @@ function normalizeMd(content) {
|
||||
return text;
|
||||
}
|
||||
|
||||
/** Check if line index i is inside an already-open fenced code block */
|
||||
function isInsideFencedBlock(lines, i) {
|
||||
let fenceCount = 0;
|
||||
for (let j = 0; j < i; j++) {
|
||||
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
|
||||
}
|
||||
return fenceCount % 2 === 1;
|
||||
}
|
||||
|
||||
/** Check if a ``` line is a closing fence (odd number of fences up to and including this one) */
|
||||
function isClosingFence(lines, i) {
|
||||
let fenceCount = 0;
|
||||
for (let j = 0; j <= i; j++) {
|
||||
if (/^```/.test(lines[j].trimEnd())) fenceCount++;
|
||||
}
|
||||
return fenceCount % 2 === 0;
|
||||
}
|
||||
|
||||
function execGit(cwd, args) {
|
||||
const result = spawnSync('git', args, {
|
||||
cwd,
|
||||
|
||||
@@ -252,6 +252,19 @@ function parseMustHavesBlock(content, blockName) {
|
||||
}
|
||||
if (current) items.push(current);
|
||||
|
||||
// Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue.
|
||||
// This is a critical diagnostic: empty must_haves causes verification to silently degrade
|
||||
// to Option C (LLM-derived truths) instead of checking documented contracts.
|
||||
if (items.length === 0 && blockLines.length > 0) {
|
||||
const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length;
|
||||
if (nonEmptyLines > 0) {
|
||||
process.stderr.write(
|
||||
`[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` +
|
||||
`Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, output, error, readSubdirectories } = require('./core.cjs');
|
||||
const { escapeRegex, loadConfig, normalizePhaseName, comparePhaseNum, findPhaseInternal, getArchivedPhaseDirs, generateSlugInternal, getMilestonePhaseFilter, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone, toPosixPath, planningDir, withPlanningLock, output, error, readSubdirectories } = require('./core.cjs');
|
||||
const { extractFrontmatter } = require('./frontmatter.cjs');
|
||||
const { writeStateMd, stateExtractField, stateReplaceField, stateReplaceFieldWithFallback } = require('./state.cjs');
|
||||
|
||||
@@ -328,57 +328,62 @@ function cmdPhaseAdd(cwd, description, raw, customId) {
|
||||
error('ROADMAP.md not found');
|
||||
}
|
||||
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
const slug = generateSlugInternal(description);
|
||||
|
||||
let newPhaseId;
|
||||
let dirName;
|
||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||
const { newPhaseId, dirName } = withPlanningLock(cwd, () => {
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
|
||||
// Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
|
||||
const projectCode = config.project_code || '';
|
||||
const prefix = projectCode ? `${projectCode}-` : '';
|
||||
// Optional project code prefix (e.g., 'CK' → 'CK-01-foundation')
|
||||
const projectCode = config.project_code || '';
|
||||
const prefix = projectCode ? `${projectCode}-` : '';
|
||||
|
||||
if (customId || config.phase_naming === 'custom') {
|
||||
// Custom phase naming: use provided ID or generate from description
|
||||
newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
|
||||
if (!newPhaseId) error('--id required when phase_naming is "custom"');
|
||||
dirName = `${prefix}${newPhaseId}-${slug}`;
|
||||
} else {
|
||||
// Sequential mode: find highest integer phase number (in current milestone only)
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
||||
let maxPhase = 0;
|
||||
let m;
|
||||
while ((m = phasePattern.exec(content)) !== null) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (num > maxPhase) maxPhase = num;
|
||||
let _newPhaseId;
|
||||
let _dirName;
|
||||
|
||||
if (customId || config.phase_naming === 'custom') {
|
||||
// Custom phase naming: use provided ID or generate from description
|
||||
_newPhaseId = customId || slug.toUpperCase().replace(/-/g, '-');
|
||||
if (!_newPhaseId) error('--id required when phase_naming is "custom"');
|
||||
_dirName = `${prefix}${_newPhaseId}-${slug}`;
|
||||
} else {
|
||||
// Sequential mode: find highest integer phase number (in current milestone only)
|
||||
const phasePattern = /#{2,4}\s*Phase\s+(\d+)[A-Z]?(?:\.\d+)*:/gi;
|
||||
let maxPhase = 0;
|
||||
let m;
|
||||
while ((m = phasePattern.exec(content)) !== null) {
|
||||
const num = parseInt(m[1], 10);
|
||||
if (num > maxPhase) maxPhase = num;
|
||||
}
|
||||
|
||||
_newPhaseId = maxPhase + 1;
|
||||
const paddedNum = String(_newPhaseId).padStart(2, '0');
|
||||
_dirName = `${prefix}${paddedNum}-${slug}`;
|
||||
}
|
||||
|
||||
newPhaseId = maxPhase + 1;
|
||||
const paddedNum = String(newPhaseId).padStart(2, '0');
|
||||
dirName = `${prefix}${paddedNum}-${slug}`;
|
||||
}
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
|
||||
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
// Build phase entry
|
||||
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof _newPhaseId === 'number' ? _newPhaseId - 1 : 'TBD'}`;
|
||||
const phaseEntry = `\n### Phase ${_newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_newPhaseId} to break down)\n`;
|
||||
|
||||
// Build phase entry
|
||||
const dependsOn = config.phase_naming === 'custom' ? '' : `\n**Depends on:** Phase ${typeof newPhaseId === 'number' ? newPhaseId - 1 : 'TBD'}`;
|
||||
const phaseEntry = `\n### Phase ${newPhaseId}: ${description}\n\n**Goal:** [To be planned]\n**Requirements**: TBD${dependsOn}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${newPhaseId} to break down)\n`;
|
||||
// Find insertion point: before last "---" or at end
|
||||
let updatedContent;
|
||||
const lastSeparator = rawContent.lastIndexOf('\n---');
|
||||
if (lastSeparator > 0) {
|
||||
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
|
||||
} else {
|
||||
updatedContent = rawContent + phaseEntry;
|
||||
}
|
||||
|
||||
// Find insertion point: before last "---" or at end
|
||||
let updatedContent;
|
||||
const lastSeparator = rawContent.lastIndexOf('\n---');
|
||||
if (lastSeparator > 0) {
|
||||
updatedContent = rawContent.slice(0, lastSeparator) + phaseEntry + rawContent.slice(lastSeparator);
|
||||
} else {
|
||||
updatedContent = rawContent + phaseEntry;
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
||||
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
||||
return { newPhaseId: _newPhaseId, dirName: _dirName };
|
||||
});
|
||||
|
||||
const result = {
|
||||
phase_number: typeof newPhaseId === 'number' ? newPhaseId : String(newPhaseId),
|
||||
@@ -402,71 +407,75 @@ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
|
||||
error('ROADMAP.md not found');
|
||||
}
|
||||
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
const slug = generateSlugInternal(description);
|
||||
|
||||
// Normalize input then strip leading zeros for flexible matching
|
||||
const normalizedAfter = normalizePhaseName(afterPhase);
|
||||
const unpadded = normalizedAfter.replace(/^0+/, '');
|
||||
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
|
||||
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
|
||||
if (!targetPattern.test(content)) {
|
||||
error(`Phase ${afterPhase} not found in ROADMAP.md`);
|
||||
}
|
||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||
const { decimalPhase, dirName } = withPlanningLock(cwd, () => {
|
||||
const rawContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const content = extractCurrentMilestone(rawContent, cwd);
|
||||
|
||||
// Calculate next decimal using existing logic
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const normalizedBase = normalizePhaseName(afterPhase);
|
||||
let existingDecimals = [];
|
||||
|
||||
try {
|
||||
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}-)?${normalizedBase}\\.(\\d+)`);
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(decimalPattern);
|
||||
if (dm) existingDecimals.push(parseInt(dm[1], 10));
|
||||
// Normalize input then strip leading zeros for flexible matching
|
||||
const normalizedAfter = normalizePhaseName(afterPhase);
|
||||
const unpadded = normalizedAfter.replace(/^0+/, '');
|
||||
const afterPhaseEscaped = unpadded.replace(/\./g, '\\.');
|
||||
const targetPattern = new RegExp(`#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:`, 'i');
|
||||
if (!targetPattern.test(content)) {
|
||||
error(`Phase ${afterPhase} not found in ROADMAP.md`);
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
|
||||
const decimalPhase = `${normalizedBase}.${nextDecimal}`;
|
||||
// Calculate next decimal using existing logic
|
||||
const phasesDir = path.join(planningDir(cwd), 'phases');
|
||||
const normalizedBase = normalizePhaseName(afterPhase);
|
||||
let existingDecimals = [];
|
||||
|
||||
// Optional project code prefix
|
||||
const config = loadConfig(cwd);
|
||||
const projectCode = config.project_code || '';
|
||||
const prefix = projectCode ? `${projectCode}-` : '';
|
||||
const dirName = `${prefix}${decimalPhase}-${slug}`;
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', dirName);
|
||||
try {
|
||||
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}-)?${normalizedBase}\\.(\\d+)`);
|
||||
for (const dir of dirs) {
|
||||
const dm = dir.match(decimalPattern);
|
||||
if (dm) existingDecimals.push(parseInt(dm[1], 10));
|
||||
}
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
|
||||
const _decimalPhase = `${normalizedBase}.${nextDecimal}`;
|
||||
// Optional project code prefix
|
||||
const insertConfig = loadConfig(cwd);
|
||||
const projectCode = insertConfig.project_code || '';
|
||||
const pfx = projectCode ? `${projectCode}-` : '';
|
||||
const _dirName = `${pfx}${_decimalPhase}-${slug}`;
|
||||
const dirPath = path.join(planningDir(cwd), 'phases', _dirName);
|
||||
|
||||
// Build phase entry
|
||||
const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${decimalPhase} to break down)\n`;
|
||||
// Create directory with .gitkeep so git tracks empty folders
|
||||
fs.mkdirSync(dirPath, { recursive: true });
|
||||
fs.writeFileSync(path.join(dirPath, '.gitkeep'), '');
|
||||
|
||||
// Insert after the target phase section
|
||||
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
|
||||
const headerMatch = rawContent.match(headerPattern);
|
||||
if (!headerMatch) {
|
||||
error(`Could not find Phase ${afterPhase} header`);
|
||||
}
|
||||
// Build phase entry
|
||||
const phaseEntry = `\n### Phase ${_decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Requirements**: TBD\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /gsd:plan-phase ${_decimalPhase} to break down)\n`;
|
||||
|
||||
const headerIdx = rawContent.indexOf(headerMatch[0]);
|
||||
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
|
||||
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
|
||||
// Insert after the target phase section
|
||||
const headerPattern = new RegExp(`(#{2,4}\\s*Phase\\s+0*${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
|
||||
const headerMatch = rawContent.match(headerPattern);
|
||||
if (!headerMatch) {
|
||||
error(`Could not find Phase ${afterPhase} header`);
|
||||
}
|
||||
|
||||
let insertIdx;
|
||||
if (nextPhaseMatch) {
|
||||
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
|
||||
} else {
|
||||
insertIdx = rawContent.length;
|
||||
}
|
||||
const headerIdx = rawContent.indexOf(headerMatch[0]);
|
||||
const afterHeader = rawContent.slice(headerIdx + headerMatch[0].length);
|
||||
const nextPhaseMatch = afterHeader.match(/\n#{2,4}\s+Phase\s+\d/i);
|
||||
|
||||
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
|
||||
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
||||
let insertIdx;
|
||||
if (nextPhaseMatch) {
|
||||
insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
|
||||
} else {
|
||||
insertIdx = rawContent.length;
|
||||
}
|
||||
|
||||
const updatedContent = rawContent.slice(0, insertIdx) + phaseEntry + rawContent.slice(insertIdx);
|
||||
fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
|
||||
return { decimalPhase: _decimalPhase, dirName: _dirName };
|
||||
});
|
||||
|
||||
const result = {
|
||||
phase_number: decimalPhase,
|
||||
@@ -554,29 +563,32 @@ function renameIntegerPhases(phasesDir, removedInt) {
|
||||
/**
|
||||
* Remove a phase section from ROADMAP.md and renumber all subsequent integer phases.
|
||||
*/
|
||||
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt) {
|
||||
let content = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const escaped = escapeRegex(targetPhase);
|
||||
function updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, removedInt, cwd) {
|
||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||
withPlanningLock(cwd, () => {
|
||||
let content = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const escaped = escapeRegex(targetPhase);
|
||||
|
||||
content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), '');
|
||||
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
|
||||
content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
|
||||
content = content.replace(new RegExp(`\\n?#{2,4}\\s*Phase\\s+${escaped}\\s*:[\\s\\S]*?(?=\\n#{2,4}\\s+Phase\\s+\\d|$)`, 'i'), '');
|
||||
content = content.replace(new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${escaped}[:\\s][^\\n]*`, 'gi'), '');
|
||||
content = content.replace(new RegExp(`\\n?\\|\\s*${escaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi'), '');
|
||||
|
||||
if (!isDecimal) {
|
||||
const MAX_PHASE = 99;
|
||||
for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) {
|
||||
const newNum = oldNum - 1;
|
||||
const oldStr = String(oldNum), newStr = String(newNum);
|
||||
const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0');
|
||||
content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`);
|
||||
content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`);
|
||||
content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`);
|
||||
content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `);
|
||||
content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`);
|
||||
if (!isDecimal) {
|
||||
const MAX_PHASE = 99;
|
||||
for (let oldNum = MAX_PHASE; oldNum > removedInt; oldNum--) {
|
||||
const newNum = oldNum - 1;
|
||||
const oldStr = String(oldNum), newStr = String(newNum);
|
||||
const oldPad = oldStr.padStart(2, '0'), newPad = newStr.padStart(2, '0');
|
||||
content = content.replace(new RegExp(`(#{2,4}\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'), `$1${newStr}$2`);
|
||||
content = content.replace(new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'), `$1${newStr}$2`);
|
||||
content = content.replace(new RegExp(`${oldPad}-(\\d{2})`, 'g'), `${newPad}-$1`);
|
||||
content = content.replace(new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'), `$1${newStr}. `);
|
||||
content = content.replace(new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'), `$1${newStr}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, content, 'utf-8');
|
||||
fs.writeFileSync(roadmapPath, content, 'utf-8');
|
||||
});
|
||||
}
|
||||
|
||||
function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
||||
@@ -617,7 +629,7 @@ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
|
||||
} catch { /* intentionally empty */ }
|
||||
|
||||
// Update ROADMAP.md
|
||||
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10));
|
||||
updateRoadmapAfterPhaseRemoval(roadmapPath, targetPhase, isDecimal, parseInt(normalized, 10), cwd);
|
||||
|
||||
// Update STATE.md phase count
|
||||
const statePath = path.join(planningDir(cwd), 'STATE.md');
|
||||
@@ -686,98 +698,100 @@ function cmdPhaseComplete(cwd, phaseNum, raw) {
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// Update ROADMAP.md: mark phase complete
|
||||
// Update ROADMAP.md and REQUIREMENTS.md atomically under lock
|
||||
if (fs.existsSync(roadmapPath)) {
|
||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
withPlanningLock(cwd, () => {
|
||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
|
||||
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
|
||||
// Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
const tableRowPattern = new RegExp(
|
||||
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
|
||||
'im'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
||||
const cells = fullRow.split('|').slice(1, -1);
|
||||
if (cells.length === 5) {
|
||||
// 5-col: Phase | Milestone | Plans | Status | Completed
|
||||
cells[2] = ` ${summaryCount}/${planCount} `;
|
||||
cells[3] = ' Complete ';
|
||||
cells[4] = ` ${today} `;
|
||||
} else if (cells.length === 4) {
|
||||
// 4-col: Phase | Plans | Status | Completed
|
||||
cells[1] = ` ${summaryCount}/${planCount} `;
|
||||
cells[2] = ' Complete ';
|
||||
cells[3] = ` ${today} `;
|
||||
}
|
||||
return '|' + cells.join('|') + '|';
|
||||
});
|
||||
|
||||
// Update plan count in phase section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(
|
||||
roadmapContent, planCountPattern,
|
||||
`$1${summaryCount}/${planCount} plans complete`
|
||||
);
|
||||
|
||||
// Mark completed plan checkboxes (safety net for missed per-plan updates)
|
||||
for (const summaryFile of phaseInfo.summaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
|
||||
// Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${escapeRegex(phaseNum)}[:\\s][^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
|
||||
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
||||
// Progress table: update Status to Complete, add date (handles 4 or 5 column tables)
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
const tableRowPattern = new RegExp(
|
||||
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
|
||||
'im'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
||||
const cells = fullRow.split('|').slice(1, -1);
|
||||
if (cells.length === 5) {
|
||||
// 5-col: Phase | Milestone | Plans | Status | Completed
|
||||
cells[2] = ` ${summaryCount}/${planCount} `;
|
||||
cells[3] = ' Complete ';
|
||||
cells[4] = ` ${today} `;
|
||||
} else if (cells.length === 4) {
|
||||
// 4-col: Phase | Plans | Status | Completed
|
||||
cells[1] = ` ${summaryCount}/${planCount} `;
|
||||
cells[2] = ' Complete ';
|
||||
cells[3] = ` ${today} `;
|
||||
}
|
||||
return '|' + cells.join('|') + '|';
|
||||
});
|
||||
|
||||
// Update REQUIREMENTS.md traceability for this phase's requirements
|
||||
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
|
||||
if (fs.existsSync(reqPath)) {
|
||||
// Extract the current phase section from roadmap (scoped to avoid cross-phase matching)
|
||||
const phaseEsc = escapeRegex(phaseNum);
|
||||
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||||
const phaseSectionMatch = currentMilestoneRoadmap.match(
|
||||
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
|
||||
// Update plan count in phase section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(
|
||||
roadmapContent, planCountPattern,
|
||||
`$1${summaryCount}/${planCount} plans complete`
|
||||
);
|
||||
|
||||
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
|
||||
const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i);
|
||||
|
||||
if (reqMatch) {
|
||||
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
|
||||
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
|
||||
'$1x$2'
|
||||
);
|
||||
// Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
|
||||
'$1 Complete $2'
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(reqPath, reqContent, 'utf-8');
|
||||
requirementsUpdated = true;
|
||||
// Mark completed plan checkboxes (safety net for missed per-plan updates)
|
||||
for (const summaryFile of phaseInfo.summaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
||||
|
||||
// Update REQUIREMENTS.md traceability for this phase's requirements
|
||||
const reqPath = path.join(planningDir(cwd), 'REQUIREMENTS.md');
|
||||
if (fs.existsSync(reqPath)) {
|
||||
// Extract the current phase section from roadmap (scoped to avoid cross-phase matching)
|
||||
const phaseEsc = escapeRegex(phaseNum);
|
||||
const currentMilestoneRoadmap = extractCurrentMilestone(roadmapContent, cwd);
|
||||
const phaseSectionMatch = currentMilestoneRoadmap.match(
|
||||
new RegExp(`(#{2,4}\\s*Phase\\s+${phaseEsc}[:\\s][\\s\\S]*?)(?=#{2,4}\\s*Phase\\s+|$)`, 'i')
|
||||
);
|
||||
|
||||
const sectionText = phaseSectionMatch ? phaseSectionMatch[1] : '';
|
||||
const reqMatch = sectionText.match(/\*\*Requirements:\*\*\s*([^\n]+)/i);
|
||||
|
||||
if (reqMatch) {
|
||||
const reqIds = reqMatch[1].replace(/[\[\]]/g, '').split(/[,\s]+/).map(r => r.trim()).filter(Boolean);
|
||||
let reqContent = fs.readFileSync(reqPath, 'utf-8');
|
||||
|
||||
for (const reqId of reqIds) {
|
||||
const reqEscaped = escapeRegex(reqId);
|
||||
// Update checkbox: - [ ] **REQ-ID** → - [x] **REQ-ID**
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(-\\s*\\[)[ ](\\]\\s*\\*\\*${reqEscaped}\\*\\*)`, 'gi'),
|
||||
'$1x$2'
|
||||
);
|
||||
// Update traceability table: | REQ-ID | Phase N | Pending/In Progress | → | REQ-ID | Phase N | Complete |
|
||||
reqContent = reqContent.replace(
|
||||
new RegExp(`(\\|\\s*${reqEscaped}\\s*\\|[^|]+\\|)\\s*(?:Pending|In Progress)\\s*(\\|)`, 'gi'),
|
||||
'$1 Complete $2'
|
||||
);
|
||||
}
|
||||
|
||||
fs.writeFileSync(reqPath, reqContent, 'utf-8');
|
||||
requirementsUpdated = true;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Find next phase — check both filesystem AND roadmap
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { escapeRegex, normalizePhaseName, planningPaths, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs');
|
||||
const { escapeRegex, normalizePhaseName, planningPaths, withPlanningLock, output, error, findPhaseInternal, stripShippedMilestones, extractCurrentMilestone, replaceInCurrentMilestone } = require('./core.cjs');
|
||||
|
||||
function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
|
||||
const roadmapPath = planningPaths(cwd).roadmap;
|
||||
@@ -254,64 +254,67 @@ function cmdRoadmapUpdatePlanProgress(cwd, phaseNum, raw) {
|
||||
return;
|
||||
}
|
||||
|
||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
// Wrap entire read-modify-write in lock to prevent concurrent corruption
|
||||
withPlanningLock(cwd, () => {
|
||||
let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
|
||||
const phaseEscaped = escapeRegex(phaseNum);
|
||||
|
||||
// Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
|
||||
const tableRowPattern = new RegExp(
|
||||
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
|
||||
'im'
|
||||
);
|
||||
const dateField = isComplete ? ` ${today} ` : ' ';
|
||||
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
||||
const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
|
||||
if (cells.length === 5) {
|
||||
// 5-col: Phase | Milestone | Plans | Status | Completed
|
||||
cells[2] = ` ${summaryCount}/${planCount} `;
|
||||
cells[3] = ` ${status.padEnd(11)}`;
|
||||
cells[4] = dateField;
|
||||
} else if (cells.length === 4) {
|
||||
// 4-col: Phase | Plans | Status | Completed
|
||||
cells[1] = ` ${summaryCount}/${planCount} `;
|
||||
cells[2] = ` ${status.padEnd(11)}`;
|
||||
cells[3] = dateField;
|
||||
// Progress table row: update Plans/Status/Date columns (handles 4 or 5 column tables)
|
||||
const tableRowPattern = new RegExp(
|
||||
`^(\\|\\s*${phaseEscaped}\\.?\\s[^|]*(?:\\|[^\\n]*))$`,
|
||||
'im'
|
||||
);
|
||||
const dateField = isComplete ? ` ${today} ` : ' ';
|
||||
roadmapContent = roadmapContent.replace(tableRowPattern, (fullRow) => {
|
||||
const cells = fullRow.split('|').slice(1, -1); // drop leading/trailing empty from split
|
||||
if (cells.length === 5) {
|
||||
// 5-col: Phase | Milestone | Plans | Status | Completed
|
||||
cells[2] = ` ${summaryCount}/${planCount} `;
|
||||
cells[3] = ` ${status.padEnd(11)}`;
|
||||
cells[4] = dateField;
|
||||
} else if (cells.length === 4) {
|
||||
// 4-col: Phase | Plans | Status | Completed
|
||||
cells[1] = ` ${summaryCount}/${planCount} `;
|
||||
cells[2] = ` ${status.padEnd(11)}`;
|
||||
cells[3] = dateField;
|
||||
}
|
||||
return '|' + cells.join('|') + '|';
|
||||
});
|
||||
|
||||
// Update plan count in phase detail section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
const planCountText = isComplete
|
||||
? `${summaryCount}/${planCount} plans complete`
|
||||
: `${summaryCount}/${planCount} plans executed`;
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
|
||||
|
||||
// If complete: check checkbox
|
||||
if (isComplete) {
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
}
|
||||
return '|' + cells.join('|') + '|';
|
||||
|
||||
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:")
|
||||
for (const summaryFile of phaseInfo.summaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
||||
});
|
||||
|
||||
// Update plan count in phase detail section
|
||||
const planCountPattern = new RegExp(
|
||||
`(#{2,4}\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
|
||||
'i'
|
||||
);
|
||||
const planCountText = isComplete
|
||||
? `${summaryCount}/${planCount} plans complete`
|
||||
: `${summaryCount}/${planCount} plans executed`;
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, planCountPattern, `$1${planCountText}`);
|
||||
|
||||
// If complete: check checkbox
|
||||
if (isComplete) {
|
||||
const checkboxPattern = new RegExp(
|
||||
`(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseEscaped}[:\\s][^\\n]*)`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = replaceInCurrentMilestone(roadmapContent, checkboxPattern, `$1x$2 (completed ${today})`);
|
||||
}
|
||||
|
||||
// Mark completed plan checkboxes (e.g. "- [ ] 50-01-PLAN.md" or "- [ ] 50-01:")
|
||||
for (const summaryFile of phaseInfo.summaries) {
|
||||
const planId = summaryFile.replace('-SUMMARY.md', '').replace('SUMMARY.md', '');
|
||||
if (!planId) continue;
|
||||
const planEscaped = escapeRegex(planId);
|
||||
const planCheckboxPattern = new RegExp(
|
||||
`(-\\s*\\[) (\\]\\s*${planEscaped})`,
|
||||
'i'
|
||||
);
|
||||
roadmapContent = roadmapContent.replace(planCheckboxPattern, '$1x$2');
|
||||
}
|
||||
|
||||
fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
|
||||
|
||||
output({
|
||||
updated: true,
|
||||
phase: phaseNum,
|
||||
|
||||
@@ -141,29 +141,28 @@ function cmdStatePatch(cwd, patches, raw) {
|
||||
|
||||
const statePath = planningPaths(cwd).state;
|
||||
try {
|
||||
let content = fs.readFileSync(statePath, 'utf-8');
|
||||
const results = { updated: [], failed: [] };
|
||||
|
||||
for (const [field, value] of Object.entries(patches)) {
|
||||
const fieldEscaped = escapeRegex(field);
|
||||
// Try **Field:** bold format first, then plain Field: format
|
||||
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
|
||||
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
|
||||
// Use atomic read-modify-write to prevent lost updates from concurrent agents
|
||||
readModifyWriteStateMd(statePath, (content) => {
|
||||
for (const [field, value] of Object.entries(patches)) {
|
||||
const fieldEscaped = escapeRegex(field);
|
||||
// Try **Field:** bold format first, then plain Field: format
|
||||
const boldPattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
|
||||
const plainPattern = new RegExp(`(^${fieldEscaped}:\\s*)(.*)`, 'im');
|
||||
|
||||
if (boldPattern.test(content)) {
|
||||
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
|
||||
results.updated.push(field);
|
||||
} else if (plainPattern.test(content)) {
|
||||
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
|
||||
results.updated.push(field);
|
||||
} else {
|
||||
results.failed.push(field);
|
||||
if (boldPattern.test(content)) {
|
||||
content = content.replace(boldPattern, (_match, prefix) => `${prefix}${value}`);
|
||||
results.updated.push(field);
|
||||
} else if (plainPattern.test(content)) {
|
||||
content = content.replace(plainPattern, (_match, prefix) => `${prefix}${value}`);
|
||||
results.updated.push(field);
|
||||
} else {
|
||||
results.failed.push(field);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (results.updated.length > 0) {
|
||||
writeStateMd(statePath, content, cwd);
|
||||
}
|
||||
return content;
|
||||
}, cwd);
|
||||
|
||||
output(results, raw, results.updated.length > 0 ? 'true' : 'false');
|
||||
} catch {
|
||||
@@ -780,6 +779,50 @@ function syncStateFrontmatter(content, cwd) {
|
||||
return `---\n${yamlStr}\n---\n\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Acquire a lockfile for STATE.md operations.
|
||||
* Returns the lock path for later release.
|
||||
*/
|
||||
function acquireStateLock(statePath) {
|
||||
const lockPath = statePath + '.lock';
|
||||
const maxRetries = 10;
|
||||
const retryDelay = 200; // ms
|
||||
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
||||
fs.writeSync(fd, String(process.pid));
|
||||
fs.closeSync(fd);
|
||||
return lockPath;
|
||||
} catch (err) {
|
||||
if (err.code === 'EEXIST') {
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
if (Date.now() - stat.mtimeMs > 10000) {
|
||||
fs.unlinkSync(lockPath);
|
||||
continue;
|
||||
}
|
||||
} catch { /* lock was released between check — retry */ }
|
||||
|
||||
if (i === maxRetries - 1) {
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
return lockPath;
|
||||
}
|
||||
const jitter = Math.floor(Math.random() * 50);
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
|
||||
continue;
|
||||
}
|
||||
return lockPath; // non-EEXIST error — proceed without lock
|
||||
}
|
||||
}
|
||||
return statePath + '.lock';
|
||||
}
|
||||
|
||||
function releaseStateLock(lockPath) {
|
||||
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* Write STATE.md with synchronized YAML frontmatter.
|
||||
* All STATE.md writes should use this instead of raw writeFileSync.
|
||||
@@ -788,48 +831,29 @@ function syncStateFrontmatter(content, cwd) {
|
||||
*/
|
||||
function writeStateMd(statePath, content, cwd) {
|
||||
const synced = syncStateFrontmatter(content, cwd);
|
||||
const lockPath = statePath + '.lock';
|
||||
const maxRetries = 10;
|
||||
const retryDelay = 200; // ms
|
||||
|
||||
// Acquire lock (spin with backoff)
|
||||
for (let i = 0; i < maxRetries; i++) {
|
||||
try {
|
||||
// O_EXCL fails if file already exists — atomic lock
|
||||
const fd = fs.openSync(lockPath, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY);
|
||||
fs.writeSync(fd, String(process.pid));
|
||||
fs.closeSync(fd);
|
||||
break;
|
||||
} catch (err) {
|
||||
if (err.code === 'EEXIST') {
|
||||
// Check for stale lock (> 10s old)
|
||||
try {
|
||||
const stat = fs.statSync(lockPath);
|
||||
if (Date.now() - stat.mtimeMs > 10000) {
|
||||
fs.unlinkSync(lockPath);
|
||||
continue; // retry immediately after clearing stale lock
|
||||
}
|
||||
} catch { /* lock was released between check — retry */ }
|
||||
|
||||
if (i === maxRetries - 1) {
|
||||
// Last resort: write anyway rather than losing data
|
||||
try { fs.unlinkSync(lockPath); } catch {}
|
||||
break;
|
||||
}
|
||||
// Spin-wait with small jitter
|
||||
const jitter = Math.floor(Math.random() * 50);
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < retryDelay + jitter) { /* busy wait */ }
|
||||
continue;
|
||||
}
|
||||
break; // non-EEXIST error — proceed without lock
|
||||
}
|
||||
}
|
||||
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
|
||||
} finally {
|
||||
try { fs.unlinkSync(lockPath); } catch { /* lock already gone */ }
|
||||
releaseStateLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Atomic read-modify-write for STATE.md.
|
||||
* Holds the lock across the entire read -> transform -> write cycle,
|
||||
* preventing the lost-update problem where two agents read the same
|
||||
* content and the second write clobbers the first.
|
||||
*/
|
||||
function readModifyWriteStateMd(statePath, transformFn, cwd) {
|
||||
const lockPath = acquireStateLock(statePath);
|
||||
try {
|
||||
const content = fs.existsSync(statePath) ? fs.readFileSync(statePath, 'utf-8') : '';
|
||||
const modified = transformFn(content);
|
||||
const synced = syncStateFrontmatter(modified, cwd);
|
||||
fs.writeFileSync(statePath, normalizeMd(synced), 'utf-8');
|
||||
} finally {
|
||||
releaseStateLock(lockPath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
819
tests/concurrency-safety.test.cjs
Normal file
819
tests/concurrency-safety.test.cjs
Normal file
@@ -0,0 +1,819 @@
|
||||
/**
|
||||
* GSD Tools Tests - Concurrency Safety
|
||||
*
|
||||
* Tests for fix/concurrency-safety-1473a:
|
||||
* - Planning lock integration (withPlanningLock in phase/roadmap operations)
|
||||
* - readModifyWriteStateMd (atomic state updates)
|
||||
* - normalizeMd behavioral equivalence (O(n) insideFence rewrite)
|
||||
* - Warnings (frontmatter parse warning, stateReplaceFieldWithFallback)
|
||||
* - Performance benchmarks (normalizeMd O(n) verification)
|
||||
* - Snapshot tests for normalizeMd (regression detection)
|
||||
* - Multi-process concurrent write tests
|
||||
* - Stress tests at scale (50+ phases)
|
||||
*/
|
||||
|
||||
const { test, describe, beforeEach, afterEach } = require('node:test');
|
||||
const assert = require('node:assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const { execSync, exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const { performance } = require('perf_hooks');
|
||||
const { runGsdTools, createTempProject, cleanup, TOOLS_PATH } = require('./helpers.cjs');
|
||||
|
||||
const {
|
||||
normalizeMd,
|
||||
} = require('../get-shit-done/bin/lib/core.cjs');
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function writeMinimalRoadmap(tmpDir, phases = ['1']) {
|
||||
const lines = phases.map(n => `### Phase ${n}: Phase ${n} Description`).join('\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n${lines}\n`
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalStateMd(tmpDir, content) {
|
||||
const defaultContent = content || `# Session State\n\n## Current Position\n\nPhase: 1\n`;
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
defaultContent
|
||||
);
|
||||
}
|
||||
|
||||
function writeMinimalProjectMd(tmpDir) {
|
||||
const sections = ['## What This Is', '## Core Value', '## Requirements'];
|
||||
const content = sections.map(s => `${s}\n\nContent here.\n`).join('\n');
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'PROJECT.md'),
|
||||
`# Project\n\n${content}`
|
||||
);
|
||||
}
|
||||
|
||||
function writeValidConfigJson(tmpDir, overrides = {}) {
|
||||
const base = { model_profile: 'balanced', commit_docs: true };
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'config.json'),
|
||||
JSON.stringify({ ...base, ...overrides }, null, 2)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a 50-phase project structure for stress testing.
|
||||
*/
|
||||
function create50PhaseProject(tmpDir, completedCount = 25) {
|
||||
let roadmapContent = '# Roadmap v1.0\n\n';
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
roadmapContent += `- [${i <= completedCount ? 'x' : ' '}] Phase ${i}: Feature ${i}\n`;
|
||||
}
|
||||
roadmapContent += '\n';
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const pad = String(i).padStart(2, '0');
|
||||
roadmapContent += `### Phase ${i}: Feature ${i}\n\n`;
|
||||
roadmapContent += `**Goal:** Build feature ${i}\n`;
|
||||
roadmapContent += `**Requirements:** REQ-${pad}\n`;
|
||||
roadmapContent += `**Plans:** 1 plans\n\n`;
|
||||
roadmapContent += `Plans:\n- [${i <= completedCount ? 'x' : ' '}] ${pad}-01-PLAN.md\n\n`;
|
||||
}
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
roadmapContent
|
||||
);
|
||||
|
||||
const phasesDir = path.join(tmpDir, '.planning', 'phases');
|
||||
for (let i = 1; i <= 50; i++) {
|
||||
const pad = String(i).padStart(2, '0');
|
||||
const dirName = `${pad}-feature-${i}`;
|
||||
const phaseDir = path.join(phasesDir, dirName);
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, `${pad}-01-PLAN.md`),
|
||||
`# Phase ${i} Plan 1\n\nBuild feature ${i}.\n`
|
||||
);
|
||||
if (i <= completedCount) {
|
||||
fs.writeFileSync(
|
||||
path.join(phaseDir, `${pad}-01-SUMMARY.md`),
|
||||
`# Phase ${i} Plan 1 Summary\n\nFeature ${i} completed.\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 1. Planning lock integration
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('planning lock integration', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('phase add creates and releases .planning/.lock during ROADMAP write', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n---\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase add Testing', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', '.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase add');
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_number, 2, 'should be phase 2');
|
||||
});
|
||||
|
||||
test('phase complete creates and releases .planning/.lock', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n- [ ] Phase 1: Foundation\n\n### Phase 1: Foundation\n**Goal:** Setup\n**Plans:** 1 plans\n\n### Phase 2: API\n**Goal:** Build\n`
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# State\n\n**Current Phase:** 01\n**Current Phase Name:** Foundation\n**Status:** In progress\n**Current Plan:** 01-01\n**Last Activity:** 2025-01-01\n**Last Activity Description:** Working\n`
|
||||
);
|
||||
|
||||
const p1 = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(p1, { recursive: true });
|
||||
fs.writeFileSync(path.join(p1, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(p1, '01-01-SUMMARY.md'), '# Summary');
|
||||
fs.mkdirSync(path.join(tmpDir, '.planning', 'phases', '02-api'), { recursive: true });
|
||||
|
||||
const result = runGsdTools('phase complete 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', '.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after phase complete');
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.completed_phase, '1', 'phase should be completed');
|
||||
});
|
||||
|
||||
test('roadmap update-plan-progress creates and releases .planning/.lock', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap\n\n| Phase | Plans | Status | Updated |\n|-------|-------|--------|---------|\n| 1 | 0/0 | Not started | - |\n\n### Phase 1: Foundation\n**Goal:** Setup\n`
|
||||
);
|
||||
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '01-foundation');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-PLAN.md'), '# Plan');
|
||||
fs.writeFileSync(path.join(phaseDir, '01-01-SUMMARY.md'), '# Summary');
|
||||
|
||||
const result = runGsdTools('roadmap update-plan-progress 1', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', '.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), '.lock file should be released after roadmap update');
|
||||
});
|
||||
|
||||
test('lock file does NOT persist after successful phase operations', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0\n`
|
||||
);
|
||||
|
||||
runGsdTools('phase add First Phase', tmpDir);
|
||||
runGsdTools('phase add Second Phase', tmpDir);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', '.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), '.lock file should not persist after multiple operations');
|
||||
});
|
||||
|
||||
test('phase add still works correctly with lock (behavioral regression)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
`# Roadmap v1.0\n\n### Phase 1: Foundation\n**Goal:** Setup\n\n### Phase 2: API\n**Goal:** Build API\n\n---\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase add User Dashboard', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.strictEqual(output.phase_number, 3, 'should be phase 3');
|
||||
assert.strictEqual(output.slug, 'user-dashboard');
|
||||
|
||||
assert.ok(
|
||||
fs.existsSync(path.join(tmpDir, '.planning', 'phases', '03-user-dashboard')),
|
||||
'directory should be created'
|
||||
);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
assert.ok(roadmap.includes('### Phase 3: User Dashboard'), 'roadmap should include new phase');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. readModifyWriteStateMd (tested via CLI commands that use it)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('readModifyWriteStateMd (via state patch)', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('transforms content atomically (read + modify + write under lock)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 03\n**Status:** Planning\n**Current Plan:** 03-01\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('state patch --Status "In progress" --"Current Plan" 03-02', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(content.includes('**Status:** In progress'), 'Status should be updated');
|
||||
assert.ok(content.includes('03-02'), 'Current Plan should be updated');
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should be released after patch');
|
||||
});
|
||||
|
||||
test('lock file cleaned up after state patch operation', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 01\n**Status:** Ready\n`
|
||||
);
|
||||
|
||||
runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after operation');
|
||||
});
|
||||
|
||||
test('state patch still works correctly via readModifyWriteStateMd path (behavioral regression)', () => {
|
||||
const stateMd = [
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 03',
|
||||
'**Status:** Planning',
|
||||
'**Current Plan:** 03-01',
|
||||
'**Last Activity:** 2024-01-15',
|
||||
].join('\n') + '\n';
|
||||
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'STATE.md'), stateMd);
|
||||
|
||||
const result = runGsdTools('state patch --Status Complete --"Current Phase" 04', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const updated = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(updated.includes('**Status:** Complete'), 'Status should be updated to Complete');
|
||||
assert.ok(updated.includes('**Last Activity:** 2024-01-15'), 'Last Activity should be unchanged');
|
||||
});
|
||||
|
||||
test('two sequential state patches both persist (patch A then patch B)', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n**Last Activity:** 2024-01-01\n`
|
||||
);
|
||||
|
||||
const resultA = runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
assert.ok(resultA.success, `Patch A failed: ${resultA.error}`);
|
||||
|
||||
const resultB = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
|
||||
assert.ok(resultB.success, `Patch B failed: ${resultB.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(content.includes('**Status:** In progress'), 'Patch A (Status) should persist');
|
||||
assert.ok(content.includes('01-02'), 'Patch B (Current Plan) should persist');
|
||||
assert.ok(content.includes('**Last Activity:** 2024-01-01'), 'Untouched field should be preserved');
|
||||
});
|
||||
|
||||
test('lock file does not persist after rapid sequential patches', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 01\n**Status:** Planning\n**Current Plan:** 01-01\n`
|
||||
);
|
||||
|
||||
runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
|
||||
runGsdTools('state patch --Status Complete', tmpDir);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
|
||||
assert.ok(!fs.existsSync(lockPath), 'STATE.md.lock should not persist after rapid sequential patches');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 3. Multi-process concurrent write tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('multi-process concurrent write tests', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('two concurrent state patches to DIFFERENT fields both persist', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 01',
|
||||
'**Status:** In progress',
|
||||
'**Current Plan:** 01-01',
|
||||
'**Last Activity:** 2025-01-01',
|
||||
'**Last Activity Description:** Working',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const toolsPath = TOOLS_PATH;
|
||||
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
|
||||
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
|
||||
|
||||
const [resultA, resultB] = await Promise.all([
|
||||
execAsync(cmdA, { encoding: 'utf-8' }).catch(e => e),
|
||||
execAsync(cmdB, { encoding: 'utf-8' }).catch(e => e),
|
||||
]);
|
||||
|
||||
const aOk = !(resultA instanceof Error);
|
||||
const bOk = !(resultB instanceof Error);
|
||||
assert.ok(aOk || bOk, 'At least one concurrent patch should succeed');
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
|
||||
assert.ok(
|
||||
content.includes('Complete') || content.includes('01-02'),
|
||||
`At least one concurrent patch should persist in STATE.md. Content:\n${content}`
|
||||
);
|
||||
|
||||
if (content.includes('Complete') && content.includes('01-02')) {
|
||||
assert.ok(true, 'Both concurrent patches persisted (lock serialization)');
|
||||
}
|
||||
|
||||
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field Current Phase should survive');
|
||||
assert.ok(content.includes('2025-01-01'), 'Untouched field Last Activity should survive');
|
||||
});
|
||||
|
||||
test('lock file does not persist after concurrent operations', async () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 01',
|
||||
'**Status:** Planning',
|
||||
'**Current Plan:** 01-01',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const toolsPath = TOOLS_PATH;
|
||||
const cmdA = `node "${toolsPath}" state patch --Status Complete --cwd "${tmpDir}"`;
|
||||
const cmdB = `node "${toolsPath}" state patch --"Current Plan" 01-02 --cwd "${tmpDir}"`;
|
||||
|
||||
await Promise.all([
|
||||
execAsync(cmdA, { encoding: 'utf-8' }).catch(() => {}),
|
||||
execAsync(cmdB, { encoding: 'utf-8' }).catch(() => {}),
|
||||
]);
|
||||
|
||||
const lockPath = path.join(tmpDir, '.planning', 'STATE.md.lock');
|
||||
assert.ok(
|
||||
!fs.existsSync(lockPath),
|
||||
'STATE.md.lock should not persist after concurrent operations complete'
|
||||
);
|
||||
});
|
||||
|
||||
test('three rapid sequential patches all persist', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
[
|
||||
'# Project State',
|
||||
'',
|
||||
'**Current Phase:** 01',
|
||||
'**Status:** Planning',
|
||||
'**Current Plan:** 01-01',
|
||||
'**Last Activity:** 2025-01-01',
|
||||
'',
|
||||
].join('\n')
|
||||
);
|
||||
|
||||
const r1 = runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
assert.ok(r1.success, `Patch 1 failed: ${r1.error}`);
|
||||
|
||||
const r2 = runGsdTools('state patch --"Current Plan" 01-02', tmpDir);
|
||||
assert.ok(r2.success, `Patch 2 failed: ${r2.error}`);
|
||||
|
||||
const r3 = runGsdTools('state patch --"Last Activity" 2025-06-15', tmpDir);
|
||||
assert.ok(r3.success, `Patch 3 failed: ${r3.error}`);
|
||||
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(content.includes('In progress'), 'Patch 1 (Status) should persist');
|
||||
assert.ok(content.includes('01-02'), 'Patch 2 (Current Plan) should persist');
|
||||
assert.ok(content.includes('2025-06-15'), 'Patch 3 (Last Activity) should persist');
|
||||
assert.ok(content.includes('**Current Phase:** 01'), 'Untouched field should be preserved');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 4. normalizeMd behavioral equivalence (O(n) insideFence rewrite)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeMd behavioral equivalence', () => {
|
||||
test('simple markdown with headings and paragraphs', () => {
|
||||
const input = '# Title\nSome text.\n## Section\nMore text.\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('# Title\n\nSome text.'), 'title heading should have blank line after');
|
||||
assert.ok(result.includes('\n\n## Section\n\nMore text.'), 'section heading should have blank lines around it');
|
||||
assert.ok(result.endsWith('\n'), 'should end with newline');
|
||||
assert.ok(!result.endsWith('\n\n'), 'should not end with double newline');
|
||||
});
|
||||
|
||||
test('single fenced code block gets blank lines before/after', () => {
|
||||
const input = 'Some text\n```js\nconst x = 1;\n```\nMore text\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('Some text\n\n```js'), 'code block should have blank line before');
|
||||
assert.ok(result.includes('```\n\nMore text'), 'code block should have blank line after');
|
||||
assert.ok(result.includes('const x = 1;'), 'code content should be preserved');
|
||||
});
|
||||
|
||||
test('multiple fenced code blocks', () => {
|
||||
const input = 'Intro\n```js\nfoo();\n```\nMiddle\n```py\nbar()\n```\nEnd\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(result.includes('Intro\n\n```js'), 'first code block should have blank line before');
|
||||
assert.ok(result.includes('```\n\nMiddle'), 'first code block should have blank line after');
|
||||
assert.ok(result.includes('Middle\n\n```py'), 'second code block should have blank line before');
|
||||
assert.ok(result.includes('```\n\nEnd'), 'second code block should have blank line after');
|
||||
});
|
||||
|
||||
test('unclosed fence at end of file (edge case)', () => {
|
||||
const input = 'Some text\n```js\nconst x = 1;\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.ok(typeof result === 'string', 'should return a string');
|
||||
assert.ok(result.includes('```js'), 'fence opener should be preserved');
|
||||
assert.ok(result.includes('const x = 1;'), 'content after unclosed fence should be preserved');
|
||||
assert.ok(result.endsWith('\n'), 'should end with newline');
|
||||
});
|
||||
|
||||
test('empty string input', () => {
|
||||
assert.strictEqual(normalizeMd(''), '', 'empty string should return empty string');
|
||||
});
|
||||
|
||||
test('mixed headings + lists + fences (complex case)', () => {
|
||||
const input = [
|
||||
'# Title',
|
||||
'## Section One',
|
||||
'Paragraph text.',
|
||||
'- item 1',
|
||||
'- item 2',
|
||||
'## Section Two',
|
||||
'```bash',
|
||||
'echo hello',
|
||||
'```',
|
||||
'After code.',
|
||||
'## Section Three',
|
||||
'1. First',
|
||||
'2. Second',
|
||||
'Done.',
|
||||
].join('\n') + '\n';
|
||||
|
||||
const result = normalizeMd(input);
|
||||
|
||||
assert.ok(result.includes('\n\n## Section One\n\n'), 'Section One heading needs blank lines');
|
||||
assert.ok(result.includes('\n\n## Section Two\n\n'), 'Section Two heading needs blank lines');
|
||||
assert.ok(result.includes('\n\n## Section Three\n\n'), 'Section Three heading needs blank lines');
|
||||
assert.ok(result.includes('Paragraph text.\n\n- item 1'), 'list should have blank line before');
|
||||
assert.ok(result.includes('\n\n```bash'), 'code block should have blank line before');
|
||||
assert.ok(result.includes('```\n\nAfter code.'), 'code block should have blank line after');
|
||||
assert.ok(result.includes('echo hello'), 'code content should be preserved');
|
||||
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 5. normalizeMd performance benchmark
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeMd performance benchmark', () => {
|
||||
test('processes a 100-line markdown file in under 50ms', () => {
|
||||
const lines = [];
|
||||
for (let i = 0; i < 100; i++) {
|
||||
if (i % 20 === 0) {
|
||||
lines.push(`## Section ${i / 20 + 1}`);
|
||||
} else if (i % 30 === 0) {
|
||||
lines.push('```js');
|
||||
lines.push(`const x${i} = ${i};`);
|
||||
lines.push('```');
|
||||
} else if (i % 5 === 0) {
|
||||
lines.push(`- List item ${i}`);
|
||||
} else {
|
||||
lines.push(`Paragraph text line ${i} with some content to process.`);
|
||||
}
|
||||
}
|
||||
const input = lines.join('\n') + '\n';
|
||||
|
||||
const start = performance.now();
|
||||
const result = normalizeMd(input);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert.ok(typeof result === 'string', 'should return a string');
|
||||
assert.ok(result.length > 0, 'result should not be empty');
|
||||
assert.ok(result.endsWith('\n'), 'result should end with newline');
|
||||
assert.ok(elapsed < 50, `100-line file should process in under 50ms, took ${elapsed.toFixed(2)}ms`);
|
||||
});
|
||||
|
||||
test('processes a 1000-line markdown file with 20 code blocks in under 200ms', () => {
|
||||
const lines = [];
|
||||
let codeBlockCount = 0;
|
||||
for (let i = 0; i < 1000; i++) {
|
||||
if (i % 50 === 0 && codeBlockCount < 20) {
|
||||
lines.push(`## Section ${codeBlockCount + 1}`);
|
||||
lines.push('');
|
||||
lines.push('Some introductory text for this section.');
|
||||
lines.push('');
|
||||
lines.push('```python');
|
||||
for (let j = 0; j < 5; j++) {
|
||||
lines.push(` result_${codeBlockCount}_${j} = compute(${j})`);
|
||||
}
|
||||
lines.push('```');
|
||||
lines.push('');
|
||||
lines.push('Explanation of the code above.');
|
||||
codeBlockCount++;
|
||||
} else if (i % 10 === 0) {
|
||||
lines.push(`### Subsection at line ${i}`);
|
||||
} else if (i % 7 === 0) {
|
||||
lines.push(`- Item ${i}: description of this list item`);
|
||||
} else if (i % 13 === 0) {
|
||||
lines.push(`1. Ordered item ${i}`);
|
||||
} else {
|
||||
lines.push(`Line ${i}: Regular paragraph content with various markdown elements.`);
|
||||
}
|
||||
}
|
||||
const input = lines.join('\n') + '\n';
|
||||
|
||||
normalizeMd(input); // warm up JIT
|
||||
|
||||
const start = performance.now();
|
||||
const result = normalizeMd(input);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert.ok(typeof result === 'string', 'should return a string');
|
||||
assert.ok(result.length > 0, 'result should not be empty');
|
||||
assert.ok(result.endsWith('\n'), 'result should end with newline');
|
||||
assert.ok(!result.includes('\n\n\n'), 'should not have 3+ consecutive blank lines');
|
||||
assert.ok(elapsed < 200, `1000-line file with 20 code blocks should process in under 200ms, took ${elapsed.toFixed(2)}ms`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 6. normalizeMd snapshot tests
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('normalizeMd snapshot tests', () => {
|
||||
test('snapshot - heading spacing', () => {
|
||||
const input = '# Title\nParagraph\n## Section\nMore text';
|
||||
const expected = '# Title\n\nParagraph\n\n## Section\n\nMore text\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.strictEqual(result, expected,
|
||||
`Heading spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot - code block spacing', () => {
|
||||
const input = 'Text before\n```js\nconst x = 1;\n```\nText after\n';
|
||||
const expected = 'Text before\n\n```js\nconst x = 1;\n```\n\nText after\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.strictEqual(result, expected,
|
||||
`Code block spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot - list spacing', () => {
|
||||
const input = 'Paragraph\n- item 1\n- item 2\nAnother paragraph';
|
||||
const expected = 'Paragraph\n\n- item 1\n- item 2\n\nAnother paragraph\n';
|
||||
const result = normalizeMd(input);
|
||||
assert.strictEqual(result, expected,
|
||||
`List spacing snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
|
||||
);
|
||||
});
|
||||
|
||||
test('snapshot - complex mixed document', () => {
|
||||
const input = [
|
||||
'# Main Title',
|
||||
'Intro paragraph.',
|
||||
'## Section One',
|
||||
'Some text here.',
|
||||
'```js',
|
||||
'const a = 1;',
|
||||
'```',
|
||||
'- first item',
|
||||
'- second item',
|
||||
'## Section Two',
|
||||
'Final text.',
|
||||
].join('\n');
|
||||
|
||||
const expected = [
|
||||
'# Main Title',
|
||||
'',
|
||||
'Intro paragraph.',
|
||||
'',
|
||||
'## Section One',
|
||||
'',
|
||||
'Some text here.',
|
||||
'',
|
||||
'```js',
|
||||
'const a = 1;',
|
||||
'```',
|
||||
'',
|
||||
'- first item',
|
||||
'- second item',
|
||||
'',
|
||||
'## Section Two',
|
||||
'',
|
||||
'Final text.',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const result = normalizeMd(input);
|
||||
assert.strictEqual(result, expected,
|
||||
`Complex mixed document snapshot mismatch.\nGot: ${JSON.stringify(result)}\nExpected: ${JSON.stringify(expected)}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 7. Warnings (frontmatter parse, state field miss)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('warnings', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('must_haves parse warning fires for block with content but 0 items', () => {
|
||||
const planDir = path.join(tmpDir, '.planning', 'phases', '01-test');
|
||||
fs.mkdirSync(planDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(planDir, '01-01-PLAN.md'),
|
||||
`---
|
||||
phase: "01"
|
||||
plan: "01"
|
||||
must_haves:
|
||||
acceptance:
|
||||
bare content without dash prefix
|
||||
another line without dash prefix
|
||||
---
|
||||
|
||||
# Plan 01-01
|
||||
`
|
||||
);
|
||||
|
||||
const result = runGsdTools(
|
||||
['frontmatter', 'get', path.join(planDir, '01-01-PLAN.md'), 'must_haves'],
|
||||
tmpDir
|
||||
);
|
||||
|
||||
const stderr = result.error || '';
|
||||
assert.ok(
|
||||
stderr.includes('WARNING') && stderr.includes('must_haves') ||
|
||||
result.output.includes('acceptance'),
|
||||
`Expected WARNING about must_haves parse or valid parse result. stderr: ${stderr}, stdout: ${result.output}`
|
||||
);
|
||||
});
|
||||
|
||||
test('stateReplaceFieldWithFallback logs warning on miss', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
`# Project State\n\n**Current Phase:** 01\n**Current Plan:** 1\n**Total Plans in Phase:** 3\n`
|
||||
);
|
||||
|
||||
const result = runGsdTools('state advance-plan', tmpDir);
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(output.advanced === true || output.reason === 'last_plan', 'advance should complete');
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 8. Malformed input resilience
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('malformed input resilience', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('STATE.md with invalid bold format -- state patch returns gracefully', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'# Project State\n\n**Current Phase: 01\n**Status:** Planning\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
const didNotCrash = result.success || (result.output !== undefined);
|
||||
assert.ok(didNotCrash, `state patch should not crash on malformed bold format: ${result.error}`);
|
||||
|
||||
if (result.success) {
|
||||
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
assert.ok(
|
||||
content.includes('In progress'),
|
||||
'Status field (with valid bold format) should be updated'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('STATE.md with only frontmatter, no body -- state patch handles gracefully', () => {
|
||||
fs.writeFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'),
|
||||
'---\nphase: "01"\n---\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('state patch --Status "In progress"', tmpDir);
|
||||
const didNotCrash = result.success || (result.output !== undefined);
|
||||
assert.ok(didNotCrash, `state patch should not crash on frontmatter-only STATE.md: ${result.error}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 9. Stress tests with 50+ phases
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('stress tests with 50+ phases', () => {
|
||||
let tmpDir;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = createTempProject();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup(tmpDir);
|
||||
});
|
||||
|
||||
test('roadmap analyze on 50-phase ROADMAP completes in under 2000ms', () => {
|
||||
create50PhaseProject(tmpDir, 25);
|
||||
|
||||
const start = performance.now();
|
||||
const result = runGsdTools('roadmap analyze', tmpDir);
|
||||
const elapsed = performance.now() - start;
|
||||
|
||||
assert.ok(result.success, `roadmap analyze should succeed: ${result.error}`);
|
||||
assert.ok(elapsed < 2000, `Should complete in under 2000ms, took ${elapsed.toFixed(0)}ms`);
|
||||
|
||||
const output = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(output.phases), 'Output should contain a phases array');
|
||||
assert.strictEqual(output.phases.length, 50, `Should have 50 phases, got ${output.phases.length}`);
|
||||
|
||||
const completedPhases = output.phases.filter(p => p.disk_status === 'complete');
|
||||
assert.strictEqual(completedPhases.length, 25, `Should have 25 complete phases, got ${completedPhases.length}`);
|
||||
});
|
||||
|
||||
test('phase complete on phase 26 of 50-phase project works correctly', () => {
|
||||
create50PhaseProject(tmpDir, 25);
|
||||
writeMinimalStateMd(tmpDir, '# Session State\n\n**Current Phase:** 26\n**Status:** In progress\n');
|
||||
|
||||
const phase26Dir = path.join(tmpDir, '.planning', 'phases', '26-feature-26');
|
||||
fs.writeFileSync(
|
||||
path.join(phase26Dir, '26-01-SUMMARY.md'),
|
||||
'# Phase 26 Plan 1 Summary\n\nFeature 26 completed.\n'
|
||||
);
|
||||
|
||||
const result = runGsdTools('phase complete 26', tmpDir);
|
||||
assert.ok(result.success, `phase complete 26 should succeed: ${result.error}`);
|
||||
|
||||
const roadmapContent = fs.readFileSync(
|
||||
path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'utf-8'
|
||||
);
|
||||
const phase26Checkbox = roadmapContent.match(/-\s*\[(x| )\]\s*.*Phase\s+26/i);
|
||||
assert.ok(phase26Checkbox, 'Should find Phase 26 checkbox in ROADMAP');
|
||||
assert.strictEqual(phase26Checkbox[1], 'x', 'Phase 26 should now be marked as complete [x]');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user