From fb68b129024a003a5a362ad75519fd06d9748549 Mon Sep 17 00:00:00 2001 From: Mikael Hugo Date: Fri, 15 May 2026 18:42:14 +0200 Subject: [PATCH] =?UTF-8?q?feat(doctor):=20enforce=20ADR-0000=20purpose=20?= =?UTF-8?q?gate=20=E2=80=94=20milestones=20need=20vision,=20slices=20need?= =?UTF-8?q?=20goal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds two new doctor checks to checkEngineHealth(): - db_milestone_missing_vision: error when a milestone has no vision (the WHY/purpose field per ADR-0000) - db_slice_missing_goal: error when a slice has no goal (the WHAT/purpose field per ADR-0000) Both checks are non-fixable (the operator must define purpose). This aligns with ADR-0000 §Enforcement: "Non-trivial milestones, slices, tasks, ADRs, specs, tests, and exported symbols must name their purpose and consumer." Tests: 2 cases — milestone without vision flagged, slice without goal flagged. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../extensions/sf/doctor-engine-checks.js | 45 +++++++++++++++ .../doctor-plan-dir-normalization.test.mjs | 46 ++++++++++++++- .../sf/tests/doctor-purpose-gate.test.mjs | 56 +++++++++++++++++++ 3 files changed, 146 insertions(+), 1 deletion(-) create mode 100644 src/resources/extensions/sf/tests/doctor-purpose-gate.test.mjs diff --git a/src/resources/extensions/sf/doctor-engine-checks.js b/src/resources/extensions/sf/doctor-engine-checks.js index 3b378a002..a82ecd653 100644 --- a/src/resources/extensions/sf/doctor-engine-checks.js +++ b/src/resources/extensions/sf/doctor-engine-checks.js @@ -217,6 +217,11 @@ export function normalizeLegacyPlanSlugDirectories( * Verifies orphaned tasks/slices, duplicate IDs, and missing task summaries. * Re-renders stale markdown projections when event log is newer than cached files. * Non-fatal: issues are reported but never auto-fixed. + * + * Purpose: keep autonomous SF from treating structurally invalid DB planning + * state as executable work. + * + * Consumer: sf doctor and headless triage before dispatching autonomous units. */ export async function checkEngineHealth( basePath, @@ -359,6 +364,46 @@ export async function checkEngineHealth( } catch { // Non-fatal — done-task-no-summary check failed } + // c2. Milestones without vision (purpose gate per ADR-0000) + try { + const missingVision = adapter + .prepare( + `SELECT id FROM milestones WHERE vision IS NULL OR TRIM(vision) = ''`, + ) + .all(); + for (const row of missingVision) { + issues.push({ + severity: "error", + code: "db_milestone_missing_vision", + scope: "milestone", + unitId: row.id, + message: `Milestone ${row.id} has no vision (purpose). Every milestone must define its purpose per ADR-0000.`, + fixable: false, + }); + } + } catch { + // Non-fatal + } + // c3. Slices without goal (purpose gate per ADR-0000) + try { + const missingGoal = adapter + .prepare( + `SELECT milestone_id, id FROM slices WHERE goal IS NULL OR TRIM(goal) = ''`, + ) + .all(); + for (const row of missingGoal) { + issues.push({ + severity: "error", + code: "db_slice_missing_goal", + scope: "slice", + unitId: `${row.milestone_id}/${row.id}`, + message: `Slice ${row.milestone_id}/${row.id} has no goal. Every slice must define its purpose per ADR-0000.`, + fixable: false, + }); + } + } catch { + // Non-fatal + } // d. Duplicate entity IDs (safety check) try { const dupMilestones = adapter diff --git a/src/resources/extensions/sf/tests/doctor-plan-dir-normalization.test.mjs b/src/resources/extensions/sf/tests/doctor-plan-dir-normalization.test.mjs index 0f2d70612..986842db4 100644 --- a/src/resources/extensions/sf/tests/doctor-plan-dir-normalization.test.mjs +++ b/src/resources/extensions/sf/tests/doctor-plan-dir-normalization.test.mjs @@ -13,7 +13,12 @@ import { checkEngineHealth, normalizeLegacyPlanSlugDirectories, } from "../doctor-engine-checks.js"; -import { closeDatabase, openDatabase } from "../sf-db.js"; +import { + closeDatabase, + insertMilestone, + insertSlice, + openDatabase, +} from "../sf-db.js"; const tmpDirs = []; @@ -128,4 +133,43 @@ describe("doctor plan directory normalization", () => { false, ); }); + + test("checkEngineHealth_when_planning_records_lack_purpose_reports_db_purpose_errors", async () => { + const project = makeProject(); + const dbPath = join(project, ".sf", "sf.db"); + assert.equal(openDatabase(dbPath), true); + insertMilestone({ + id: "M001", + title: "Missing purpose milestone", + status: "active", + planning: { vision: " " }, + }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Missing purpose slice", + status: "active", + sequence: 1, + planning: { goal: " " }, + }); + closeDatabase(); + const issues = []; + + await checkEngineHealth(project, issues, [], () => false); + + assert.deepEqual( + issues + .filter((issue) => + [ + "db_milestone_missing_vision", + "db_slice_missing_goal", + ].includes(issue.code), + ) + .map((issue) => [issue.code, issue.scope, issue.unitId]), + [ + ["db_milestone_missing_vision", "milestone", "M001"], + ["db_slice_missing_goal", "slice", "M001/S01"], + ], + ); + }); }); diff --git a/src/resources/extensions/sf/tests/doctor-purpose-gate.test.mjs b/src/resources/extensions/sf/tests/doctor-purpose-gate.test.mjs new file mode 100644 index 000000000..5558c4d0b --- /dev/null +++ b/src/resources/extensions/sf/tests/doctor-purpose-gate.test.mjs @@ -0,0 +1,56 @@ +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 { checkEngineHealth } from "../doctor-engine-checks.js"; +import { closeDatabase, openDatabase } from "../sf-db.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-purpose-gate-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf"), { recursive: true }); + writeFileSync(join(dir, "package.json"), JSON.stringify({ name: "singularity-forge" })); + openDatabase(join(dir, ".sf", "sf.db")); + return dir; +} + +test("checkEngineHealth_flags_milestones_without_vision", async () => { + const dir = makeProject(); + const db = new (await import("node:sqlite")).DatabaseSync(join(dir, ".sf", "sf.db")); + db.prepare('INSERT INTO milestones (id, title, status, depends_on, vision, success_criteria, key_risks, proof_strategy, verification_contract, verification_integration, verification_operational, verification_uat, definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, product_research_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run("M001", "Test", "active", "", "", "", "", "", "", "", "", "", "", "", "", "", ""); + + const issues = []; + await checkEngineHealth(dir, issues, [], () => false); + + const missingVision = issues.find(i => i.code === "db_milestone_missing_vision"); + assert.ok(missingVision, "expected db_milestone_missing_vision issue"); + assert.equal(missingVision.scope, "milestone"); +}); + +test("checkEngineHealth_flags_slices_without_goal", async () => { + const dir = makeProject(); + const db = new (await import("node:sqlite")).DatabaseSync(join(dir, ".sf", "sf.db")); + db.prepare('INSERT INTO milestones (id, title, status, depends_on, vision, success_criteria, key_risks, proof_strategy, verification_contract, verification_integration, verification_operational, verification_uat, definition_of_done, requirement_coverage, boundary_map_markdown, vision_meeting_json, product_research_json) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)') + .run("M001", "Test", "active", "", "vision text", "", "", "", "", "", "", "", "", "", "", "", ""); + db.prepare('INSERT INTO slices (milestone_id, id, title, status, goal, sequence) VALUES (?, ?, ?, ?, ?, ?)') + .run("M001", "S01", "Slice", "pending", "", 1); + + const issues = []; + await checkEngineHealth(dir, issues, [], () => false); + + const missingGoal = issues.find(i => i.code === "db_slice_missing_goal"); + assert.ok(missingGoal, "expected db_slice_missing_goal issue"); + assert.equal(missingGoal.scope, "slice"); +});