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:
316
packages/engine/src/merger-file-scope.ts
Normal file
316
packages/engine/src/merger-file-scope.ts
Normal 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)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
86
packages/engine/src/merger-glob.ts
Normal file
86
packages/engine/src/merger-glob.ts
Normal 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);
|
||||
}
|
||||
485
packages/engine/src/merger-workspace-test-commands.ts
Normal file
485
packages/engine/src/merger-workspace-test-commands.ts
Normal 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;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user