feat(gsd): add pre-execution plan verification checks

Adds 4 pre-execution checks that run before each task:
- File ops review: surfaces create/edit/delete intent for manual review
- Read-before-create guard: fails when plan reads a file before creating it
- Package existence: verifies npm packages exist before install attempts
- Interface contract: warns on mismatched function signatures

Includes preference types and validation for enhanced_verification settings.
This commit is contained in:
Alan Alwakeel 2026-04-03 16:17:25 -04:00
parent d1b7f6f85c
commit 992b321b63
5 changed files with 1465 additions and 0 deletions

View file

@ -0,0 +1,533 @@
/**
* Pre-Execution Checks Validate task plans before execution begins.
*
* Runs these checks against a slice's task plan:
* 1. Package existence npm view calls in parallel with timeout
* 2. File path consistency verify files exist or are in prior expected_output
* 3. Task ordering detect impossible ordering (task reads file created later)
* 4. Interface contracts detect contradictory function signatures (warn only)
*
* Design principles:
* - Pure functions taking (tasks: TaskRow[], basePath: string) for testability
* - Network failures warn, don't fail (R012 conservative design)
* - Total execution <2s target (R013)
* - No AST parsers interface parsing is heuristic (regex on code blocks)
*/
import { existsSync } from "node:fs";
import { spawn } from "node:child_process";
import { resolve } from "node:path";
import type { TaskRow } from "./gsd-db.ts";
import type { PreExecutionCheckJSON } from "./verification-evidence.ts";
// ─── Result Types ────────────────────────────────────────────────────────────
export interface PreExecutionResult {
/** Overall result: pass if no blocking failures, warn if non-blocking issues, fail if blocking issues */
status: "pass" | "warn" | "fail";
/** All check results */
checks: PreExecutionCheckJSON[];
/** Total duration in milliseconds */
durationMs: number;
}
// ─── Package Existence Check ─────────────────────────────────────────────────
/**
* Extract npm package names from task descriptions.
* Looks for:
* - `npm install <pkg>` patterns
* - Code blocks with `require('<pkg>')` or `import ... from '<pkg>'`
* - Explicit mentions like "uses lodash" or "package: axios"
*/
export function extractPackageReferences(description: string): string[] {
const packages = new Set<string>();
// Common words that aren't package names but might appear after install
const stopwords = new Set([
"then", "and", "the", "to", "a", "an", "in", "for", "with", "from", "or",
"npm", "yarn", "pnpm", "i", // Don't capture the command itself
]);
// npm install <pkg> patterns (handles npm i, npm add, yarn add, pnpm add)
// Use a global pattern to find all install commands, then parse following tokens
const installCmdPattern = /(?:npm\s+(?:install|i|add)|yarn\s+add|pnpm\s+add)\s+/g;
let cmdMatch: RegExpExecArray | null;
while ((cmdMatch = installCmdPattern.exec(description)) !== null) {
// Start after the install command
const afterCmd = description.slice(cmdMatch.index + cmdMatch[0].length);
// Match package-like tokens (alphanumeric, @, /, -, _) until we hit
// something that's not a package (non-token char after whitespace)
const tokenPattern = /^([@a-zA-Z][a-zA-Z0-9@/_-]*)(?:\s+|$)/;
let remaining = afterCmd;
while (remaining.length > 0) {
// Skip any flags like -D, --save-dev
const flagMatch = remaining.match(/^(-[a-zA-Z-]+)\s*/);
if (flagMatch) {
remaining = remaining.slice(flagMatch[0].length);
continue;
}
// Try to match a package name
const pkgMatch = remaining.match(tokenPattern);
if (pkgMatch) {
const token = pkgMatch[1];
// Skip stopwords - they indicate end of package list
if (stopwords.has(token.toLowerCase())) {
break;
}
packages.add(normalizePackageName(token));
remaining = remaining.slice(pkgMatch[0].length);
} else {
// Not a package name, stop parsing this install command
break;
}
}
}
// require('pkg') or import from 'pkg' in code blocks
const importPattern = /(?:require\s*\(\s*['"]|from\s+['"])([a-zA-Z0-9@/_-]+)['"\)]/g;
let importMatch: RegExpExecArray | null;
while ((importMatch = importPattern.exec(description)) !== null) {
// Skip relative imports and node builtins
const pkg = importMatch[1];
if (!pkg.startsWith(".") && !pkg.startsWith("node:")) {
packages.add(normalizePackageName(pkg));
}
}
return Array.from(packages);
}
/**
* Normalize package name to registry-checkable form.
* Handles scoped packages (@org/pkg) and subpaths (pkg/subpath pkg).
*/
function normalizePackageName(raw: string): string {
// Scoped package: @org/pkg or @org/pkg/subpath
if (raw.startsWith("@")) {
const parts = raw.split("/");
return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : raw;
}
// Regular package: pkg or pkg/subpath
return raw.split("/")[0];
}
/**
* Check if a package exists on npm registry.
* Returns null on success, error message on failure.
* Times out after timeoutMs (default 5000ms).
*/
async function checkPackageOnNpm(
packageName: string,
timeoutMs = 5000
): Promise<{ exists: boolean; error?: string }> {
return new Promise((resolve) => {
const child = spawn("npm", ["view", packageName, "name"], {
stdio: ["ignore", "pipe", "pipe"],
timeout: timeoutMs,
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (data: Buffer) => {
stdout += data.toString();
});
child.stderr.on("data", (data: Buffer) => {
stderr += data.toString();
});
const timer = setTimeout(() => {
child.kill("SIGTERM");
resolve({ exists: false, error: `Timeout after ${timeoutMs}ms` });
}, timeoutMs);
child.on("close", (code) => {
clearTimeout(timer);
if (code === 0 && stdout.trim()) {
resolve({ exists: true });
} else if (stderr.includes("404") || stderr.includes("not found")) {
resolve({ exists: false, error: `Package not found: ${packageName}` });
} else if (code !== 0) {
// Network error or other issue — warn, don't fail
resolve({ exists: true, error: `npm view failed (code ${code}): ${stderr.slice(0, 100)}` });
} else {
resolve({ exists: true });
}
});
child.on("error", (err) => {
clearTimeout(timer);
resolve({ exists: true, error: `npm spawn error: ${err.message}` });
});
});
}
/**
* Check all package references in tasks for existence on npm.
* Runs checks in parallel with a 5s timeout per package.
* Network failures warn but don't fail (R012 conservative design).
*/
export async function checkPackageExistence(
tasks: TaskRow[],
_basePath: string
): Promise<PreExecutionCheckJSON[]> {
const results: PreExecutionCheckJSON[] = [];
const packagesToCheck = new Set<string>();
// Collect all package references from task descriptions
for (const task of tasks) {
const packages = extractPackageReferences(task.description);
for (const pkg of packages) {
packagesToCheck.add(pkg);
}
}
if (packagesToCheck.size === 0) {
return results;
}
// Check packages in parallel
const checkPromises = Array.from(packagesToCheck).map(async (pkg) => {
const result = await checkPackageOnNpm(pkg);
return { pkg, result };
});
const checkResults = await Promise.all(checkPromises);
for (const { pkg, result } of checkResults) {
if (!result.exists && !result.error?.includes("Timeout") && !result.error?.includes("spawn error")) {
// Package genuinely doesn't exist — blocking failure
results.push({
category: "package",
target: pkg,
passed: false,
message: result.error || `Package '${pkg}' not found on npm`,
blocking: true,
});
} else if (result.error) {
// Network issue or timeout — warn but don't block
results.push({
category: "package",
target: pkg,
passed: true,
message: `Warning: ${result.error}`,
blocking: false,
});
}
// Silent success for existing packages — no need to report
}
return results;
}
// ─── File Path Consistency Check ─────────────────────────────────────────────
/**
* Build a set of files that will be created by tasks up to (but not including) taskIndex.
*/
function getExpectedOutputsUpTo(tasks: TaskRow[], taskIndex: number): Set<string> {
const outputs = new Set<string>();
for (let i = 0; i < taskIndex; i++) {
for (const file of tasks[i].expected_output) {
outputs.add(file);
}
}
return outputs;
}
/**
* Check that all files referenced in task.files and task.inputs either:
* 1. Exist on disk, OR
* 2. Are in a prior task's expected_output
*/
export function checkFilePathConsistency(
tasks: TaskRow[],
basePath: string
): PreExecutionCheckJSON[] {
const results: PreExecutionCheckJSON[] = [];
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
const priorOutputs = getExpectedOutputsUpTo(tasks, i);
const filesToCheck = [...task.files, ...task.inputs];
for (const file of filesToCheck) {
// Skip empty strings
if (!file.trim()) continue;
// Check if file exists on disk
const absolutePath = resolve(basePath, file);
const existsOnDisk = existsSync(absolutePath);
// Check if file is in prior expected outputs
const inPriorOutputs = priorOutputs.has(file);
if (!existsOnDisk && !inPriorOutputs) {
results.push({
category: "file",
target: file,
passed: false,
message: `Task ${task.id} references '${file}' which doesn't exist and isn't created by prior tasks`,
blocking: true,
});
}
}
}
return results;
}
// ─── Task Ordering Check ─────────────────────────────────────────────────────
/**
* Detect impossible task ordering: task N reads a file that task N+M creates.
* This is a fatal error the plan has an impossible dependency.
*/
export function checkTaskOrdering(
tasks: TaskRow[],
_basePath: string
): PreExecutionCheckJSON[] {
const results: PreExecutionCheckJSON[] = [];
// Build map: file → task index that creates it
const fileCreators = new Map<string, { taskId: string; index: number }>();
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
for (const file of task.expected_output) {
if (!fileCreators.has(file)) {
fileCreators.set(file, { taskId: task.id, index: i });
}
}
}
// Check each task's inputs against file creators
for (let i = 0; i < tasks.length; i++) {
const task = tasks[i];
const filesToCheck = [...task.files, ...task.inputs];
for (const file of filesToCheck) {
const creator = fileCreators.get(file);
if (creator && creator.index > i) {
// Task reads file that is created later — impossible ordering
results.push({
category: "file",
target: file,
passed: false,
message: `Task ${task.id} reads '${file}' but it's created by task ${creator.taskId} (sequence violation)`,
blocking: true,
});
}
}
}
return results;
}
// ─── Interface Contract Check ────────────────────────────────────────────────
interface FunctionSignature {
name: string;
params: string;
returnType: string;
taskId: string;
raw: string;
}
/**
* Extract function signatures from code blocks in task description.
* Uses heuristic regex not an AST parser.
*/
function extractFunctionSignatures(description: string, taskId: string): FunctionSignature[] {
const signatures: FunctionSignature[] = [];
// Match code blocks (```...```)
const codeBlockPattern = /```(?:typescript|ts|javascript|js)?\n([\s\S]*?)```/g;
let blockMatch: RegExpExecArray | null;
while ((blockMatch = codeBlockPattern.exec(description)) !== null) {
const codeBlock = blockMatch[1];
// Match function declarations and exports
// Patterns:
// function name(params): ReturnType
// export function name(params): ReturnType
// export async function name(params): Promise<ReturnType>
// const name = (params): ReturnType =>
// export const name = (params): ReturnType =>
const funcPattern = /(?:export\s+)?(?:async\s+)?(?:function\s+|const\s+)(\w+)(?:\s*=\s*)?\s*\(([^)]*)\)(?:\s*:\s*([^{=>\n]+))?/g;
let funcMatch: RegExpExecArray | null;
while ((funcMatch = funcPattern.exec(codeBlock)) !== null) {
const [raw, name, params, returnType] = funcMatch;
signatures.push({
name,
params: normalizeParams(params),
returnType: normalizeType(returnType || "void"),
taskId,
raw: raw.trim(),
});
}
// Match interface method signatures
// Pattern: methodName(params): ReturnType;
const methodPattern = /^\s*(\w+)\s*\(([^)]*)\)\s*:\s*([^;]+);/gm;
let methodMatch: RegExpExecArray | null;
while ((methodMatch = methodPattern.exec(codeBlock)) !== null) {
const [raw, name, params, returnType] = methodMatch;
signatures.push({
name,
params: normalizeParams(params),
returnType: normalizeType(returnType),
taskId,
raw: raw.trim(),
});
}
}
return signatures;
}
/**
* Normalize parameter list for comparison.
* Removes whitespace, comments, and default values.
*/
function normalizeParams(params: string): string {
return params
.replace(/\/\*[\s\S]*?\*\//g, "") // Remove block comments
.replace(/\/\/[^\n]*/g, "") // Remove line comments
.replace(/\s*=\s*[^,)]+/g, "") // Remove default values
.replace(/\s+/g, " ") // Normalize whitespace
.trim();
}
/**
* Normalize type for comparison.
*/
function normalizeType(type: string): string {
return type
.replace(/\s+/g, " ")
.trim();
}
/**
* Check for contradictory function signatures across tasks.
* Same function name with different signatures is a warning (not blocking).
*/
export function checkInterfaceContracts(
tasks: TaskRow[],
_basePath: string
): PreExecutionCheckJSON[] {
const results: PreExecutionCheckJSON[] = [];
// Collect all signatures
const allSignatures: FunctionSignature[] = [];
for (const task of tasks) {
const sigs = extractFunctionSignatures(task.description, task.id);
allSignatures.push(...sigs);
}
// Group by function name
const byName = new Map<string, FunctionSignature[]>();
for (const sig of allSignatures) {
const existing = byName.get(sig.name) || [];
existing.push(sig);
byName.set(sig.name, existing);
}
// Check for contradictions
for (const [name, sigs] of byName) {
if (sigs.length < 2) continue;
// Compare signatures
const first = sigs[0];
for (let i = 1; i < sigs.length; i++) {
const current = sigs[i];
// Check parameter mismatch
if (first.params !== current.params) {
results.push({
category: "schema",
target: name,
passed: true, // Warning only, not blocking
message: `Function '${name}' has different parameters: '${first.params}' (${first.taskId}) vs '${current.params}' (${current.taskId})`,
blocking: false,
});
}
// Check return type mismatch
if (first.returnType !== current.returnType) {
results.push({
category: "schema",
target: name,
passed: true, // Warning only, not blocking
message: `Function '${name}' has different return types: '${first.returnType}' (${first.taskId}) vs '${current.returnType}' (${current.taskId})`,
blocking: false,
});
}
}
}
return results;
}
// ─── Main Entry Point ────────────────────────────────────────────────────────
/**
* Run all pre-execution checks against a slice's task plan.
*
* @param tasks - Array of TaskRow from the slice
* @param basePath - Base path for resolving file references
* @returns PreExecutionResult with status, checks, and duration
*/
export async function runPreExecutionChecks(
tasks: TaskRow[],
basePath: string
): Promise<PreExecutionResult> {
const startTime = Date.now();
const allChecks: PreExecutionCheckJSON[] = [];
// Run sync checks first
const fileChecks = checkFilePathConsistency(tasks, basePath);
const orderingChecks = checkTaskOrdering(tasks, basePath);
const contractChecks = checkInterfaceContracts(tasks, basePath);
allChecks.push(...fileChecks, ...orderingChecks, ...contractChecks);
// Run async package checks
const packageChecks = await checkPackageExistence(tasks, basePath);
allChecks.push(...packageChecks);
const durationMs = Date.now() - startTime;
// Determine overall status
const hasBlockingFailure = allChecks.some((c) => !c.passed && c.blocking);
const hasNonBlockingFailure = allChecks.some((c) => !c.passed && !c.blocking);
// Interface contract checks pass but still report warnings via message
const hasInterfaceWarning = allChecks.some(
(c) => c.category === "schema" && c.message && !c.message.startsWith("Warning:")
);
const hasNetworkWarning = allChecks.some(
(c) => c.passed && c.message?.startsWith("Warning:")
);
let status: "pass" | "warn" | "fail";
if (hasBlockingFailure) {
status = "fail";
} else if (hasNonBlockingFailure || hasInterfaceWarning || hasNetworkWarning) {
status = "warn";
} else {
status = "pass";
}
return {
status,
checks: allChecks,
durationMs,
};
}

