feat(doctor): enforce ADR-0000 purpose gate — milestones need vision, slices need goal
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>
This commit is contained in:
parent
af1401e4ea
commit
fb68b12902
3 changed files with 146 additions and 1 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
],
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue