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:
parent
67f47bea06
commit
cc48cc9435
13 changed files with 299 additions and 110 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) ─
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue