mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-04-25 17:25:23 +02:00
Adds W018 warning when .planning/milestones/vX.Y-ROADMAP.md snapshots exist without a corresponding entry in MILESTONES.md. Introduces --backfill flag to synthesize missing entries from snapshot titles. Closes #2446
This commit is contained in:
@@ -764,7 +764,8 @@ async function runCommand(command, args, cwd, raw, defaultValue) {
|
||||
verify.cmdValidateConsistency(cwd, raw);
|
||||
} else if (subcommand === 'health') {
|
||||
const repairFlag = args.includes('--repair');
|
||||
verify.cmdValidateHealth(cwd, { repair: repairFlag }, raw);
|
||||
const backfillFlag = args.includes('--backfill');
|
||||
verify.cmdValidateHealth(cwd, { repair: repairFlag, backfill: backfillFlag }, raw);
|
||||
} else if (subcommand === 'agents') {
|
||||
verify.cmdValidateAgents(cwd, raw);
|
||||
} else {
|
||||
|
||||
@@ -871,6 +871,38 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
}
|
||||
} catch { /* git worktree not available or not a git repo — skip silently */ }
|
||||
|
||||
// ─── Check 12: MILESTONES.md / archive snapshot drift (#2446) ─────────────
|
||||
const milestonesPath = path.join(planBase, 'MILESTONES.md');
|
||||
const milestonesArchiveDir = path.join(planBase, 'milestones');
|
||||
const missingFromRegistry = [];
|
||||
try {
|
||||
if (fs.existsSync(milestonesArchiveDir)) {
|
||||
const archiveFiles = fs.readdirSync(milestonesArchiveDir);
|
||||
const archivedVersions = archiveFiles
|
||||
.map(f => f.match(/^(v\d+\.\d+(?:\.\d+)?)-ROADMAP\.md$/))
|
||||
.filter(Boolean)
|
||||
.map(m => m[1]);
|
||||
|
||||
if (archivedVersions.length > 0) {
|
||||
const registryContent = fs.existsSync(milestonesPath)
|
||||
? fs.readFileSync(milestonesPath, 'utf-8')
|
||||
: '';
|
||||
for (const ver of archivedVersions) {
|
||||
if (!registryContent.includes(`## ${ver}`)) {
|
||||
missingFromRegistry.push(ver);
|
||||
}
|
||||
}
|
||||
if (missingFromRegistry.length > 0) {
|
||||
addIssue('warning', 'W018',
|
||||
`MILESTONES.md missing ${missingFromRegistry.length} archived milestone(s): ${missingFromRegistry.join(', ')}`,
|
||||
'Run /gsd-health --backfill to synthesize missing entries from archive snapshots',
|
||||
true);
|
||||
repairs.push('backfillMilestones');
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch { /* intentionally empty — milestone sync check is advisory */ }
|
||||
|
||||
// ─── Perform repairs if requested ─────────────────────────────────────────
|
||||
const repairActions = [];
|
||||
if (options.repair && repairs.length > 0) {
|
||||
@@ -960,6 +992,39 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'backfillMilestones': {
|
||||
if (!options.backfill && !options.repair) break;
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
let backfilled = 0;
|
||||
for (const ver of missingFromRegistry) {
|
||||
try {
|
||||
const snapshotPath = path.join(milestonesArchiveDir, `${ver}-ROADMAP.md`);
|
||||
const snapshot = fs.existsSync(snapshotPath) ? fs.readFileSync(snapshotPath, 'utf-8') : null;
|
||||
// Build minimal entry from snapshot title or version
|
||||
const titleMatch = snapshot && snapshot.match(/^#\s+(.+)$/m);
|
||||
const milestoneName = titleMatch ? titleMatch[1].replace(/^Milestone\s+/i, '').replace(/^v[\d.]+\s*/, '').trim() : ver;
|
||||
const entry = `## ${ver}${milestoneName && milestoneName !== ver ? ` ${milestoneName}` : ''} (Backfilled: ${today})\n\n**Note:** Synthesized from archive snapshot by \`/gsd-health --backfill\`. Original completion date unknown.\n\n---\n\n`;
|
||||
const milestonesContent = fs.existsSync(milestonesPath)
|
||||
? fs.readFileSync(milestonesPath, 'utf-8')
|
||||
: '';
|
||||
if (!milestonesContent.trim()) {
|
||||
fs.writeFileSync(milestonesPath, `# Milestones\n\n${entry}`, 'utf-8');
|
||||
} else {
|
||||
const headerMatch = milestonesContent.match(/^(#{1,3}\s+[^\n]*\n\n?)/);
|
||||
if (headerMatch) {
|
||||
const header = headerMatch[1];
|
||||
const rest = milestonesContent.slice(header.length);
|
||||
fs.writeFileSync(milestonesPath, header + entry + rest, 'utf-8');
|
||||
} else {
|
||||
fs.writeFileSync(milestonesPath, entry + milestonesContent, 'utf-8');
|
||||
}
|
||||
}
|
||||
backfilled++;
|
||||
} catch { /* intentionally empty — partial backfill is acceptable */ }
|
||||
}
|
||||
repairActions.push({ action: repair, success: true, detail: `Backfilled ${backfilled} milestone(s) into MILESTONES.md` });
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
repairActions.push({ action: repair, success: false, error: err.message });
|
||||
@@ -980,14 +1045,16 @@ function cmdValidateHealth(cwd, options, raw) {
|
||||
const repairableCount = errors.filter(e => e.repairable).length +
|
||||
warnings.filter(w => w.repairable).length;
|
||||
|
||||
output({
|
||||
const result = {
|
||||
status,
|
||||
errors,
|
||||
warnings,
|
||||
info,
|
||||
repairable_count: repairableCount,
|
||||
repairs_performed: repairActions.length > 0 ? repairActions : undefined,
|
||||
}, raw);
|
||||
};
|
||||
output(result, raw);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -11,13 +11,17 @@ Read all files referenced by the invoking prompt's execution_context before star
|
||||
<step name="parse_args">
|
||||
**Parse arguments:**
|
||||
|
||||
Check if `--repair` flag is present in the command arguments.
|
||||
Check if `--repair` or `--backfill` flags are present in the command arguments.
|
||||
|
||||
```
|
||||
REPAIR_FLAG=""
|
||||
BACKFILL_FLAG=""
|
||||
if arguments contain "--repair"; then
|
||||
REPAIR_FLAG="--repair"
|
||||
fi
|
||||
if arguments contain "--backfill"; then
|
||||
BACKFILL_FLAG="--backfill"
|
||||
fi
|
||||
```
|
||||
</step>
|
||||
|
||||
@@ -25,7 +29,7 @@ fi
|
||||
**Run health validation:**
|
||||
|
||||
```bash
|
||||
gsd-sdk query validate.health $REPAIR_FLAG
|
||||
gsd-sdk query validate.health $REPAIR_FLAG $BACKFILL_FLAG
|
||||
```
|
||||
|
||||
Parse JSON output:
|
||||
@@ -138,6 +142,7 @@ Report final status.
|
||||
| W007 | warning | Phase on disk but not in ROADMAP | No |
|
||||
| W008 | warning | config.json: workflow.nyquist_validation absent (defaults to enabled but agents may skip) | Yes |
|
||||
| W009 | warning | Phase has Validation Architecture in RESEARCH.md but no VALIDATION.md | No |
|
||||
| W018 | warning | MILESTONES.md missing entry for archived milestone snapshot | Yes (`--backfill`) |
|
||||
| I001 | info | Plan without SUMMARY (may be in progress) | No |
|
||||
|
||||
</error_codes>
|
||||
@@ -150,6 +155,7 @@ Report final status.
|
||||
| resetConfig | Delete + recreate config.json | Loses custom settings |
|
||||
| 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 |
|
||||
| backfillMilestones | Synthesize missing MILESTONES.md entries from `.planning/milestones/vX.Y-ROADMAP.md` snapshots | None — additive only; triggered by `--backfill` flag |
|
||||
|
||||
**Not repairable (too risky):**
|
||||
- PROJECT.md, ROADMAP.md content
|
||||
|
||||
100
tests/enh-2446-milestones-drift.test.cjs
Normal file
100
tests/enh-2446-milestones-drift.test.cjs
Normal file
@@ -0,0 +1,100 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* Tests for gsd-health MILESTONES.md drift detection (#2446).
|
||||
*/
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const os = require('node:os');
|
||||
|
||||
const { cmdValidateHealth } = require('../get-shit-done/bin/lib/verify.cjs');
|
||||
|
||||
function makeTempProject(files = {}) {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-2446-'));
|
||||
fs.mkdirSync(path.join(dir, '.planning', 'milestones'), { recursive: true });
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
const abs = path.join(dir, rel);
|
||||
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
||||
fs.writeFileSync(abs, content, 'utf-8');
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
test('W018: warns when archived snapshot has no MILESTONES.md entry', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n',
|
||||
'.planning/ROADMAP.md': '# Roadmap\n',
|
||||
'.planning/STATE.md': '# State\n',
|
||||
'.planning/config.json': '{}',
|
||||
'.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n',
|
||||
// No MILESTONES.md entry for v1.0
|
||||
});
|
||||
|
||||
const result = cmdValidateHealth(dir, { repair: false }, false);
|
||||
|
||||
const w018 = result.warnings.find(w => w.code === 'W018');
|
||||
assert.ok(w018, 'W018 warning should be emitted');
|
||||
assert.ok(w018.message.includes('v1.0'), 'warning should mention v1.0');
|
||||
assert.ok(w018.repairable, 'W018 should be marked repairable');
|
||||
});
|
||||
|
||||
test('no W018 when all snapshots have MILESTONES.md entries', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n',
|
||||
'.planning/ROADMAP.md': '# Roadmap\n',
|
||||
'.planning/STATE.md': '# State\n',
|
||||
'.planning/config.json': '{}',
|
||||
'.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0\n',
|
||||
'.planning/MILESTONES.md': '# Milestones\n\n## v1.0 My App (Shipped: 2026-01-01)\n\n---\n\n',
|
||||
});
|
||||
|
||||
const result = cmdValidateHealth(dir, { repair: false }, false);
|
||||
|
||||
const w018 = result.warnings.find(w => w.code === 'W018');
|
||||
assert.strictEqual(w018, undefined, 'no W018 when entries are present');
|
||||
});
|
||||
|
||||
test('no W018 when milestones archive dir is empty', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n',
|
||||
'.planning/ROADMAP.md': '# Roadmap\n',
|
||||
'.planning/STATE.md': '# State\n',
|
||||
'.planning/config.json': '{}',
|
||||
// No snapshots in milestones/
|
||||
});
|
||||
|
||||
const result = cmdValidateHealth(dir, { repair: false }, false);
|
||||
|
||||
const w018 = result.warnings.find(w => w.code === 'W018');
|
||||
assert.strictEqual(w018, undefined, 'no W018 with empty archive dir');
|
||||
});
|
||||
|
||||
test('--backfill synthesizes missing MILESTONES.md entry from snapshot', () => {
|
||||
const dir = makeTempProject({
|
||||
'.planning/PROJECT.md': '# P\n\n## What This Is\n\nX\n\n## Core Value\n\nY\n\n## Requirements\n\nZ\n',
|
||||
'.planning/ROADMAP.md': '# Roadmap\n',
|
||||
'.planning/STATE.md': '# State\n',
|
||||
'.planning/config.json': '{}',
|
||||
'.planning/milestones/v1.0-ROADMAP.md': '# Milestone v1.0 First Release\n',
|
||||
});
|
||||
|
||||
cmdValidateHealth(dir, { repair: true, backfill: true }, false);
|
||||
|
||||
const milestonesPath = path.join(dir, '.planning', 'MILESTONES.md');
|
||||
assert.ok(fs.existsSync(milestonesPath), 'MILESTONES.md should be created');
|
||||
const content = fs.readFileSync(milestonesPath, 'utf-8');
|
||||
assert.ok(content.includes('## v1.0'), 'backfilled entry should contain v1.0');
|
||||
assert.ok(content.includes('Backfilled'), 'should note it was backfilled');
|
||||
});
|
||||
|
||||
test('health.md mentions --backfill flag', () => {
|
||||
const healthMd = fs.readFileSync(
|
||||
path.join(__dirname, '../get-shit-done/workflows/health.md'), 'utf-8'
|
||||
);
|
||||
assert.ok(healthMd.includes('--backfill'), 'health.md should document --backfill');
|
||||
assert.ok(healthMd.includes('W018'), 'health.md should list W018 error code');
|
||||
assert.ok(healthMd.includes('backfillMilestones'), 'repair_actions should include backfillMilestones');
|
||||
});
|
||||
Reference in New Issue
Block a user