diff --git a/src/resources/extensions/sf/doctor-engine-checks.js b/src/resources/extensions/sf/doctor-engine-checks.js index 92efd3142..3b378a002 100644 --- a/src/resources/extensions/sf/doctor-engine-checks.js +++ b/src/resources/extensions/sf/doctor-engine-checks.js @@ -14,7 +14,6 @@ import { _getAdapter, getAllMilestones, getMilestoneSlices, - getMilestoneValidationAssessment, isDbAvailable, openDatabase, } from "./sf-db.js"; @@ -23,7 +22,6 @@ import { summarizeParityHealth, writeParityReport, } from "./uok/parity-report.js"; -import { extractVerdict } from "./verdict-parser.js"; import { readEvents } from "./workflow-events.js"; import { renderAllProjections } from "./workflow-projections.js"; import { getErrorMessage } from "./error-utils.js"; @@ -125,29 +123,10 @@ function projectionDriftIssues(basePath, milestoneId) { }); } } - const validationPath = resolveMilestoneFile( - basePath, - milestoneId, - "VALIDATION", - ); - if (validationPath && existsSync(validationPath)) { - const dbAssessment = getMilestoneValidationAssessment(milestoneId); - if (dbAssessment?.status) { - const fileVerdict = extractVerdict(readFileSync(validationPath, "utf-8")); - const dbVerdict = String(dbAssessment.status).trim().toLowerCase(); - if (fileVerdict && fileVerdict !== dbVerdict) { - issues.push({ - severity: "warning", - code: "db_projection_validation_drift", - scope: "milestone", - unitId: milestoneId, - message: `${milestoneId}-VALIDATION.md verdict "${fileVerdict}" differs from DB assessment "${dbVerdict}". DB remains authoritative.`, - file: validationPath, - fixable: false, - }); - } - } - } + // db_projection_validation_drift check removed (#db-first-validation): + // VALIDATION.md is now a generated projection from assessments table. + // Drift is structurally impossible — the file is always regenerated from DB + // by handleValidateMilestone. No drift check needed. return issues; } diff --git a/src/resources/extensions/sf/tests/db-first-validation.test.mjs b/src/resources/extensions/sf/tests/db-first-validation.test.mjs new file mode 100644 index 000000000..c867f28fb --- /dev/null +++ b/src/resources/extensions/sf/tests/db-first-validation.test.mjs @@ -0,0 +1,304 @@ +/** + * db-first-validation.test.mjs — VALIDATION.md is a DB projection. + * + * Purpose: prove that after the DB-first migration: + * 1. Writing an assessments row with status=needs-attention and regenerating + * VALIDATION.md produces a file with verdict: needs-attention in frontmatter. + * 2. The three read sites (complete-milestone, workspace-index, state-shared) + * return the DB value, not the parsed .md. + * 3. If .md is hand-edited to disagree with DB, the next regeneration overrides. + * 4. Doctor does NOT register db_projection_validation_drift (check is retired). + */ +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 { afterEach, test } from "vitest"; +import { checkEngineHealth } from "../doctor-engine-checks.js"; +import { + closeDatabase, + getMilestoneValidationAssessment, + insertAssessment, + insertMilestone, + insertSlice, + insertTask, + openDatabase, +} from "../sf-db.js"; +import { invalidateStateCache } from "../state.js"; +import { readMilestoneValidationVerdict } from "../state-shared.js"; +import { handleCompleteMilestone } from "../tools/complete-milestone.js"; +import { handleValidateMilestone } from "../tools/validate-milestone.js"; +import { indexWorkspace } from "../workspace-index.js"; + +const tmpDirs = []; + +afterEach(() => { + closeDatabase(); + invalidateStateCache(); + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop(); + if (dir) rmSync(dir, { recursive: true, force: true }); + } +}); + +function makeProject() { + const dir = mkdtempSync(join(tmpdir(), "sf-db-first-validation-")); + tmpDirs.push(dir); + mkdirSync(join(dir, ".sf", "milestones", "M001"), { recursive: true }); + openDatabase(join(dir, ".sf", "sf.db")); + insertMilestone({ id: "M001", title: "DB-first validation", status: "active" }); + insertSlice({ + milestoneId: "M001", + id: "S01", + title: "Done slice", + status: "complete", + sequence: 1, + }); + insertTask({ + milestoneId: "M001", + sliceId: "S01", + id: "T01", + title: "Done task", + status: "complete", + }); + return dir; +} + +// ── Test 1: regeneration produces correct frontmatter ────────────────────── +test("handleValidateMilestone_regenerates_validation_md_with_db_verdict_in_frontmatter", async () => { + const project = makeProject(); + + const result = await handleValidateMilestone( + { + milestoneId: "M001", + verdict: "needs-attention", + remediationRound: 1, + successCriteriaChecklist: "- [ ] criterion one", + sliceDeliveryAudit: "S01: complete", + crossSliceIntegration: "no issues", + requirementCoverage: "100%", + verdictRationale: "minor attention item", + }, + project, + { uokGatesEnabled: false }, + ); + + assert.equal(result.error, undefined, `handleValidateMilestone error: ${result.error}`); + assert.equal(result.verdict, "needs-attention"); + + // The file must exist and have the generated header + const validationPath = join( + project, + ".sf", + "milestones", + "M001", + "M001-VALIDATION.md", + ); + const content = readFileSync(validationPath, "utf-8"); + assert.ok( + content.startsWith(" +--- verdict: ${params.verdict} remediation_round: ${params.remediationRound} --- diff --git a/src/resources/extensions/sf/verdict-parser.js b/src/resources/extensions/sf/verdict-parser.js index b2f073756..537ab73fc 100644 --- a/src/resources/extensions/sf/verdict-parser.js +++ b/src/resources/extensions/sf/verdict-parser.js @@ -16,8 +16,13 @@ import { extractUatType } from "./files.js"; * Returns `undefined` when frontmatter is absent or has no `verdict` field. */ export function extractVerdict(content) { + // Strip leading generated-file comment header before parsing frontmatter. + // VALIDATION.md is a DB projection; the header is always the first line. + const stripped = content.startsWith("\n/, "") + : content; // Primary: YAML frontmatter verdict (canonical format) - const fmMatch = content.match(/^---\n([\s\S]*?)\n---/); + const fmMatch = stripped.match(/^---\n([\s\S]*?)\n---/); if (fmMatch) { const verdictMatch = fmMatch[1].match(/verdict:\s*([\w-]+)/i); if (verdictMatch) { @@ -29,7 +34,7 @@ export function extractVerdict(content) { } // Fallback: detect verdict in markdown body (LLM manual writes, #2960). // Matches patterns like: **Verdict:** PASS, **Verdict:** ✅ PASS, **Verdict** needs-remediation - const bodyMatch = content.match(/\*\*Verdict:?\*\*\s*(?:✅\s*)?(\w[\w-]*)/i); + const bodyMatch = stripped.match(/\*\*Verdict:?\*\*\s*(?:✅\s*)?(\w[\w-]*)/i); if (bodyMatch) { let v = bodyMatch[1].toLowerCase(); if (v === "passed") v = "pass"; diff --git a/src/resources/extensions/sf/workspace-index.js b/src/resources/extensions/sf/workspace-index.js index 9573f7bd2..8d4d9c0f4 100644 --- a/src/resources/extensions/sf/workspace-index.js +++ b/src/resources/extensions/sf/workspace-index.js @@ -7,9 +7,13 @@ import { resolveTaskFile, resolveTasksDir, } from "./paths.js"; -import { getMilestoneSlices, getSliceTasks, isDbAvailable } from "./sf-db.js"; +import { + getMilestoneSlices, + getMilestoneValidationAssessment, + getSliceTasks, + isDbAvailable, +} from "./sf-db.js"; import { deriveState } from "./state.js"; -import { extractVerdict } from "./verdict-parser.js"; import { detectWorktreeName, getSliceBranchName } from "./worktree.js"; // Extract milestone title from roadmap header without using parsers. @@ -192,24 +196,18 @@ export async function indexWorkspace(basePath, _opts = {}) { } } } - // Populate validationVerdict from VALIDATION files (#2807) + // Populate validationVerdict from DB assessments table (#db-first-validation) + // DB is authoritative; VALIDATION.md is a generated projection only. for (const milestone of milestones) { - const validationPath = resolveMilestoneFile( - basePath, - milestone.id, - "VALIDATION", - ); - if (validationPath) { - const validationContent = await loadFile(validationPath); - if (validationContent) { - const verdict = extractVerdict(validationContent); - if ( - verdict === "pass" || - verdict === "needs-attention" || - verdict === "needs-remediation" - ) { - milestone.validationVerdict = verdict; - } + const assessment = getMilestoneValidationAssessment(milestone.id); + if (assessment?.status) { + const verdict = String(assessment.status).trim().toLowerCase(); + if ( + verdict === "pass" || + verdict === "needs-attention" || + verdict === "needs-remediation" + ) { + milestone.validationVerdict = verdict; } } }