Merge pull request #1149 from Solvely-Colin/Solvely/health-non-destructive-state-repair

fix(health): preserve state on phase mismatch repair
This commit is contained in:
Tom Boucher
2026-03-19 12:02:11 -04:00
committed by GitHub
3 changed files with 36 additions and 24 deletions

View File

@@ -609,8 +609,12 @@ function cmdValidateHealth(cwd, options, raw) {
if (!diskPhases.has(ref) && !diskPhases.has(normalizedRef) && !diskPhases.has(String(parseInt(ref, 10)))) {
// Only warn if phases dir has any content (not just an empty project)
if (diskPhases.size > 0) {
addIssue('warning', 'W002', `STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`, 'Run /gsd:health --repair to regenerate STATE.md', true);
if (!repairs.includes('regenerateState')) repairs.push('regenerateState');
addIssue(
'warning',
'W002',
`STATE.md references phase ${ref}, but only phases ${[...diskPhases].sort().join(', ')} exist`,
'Review STATE.md manually before changing it; /gsd:health --repair will not overwrite an existing STATE.md for phase mismatches'
);
}
}
}

View File

@@ -72,8 +72,8 @@ Errors: N | Warnings: N | Info: N
```
## Warnings
- [W001] STATE.md references phase 5, but only phases 1-3 exist
Fix: Run /gsd:health --repair to regenerate
- [W002] STATE.md references phase 5, but only phases 1-3 exist
Fix: Review STATE.md manually before changing it; repair will not overwrite an existing STATE.md
- [W005] Phase directory "1-setup" doesn't follow NN-name format
Fix: Rename to match pattern (e.g., 01-setup)
@@ -130,7 +130,7 @@ Report final status.
| E004 | error | STATE.md not found | Yes |
| E005 | error | config.json parse error | Yes |
| W001 | warning | PROJECT.md missing required section | No |
| W002 | warning | STATE.md references invalid phase | Yes |
| W002 | warning | STATE.md references invalid phase | No |
| W003 | warning | config.json not found | Yes |
| W004 | warning | config.json invalid field value | No |
| W005 | warning | Phase directory naming mismatch | No |
@@ -148,7 +148,7 @@ Report final status.
|--------|--------|------|
| createConfig | Create config.json with defaults | None |
| resetConfig | Delete + recreate config.json | Loses custom settings |
| regenerateState | Create STATE.md from ROADMAP structure | Loses session history |
| regenerateState | Create STATE.md from ROADMAP structure when it is missing | Loses session history |
| addNyquistKey | Add workflow.nyquist_validation: true to config.json | None — matches existing default |
**Not repairable (too risky):**

View File

@@ -191,10 +191,9 @@ describe('validate health command', () => {
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.ok(
output.warnings.some(w => w.code === 'W002'),
`Expected W002 in warnings: ${JSON.stringify(output.warnings)}`
);
const w002 = output.warnings.find(w => w.code === 'W002');
assert.ok(w002, `Expected W002 in warnings: ${JSON.stringify(output.warnings)}`);
assert.strictEqual(w002.repairable, false, 'W002 should not be auto-repairable');
});
// ─── Check 5: config.json valid JSON + valid schema ───────────────────────
@@ -613,16 +612,13 @@ describe('validate health --repair command', () => {
assert.ok(stateContent.includes('# Session State'), 'regenerated STATE.md should contain "# Session State"');
});
test('backs up existing STATE.md before regenerating', () => {
test('does not rewrite existing STATE.md for invalid phase references', () => {
writeValidConfigJson(tmpDir);
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
const originalContent = '# Session State\n\nOriginal content here.\n';
fs.writeFileSync(statePath, originalContent);
// Make STATE.md reference a nonexistent phase so repair is triggered
const originalContent = '# Session State\n\nPhase 99 is current.\n';
fs.writeFileSync(
statePath,
'# Session State\n\nPhase 99 is current.\n'
originalContent
);
const result = runGsdTools('validate health --repair', tmpDir);
@@ -630,19 +626,17 @@ describe('validate health --repair command', () => {
const output = JSON.parse(result.output);
assert.ok(
Array.isArray(output.repairs_performed),
`Expected repairs_performed: ${JSON.stringify(output)}`
!Array.isArray(output.repairs_performed) || !output.repairs_performed.some(r => r.action === 'regenerateState'),
`Did not expect regenerateState for W002: ${JSON.stringify(output)}`
);
// Verify a .bak- file exists alongside STATE.md
const stateContent = fs.readFileSync(statePath, 'utf-8');
assert.strictEqual(stateContent, originalContent, 'existing STATE.md should be preserved');
const planningDir = path.join(tmpDir, '.planning');
const planningFiles = fs.readdirSync(planningDir);
const backupFile = planningFiles.find(f => f.startsWith('STATE.md.bak-'));
assert.ok(backupFile, `Expected a STATE.md.bak- file. Found files: ${planningFiles.join(', ')}`);
// Verify backup contains the original content
const backupContent = fs.readFileSync(path.join(planningDir, backupFile), 'utf-8');
assert.ok(backupContent.includes('Phase 99'), 'backup should contain the original STATE.md content');
assert.strictEqual(backupFile, undefined, `Did not expect backup file for non-destructive repair. Found: ${planningFiles.join(', ')}`);
});
test('adds nyquist_validation key to config.json via addNyquistKey repair', () => {
@@ -688,4 +682,18 @@ describe('validate health --repair command', () => {
`Expected repairable_count >= 2, got ${output.repairable_count}. Full output: ${JSON.stringify(output)}`
);
});
test('phase mismatch warnings do not count as repairable issues', () => {
writeValidConfigJson(tmpDir);
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
'# Session State\n\nPhase 99 is the current phase.\n'
);
const result = runGsdTools('validate health', tmpDir);
assert.ok(result.success, `Command failed: ${result.error}`);
const output = JSON.parse(result.output);
assert.strictEqual(output.repairable_count, 0, `Expected no repairable issues for W002: ${JSON.stringify(output)}`);
});
});