diff --git a/packages/engine/src/merger-file-scope.ts b/packages/engine/src/merger-file-scope.ts
new file mode 100644
index 0000000000..788ef35da4
--- /dev/null
+++ b/packages/engine/src/merger-file-scope.ts
@@ -0,0 +1,316 @@
+/**
+ * FNXC:CodeOrganization 2026-07-15-12:00:
+ * File-scope helpers, squash file-scope invariant, and diff-stat parsing peeled from merger.ts.
+ * Re-exported from merger.ts for stable public/test import paths.
+ */
+import type { Task, TaskStore } from "@fusion/core";
+import { promisify } from "node:util";
+import { exec } from "node:child_process";
+import { matchGlob } from "./merger-glob.js";
+import { mergerLog } from "./logger.js";
+import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js";
+import type { RunAuditor } from "./run-audit.js";
+
+const execAsync = promisify(exec);
+
+export interface DiffFileEntry {
+ file: string;
+ insertions: number;
+ deletions: number;
+}
+
+export interface DiffScopeResult {
+ warnings: string[];
+ outOfScopeFiles: string[];
+ largeOutOfScopeDeletions: { file: string; deletions: number }[];
+}
+
+/**
+ * Parse git `--stat` output into per-file insertion/deletion counts.
+ *
+ * Example line: ` packages/core/src/types.ts | 9 ++--`
+ * Binary line: ` some/image.png | Bin 0 -> 1234 bytes`
+ */
+export function parseDiffStat(diffStat: string): DiffFileEntry[] {
+ const entries: DiffFileEntry[] = [];
+ for (const line of diffStat.split("\n")) {
+ // Skip the summary line ("5 files changed, 10 insertions(+), 3 deletions(-)")
+ if (line.includes("files changed") || line.includes("file changed")) continue;
+ // Match: " path/to/file | 42 +++---" or " path/to/file | Bin ..."
+ const match = line.match(/^\s*(.+?)\s+\|\s+(\d+)\s+(\+*)(-*)\s*$/);
+ if (!match) continue;
+ const file = match[1].trim();
+ const plusses = match[3].length;
+ const minuses = match[4].length;
+ // The number is total changes; +/- chars show the ratio
+ const total = parseInt(match[2], 10);
+ if (total === 0) continue;
+ const ratio = plusses + minuses > 0 ? plusses / (plusses + minuses) : 0.5;
+ entries.push({
+ file,
+ insertions: Math.round(total * ratio),
+ deletions: Math.round(total * (1 - ratio)),
+ });
+ }
+ return entries;
+}
+
+/**
+ * Extract the `## File Scope` section from a PROMPT.md string.
+ * Returns an array of file/glob patterns (lines starting with `- \``).
+ */
+export function extractFileScope(promptContent: string): string[] {
+ const lines = promptContent.split("\n");
+ const patterns: string[] = [];
+ let inScope = false;
+ for (const line of lines) {
+ if (/^##\s+File Scope/.test(line)) {
+ inScope = true;
+ continue;
+ }
+ if (inScope && /^##\s/.test(line)) break; // next section
+ if (inScope) {
+ // Match "- `path/to/file`" or "- path/to/file"
+ const m = line.match(/^-\s+`?([^`\s]+)`?\s*(?:\(.*\))?\s*$/);
+ if (m) patterns.push(m[1]);
+ }
+ }
+ return patterns;
+}
+
+/**
+ * Check whether a file path matches any of the declared scope patterns.
+ * Reuses the existing `matchGlob` helper. Also matches if the file is
+ * inside a directory that's in scope (e.g., scope has `src/utils/*` and
+ * file is `src/utils/helpers.ts`).
+ */
+export function matchesScope(filePath: string, scopePatterns: string[]): boolean {
+ for (const pattern of scopePatterns) {
+ if (matchGlob(filePath, pattern)) return true;
+ // Directory match: if pattern ends with /* or /**, check prefix
+ const dirPattern = pattern.replace(/\/\*+$/, "");
+ if (dirPattern !== pattern && filePath.startsWith(dirPattern + "/")) return true;
+ // Exact directory match: scope says `src/foo/` and file is inside it
+ if (pattern.endsWith("/") && filePath.startsWith(pattern)) return true;
+ // Also match if both share the same directory
+ const patternDir = pattern.lastIndexOf("/") >= 0 ? pattern.slice(0, pattern.lastIndexOf("/")) : "";
+ const fileDir = filePath.lastIndexOf("/") >= 0 ? filePath.slice(0, filePath.lastIndexOf("/")) : "";
+ if (patternDir && fileDir === patternDir) return true;
+ }
+ return false;
+}
+
+export function partitionConflictsByFileScope(params: {
+ conflictFiles: string[];
+ declaredScope: string[];
+}): { inScope: string[]; outOfScope: string[] } {
+ const { conflictFiles, declaredScope } = params;
+ if (declaredScope.length === 0) {
+ return { inScope: [...conflictFiles], outOfScope: [] };
+ }
+
+ const inScope: string[] = [];
+ const outOfScope: string[] = [];
+ for (const file of conflictFiles) {
+ if (matchesScope(file, declaredScope)) {
+ inScope.push(file);
+ } else {
+ outOfScope.push(file);
+ }
+ }
+ return { inScope, outOfScope };
+}
+
+export class FileScopeViolationError extends Error {
+ taskId: string;
+ stagedFiles: string[];
+ declaredScope: string[];
+
+ constructor(taskId: string, stagedFiles: string[], declaredScope: string[]) {
+ const stagedList = stagedFiles.length > 0 ? stagedFiles.join(", ") : "";
+ const scopeList = declaredScope.join(", ");
+ super(
+ `File-scope invariant violation for ${taskId}: staged files [${stagedList}] have zero overlap with declared File Scope [${scopeList}]. Refile genuinely out-of-scope work as a follow-up task via fn_task_create before retrying this merge.`,
+ );
+ this.name = "FileScopeViolationError";
+ this.taskId = taskId;
+ this.stagedFiles = stagedFiles;
+ this.declaredScope = declaredScope;
+ }
+}
+
+export type StagedFilesReader = (cwd: string) => Promise;
+
+async function readStagedFileNames(cwd: string): Promise {
+ const { stdout } = await execAsync("git diff --cached --name-only", {
+ cwd,
+ encoding: "utf-8",
+ });
+ return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
+}
+
+export async function assertSquashOverlapsFileScope(params: {
+ store: TaskStore;
+ taskId: string;
+ rootDir: string;
+ task: Task;
+ /** Test seam for deterministic file-scope invariant coverage. Production
+ * callers use the default real-git staged-file reader. */
+ stagedFilesReader?: StagedFilesReader;
+ /** U7 (R10): when the merge trait's `fileScope: "custom"` mode is active,
+ * these glob/path rules replace the task's File Scope section as the
+ * declared scope. `scopeOverride` is a documented no-op only under
+ * `fileScope: "off"` (handled by the caller, which skips this assert). */
+ customScopeRules?: string[];
+}): Promise {
+ const { store, taskId, rootDir, task, customScopeRules, stagedFilesReader = readStagedFileNames } = params;
+ const hasCustomRules = Array.isArray(customScopeRules) && customScopeRules.length > 0;
+
+ if (!hasCustomRules && task.scopeOverride === true) {
+ const reasonSuffix = task.scopeOverrideReason?.trim()
+ ? ` — reason: ${task.scopeOverrideReason.trim()}`
+ : "";
+ await store.appendAgentLog(
+ taskId,
+ `file-scope invariant bypassed via scopeOverride${reasonSuffix}`,
+ "status",
+ undefined,
+ "merger",
+ );
+ return;
+ }
+
+ let declaredScope: string[];
+ if (hasCustomRules) {
+ // Custom rules replace the parsed File Scope section entirely.
+ declaredScope = customScopeRules;
+ } else {
+ if (typeof (store as Partial).parseFileScopeFromPrompt !== "function") {
+ return;
+ }
+ declaredScope = await store.parseFileScopeFromPrompt(taskId);
+ }
+ if (declaredScope.length === 0) {
+ return;
+ }
+
+ const stagedFiles = await stagedFilesReader(rootDir);
+ const hasOverlap = stagedFiles.some((file) => matchesScope(file, declaredScope));
+ if (!hasOverlap) {
+ throw new FileScopeViolationError(taskId, stagedFiles, declaredScope);
+ }
+}
+
+export function formatFileScopeViolationAgentLog(error: FileScopeViolationError): string {
+ const stagedFiles = error.stagedFiles.length > 0 ? error.stagedFiles.join("\n") : "";
+ return [
+ `taskId: ${error.taskId}`,
+ "declaredScope:",
+ ...error.declaredScope.map((entry) => `- ${entry}`),
+ "stagedFiles:",
+ ...stagedFiles.split("\n").map((entry) => `- ${entry}`),
+ ].join("\n");
+}
+
+export async function enforceSquashFileScopeInvariant(params: {
+ store: TaskStore;
+ taskId: string;
+ rootDir: string;
+ task: Task;
+ resetLabel: string;
+ stagedFilesReader?: StagedFilesReader;
+ auditor?: RunAuditor;
+}): Promise {
+ // U7 (R10): resolve the file-scope enforcement mode from the merge trait
+ // (flag ON) or settings (back-compat). The lost-work guard trio is NOT gated
+ // by this mode — it lives elsewhere in the mechanics and stays enforced for
+ // every mode (KTD-6).
+ const policy = await resolveMergePolicy(params.store, params.task);
+ const mode: MergeFileScopeMode = policy.fileScope;
+
+ if (mode === "off") {
+ // Skip the violation throw, but emit exactly one per-merge audit event
+ // recording that scope enforcement was disabled by workflow config. Per-task
+ // `scopeOverride` is a documented no-op in this mode (the scope check itself
+ // is disabled, so there is nothing to override).
+ if (params.auditor) {
+ try {
+ await params.auditor.git({
+ type: "merge:file-scope-enforcement-disabled",
+ target: params.taskId,
+ metadata: {
+ resetLabel: params.resetLabel,
+ mode: "off",
+ disabledByWorkflowConfig: true,
+ scopeOverrideIsNoOp: params.task.scopeOverride === true,
+ },
+ });
+ } catch (auditErr) {
+ mergerLog.warn(`${params.taskId}: failed to emit run_audit event for file-scope-enforcement-disabled: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
+ }
+ }
+ return;
+ }
+
+ const customScopeRules = mode === "custom" ? policy.fileScopeRules : undefined;
+
+ try {
+ await assertSquashOverlapsFileScope({ ...params, customScopeRules });
+ } catch (error: unknown) {
+ if (!(error instanceof FileScopeViolationError)) {
+ throw error;
+ }
+ // `strict` re-throws the violation (hard guardrail that blocks the merge);
+ // `warn`/`custom` log + proceed, with the audit carrying the violating file
+ // list (same payload as the error).
+ if (mode === "strict") {
+ if (params.auditor) {
+ try {
+ await params.auditor.git({
+ type: "merge:file-scope-violation",
+ target: params.taskId,
+ metadata: {
+ resetLabel: params.resetLabel,
+ mode: "strict",
+ stagedFiles: error.stagedFiles,
+ declaredScope: error.declaredScope,
+ stagedFileCount: error.stagedFiles.length,
+ declaredScopeCount: error.declaredScope.length,
+ warningOnly: false,
+ },
+ });
+ } catch (auditErr) {
+ mergerLog.warn(`${params.taskId}: failed to emit run_audit event for FileScopeViolationError (strict): ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
+ }
+ }
+ throw error;
+ }
+ const warningMessage = `${error.message} Warning only — continuing merge.`;
+ await params.store.appendAgentLog(
+ params.taskId,
+ warningMessage,
+ "status",
+ formatFileScopeViolationAgentLog(error),
+ "merger",
+ );
+ mergerLog.warn(`${params.taskId}: ${warningMessage}`);
+ if (params.auditor) {
+ try {
+ await params.auditor.git({
+ type: "merge:file-scope-violation",
+ target: params.taskId,
+ metadata: {
+ resetLabel: params.resetLabel,
+ stagedFiles: error.stagedFiles,
+ declaredScope: error.declaredScope,
+ stagedFileCount: error.stagedFiles.length,
+ declaredScopeCount: error.declaredScope.length,
+ warningOnly: true,
+ },
+ });
+ } catch (auditErr) {
+ mergerLog.warn(`${params.taskId}: failed to emit run_audit event for FileScopeViolationError: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/packages/engine/src/merger-glob.ts b/packages/engine/src/merger-glob.ts
new file mode 100644
index 0000000000..ba956d7516
--- /dev/null
+++ b/packages/engine/src/merger-glob.ts
@@ -0,0 +1,86 @@
+/**
+ * FNXC:CodeOrganization 2026-07-15-12:00:
+ * Conflict path patterns and simple glob matching peeled from merger.ts.
+ * Public path remains merger.ts via re-export for test import stability.
+ */
+/** Conflict type classification for merge conflict resolution */
+export type ConflictType =
+ | "lockfile-ours"
+ | "generated-theirs"
+ | "trivial-whitespace"
+ | "complex";
+
+/** Lock file patterns that should auto-resolve using "ours" (keep current branch's version) */
+export const LOCKFILE_PATTERNS = [
+ "package-lock.json",
+ "pnpm-lock.yaml",
+ "yarn.lock",
+ "Gemfile.lock",
+ "composer.lock",
+ "poetry.lock",
+ "bun.lockb",
+ "go.sum",
+];
+
+/** Generated file patterns that should auto-resolve using "theirs" (keep branch's fresh generation) */
+export const GENERATED_PATTERNS = [
+ "*.gen.ts",
+ "*.gen.js",
+ "*.min.js",
+ "*.min.css",
+ "dist/*",
+ "build/*",
+ "coverage/*",
+ ".next/*",
+ ".nuxt/*",
+ ".output/*",
+ ".cache/*",
+ "out/*",
+ "__generated__/*",
+ "generated/*",
+];
+
+/** Check if a path matches a glob pattern (simple glob support: * and **) */
+export function matchGlob(path: string, pattern: string): boolean {
+ // Handle ** which matches across directory boundaries (must do before single *)
+ if (pattern.includes("**")) {
+ // Convert ** to match any characters including /
+ const regexPattern = pattern
+ .replace(/\./g, "\\.")
+ .replace(/\*\*/g, "<<>>")
+ .replace(/\*/g, "[^/]*")
+ .replace(/<<>>/g, ".*");
+ const regex = new RegExp(`^${regexPattern}$`);
+ return regex.test(path);
+ }
+
+ // Handle patterns with single directory wildcards (e.g., "src/*.ts")
+ const lastSlash = pattern.lastIndexOf("/");
+ if (lastSlash !== -1) {
+ const patternDir = pattern.slice(0, lastSlash);
+ const patternFile = pattern.slice(lastSlash + 1);
+ const pathDir = path.lastIndexOf("/") !== -1 ? path.slice(0, path.lastIndexOf("/")) : "";
+ const pathFile = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/")) : path;
+
+ // Check if directories match
+ if (patternDir.includes("*")) {
+ const dirRegex = new RegExp(`^${patternDir.replace(/\./g, "\\.").replace(/\*/g, "[^/]*")}$`);
+ if (!dirRegex.test(pathDir)) return false;
+ } else if (!pathDir.endsWith(patternDir) && patternDir !== pathDir) {
+ return false;
+ }
+
+ // Match filename pattern
+ return matchGlob(pathFile, patternFile);
+ }
+
+ // Simple pattern without directory - match against filename only or full path
+ const fileName = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/") + 1) : path;
+
+ // Convert glob to regex
+ const regexPattern = pattern
+ .replace(/\./g, "\\.")
+ .replace(/\*/g, "[^/]*");
+ const regex = new RegExp(`^${regexPattern}$`);
+ return regex.test(fileName) || regex.test(path);
+}
diff --git a/packages/engine/src/merger-workspace-test-commands.ts b/packages/engine/src/merger-workspace-test-commands.ts
new file mode 100644
index 0000000000..c992315152
--- /dev/null
+++ b/packages/engine/src/merger-workspace-test-commands.ts
@@ -0,0 +1,485 @@
+/**
+ * FNXC:CodeOrganization 2026-07-15-12:00:
+ * Workspace package mapping and default test command inference peeled from merger.ts.
+ * Re-exported from merger.ts for stable public/test import paths.
+ */
+import { existsSync, readdirSync, readFileSync } from "node:fs";
+import { basename, dirname, join } from "node:path";
+import { execSync } from "node:child_process";
+import { mergerLog } from "./logger.js";
+
+/** Shell-safe single-argument quoting for command composition. */
+function quoteArg(value: string): string {
+ return `'${value.replace(/'/g, "'\\''")}'`;
+}
+
+/** Result of inferring a default test command */
+export interface InferredTestCommand {
+ command: string;
+ /** Source indicates whether this was explicitly configured or inferred from project files */
+ testSource: "explicit" | "inferred" | "inferred-scoped";
+ buildSource?: "explicit" | "inferred";
+}
+
+/**
+ * Parse a pnpm-workspace.yaml file and return the list of package glob patterns.
+ * Handles only the `packages:` list format used in pnpm workspace configs.
+ * Returns an empty array on any parse failure (best-effort).
+ *
+ * @internal Exported for testing only.
+ */
+export function parsePnpmWorkspaceGlobs(workspaceYamlContent: string): string[] {
+ const globs: string[] = [];
+ let inPackages = false;
+ for (const rawLine of workspaceYamlContent.split("\n")) {
+ const line = rawLine.trimEnd();
+ if (/^packages\s*:/.test(line)) {
+ inPackages = true;
+ continue;
+ }
+ if (inPackages) {
+ // A new top-level key ends the packages block
+ if (/^\S/.test(line) && line.trim() !== "") {
+ break;
+ }
+ // List item: " - 'some/glob'" or ` - "some/glob"` or ` - some/glob`
+ const match = line.match(/^\s+-\s+['"]?([^'"#\s]+)['"]?/);
+ if (match && match[1]) {
+ globs.push(match[1]);
+ }
+ }
+ }
+ return globs;
+}
+
+/**
+ * Given a list of workspace package globs (e.g. "packages/*") and a rootDir,
+ * return all package root directories (dirs that contain a package.json) that
+ * match at least one glob.
+ *
+ * Glob matching: only simple single-star patterns at the last path segment are
+ * supported (covering the `packages/*` and `plugins/examples/*` patterns used
+ * in practice). Literal paths (no glob) are treated as direct package roots.
+ *
+ * @internal Exported for testing only.
+ */
+export function resolveWorkspacePackageRoots(
+ rootDir: string,
+ globs: string[],
+): string[] {
+ const roots: string[] = [];
+ for (const glob of globs) {
+ const starIdx = glob.indexOf("*");
+ if (starIdx === -1) {
+ // Literal path — treat the glob itself as a package root
+ const candidate = join(rootDir, glob);
+ if (existsSync(join(candidate, "package.json"))) {
+ roots.push(glob); // Store relative to rootDir
+ }
+ continue;
+ }
+ // Pattern like "packages/*" or "plugins/examples/*"
+ // The prefix is everything before the last slash before the star
+ const prefix = glob.slice(0, starIdx);
+ const parentDir = join(rootDir, prefix.replace(/\/$/, ""));
+ let entries: string[];
+ try {
+ entries = readdirSync(parentDir, { withFileTypes: true })
+ .filter((e) => e.isDirectory())
+ .map((e) => e.name);
+ } catch {
+ continue;
+ }
+ for (const entry of entries) {
+ const relPath = `${prefix.replace(/\/$/, "")}/${entry}`;
+ const absPath = join(rootDir, relPath);
+ if (existsSync(join(absPath, "package.json"))) {
+ roots.push(relPath); // Store relative to rootDir
+ }
+ }
+ }
+ return roots;
+}
+
+/**
+ * Given a list of changed files (relative to rootDir) and a list of package
+ * root paths (relative to rootDir), return the unique package names (from each
+ * package.json's "name" field) whose root is the longest prefix-match for at
+ * least one changed file.
+ *
+ * @internal Exported for testing only.
+ */
+export function mapChangedFilesToPackageNames(
+ changedFiles: string[],
+ packageRoots: string[],
+ rootDir: string,
+): string[] {
+ const nameSet = new Set();
+ for (const file of changedFiles) {
+ // Find the longest package root that is a prefix of this file
+ let bestRoot: string | null = null;
+ let bestLen = -1;
+ for (const pkgRoot of packageRoots) {
+ const prefix = pkgRoot.endsWith("/") ? pkgRoot : `${pkgRoot}/`;
+ if (file === pkgRoot || file.startsWith(prefix)) {
+ if (pkgRoot.length > bestLen) {
+ bestLen = pkgRoot.length;
+ bestRoot = pkgRoot;
+ }
+ }
+ }
+ if (bestRoot !== null) {
+ // Read the package name from package.json
+ try {
+ const pkgJsonPath = join(rootDir, bestRoot, "package.json");
+ const raw = readFileSync(pkgJsonPath, "utf-8");
+ const parsed = JSON.parse(raw) as { name?: string };
+ if (parsed.name) {
+ nameSet.add(parsed.name);
+ }
+ } catch {
+ // If we can't read the package name, use the relative root path
+ nameSet.add(bestRoot);
+ }
+ }
+ }
+ return Array.from(nameSet);
+}
+
+/**
+ * Best-effort: map a list of repo-relative file paths to the pnpm package
+ * names they belong to. Returns an empty array if pnpm-workspace.yaml is
+ * missing or unparseable — callers fall back to a directory-based heuristic.
+ *
+ * @internal Exported for testing only.
+ */
+export function packageNamesForFiles(rootDir: string, files: string[]): string[] {
+ if (files.length === 0) return [];
+ let workspaceContent: string;
+ try {
+ workspaceContent = readFileSync(join(rootDir, "pnpm-workspace.yaml"), "utf-8");
+ } catch {
+ return [];
+ }
+ const globs = parsePnpmWorkspaceGlobs(workspaceContent);
+ if (globs.length === 0) return [];
+ const packageRoots = resolveWorkspacePackageRoots(rootDir, globs);
+ if (packageRoots.length === 0) return [];
+ return mapChangedFilesToPackageNames(files, packageRoots, rootDir);
+}
+
+/**
+ * Attempt to derive the set of pnpm package names touched by the branch.
+ * Returns null when scoping cannot be determined (missing git context, no
+ * workspace file, root-only changes, etc.) — callers fall back to `pnpm test`.
+ *
+ * @internal Exported for testing only.
+ */
+export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string, branch: string): string | null {
+ // 1. Read and parse pnpm-workspace.yaml
+ const workspacePath = join(rootDir, "pnpm-workspace.yaml");
+ let workspaceContent: string;
+ try {
+ workspaceContent = readFileSync(workspacePath, "utf-8");
+ } catch {
+ return null;
+ }
+ const globs = parsePnpmWorkspaceGlobs(workspaceContent);
+ if (globs.length === 0) return null;
+
+ // 2. Resolve actual package roots
+ const packageRoots = resolveWorkspacePackageRoots(rootDir, globs);
+ if (packageRoots.length === 0) return null;
+
+ // 3. Get the changed files between base and the branch tip passed by caller.
+ let changedFilesOutput: string;
+ try {
+ changedFilesOutput = execSync(
+ `git diff --name-only ${quoteArg(baseBranch)}...${quoteArg(branch)}`,
+ { cwd: rootDir, stdio: "pipe", encoding: "utf-8" },
+ ).toString();
+ } catch {
+ return null;
+ }
+ const changedFiles = changedFilesOutput
+ .split("\n")
+ .map((f) => f.trim())
+ .filter(Boolean);
+ if (changedFiles.length === 0) return null;
+
+ // 4. Map changed files to package names
+ const packageNames = mapChangedFilesToPackageNames(changedFiles, packageRoots, rootDir);
+ if (packageNames.length === 0) {
+ // All changes are at the root (e.g. workspace config) — fall back to full suite
+ return null;
+ }
+
+ // 5. Compose the scoped pnpm command
+ // `...^` includes dependents (packages that import the changed packages).
+ // Package names come from workspace package.json files (potentially
+ // untrusted) so we quote each filter argument via `quoteArg` to prevent
+ // shell interpolation if a name contains metacharacters.
+ const filters = packageNames.map((name) => `--filter ${quoteArg(`${name}...^`)}`).join(" ");
+ return `pnpm ${filters} test`;
+}
+
+/**
+ * Matches a Vitest/Jest-style test or spec file by extension.
+ * @internal
+ */
+const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
+
+/**
+ * Derive a verification command that runs ONLY the test files implicated by the
+ * branch diff, so merge verification scales with the change instead of the
+ * repository.
+ *
+ * For each file changed between `baseBranch` and `branch`:
+ * - A changed test/spec file (`*.test.ts` / `*.spec.tsx` / …) is run directly.
+ * - A changed source file resolves to its co-located test, if one exists on
+ * disk: `/__tests__/.test.{ts,tsx}` or the sibling
+ * `/.test.{ts,tsx}`.
+ * Resolved test files are grouped by their owning pnpm workspace package and run
+ * via `pnpm --filter exec vitest run --silent=passed-only
+ * --reporter=dot`. Multiple packages are joined with ` && `.
+ *
+ * Returns `null` when scoping can't be established (no workspace, no git
+ * context, or — importantly — when NO test files resolve from the diff). The
+ * caller treats `null` as "fall back to the broader command".
+ *
+ * FNXC:Verification 2026-06-25-00:00:
+ * Merge/executor verification must complete in seconds-to-<2min by running only
+ * the diff's own tests, not a whole-package or full-suite command. This relies
+ * on the thin, trusted merge gate (`pnpm test:gate`) to carry cross-cutting
+ * coverage; per-branch verification only needs to prove the branch's own tests
+ * still pass. When a diff touches source with no co-located test (or only
+ * non-source files), file-scoping yields nothing and we deliberately return
+ * null so the caller falls back to the existing package-scoped/explicit command
+ * rather than verifying nothing. Package names come from workspace package.json
+ * files and test paths come from `git diff`, so every shell argument is quoted
+ * via `quoteArg`.
+ *
+ * @internal Exported for testing only.
+ */
+export function deriveFileScopedPnpmTestCommand(
+ rootDir: string,
+ baseBranch: string,
+ branch: string,
+): string | null {
+ // 1. Read and parse pnpm-workspace.yaml + resolve package roots.
+ let workspaceContent: string;
+ try {
+ workspaceContent = readFileSync(join(rootDir, "pnpm-workspace.yaml"), "utf-8");
+ } catch {
+ return null;
+ }
+ const globs = parsePnpmWorkspaceGlobs(workspaceContent);
+ if (globs.length === 0) return null;
+ const packageRoots = resolveWorkspacePackageRoots(rootDir, globs);
+ if (packageRoots.length === 0) return null;
+
+ // 2. Get the changed files between base and the branch tip.
+ let changedFilesOutput: string;
+ try {
+ changedFilesOutput = execSync(
+ `git diff --name-only ${quoteArg(baseBranch)}...${quoteArg(branch)}`,
+ { cwd: rootDir, stdio: "pipe", encoding: "utf-8" },
+ ).toString();
+ } catch {
+ return null;
+ }
+ const changedFiles = changedFilesOutput
+ .split("\n")
+ .map((f) => f.trim())
+ .filter(Boolean);
+ if (changedFiles.length === 0) return null;
+
+ // 3. Resolve a set of repo-relative test files from the diff.
+ const resolvedTests = new Set();
+ for (const file of changedFiles) {
+ if (TEST_FILE_RE.test(file)) {
+ // A changed test/spec file is run directly.
+ resolvedTests.add(file);
+ continue;
+ }
+ // A changed source file maps to a co-located test if one exists on disk.
+ const dir = dirname(file);
+ const stem = basename(file).replace(/\.(ts|tsx|js|jsx)$/, "");
+ if (!stem) continue;
+ const candidates = [
+ `${dir}/__tests__/${stem}.test.ts`,
+ `${dir}/__tests__/${stem}.test.tsx`,
+ `${dir}/${stem}.test.ts`,
+ `${dir}/${stem}.test.tsx`,
+ ];
+ for (const candidate of candidates) {
+ // dirname("foo.ts") === "." → normalize the leading "./".
+ const normalized = candidate.startsWith("./") ? candidate.slice(2) : candidate;
+ if (existsSync(join(rootDir, normalized))) {
+ resolvedTests.add(normalized);
+ }
+ }
+ }
+ if (resolvedTests.size === 0) return null;
+
+ // 4. Group resolved test files by their owning workspace package.
+ const byPackage = new Map }>();
+ for (const testFile of resolvedTests) {
+ // Find the longest package root that is a prefix of this test file.
+ let bestRoot: string | null = null;
+ let bestLen = -1;
+ for (const pkgRoot of packageRoots) {
+ const prefix = pkgRoot.endsWith("/") ? pkgRoot : `${pkgRoot}/`;
+ if (testFile === pkgRoot || testFile.startsWith(prefix)) {
+ if (pkgRoot.length > bestLen) {
+ bestLen = pkgRoot.length;
+ bestRoot = pkgRoot;
+ }
+ }
+ }
+ if (bestRoot === null) continue;
+ const relPath = testFile.slice(bestRoot.length + 1);
+ if (!relPath) continue;
+ // Defensively skip any path quoting can't safely contain.
+ if (relPath.includes("\n") || relPath.includes("\0")) continue;
+ let entry = byPackage.get(bestRoot);
+ if (!entry) {
+ // Read the package name from package.json (fall back to the root path).
+ let name = bestRoot;
+ try {
+ const parsed = JSON.parse(
+ readFileSync(join(rootDir, bestRoot, "package.json"), "utf-8"),
+ ) as { name?: string };
+ if (parsed.name) name = parsed.name;
+ } catch {
+ // keep the relative root path as the filter
+ }
+ entry = { name, tests: new Set() };
+ byPackage.set(bestRoot, entry);
+ }
+ entry.tests.add(relPath);
+ }
+ if (byPackage.size === 0) return null;
+
+ // 5. Compose one scoped vitest invocation per package, joined with ` && `.
+ const segments: string[] = [];
+ for (const root of Array.from(byPackage.keys()).sort()) {
+ const entry = byPackage.get(root);
+ if (!entry) continue;
+ const quotedPaths = Array.from(entry.tests)
+ .sort()
+ .map((p) => quoteArg(p));
+ if (quotedPaths.length === 0) continue;
+ segments.push(
+ `pnpm --filter ${quoteArg(entry.name)} exec vitest run ${quotedPaths.join(" ")} --silent=passed-only --reporter=dot`,
+ );
+ }
+ if (segments.length === 0) return null;
+ return segments.join(" && ");
+}
+
+/**
+ * Infer a default test command based on project files.
+ * Returns the command and whether it was explicitly configured or inferred.
+ *
+ * Inference rules:
+ * - pnpm-lock.yaml → "pnpm test" (or scoped when monorepo + git context available)
+ * - yarn.lock → "yarn test"
+ * - bun.lock/bun.lockb → "bun test"
+ * - package-lock.json → "npm test"
+ *
+ * When a pnpm workspace is detected and git context (baseBranch + branch) is
+ * provided, the command is automatically scoped to the packages touched by the
+ * branch diff. testSource will be "inferred-scoped" in that case.
+ *
+ * FNXC:Verification 2026-06-25-00:00:
+ * When `scopeToChangedFiles` is true (project setting
+ * `scopeVerificationToChangedFiles`, default true) AND git context is present,
+ * verification is first narrowed to the diff's own test FILES via
+ * `deriveFileScopedPnpmTestCommand` — for BOTH explicit and inferred commands,
+ * so even a configured whole-package `testCommand` gets file-scoped. This keeps
+ * per-branch verification proportional to the change; cross-cutting coverage is
+ * owned by the thin merge gate. If file-scoping yields nothing (no resolvable
+ * tests) or the setting is off, the original behavior is preserved exactly:
+ * explicit command as-is, else package-scoped inference, else unscoped fallback.
+ *
+ * Returns null if no test command can be inferred.
+ */
+export function inferDefaultTestCommand(
+ rootDir: string,
+ explicitTestCommand?: string,
+ explicitBuildCommand?: string,
+ baseBranch?: string,
+ branch?: string,
+ scopeToChangedFiles?: boolean,
+): InferredTestCommand | null {
+ // File-scoped verification: try first for BOTH explicit and inferred cases.
+ // Only narrows when the setting is on, git context exists, and at least one
+ // test file resolves from the diff; otherwise falls through to existing logic.
+ if (scopeToChangedFiles && baseBranch?.trim() && branch?.trim()) {
+ try {
+ const fileScoped = deriveFileScopedPnpmTestCommand(rootDir, baseBranch.trim(), branch.trim());
+ if (fileScoped) {
+ mergerLog.log(`Scoped verification to changed test files: ${fileScoped}`);
+ const fileScopedBuildSource = explicitBuildCommand?.trim() ? "explicit" : undefined;
+ return { command: fileScoped, testSource: "inferred-scoped", buildSource: fileScopedBuildSource };
+ }
+ } catch {
+ // Fall through to existing explicit/inferred behavior.
+ }
+ }
+
+ // If explicit test command is set, use it (no inference needed)
+ if (explicitTestCommand?.trim()) {
+ return {
+ command: explicitTestCommand.trim(),
+ testSource: "explicit",
+ buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
+ };
+ }
+
+ const buildSource = explicitBuildCommand?.trim() ? "explicit" : undefined;
+
+ // Infer test command from lock files
+ if (existsSync(join(rootDir, "pnpm-lock.yaml"))) {
+ // Monorepo heuristic: if pnpm-workspace.yaml exists and we have git context,
+ // scope the command to only the packages touched by this branch's diff.
+ if (existsSync(join(rootDir, "pnpm-workspace.yaml"))) {
+ if (baseBranch?.trim() && branch?.trim()) {
+ try {
+ const scoped = deriveScopedPnpmTestCommand(rootDir, baseBranch.trim(), branch.trim());
+ if (scoped) {
+ mergerLog.log(
+ `Scoped inferred test command to changed packages: ${scoped}`,
+ );
+ return { command: scoped, testSource: "inferred-scoped", buildSource };
+ }
+ } catch {
+ // Fall through to unscoped fallback
+ }
+ }
+ // No git context or scoping failed — warn and use unscoped
+ mergerLog.warn(
+ `Inferred test command "pnpm test" in a pnpm workspace (${rootDir}). ` +
+ `This runs the full monorepo suite on every merge. Consider setting an explicit ` +
+ `scoped testCommand in project settings, e.g. \`pnpm -r --filter "...[main]" test\`.`,
+ );
+ }
+ return { command: "pnpm test", testSource: "inferred", buildSource };
+ }
+
+ if (existsSync(join(rootDir, "yarn.lock"))) {
+ return { command: "yarn test", testSource: "inferred", buildSource };
+ }
+
+ if (existsSync(join(rootDir, "bun.lock")) || existsSync(join(rootDir, "bun.lockb"))) {
+ return { command: "bun test", testSource: "inferred", buildSource };
+ }
+
+ if (existsSync(join(rootDir, "package-lock.json"))) {
+ return { command: "npm test", testSource: "inferred", buildSource };
+ }
+
+ // No inference possible — return null, letting the caller decide what to do
+ return null;
+}
diff --git a/packages/engine/src/merger.ts b/packages/engine/src/merger.ts
index 3bb62d5dc2..584d33acbf 100644
--- a/packages/engine/src/merger.ts
+++ b/packages/engine/src/merger.ts
@@ -51,9 +51,9 @@ export {
type VerificationResult,
} from "./verification-utils.js";
-import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
+import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
import { createHash } from "node:crypto";
-import { join, dirname, basename } from "node:path";
+import { join } from "node:path";
import {
computeLockfileHash,
getConfiguredWorktreeInitCommand,
@@ -106,7 +106,7 @@ import {
isMergeRequestContractShadowEnabled,
} from "@fusion/core";
import { evaluateAutoMergeFactProviders } from "./auto-merge-fact-providers.js";
-import { resolveMergePolicy, type MergeFileScopeMode } from "./merge-trait.js";
+import { resolveMergePolicy } from "./merge-trait.js";
import { describeModel, promptWithFallback } from "./pi.js";
import { accumulateSessionTokenUsage } from "./session-token-usage.js";
import { createResolvedAgentSession, extractRuntimeHint, resolveMergerSessionModel, resolveMergerThinkingLevel, resolveMergerFallbackThinkingLevel } from "./agent-session-helpers.js";
@@ -117,6 +117,54 @@ import { classifyTaskWorktree, getRegisteredWorktreeBranches, isRepoRootPath, Re
import { activeSessionRegistry } from "./active-session-registry.js";
import { AgentLogger } from "./agent-logger.js";
import { mergerLog } from "./logger.js";
+
+// FNXC:CodeOrganization 2026-07-15-12:00:
+// Domain satellites re-exported so existing merger.js import paths stay stable.
+export {
+ LOCKFILE_PATTERNS,
+ GENERATED_PATTERNS,
+ matchGlob,
+} from "./merger-glob.js";
+export type { ConflictType } from "./merger-glob.js";
+import { matchGlob, LOCKFILE_PATTERNS, GENERATED_PATTERNS } from "./merger-glob.js";
+import type { ConflictType } from "./merger-glob.js";
+
+export {
+ parsePnpmWorkspaceGlobs,
+ resolveWorkspacePackageRoots,
+ mapChangedFilesToPackageNames,
+ packageNamesForFiles,
+ deriveScopedPnpmTestCommand,
+ deriveFileScopedPnpmTestCommand,
+ inferDefaultTestCommand,
+} from "./merger-workspace-test-commands.js";
+export type { InferredTestCommand } from "./merger-workspace-test-commands.js";
+import {
+ packageNamesForFiles,
+ inferDefaultTestCommand,
+} from "./merger-workspace-test-commands.js";
+
+export {
+ parseDiffStat,
+ extractFileScope,
+ matchesScope,
+ partitionConflictsByFileScope,
+ FileScopeViolationError,
+ assertSquashOverlapsFileScope,
+ formatFileScopeViolationAgentLog,
+ enforceSquashFileScopeInvariant,
+} from "./merger-file-scope.js";
+export type { DiffFileEntry, DiffScopeResult, StagedFilesReader } from "./merger-file-scope.js";
+import {
+ parseDiffStat,
+ extractFileScope,
+ matchesScope,
+ partitionConflictsByFileScope,
+ FileScopeViolationError,
+ enforceSquashFileScopeInvariant,
+} from "./merger-file-scope.js";
+import type { DiffScopeResult } from "./merger-file-scope.js";
+
import { regenerateBareMergeSubject } from "./merger-bare-subject.js";
export { regenerateBareMergeSubject, BARE_MERGE_SUBJECT_RE } from "./merger-bare-subject.js";
import { isUsageLimitError, checkSessionError, type UsageLimitPauser } from "./usage-limit-detector.js";
@@ -298,42 +346,6 @@ export async function runMergeAdvanceAutoSync(input: {
}
}
-/** Conflict type classification for merge conflict resolution */
-export type ConflictType =
- | "lockfile-ours"
- | "generated-theirs"
- | "trivial-whitespace"
- | "complex";
-
-/** Lock file patterns that should auto-resolve using "ours" (keep current branch's version) */
-export const LOCKFILE_PATTERNS = [
- "package-lock.json",
- "pnpm-lock.yaml",
- "yarn.lock",
- "Gemfile.lock",
- "composer.lock",
- "poetry.lock",
- "bun.lockb",
- "go.sum",
-];
-
-/** Generated file patterns that should auto-resolve using "theirs" (keep branch's fresh generation) */
-export const GENERATED_PATTERNS = [
- "*.gen.ts",
- "*.gen.js",
- "*.min.js",
- "*.min.css",
- "dist/*",
- "build/*",
- "coverage/*",
- ".next/*",
- ".nuxt/*",
- ".output/*",
- ".cache/*",
- "out/*",
- "__generated__/*",
- "generated/*",
-];
const DEPENDENCY_SYNC_TRIGGER_PATTERNS = [
"package.json",
@@ -396,50 +408,6 @@ const MERGE_USER_COMMENTS_MAX_CHARS = 4000;
*/
export const summarizeVerificationOutputLocal = summarizeVerificationOutput;
-/** Check if a path matches a glob pattern (simple glob support: * and **) */
-export function matchGlob(path: string, pattern: string): boolean {
- // Handle ** which matches across directory boundaries (must do before single *)
- if (pattern.includes("**")) {
- // Convert ** to match any characters including /
- const regexPattern = pattern
- .replace(/\./g, "\\.")
- .replace(/\*\*/g, "<<>>")
- .replace(/\*/g, "[^/]*")
- .replace(/<<>>/g, ".*");
- const regex = new RegExp(`^${regexPattern}$`);
- return regex.test(path);
- }
-
- // Handle patterns with single directory wildcards (e.g., "src/*.ts")
- const lastSlash = pattern.lastIndexOf("/");
- if (lastSlash !== -1) {
- const patternDir = pattern.slice(0, lastSlash);
- const patternFile = pattern.slice(lastSlash + 1);
- const pathDir = path.lastIndexOf("/") !== -1 ? path.slice(0, path.lastIndexOf("/")) : "";
- const pathFile = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/")) : path;
-
- // Check if directories match
- if (patternDir.includes("*")) {
- const dirRegex = new RegExp(`^${patternDir.replace(/\./g, "\\.").replace(/\*/g, "[^/]*")}$`);
- if (!dirRegex.test(pathDir)) return false;
- } else if (!pathDir.endsWith(patternDir) && patternDir !== pathDir) {
- return false;
- }
-
- // Match filename pattern
- return matchGlob(pathFile, patternFile);
- }
-
- // Simple pattern without directory - match against filename only or full path
- const fileName = path.lastIndexOf("/") !== -1 ? path.slice(path.lastIndexOf("/") + 1) : path;
-
- // Convert glob to regex
- const regexPattern = pattern
- .replace(/\./g, "\\.")
- .replace(/\*/g, "[^/]*");
- const regex = new RegExp(`^${regexPattern}$`);
- return regex.test(fileName) || regex.test(path);
-}
interface DiffVolumeGateSettings {
minLines: number;
@@ -566,15 +534,6 @@ async function syncDependenciesForMerge(
});
}
-// ── Default test command inference ────────────────────────────────────
-
-/** Result of inferring a default test command */
-interface InferredTestCommand {
- command: string;
- /** Source indicates whether this was explicitly configured or inferred from project files */
- testSource: "explicit" | "inferred" | "inferred-scoped";
- buildSource?: "explicit" | "inferred";
-}
interface OwnedLandedCommit {
sha: string;
@@ -871,468 +830,6 @@ export async function classifyOwnedLandedEvidence(
};
}
-/**
- * Parse a pnpm-workspace.yaml file and return the list of package glob patterns.
- * Handles only the `packages:` list format used in pnpm workspace configs.
- * Returns an empty array on any parse failure (best-effort).
- *
- * @internal Exported for testing only.
- */
-export function parsePnpmWorkspaceGlobs(workspaceYamlContent: string): string[] {
- const globs: string[] = [];
- let inPackages = false;
- for (const rawLine of workspaceYamlContent.split("\n")) {
- const line = rawLine.trimEnd();
- if (/^packages\s*:/.test(line)) {
- inPackages = true;
- continue;
- }
- if (inPackages) {
- // A new top-level key ends the packages block
- if (/^\S/.test(line) && line.trim() !== "") {
- break;
- }
- // List item: " - 'some/glob'" or ` - "some/glob"` or ` - some/glob`
- const match = line.match(/^\s+-\s+['"]?([^'"#\s]+)['"]?/);
- if (match && match[1]) {
- globs.push(match[1]);
- }
- }
- }
- return globs;
-}
-
-/**
- * Given a list of workspace package globs (e.g. "packages/*") and a rootDir,
- * return all package root directories (dirs that contain a package.json) that
- * match at least one glob.
- *
- * Glob matching: only simple single-star patterns at the last path segment are
- * supported (covering the `packages/*` and `plugins/examples/*` patterns used
- * in practice). Literal paths (no glob) are treated as direct package roots.
- *
- * @internal Exported for testing only.
- */
-export function resolveWorkspacePackageRoots(
- rootDir: string,
- globs: string[],
-): string[] {
- const roots: string[] = [];
- for (const glob of globs) {
- const starIdx = glob.indexOf("*");
- if (starIdx === -1) {
- // Literal path — treat the glob itself as a package root
- const candidate = join(rootDir, glob);
- if (existsSync(join(candidate, "package.json"))) {
- roots.push(glob); // Store relative to rootDir
- }
- continue;
- }
- // Pattern like "packages/*" or "plugins/examples/*"
- // The prefix is everything before the last slash before the star
- const prefix = glob.slice(0, starIdx);
- const parentDir = join(rootDir, prefix.replace(/\/$/, ""));
- let entries: string[];
- try {
- entries = readdirSync(parentDir, { withFileTypes: true })
- .filter((e) => e.isDirectory())
- .map((e) => e.name);
- } catch {
- continue;
- }
- for (const entry of entries) {
- const relPath = `${prefix.replace(/\/$/, "")}/${entry}`;
- const absPath = join(rootDir, relPath);
- if (existsSync(join(absPath, "package.json"))) {
- roots.push(relPath); // Store relative to rootDir
- }
- }
- }
- return roots;
-}
-
-/**
- * Given a list of changed files (relative to rootDir) and a list of package
- * root paths (relative to rootDir), return the unique package names (from each
- * package.json's "name" field) whose root is the longest prefix-match for at
- * least one changed file.
- *
- * @internal Exported for testing only.
- */
-export function mapChangedFilesToPackageNames(
- changedFiles: string[],
- packageRoots: string[],
- rootDir: string,
-): string[] {
- const nameSet = new Set();
- for (const file of changedFiles) {
- // Find the longest package root that is a prefix of this file
- let bestRoot: string | null = null;
- let bestLen = -1;
- for (const pkgRoot of packageRoots) {
- const prefix = pkgRoot.endsWith("/") ? pkgRoot : `${pkgRoot}/`;
- if (file === pkgRoot || file.startsWith(prefix)) {
- if (pkgRoot.length > bestLen) {
- bestLen = pkgRoot.length;
- bestRoot = pkgRoot;
- }
- }
- }
- if (bestRoot !== null) {
- // Read the package name from package.json
- try {
- const pkgJsonPath = join(rootDir, bestRoot, "package.json");
- const raw = readFileSync(pkgJsonPath, "utf-8");
- const parsed = JSON.parse(raw) as { name?: string };
- if (parsed.name) {
- nameSet.add(parsed.name);
- }
- } catch {
- // If we can't read the package name, use the relative root path
- nameSet.add(bestRoot);
- }
- }
- }
- return Array.from(nameSet);
-}
-
-/**
- * Best-effort: map a list of repo-relative file paths to the pnpm package
- * names they belong to. Returns an empty array if pnpm-workspace.yaml is
- * missing or unparseable — callers fall back to a directory-based heuristic.
- *
- * @internal Exported for testing only.
- */
-export function packageNamesForFiles(rootDir: string, files: string[]): string[] {
- if (files.length === 0) return [];
- let workspaceContent: string;
- try {
- workspaceContent = readFileSync(join(rootDir, "pnpm-workspace.yaml"), "utf-8");
- } catch {
- return [];
- }
- const globs = parsePnpmWorkspaceGlobs(workspaceContent);
- if (globs.length === 0) return [];
- const packageRoots = resolveWorkspacePackageRoots(rootDir, globs);
- if (packageRoots.length === 0) return [];
- return mapChangedFilesToPackageNames(files, packageRoots, rootDir);
-}
-
-/**
- * Attempt to derive the set of pnpm package names touched by the branch.
- * Returns null when scoping cannot be determined (missing git context, no
- * workspace file, root-only changes, etc.) — callers fall back to `pnpm test`.
- *
- * @internal Exported for testing only.
- */
-export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string, branch: string): string | null {
- // 1. Read and parse pnpm-workspace.yaml
- const workspacePath = join(rootDir, "pnpm-workspace.yaml");
- let workspaceContent: string;
- try {
- workspaceContent = readFileSync(workspacePath, "utf-8");
- } catch {
- return null;
- }
- const globs = parsePnpmWorkspaceGlobs(workspaceContent);
- if (globs.length === 0) return null;
-
- // 2. Resolve actual package roots
- const packageRoots = resolveWorkspacePackageRoots(rootDir, globs);
- if (packageRoots.length === 0) return null;
-
- // 3. Get the changed files between base and the branch tip passed by caller.
- let changedFilesOutput: string;
- try {
- changedFilesOutput = execSync(
- `git diff --name-only ${quoteArg(baseBranch)}...${quoteArg(branch)}`,
- { cwd: rootDir, stdio: "pipe", encoding: "utf-8" },
- ).toString();
- } catch {
- return null;
- }
- const changedFiles = changedFilesOutput
- .split("\n")
- .map((f) => f.trim())
- .filter(Boolean);
- if (changedFiles.length === 0) return null;
-
- // 4. Map changed files to package names
- const packageNames = mapChangedFilesToPackageNames(changedFiles, packageRoots, rootDir);
- if (packageNames.length === 0) {
- // All changes are at the root (e.g. workspace config) — fall back to full suite
- return null;
- }
-
- // 5. Compose the scoped pnpm command
- // `...^` includes dependents (packages that import the changed packages).
- // Package names come from workspace package.json files (potentially
- // untrusted) so we quote each filter argument via `quoteArg` to prevent
- // shell interpolation if a name contains metacharacters.
- const filters = packageNames.map((name) => `--filter ${quoteArg(`${name}...^`)}`).join(" ");
- return `pnpm ${filters} test`;
-}
-
-/**
- * Matches a Vitest/Jest-style test or spec file by extension.
- * @internal
- */
-const TEST_FILE_RE = /\.(test|spec)\.(ts|tsx|js|jsx)$/;
-
-/**
- * Derive a verification command that runs ONLY the test files implicated by the
- * branch diff, so merge verification scales with the change instead of the
- * repository.
- *
- * For each file changed between `baseBranch` and `branch`:
- * - A changed test/spec file (`*.test.ts` / `*.spec.tsx` / …) is run directly.
- * - A changed source file resolves to its co-located test, if one exists on
- * disk: `/__tests__/.test.{ts,tsx}` or the sibling
- * `/.test.{ts,tsx}`.
- * Resolved test files are grouped by their owning pnpm workspace package and run
- * via `pnpm --filter exec vitest run --silent=passed-only
- * --reporter=dot`. Multiple packages are joined with ` && `.
- *
- * Returns `null` when scoping can't be established (no workspace, no git
- * context, or — importantly — when NO test files resolve from the diff). The
- * caller treats `null` as "fall back to the broader command".
- *
- * FNXC:Verification 2026-06-25-00:00:
- * Merge/executor verification must complete in seconds-to-<2min by running only
- * the diff's own tests, not a whole-package or full-suite command. This relies
- * on the thin, trusted merge gate (`pnpm test:gate`) to carry cross-cutting
- * coverage; per-branch verification only needs to prove the branch's own tests
- * still pass. When a diff touches source with no co-located test (or only
- * non-source files), file-scoping yields nothing and we deliberately return
- * null so the caller falls back to the existing package-scoped/explicit command
- * rather than verifying nothing. Package names come from workspace package.json
- * files and test paths come from `git diff`, so every shell argument is quoted
- * via `quoteArg`.
- *
- * @internal Exported for testing only.
- */
-export function deriveFileScopedPnpmTestCommand(
- rootDir: string,
- baseBranch: string,
- branch: string,
-): string | null {
- // 1. Read and parse pnpm-workspace.yaml + resolve package roots.
- let workspaceContent: string;
- try {
- workspaceContent = readFileSync(join(rootDir, "pnpm-workspace.yaml"), "utf-8");
- } catch {
- return null;
- }
- const globs = parsePnpmWorkspaceGlobs(workspaceContent);
- if (globs.length === 0) return null;
- const packageRoots = resolveWorkspacePackageRoots(rootDir, globs);
- if (packageRoots.length === 0) return null;
-
- // 2. Get the changed files between base and the branch tip.
- let changedFilesOutput: string;
- try {
- changedFilesOutput = execSync(
- `git diff --name-only ${quoteArg(baseBranch)}...${quoteArg(branch)}`,
- { cwd: rootDir, stdio: "pipe", encoding: "utf-8" },
- ).toString();
- } catch {
- return null;
- }
- const changedFiles = changedFilesOutput
- .split("\n")
- .map((f) => f.trim())
- .filter(Boolean);
- if (changedFiles.length === 0) return null;
-
- // 3. Resolve a set of repo-relative test files from the diff.
- const resolvedTests = new Set();
- for (const file of changedFiles) {
- if (TEST_FILE_RE.test(file)) {
- // A changed test/spec file is run directly.
- resolvedTests.add(file);
- continue;
- }
- // A changed source file maps to a co-located test if one exists on disk.
- const dir = dirname(file);
- const stem = basename(file).replace(/\.(ts|tsx|js|jsx)$/, "");
- if (!stem) continue;
- const candidates = [
- `${dir}/__tests__/${stem}.test.ts`,
- `${dir}/__tests__/${stem}.test.tsx`,
- `${dir}/${stem}.test.ts`,
- `${dir}/${stem}.test.tsx`,
- ];
- for (const candidate of candidates) {
- // dirname("foo.ts") === "." → normalize the leading "./".
- const normalized = candidate.startsWith("./") ? candidate.slice(2) : candidate;
- if (existsSync(join(rootDir, normalized))) {
- resolvedTests.add(normalized);
- }
- }
- }
- if (resolvedTests.size === 0) return null;
-
- // 4. Group resolved test files by their owning workspace package.
- const byPackage = new Map }>();
- for (const testFile of resolvedTests) {
- // Find the longest package root that is a prefix of this test file.
- let bestRoot: string | null = null;
- let bestLen = -1;
- for (const pkgRoot of packageRoots) {
- const prefix = pkgRoot.endsWith("/") ? pkgRoot : `${pkgRoot}/`;
- if (testFile === pkgRoot || testFile.startsWith(prefix)) {
- if (pkgRoot.length > bestLen) {
- bestLen = pkgRoot.length;
- bestRoot = pkgRoot;
- }
- }
- }
- if (bestRoot === null) continue;
- const relPath = testFile.slice(bestRoot.length + 1);
- if (!relPath) continue;
- // Defensively skip any path quoting can't safely contain.
- if (relPath.includes("\n") || relPath.includes("\0")) continue;
- let entry = byPackage.get(bestRoot);
- if (!entry) {
- // Read the package name from package.json (fall back to the root path).
- let name = bestRoot;
- try {
- const parsed = JSON.parse(
- readFileSync(join(rootDir, bestRoot, "package.json"), "utf-8"),
- ) as { name?: string };
- if (parsed.name) name = parsed.name;
- } catch {
- // keep the relative root path as the filter
- }
- entry = { name, tests: new Set() };
- byPackage.set(bestRoot, entry);
- }
- entry.tests.add(relPath);
- }
- if (byPackage.size === 0) return null;
-
- // 5. Compose one scoped vitest invocation per package, joined with ` && `.
- const segments: string[] = [];
- for (const root of Array.from(byPackage.keys()).sort()) {
- const entry = byPackage.get(root);
- if (!entry) continue;
- const quotedPaths = Array.from(entry.tests)
- .sort()
- .map((p) => quoteArg(p));
- if (quotedPaths.length === 0) continue;
- segments.push(
- `pnpm --filter ${quoteArg(entry.name)} exec vitest run ${quotedPaths.join(" ")} --silent=passed-only --reporter=dot`,
- );
- }
- if (segments.length === 0) return null;
- return segments.join(" && ");
-}
-
-/**
- * Infer a default test command based on project files.
- * Returns the command and whether it was explicitly configured or inferred.
- *
- * Inference rules:
- * - pnpm-lock.yaml → "pnpm test" (or scoped when monorepo + git context available)
- * - yarn.lock → "yarn test"
- * - bun.lock/bun.lockb → "bun test"
- * - package-lock.json → "npm test"
- *
- * When a pnpm workspace is detected and git context (baseBranch + branch) is
- * provided, the command is automatically scoped to the packages touched by the
- * branch diff. testSource will be "inferred-scoped" in that case.
- *
- * FNXC:Verification 2026-06-25-00:00:
- * When `scopeToChangedFiles` is true (project setting
- * `scopeVerificationToChangedFiles`, default true) AND git context is present,
- * verification is first narrowed to the diff's own test FILES via
- * `deriveFileScopedPnpmTestCommand` — for BOTH explicit and inferred commands,
- * so even a configured whole-package `testCommand` gets file-scoped. This keeps
- * per-branch verification proportional to the change; cross-cutting coverage is
- * owned by the thin merge gate. If file-scoping yields nothing (no resolvable
- * tests) or the setting is off, the original behavior is preserved exactly:
- * explicit command as-is, else package-scoped inference, else unscoped fallback.
- *
- * Returns null if no test command can be inferred.
- */
-export function inferDefaultTestCommand(
- rootDir: string,
- explicitTestCommand?: string,
- explicitBuildCommand?: string,
- baseBranch?: string,
- branch?: string,
- scopeToChangedFiles?: boolean,
-): InferredTestCommand | null {
- // File-scoped verification: try first for BOTH explicit and inferred cases.
- // Only narrows when the setting is on, git context exists, and at least one
- // test file resolves from the diff; otherwise falls through to existing logic.
- if (scopeToChangedFiles && baseBranch?.trim() && branch?.trim()) {
- try {
- const fileScoped = deriveFileScopedPnpmTestCommand(rootDir, baseBranch.trim(), branch.trim());
- if (fileScoped) {
- mergerLog.log(`Scoped verification to changed test files: ${fileScoped}`);
- const fileScopedBuildSource = explicitBuildCommand?.trim() ? "explicit" : undefined;
- return { command: fileScoped, testSource: "inferred-scoped", buildSource: fileScopedBuildSource };
- }
- } catch {
- // Fall through to existing explicit/inferred behavior.
- }
- }
-
- // If explicit test command is set, use it (no inference needed)
- if (explicitTestCommand?.trim()) {
- return {
- command: explicitTestCommand.trim(),
- testSource: "explicit",
- buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
- };
- }
-
- const buildSource = explicitBuildCommand?.trim() ? "explicit" : undefined;
-
- // Infer test command from lock files
- if (existsSync(join(rootDir, "pnpm-lock.yaml"))) {
- // Monorepo heuristic: if pnpm-workspace.yaml exists and we have git context,
- // scope the command to only the packages touched by this branch's diff.
- if (existsSync(join(rootDir, "pnpm-workspace.yaml"))) {
- if (baseBranch?.trim() && branch?.trim()) {
- try {
- const scoped = deriveScopedPnpmTestCommand(rootDir, baseBranch.trim(), branch.trim());
- if (scoped) {
- mergerLog.log(
- `Scoped inferred test command to changed packages: ${scoped}`,
- );
- return { command: scoped, testSource: "inferred-scoped", buildSource };
- }
- } catch {
- // Fall through to unscoped fallback
- }
- }
- // No git context or scoping failed — warn and use unscoped
- mergerLog.warn(
- `Inferred test command "pnpm test" in a pnpm workspace (${rootDir}). ` +
- `This runs the full monorepo suite on every merge. Consider setting an explicit ` +
- `scoped testCommand in project settings, e.g. \`pnpm -r --filter "...[main]" test\`.`,
- );
- }
- return { command: "pnpm test", testSource: "inferred", buildSource };
- }
-
- if (existsSync(join(rootDir, "yarn.lock"))) {
- return { command: "yarn test", testSource: "inferred", buildSource };
- }
-
- if (existsSync(join(rootDir, "bun.lock")) || existsSync(join(rootDir, "bun.lockb"))) {
- return { command: "bun test", testSource: "inferred", buildSource };
- }
-
- if (existsSync(join(rootDir, "package-lock.json"))) {
- return { command: "npm test", testSource: "inferred", buildSource };
- }
-
- // No inference possible — return null, letting the caller decide what to do
- return null;
-}
// ── Deterministic merge verification ──────────────────────────────────
@@ -4948,113 +4445,6 @@ export async function commitOrAmendMergeWithFixes(
// ── Pre-merge diffstat scope validation ──────────────────────────────
-interface DiffFileEntry {
- file: string;
- insertions: number;
- deletions: number;
-}
-
-interface DiffScopeResult {
- warnings: string[];
- outOfScopeFiles: string[];
- largeOutOfScopeDeletions: { file: string; deletions: number }[];
-}
-
-/**
- * Parse git `--stat` output into per-file insertion/deletion counts.
- *
- * Example line: ` packages/core/src/types.ts | 9 ++--`
- * Binary line: ` some/image.png | Bin 0 -> 1234 bytes`
- */
-export function parseDiffStat(diffStat: string): DiffFileEntry[] {
- const entries: DiffFileEntry[] = [];
- for (const line of diffStat.split("\n")) {
- // Skip the summary line ("5 files changed, 10 insertions(+), 3 deletions(-)")
- if (line.includes("files changed") || line.includes("file changed")) continue;
- // Match: " path/to/file | 42 +++---" or " path/to/file | Bin ..."
- const match = line.match(/^\s*(.+?)\s+\|\s+(\d+)\s+(\+*)(-*)\s*$/);
- if (!match) continue;
- const file = match[1].trim();
- const plusses = match[3].length;
- const minuses = match[4].length;
- // The number is total changes; +/- chars show the ratio
- const total = parseInt(match[2], 10);
- if (total === 0) continue;
- const ratio = plusses + minuses > 0 ? plusses / (plusses + minuses) : 0.5;
- entries.push({
- file,
- insertions: Math.round(total * ratio),
- deletions: Math.round(total * (1 - ratio)),
- });
- }
- return entries;
-}
-
-/**
- * Extract the `## File Scope` section from a PROMPT.md string.
- * Returns an array of file/glob patterns (lines starting with `- \``).
- */
-export function extractFileScope(promptContent: string): string[] {
- const lines = promptContent.split("\n");
- const patterns: string[] = [];
- let inScope = false;
- for (const line of lines) {
- if (/^##\s+File Scope/.test(line)) {
- inScope = true;
- continue;
- }
- if (inScope && /^##\s/.test(line)) break; // next section
- if (inScope) {
- // Match "- `path/to/file`" or "- path/to/file"
- const m = line.match(/^-\s+`?([^`\s]+)`?\s*(?:\(.*\))?\s*$/);
- if (m) patterns.push(m[1]);
- }
- }
- return patterns;
-}
-
-/**
- * Check whether a file path matches any of the declared scope patterns.
- * Reuses the existing `matchGlob` helper. Also matches if the file is
- * inside a directory that's in scope (e.g., scope has `src/utils/*` and
- * file is `src/utils/helpers.ts`).
- */
-export function matchesScope(filePath: string, scopePatterns: string[]): boolean {
- for (const pattern of scopePatterns) {
- if (matchGlob(filePath, pattern)) return true;
- // Directory match: if pattern ends with /* or /**, check prefix
- const dirPattern = pattern.replace(/\/\*+$/, "");
- if (dirPattern !== pattern && filePath.startsWith(dirPattern + "/")) return true;
- // Exact directory match: scope says `src/foo/` and file is inside it
- if (pattern.endsWith("/") && filePath.startsWith(pattern)) return true;
- // Also match if both share the same directory
- const patternDir = pattern.lastIndexOf("/") >= 0 ? pattern.slice(0, pattern.lastIndexOf("/")) : "";
- const fileDir = filePath.lastIndexOf("/") >= 0 ? filePath.slice(0, filePath.lastIndexOf("/")) : "";
- if (patternDir && fileDir === patternDir) return true;
- }
- return false;
-}
-
-export function partitionConflictsByFileScope(params: {
- conflictFiles: string[];
- declaredScope: string[];
-}): { inScope: string[]; outOfScope: string[] } {
- const { conflictFiles, declaredScope } = params;
- if (declaredScope.length === 0) {
- return { inScope: [...conflictFiles], outOfScope: [] };
- }
-
- const inScope: string[] = [];
- const outOfScope: string[] = [];
- for (const file of conflictFiles) {
- if (matchesScope(file, declaredScope)) {
- inScope.push(file);
- } else {
- outOfScope.push(file);
- }
- }
- return { inScope, outOfScope };
-}
export async function applyLayer3ConflictScopePartition(params: {
store: TaskStore;
@@ -5201,199 +4591,6 @@ export async function applyLayer3ConflictScopePartition(params: {
* just returning warnings (hard guardrail that blocks merge).
*/
-export class FileScopeViolationError extends Error {
- taskId: string;
- stagedFiles: string[];
- declaredScope: string[];
-
- constructor(taskId: string, stagedFiles: string[], declaredScope: string[]) {
- const stagedList = stagedFiles.length > 0 ? stagedFiles.join(", ") : "";
- const scopeList = declaredScope.join(", ");
- super(
- `File-scope invariant violation for ${taskId}: staged files [${stagedList}] have zero overlap with declared File Scope [${scopeList}]. Refile genuinely out-of-scope work as a follow-up task via fn_task_create before retrying this merge.`,
- );
- this.name = "FileScopeViolationError";
- this.taskId = taskId;
- this.stagedFiles = stagedFiles;
- this.declaredScope = declaredScope;
- }
-}
-
-export type StagedFilesReader = (cwd: string) => Promise;
-
-async function readStagedFileNames(cwd: string): Promise {
- const { stdout } = await execAsync("git diff --cached --name-only", {
- cwd,
- encoding: "utf-8",
- });
- return stdout.split("\n").map((line) => line.trim()).filter(Boolean);
-}
-
-export async function assertSquashOverlapsFileScope(params: {
- store: TaskStore;
- taskId: string;
- rootDir: string;
- task: Task;
- /** Test seam for deterministic file-scope invariant coverage. Production
- * callers use the default real-git staged-file reader. */
- stagedFilesReader?: StagedFilesReader;
- /** U7 (R10): when the merge trait's `fileScope: "custom"` mode is active,
- * these glob/path rules replace the task's File Scope section as the
- * declared scope. `scopeOverride` is a documented no-op only under
- * `fileScope: "off"` (handled by the caller, which skips this assert). */
- customScopeRules?: string[];
-}): Promise {
- const { store, taskId, rootDir, task, customScopeRules, stagedFilesReader = readStagedFileNames } = params;
- const hasCustomRules = Array.isArray(customScopeRules) && customScopeRules.length > 0;
-
- if (!hasCustomRules && task.scopeOverride === true) {
- const reasonSuffix = task.scopeOverrideReason?.trim()
- ? ` — reason: ${task.scopeOverrideReason.trim()}`
- : "";
- await store.appendAgentLog(
- taskId,
- `file-scope invariant bypassed via scopeOverride${reasonSuffix}`,
- "status",
- undefined,
- "merger",
- );
- return;
- }
-
- let declaredScope: string[];
- if (hasCustomRules) {
- // Custom rules replace the parsed File Scope section entirely.
- declaredScope = customScopeRules;
- } else {
- if (typeof (store as Partial).parseFileScopeFromPrompt !== "function") {
- return;
- }
- declaredScope = await store.parseFileScopeFromPrompt(taskId);
- }
- if (declaredScope.length === 0) {
- return;
- }
-
- const stagedFiles = await stagedFilesReader(rootDir);
- const hasOverlap = stagedFiles.some((file) => matchesScope(file, declaredScope));
- if (!hasOverlap) {
- throw new FileScopeViolationError(taskId, stagedFiles, declaredScope);
- }
-}
-
-export function formatFileScopeViolationAgentLog(error: FileScopeViolationError): string {
- const stagedFiles = error.stagedFiles.length > 0 ? error.stagedFiles.join("\n") : "";
- return [
- `taskId: ${error.taskId}`,
- "declaredScope:",
- ...error.declaredScope.map((entry) => `- ${entry}`),
- "stagedFiles:",
- ...stagedFiles.split("\n").map((entry) => `- ${entry}`),
- ].join("\n");
-}
-
-export async function enforceSquashFileScopeInvariant(params: {
- store: TaskStore;
- taskId: string;
- rootDir: string;
- task: Task;
- resetLabel: string;
- stagedFilesReader?: StagedFilesReader;
- auditor?: RunAuditor;
-}): Promise {
- // U7 (R10): resolve the file-scope enforcement mode from the merge trait
- // (flag ON) or settings (back-compat). The lost-work guard trio is NOT gated
- // by this mode — it lives elsewhere in the mechanics and stays enforced for
- // every mode (KTD-6).
- const policy = await resolveMergePolicy(params.store, params.task);
- const mode: MergeFileScopeMode = policy.fileScope;
-
- if (mode === "off") {
- // Skip the violation throw, but emit exactly one per-merge audit event
- // recording that scope enforcement was disabled by workflow config. Per-task
- // `scopeOverride` is a documented no-op in this mode (the scope check itself
- // is disabled, so there is nothing to override).
- if (params.auditor) {
- try {
- await params.auditor.git({
- type: "merge:file-scope-enforcement-disabled",
- target: params.taskId,
- metadata: {
- resetLabel: params.resetLabel,
- mode: "off",
- disabledByWorkflowConfig: true,
- scopeOverrideIsNoOp: params.task.scopeOverride === true,
- },
- });
- } catch (auditErr) {
- mergerLog.warn(`${params.taskId}: failed to emit run_audit event for file-scope-enforcement-disabled: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
- }
- }
- return;
- }
-
- const customScopeRules = mode === "custom" ? policy.fileScopeRules : undefined;
-
- try {
- await assertSquashOverlapsFileScope({ ...params, customScopeRules });
- } catch (error: unknown) {
- if (!(error instanceof FileScopeViolationError)) {
- throw error;
- }
- // `strict` re-throws the violation (hard guardrail that blocks the merge);
- // `warn`/`custom` log + proceed, with the audit carrying the violating file
- // list (same payload as the error).
- if (mode === "strict") {
- if (params.auditor) {
- try {
- await params.auditor.git({
- type: "merge:file-scope-violation",
- target: params.taskId,
- metadata: {
- resetLabel: params.resetLabel,
- mode: "strict",
- stagedFiles: error.stagedFiles,
- declaredScope: error.declaredScope,
- stagedFileCount: error.stagedFiles.length,
- declaredScopeCount: error.declaredScope.length,
- warningOnly: false,
- },
- });
- } catch (auditErr) {
- mergerLog.warn(`${params.taskId}: failed to emit run_audit event for FileScopeViolationError (strict): ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
- }
- }
- throw error;
- }
- const warningMessage = `${error.message} Warning only — continuing merge.`;
- await params.store.appendAgentLog(
- params.taskId,
- warningMessage,
- "status",
- formatFileScopeViolationAgentLog(error),
- "merger",
- );
- mergerLog.warn(`${params.taskId}: ${warningMessage}`);
- if (params.auditor) {
- try {
- await params.auditor.git({
- type: "merge:file-scope-violation",
- target: params.taskId,
- metadata: {
- resetLabel: params.resetLabel,
- stagedFiles: error.stagedFiles,
- declaredScope: error.declaredScope,
- stagedFileCount: error.stagedFiles.length,
- declaredScopeCount: error.declaredScope.length,
- warningOnly: true,
- },
- });
- } catch (auditErr) {
- mergerLog.warn(`${params.taskId}: failed to emit run_audit event for FileScopeViolationError: ${auditErr instanceof Error ? auditErr.message : String(auditErr)}`);
- }
- }
- }
-}
export async function validateDiffScope(
store: TaskStore,
diff --git a/scripts/line-count-baseline.json b/scripts/line-count-baseline.json
index 4cdc1d57d5..6ae9e89df2 100644
--- a/scripts/line-count-baseline.json
+++ b/scripts/line-count-baseline.json
@@ -92,7 +92,7 @@
"packages/engine/src/agent-tools.ts": 4793,
"packages/engine/src/executor.ts": 19107,
"packages/engine/src/merger-ai.ts": 2148,
- "packages/engine/src/merger.ts": 12487,
+ "packages/engine/src/merger.ts": 11809,
"packages/engine/src/pi.ts": 2799,
"packages/engine/src/project-engine.ts": 5051,
"packages/engine/src/scheduler.ts": 3370,