fix(gsd): add file-based fallbacks for DB-dependent code paths and fix CI test failures

The DB-backed planning migration (#2280) moved 6 core modules to DB-primary
queries but left no fallback when DB is unavailable, breaking 19 tests in CI.

Source fixes: add file-based fallbacks in auto-direct-dispatch, auto-prompts,
auto-worktree, dispatch-guard, reactive-graph, visualizer-data, workspace-index,
and skill-health. Windows fixes: CRLF normalization, EPERM retry on rmSync,
path normalization. Enable --experimental-test-isolation=process to prevent
cross-test DB state leakage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lex Christopherson 2026-03-24 14:59:56 -06:00
parent 67f47bea06
commit cc48cc9435
13 changed files with 299 additions and 110 deletions

View file

@ -53,10 +53,10 @@
"copy-resources": "node scripts/copy-resources.cjs",
"copy-themes": "node scripts/copy-themes.cjs",
"copy-export-html": "node scripts/copy-export-html.cjs",
"test:unit": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
"test:unit": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --experimental-test-isolation=process --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
"test:marketplace": "GSD_TEST_CLONE_MARKETPLACES=1 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/claude-import-tui.test.ts src/resources/extensions/gsd/tests/plugin-importer-live.test.ts src/tests/marketplace-discovery.test.ts",
"test:coverage": "c8 --reporter=text --reporter=lcov --exclude='src/resources/extensions/gsd/tests/**' --exclude='src/tests/**' --exclude='scripts/**' --exclude='native/**' --exclude='node_modules/**' --check-coverage --statements=50 --lines=50 --branches=20 --functions=20 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
"test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --test src/resources/extensions/gsd/tests/*integration*.test.ts src/tests/integration/*.test.ts",
"test:coverage": "c8 --reporter=text --reporter=lcov --exclude='src/resources/extensions/gsd/tests/**' --exclude='src/tests/**' --exclude='scripts/**' --exclude='native/**' --exclude='node_modules/**' --check-coverage --statements=50 --lines=50 --branches=20 --functions=20 node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --experimental-test-isolation=process --test src/resources/extensions/gsd/tests/*.test.ts src/resources/extensions/gsd/tests/*.test.mjs src/tests/*.test.ts",
"test:integration": "node --import ./src/resources/extensions/gsd/tests/resolve-ts.mjs --experimental-strip-types --experimental-test-isolation=process --test src/resources/extensions/gsd/tests/*integration*.test.ts src/tests/integration/*.test.ts",
"test": "npm run test:unit && npm run test:integration",
"test:smoke": "node --experimental-strip-types tests/smoke/run.ts",
"test:fixtures": "node --experimental-strip-types tests/fixtures/run.ts",

View file

@ -11,6 +11,7 @@ import type {
import { deriveState } from "./state.js";
import { loadFile } from "./files.js";
import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js";
import { parseRoadmap } from "./parsers-legacy.js";
import {
resolveMilestoneFile, resolveSliceFile, relSliceFile,
} from "./paths.js";
@ -152,13 +153,20 @@ export async function dispatchDirectPhase(
case "reassess":
case "reassess-roadmap": {
// DB primary path — get completed slices
// DB primary path — get completed slices, fall back to file parsing when DB has no data
let completedSliceIds: string[] = [];
if (isDbAvailable()) {
completedSliceIds = getMilestoneSlices(mid).filter(s => s.status === "complete").map(s => s.id);
} else {
ctx.ui.notify("Cannot dispatch reassess-roadmap: DB unavailable.", "warning");
return;
}
if (completedSliceIds.length === 0) {
// File-based fallback: parse roadmap checkboxes
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
if (roadmapPath) {
const roadmapContent = await loadFile(roadmapPath);
if (roadmapContent) {
completedSliceIds = parseRoadmap(roadmapContent).slices.filter(s => s.done).map(s => s.id);
}
}
}
if (completedSliceIds.length === 0) {
ctx.ui.notify("Cannot dispatch reassess-roadmap: no completed slices.", "warning");
@ -180,9 +188,16 @@ export async function dispatchDirectPhase(
let uatCompletedSliceIds: string[] = [];
if (isDbAvailable()) {
uatCompletedSliceIds = getMilestoneSlices(mid).filter(s => s.status === "complete").map(s => s.id);
} else {
ctx.ui.notify("Cannot dispatch run-uat: DB unavailable.", "warning");
return;
}
if (uatCompletedSliceIds.length === 0) {
// File-based fallback: parse roadmap checkboxes
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
if (roadmapPath) {
const roadmapContent = await loadFile(roadmapPath);
if (roadmapContent) {
uatCompletedSliceIds = parseRoadmap(roadmapContent).slices.filter(s => s.done).map(s => s.id);
}
}
}
if (uatCompletedSliceIds.length === 0) {
ctx.ui.notify("Cannot dispatch run-uat: no completed slices.", "warning");

View file

@ -16,6 +16,7 @@ import {
resolveGsdRootFile, relGsdRootFile, resolveRuntimeFile,
} from "./paths.js";
import { resolveSkillDiscoveryMode, resolveInlineLevel, loadEffectiveGSDPreferences, resolveAllSkillReferences } from "./preferences.js";
import { parseRoadmap } from "./parsers-legacy.js";
import type { GSDState, InlineLevel } from "./types.js";
import type { GSDPreferences } from "./preferences.js";
import { getLoadedSkills, type Skill } from "@gsd/pi-coding-agent";
@ -183,14 +184,30 @@ export async function inlineDependencySummaries(
const { isDbAvailable, getSlice } = await import("./gsd-db.js");
if (isDbAvailable()) {
const slice = getSlice(mid, sid);
if (!slice || slice.depends.length === 0) return "- (no dependencies)";
depends = slice.depends as string[];
if (slice) {
if (slice.depends.length === 0) return "- (no dependencies)";
depends = slice.depends as string[];
}
// If slice not found in DB, fall through to file-based parsing
}
} catch { /* fall through */ }
// If DB didn't provide depends, we can't determine them without parsers
// If DB didn't provide depends, fall back to roadmap parsing
if (!depends) {
return "- (no dependencies)";
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
if (roadmapPath) {
const roadmapContent = await loadFile(roadmapPath);
if (roadmapContent) {
const parsed = parseRoadmap(roadmapContent);
const slice = parsed.slices.find(s => s.id === sid);
if (slice && slice.depends.length > 0) {
depends = slice.depends;
}
}
}
if (!depends) {
return "- (no dependencies)";
}
}
const sections: string[] = [];
@ -684,29 +701,44 @@ export async function getDependencyTaskSummaryPaths(
export async function checkNeedsReassessment(
base: string, mid: string, state: GSDState,
): Promise<{ sliceId: string } | null> {
// DB primary path
let completedSliceIds: string[] = [];
let hasIncomplete = false;
// DB primary path — fall through to file-based when DB has no data for this milestone
try {
const { isDbAvailable, getMilestoneSlices } = await import("./gsd-db.js");
if (isDbAvailable()) {
const slices = getMilestoneSlices(mid);
completedSliceIds = slices.filter(s => s.status === "complete").map(s => s.id);
hasIncomplete = slices.some(s => s.status !== "complete");
if (completedSliceIds.length === 0 || !hasIncomplete) return null;
const lastCompleted = completedSliceIds[completedSliceIds.length - 1];
const assessmentFile = resolveSliceFile(base, mid, lastCompleted, "ASSESSMENT");
const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile));
if (hasAssessment) return null;
const summaryFile = resolveSliceFile(base, mid, lastCompleted, "SUMMARY");
const hasSummary = !!(summaryFile && await loadFile(summaryFile));
if (!hasSummary) return null;
return { sliceId: lastCompleted };
if (slices.length > 0) {
const completedSliceIds = slices.filter(s => s.status === "complete").map(s => s.id);
const hasIncomplete = slices.some(s => s.status !== "complete");
if (completedSliceIds.length === 0 || !hasIncomplete) return null;
const lastCompleted = completedSliceIds[completedSliceIds.length - 1];
const assessmentFile = resolveSliceFile(base, mid, lastCompleted, "ASSESSMENT");
const hasAssessment = !!(assessmentFile && await loadFile(assessmentFile));
if (hasAssessment) return null;
const summaryFile = resolveSliceFile(base, mid, lastCompleted, "SUMMARY");
const hasSummary = !!(summaryFile && await loadFile(summaryFile));
if (!hasSummary) return null;
return { sliceId: lastCompleted };
}
}
} catch { /* fall through */ }
// DB unavailable — cannot determine assessment needs
return null;
// File-based fallback using roadmap checkboxes
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
if (!roadmapPath) return null;
const roadmapContent = await loadFile(roadmapPath);
if (!roadmapContent) return null;
const parsed = parseRoadmap(roadmapContent);
const fileCompletedIds = parsed.slices.filter(s => s.done).map(s => s.id);
const fileHasIncomplete = parsed.slices.some(s => !s.done);
if (fileCompletedIds.length === 0 || !fileHasIncomplete) return null;
const lastDone = fileCompletedIds[fileCompletedIds.length - 1];
const assessFile = resolveSliceFile(base, mid, lastDone, "ASSESSMENT");
const hasAssess = !!(assessFile && await loadFile(assessFile));
if (hasAssess) return null;
const summFile = resolveSliceFile(base, mid, lastDone, "SUMMARY");
const hasSumm = !!(summFile && await loadFile(summFile));
if (!hasSumm) return null;
return { sliceId: lastDone };
}
/**
@ -723,34 +755,57 @@ export async function checkNeedsReassessment(
export async function checkNeedsRunUat(
base: string, mid: string, state: GSDState, prefs: GSDPreferences | undefined,
): Promise<{ sliceId: string; uatType: UatType } | null> {
// DB primary path
// DB primary path — fall through to file-based when DB has no data for this milestone
try {
const { isDbAvailable, getMilestoneSlices } = await import("./gsd-db.js");
if (isDbAvailable()) {
const slices = getMilestoneSlices(mid);
const completedSlices = slices.filter(s => s.status === "complete");
const incompleteSlices = slices.filter(s => s.status !== "complete");
if (completedSlices.length === 0) return null;
if (incompleteSlices.length === 0) return null;
if (!prefs?.uat_dispatch) return null;
const lastCompleted = completedSlices[completedSlices.length - 1];
const sid = lastCompleted.id;
const uatFile = resolveSliceFile(base, mid, sid, "UAT");
if (!uatFile) return null;
const uatContent = await loadFile(uatFile);
if (!uatContent) return null;
const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT");
if (uatResultFile) {
const hasResult = !!(await loadFile(uatResultFile));
if (hasResult) return null;
if (slices.length > 0) {
const completedSlices = slices.filter(s => s.status === "complete");
const incompleteSlices = slices.filter(s => s.status !== "complete");
if (completedSlices.length === 0) return null;
if (incompleteSlices.length === 0) return null;
if (!prefs?.uat_dispatch) return null;
const lastCompleted = completedSlices[completedSlices.length - 1];
const sid = lastCompleted.id;
const uatFile = resolveSliceFile(base, mid, sid, "UAT");
if (!uatFile) return null;
const uatContent = await loadFile(uatFile);
if (!uatContent) return null;
const uatResultFile = resolveSliceFile(base, mid, sid, "UAT-RESULT");
if (uatResultFile) {
const hasResult = !!(await loadFile(uatResultFile));
if (hasResult) return null;
}
const uatType = extractUatType(uatContent) ?? "artifact-driven";
return { sliceId: sid, uatType };
}
const uatType = extractUatType(uatContent) ?? "artifact-driven";
return { sliceId: sid, uatType };
}
} catch { /* fall through */ }
// DB unavailable — cannot determine UAT needs
return null;
// File-based fallback using roadmap checkboxes
if (!prefs?.uat_dispatch) return null;
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
if (!roadmapPath) return null;
const roadmapContent = await loadFile(roadmapPath);
if (!roadmapContent) return null;
const parsed = parseRoadmap(roadmapContent);
const completedFileSlices = parsed.slices.filter(s => s.done);
const incompleteFileSlices = parsed.slices.filter(s => !s.done);
if (completedFileSlices.length === 0 || incompleteFileSlices.length === 0) return null;
const lastCompletedFile = completedFileSlices[completedFileSlices.length - 1];
const uatSid = lastCompletedFile.id;
const uatFileFb = resolveSliceFile(base, mid, uatSid, "UAT");
if (!uatFileFb) return null;
const uatContentFb = await loadFile(uatFileFb);
if (!uatContentFb) return null;
const uatResultFb = resolveSliceFile(base, mid, uatSid, "UAT-RESULT");
if (uatResultFb) {
const hasResultFb = !!(await loadFile(uatResultFb));
if (hasResultFb) return null;
}
const uatTypeFb = extractUatType(uatContentFb) ?? "artifact-driven";
return { sliceId: uatSid, uatType: uatTypeFb };
}
// ─── Prompt Builders ──────────────────────────────────────────────────────
@ -1207,7 +1262,13 @@ export async function buildCompleteMilestonePrompt(
sliceIds = getMilestoneSlices(mid).map(s => s.id);
}
} catch { /* fall through */ }
// If DB didn't provide slice IDs, sliceIds stays empty — no summaries to inline
// File-based fallback: parse roadmap for slice IDs when DB has no data
if (sliceIds.length === 0 && roadmapPath) {
const roadmapContent = await loadFile(roadmapPath);
if (roadmapContent) {
sliceIds = parseRoadmap(roadmapContent).slices.map(s => s.id);
}
}
const seenSlices = new Set<string>();
for (const sid of sliceIds) {
if (seenSlices.has(sid)) continue;
@ -1267,7 +1328,13 @@ export async function buildValidateMilestonePrompt(
valSliceIds = getMilestoneSlices(mid).map(s => s.id);
}
} catch { /* fall through */ }
// If DB didn't provide slice IDs, valSliceIds stays empty
// File-based fallback: parse roadmap for slice IDs when DB has no data
if (valSliceIds.length === 0 && roadmapPath) {
const roadmapContent = await loadFile(roadmapPath);
if (roadmapContent) {
valSliceIds = parseRoadmap(roadmapContent).slices.map(s => s.id);
}
}
const seenValSlices = new Set<string>();
for (const sid of valSliceIds) {
if (seenValSlices.has(sid)) continue;

View file

@ -1006,7 +1006,14 @@ export function mergeMilestoneToMain(
.filter(s => s.status === "complete")
.map(s => ({ id: s.id, title: s.title }));
}
// When DB unavailable, completedSlices stays empty — commit message will omit slice details
// Fallback: parse roadmap content when DB is unavailable
if (completedSlices.length === 0 && roadmapContent) {
const sliceRe = /- \[x\] \*\*(\w+):\s*(.+?)\*\*/gi;
let m: RegExpExecArray | null;
while ((m = sliceRe.exec(roadmapContent)) !== null) {
completedSlices.push({ id: m[1], title: m[2] });
}
}
// 3. chdir to original base
const previousCwd = process.cwd();
@ -1037,8 +1044,14 @@ export function mergeMilestoneToMain(
// 6. Build rich commit message
const dbMilestone = getMilestone(milestoneId);
const milestoneTitle =
(dbMilestone?.title ?? "").replace(/^M\d+:\s*/, "").trim() || milestoneId;
let milestoneTitle =
(dbMilestone?.title ?? "").replace(/^M\d+:\s*/, "").trim();
// Fallback: parse title from roadmap content header (e.g. "# M020: Backend foundation")
if (!milestoneTitle && roadmapContent) {
const titleMatch = roadmapContent.match(new RegExp(`^#\\s+${milestoneId}:\\s*(.+)`, "m"));
if (titleMatch) milestoneTitle = titleMatch[1].trim();
}
milestoneTitle = milestoneTitle || milestoneId;
const subject = `feat(${milestoneId}): ${milestoneTitle}`;
let body = "";
if (completedSlices.length > 0) {

View file

@ -3,6 +3,8 @@
import { resolveMilestoneFile } from "./paths.js";
import { findMilestoneIds } from "./guided-flow.js";
import { isDbAvailable, getMilestoneSlices } from "./gsd-db.js";
import { parseRoadmap } from "./parsers-legacy.js";
import { readFileSync } from "node:fs";
const SLICE_DISPATCH_TYPES = new Set([
"research-slice",
@ -34,18 +36,34 @@ export function getPriorSliceCompletionBlocker(
if (resolveMilestoneFile(base, mid, "PARKED")) continue;
if (resolveMilestoneFile(base, mid, "SUMMARY")) continue;
// Normalised slice list from DB
// Normalised slice list from DB or file fallback
type NormSlice = { id: string; done: boolean; depends: string[] };
let slices: NormSlice[] | null = null;
if (!isDbAvailable()) continue;
const rows = getMilestoneSlices(mid);
if (rows.length === 0) continue;
const slices: NormSlice[] = rows.map((r) => ({
id: r.id,
done: r.status === "complete",
depends: r.depends ?? [],
}));
if (isDbAvailable()) {
const rows = getMilestoneSlices(mid);
if (rows.length > 0) {
slices = rows.map((r) => ({
id: r.id,
done: r.status === "complete",
depends: r.depends ?? [],
}));
}
}
if (!slices) {
// File-based fallback: parse roadmap checkboxes
const roadmapPath = resolveMilestoneFile(base, mid, "ROADMAP");
if (!roadmapPath) continue;
let roadmapContent: string;
try { roadmapContent = readFileSync(roadmapPath, "utf-8"); } catch { continue; }
const parsed = parseRoadmap(roadmapContent);
if (parsed.slices.length === 0) continue;
slices = parsed.slices.map((s) => ({
id: s.id,
done: s.done,
depends: s.depends ?? [],
}));
}
if (mid !== targetMid) {
const incomplete = slices.find((slice) => !slice.done);

View file

@ -933,12 +933,14 @@ export async function repairStaleRenders(basePath: string): Promise<number> {
for (const entry of staleEntries) {
if (repairedPaths.has(entry.path)) continue;
// Normalize path separators for cross-platform regex matching
const normPath = entry.path.replace(/\\/g, "/");
try {
// Determine repair action from the reason
if (entry.reason.includes("in roadmap")) {
// Roadmap checkbox mismatch — extract milestone ID from path
const milestoneMatch = entry.path.match(/milestones\/([^/]+)\//);
const milestoneMatch = normPath.match(/milestones\/([^/]+)\//);
if (milestoneMatch) {
const ok = await renderRoadmapCheckboxes(basePath, milestoneMatch[1]);
if (ok) {
@ -948,7 +950,7 @@ export async function repairStaleRenders(basePath: string): Promise<number> {
}
} else if (entry.reason.includes("in plan")) {
// Plan checkbox mismatch — extract milestone + slice IDs from path
const pathMatch = entry.path.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
if (pathMatch) {
const ok = await renderPlanCheckboxes(basePath, pathMatch[1], pathMatch[2]);
if (ok) {
@ -958,7 +960,7 @@ export async function repairStaleRenders(basePath: string): Promise<number> {
}
} else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^T\d+/)) {
// Missing task summary — extract IDs from path
const pathMatch = entry.path.match(/milestones\/([^/]+)\/slices\/([^/]+)\/tasks\//);
const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\/tasks\//);
const taskMatch = entry.reason.match(/^(T\d+)/);
if (pathMatch && taskMatch) {
const ok = await renderTaskSummary(basePath, pathMatch[1], pathMatch[2], taskMatch[1]);
@ -969,7 +971,7 @@ export async function repairStaleRenders(basePath: string): Promise<number> {
}
} else if (entry.reason.includes("SUMMARY.md missing") && entry.reason.match(/^S\d+/)) {
// Missing slice summary — extract IDs from path
const pathMatch = entry.path.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
if (pathMatch) {
const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]);
if (ok) {
@ -979,7 +981,7 @@ export async function repairStaleRenders(basePath: string): Promise<number> {
}
} else if (entry.reason.includes("UAT.md missing")) {
// Missing slice UAT — renderSliceSummary handles both SUMMARY + UAT
const pathMatch = entry.path.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
const pathMatch = normPath.match(/milestones\/([^/]+)\/slices\/([^/]+)\//);
if (pathMatch) {
const ok = await renderSliceSummary(basePath, pathMatch[1], pathMatch[2]);
if (ok) {

View file

@ -12,6 +12,7 @@
import type { TaskIO, DerivedTaskNode, ReactiveExecutionState } from "./types.js";
import { loadFile, parseTaskPlanIO } from "./files.js";
import { isDbAvailable, getSliceTasks } from "./gsd-db.js";
import { parsePlan } from "./parsers-legacy.js";
import { resolveTasksDir, resolveTaskFiles } from "./paths.js";
import { join } from "node:path";
import { loadJsonFileOrNull, saveJsonFile } from "./json-persistence.js";
@ -205,8 +206,17 @@ export async function loadSliceTaskIO(
} catch { /* fall through */ }
if (!taskEntries) {
// DB unavailable — cannot determine task graph
return [];
// File-based fallback: parse slice plan for task entries
const parsed = parsePlan(planContent);
if (parsed.tasks.length > 0) {
taskEntries = parsed.tasks.map(t => ({
id: t.id,
title: t.title,
done: t.done,
}));
} else {
return [];
}
}
const tDir = resolveTasksDir(basePath, mid, sid);

View file

@ -283,7 +283,8 @@ export function computeStaleAvoidList(
staleDays?: number,
): string[] {
const ledger = loadLedgerFromDisk(basePath);
const units = (ledger?.units ?? []).filter(u => u.skills && u.skills.length > 0);
if (!ledger) return [];
const units = ledger.units.filter(u => u.skills && u.skills.length > 0);
const stale = detectStaleSkills(units, staleDays ?? DEFAULT_STALE_DAYS);
const avoidSet = new Set(currentAvoidList);

View file

@ -83,7 +83,7 @@ test("#2151 bug 1: auto-stash unblocks merge when unrelated files are dirty", ()
const readmeContent = readFileSync(join(repo, "README.md"), "utf-8");
assert.equal(readmeContent, "# modified locally\n", "stash popped — dirty file restored after merge");
} finally {
rmSync(repo, { recursive: true, force: true });
rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
});
@ -116,6 +116,6 @@ test("#2151 bug 2: nativeMergeSquash returns dirty filenames", async () => {
);
} finally {
run("git checkout -- . 2>/dev/null || true", repo);
rmSync(repo, { recursive: true, force: true });
rmSync(repo, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 });
}
});

View file

@ -490,7 +490,7 @@ async function main(): Promise<void> {
// The milestone code should be on main.
assertTrue(existsSync(join(repo, "e2e.ts")), "#2151: e2e.ts merged to main");
const content = readFileSync(join(repo, "e2e.ts"), "utf-8");
assertEq(content, "export const e2e = true;\n", "#2151: merged content is from milestone branch");
assertEq(content.replace(/\r\n/g, "\n"), "export const e2e = true;\n", "#2151: merged content is from milestone branch");
}
// ─── Test 12: Throw on unanchored code changes after empty commit (#1792) ─

View file

@ -5,6 +5,7 @@ import { join } from 'node:path';
import { deriveState } from './state.js';
import { parseSummary, loadFile } from './files.js';
import { isDbAvailable, getMilestoneSlices, getSliceTasks } from './gsd-db.js';
import { parseRoadmap, parsePlan } from './parsers-legacy.js';
import { findMilestoneIds } from './milestone-ids.js';
import { resolveMilestoneFile, resolveSliceFile, resolveGsdRootFile, gsdRoot } from './paths.js';
import {
@ -798,14 +799,21 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
const roadmapContent = roadmapFile ? readFileCached(roadmapFile) : null;
if (roadmapContent || isDbAvailable()) {
// Normalize slices from DB
// Normalize slices from DB, fall back to file-based parsing when DB has no data
type NormSlice = { id: string; done: boolean; title: string; risk: string; depends: string[]; demo: string };
let normSlices: NormSlice[];
let normSlices: NormSlice[] | null = null;
if (isDbAvailable()) {
normSlices = getMilestoneSlices(mid).map(s => ({ id: s.id, done: s.status === 'complete', title: s.title, risk: s.risk || 'medium', depends: s.depends, demo: s.demo }));
} else {
normSlices = [];
const dbSlices = getMilestoneSlices(mid);
if (dbSlices.length > 0) {
normSlices = dbSlices.map(s => ({ id: s.id, done: s.status === 'complete', title: s.title, risk: s.risk || 'medium', depends: s.depends, demo: s.demo }));
}
}
if (!normSlices && roadmapContent) {
// File-based fallback: parse roadmap for slice entries
const parsed = parseRoadmap(roadmapContent);
normSlices = parsed.slices.map(s => ({ id: s.id, done: s.done, title: s.title, risk: s.risk || 'medium', depends: s.depends, demo: '' }));
}
if (!normSlices) normSlices = [];
for (const s of normSlices) {
const isActiveSlice =
@ -815,16 +823,40 @@ export async function loadVisualizerData(basePath: string): Promise<VisualizerDa
const tasks: VisualizerTask[] = [];
if (isActiveSlice) {
// Normalize tasks from DB
// Normalize tasks from DB, fall back to file parsing when DB has no data
let usedDbTasks = false;
if (isDbAvailable()) {
for (const t of getSliceTasks(mid, s.id)) {
tasks.push({
id: t.id,
title: t.title,
done: t.status === 'complete' || t.status === 'done',
active: state.activeTask?.id === t.id,
estimate: t.estimate || undefined,
});
const dbTasks = getSliceTasks(mid, s.id);
if (dbTasks.length > 0) {
usedDbTasks = true;
for (const t of dbTasks) {
tasks.push({
id: t.id,
title: t.title,
done: t.status === 'complete' || t.status === 'done',
active: state.activeTask?.id === t.id,
estimate: t.estimate || undefined,
});
}
}
}
if (!usedDbTasks) {
// File-based fallback: parse slice plan for task entries
const slicePlanFile = resolveSliceFile(basePath, mid, s.id, 'PLAN');
if (slicePlanFile) {
const planContent = readFileCached(slicePlanFile);
if (planContent) {
const parsed = parsePlan(planContent);
for (const t of parsed.tasks) {
tasks.push({
id: t.id,
title: t.title,
done: t.done,
active: state.activeTask?.id === t.id,
estimate: t.estimate || undefined,
});
}
}
}
}
}

View file

@ -2,6 +2,7 @@ import { join } from "node:path";
import { loadFile } from "./files.js";
import { isDbAvailable, getMilestoneSlices, getSliceTasks } from "./gsd-db.js";
import { parseRoadmap, parsePlan } from "./parsers-legacy.js";
import {
resolveMilestoneFile,
resolveSliceFile,
@ -79,21 +80,40 @@ async function indexSlice(basePath: string, milestoneId: string, sliceId: string
const tasks: WorkspaceTaskTarget[] = [];
let title = fallbackTitle;
// Prefer DB for task data
// Prefer DB for task data, fall back to file parsing when DB has no data
let usedDb = false;
if (isDbAvailable()) {
const dbTasks = getSliceTasks(milestoneId, sliceId);
for (const task of dbTasks) {
title = fallbackTitle; // title comes from slice-level data, not plan
tasks.push({
id: task.id,
title: task.title,
done: task.status === "complete" || task.status === "done",
planPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "PLAN") ?? undefined,
summaryPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "SUMMARY") ?? undefined,
});
if (dbTasks.length > 0) {
usedDb = true;
for (const task of dbTasks) {
title = fallbackTitle; // title comes from slice-level data, not plan
tasks.push({
id: task.id,
title: task.title,
done: task.status === "complete" || task.status === "done",
planPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "PLAN") ?? undefined,
summaryPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "SUMMARY") ?? undefined,
});
}
}
}
if (!usedDb && planPath) {
// File-based fallback: parse slice plan for task entries
const planContent = await loadFile(planPath);
if (planContent) {
const parsed = parsePlan(planContent);
for (const task of parsed.tasks) {
tasks.push({
id: task.id,
title: task.title,
done: task.done,
planPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "PLAN") ?? undefined,
summaryPath: resolveTaskFile(basePath, milestoneId, sliceId, task.id, "SUMMARY") ?? undefined,
});
}
}
}
// When DB unavailable, tasks stays empty
return {
id: sliceId,
@ -125,23 +145,34 @@ export async function indexWorkspace(basePath: string, opts: IndexWorkspaceOptio
const slices: WorkspaceSliceTarget[] = [];
if (roadmapPath || isDbAvailable()) {
// Normalize slices from DB
// Normalize slices from DB, fall back to file-based parsing when DB has no data
type NormSlice = { id: string; done: boolean; title: string; risk: string; depends: string[]; demo: string };
let normSlices: NormSlice[];
let normSlices: NormSlice[] | null = null;
if (isDbAvailable()) {
normSlices = getMilestoneSlices(milestoneId).map(s => ({ id: s.id, done: s.status === "complete", title: s.title, risk: s.risk || "medium", depends: s.depends, demo: s.demo }));
const dbSlices = getMilestoneSlices(milestoneId);
if (dbSlices.length > 0) {
normSlices = dbSlices.map(s => ({ id: s.id, done: s.status === "complete", title: s.title, risk: s.risk || "medium", depends: s.depends, demo: s.demo }));
}
// Get title from roadmap header
if (roadmapPath) {
const roadmapContent = await loadFile(roadmapPath);
if (roadmapContent) title = titleFromRoadmapHeader(roadmapContent, milestoneId);
}
} else {
normSlices = [];
}
if (!normSlices && roadmapPath) {
// File-based fallback: parse roadmap for slice entries
const roadmapContent = await loadFile(roadmapPath);
if (roadmapContent) {
title = titleFromRoadmapHeader(roadmapContent, milestoneId);
const parsed = parseRoadmap(roadmapContent);
normSlices = parsed.slices.map(s => ({ id: s.id, done: s.done, title: s.title, risk: s.risk || "medium", depends: s.depends, demo: s.demo || "" }));
}
}
if (!normSlices) normSlices = [];
if (normSlices!.length > 0) {
if (normSlices.length > 0) {
const sliceResults = await Promise.all(
normSlices!.map(async (slice) => {
normSlices.map(async (slice) => {
return indexSlice(basePath, milestoneId, slice.id, slice.title, slice.done, { risk: slice.risk as RiskLevel, depends: slice.depends, demo: slice.demo });
}),
);

View file

@ -5,7 +5,7 @@ import {
existsSync, statSync,
} from "node:fs";
import { tmpdir, homedir } from "node:os";
import { join, resolve } from "node:path";
import { join, resolve, isAbsolute } from "node:path";
// ---------------------------------------------------------------------------
// Test the core validation + persistence logic used by /api/switch-root
@ -162,7 +162,7 @@ describe("switch-root: path validation", () => {
// Create a relative path that's valid from cwd
const result = validateSwitchRoot(rootA);
assert.ok(result.ok);
assert.ok(result.devRoot!.startsWith("/"), "Should be absolute path");
assert.ok(isAbsolute(result.devRoot!), "Should be absolute path");
});
});