View file

@ -106,6 +106,10 @@ export const KNOWN_PREFERENCE_KEYS = new Set<string>([
"codebase",
"slice_parallel",
"safety_harness",
"enhanced_verification",
"enhanced_verification_pre",
"enhanced_verification_post",
"enhanced_verification_strict",
]);
/** Canonical list of all dispatch unit types. */
@ -304,6 +308,30 @@ export interface GSDPreferences {
auto_rollback?: boolean;
timeout_scale_cap?: number;
};
// ─── Enhanced Verification ──────────────────────────────────────────────────
/**
* Enable enhanced verification (both pre-execution and post-execution checks).
* Default: true (opt-out, not opt-in). Set false to disable all enhanced verification.
*/
enhanced_verification?: boolean;
/**
* Enable pre-execution checks (package existence, file references, etc.).
* Only applies when enhanced_verification is true.
* Default: true.
*/
enhanced_verification_pre?: boolean;
/**
* Enable post-execution checks (runtime error detection, audit warnings, etc.).
* Only applies when enhanced_verification is true.
* Default: true.
*/
enhanced_verification_post?: boolean;
/**
* Strict mode: treat any pre-execution check failure as blocking.
* Default: false (warnings only for non-critical failures).
*/
enhanced_verification_strict?: boolean;
}
export interface LoadedGSDPreferences {

View file

@ -902,5 +902,38 @@ export function validatePreferences(preferences: GSDPreferences): {
}
}
// ─── Enhanced Verification ──────────────────────────────────────────────────
if (preferences.enhanced_verification !== undefined) {
if (typeof preferences.enhanced_verification === "boolean") {
validated.enhanced_verification = preferences.enhanced_verification;
} else {
errors.push("enhanced_verification must be a boolean");
}
}
if (preferences.enhanced_verification_pre !== undefined) {
if (typeof preferences.enhanced_verification_pre === "boolean") {
validated.enhanced_verification_pre = preferences.enhanced_verification_pre;
} else {
errors.push("enhanced_verification_pre must be a boolean");
}
}
if (preferences.enhanced_verification_post !== undefined) {
if (typeof preferences.enhanced_verification_post === "boolean") {
validated.enhanced_verification_post = preferences.enhanced_verification_post;
} else {
errors.push("enhanced_verification_post must be a boolean");
}
}
if (preferences.enhanced_verification_strict !== undefined) {
if (typeof preferences.enhanced_verification_strict === "boolean") {
validated.enhanced_verification_strict = preferences.enhanced_verification_strict;
} else {
errors.push("enhanced_verification_strict must be a boolean");
}
}
return { preferences: validated, errors, warnings };
}

