From 2aed04608cdaba5d1e1e5f845c2c6043cd85f6f0 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Thu, 7 May 2026 06:37:21 +0200 Subject: [PATCH] fix: bind escalate command to project db --- .../extensions/sf/commands-escalate.js | 2 + .../sf/tests/commands-escalate-db.test.mjs | 102 ++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 src/resources/extensions/sf/tests/commands-escalate-db.test.mjs diff --git a/src/resources/extensions/sf/commands-escalate.js b/src/resources/extensions/sf/commands-escalate.js index d5b2ba0a8..22b52a866 100644 --- a/src/resources/extensions/sf/commands-escalate.js +++ b/src/resources/extensions/sf/commands-escalate.js @@ -7,6 +7,7 @@ // — apply user choice, clear flag, allow loop to continue // // All operations run against the active project's DB (process.cwd()-rooted). +import { ensureDbOpen } from "./bootstrap/dynamic-tools.js"; import { readEscalationArtifact, resolveEscalation } from "./escalation.js"; import { getActiveMilestoneFromDb, @@ -32,6 +33,7 @@ function parseSliceTask(spec) { return { sliceId: m[1], taskId: m[2] }; } export async function handleEscalate(args, ctx) { + await ensureDbOpen(process.cwd()); if (!isDbAvailable()) { ctx.ui.notify("SF database is not available. Run /sf doctor.", "error"); return; diff --git a/src/resources/extensions/sf/tests/commands-escalate-db.test.mjs b/src/resources/extensions/sf/tests/commands-escalate-db.test.mjs new file mode 100644 index 000000000..b1a644a3f --- /dev/null +++ b/src/resources/extensions/sf/tests/commands-escalate-db.test.mjs @@ -0,0 +1,102 @@ +/** + * commands-escalate-db.test.mjs — `/sf escalate` database binding coverage. + * + * Purpose: prove escalation commands read the current project SQLite DB instead + * of reusing a previously-open DB from another project. + */ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, test } from "vitest"; +import { handleEscalate } from "../commands-escalate.js"; +import { + closeDatabase, + insertMilestone, + insertSlice, + insertTask, + openDatabase, + setTaskEscalationPending, +} from "../sf-db.js"; + +const tmpRoots = []; +const originalCwd = process.cwd(); + +afterEach(() => { + process.chdir(originalCwd); + closeDatabase(); + for (const root of tmpRoots.splice(0)) { + rmSync(root, { recursive: true, force: true }); + } +}); + +function makeProject() { + const root = mkdtempSync(join(tmpdir(), "sf-escalate-db-")); + mkdirSync(join(root, ".sf", "runtime"), { recursive: true }); + tmpRoots.push(root); + return root; +} + +function makeCtx() { + const notifications = []; + return { + ctx: { + ui: { + notify(message, level = "info") { + notifications.push({ message, level }); + }, + }, + }, + notifications, + }; +} + +function seedEscalation(project) { + openDatabase(join(project, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "First milestone", status: "active" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "First slice", + status: "pending", + }); + insertTask({ + milestoneId: "M001", + sliceId: "S01", + id: "T01", + title: "Needs decision", + status: "pending", + }); + const artifactPath = join( + project, + ".sf", + "runtime", + "M001-S01-T01-ESCALATION.json", + ); + writeFileSync( + artifactPath, + JSON.stringify({ + question: "Use path A or B?", + options: [{ id: "a", label: "Path A" }], + recommendation: "a", + recommendationRationale: "lowest risk", + }), + "utf-8", + ); + setTaskEscalationPending("M001", "S01", "T01", artifactPath); +} + +test("handleEscalate_when_process_switches_projects_opens_current_project_db", async () => { + const first = makeProject(); + const second = makeProject(); + seedEscalation(first); + process.chdir(second); + + const { ctx, notifications } = makeCtx(); + await handleEscalate("list", ctx); + + assert.equal(notifications.length, 1); + assert.equal(notifications[0].level, "info"); + assert.match(notifications[0].message, /No active milestone/); + assert.doesNotMatch(notifications[0].message, /Use path A or B/); +});