From 8c3758584bbeed2c930b81e08d66e4618b35aaa6 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Wed, 15 Jul 2026 14:44:35 -0700 Subject: [PATCH] refactor(engine): peel merger glob, test-command, and file-scope satellites (#2143) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Wave 2 of package code organization (follows merged #2139): peel pure/satellite clusters out of `packages/engine/src/merger.ts` while keeping `merger.js` import paths stable for tests and production. - **`merger-glob.ts`** — `ConflictType`, `LOCKFILE_PATTERNS`, `GENERATED_PATTERNS`, `matchGlob` - **`merger-workspace-test-commands.ts`** — pnpm workspace package mapping + `inferDefaultTestCommand` / scoped + file-scoped test command derivation - **`merger-file-scope.ts`** — `parseDiffStat`, `extractFileScope` / `matchesScope`, squash file-scope invariant (`FileScopeViolationError`, assert/enforce with main’s `status` audit semantics and merge-trait modes) - Re-exports from `merger.ts`; baseline ratchet for `merger.ts` (~12.5k → ~11.8k) ## Test plan - [x] `pnpm --filter @fusion/engine exec tsc --noEmit` - [x] eslint on merger + satellites - [x] `merger-file-scope-invariant.test.ts` (18 tests) - [x] focused conflict-resolution pattern tests - [ ] CI merge gate ## Plan Continues `docs/plans/2026-07-14-001-refactor-package-code-organization-plan.md` (U6 merger satellites). ## Summary by CodeRabbit * **New Features** * Added file-scope validation for merge operations, with strict and warning-only enforcement options. * Added automatic classification of lockfile and generated-file conflicts. * Added workspace-aware test selection based on changed packages and files. * Added support for inferring test commands across pnpm, Yarn, Bun, and npm projects. * **Refactor** * Organized merge, conflict-resolution, file-scope, and test-inference functionality into dedicated modules without changing existing public access paths. --- packages/engine/src/merger-file-scope.ts | 316 ++++++ packages/engine/src/merger-glob.ts | 86 ++ .../src/merger-workspace-test-commands.ts | 485 ++++++++++ packages/engine/src/merger.ts | 905 +----------------- scripts/line-count-baseline.json | 2 +- 5 files changed, 939 insertions(+), 855 deletions(-) create mode 100644 packages/engine/src/merger-file-scope.ts create mode 100644 packages/engine/src/merger-glob.ts create mode 100644 packages/engine/src/merger-workspace-test-commands.ts 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,