feat(sf): add cross-slice and milestone integrity checks to post-execution checks

- Add checkCrossSliceConsistency() to detect key_file conflicts across slices
- Add checkMilestoneIntegrity() to verify completed slices have summaries
  and no active requirements are orphaned
- Extend runPostExecutionChecks() signature with optional milestoneId
  and allSliceTasks parameters
- Wire cross-slice task gathering into auto-verification.js call site
- Add comprehensive node:test suite for both new checks
This commit is contained in:
Mikael Hugo 2026-05-11 17:22:11 +02:00
parent 338c75fc6f
commit 7b225696cc
3 changed files with 570 additions and 2 deletions

View file

@ -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);

View file

@ -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"));
});
});

View file

@ -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;