View file

@ -0,0 +1,803 @@
/**
* pre-execution-checks.test.ts Unit tests for pre-execution validation checks.
*
* Tests all 4 check types:
* 1. Package existence npm view mocking, timeout handling
* 2. File path consistency files exist vs prior expected_output
* 3. Task ordering detect impossible read-before-create
* 4. Interface contracts contradictory function signatures
*/
import { describe, test, mock } from "node:test";
import assert from "node:assert/strict";
import { tmpdir } from "node:os";
import { mkdirSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import {
extractPackageReferences,
checkFilePathConsistency,
checkTaskOrdering,
checkInterfaceContracts,
runPreExecutionChecks,
type PreExecutionResult,
} from "../pre-execution-checks.ts";
import type { TaskRow } from "../gsd-db.ts";
// ─── Test Fixtures ───────────────────────────────────────────────────────────
/**
* Create a minimal TaskRow for testing.
*/
function createTask(overrides: Partial<TaskRow> = {}): TaskRow {
return {
milestone_id: "M001",
slice_id: "S01",
id: overrides.id ?? "T01",
title: "Test Task",
status: "pending",
one_liner: "",
narrative: "",
verification_result: "",
duration: "",
completed_at: null,
blocker_discovered: false,
deviations: "",
known_issues: "",
key_files: [],
key_decisions: [],
full_summary_md: "",
description: overrides.description ?? "",
estimate: "",
files: overrides.files ?? [],
verify: "",
inputs: overrides.inputs ?? [],
expected_output: overrides.expected_output ?? [],
observability_impact: "",
full_plan_md: "",
sequence: overrides.sequence ?? 0,
...overrides,
};
}
// ─── Package Reference Extraction Tests ──────────────────────────────────────
describe("extractPackageReferences", () => {
test("extracts npm install patterns", () => {
const desc = "Run npm install lodash then npm i axios";
const packages = extractPackageReferences(desc);
assert.deepEqual(packages.sort(), ["axios", "lodash"]);
});
test("extracts yarn add patterns", () => {
const desc = "yarn add react-dom";
const packages = extractPackageReferences(desc);
assert.deepEqual(packages, ["react-dom"]);
});
test("extracts scoped packages", () => {
const desc = "npm install @types/node @babel/core";
const packages = extractPackageReferences(desc);
assert.ok(packages.includes("@types/node"));
assert.ok(packages.includes("@babel/core"));
});
test("extracts require statements from code blocks", () => {
const desc = `
\`\`\`javascript
const fs = require('fs-extra');
const path = require('path');
\`\`\`
`;
const packages = extractPackageReferences(desc);
assert.ok(packages.includes("fs-extra"));
});
test("extracts import statements from code blocks", () => {
const desc = `
\`\`\`typescript
import express from 'express';
import { Router } from 'express';
import type { Request } from 'express';
\`\`\`
`;
const packages = extractPackageReferences(desc);
assert.ok(packages.includes("express"));
});
test("ignores relative imports", () => {
const desc = `import { foo } from './local-file';`;
const packages = extractPackageReferences(desc);
assert.deepEqual(packages, []);
});
test("ignores node builtins", () => {
const desc = `import fs from 'node:fs';`;
const packages = extractPackageReferences(desc);
assert.deepEqual(packages, []);
});
test("normalizes package subpaths", () => {
const desc = "npm install lodash/get";
const packages = extractPackageReferences(desc);
assert.deepEqual(packages, ["lodash"]);
});
test("handles empty description", () => {
const packages = extractPackageReferences("");
assert.deepEqual(packages, []);
});
test("ignores flags in npm install", () => {
const desc = "npm install -D typescript";
const packages = extractPackageReferences(desc);
assert.ok(packages.includes("typescript"));
assert.ok(!packages.includes("-D"));
});
});
// ─── File Path Consistency Tests ─────────────────────────────────────────────
describe("checkFilePathConsistency", () => {
let tempDir: string;
test("passes when files exist on disk", () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
writeFileSync(join(tempDir, "existing.ts"), "// content");
try {
const tasks = [
createTask({
id: "T01",
files: ["existing.ts"],
inputs: [],
expected_output: [],
}),
];
const results = checkFilePathConsistency(tasks, tempDir);
assert.deepEqual(results, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("passes when files are in prior expected_output", () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const tasks = [
createTask({
id: "T01",
sequence: 0,
files: [],
inputs: [],
expected_output: ["generated.ts"],
}),
createTask({
id: "T02",
sequence: 1,
files: ["generated.ts"],
inputs: [],
expected_output: [],
}),
];
const results = checkFilePathConsistency(tasks, tempDir);
assert.deepEqual(results, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("fails when files don't exist and not in prior outputs", () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const tasks = [
createTask({
id: "T01",
files: ["nonexistent.ts"],
inputs: [],
expected_output: [],
}),
];
const results = checkFilePathConsistency(tasks, tempDir);
assert.equal(results.length, 1);
assert.equal(results[0].category, "file");
assert.equal(results[0].passed, false);
assert.equal(results[0].blocking, true);
assert.ok(results[0].message.includes("nonexistent.ts"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("checks both files and inputs arrays", () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const tasks = [
createTask({
id: "T01",
files: ["missing-file.ts"],
inputs: ["missing-input.ts"],
expected_output: [],
}),
];
const results = checkFilePathConsistency(tasks, tempDir);
assert.equal(results.length, 2);
assert.ok(results.some((r) => r.target === "missing-file.ts"));
assert.ok(results.some((r) => r.target === "missing-input.ts"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("skips empty file strings", () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const tasks = [
createTask({
id: "T01",
files: ["", " "],
inputs: [],
expected_output: [],
}),
];
const results = checkFilePathConsistency(tasks, tempDir);
assert.deepEqual(results, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
// ─── Task Ordering Tests ─────────────────────────────────────────────────────
describe("checkTaskOrdering", () => {
test("passes when tasks are correctly ordered", () => {
const tasks = [
createTask({
id: "T01",
sequence: 0,
files: [],
inputs: [],
expected_output: ["api.ts"],
}),
createTask({
id: "T02",
sequence: 1,
files: ["api.ts"],
inputs: [],
expected_output: [],
}),
];
const results = checkTaskOrdering(tasks, "/tmp");
assert.deepEqual(results, []);
});
test("fails when task reads file created by later task", () => {
const tasks = [
createTask({
id: "T01",
sequence: 0,
files: ["generated.ts"], // Reads file that doesn't exist yet
inputs: [],
expected_output: [],
}),
createTask({
id: "T02",
sequence: 1,
files: [],
inputs: [],
expected_output: ["generated.ts"], // Creates the file
}),
];
const results = checkTaskOrdering(tasks, "/tmp");
assert.equal(results.length, 1);
assert.equal(results[0].category, "file");
assert.equal(results[0].passed, false);
assert.equal(results[0].blocking, true);
assert.ok(results[0].message.includes("T01"));
assert.ok(results[0].message.includes("T02"));
assert.ok(results[0].message.includes("sequence violation"));
});
test("detects ordering violation in inputs array", () => {
const tasks = [
createTask({
id: "T01",
sequence: 0,
files: [],
inputs: ["schema.json"],
expected_output: [],
}),
createTask({
id: "T02",
sequence: 1,
files: [],
inputs: [],
expected_output: ["schema.json"],
}),
];
const results = checkTaskOrdering(tasks, "/tmp");
assert.equal(results.length, 1);
assert.ok(results[0].message.includes("schema.json"));
});
test("handles multiple ordering violations", () => {
const tasks = [
createTask({
id: "T01",
sequence: 0,
files: ["a.ts", "b.ts"],
inputs: [],
expected_output: [],
}),
createTask({
id: "T02",
sequence: 1,
files: [],
inputs: [],
expected_output: ["a.ts"],
}),
createTask({
id: "T03",
sequence: 2,
files: [],
inputs: [],
expected_output: ["b.ts"],
}),
];
const results = checkTaskOrdering(tasks, "/tmp");
assert.equal(results.length, 2);
});
test("passes when no dependencies between tasks", () => {
const tasks = [
createTask({
id: "T01",
sequence: 0,
files: [],
inputs: [],
expected_output: ["a.ts"],
}),
createTask({
id: "T02",
sequence: 1,
files: [],
inputs: [],
expected_output: ["b.ts"],
}),
];
const results = checkTaskOrdering(tasks, "/tmp");
assert.deepEqual(results, []);
});
});
// ─── Interface Contract Tests ────────────────────────────────────────────────
describe("checkInterfaceContracts", () => {
test("passes when function signatures match", () => {
const tasks = [
createTask({
id: "T01",
description: `
\`\`\`typescript
function processData(input: string): boolean
\`\`\`
`,
}),
createTask({
id: "T02",
description: `
\`\`\`typescript
function processData(input: string): boolean
\`\`\`
`,
}),
];
const results = checkInterfaceContracts(tasks, "/tmp");
assert.deepEqual(results, []);
});
test("warns on parameter mismatch (non-blocking)", () => {
const tasks = [
createTask({
id: "T01",
description: `
\`\`\`typescript
function saveUser(name: string): void
\`\`\`
`,
}),
createTask({
id: "T02",
description: `
\`\`\`typescript
function saveUser(name: string, email: string): void
\`\`\`
`,
}),
];
const results = checkInterfaceContracts(tasks, "/tmp");
assert.equal(results.length, 1);
assert.equal(results[0].category, "schema");
assert.equal(results[0].target, "saveUser");
assert.equal(results[0].passed, true); // Warning, not failure
assert.equal(results[0].blocking, false);
assert.ok(results[0].message.includes("different parameters"));
});
test("warns on return type mismatch (non-blocking)", () => {
const tasks = [
createTask({
id: "T01",
description: `
\`\`\`typescript
function getData(): string
\`\`\`
`,
}),
createTask({
id: "T02",
description: `
\`\`\`typescript
function getData(): number
\`\`\`
`,
}),
];
const results = checkInterfaceContracts(tasks, "/tmp");
assert.equal(results.length, 1);
assert.ok(results[0].message.includes("different return types"));
});
test("handles export function syntax", () => {
const tasks = [
createTask({
id: "T01",
description: `
\`\`\`typescript
export function validate(data: object): boolean
\`\`\`
`,
}),
createTask({
id: "T02",
description: `
\`\`\`typescript
export function validate(data: string): boolean
\`\`\`
`,
}),
];
const results = checkInterfaceContracts(tasks, "/tmp");
assert.equal(results.length, 1);
assert.ok(results[0].message.includes("validate"));
});
test("handles async function syntax", () => {
const tasks = [
createTask({
id: "T01",
description: `
\`\`\`typescript
export async function fetchData(): Promise<string>
\`\`\`
`,
}),
createTask({
id: "T02",
description: `
\`\`\`typescript
export async function fetchData(): Promise<number>
\`\`\`
`,
}),
];
const results = checkInterfaceContracts(tasks, "/tmp");
assert.equal(results.length, 1);
});
test("handles const arrow function syntax", () => {
const tasks = [
createTask({
id: "T01",
description: `
\`\`\`typescript
const handler = (req: Request): Response =>
\`\`\`
`,
}),
createTask({
id: "T02",
description: `
\`\`\`typescript
const handler = (req: Request, res: Response): void =>
\`\`\`
`,
}),
];
const results = checkInterfaceContracts(tasks, "/tmp");
// Should have 2 results: parameter mismatch AND return type mismatch
assert.equal(results.length, 2);
assert.ok(results.some((r) => r.message.includes("handler")));
assert.ok(results.some((r) => r.message.includes("parameters")));
assert.ok(results.some((r) => r.message.includes("return types")));
});
test("passes when no code blocks present", () => {
const tasks = [
createTask({
id: "T01",
description: "Just some text without code blocks",
}),
];
const results = checkInterfaceContracts(tasks, "/tmp");
assert.deepEqual(results, []);
});
test("handles multiple mismatches for same function", () => {
const tasks = [
createTask({
id: "T01",
description: `
\`\`\`typescript
function process(a: string): string
\`\`\`
`,
}),
createTask({
id: "T02",
description: `
\`\`\`typescript
function process(a: number): number
\`\`\`
`,
}),
];
const results = checkInterfaceContracts(tasks, "/tmp");
// Should have both parameter and return type mismatches
assert.equal(results.length, 2);
});
});
// ─── runPreExecutionChecks Integration Tests ─────────────────────────────────
describe("runPreExecutionChecks", () => {
let tempDir: string;
test("returns pass status when all checks pass", async () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
writeFileSync(join(tempDir, "existing.ts"), "// content");
try {
const tasks = [
createTask({
id: "T01",
files: ["existing.ts"],
inputs: [],
expected_output: ["output.ts"],
}),
createTask({
id: "T02",
files: ["output.ts"],
inputs: [],
expected_output: [],
}),
];
const result = await runPreExecutionChecks(tasks, tempDir);
assert.equal(result.status, "pass");
assert.equal(result.checks.length, 0);
assert.ok(result.durationMs >= 0);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("returns fail status when blocking failure exists", async () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const tasks = [
createTask({
id: "T01",
files: ["nonexistent.ts"],
inputs: [],
expected_output: [],
}),
];
const result = await runPreExecutionChecks(tasks, tempDir);
assert.equal(result.status, "fail");
assert.ok(result.checks.length > 0);
assert.ok(result.checks.some((c) => c.blocking === true));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("returns warn status for non-blocking issues", async () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
// Create tasks with only interface contract warnings
const tasks = [
createTask({
id: "T01",
files: [],
inputs: [],
expected_output: [],
description: `
\`\`\`typescript
function foo(a: string): void
\`\`\`
`,
}),
createTask({
id: "T02",
files: [],
inputs: [],
expected_output: [],
description: `
\`\`\`typescript
function foo(a: number): void
\`\`\`
`,
}),
];
const result = await runPreExecutionChecks(tasks, tempDir);
assert.equal(result.status, "warn");
assert.ok(result.checks.some((c) => c.blocking === false));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("combines results from all check types", async () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const tasks = [
createTask({
id: "T01",
sequence: 0,
files: ["will-be-created.ts"], // Ordering violation
inputs: ["missing.ts"], // Missing file
expected_output: [],
description: `
\`\`\`typescript
function check(a: string): void
\`\`\`
`,
}),
createTask({
id: "T02",
sequence: 1,
files: [],
inputs: [],
expected_output: ["will-be-created.ts"],
description: `
\`\`\`typescript
function check(a: number): void
\`\`\`
`,
}),
];
const result = await runPreExecutionChecks(tasks, tempDir);
assert.equal(result.status, "fail");
// Should have multiple types of issues
const categories = new Set(result.checks.map((c) => c.category));
assert.ok(categories.has("file")); // From consistency and ordering
assert.ok(categories.has("schema")); // From interface check
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("reports duration in milliseconds", async () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const tasks = [createTask({ id: "T01" })];
const result = await runPreExecutionChecks(tasks, tempDir);
assert.ok(typeof result.durationMs === "number");
assert.ok(result.durationMs >= 0);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("handles empty task array", async () => {
tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const result = await runPreExecutionChecks([], tempDir);
assert.equal(result.status, "pass");
assert.deepEqual(result.checks, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
// ─── PreExecutionResult Type Tests ───────────────────────────────────────────
describe("PreExecutionResult type", () => {
test("status is one of pass, warn, fail", async () => {
const tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const tasks = [createTask({ id: "T01" })];
const result = await runPreExecutionChecks(tasks, tempDir);
assert.ok(["pass", "warn", "fail"].includes(result.status));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("checks array matches PreExecutionCheckJSON schema", async () => {
const tempDir = join(tmpdir(), `pre-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const tasks = [
createTask({
id: "T01",
files: ["missing.ts"],
}),
];
const result = await runPreExecutionChecks(tasks, tempDir);
for (const check of result.checks) {
assert.ok(["package", "file", "tool", "endpoint", "schema"].includes(check.category));
assert.ok(typeof check.target === "string");
assert.ok(typeof check.passed === "boolean");
assert.ok(typeof check.message === "string");
if (check.blocking !== undefined) {
assert.ok(typeof check.blocking === "boolean");
}
}
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});

View file

@ -52,6 +52,32 @@ export interface BrowserEvidenceJSON {
duration: number;
}
export interface PreExecutionCheckJSON {
/** Check category: package, file, tool, endpoint, schema */
category: "package" | "file" | "tool" | "endpoint" | "schema";
/** What was checked (e.g., package name, file path) */
target: string;
/** Whether the check passed */
passed: boolean;
/** Human-readable message explaining the result */
message: string;
/** Whether this failure should block execution (only meaningful when passed=false) */
blocking?: boolean;
}
export interface PostExecutionCheckJSON {
/** Check category: import, signature, pattern */
category: "import" | "signature" | "pattern";
/** What was checked (e.g., file:line, function name) */
target: string;
/** Whether the check passed */
passed: boolean;
/** Human-readable message explaining the result */
message: string;
/** Whether this failure should block completion (only meaningful when passed=false) */
blocking?: boolean;
}
export interface EvidenceJSON {
schemaVersion: 1;
taskId: string;
@ -65,6 +91,10 @@ export interface EvidenceJSON {
runtimeErrors?: RuntimeErrorJSON[];
auditWarnings?: AuditWarningJSON[];
browser?: BrowserEvidenceJSON;
/** Pre-execution checks run before task execution (package existence, file refs, etc.) */
preExecutionChecks?: PreExecutionCheckJSON[];
/** Post-execution checks run after task completion (import resolution, signature drift, pattern consistency) */
postExecutionChecks?: PostExecutionCheckJSON[];
}
/**
@ -124,6 +154,44 @@ export function writeVerificationJSON(
writeFileSync(filePath, JSON.stringify(evidence, null, 2) + "\n", "utf-8");
}
// ─── Pre-Execution Evidence ──────────────────────────────────────────────────
export interface PreExecutionEvidenceJSON {
schemaVersion: 1;
milestoneId: string;
sliceId: string;
timestamp: number;
status: "pass" | "warn" | "fail";
durationMs: number;
checks: PreExecutionCheckJSON[];
}
/**
* Write pre-execution check results to a PRE-EXEC-VERIFY.json artifact
* in the slice directory.
*/
export function writePreExecutionEvidence(
result: { status: "pass" | "warn" | "fail"; checks: PreExecutionCheckJSON[]; durationMs: number },
sliceDir: string,
milestoneId: string,
sliceId: string,
): void {
mkdirSync(sliceDir, { recursive: true });
const evidence: PreExecutionEvidenceJSON = {
schemaVersion: 1,
milestoneId,
sliceId,
timestamp: Date.now(),
status: result.status,
durationMs: result.durationMs,
checks: result.checks,
};
const filePath = join(sliceDir, `${sliceId}-PRE-EXEC-VERIFY.json`);
writeFileSync(filePath, JSON.stringify(evidence, null, 2) + "\n", "utf-8");
}
// ─── Markdown Evidence Table ─────────────────────────────────────────────────
/**