* test(#2978): roadmap validate must perform structural validation Failing-first: roadmap validate returns {"warnings":[]} (exit 0) for every input — empty file, garbage, missing file, truncated frontmatter — because it performs no structural validation and its one opt-in check (W021 milestone- prefix) is off by default. Six cases: empty, garbage, missing, truncated frontmatter, well-formed (no false positive), BOM-prefixed (not corruption). * fix(#2978): add structural validation to roadmap validate roadmap validate returned {"warnings":[]} (exit 0) for every input — empty file, garbage, missing file, truncated frontmatter — because it performed no structural validation and its one opt-in check (W021 milestone-prefix) is off by default. A verb named validate that cannot produce a negative result provides false assurance. Add four structural checks, each producing a coded warning {code, message}: - V001: file missing/unreadable (was silent success) - V002: empty/whitespace-only - V003: malformed frontmatter (unterminated --- fence; BOM-tolerant per #3057) - V004: no recognizable phase entries (no ### Phase N: heading) Keep the existing W021 milestone-prefix check as-is. Exit non-zero via ExitError(1) when warnings are non-empty, per the documented contract ('exits non-zero on any error or warning'). Well-formed roadmaps (incl. BOM-prefixed, CRLF) still validate cleanly with warnings: [] and exit 0. * test(#2978): update W021 tests for non-zero exit on warnings Two existing W021 tests asserted roadmap validate exits 0 even with warnings ('roadmap validate should exit 0 even with warnings') — that was the bug. #2978 made validate exit non-zero on any warning (per its documented contract). Updated both mismatch-case tests to expect success===false and parse the JSON output from the failure path (stdout is written before the ExitError throw). * chore(#2978): add changeset fragment * chore(#2978): backfill changeset PR number 3092 --------- Co-authored-by: sim <sim@local>
This commit is contained in:
@@ -89,7 +89,8 @@ describe('W021 — milestone-prefixed phase ID convention', () => {
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
|
||||
assert.ok(result.success, `roadmap validate should exit 0 even with warnings: ${result.error}`);
|
||||
// #2978: validate now exits non-zero on any warning (per its documented contract).
|
||||
assert.strictEqual(result.success, false, `roadmap validate must exit non-zero on W021 warnings: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(out.warnings), 'output.warnings should be an array');
|
||||
@@ -219,7 +220,8 @@ describe('W021 — milestone-prefixed phase ID convention', () => {
|
||||
]);
|
||||
|
||||
const result = runGsdTools(['roadmap', 'validate'], tmpDir);
|
||||
assert.ok(result.success, `roadmap validate failed: ${result.error}`);
|
||||
// #2978: validate now exits non-zero on any warning (per its documented contract).
|
||||
assert.strictEqual(result.success, false, `roadmap validate must exit non-zero on W021 warnings: ${result.error}`);
|
||||
|
||||
const out = JSON.parse(result.output);
|
||||
const w021 = (out.warnings || []).filter(w => w.code === 'W021');
|
||||
|
||||
@@ -3612,3 +3612,79 @@ describe('bug #1103 — annotate-dependencies preserves newline before Plans: he
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// bug #2978: roadmap validate returns {"warnings":[]} for every input
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('bug #2978: roadmap validate performs structural validation', () => {
|
||||
test('empty (zero-byte) ROADMAP.md → non-empty warnings, non-zero exit', () => {
|
||||
const tmpDir = createTempProject('gsd-2978-empty-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), '');
|
||||
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.success, false, 'empty file must exit non-zero');
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.ok(Array.isArray(payload.warnings) && payload.warnings.length > 0,
|
||||
`empty file must produce non-empty warnings; got: ${JSON.stringify(payload)}`);
|
||||
} finally { cleanup(tmpDir); }
|
||||
});
|
||||
|
||||
test('garbage/non-roadmap text → non-empty warnings, non-zero exit', () => {
|
||||
const tmpDir = createTempProject('gsd-2978-garbage-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'not a roadmap at all');
|
||||
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.success, false, 'garbage must exit non-zero');
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.ok(payload.warnings.length > 0, 'garbage must produce warnings');
|
||||
} finally { cleanup(tmpDir); }
|
||||
});
|
||||
|
||||
test('missing ROADMAP.md → non-empty warnings, non-zero exit', () => {
|
||||
const tmpDir = createTempProject('gsd-2978-missing-');
|
||||
try {
|
||||
// createTempProject creates .planning/phases but no ROADMAP.md — don't write one
|
||||
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.success, false, 'missing file must exit non-zero');
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.ok(payload.warnings.length > 0, 'missing file must produce warnings');
|
||||
} finally { cleanup(tmpDir); }
|
||||
});
|
||||
|
||||
test('truncated frontmatter (unterminated ---) → non-empty warnings', () => {
|
||||
const tmpDir = createTempProject('gsd-2978-trunc-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'---\nmilestone: v1.0\n### Phase 1: Setup\n');
|
||||
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
|
||||
assert.strictEqual(result.success, false, 'truncated frontmatter must exit non-zero');
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.ok(payload.warnings.length > 0, 'truncated frontmatter must produce warnings');
|
||||
} finally { cleanup(tmpDir); }
|
||||
});
|
||||
|
||||
test('well-formed roadmap → warnings: [], exit 0 (no false positive)', () => {
|
||||
const tmpDir = createTempProject('gsd-2978-good-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'# Roadmap\n\n## v1.0\n\n### Phase 1: Foundation\n**Goal:** setup\n');
|
||||
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `well-formed roadmap must exit 0; got: ${result.error}`);
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(payload.warnings, [], 'well-formed roadmap must have no warnings');
|
||||
} finally { cleanup(tmpDir); }
|
||||
});
|
||||
|
||||
test('BOM-prefixed well-formed roadmap → warnings: [], exit 0 (not corruption)', () => {
|
||||
const tmpDir = createTempProject('gsd-2978-bom-');
|
||||
try {
|
||||
fs.writeFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'),
|
||||
'\uFEFF# Roadmap\n\n### Phase 1: Foundation\n**Goal:** setup\n');
|
||||
const result = runGsdTools(['roadmap', 'validate', '--raw'], tmpDir);
|
||||
assert.ok(result.success, `BOM-prefixed roadmap must exit 0; got: ${result.error}`);
|
||||
const payload = JSON.parse(result.output);
|
||||
assert.deepStrictEqual(payload.warnings, [], 'BOM is not corruption');
|
||||
} finally { cleanup(tmpDir); }
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user