Merge pull request #2053 from Tibsfox/fix/merge-readdir-health-1973

perf(health): merge four readdirSync passes into one in cmdValidateHealth
This commit is contained in:
Tom Boucher
2026-04-11 13:55:26 -04:00
committed by GitHub
2 changed files with 82 additions and 40 deletions

View File

@@ -655,52 +655,55 @@ function cmdValidateHealth(cwd, options, raw) {
} catch { /* intentionally empty */ }
}
// ─── Check 6: Phase directory naming (NN-name format) ─────────────────────
// ─── Read phase directories once for checks 6, 7, 7b, and 8 (#1973) ──────
let phaseDirEntries = [];
const phaseDirFiles = new Map(); // phase dir name → file list
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory() && !e.name.match(/^\d{2}(?:\.\d+)*-[\w-]+$/)) {
addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
}
phaseDirEntries = fs.readdirSync(phasesDir, { withFileTypes: true }).filter(e => e.isDirectory());
for (const e of phaseDirEntries) {
try {
phaseDirFiles.set(e.name, fs.readdirSync(path.join(phasesDir, e.name)));
} catch { phaseDirFiles.set(e.name, []); }
}
} catch { /* intentionally empty */ }
// ─── Check 6: Phase directory naming (NN-name format) ─────────────────────
for (const e of phaseDirEntries) {
if (!e.name.match(/^\d{2}(?:\.\d+)*-[\w-]+$/)) {
addIssue('warning', 'W005', `Phase directory "${e.name}" doesn't follow NN-name format`, 'Rename to match pattern (e.g., 01-setup)');
}
}
// ─── Check 7: Orphaned plans (PLAN without SUMMARY) ───────────────────────
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (!e.isDirectory()) continue;
const phaseFiles = fs.readdirSync(path.join(phasesDir, e.name));
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const summaryBases = new Set(summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')));
for (const e of phaseDirEntries) {
const phaseFiles = phaseDirFiles.get(e.name) || [];
const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
const summaryBases = new Set(summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', '')));
for (const plan of plans) {
const planBase = plan.replace('-PLAN.md', '').replace('PLAN.md', '');
if (!summaryBases.has(planBase)) {
addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
}
for (const plan of plans) {
const planBase = plan.replace('-PLAN.md', '').replace('PLAN.md', '');
if (!summaryBases.has(planBase)) {
addIssue('info', 'I001', `${e.name}/${plan} has no SUMMARY.md`, 'May be in progress');
}
}
} catch { /* intentionally empty */ }
}
// ─── Check 7b: Nyquist VALIDATION.md consistency ────────────────────────
try {
const phaseEntries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of phaseEntries) {
if (!e.isDirectory()) continue;
const phaseFiles = fs.readdirSync(path.join(phasesDir, e.name));
const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md'));
const hasValidation = phaseFiles.some(f => f.endsWith('-VALIDATION.md'));
if (hasResearch && !hasValidation) {
const researchFile = phaseFiles.find(f => f.endsWith('-RESEARCH.md'));
for (const e of phaseDirEntries) {
const phaseFiles = phaseDirFiles.get(e.name) || [];
const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md'));
const hasValidation = phaseFiles.some(f => f.endsWith('-VALIDATION.md'));
if (hasResearch && !hasValidation) {
const researchFile = phaseFiles.find(f => f.endsWith('-RESEARCH.md'));
try {
const researchContent = fs.readFileSync(path.join(phasesDir, e.name, researchFile), 'utf-8');
if (researchContent.includes('## Validation Architecture')) {
addIssue('warning', 'W009', `Phase ${e.name}: has Validation Architecture in RESEARCH.md but no VALIDATION.md`, 'Re-run /gsd-plan-phase with --research to regenerate');
}
}
} catch { /* intentionally empty */ }
}
} catch { /* intentionally empty */ }
}
// ─── Check 7c: Agent installation (#1371) ──────────────────────────────────
// Verify GSD agents are installed. Missing agents cause Task(subagent_type=...)
@@ -733,15 +736,10 @@ function cmdValidateHealth(cwd, options, raw) {
}
const diskPhases = new Set();
try {
const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
for (const e of entries) {
if (e.isDirectory()) {
const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (dm) diskPhases.add(dm[1]);
}
}
} catch { /* intentionally empty */ }
for (const e of phaseDirEntries) {
const dm = e.name.match(/^(\d+[A-Z]?(?:\.\d+)*)/i);
if (dm) diskPhases.add(dm[1]);
}
// Build a set of phases explicitly marked not-yet-started in the ROADMAP
// summary list (- [ ] **Phase N:**). These phases are intentionally absent

View File

@@ -767,3 +767,47 @@ describe('validate health --repair command', () => {
assert.strictEqual(output.repairable_count, 0, `Expected no repairable issues for W002: ${JSON.stringify(output)}`);
});
});
// ─────────────────────────────────────────────────────────────────────────────
// Graceful degradation when phasesDir is missing (#1973)
// ─────────────────────────────────────────────────────────────────────────────
describe('validate health — missing phasesDir', () => {
let tmpDir;
beforeEach(() => {
tmpDir = createTempProject();
});
afterEach(() => {
cleanup(tmpDir);
});
test('completes without throwing and emits zero phase-directory warnings when phasesDir does not exist', () => {
// Setup: valid PROJECT, ROADMAP, STATE, config but NO phases directory
writeMinimalProjectMd(tmpDir);
writeMinimalRoadmap(tmpDir, ['1', '2']);
writeMinimalStateMd(tmpDir);
writeValidConfigJson(tmpDir);
// Remove the phases directory if it exists
const phasesDir = path.join(tmpDir, '.planning', 'phases');
if (fs.existsSync(phasesDir)) {
fs.rmSync(phasesDir, { recursive: true, force: true });
}
// Should complete without throwing
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command should succeed when phasesDir is missing: ${result.error}`);
const output = JSON.parse(result.output);
// Assert no phase-directory warnings fired
const phaseDirCodes = ['W005', 'W006', 'W007', 'W009', 'I001'];
const issues = output.issues || [];
for (const code of phaseDirCodes) {
const matches = issues.filter(i => i.code === code);
assert.strictEqual(matches.length, 0, `Expected no ${code} issues when phasesDir is missing, got: ${JSON.stringify(matches)}`);
}
});
});