diff --git a/src/resources/extensions/sf/post-execution-checks.js b/src/resources/extensions/sf/post-execution-checks.js index ee09637f0..9cee22980 100644 --- a/src/resources/extensions/sf/post-execution-checks.js +++ b/src/resources/extensions/sf/post-execution-checks.js @@ -14,6 +14,13 @@ */ import { existsSync, readFileSync } from "node:fs"; import { dirname, extname, resolve } from "node:path"; +import { parseRequirementsSections } from "./md-importer.js"; +import { parseRoadmap } from "./parsers.js"; +import { + resolveMilestoneFile, + resolveSfRootFile, + resolveSliceFile, +} from "./paths.js"; // ─── Import Resolution Check ───────────────────────────────────────────────── /** * Extract relative import paths from TypeScript/JavaScript source code. @@ -363,6 +370,165 @@ function checkNamingConsistency(source, fileName) { } return results; } +// ─── Cross-Slice Consistency Check ─────────────────────────────────────────── +/** + * Check whether any key_file in the current task is also referenced by tasks + * in other slices. Returns non-blocking warnings for potential conflicts. + */ +export function checkCrossSliceConsistency(taskRow, allSliceTasks, _basePath) { + const results = []; + if (!taskRow.key_files || taskRow.key_files.length === 0) { + return [ + { + passed: true, + blocking: false, + category: "cross-slice", + target: taskRow.id, + message: "No key files to check for cross-slice consistency", + }, + ]; + } + if (!allSliceTasks || allSliceTasks.length === 0) { + return [ + { + passed: true, + blocking: false, + category: "cross-slice", + target: taskRow.id, + message: "No other slice tasks to check against", + }, + ]; + } + const otherSliceTasks = allSliceTasks.filter( + (t) => + t.slice_id !== taskRow.slice_id && t.key_files && t.key_files.length > 0, + ); + const conflictingSlices = new Map(); // file → Set(sliceIds) + for (const file of taskRow.key_files) { + for (const other of otherSliceTasks) { + if (other.key_files.includes(file)) { + if (!conflictingSlices.has(file)) { + conflictingSlices.set(file, new Set()); + } + conflictingSlices.get(file).add(other.slice_id); + } + } + } + if (conflictingSlices.size === 0) { + return [ + { + passed: true, + blocking: false, + category: "cross-slice", + target: taskRow.id, + message: "No cross-slice conflicts detected", + }, + ]; + } + for (const [file, sliceIds] of conflictingSlices) { + results.push({ + passed: false, + blocking: false, + category: "cross-slice", + target: file, + message: `File '${file}' is also a key file in slice(s) ${Array.from(sliceIds).join(", ")} — verify this is intentional`, + }); + } + return results; +} +// ─── Milestone Integrity Check ─────────────────────────────────────────────── +/** + * Verify milestone-level integrity: completed slices have summaries, + * roadmap is consistent, and no active requirements are orphaned. + */ +export function checkMilestoneIntegrity(milestoneId, basePath) { + const results = []; + // Check ROADMAP exists and parse it + const roadmapPath = resolveMilestoneFile(basePath, milestoneId, "ROADMAP"); + if (!roadmapPath || !existsSync(roadmapPath)) { + results.push({ + passed: false, + blocking: false, + category: "milestone", + target: milestoneId, + message: `Milestone ROADMAP.md not found for ${milestoneId}`, + }); + return results; + } + let roadmap; + try { + const content = readFileSync(roadmapPath, "utf-8"); + roadmap = parseRoadmap(content); + } catch (err) { + results.push({ + passed: false, + blocking: false, + category: "milestone", + target: milestoneId, + message: `Failed to parse ROADMAP.md for ${milestoneId}: ${err.message}`, + }); + return results; + } + // Check that slices marked done in ROADMAP have SUMMARY.md files + for (const slice of roadmap.slices) { + if (slice.done) { + const summaryPath = resolveSliceFile( + basePath, + milestoneId, + slice.id, + "SUMMARY", + ); + if (!summaryPath || !existsSync(summaryPath)) { + results.push({ + passed: false, + blocking: false, + category: "milestone", + target: `${milestoneId}/${slice.id}`, + message: `Slice ${slice.id} is marked done in ROADMAP but missing SUMMARY.md`, + }); + } + } + } + // Check REQUIREMENTS.md for active requirements belonging to this milestone + const reqPath = resolveSfRootFile(basePath, "REQUIREMENTS"); + if (reqPath && existsSync(reqPath)) { + try { + const reqContent = readFileSync(reqPath, "utf-8"); + const requirements = parseRequirementsSections(reqContent); + const activeForMilestone = requirements.filter( + (r) => r.status === "active" && r.primary_owner === milestoneId, + ); + for (const req of activeForMilestone) { + results.push({ + passed: false, + blocking: false, + category: "milestone", + target: req.id, + message: `Active requirement ${req.id} (${req.description || "no description"}) is owned by ${milestoneId} but not yet validated`, + }); + } + } catch (err) { + results.push({ + passed: false, + blocking: false, + category: "milestone", + target: milestoneId, + message: `Failed to parse REQUIREMENTS.md: ${err.message}`, + }); + } + } + // If no issues found, return a passing check + if (results.length === 0) { + results.push({ + passed: true, + blocking: false, + category: "milestone", + target: milestoneId, + message: `Milestone ${milestoneId} integrity checks passed`, + }); + } + return results; +} // ─── Main Entry Point ──────────────────────────────────────────────────────── /** * Run all post-execution checks against a completed task. @@ -370,9 +536,17 @@ function checkNamingConsistency(source, fileName) { * @param taskRow - The completed task row * @param priorTasks - Array of TaskRow from prior completed tasks in the slice * @param basePath - Base path for resolving file references + * @param milestoneId - Optional milestone ID for milestone-level checks + * @param allSliceTasks - Optional array of all tasks across all slices for cross-slice checks * @returns PostExecutionResult with status, checks, and duration */ -export function runPostExecutionChecks(taskRow, priorTasks, basePath) { +export function runPostExecutionChecks( + taskRow, + priorTasks, + basePath, + milestoneId, + allSliceTasks, +) { const startTime = Date.now(); const allChecks = []; // Run all checks @@ -384,6 +558,19 @@ export function runPostExecutionChecks(taskRow, priorTasks, basePath) { ); const patternChecks = checkPatternConsistency(taskRow, priorTasks, basePath); allChecks.push(...importChecks, ...signatureChecks, ...patternChecks); + // New checks: cross-slice consistency and milestone integrity + if (allSliceTasks && allSliceTasks.length > 0) { + const crossSliceChecks = checkCrossSliceConsistency( + taskRow, + allSliceTasks, + basePath, + ); + allChecks.push(...crossSliceChecks); + } + if (milestoneId) { + const milestoneChecks = checkMilestoneIntegrity(milestoneId, basePath); + allChecks.push(...milestoneChecks); + } const durationMs = Date.now() - startTime; // Determine overall status const hasBlockingFailure = allChecks.some((c) => !c.passed && c.blocking); diff --git a/src/resources/extensions/sf/tests/post-execution-checks.test.mjs b/src/resources/extensions/sf/tests/post-execution-checks.test.mjs new file mode 100644 index 000000000..a88172a87 --- /dev/null +++ b/src/resources/extensions/sf/tests/post-execution-checks.test.mjs @@ -0,0 +1,368 @@ +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 { describe, it } from "node:test"; +import { + checkCrossSliceConsistency, + checkMilestoneIntegrity, + runPostExecutionChecks, +} from "../post-execution-checks.js"; + +function makeTempDir(prefix) { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function cleanup(dir) { + try { + rmSync(dir, { recursive: true, force: true }); + } catch {} +} + +function writeMilestoneFile(base, milestoneId, fileName, content) { + const dir = join(base, ".sf", "milestones", milestoneId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, fileName), content); +} + +function writeSliceFile(base, milestoneId, sliceId, fileName, content) { + const dir = join(base, ".sf", "milestones", milestoneId, "slices", sliceId); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, fileName), content); +} + +// ─── checkCrossSliceConsistency ──────────────────────────────────────────── + +describe("checkCrossSliceConsistency", () => { + it("passes_when_no_other_slice_touches_same_files", () => { + const taskRow = { + milestone_id: "M001", + slice_id: "S03", + id: "T01", + key_files: ["src/a.ts", "src/b.ts"], + }; + const allSliceTasks = [ + { + milestone_id: "M001", + slice_id: "S01", + id: "T01", + key_files: ["src/c.ts"], + }, + { + milestone_id: "M001", + slice_id: "S02", + id: "T01", + key_files: ["src/d.ts"], + }, + ]; + const results = checkCrossSliceConsistency(taskRow, allSliceTasks, "/tmp"); + assert.equal(results.length, 1); + assert.equal(results[0].passed, true); + assert.equal(results[0].category, "cross-slice"); + }); + + it("warns_when_another_slice_has_same_key_file", () => { + const taskRow = { + milestone_id: "M001", + slice_id: "S03", + id: "T01", + key_files: ["src/shared.ts", "src/b.ts"], + }; + const allSliceTasks = [ + { + milestone_id: "M001", + slice_id: "S01", + id: "T01", + key_files: ["src/shared.ts"], + }, + { + milestone_id: "M001", + slice_id: "S02", + id: "T01", + key_files: ["src/d.ts"], + }, + ]; + const results = checkCrossSliceConsistency(taskRow, allSliceTasks, "/tmp"); + assert.equal(results.length, 1); + assert.equal(results[0].passed, false); + assert.equal(results[0].blocking, false); + assert.equal(results[0].category, "cross-slice"); + assert.ok(results[0].message.includes("src/shared.ts")); + assert.ok(results[0].message.includes("S01")); + }); + + it("ignores_tasks_in_same_slice", () => { + const taskRow = { + milestone_id: "M001", + slice_id: "S03", + id: "T02", + key_files: ["src/shared.ts"], + }; + const allSliceTasks = [ + { + milestone_id: "M001", + slice_id: "S03", + id: "T01", + key_files: ["src/shared.ts"], + }, + ]; + const results = checkCrossSliceConsistency(taskRow, allSliceTasks, "/tmp"); + assert.equal(results.length, 1); + assert.equal(results[0].passed, true); + }); + + it("handles_multiple_conflicts", () => { + const taskRow = { + milestone_id: "M001", + slice_id: "S03", + id: "T01", + key_files: ["src/a.ts", "src/b.ts"], + }; + const allSliceTasks = [ + { + milestone_id: "M001", + slice_id: "S01", + id: "T01", + key_files: ["src/a.ts"], + }, + { + milestone_id: "M001", + slice_id: "S02", + id: "T01", + key_files: ["src/b.ts"], + }, + ]; + const results = checkCrossSliceConsistency(taskRow, allSliceTasks, "/tmp"); + assert.equal(results.length, 2); + assert.equal(results[0].passed, false); + assert.equal(results[1].passed, false); + }); +}); + +// ─── checkMilestoneIntegrity ─────────────────────────────────────────────── + +describe("checkMilestoneIntegrity", () => { + it("passes_when_all_complete_slices_have_summaries", () => { + const base = makeTempDir("sf-pec-mi-"); + try { + writeMilestoneFile( + base, + "M001", + "M001-ROADMAP.md", + [ + "# M001: Test", + "", + "## Slice Overview", + "", + "| ID | Slice | Risk | Depends | Done | After this |", + "|----|-------|------|---------|------|------------|", + "| S01 | S01 | low | — | ✅ | Done. |", + "| S02 | S02 | low | — | ⬜ | Pending. |", + "", + ].join("\n"), + ); + writeSliceFile(base, "M001", "S01", "S01-SUMMARY.md", "# S01 Summary\n"); + writeFileSync( + join(base, ".sf", "REQUIREMENTS.md"), + [ + "## Active", + "", + "### R001 — Test", + "- Class: functional", + "- Description: test", + "- Why: test", + "- Source: test", + "- Primary owner: M002", + "", + ].join("\n"), + ); + + const results = checkMilestoneIntegrity("M001", base); + assert.ok( + results.every((r) => r.passed), + `Expected all checks to pass, got: ${JSON.stringify(results)}`, + ); + } finally { + cleanup(base); + } + }); + + it("warns_when_complete_slice_lacks_summary", () => { + const base = makeTempDir("sf-pec-mi-"); + try { + writeMilestoneFile( + base, + "M001", + "M001-ROADMAP.md", + [ + "# M001: Test", + "", + "## Slice Overview", + "", + "| ID | Slice | Risk | Depends | Done | After this |", + "|----|-------|------|---------|------|------------|", + "| S01 | S01 | low | — | ✅ | Done. |", + "", + ].join("\n"), + ); + // Intentionally NOT writing S01-SUMMARY.md + writeFileSync( + join(base, ".sf", "REQUIREMENTS.md"), + ["## Active", "", "### R001 — Test", "- Primary owner: M001", ""].join( + "\n", + ), + ); + + const results = checkMilestoneIntegrity("M001", base); + const missing = results.find((r) => r.message.includes("SUMMARY")); + assert.ok( + missing, + `Expected missing summary warning, got: ${JSON.stringify(results)}`, + ); + assert.equal(missing.passed, false); + assert.equal(missing.blocking, false); + } finally { + cleanup(base); + } + }); + + it("warns_when_active_requirement_belongs_to_milestone", () => { + const base = makeTempDir("sf-pec-mi-"); + try { + writeMilestoneFile( + base, + "M001", + "M001-ROADMAP.md", + [ + "# M001: Test", + "", + "## Slice Overview", + "", + "| ID | Slice | Risk | Depends | Done | After this |", + "|----|-------|------|---------|------|------------|", + "| S01 | S01 | low | — | ✅ | Done. |", + "", + ].join("\n"), + ); + writeSliceFile(base, "M001", "S01", "S01-SUMMARY.md", "# S01 Summary\n"); + writeFileSync( + join(base, ".sf", "REQUIREMENTS.md"), + [ + "## Active", + "", + "### R001 — Test", + "- Class: functional", + "- Description: test", + "- Why: test", + "- Source: test", + "- Primary owner: M001", + "", + ].join("\n"), + ); + + const results = checkMilestoneIntegrity("M001", base); + const reqWarning = results.find((r) => r.message.includes("R001")); + assert.ok( + reqWarning, + `Expected active requirement warning, got: ${JSON.stringify(results)}`, + ); + assert.equal(reqWarning.passed, false); + assert.equal(reqWarning.blocking, false); + } finally { + cleanup(base); + } + }); + + it("warns_when_roadmap_missing", () => { + const base = makeTempDir("sf-pec-mi-"); + try { + mkdirSync(join(base, ".sf"), { recursive: true }); + writeFileSync(join(base, ".sf", "REQUIREMENTS.md"), "## Active\n\n"); + const results = checkMilestoneIntegrity("M001", base); + const missing = results.find((r) => r.message.includes("ROADMAP")); + assert.ok( + missing, + `Expected missing roadmap warning, got: ${JSON.stringify(results)}`, + ); + assert.equal(missing.passed, false); + assert.equal(missing.blocking, false); + } finally { + cleanup(base); + } + }); +}); + +// ─── runPostExecutionChecks ──────────────────────────────────────────────── + +describe("runPostExecutionChecks", () => { + it("includes_cross_slice_and_milestone_checks_when_args_provided", () => { + const base = makeTempDir("sf-pec-rpec-"); + try { + writeMilestoneFile( + base, + "M001", + "M001-ROADMAP.md", + [ + "# M001: Test", + "", + "## Slice Overview", + "", + "| ID | Slice | Risk | Depends | Done | After this |", + "|----|-------|------|---------|------|------------|", + "| S01 | S01 | low | — | ✅ | Done. |", + "| S02 | S02 | low | — | ⬜ | Pending. |", + "", + ].join("\n"), + ); + writeSliceFile(base, "M001", "S01", "S01-SUMMARY.md", "# S01\n"); + writeFileSync(join(base, ".sf", "REQUIREMENTS.md"), "## Active\n\n"); + + const taskRow = { + milestone_id: "M001", + slice_id: "S02", + id: "T01", + key_files: ["src/a.ts"], + }; + const allSliceTasks = [ + { + milestone_id: "M001", + slice_id: "S01", + id: "T01", + key_files: ["src/a.ts"], + }, + ]; + + const result = runPostExecutionChecks( + taskRow, + [], + base, + "M001", + allSliceTasks, + ); + const categories = result.checks.map((c) => c.category); + assert.ok( + categories.includes("cross-slice"), + `Expected cross-slice check, got categories: ${categories}`, + ); + assert.ok( + categories.includes("milestone"), + `Expected milestone check, got categories: ${categories}`, + ); + } finally { + cleanup(base); + } + }); + + it("skips_new_checks_when_optional_args_omitted", () => { + const taskRow = { + milestone_id: "M001", + slice_id: "S01", + id: "T01", + key_files: [], + }; + const result = runPostExecutionChecks(taskRow, [], "/tmp"); + const categories = result.checks.map((c) => c.category); + assert.ok(!categories.includes("cross-slice")); + assert.ok(!categories.includes("milestone")); + }); +}); diff --git a/src/resources/extensions/sf/uok/auto-verification.js b/src/resources/extensions/sf/uok/auto-verification.js index fbc282230..3f85c2f30 100644 --- a/src/resources/extensions/sf/uok/auto-verification.js +++ b/src/resources/extensions/sf/uok/auto-verification.js @@ -11,6 +11,7 @@ */ import { mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { getErrorMessage } from "../error-utils.js"; import { loadFile } from "../files.js"; import { parseRoadmap } from "../parsers.js"; import { resolveMilestoneFile, resolveSlicePath } from "../paths.js"; @@ -45,7 +46,6 @@ import { formatExecuteTaskRecoveryStatus, inspectExecuteTaskDurability, } from "./unit-runtime.js"; -import { getErrorMessage } from "../error-utils.js"; function computeTokenCountFromSession(ctx) { const entries = ctx.sessionManager?.getEntries?.() ?? []; @@ -568,10 +568,23 @@ export async function runPostUnitVerification(vctx, pauseAuto) { t.sequence < taskRow.sequence, ); // Run post-execution checks + // Get all tasks across all slices for cross-slice consistency check + const allSliceTasks = []; + if (mid) { + const slices = getMilestoneSlices(mid); + for (const slice of slices) { + if (slice.id !== sid) { + const sliceTasks = getSliceTasks(mid, slice.id); + allSliceTasks.push(...sliceTasks); + } + } + } const postExecResult = runPostExecutionChecks( taskRow, priorTasks, s.basePath, + mid, + allSliceTasks, ); // Store checks for evidence JSON postExecChecks = postExecResult.checks;