When gsd.db is truncated to 0 bytes after a crash, getMilestoneSlices() returns [] even though isDbAvailable() is true. This caused showDiscuss() to falsely report "All slices are complete" despite incomplete slices existing in the ROADMAP file. Add a cross-check: if the DB returns zero slices but a roadmap exists, fall back to parseRoadmapSlices() to derive slice state from the roadmap (the ground truth). Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
c31b03d57c
commit
6a8f33f49c
2 changed files with 134 additions and 0 deletions
|
|
@ -10,6 +10,7 @@ import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@g
|
|||
import { showNextAction } from "../shared/tui.js";
|
||||
import { loadFile } from "./files.js";
|
||||
import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js";
|
||||
import { parseRoadmapSlices } from "./roadmap-slices.js";
|
||||
import { loadPrompt, inlineTemplate } from "./prompt-loader.js";
|
||||
import { buildSkillActivationBlock } from "./auto-prompts.js";
|
||||
import { deriveState } from "./state.js";
|
||||
|
|
@ -617,6 +618,12 @@ export async function showDiscuss(
|
|||
} else {
|
||||
normSlices = [];
|
||||
}
|
||||
// DB is open but returned zero slices despite a roadmap existing —
|
||||
// the DB may be empty due to WAL loss or truncation (see #2815, #2892).
|
||||
// Fall back to roadmap parsing to prevent false "all complete" exit.
|
||||
if (normSlices.length === 0 && roadmapContent) {
|
||||
normSlices = parseRoadmapSlices(roadmapContent).map(s => ({ id: s.id, done: s.done, title: s.title }));
|
||||
}
|
||||
const pendingSlices = normSlices.filter(s => !s.done);
|
||||
|
||||
if (pendingSlices.length === 0) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* discuss-empty-db-fallback.test.ts — Tests for #2892.
|
||||
*
|
||||
* When the DB is open but empty (e.g., after crash/truncation),
|
||||
* getMilestoneSlices() returns [] and showDiscuss() incorrectly declares
|
||||
* "All slices are complete." The fix adds a roadmap fallback: when the DB
|
||||
* returns zero slices but a ROADMAP file exists, parse slices from the
|
||||
* roadmap instead of treating zero slices as "all complete."
|
||||
*/
|
||||
|
||||
import { describe, test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, join } from "node:path";
|
||||
import { parseRoadmapSlices } from "../roadmap-slices.ts";
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function readGuidedFlowSource(): string {
|
||||
const thisFile = fileURLToPath(import.meta.url);
|
||||
const thisDir = dirname(thisFile);
|
||||
return readFileSync(join(thisDir, "..", "guided-flow.ts"), "utf-8");
|
||||
}
|
||||
|
||||
const SAMPLE_ROADMAP = `# M012 Roadmap
|
||||
|
||||
## Slices
|
||||
- [ ] **S01: Core setup** \`risk:low\` \`depends:[]\`
|
||||
> After this: basic project scaffolding works
|
||||
- [ ] **S02: Auth module** \`risk:medium\` \`depends:[S01]\`
|
||||
> After this: users can log in
|
||||
- [ ] **S03: Dashboard** \`risk:low\` \`depends:[S02]\`
|
||||
> After this: dashboard renders
|
||||
`;
|
||||
|
||||
// ─── Tests ───────────────────────────────────────────────────────────────────
|
||||
|
||||
describe("discuss-empty-db-fallback (#2892)", () => {
|
||||
|
||||
test("1. parseRoadmapSlices extracts slices from a valid ROADMAP", () => {
|
||||
const slices = parseRoadmapSlices(SAMPLE_ROADMAP);
|
||||
assert.strictEqual(slices.length, 3, "should parse 3 slices from sample roadmap");
|
||||
assert.strictEqual(slices[0]!.id, "S01");
|
||||
assert.strictEqual(slices[1]!.id, "S02");
|
||||
assert.strictEqual(slices[2]!.id, "S03");
|
||||
// All slices are incomplete ([ ] not [x])
|
||||
assert.ok(slices.every(s => !s.done), "all slices should be incomplete");
|
||||
});
|
||||
|
||||
test("2. guided-flow imports parseRoadmapSlices for roadmap fallback", () => {
|
||||
const source = readGuidedFlowSource();
|
||||
assert.ok(
|
||||
source.includes("parseRoadmapSlices"),
|
||||
"guided-flow must import parseRoadmapSlices to support roadmap fallback when DB is empty",
|
||||
);
|
||||
});
|
||||
|
||||
test("3. guided-flow has roadmap fallback when normSlices is empty but roadmapContent exists", () => {
|
||||
const source = readGuidedFlowSource();
|
||||
// The fix must add a fallback that checks normSlices.length === 0 && roadmapContent
|
||||
// and repopulates normSlices from the roadmap before the pendingSlices guard.
|
||||
//
|
||||
// Pattern: after DB query produces normSlices, if empty + roadmap exists,
|
||||
// fall back to parseRoadmapSlices(roadmapContent).
|
||||
const fallbackPattern = /normSlices\.length\s*===\s*0\s*&&\s*roadmapContent/;
|
||||
assert.ok(
|
||||
fallbackPattern.test(source),
|
||||
"guided-flow must check normSlices.length === 0 && roadmapContent to trigger roadmap fallback",
|
||||
);
|
||||
});
|
||||
|
||||
test("4. guided-flow no longer has unguarded pendingSlices === 0 exit after DB-only query", () => {
|
||||
const source = readGuidedFlowSource();
|
||||
// Extract the showDiscuss function body
|
||||
const fnMatch = source.match(
|
||||
/async function showDiscuss\s*\([^)]*\)[^{]*\{([\s\S]*?)\nfunction\s/,
|
||||
);
|
||||
assert.ok(!!fnMatch, "showDiscuss function body must be found");
|
||||
|
||||
if (fnMatch) {
|
||||
const body = fnMatch[1]!;
|
||||
// After the DB query block (isDbAvailable/getMilestoneSlices), there should
|
||||
// be a roadmap fallback BEFORE the pendingSlices.length === 0 check.
|
||||
// Find the getMilestoneSlices call and the pendingSlices === 0 check
|
||||
const dbQueryIdx = body.indexOf("getMilestoneSlices");
|
||||
const fallbackIdx = body.indexOf("parseRoadmapSlices");
|
||||
const pendingGuardIdx = body.indexOf('pendingSlices.length === 0');
|
||||
|
||||
assert.ok(dbQueryIdx > 0, "getMilestoneSlices call must exist");
|
||||
assert.ok(fallbackIdx > 0, "parseRoadmapSlices fallback must exist");
|
||||
assert.ok(pendingGuardIdx > 0, "pendingSlices.length === 0 guard must exist");
|
||||
assert.ok(
|
||||
fallbackIdx > dbQueryIdx && fallbackIdx < pendingGuardIdx,
|
||||
"parseRoadmapSlices fallback must appear BETWEEN DB query and pendingSlices === 0 guard",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("5. roadmap-parsed slices map to NormSlice format with done=false by default", () => {
|
||||
// When falling back to roadmap, incomplete slices ([ ]) should map to done:false,
|
||||
// ensuring they appear as pending and are NOT falsely reported as complete.
|
||||
const slices = parseRoadmapSlices(SAMPLE_ROADMAP);
|
||||
const normSlices = slices.map(s => ({ id: s.id, done: s.done, title: s.title }));
|
||||
const pendingSlices = normSlices.filter(s => !s.done);
|
||||
assert.strictEqual(pendingSlices.length, 3,
|
||||
"all 3 incomplete roadmap slices should be pending — not falsely treated as complete");
|
||||
});
|
||||
|
||||
test("6. roadmap with completed slices correctly reports them as done", () => {
|
||||
const completedRoadmap = `# M012 Roadmap
|
||||
|
||||
## Slices
|
||||
- [x] **S01: Core setup** \`risk:low\` \`depends:[]\`
|
||||
> After this: basic project scaffolding works
|
||||
- [ ] **S02: Auth module** \`risk:medium\` \`depends:[S01]\`
|
||||
> After this: users can log in
|
||||
- [x] **S03: Dashboard** \`risk:low\` \`depends:[S02]\`
|
||||
> After this: dashboard renders
|
||||
`;
|
||||
const slices = parseRoadmapSlices(completedRoadmap);
|
||||
const normSlices = slices.map(s => ({ id: s.id, done: s.done, title: s.title }));
|
||||
const pendingSlices = normSlices.filter(s => !s.done);
|
||||
assert.strictEqual(pendingSlices.length, 1, "only S02 should be pending");
|
||||
assert.strictEqual(pendingSlices[0]!.id, "S02");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue