feat(engine): harden review pipeline with strict scope, build retry, and E2E tests

Improve the plan→review→approve→merge agent pipeline:

- Harden verdict extraction with JSON block parsing and anchored regexes
- Consolidate legacy/new merger conflict APIs into thin deprecated wrappers
- Add configurable strict scope enforcement (strictScopeEnforcement setting)
- Add build retry with timeout to merger (buildRetryCount, buildTimeoutMs)
- Add handleChangesRequested to PrCommentHandler for review feedback loop
- Remove dead code: handleFsChange, processTaskChange, unused imports/fields
- Add E2E multi-verdict sequence tests for the full review pipeline
- Fix unused parameter warnings across engine and core packages

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-04-04 00:29:34 -07:00
parent a2e67ce0f6
commit eef15f077c
11 changed files with 427 additions and 357 deletions

View File

@@ -1,6 +1,6 @@
import { execSync } from "node:child_process";
import { existsSync, readFileSync } from "node:fs";
import type { TaskStore, Task, MergeResult } from "@fusion/core";
import { existsSync } from "node:fs";
import type { TaskStore, MergeResult } from "@fusion/core";
import { createKbAgent, promptWithFallback } from "./pi.js";
import type { WorktreePool } from "./worktree-pool.js";
import { AgentLogger } from "./agent-logger.js";
@@ -184,12 +184,15 @@ function matchesScope(filePath: string, scopePatterns: string[]): boolean {
/**
* Validate that the diff stays within the task's declared File Scope.
* Returns warnings for out-of-scope changes, especially large deletions.
* This is a soft guardrail — warnings are logged but do not block merge.
*
* When `strict` is true, throws an error on scope violations instead of
* just returning warnings (hard guardrail that blocks merge).
*/
export async function validateDiffScope(
store: TaskStore,
taskId: string,
diffStat: string,
strict: boolean = false,
): Promise<DiffScopeResult> {
const result: DiffScopeResult = { warnings: [], outOfScopeFiles: [], largeOutOfScopeDeletions: [] };
@@ -239,6 +242,13 @@ export async function validateDiffScope(
);
}
// In strict mode, scope violations block the merge
if (strict && result.warnings.length > 0) {
throw new Error(
`Scope enforcement failed for ${taskId}: ${result.warnings.join("; ")}`,
);
}
return result;
}
@@ -364,189 +374,64 @@ export function resolveTrivialWhitespace(filePath: string, cwd: string): void {
}
}
// TODO(KB-023 Step 4): Consolidate with new API above. The following legacy API
// (ConflictCategory, detectResolvableConflicts, isTrivialConflict, autoResolveFile,
// resolveConflicts) duplicates functionality with the new Step 2 API. Migrate
// callers to use classifyConflict, resolveWithOurs, resolveWithTheirs, etc.
/** Conflict category for a file with merge conflicts - LEGACY API, see above */
// Legacy types re-exported for backward compatibility (tests may reference them)
/** @deprecated Use ConflictType instead */
export type ConflictResolution = "ours" | "theirs";
/** @deprecated Use classifyConflict + getConflictedFiles instead */
export interface ConflictCategory {
filePath: string;
/** Whether this conflict can be auto-resolved without AI */
autoResolvable: boolean;
/** Resolution strategy: 'ours' = take current branch, 'theirs' = take incoming branch */
strategy?: ConflictResolution;
/** Reason for the categorization */
reason: "lock-file" | "generated-file" | "trivial" | "complex";
}
/** Lock file patterns that should auto-resolve using "ours" (keep current branch's version) */
const LOCK_FILE_PATTERNS = [
/package-lock\.json$/,
/pnpm-lock\.yaml$/,
/yarn\.lock$/,
/Gemfile\.lock$/,
/Cargo\.lock$/,
/composer\.lock$/,
/poetry\.lock$/,
/bun\.lockb$/,
/go\.sum$/,
];
/** Generated file patterns that should auto-resolve using "theirs" (keep branch's fresh generation) */
const GENERATED_FILE_PATTERNS = [
/\.gen\.(ts|js|tsx|jsx|mjs|cjs)$/,
/\.min\.(js|css)$/,
/dist\//,
/build\//,
/coverage\//,
/\.next\//,
/\.nuxt\//,
/\.output\//,
/\.cache\//,
/out\//,
/__generated__\//,
/generated\//,
];
/**
* Detect and categorize merge conflicts in the working directory.
* Returns array of ConflictCategory for each conflicted file.
* Detect and categorize merge conflicts. Delegates to the new classifyConflict API.
* @deprecated Use getConflictedFiles() + classifyConflict() instead.
*/
export function detectResolvableConflicts(rootDir: string): ConflictCategory[] {
try {
// Get list of conflicted files
const conflictedOutput = execSync("git diff --name-only --diff-filter=U", {
cwd: rootDir,
encoding: "utf-8",
}).trim();
if (!conflictedOutput) {
return [];
const files = getConflictedFiles(rootDir);
return files.map((filePath): ConflictCategory => {
const type = classifyConflict(filePath, rootDir);
switch (type) {
case "lockfile-ours":
return { filePath, autoResolvable: true, strategy: "ours", reason: "lock-file" };
case "generated-theirs":
return { filePath, autoResolvable: true, strategy: "theirs", reason: "generated-file" };
case "trivial-whitespace":
return { filePath, autoResolvable: true, strategy: "ours", reason: "trivial" };
case "complex":
return { filePath, autoResolvable: false, reason: "complex" };
}
const conflictedFiles = conflictedOutput.split("\n").filter(Boolean);
return conflictedFiles.map((filePath): ConflictCategory => {
// Check for lock files - always take "ours" (current branch's version)
if (LOCK_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) {
return {
filePath,
autoResolvable: true,
strategy: "ours",
reason: "lock-file",
};
}
// Check for generated files - take "theirs" (keep branch's fresh generation)
if (GENERATED_FILE_PATTERNS.some((pattern) => pattern.test(filePath))) {
return {
filePath,
autoResolvable: true,
strategy: "theirs",
reason: "generated-file",
};
}
// Check for trivial conflicts (whitespace-only)
if (isTrivialConflict(filePath, rootDir)) {
return {
filePath,
autoResolvable: true,
strategy: "ours", // Either would work, but ours is current branch
reason: "trivial",
};
}
// Complex conflicts require AI intervention
return {
filePath,
autoResolvable: false,
reason: "complex",
};
});
} catch (error) {
mergerLog.error(`Failed to detect conflicts: ${error}`);
return [];
}
}
/**
* Check if a conflicted file has only trivial changes (whitespace-only differences).
* Reads the working directory file and compares the conflict sections.
*/
function isTrivialConflict(filePath: string, rootDir: string): boolean {
try {
const fullPath = `${rootDir}/${filePath}`;
const content = readFileSync(fullPath, "utf-8");
// Look for conflict markers - support any text after <<<<<<< (HEAD, ours, Updated upstream, etc.)
const conflictRegex = /<<<<<<<\s+.+?[\s\S]*?^=======([\s\S]*?)^>>>>>>>\s+/gm;
let hasConflicts = false;
for (const match of content.matchAll(conflictRegex)) {
hasConflicts = true;
const fullMatch = match[0];
const theirsContent = match[1];
// Extract "ours" content (between <<<<<<< line and ======= line)
const oursMatch = fullMatch.match(/<<<<<<<\s+.+?\n([\s\S]*?)\n=======/);
if (!oursMatch) continue;
const oursContent = oursMatch[1];
// Normalize: remove all whitespace and compare
const oursNormalized = oursContent.replace(/\s+/g, "");
const theirsNormalized = theirsContent.replace(/\s+/g, "");
// If content is the same after stripping whitespace, it's trivial
if (oursNormalized !== theirsNormalized) {
return false; // Real content difference found
}
}
return hasConflicts; // Only trivial if we found conflicts and they're all trivial
} catch {
return false; // On error, assume complex
}
});
}
/**
* Auto-resolve a single file using git checkout --ours or --theirs.
* Stages the resolved file.
* @deprecated Use resolveWithOurs() or resolveWithTheirs() instead.
*/
export function autoResolveFile(
filePath: string,
resolution: ConflictResolution,
rootDir: string,
): void {
try {
execSync(`git checkout --${resolution} "${filePath}"`, {
cwd: rootDir,
stdio: "pipe",
});
execSync(`git add "${filePath}"`, {
cwd: rootDir,
stdio: "pipe",
});
mergerLog.log(`Auto-resolved ${filePath} using --${resolution}`);
} catch (error) {
throw new Error(`Failed to auto-resolve ${filePath}: ${error}`);
if (resolution === "ours") {
resolveWithOurs(filePath, rootDir);
} else {
resolveWithTheirs(filePath, rootDir);
}
}
/**
* Auto-resolve all resolvable conflicts from the categorization.
* Returns the list of remaining complex conflicts that need AI resolution.
* @deprecated Use classifyConflict + resolveWithOurs/resolveWithTheirs instead.
*/
export function resolveConflicts(
categories: ConflictCategory[],
rootDir: string,
): string[] {
const remainingComplex: string[] = [];
for (const category of categories) {
if (category.autoResolvable && category.strategy) {
autoResolveFile(category.filePath, category.strategy, rootDir);
@@ -554,7 +439,6 @@ export function resolveConflicts(
remainingComplex.push(category.filePath);
}
}
return remainingComplex;
}
@@ -761,13 +645,18 @@ export async function aiMergeTask(
// 4b. Validate diff scope against task's declared File Scope
try {
const scopeResult = await validateDiffScope(store, taskId, diffStat);
const scopeResult = await validateDiffScope(store, taskId, diffStat, settings.strictScopeEnforcement);
for (const warning of scopeResult.warnings) {
mergerLog.warn(`${taskId}: ${warning}`);
await store.logEntry(taskId, warning);
}
} catch {
// Scope validation is best-effort — never block merge on validation failure
} catch (scopeError: any) {
if (settings.strictScopeEnforcement && scopeError.message?.includes("Scope enforcement failed")) {
// Strict mode — block the merge
await store.logEntry(taskId, `Merge blocked: ${scopeError.message}`);
throw scopeError;
}
// Soft mode — scope validation is best-effort
}
// 5. Execute merge with retry logic
@@ -814,11 +703,22 @@ export async function aiMergeTask(
return false;
} catch (error: any) {
// Check if it's a build verification failure - don't retry, propagate immediately
// Check if it's a build verification failure
if (error.message?.includes("Build verification failed")) {
throw error; // Fatal - don't retry build failures
const buildRetryCount = settings.buildRetryCount ?? 0;
if (buildRetryCount > 0 && !result._buildRetried) {
// Allow one build retry — reset merge state and re-attempt same strategy
mergerLog.log(`${taskId}: build failed, retrying (${buildRetryCount} retry allowed)...`);
await store.logEntry(taskId, "Build failed — retrying merge attempt");
result._buildRetried = true;
try {
execSync("git reset --merge", { cwd: rootDir, stdio: "pipe" });
} catch { /* ignore cleanup errors */ }
return false; // Retry
}
throw error; // No retries left — fatal
}
// Clean up on error before potentially rethrowing or retrying
if (attemptNum < 3 && smartConflictResolution) {
mergerLog.log(`${taskId}: attempt ${attemptNum} error, cleaning up for retry...`);