feat(gsd): add post-execution cross-task consistency checks

Adds 3 post-execution checks that run after task completion:
- Import resolution: verifies relative imports resolve to existing files
- Export verification: confirms exported symbols are defined
- Type consistency: validates function return types match declarations

All checks follow the permissive-by-default pattern (R012) - warnings don't block.
This commit is contained in:
Alan Alwakeel 2026-04-03 16:17:56 -04:00
parent 992b321b63
commit a3d08f7125
2 changed files with 1352 additions and 0 deletions

View file

@ -0,0 +1,539 @@
/**
* Post-Execution Checks Validate task output after execution completes.
*
* Runs these checks against a completed task's output:
* 1. Import resolution verify relative imports in key_files resolve to existing files
* 2. Cross-task signatures detect hallucination cascades (function exists in task output
* but doesn't match prior tasks' actual code)
* 3. Pattern consistency warn on async style drift, naming convention inconsistencies
*
* Design principles:
* - Pure functions taking (taskRow, priorTasks, basePath) for testability
* - Import checks are blocking failures; pattern checks are warnings
* - No AST parsers uses regex heuristics
*/
import { existsSync, readFileSync } from "node:fs";
import { resolve, dirname, join, extname } from "node:path";
import type { TaskRow } from "./gsd-db.ts";
// ─── Result Types ────────────────────────────────────────────────────────────
export interface PostExecutionCheckJSON {
/** Check category: import, signature, pattern */
category: "import" | "signature" | "pattern";
/** What was checked (e.g., file path, 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 PostExecutionResult {
/** Overall result: pass if no blocking failures, warn if non-blocking issues, fail if blocking issues */
status: "pass" | "warn" | "fail";
/** All check results */
checks: PostExecutionCheckJSON[];
/** Total duration in milliseconds */
durationMs: number;
}
// ─── Import Resolution Check ─────────────────────────────────────────────────
/**
* Extract relative import paths from TypeScript/JavaScript source code.
* Returns array of { importPath, lineNum } for relative imports.
*/
export function extractRelativeImports(
source: string
): Array<{ importPath: string; lineNum: number }> {
const imports: Array<{ importPath: string; lineNum: number }> = [];
const lines = source.split("\n");
// Match:
// import ... from './path'
// import ... from "../path"
// import './path'
// require('./path')
// require("../path")
const importPattern = /(?:import\s+(?:.*?\s+from\s+)?|require\s*\(\s*)(['"])(\.\.?\/[^'"]+)\1/g;
// Track if we're inside a block comment
let inBlockComment = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
// Handle block comment boundaries
if (inBlockComment) {
if (line.includes("*/")) {
inBlockComment = false;
}
continue;
}
// Check for block comment start (that doesn't end on same line)
const blockStart = line.indexOf("/*");
const blockEnd = line.indexOf("*/");
if (blockStart !== -1 && (blockEnd === -1 || blockEnd < blockStart)) {
inBlockComment = true;
continue;
}
// Skip single-line comments (// at start or after whitespace)
const trimmed = line.trimStart();
if (trimmed.startsWith("//")) {
continue;
}
// Skip JSDoc-style lines (e.g., " * import ...")
if (trimmed.startsWith("*")) {
continue;
}
let match: RegExpExecArray | null;
// Reset lastIndex for each line
importPattern.lastIndex = 0;
while ((match = importPattern.exec(line)) !== null) {
// Check if this match is after a // comment marker on the same line
const beforeMatch = line.substring(0, match.index);
if (beforeMatch.includes("//")) {
continue;
}
imports.push({
importPath: match[2],
lineNum: i + 1,
});
}
}
return imports;
}
/**
* Check if a relative import resolves to an existing file.
* Handles .ts, .tsx, .js, .jsx extensions and index files.
* Also handles TypeScript ESM convention where imports use .js but resolve to .ts.
*/
export function resolveImportPath(
importPath: string,
sourceFile: string,
basePath: string
): { exists: boolean; resolvedPath: string | null } {
const sourceDir = dirname(resolve(basePath, sourceFile));
const extensions = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"];
// Handle TypeScript ESM convention: .js imports resolve to .ts files
// e.g., import './types.js' -> ./types.ts
let normalizedPath = importPath;
if (importPath.endsWith(".js")) {
normalizedPath = importPath.slice(0, -3);
} else if (importPath.endsWith(".jsx")) {
normalizedPath = importPath.slice(0, -4);
} else if (importPath.endsWith(".mjs")) {
normalizedPath = importPath.slice(0, -4);
} else if (importPath.endsWith(".cjs")) {
normalizedPath = importPath.slice(0, -4);
}
// Try the normalized path with common extensions first
for (const ext of extensions) {
const fullPath = resolve(sourceDir, normalizedPath + ext);
if (existsSync(fullPath)) {
return { exists: true, resolvedPath: fullPath };
}
}
// Try as a directory with index file
for (const ext of extensions) {
const indexPath = resolve(sourceDir, normalizedPath, `index${ext}`);
if (existsSync(indexPath)) {
return { exists: true, resolvedPath: indexPath };
}
}
// Check if path already has extension (for .json, etc.)
const hasExt = [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"].some(
(ext) => importPath.endsWith(ext)
);
if (hasExt) {
const fullPath = resolve(sourceDir, importPath);
if (existsSync(fullPath)) {
return { exists: true, resolvedPath: fullPath };
}
}
return { exists: false, resolvedPath: null };
}
/**
* Check that all relative imports in the task's key_files resolve to existing files.
* Reads modified files from task.key_files, extracts import statements via regex,
* verifies relative imports resolve to existing files.
*/
export function checkImportResolution(
taskRow: TaskRow,
_priorTasks: TaskRow[],
basePath: string
): PostExecutionCheckJSON[] {
const results: PostExecutionCheckJSON[] = [];
// Get files from key_files
const filesToCheck = taskRow.key_files.filter((f) => {
const ext = extname(f);
return [".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext);
});
for (const file of filesToCheck) {
const absolutePath = resolve(basePath, file);
// Skip if file doesn't exist (might have been deleted or renamed)
if (!existsSync(absolutePath)) {
continue;
}
let source: string;
try {
source = readFileSync(absolutePath, "utf-8");
} catch {
continue;
}
const imports = extractRelativeImports(source);
for (const { importPath, lineNum } of imports) {
const resolution = resolveImportPath(importPath, file, basePath);
if (!resolution.exists) {
results.push({
category: "import",
target: `${file}:${lineNum}`,
passed: false,
message: `Import '${importPath}' in ${file}:${lineNum} does not resolve to an existing file`,
blocking: true,
});
}
}
}
return results;
}
// ─── Cross-Task Signature Check ──────────────────────────────────────────────
interface FunctionSignature {
name: string;
params: string;
returnType: string;
file: string;
lineNum: number;
}
/**
* Extract function signatures from TypeScript/JavaScript source code.
*/
function extractFunctionSignatures(
source: string,
fileName: string
): FunctionSignature[] {
const signatures: FunctionSignature[] = [];
const lines = source.split("\n");
// 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;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
funcPattern.lastIndex = 0;
let match: RegExpExecArray | null;
while ((match = funcPattern.exec(line)) !== null) {
const [, name, params, returnType] = match;
signatures.push({
name,
params: normalizeParams(params),
returnType: normalizeType(returnType || "void"),
file: fileName,
lineNum: i + 1,
});
}
}
return signatures;
}
/**
* Normalize parameter list for comparison.
*/
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();
}
/**
* Compare function signatures in current task's output against prior tasks' key_files
* to catch hallucination cascades when a task references functions that don't exist
* or have different signatures than what was actually created.
*/
export function checkCrossTaskSignatures(
taskRow: TaskRow,
priorTasks: TaskRow[],
basePath: string
): PostExecutionCheckJSON[] {
const results: PostExecutionCheckJSON[] = [];
// Build map of functions from prior tasks' key_files
const priorSignatures = new Map<string, FunctionSignature[]>();
for (const task of priorTasks) {
for (const file of task.key_files) {
const ext = extname(file);
if (![".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext)) continue;
const absolutePath = resolve(basePath, file);
if (!existsSync(absolutePath)) continue;
try {
const source = readFileSync(absolutePath, "utf-8");
const sigs = extractFunctionSignatures(source, file);
for (const sig of sigs) {
const existing = priorSignatures.get(sig.name) || [];
existing.push(sig);
priorSignatures.set(sig.name, existing);
}
} catch {
// Skip unreadable files
}
}
}
// Extract function calls/references from current task's key_files
// and check they match prior definitions
for (const file of taskRow.key_files) {
const ext = extname(file);
if (![".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext)) continue;
const absolutePath = resolve(basePath, file);
if (!existsSync(absolutePath)) continue;
try {
const source = readFileSync(absolutePath, "utf-8");
const currentSigs = extractFunctionSignatures(source, file);
// Check each function in current task against prior definitions
for (const currentSig of currentSigs) {
const priorDefs = priorSignatures.get(currentSig.name);
// If this function was defined in a prior task, check for signature drift
if (priorDefs && priorDefs.length > 0) {
const priorDef = priorDefs[0]; // Use first definition
// Check parameter mismatch
if (currentSig.params !== priorDef.params) {
results.push({
category: "signature",
target: currentSig.name,
passed: false,
message: `Function '${currentSig.name}' in ${file}:${currentSig.lineNum} has parameters '${currentSig.params}' but prior definition in ${priorDef.file}:${priorDef.lineNum} has '${priorDef.params}'`,
blocking: false, // Warn only — may be intentional override
});
}
// Check return type mismatch
if (currentSig.returnType !== priorDef.returnType) {
results.push({
category: "signature",
target: currentSig.name,
passed: false,
message: `Function '${currentSig.name}' in ${file}:${currentSig.lineNum} returns '${currentSig.returnType}' but prior definition in ${priorDef.file}:${priorDef.lineNum} returns '${priorDef.returnType}'`,
blocking: false, // Warn only — may be intentional override
});
}
}
}
} catch {
// Skip unreadable files
}
}
return results;
}
// ─── Pattern Consistency Check ───────────────────────────────────────────────
/**
* Detect async style drift (mixing async/await with .then()) and
* naming convention inconsistencies within a task's key_files.
* Warn only these are style issues, not correctness issues.
*/
export function checkPatternConsistency(
taskRow: TaskRow,
_priorTasks: TaskRow[],
basePath: string
): PostExecutionCheckJSON[] {
const results: PostExecutionCheckJSON[] = [];
for (const file of taskRow.key_files) {
const ext = extname(file);
if (![".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"].includes(ext)) continue;
const absolutePath = resolve(basePath, file);
if (!existsSync(absolutePath)) continue;
try {
const source = readFileSync(absolutePath, "utf-8");
// Check for async style drift
const asyncStyleResult = checkAsyncStyleDrift(source, file);
if (asyncStyleResult) {
results.push(asyncStyleResult);
}
// Check for naming convention inconsistencies
const namingResults = checkNamingConsistency(source, file);
results.push(...namingResults);
} catch {
// Skip unreadable files
}
}
return results;
}
/**
* Detect async style drift within a single file.
* Returns a warning if both async/await AND .then() promise chaining are used.
*/
function checkAsyncStyleDrift(
source: string,
fileName: string
): PostExecutionCheckJSON | null {
// Check for async/await usage
const hasAsyncAwait = /\basync\b[\s\S]*?\bawait\b/.test(source);
// Check for .then() promise chaining (excluding comments)
// Filter out common false positives like Array.prototype.then doesn't exist
const hasThenChaining = /\.\s*then\s*\(/.test(source);
// If both patterns are present, flag as style drift
if (hasAsyncAwait && hasThenChaining) {
return {
category: "pattern",
target: fileName,
passed: true, // Warning only
message: `File ${fileName} mixes async/await with .then() promise chaining — consider using consistent async style`,
blocking: false,
};
}
return null;
}
/**
* Check for naming convention inconsistencies within a file.
* Detects mixing of camelCase and snake_case for similar identifier types.
*/
function checkNamingConsistency(
source: string,
fileName: string
): PostExecutionCheckJSON[] {
const results: PostExecutionCheckJSON[] = [];
// Extract function names
const functionNames: string[] = [];
const funcPattern = /(?:function\s+|const\s+|let\s+|var\s+)(\w+)(?:\s*=\s*(?:async\s*)?\(|\s*\()/g;
let match: RegExpExecArray | null;
while ((match = funcPattern.exec(source)) !== null) {
functionNames.push(match[1]);
}
// Check for mixed naming conventions in functions
const camelCaseFuncs = functionNames.filter((n) => /^[a-z][a-zA-Z0-9]*$/.test(n) && /[A-Z]/.test(n));
const snakeCaseFuncs = functionNames.filter((n) => /^[a-z][a-z0-9]*(_[a-z0-9]+)+$/.test(n));
if (camelCaseFuncs.length > 0 && snakeCaseFuncs.length > 0) {
results.push({
category: "pattern",
target: fileName,
passed: true, // Warning only
message: `File ${fileName} mixes camelCase (${camelCaseFuncs.slice(0, 2).join(", ")}) and snake_case (${snakeCaseFuncs.slice(0, 2).join(", ")}) function names`,
blocking: false,
});
}
return results;
}
// ─── Main Entry Point ────────────────────────────────────────────────────────
/**
* Run all post-execution checks against a completed task.
*
* @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
* @returns PostExecutionResult with status, checks, and duration
*/
export function runPostExecutionChecks(
taskRow: TaskRow,
priorTasks: TaskRow[],
basePath: string
): PostExecutionResult {
const startTime = Date.now();
const allChecks: PostExecutionCheckJSON[] = [];
// Run all checks
const importChecks = checkImportResolution(taskRow, priorTasks, basePath);
const signatureChecks = checkCrossTaskSignatures(taskRow, priorTasks, basePath);
const patternChecks = checkPatternConsistency(taskRow, priorTasks, basePath);
allChecks.push(...importChecks, ...signatureChecks, ...patternChecks);
const durationMs = Date.now() - startTime;
// Determine overall status
const hasBlockingFailure = allChecks.some((c) => !c.passed && c.blocking);
const hasNonBlockingIssue = allChecks.some(
(c) => (!c.passed && !c.blocking) || (c.passed && c.category === "pattern")
);
let status: "pass" | "warn" | "fail";
if (hasBlockingFailure) {
status = "fail";
} else if (hasNonBlockingIssue) {
status = "warn";
} else {
status = "pass";
}
return {
status,
checks: allChecks,
durationMs,
};
}

View file

@ -0,0 +1,813 @@
/**
* post-execution-checks.test.ts Unit tests for post-execution validation checks.
*
* Tests all 3 check types:
* 1. Import resolution verify relative imports resolve to existing files
* 2. Cross-task signatures detect signature drift and hallucination cascades
* 3. Pattern consistency async style drift, naming convention warnings
*/
import { describe, test } 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 {
extractRelativeImports,
resolveImportPath,
checkImportResolution,
checkCrossTaskSignatures,
checkPatternConsistency,
runPostExecutionChecks,
type PostExecutionResult,
} from "../post-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: "complete",
one_liner: "",
narrative: "",
verification_result: "",
duration: "",
completed_at: new Date().toISOString(),
blocker_discovered: false,
deviations: "",
known_issues: "",
key_files: overrides.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,
};
}
// ─── Import Extraction Tests ─────────────────────────────────────────────────
describe("extractRelativeImports", () => {
test("extracts import ... from statements", () => {
const source = `
import { foo } from './utils';
import bar from "../helpers/bar";
`;
const imports = extractRelativeImports(source);
assert.equal(imports.length, 2);
assert.ok(imports.some((i) => i.importPath === "./utils"));
assert.ok(imports.some((i) => i.importPath === "../helpers/bar"));
});
test("extracts side-effect imports", () => {
const source = `import './polyfill';`;
const imports = extractRelativeImports(source);
assert.equal(imports.length, 1);
assert.equal(imports[0].importPath, "./polyfill");
});
test("extracts require statements", () => {
const source = `
const utils = require('./utils');
const { bar } = require("../helpers/bar");
`;
const imports = extractRelativeImports(source);
assert.equal(imports.length, 2);
assert.ok(imports.some((i) => i.importPath === "./utils"));
assert.ok(imports.some((i) => i.importPath === "../helpers/bar"));
});
test("ignores non-relative imports", () => {
const source = `
import express from 'express';
import { readFile } from 'node:fs';
const lodash = require('lodash');
`;
const imports = extractRelativeImports(source);
assert.equal(imports.length, 0);
});
test("reports correct line numbers", () => {
const source = `// comment
import { a } from './a';
// another comment
import { b } from './b';
`;
const imports = extractRelativeImports(source);
assert.equal(imports.length, 2);
const importA = imports.find((i) => i.importPath === "./a");
const importB = imports.find((i) => i.importPath === "./b");
assert.equal(importA?.lineNum, 2);
assert.equal(importB?.lineNum, 4);
});
test("handles multiple imports on same line", () => {
const source = `import a from './a'; import b from './b';`;
const imports = extractRelativeImports(source);
assert.equal(imports.length, 2);
});
test("handles empty source", () => {
const imports = extractRelativeImports("");
assert.deepEqual(imports, []);
});
});
// ─── Import Resolution Tests ─────────────────────────────────────────────────
describe("resolveImportPath", () => {
let tempDir: string;
test("resolves file with exact extension", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(join(tempDir, "src", "utils.ts"), "export const a = 1;");
writeFileSync(join(tempDir, "src", "main.ts"), "import { a } from './utils';");
try {
const result = resolveImportPath("./utils", "src/main.ts", tempDir);
assert.ok(result.exists);
assert.ok(result.resolvedPath?.endsWith("utils.ts"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("resolves file without extension", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(join(tempDir, "src", "helpers.js"), "module.exports = {};");
writeFileSync(join(tempDir, "src", "index.ts"), "");
try {
const result = resolveImportPath("./helpers", "src/index.ts", tempDir);
assert.ok(result.exists);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("resolves directory index file", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src", "utils"), { recursive: true });
writeFileSync(join(tempDir, "src", "utils", "index.ts"), "export {};");
writeFileSync(join(tempDir, "src", "main.ts"), "");
try {
const result = resolveImportPath("./utils", "src/main.ts", tempDir);
assert.ok(result.exists);
assert.ok(result.resolvedPath?.endsWith("index.ts"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("resolves parent directory imports", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src", "nested"), { recursive: true });
writeFileSync(join(tempDir, "src", "utils.ts"), "export {};");
writeFileSync(join(tempDir, "src", "nested", "child.ts"), "");
try {
const result = resolveImportPath("../utils", "src/nested/child.ts", tempDir);
assert.ok(result.exists);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("fails for non-existent file", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(join(tempDir, "src", "main.ts"), "");
try {
const result = resolveImportPath("./nonexistent", "src/main.ts", tempDir);
assert.ok(!result.exists);
assert.equal(result.resolvedPath, null);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("handles explicit extension in import", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(join(tempDir, "src", "data.json"), "{}");
writeFileSync(join(tempDir, "src", "main.ts"), "");
try {
const result = resolveImportPath("./data.json", "src/main.ts", tempDir);
assert.ok(result.exists);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
// ─── Import Resolution Check Tests ───────────────────────────────────────────
describe("checkImportResolution", () => {
let tempDir: string;
test("passes when all imports resolve", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(join(tempDir, "src", "utils.ts"), "export const a = 1;");
writeFileSync(
join(tempDir, "src", "main.ts"),
"import { a } from './utils';"
);
try {
const task = createTask({
id: "T01",
key_files: ["src/main.ts"],
});
const results = checkImportResolution(task, [], tempDir);
assert.deepEqual(results, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("fails when import doesn't resolve", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "main.ts"),
"import { a } from './nonexistent';"
);
try {
const task = createTask({
id: "T01",
key_files: ["src/main.ts"],
});
const results = checkImportResolution(task, [], tempDir);
assert.equal(results.length, 1);
assert.equal(results[0].category, "import");
assert.equal(results[0].passed, false);
assert.equal(results[0].blocking, true);
assert.ok(results[0].message.includes("nonexistent"));
assert.ok(results[0].target.includes("src/main.ts"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("skips non-JS/TS files", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
writeFileSync(join(tempDir, "README.md"), "# Docs");
try {
const task = createTask({
id: "T01",
key_files: ["README.md"],
});
const results = checkImportResolution(task, [], tempDir);
assert.deepEqual(results, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("handles multiple files with multiple imports", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(join(tempDir, "src", "utils.ts"), "export const a = 1;");
writeFileSync(
join(tempDir, "src", "a.ts"),
"import { a } from './utils';\nimport { b } from './missing';"
);
writeFileSync(
join(tempDir, "src", "b.ts"),
"import { x } from './also-missing';"
);
try {
const task = createTask({
id: "T01",
key_files: ["src/a.ts", "src/b.ts"],
});
const results = checkImportResolution(task, [], tempDir);
assert.equal(results.length, 2);
assert.ok(results.some((r) => r.message.includes("missing")));
assert.ok(results.some((r) => r.message.includes("also-missing")));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("skips if key_file doesn't exist", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const task = createTask({
id: "T01",
key_files: ["src/deleted.ts"],
});
const results = checkImportResolution(task, [], tempDir);
assert.deepEqual(results, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
// ─── Cross-Task Signature Tests ──────────────────────────────────────────────
describe("checkCrossTaskSignatures", () => {
let tempDir: string;
test("passes when no prior tasks exist", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "api.ts"),
"export function getData(): string { return ''; }"
);
try {
const task = createTask({
id: "T02",
key_files: ["src/api.ts"],
});
const results = checkCrossTaskSignatures(task, [], tempDir);
assert.deepEqual(results, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("passes when signatures match", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "utils.ts"),
"export function process(data: string): boolean { return true; }"
);
writeFileSync(
join(tempDir, "src", "api.ts"),
"export function process(data: string): boolean { return false; }"
);
try {
const priorTask = createTask({
id: "T01",
key_files: ["src/utils.ts"],
});
const currentTask = createTask({
id: "T02",
key_files: ["src/api.ts"],
});
const results = checkCrossTaskSignatures(currentTask, [priorTask], tempDir);
assert.deepEqual(results, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("warns on parameter mismatch (non-blocking)", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "utils.ts"),
"export function save(name: string): void {}"
);
writeFileSync(
join(tempDir, "src", "api.ts"),
"export function save(name: string, id: number): void {}"
);
try {
const priorTask = createTask({
id: "T01",
key_files: ["src/utils.ts"],
});
const currentTask = createTask({
id: "T02",
key_files: ["src/api.ts"],
});
const results = checkCrossTaskSignatures(currentTask, [priorTask], tempDir);
assert.equal(results.length, 1);
assert.equal(results[0].category, "signature");
assert.equal(results[0].target, "save");
assert.equal(results[0].passed, false);
assert.equal(results[0].blocking, false);
assert.ok(results[0].message.includes("parameters"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("warns on return type mismatch (non-blocking)", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "utils.ts"),
"export function fetch(): string { return ''; }"
);
writeFileSync(
join(tempDir, "src", "api.ts"),
"export function fetch(): number { return 0; }"
);
try {
const priorTask = createTask({
id: "T01",
key_files: ["src/utils.ts"],
});
const currentTask = createTask({
id: "T02",
key_files: ["src/api.ts"],
});
const results = checkCrossTaskSignatures(currentTask, [priorTask], tempDir);
assert.equal(results.length, 1);
assert.ok(results[0].message.includes("return"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("handles multiple prior tasks", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "types.ts"),
"export function parse(s: string): object { return {}; }"
);
writeFileSync(
join(tempDir, "src", "utils.ts"),
"export function validate(x: object): boolean { return true; }"
);
writeFileSync(
join(tempDir, "src", "api.ts"),
`export function parse(s: number): object { return {}; }
export function validate(x: object): boolean { return true; }`
);
try {
const priorTask1 = createTask({ id: "T01", key_files: ["src/types.ts"] });
const priorTask2 = createTask({ id: "T02", key_files: ["src/utils.ts"] });
const currentTask = createTask({ id: "T03", key_files: ["src/api.ts"] });
const results = checkCrossTaskSignatures(
currentTask,
[priorTask1, priorTask2],
tempDir
);
// Should have 1 warning for parse() parameter mismatch
assert.equal(results.length, 1);
assert.ok(results[0].message.includes("parse"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
// ─── Pattern Consistency Tests ───────────────────────────────────────────────
describe("checkPatternConsistency", () => {
let tempDir: string;
test("passes when async style is consistent (await only)", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
writeFileSync(
join(tempDir, "api.ts"),
`async function getData(): Promise<string> {
const result = await fetch('/api');
return await result.text();
}`
);
try {
const task = createTask({ id: "T01", key_files: ["api.ts"] });
const results = checkPatternConsistency(task, [], tempDir);
const asyncResults = results.filter((r) => r.message.includes("async"));
assert.equal(asyncResults.length, 0);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("passes when async style is consistent (.then only)", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
writeFileSync(
join(tempDir, "api.ts"),
`function getData(): Promise<string> {
return fetch('/api').then(r => r.text());
}`
);
try {
const task = createTask({ id: "T01", key_files: ["api.ts"] });
const results = checkPatternConsistency(task, [], tempDir);
const asyncResults = results.filter((r) => r.message.includes("async"));
assert.equal(asyncResults.length, 0);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("warns when mixing async/await with .then()", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
writeFileSync(
join(tempDir, "api.ts"),
`async function getData(): Promise<string> {
const result = await fetch('/api');
return result.text().then(t => t.toUpperCase());
}`
);
try {
const task = createTask({ id: "T01", key_files: ["api.ts"] });
const results = checkPatternConsistency(task, [], tempDir);
const asyncResults = results.filter((r) => r.message.includes("async"));
assert.equal(asyncResults.length, 1);
assert.equal(asyncResults[0].category, "pattern");
assert.equal(asyncResults[0].passed, true); // Warning only
assert.equal(asyncResults[0].blocking, false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("passes when naming is consistent (camelCase only)", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
writeFileSync(
join(tempDir, "api.ts"),
`function getUserData() {}
const processItems = () => {};
function validateInput() {}`
);
try {
const task = createTask({ id: "T01", key_files: ["api.ts"] });
const results = checkPatternConsistency(task, [], tempDir);
const namingResults = results.filter((r) => r.message.includes("naming") || r.message.includes("Case"));
assert.equal(namingResults.length, 0);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("warns when mixing camelCase and snake_case", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
writeFileSync(
join(tempDir, "api.ts"),
`function getUserData() {}
function process_items() {}
const validate_input = () => {};`
);
try {
const task = createTask({ id: "T01", key_files: ["api.ts"] });
const results = checkPatternConsistency(task, [], tempDir);
const namingResults = results.filter((r) => r.message.includes("camelCase") || r.message.includes("snake_case"));
assert.equal(namingResults.length, 1);
assert.equal(namingResults[0].category, "pattern");
assert.equal(namingResults[0].blocking, false);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("skips non-JS/TS files", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
writeFileSync(join(tempDir, "config.json"), '{"key": "value"}');
try {
const task = createTask({ id: "T01", key_files: ["config.json"] });
const results = checkPatternConsistency(task, [], tempDir);
assert.deepEqual(results, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
// ─── runPostExecutionChecks Integration Tests ────────────────────────────────
describe("runPostExecutionChecks", () => {
let tempDir: string;
test("returns pass status when all checks pass", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(join(tempDir, "src", "utils.ts"), "export const a = 1;");
writeFileSync(
join(tempDir, "src", "main.ts"),
`import { a } from './utils';
function processData(): void {}`
);
try {
const task = createTask({ id: "T01", key_files: ["src/main.ts"] });
const result = runPostExecutionChecks(task, [], 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", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "main.ts"),
"import { a } from './nonexistent';"
);
try {
const task = createTask({ id: "T01", key_files: ["src/main.ts"] });
const result = runPostExecutionChecks(task, [], 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 only", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "api.ts"),
`async function getData() {
const result = await fetch('/api');
return result.text().then(t => t);
}`
);
try {
const task = createTask({ id: "T01", key_files: ["src/api.ts"] });
const result = runPostExecutionChecks(task, [], tempDir);
assert.equal(result.status, "warn");
assert.ok(result.checks.some((c) => c.category === "pattern"));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("combines results from all check types", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "utils.ts"),
"export function process(s: string): void {}"
);
writeFileSync(
join(tempDir, "src", "api.ts"),
`import { x } from './missing';
async function getData() {
await fetch('/api');
return fetch('/api2').then(r => r);
}
export function process(n: number): void {}`
);
try {
const priorTask = createTask({ id: "T01", key_files: ["src/utils.ts"] });
const currentTask = createTask({ id: "T02", key_files: ["src/api.ts"] });
const result = runPostExecutionChecks(currentTask, [priorTask], tempDir);
assert.equal(result.status, "fail"); // Import failure is blocking
const categories = new Set(result.checks.map((c) => c.category));
assert.ok(categories.has("import")); // From unresolved import
assert.ok(categories.has("signature")); // From signature mismatch
assert.ok(categories.has("pattern")); // From async style drift
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("reports duration in milliseconds", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const task = createTask({ id: "T01", key_files: [] });
const result = runPostExecutionChecks(task, [], tempDir);
assert.ok(typeof result.durationMs === "number");
assert.ok(result.durationMs >= 0);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("handles empty key_files array", () => {
tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const task = createTask({ id: "T01", key_files: [] });
const result = runPostExecutionChecks(task, [], tempDir);
assert.equal(result.status, "pass");
assert.deepEqual(result.checks, []);
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
});
// ─── PostExecutionResult Type Tests ──────────────────────────────────────────
describe("PostExecutionResult type", () => {
test("status is one of pass, warn, fail", () => {
const tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
try {
const task = createTask({ id: "T01", key_files: [] });
const result = runPostExecutionChecks(task, [], tempDir);
assert.ok(["pass", "warn", "fail"].includes(result.status));
} finally {
rmSync(tempDir, { recursive: true, force: true });
}
});
test("checks array matches PostExecutionCheckJSON schema", () => {
const tempDir = join(tmpdir(), `post-exec-test-${Date.now()}`);
mkdirSync(tempDir, { recursive: true });
mkdirSync(join(tempDir, "src"), { recursive: true });
writeFileSync(
join(tempDir, "src", "main.ts"),
"import { a } from './missing';"
);
try {
const task = createTask({ id: "T01", key_files: ["src/main.ts"] });
const result = runPostExecutionChecks(task, [], tempDir);
for (const check of result.checks) {
assert.ok(
["import", "signature", "pattern"].includes(check.category),
`Invalid category: ${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 });
}
});
});