diff --git a/src/resources/extensions/gsd/post-execution-checks.ts b/src/resources/extensions/gsd/post-execution-checks.ts new file mode 100644 index 000000000..284c803c0 --- /dev/null +++ b/src/resources/extensions/gsd/post-execution-checks.ts @@ -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 + // 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(); + + 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, + }; +} diff --git a/src/resources/extensions/gsd/tests/post-execution-checks.test.ts b/src/resources/extensions/gsd/tests/post-execution-checks.test.ts new file mode 100644 index 000000000..a70a5e962 --- /dev/null +++ b/src/resources/extensions/gsd/tests/post-execution-checks.test.ts @@ -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 { + 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 { + 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 { + 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 { + 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 }); + } + }); +});