refactor(engine): peel merger glob, test-command, and file-scope satellites (#2143)

## 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).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## 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.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
gsxdsm
2026-07-15 14:44:35 -07:00
committed by GitHub
parent f9c19f9f3a
commit 8c3758584b
5 changed files with 939 additions and 855 deletions

View File

@@ -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(", ") : "<none outside .changeset/>";
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<string[]>;
async function readStagedFileNames(cwd: string): Promise<string[]> {
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<void> {
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<TaskStore>).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") : "<none>";
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<void> {
// 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)}`);
}
}
}
}

View File

@@ -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, "<<<DOUBLESTAR>>>")
.replace(/\*/g, "[^/]*")
.replace(/<<<DOUBLESTAR>>>/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);
}

View File

@@ -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<string>();
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: `<dir>/__tests__/<name>.test.{ts,tsx}` or the sibling
* `<dir>/<name>.test.{ts,tsx}`.
* Resolved test files are grouped by their owning pnpm workspace package and run
* via `pnpm --filter <pkg> exec vitest run <relPaths…> --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<string>();
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<string, { name: string; tests: Set<string> }>();
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<string>() };
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;
}

View File

@@ -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, "<<<DOUBLESTAR>>>")
.replace(/\*/g, "[^/]*")
.replace(/<<<DOUBLESTAR>>>/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<string>();
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: `<dir>/__tests__/<name>.test.{ts,tsx}` or the sibling
* `<dir>/<name>.test.{ts,tsx}`.
* Resolved test files are grouped by their owning pnpm workspace package and run
* via `pnpm --filter <pkg> exec vitest run <relPaths…> --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<string>();
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<string, { name: string; tests: Set<string> }>();
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<string>() };
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(", ") : "<none outside .changeset/>";
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<string[]>;
async function readStagedFileNames(cwd: string): Promise<string[]> {
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<void> {
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<TaskStore>).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") : "<none>";
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<void> {
// 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,

View File

@@ -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,