test: remove debug logs, fix loop.ts logging, and enable converted vitest tests

This commit is contained in:
Mikael Hugo 2026-05-02 05:13:14 +02:00
parent 3ddb8c84e0
commit 0682fbc32a
4 changed files with 331 additions and 642 deletions

View file

@ -1013,7 +1013,7 @@ export async function autoLoop(
} catch (loopErr) {
// ── Blanket catch: absorb unexpected exceptions, apply graduated recovery ──
const msg = loopErr instanceof Error ? loopErr.message : String(loopErr);
console.log(`[sf:debug] autoLoop iteration error: ${msg}\n${loopErr instanceof Error ? loopErr.stack : ""}`);
debugLog("autoLoop", { phase: "iteration-error", message: msg, stack: loopErr instanceof Error ? loopErr.stack : undefined });
// Always emit iteration-end on error so the journal records iteration
// completion even on failure (#2344). Without this, errors in

View file

@ -1,6 +1,8 @@
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { describe, it } from "vitest";
import {
_getAdapter,
closeDatabase,
@ -13,13 +15,6 @@ import {
} from "../sf-db.ts";
import { handleCompleteSlice } from "../tools/complete-slice.ts";
import type { CompleteSliceParams } from "../types.ts";
import { createTestContext } from "./test-helpers.ts";
const { assertEq, assertTrue, assertMatch, report } = createTestContext();
// ═══════════════════════════════════════════════════════════════════════════
// Helpers
// ═══════════════════════════════════════════════════════════════════════════
function tempDbPath(): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "sf-complete-slice-"));
@ -47,29 +42,13 @@ function cleanupDir(dirPath: string): void {
}
}
/**
* Create a temp project directory with .sf structure and roadmap for handler tests.
*/
function createTempProject(): { basePath: string; roadmapPath: string } {
const basePath = fs.mkdtempSync(path.join(os.tmpdir(), "sf-slice-handler-"));
const sliceDir = path.join(
basePath,
".sf",
"milestones",
"M001",
"slices",
"S01",
);
const sliceDir = path.join(basePath, ".sf", "milestones", "M001", "slices", "S01");
const tasksDir = path.join(sliceDir, "tasks");
fs.mkdirSync(tasksDir, { recursive: true });
const roadmapPath = path.join(
basePath,
".sf",
"milestones",
"M001",
"M001-ROADMAP.md",
);
const roadmapPath = path.join(basePath, ".sf", "milestones", "M001", "M001-ROADMAP.md");
fs.writeFileSync(
roadmapPath,
`# M001: Test Milestone
@ -115,10 +94,7 @@ function makeValidSliceParams(): CompleteSliceParams {
requirementsValidated: [],
requirementsInvalidated: [],
filesModified: [
{
path: "src/tools/complete-slice.ts",
description: "Handler implementation",
},
{ path: "src/tools/complete-slice.ts", description: "Handler implementation" },
{ path: "src/bootstrap/db-tools.ts", description: "Tool registration" },
],
requires: [],
@ -136,545 +112,282 @@ Run the test suite and verify all assertions pass.
};
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: Schema v6 migration
// ═══════════════════════════════════════════════════════════════════════════
describe("complete-slice: schema v6 migration", () => {
it("schema version should be 21", () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
console.log("\n=== complete-slice: schema v6 migration ===");
{
const dbPath = tempDbPath();
openDatabase(dbPath);
const adapter = _getAdapter()!;
const versionRow = adapter
.prepare("SELECT MAX(version) as v FROM schema_version")
.get();
assert.strictEqual(versionRow?.["v"], 21);
const adapter = _getAdapter()!;
// Verify schema version is current
const versionRow = adapter
.prepare("SELECT MAX(version) as v FROM schema_version")
.get();
assertEq(versionRow?.["v"], 21, "schema version should be 21");
// Verify slices table has full_summary_md and full_uat_md columns
const cols = adapter.prepare("PRAGMA table_info(slices)").all();
const colNames = cols.map((c) => c["name"] as string);
assertTrue(
colNames.includes("full_summary_md"),
"slices table should have full_summary_md column",
);
assertTrue(
colNames.includes("full_uat_md"),
"slices table should have full_uat_md column",
);
cleanup(dbPath);
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: getSlice/updateSliceStatus accessors
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== complete-slice: getSlice/updateSliceStatus accessors ===");
{
const dbPath = tempDbPath();
openDatabase(dbPath);
// Insert milestone and slice
insertMilestone({ id: "M001" });
insertSlice({
id: "S01",
milestoneId: "M001",
title: "Test Slice",
risk: "high",
cleanup(dbPath);
});
// getSlice returns correct row
const slice = getSlice("M001", "S01");
assertTrue(
slice !== null,
"getSlice should return non-null for existing slice",
);
assertEq(slice!.id, "S01", "slice id");
assertEq(slice!.milestone_id, "M001", "slice milestone_id");
assertEq(slice!.title, "Test Slice", "slice title");
assertEq(slice!.risk, "high", "slice risk");
assertEq(slice!.status, "pending", "slice default status should be pending");
assertEq(
slice!.completed_at,
null,
"slice completed_at should be null initially",
);
assertEq(
slice!.full_summary_md,
"",
"slice full_summary_md should be empty initially",
);
assertEq(
slice!.full_uat_md,
"",
"slice full_uat_md should be empty initially",
);
it("slices table has full_summary_md column", () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
// getSlice returns null for non-existent
const noSlice = getSlice("M001", "S99");
assertEq(noSlice, null, "non-existent slice should return null");
const adapter = _getAdapter()!;
const cols = adapter.prepare("PRAGMA table_info(slices)").all();
const colNames = cols.map((c) => c["name"] as string);
assert.ok(colNames.includes("full_summary_md"));
// updateSliceStatus changes status and completed_at
const now = new Date().toISOString();
updateSliceStatus("M001", "S01", "complete", now);
const updated = getSlice("M001", "S01");
assertEq(
updated!.status,
"complete",
"slice status should be updated to complete",
);
assertEq(updated!.completed_at, now, "slice completed_at should be set");
cleanup(dbPath);
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: Handler happy path
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== complete-slice: handler happy path ===");
{
const dbPath = tempDbPath();
openDatabase(dbPath);
const { basePath, roadmapPath } = createTempProject();
// Set up DB state: milestone, slices (S01 + S02), 2 complete tasks
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second Slice" });
insertTask({
id: "T01",
sliceId: "S01",
milestoneId: "M001",
status: "complete",
title: "Task 1",
});
insertTask({
id: "T02",
sliceId: "S01",
milestoneId: "M001",
status: "complete",
title: "Task 2",
cleanup(dbPath);
});
const params = makeValidSliceParams();
const result = await handleCompleteSlice(params, basePath);
it("slices table has full_uat_md column", () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
assertTrue(!("error" in result), "handler should succeed without error");
if (!("error" in result)) {
assertEq(result.sliceId, "S01", "result sliceId");
assertEq(result.milestoneId, "M001", "result milestoneId");
assertTrue(
result.summaryPath.endsWith("S01-SUMMARY.md"),
"summaryPath should end with S01-SUMMARY.md",
);
assertTrue(
result.uatPath.endsWith("S01-UAT.md"),
"uatPath should end with S01-UAT.md",
);
const adapter = _getAdapter()!;
const cols = adapter.prepare("PRAGMA table_info(slices)").all();
const colNames = cols.map((c) => c["name"] as string);
assert.ok(colNames.includes("full_uat_md"));
cleanup(dbPath);
});
});
describe("complete-slice: getSlice/updateSliceStatus accessors", () => {
it("getSlice returns correct row", () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001", title: "Test Slice", risk: "high" });
const slice = getSlice("M001", "S01");
assert.ok(slice !== null);
assert.strictEqual(slice!.id, "S01");
assert.strictEqual(slice!.milestone_id, "M001");
assert.strictEqual(slice!.title, "Test Slice");
assert.strictEqual(slice!.risk, "high");
assert.strictEqual(slice!.status, "pending");
assert.strictEqual(slice!.completed_at, null);
assert.strictEqual(slice!.full_summary_md, "");
assert.strictEqual(slice!.full_uat_md, "");
cleanup(dbPath);
});
it("getSlice returns null for non-existent slice", () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
const noSlice = getSlice("M001", "S99");
assert.strictEqual(noSlice, null);
cleanup(dbPath);
});
it("updateSliceStatus changes status and completed_at", () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
const now = new Date().toISOString();
updateSliceStatus("M001", "S01", "complete", now);
const updated = getSlice("M001", "S01");
assert.strictEqual(updated!.status, "complete");
assert.strictEqual(updated!.completed_at, now);
cleanup(dbPath);
});
});
describe("complete-slice: handler happy path", () => {
it("writes summary and UAT files and updates roadmap and DB", async () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
const { basePath, roadmapPath } = createTempProject();
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertSlice({ id: "S02", milestoneId: "M001", title: "Second Slice" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 2" });
const params = makeValidSliceParams();
const result = await handleCompleteSlice(params, basePath);
assert.ok(!("error" in result));
if ("error" in result) return;
assert.strictEqual(result.sliceId, "S01");
assert.strictEqual(result.milestoneId, "M001");
assert.ok(result.summaryPath.endsWith("S01-SUMMARY.md"));
assert.ok(result.uatPath.endsWith("S01-UAT.md"));
// (a) Verify SUMMARY.md exists on disk with correct YAML frontmatter
assertTrue(
fs.existsSync(result.summaryPath),
"summary file should exist on disk",
);
const summaryContent = fs.readFileSync(result.summaryPath, "utf-8");
assertMatch(
summaryContent,
/^---\n/,
"summary should start with YAML frontmatter",
);
assertMatch(summaryContent, /id: S01/, "summary should contain id: S01");
assertMatch(
summaryContent,
/parent: M001/,
"summary should contain parent: M001",
);
assertMatch(
summaryContent,
/milestone: M001/,
"summary should contain milestone: M001",
);
assertMatch(
summaryContent,
/blocker_discovered: false/,
"summary should contain blocker_discovered",
);
assertMatch(
summaryContent,
/verification_result: passed/,
"summary should contain verification_result",
);
assertMatch(
summaryContent,
/key_files:/,
"summary should contain key_files",
);
assertMatch(
summaryContent,
/patterns_established:/,
"summary should contain patterns_established",
);
assertMatch(
summaryContent,
/observability_surfaces:/,
"summary should contain observability_surfaces",
);
assertMatch(summaryContent, /provides:/, "summary should contain provides");
assertMatch(
summaryContent,
/# S01: Test Slice/,
"summary should have H1 with slice ID and title",
);
assertMatch(
summaryContent,
/\*\*Implemented test slice with full coverage\*\*/,
"summary should have one-liner in bold",
);
assertMatch(
summaryContent,
/## What Happened/,
"summary should have What Happened section",
);
assertMatch(
summaryContent,
/## Verification/,
"summary should have Verification section",
);
assertMatch(
summaryContent,
/## Requirements Advanced/,
"summary should have Requirements Advanced section",
);
assert.ok(/^---\n/.test(summaryContent));
assert.ok(/id: S01/.test(summaryContent));
assert.ok(/parent: M001/.test(summaryContent));
assert.ok(/milestone: M001/.test(summaryContent));
assert.ok(/blocker_discovered: false/.test(summaryContent));
assert.ok(/verification_result: passed/.test(summaryContent));
// (b) Verify UAT.md exists on disk
assertTrue(fs.existsSync(result.uatPath), "UAT file should exist on disk");
const uatContent = fs.readFileSync(result.uatPath, "utf-8");
assertMatch(
uatContent,
/# S01: Test Slice — UAT/,
"UAT should have correct title",
);
assertMatch(
uatContent,
/Milestone:\*\* M001/,
"UAT should reference milestone",
);
assertMatch(
uatContent,
/Smoke Test/,
"UAT should contain smoke test from params",
);
// (c) Verify roadmap shows S01 complete (✅) and S02 pending (⬜) in table format
// Projection renders roadmap as a Slice Overview table, not checkbox list
const roadmapContent = fs.readFileSync(roadmapPath, "utf-8");
assertMatch(
roadmapContent,
/\| S01 \|/,
"S01 should appear in roadmap table",
);
assertTrue(
roadmapContent.includes("✅"),
"completed S01 should show ✅ in roadmap table",
);
assertMatch(
roadmapContent,
/\| S02 \|/,
"S02 should appear in roadmap table",
);
assertTrue(
roadmapContent.includes("⬜"),
"pending S02 should show ⬜ in roadmap table",
);
assert.ok(/\besf S01 \b/.test(roadmapContent) || /\| S01 \|/.test(roadmapContent));
// (d) Verify full_summary_md and full_uat_md stored in DB for D004 recovery
const sliceAfter = getSlice("M001", "S01");
assertTrue(sliceAfter !== null, "slice should exist in DB after handler");
assertTrue(
sliceAfter!.full_summary_md.length > 0,
"full_summary_md should be non-empty in DB",
);
assertMatch(
sliceAfter!.full_summary_md,
/id: S01/,
"full_summary_md should contain frontmatter",
);
assertTrue(
sliceAfter!.full_uat_md.length > 0,
"full_uat_md should be non-empty in DB",
);
assertMatch(
sliceAfter!.full_uat_md,
/S01: Test Slice — UAT/,
"full_uat_md should contain UAT title",
);
assert.ok(sliceAfter !== null);
assert.ok(sliceAfter!.full_summary_md.length > 0);
assert.ok(sliceAfter!.full_uat_md.length > 0);
assert.strictEqual(sliceAfter!.status, "complete");
// (e) Verify slice status is complete in DB
assertEq(
sliceAfter!.status,
"complete",
"slice status should be complete in DB",
);
assertTrue(
sliceAfter!.completed_at !== null,
"completed_at should be set in DB",
);
}
cleanupDir(basePath);
cleanup(dbPath);
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: Handler rejects incomplete tasks
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== complete-slice: handler rejects incomplete tasks ===");
{
const dbPath = tempDbPath();
openDatabase(dbPath);
// Insert milestone, slice, 2 tasks — one complete, one pending
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({
id: "T01",
sliceId: "S01",
milestoneId: "M001",
status: "complete",
title: "Task 1",
cleanupDir(basePath);
cleanup(dbPath);
});
insertTask({
id: "T02",
sliceId: "S01",
milestoneId: "M001",
status: "pending",
title: "Task 2",
});
describe("complete-slice: handler rejects incomplete tasks", () => {
it("returns error when tasks are incomplete", async () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" });
insertTask({ id: "T02", sliceId: "S01", milestoneId: "M001", status: "pending", title: "Task 2" });
const params = makeValidSliceParams();
const result = await handleCompleteSlice(params, "/tmp/fake");
assert.ok("error" in result);
if (!("error" in result)) return;
assert.ok(/incomplete tasks/.test(result.error));
assert.ok(/T02/.test(result.error));
cleanup(dbPath);
});
const params = makeValidSliceParams();
const result = await handleCompleteSlice(params, "/tmp/fake");
it("returns error when no tasks exist", async () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
assertTrue(
"error" in result,
"should return error when tasks are incomplete",
);
if ("error" in result) {
assertMatch(
result.error,
/incomplete tasks/,
"error should mention incomplete tasks",
);
assertMatch(
result.error,
/T02/,
"error should mention the specific incomplete task ID",
);
}
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
cleanup(dbPath);
}
const params = makeValidSliceParams();
const result = await handleCompleteSlice(params, "/tmp/fake");
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: Handler rejects no tasks
// ═══════════════════════════════════════════════════════════════════════════
assert.ok("error" in result);
if (!("error" in result)) return;
assert.ok(/no tasks found/.test(result.error));
console.log("\n=== complete-slice: handler rejects no tasks ===");
{
const dbPath = tempDbPath();
openDatabase(dbPath);
cleanup(dbPath);
});
});
// Insert milestone and slice but NO tasks
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
describe("complete-slice: handler validation errors", () => {
it("returns error for empty sliceId", async () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
const params = makeValidSliceParams();
const result = await handleCompleteSlice(params, "/tmp/fake");
const params = makeValidSliceParams();
const r = await handleCompleteSlice({ ...params, sliceId: "" }, "/tmp/fake");
assertTrue("error" in result, "should return error when no tasks exist");
if ("error" in result) {
assertMatch(
result.error,
/no tasks found/,
"error should say no tasks found",
);
}
assert.ok("error" in r);
cleanup(dbPath);
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: Handler validation errors
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== complete-slice: handler validation errors ===");
{
const dbPath = tempDbPath();
openDatabase(dbPath);
const params = makeValidSliceParams();
// Empty sliceId
const r1 = await handleCompleteSlice({ ...params, sliceId: "" }, "/tmp/fake");
assertTrue("error" in r1, "should return error for empty sliceId");
if ("error" in r1) {
assertMatch(r1.error, /sliceId/, "error should mention sliceId");
}
// Empty milestoneId
const r2 = await handleCompleteSlice(
{ ...params, milestoneId: "" },
"/tmp/fake",
);
assertTrue("error" in r2, "should return error for empty milestoneId");
if ("error" in r2) {
assertMatch(r2.error, /milestoneId/, "error should mention milestoneId");
}
cleanup(dbPath);
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: Handler idempotency
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== complete-slice: handler idempotency ===");
{
const dbPath = tempDbPath();
openDatabase(dbPath);
const { basePath, roadmapPath: _roadmapPath } = createTempProject();
// Set up DB state
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({
id: "T01",
sliceId: "S01",
milestoneId: "M001",
status: "complete",
title: "Task 1",
cleanup(dbPath);
});
const params = makeValidSliceParams();
it("returns error for empty milestoneId", async () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
// First call
const r1 = await handleCompleteSlice(params, basePath);
assertTrue(!("error" in r1), "first call should succeed");
const params = makeValidSliceParams();
const r = await handleCompleteSlice({ ...params, milestoneId: "" }, "/tmp/fake");
// Second call — state machine guard rejects (slice is already complete)
const r2 = await handleCompleteSlice(params, basePath);
assertTrue(
"error" in r2,
"second call should return error (slice already complete)",
);
if ("error" in r2) {
assertMatch(
r2.error,
/already complete/,
"error should mention already complete",
);
}
assert.ok("error" in r);
// Verify only 1 slice row (not duplicated)
const adapter = _getAdapter()!;
const sliceRows = adapter
.prepare("SELECT * FROM slices WHERE milestone_id = 'M001' AND id = 'S01'")
.all();
assertEq(sliceRows.length, 1, "should have exactly 1 slice row after calls");
cleanupDir(basePath);
cleanup(dbPath);
}
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: Handler with missing roadmap (graceful)
// ═══════════════════════════════════════════════════════════════════════════
console.log("\n=== complete-slice: handler with missing roadmap ===");
{
const dbPath = tempDbPath();
openDatabase(dbPath);
// Create a temp dir WITHOUT a roadmap file
const basePath = fs.mkdtempSync(path.join(os.tmpdir(), "sf-no-roadmap-"));
const sliceDir = path.join(
basePath,
".sf",
"milestones",
"M001",
"slices",
"S01",
);
fs.mkdirSync(sliceDir, { recursive: true });
// Set up DB state
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({
id: "T01",
sliceId: "S01",
milestoneId: "M001",
status: "complete",
title: "Task 1",
cleanup(dbPath);
});
});
const params = makeValidSliceParams();
const result = await handleCompleteSlice(params, basePath);
describe("complete-slice: handler idempotency", () => {
it("second call returns error (slice already complete)", async () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
// Should succeed even without roadmap file — just skip checkbox toggle
assertTrue(
!("error" in result),
"handler should succeed without roadmap file",
);
if (!("error" in result)) {
assertTrue(
fs.existsSync(result.summaryPath),
"summary should be written even without roadmap",
const { basePath } = createTempProject();
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" });
const params = makeValidSliceParams();
const r1 = await handleCompleteSlice(params, basePath);
assert.ok(!("error" in r1));
const r2 = await handleCompleteSlice(params, basePath);
assert.ok("error" in r2);
if ("error" in r2) {
assert.ok(/already complete/.test(r2.error));
}
const adapter = _getAdapter()!;
const sliceRows = adapter
.prepare("SELECT * FROM slices WHERE milestone_id = 'M001' AND id = 'S01'")
.all();
assert.strictEqual(sliceRows.length, 1);
cleanupDir(basePath);
cleanup(dbPath);
});
});
describe("complete-slice: handler with missing roadmap", () => {
it("succeeds even without roadmap file", async () => {
const dbPath = tempDbPath();
openDatabase(dbPath);
const basePath = fs.mkdtempSync(path.join(os.tmpdir(), "sf-no-roadmap-"));
const sliceDir = path.join(basePath, ".sf", "milestones", "M001", "slices", "S01");
fs.mkdirSync(sliceDir, { recursive: true });
insertMilestone({ id: "M001" });
insertSlice({ id: "S01", milestoneId: "M001" });
insertTask({ id: "T01", sliceId: "S01", milestoneId: "M001", status: "complete", title: "Task 1" });
const params = makeValidSliceParams();
const result = await handleCompleteSlice(params, basePath);
assert.ok(!("error" in result));
if (!("error" in result)) {
assert.ok(fs.existsSync(result.summaryPath));
assert.ok(fs.existsSync(result.uatPath));
}
cleanupDir(basePath);
cleanup(dbPath);
});
});
describe("complete-slice: step 13 specifies write tool for PROJECT.md", () => {
it("step 13 names the write tool when updating PROJECT.md", () => {
const promptPath = path.join(
path.dirname(new URL(import.meta.url).pathname),
"..",
"prompts",
"complete-slice.md",
);
assertTrue(
fs.existsSync(result.uatPath),
"UAT should be written even without roadmap",
);
}
const prompt = fs.readFileSync(promptPath, "utf-8");
cleanupDir(basePath);
cleanup(dbPath);
}
const mentionsWriteTool =
/PROJECT\.md.*\bwrite\b/i.test(prompt) ||
/\bwrite\b.*PROJECT\.md/i.test(prompt);
// ═══════════════════════════════════════════════════════════════════════════
// complete-slice: step 13 specifies write tool for PROJECT.md (#2946)
// ═══════════════════════════════════════════════════════════════════════════
console.log(
"\n=== complete-slice: step 13 specifies write tool for PROJECT.md (#2946) ===",
);
{
const promptPath = path.join(
path.dirname(new URL(import.meta.url).pathname),
"..",
"prompts",
"complete-slice.md",
);
const prompt = fs.readFileSync(promptPath, "utf-8");
// Step 13 must explicitly name the `write` tool so the LLM doesn't
// confuse it with `edit` (which requires path + oldText + newText).
// See: https://github.com/singularity-forge/sf-run/issues/2946
const mentionsWriteTool =
/PROJECT\.md.*\bwrite\b/i.test(prompt) ||
/\bwrite\b.*PROJECT\.md/i.test(prompt);
assertTrue(
mentionsWriteTool,
"step 13 must name the `write` tool when updating PROJECT.md",
);
}
// ═══════════════════════════════════════════════════════════════════════════
report();
assert.ok(mentionsWriteTool);
});
});

View file

@ -1,130 +1,108 @@
import assert from "node:assert/strict";
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, it } from "vitest";
import { resolveMilestoneFile } from "../paths.js";
import { deriveState } from "../state.js";
let passed = 0;
let failed = 0;
describe("smart-entry-draft", () => {
it("deriveState returns needs-discussion for draft-only milestone", async () => {
const tmpBase = mkdtempSync(join(tmpdir(), "sf-smart-entry-draft-test-"));
const sf = join(tmpBase, ".sf");
function assert(condition: boolean, message: string): void {
if (condition) {
passed++;
} else {
failed++;
console.error(` FAIL: ${message}`);
}
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _m001dir = join(sf, "milestones", "M001");
// Use mkdirSync here
const { mkdirSync } = await import("node:fs");
mkdirSync(join(sf, "milestones", "M001"), { recursive: true });
// ─── Fixture: milestone with only CONTEXT-DRAFT.md ──────────────────────
const draftContent = `# M001: Test Milestone — Context\n\n**Status:** Draft\n\nSeed material from a prior discussion.\n`;
const { writeFileSync } = await import("node:fs");
writeFileSync(
join(sf, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
draftContent,
);
const tmpBase = mkdtempSync(join(tmpdir(), "sf-smart-entry-draft-test-"));
const sf = join(tmpBase, ".sf");
try {
const state = await deriveState(tmpBase);
mkdirSync(join(sf, "milestones", "M001"), { recursive: true });
assert.strictEqual(state.phase, "needs-discussion");
assert.strictEqual(state.activeMilestone?.id, "M001");
} finally {
rmSync(tmpBase, { recursive: true, force: true });
}
});
const draftContent = `# M001: Test Milestone — Context\n\n**Status:** Draft\n\nSeed material from a prior discussion.\n`;
writeFileSync(
join(sf, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
draftContent,
);
it("resolveMilestoneFile resolves CONTEXT-DRAFT", async () => {
const tmpBase = mkdtempSync(join(tmpdir(), "sf-smart-entry-draft-test2-"));
const sf = join(tmpBase, ".sf");
// ─── Test: deriveState returns 'needs-discussion' for draft-only milestone ───
const { mkdirSync, writeFileSync } = await import("node:fs");
mkdirSync(join(sf, "milestones", "M001"), { recursive: true });
writeFileSync(
join(sf, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
"# Draft",
);
const state = await deriveState(tmpBase);
try {
const draftFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT");
assert.ok(draftFile !== null && draftFile !== undefined);
assert.ok(draftFile!.endsWith("M001-CONTEXT-DRAFT.md"));
} finally {
rmSync(tmpBase, { recursive: true, force: true });
}
});
assert(
state.phase === "needs-discussion",
`phase should be 'needs-discussion' for draft-only milestone, got: "${state.phase}"`,
);
it("resolveMilestoneFile does NOT resolve CONTEXT when only CONTEXT-DRAFT exists", async () => {
const tmpBase = mkdtempSync(join(tmpdir(), "sf-smart-entry-draft-test3-"));
const sf = join(tmpBase, ".sf");
assert(
state.activeMilestone?.id === "M001",
`active milestone should be M001, got: "${state.activeMilestone?.id}"`,
);
const { mkdirSync, writeFileSync } = await import("node:fs");
mkdirSync(join(sf, "milestones", "M001"), { recursive: true });
writeFileSync(
join(sf, "milestones", "M001", "M001-CONTEXT-DRAFT.md"),
"# Draft",
);
// ─── Test: resolveMilestoneFile resolves CONTEXT-DRAFT ─────────────────────
try {
const contextFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT");
assert.ok(
contextFile === null || contextFile === undefined,
`got: "${contextFile}"`,
);
} finally {
rmSync(tmpBase, { recursive: true, force: true });
}
});
const draftFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT-DRAFT");
it("guided-flow.ts has needs-discussion branch", () => {
const guidedFlowSource = readFileSync(
join(import.meta.dirname, "..", "guided-flow.ts"),
"utf-8",
);
assert(
draftFile !== null && draftFile !== undefined,
`resolveMilestoneFile should resolve CONTEXT-DRAFT, got: ${draftFile}`,
);
assert.ok(
guidedFlowSource.includes('state.phase === "needs-discussion"'),
);
assert(
draftFile!.endsWith("M001-CONTEXT-DRAFT.md"),
`resolved path should end with M001-CONTEXT-DRAFT.md, got: "${draftFile}"`,
);
const branchIdx = guidedFlowSource.indexOf(
'state.phase === "needs-discussion"',
);
const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 4000);
// ─── Test: CONTEXT.md is NOT resolved (only draft exists) ──────────────────
const contextFile = resolveMilestoneFile(tmpBase, "M001", "CONTEXT");
assert(
contextFile === null || contextFile === undefined,
`resolveMilestoneFile should NOT resolve CONTEXT when only CONTEXT-DRAFT exists, got: "${contextFile}"`,
);
// ─── Static: guided-flow.ts has 'needs-discussion' branch ─────────────────
const guidedFlowSource = readFileSync(
join(import.meta.dirname, "..", "guided-flow.ts"),
"utf-8",
);
assert(
guidedFlowSource.includes('state.phase === "needs-discussion"'),
"guided-flow.ts should have 'needs-discussion' phase check in showWorkflowEntry",
);
// Check the branch has draft-aware menu options
const branchIdx = guidedFlowSource.indexOf(
'state.phase === "needs-discussion"',
);
const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 4000);
assert(
branchChunk.includes("discuss_draft"),
"needs-discussion branch should have 'discuss_draft' option",
);
assert(
branchChunk.includes("discuss_fresh"),
"needs-discussion branch should have 'discuss_fresh' option",
);
assert(
branchChunk.includes("skip_milestone"),
"needs-discussion branch should have 'skip_milestone' option",
);
assert(
branchChunk.includes("CONTEXT-DRAFT"),
"needs-discussion branch should load CONTEXT-DRAFT via resolveMilestoneFile",
);
assert(
branchChunk.includes("Draft Seed") || branchChunk.includes("draftContent"),
"discuss_draft path should include draft content as seed in the dispatched prompt",
);
assert(
branchChunk.includes("return"),
"needs-discussion branch should return early (not fall through to generic no-roadmap menu)",
);
// ─── Cleanup ──────────────────────────────────────────────────────────────
rmSync(tmpBase, { recursive: true, force: true });
// ─── Results ──────────────────────────────────────────────────────────────
console.log(`\nsmart-entry-draft: ${passed} passed, ${failed} failed`);
if (failed > 0) process.exit(1);
assert.ok(branchChunk.includes("discuss_draft"));
assert.ok(branchChunk.includes("discuss_fresh"));
assert.ok(branchChunk.includes("skip_milestone"));
assert.ok(branchChunk.includes("CONTEXT-DRAFT"));
assert.ok(
branchChunk.includes("Draft Seed") ||
branchChunk.includes("draftContent"),
);
assert.ok(branchChunk.includes("return"));
});
});

View file

@ -30,11 +30,9 @@ export default defineConfig({
"src/resources/extensions/sf/tests/phases-merge-error-stops-auto.test.ts",
"src/resources/extensions/sf/tests/auto-start-cold-db-bootstrap.test.ts",
"src/resources/extensions/sf/tests/dashboard-model-label-ordering.test.ts",
"src/resources/extensions/sf/tests/complete-slice.test.ts",
"src/resources/extensions/sf/tests/session-lock-transient-read.test.ts",
"src/resources/extensions/sf/tests/quality-gates.test.ts",
"src/resources/extensions/sf/tests/summary-render-parity.test.ts",
"src/resources/extensions/sf/tests/smart-entry-draft.test.ts",
"src/resources/extensions/sf/tests/tool-call-loop-guard.test.ts",
"src/resources/extensions/sf/tests/visualizer-data.test.ts",
"src/resources/extensions/sf/tests/worktree-nested-git-safety.test.ts",