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:
parent
992b321b63
commit
a3d08f7125
2 changed files with 1352 additions and 0 deletions
539
src/resources/extensions/gsd/post-execution-checks.ts
Normal file
539
src/resources/extensions/gsd/post-execution-checks.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
813
src/resources/extensions/gsd/tests/post-execution-checks.test.ts
Normal file
813
src/resources/extensions/gsd/tests/post-execution-checks.test.ts
Normal 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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue