feat(merger): scope pnpm verification to changed packages in monorepo
When a pnpm workspace is detected and git context is available, inferDefaultTestCommand now derives the set of packages touched by the branch diff (git diff --name-only <base>...HEAD) and emits: pnpm --filter "<pkg>...^" test instead of the broad `pnpm test`. The `...^` suffix includes dependents so packages that import the changed one are also exercised. Falls back to unscoped `pnpm test` when git context is missing, the workspace has no package roots, or all changed files are at the root (e.g. config). New exports: parsePnpmWorkspaceGlobs, resolveWorkspacePackageRoots, mapChangedFilesToPackageNames, deriveScopedPnpmTestCommand. testSource is now "inferred-scoped" for the scoped path. Tests added: parsePnpmWorkspaceGlobs (7), resolveWorkspacePackageRoots (4), mapChangedFilesToPackageNames (4), inferDefaultTestCommand scoping (6). All 466 merger test files pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,7 @@ vi.mock("node:child_process", async () => {
|
|||||||
vi.mock("node:fs", () => ({
|
vi.mock("node:fs", () => ({
|
||||||
existsSync: vi.fn().mockReturnValue(true),
|
existsSync: vi.fn().mockReturnValue(true),
|
||||||
readFileSync: vi.fn(),
|
readFileSync: vi.fn(),
|
||||||
|
readdirSync: vi.fn().mockReturnValue([]),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("../rate-limit-retry.js", () => ({
|
vi.mock("../rate-limit-retry.js", () => ({
|
||||||
@@ -145,6 +146,10 @@ import {
|
|||||||
resolveTaskDiffBaseRef,
|
resolveTaskDiffBaseRef,
|
||||||
commitOrAmendMergeWithFixes,
|
commitOrAmendMergeWithFixes,
|
||||||
MergeAbortedError,
|
MergeAbortedError,
|
||||||
|
parsePnpmWorkspaceGlobs,
|
||||||
|
resolveWorkspacePackageRoots,
|
||||||
|
mapChangedFilesToPackageNames,
|
||||||
|
deriveScopedPnpmTestCommand,
|
||||||
type ConflictCategory,
|
type ConflictCategory,
|
||||||
} from "../merger.js";
|
} from "../merger.js";
|
||||||
import { mergerLog } from "../logger.js";
|
import { mergerLog } from "../logger.js";
|
||||||
@@ -156,9 +161,10 @@ import { type TaskStore, type Task, type MergeResult, DEFAULT_SETTINGS } from "@
|
|||||||
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
const mockedCreateFnAgent = vi.mocked(createFnAgent);
|
||||||
const mockedExecSync = vi.mocked(execSync);
|
const mockedExecSync = vi.mocked(execSync);
|
||||||
const mockedExec = vi.mocked(exec);
|
const mockedExec = vi.mocked(exec);
|
||||||
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw } = await import("node:fs");
|
const { existsSync: mockedExistsSyncRaw, readFileSync: mockedReadFileSyncRaw, readdirSync: mockedReaddirSyncRaw } = await import("node:fs");
|
||||||
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
const mockedExistsSync = vi.mocked(mockedExistsSyncRaw);
|
||||||
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
|
const mockedReadFileSync = vi.mocked(mockedReadFileSyncRaw);
|
||||||
|
const mockedReaddirSync = vi.mocked(mockedReaddirSyncRaw);
|
||||||
|
|
||||||
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
function createMockStore(taskOverrides: Partial<Task> = {}, allTasks: Task[] = []) {
|
||||||
const baseTask: Task = {
|
const baseTask: Task = {
|
||||||
@@ -2670,4 +2676,282 @@ describe("aiMergeTask — in-merge verification fix", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── parsePnpmWorkspaceGlobs ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("parsePnpmWorkspaceGlobs", () => {
|
||||||
|
it("parses a simple packages list", () => {
|
||||||
|
const content = `packages:\n - "packages/*"\n - "plugins/*"\n`;
|
||||||
|
expect(parsePnpmWorkspaceGlobs(content)).toEqual(["packages/*", "plugins/*"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles single-quoted entries", () => {
|
||||||
|
const content = `packages:\n - 'packages/*'\n`;
|
||||||
|
expect(parsePnpmWorkspaceGlobs(content)).toEqual(["packages/*"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles unquoted entries", () => {
|
||||||
|
const content = `packages:\n - packages/*\n`;
|
||||||
|
expect(parsePnpmWorkspaceGlobs(content)).toEqual(["packages/*"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops at next top-level key", () => {
|
||||||
|
const content = `packages:\n - packages/*\ncatalog:\n react: ^18\n`;
|
||||||
|
expect(parsePnpmWorkspaceGlobs(content)).toEqual(["packages/*"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for empty content", () => {
|
||||||
|
expect(parsePnpmWorkspaceGlobs("")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array when no packages key", () => {
|
||||||
|
expect(parsePnpmWorkspaceGlobs("name: root\n")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("parses literal (non-glob) package paths", () => {
|
||||||
|
const content = `packages:\n - "plugins/fusion-plugin-foo"\n`;
|
||||||
|
expect(parsePnpmWorkspaceGlobs(content)).toEqual(["plugins/fusion-plugin-foo"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── resolveWorkspacePackageRoots ──────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("resolveWorkspacePackageRoots", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("resolves glob pattern to subdirectories with package.json", () => {
|
||||||
|
mockedReaddirSync.mockReturnValue([
|
||||||
|
{ name: "dashboard", isDirectory: () => true },
|
||||||
|
{ name: "engine", isDirectory: () => true },
|
||||||
|
] as any);
|
||||||
|
mockedExistsSync.mockImplementation((p: any) => String(p).endsWith("package.json"));
|
||||||
|
|
||||||
|
const roots = resolveWorkspacePackageRoots("/repo", ["packages/*"]);
|
||||||
|
expect(roots).toEqual(["packages/dashboard", "packages/engine"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips directories without package.json", () => {
|
||||||
|
mockedReaddirSync.mockReturnValue([
|
||||||
|
{ name: "dashboard", isDirectory: () => true },
|
||||||
|
{ name: ".cache", isDirectory: () => true },
|
||||||
|
] as any);
|
||||||
|
mockedExistsSync.mockImplementation((p: any) => String(p).includes("dashboard/package.json"));
|
||||||
|
|
||||||
|
const roots = resolveWorkspacePackageRoots("/repo", ["packages/*"]);
|
||||||
|
expect(roots).toEqual(["packages/dashboard"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles literal (non-glob) paths", () => {
|
||||||
|
mockedExistsSync.mockImplementation((p: any) => String(p).endsWith("package.json"));
|
||||||
|
const roots = resolveWorkspacePackageRoots("/repo", ["plugins/fusion-plugin-foo"]);
|
||||||
|
expect(roots).toEqual(["plugins/fusion-plugin-foo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty when readdirSync throws", () => {
|
||||||
|
mockedReaddirSync.mockImplementation(() => { throw new Error("ENOENT"); });
|
||||||
|
const roots = resolveWorkspacePackageRoots("/repo", ["packages/*"]);
|
||||||
|
expect(roots).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── mapChangedFilesToPackageNames ─────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("mapChangedFilesToPackageNames", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps a changed file to the correct package name", () => {
|
||||||
|
mockedReadFileSync.mockReturnValue(JSON.stringify({ name: "@fusion/dashboard" }));
|
||||||
|
mockedExistsSync.mockReturnValue(true);
|
||||||
|
|
||||||
|
const names = mapChangedFilesToPackageNames(
|
||||||
|
["packages/dashboard/src/index.ts"],
|
||||||
|
["packages/dashboard", "packages/engine"],
|
||||||
|
"/repo",
|
||||||
|
);
|
||||||
|
expect(names).toEqual(["@fusion/dashboard"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("assigns to the longest matching prefix", () => {
|
||||||
|
mockedReadFileSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
if (path.includes("plugins/examples/foo")) return JSON.stringify({ name: "@fusion/example-foo" });
|
||||||
|
if (path.includes("plugins")) return JSON.stringify({ name: "@fusion/plugins" });
|
||||||
|
return JSON.stringify({ name: "unknown" });
|
||||||
|
});
|
||||||
|
mockedExistsSync.mockReturnValue(true);
|
||||||
|
|
||||||
|
const names = mapChangedFilesToPackageNames(
|
||||||
|
["plugins/examples/foo/index.ts"],
|
||||||
|
["plugins", "plugins/examples/foo"],
|
||||||
|
"/repo",
|
||||||
|
);
|
||||||
|
expect(names).toEqual(["@fusion/example-foo"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns empty array for files not in any package root", () => {
|
||||||
|
const names = mapChangedFilesToPackageNames(
|
||||||
|
["root-file.ts"],
|
||||||
|
["packages/dashboard"],
|
||||||
|
"/repo",
|
||||||
|
);
|
||||||
|
expect(names).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates package names when multiple files touch same package", () => {
|
||||||
|
mockedReadFileSync.mockReturnValue(JSON.stringify({ name: "@fusion/dashboard" }));
|
||||||
|
mockedExistsSync.mockReturnValue(true);
|
||||||
|
|
||||||
|
const names = mapChangedFilesToPackageNames(
|
||||||
|
["packages/dashboard/a.ts", "packages/dashboard/b.ts"],
|
||||||
|
["packages/dashboard"],
|
||||||
|
"/repo",
|
||||||
|
);
|
||||||
|
expect(names).toEqual(["@fusion/dashboard"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── inferDefaultTestCommand — scoped pnpm workspace ──────────────────────
|
||||||
|
|
||||||
|
describe("inferDefaultTestCommand — pnpm workspace scoping", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedExistsSync.mockReturnValue(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns pnpm test (unscoped/inferred) when no git context provided", () => {
|
||||||
|
mockedExistsSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
return path.includes("pnpm-lock.yaml") || path.includes("pnpm-workspace.yaml");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = inferDefaultTestCommand("/tmp/root");
|
||||||
|
expect(result?.command).toBe("pnpm test");
|
||||||
|
expect(result?.testSource).toBe("inferred");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns scoped command (inferred-scoped) when monorepo + git context + 1 changed package", () => {
|
||||||
|
mockedExistsSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
// workspace files exist; package.json for dashboard exists
|
||||||
|
if (path.includes("pnpm-lock.yaml")) return true;
|
||||||
|
if (path.includes("pnpm-workspace.yaml")) return true;
|
||||||
|
if (path.includes("package.json")) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
mockedReadFileSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
if (path.includes("pnpm-workspace.yaml")) {
|
||||||
|
return `packages:\n - "packages/*"\n`;
|
||||||
|
}
|
||||||
|
if (path.includes("dashboard/package.json")) {
|
||||||
|
return JSON.stringify({ name: "@fusion/dashboard" });
|
||||||
|
}
|
||||||
|
return JSON.stringify({ name: "unknown" });
|
||||||
|
});
|
||||||
|
mockedReaddirSync.mockReturnValue([
|
||||||
|
{ name: "dashboard", isDirectory: () => true },
|
||||||
|
] as any);
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("git diff --name-only")) {
|
||||||
|
return "packages/dashboard/src/index.ts\n";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = inferDefaultTestCommand("/tmp/root", undefined, undefined, "main", "fusion/fn-123");
|
||||||
|
expect(result?.command).toBe(`pnpm --filter "@fusion/dashboard...^" test`);
|
||||||
|
expect(result?.testSource).toBe("inferred-scoped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns command with 2 filters when 2 packages are changed", () => {
|
||||||
|
mockedExistsSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
if (path.includes("pnpm-lock.yaml")) return true;
|
||||||
|
if (path.includes("pnpm-workspace.yaml")) return true;
|
||||||
|
if (path.includes("package.json")) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
mockedReadFileSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
if (path.includes("pnpm-workspace.yaml")) return `packages:\n - "packages/*"\n`;
|
||||||
|
if (path.includes("dashboard/package.json")) return JSON.stringify({ name: "@fusion/dashboard" });
|
||||||
|
if (path.includes("engine/package.json")) return JSON.stringify({ name: "@fusion/engine" });
|
||||||
|
return JSON.stringify({ name: "unknown" });
|
||||||
|
});
|
||||||
|
mockedReaddirSync.mockReturnValue([
|
||||||
|
{ name: "dashboard", isDirectory: () => true },
|
||||||
|
{ name: "engine", isDirectory: () => true },
|
||||||
|
] as any);
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("git diff --name-only")) {
|
||||||
|
return "packages/dashboard/src/index.ts\npackages/engine/src/merger.ts\n";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = inferDefaultTestCommand("/tmp/root", undefined, undefined, "main", "fusion/fn-123");
|
||||||
|
expect(result?.command).toContain("--filter");
|
||||||
|
expect(result?.command).toContain("@fusion/dashboard");
|
||||||
|
expect(result?.command).toContain("@fusion/engine");
|
||||||
|
expect(result?.testSource).toBe("inferred-scoped");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to pnpm test (inferred) when all changes are root-only files", () => {
|
||||||
|
mockedExistsSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
if (path.includes("pnpm-lock.yaml")) return true;
|
||||||
|
if (path.includes("pnpm-workspace.yaml")) return true;
|
||||||
|
if (path.includes("package.json")) return true;
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
mockedReadFileSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
if (path.includes("pnpm-workspace.yaml")) return `packages:\n - "packages/*"\n`;
|
||||||
|
return JSON.stringify({ name: "unknown" });
|
||||||
|
});
|
||||||
|
mockedReaddirSync.mockReturnValue([
|
||||||
|
{ name: "dashboard", isDirectory: () => true },
|
||||||
|
] as any);
|
||||||
|
// Changed file is at root (pnpm-workspace.yaml) — no package prefix match
|
||||||
|
mockedExecSync.mockImplementation((cmd: any) => {
|
||||||
|
const cmdStr = String(cmd);
|
||||||
|
if (cmdStr.includes("git diff --name-only")) {
|
||||||
|
return "pnpm-workspace.yaml\n";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = inferDefaultTestCommand("/tmp/root", undefined, undefined, "main", "fusion/fn-123");
|
||||||
|
expect(result?.command).toBe("pnpm test");
|
||||||
|
expect(result?.testSource).toBe("inferred");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to pnpm test (inferred) when no git context", () => {
|
||||||
|
mockedExistsSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
return path.includes("pnpm-lock.yaml") || path.includes("pnpm-workspace.yaml");
|
||||||
|
});
|
||||||
|
|
||||||
|
// No baseBranch or branch passed
|
||||||
|
const result = inferDefaultTestCommand("/tmp/root", undefined, undefined, undefined, undefined);
|
||||||
|
expect(result?.command).toBe("pnpm test");
|
||||||
|
expect(result?.testSource).toBe("inferred");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("explicit testCommand always wins over scoping", () => {
|
||||||
|
mockedExistsSync.mockImplementation((p: any) => {
|
||||||
|
const path = String(p);
|
||||||
|
return path.includes("pnpm-lock.yaml") || path.includes("pnpm-workspace.yaml");
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = inferDefaultTestCommand("/tmp/root", "vitest run --reporter=verbose", undefined, "main", "fusion/fn-123");
|
||||||
|
expect(result?.command).toBe("vitest run --reporter=verbose");
|
||||||
|
expect(result?.testSource).toBe("explicit");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export {
|
|||||||
type VerificationResult,
|
type VerificationResult,
|
||||||
} from "./verification-utils.js";
|
} from "./verification-utils.js";
|
||||||
|
|
||||||
import { existsSync, readFileSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
|
import { existsSync, readFileSync, readdirSync, writeFileSync, unlinkSync, renameSync } from "node:fs";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { resolveTaskWorktreePath } from "./worktree-paths.js";
|
import { resolveTaskWorktreePath } from "./worktree-paths.js";
|
||||||
@@ -461,7 +461,7 @@ async function syncDependenciesForMerge(
|
|||||||
interface InferredTestCommand {
|
interface InferredTestCommand {
|
||||||
command: string;
|
command: string;
|
||||||
/** Source indicates whether this was explicitly configured or inferred from project files */
|
/** Source indicates whether this was explicitly configured or inferred from project files */
|
||||||
testSource: "explicit" | "inferred";
|
testSource: "explicit" | "inferred" | "inferred-scoped";
|
||||||
buildSource?: "explicit" | "inferred";
|
buildSource?: "explicit" | "inferred";
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -745,22 +745,209 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 branch tip
|
||||||
|
let changedFilesOutput: string;
|
||||||
|
try {
|
||||||
|
changedFilesOutput = execSync(
|
||||||
|
`git diff --name-only ${quoteArg(baseBranch)}...HEAD`,
|
||||||
|
{ 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)
|
||||||
|
const filters = packageNames.map((name) => `--filter "${name}...^"`).join(" ");
|
||||||
|
return `pnpm ${filters} test`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Infer a default test command based on project files.
|
* Infer a default test command based on project files.
|
||||||
* Returns the command and whether it was explicitly configured or inferred.
|
* Returns the command and whether it was explicitly configured or inferred.
|
||||||
*
|
*
|
||||||
* Inference rules:
|
* Inference rules:
|
||||||
* - pnpm-lock.yaml → "pnpm test"
|
* - pnpm-lock.yaml → "pnpm test" (or scoped when monorepo + git context available)
|
||||||
* - yarn.lock → "yarn test"
|
* - yarn.lock → "yarn test"
|
||||||
* - bun.lock/bun.lockb → "bun test"
|
* - bun.lock/bun.lockb → "bun test"
|
||||||
* - package-lock.json → "npm 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.
|
||||||
|
*
|
||||||
* Returns null if no test command can be inferred.
|
* Returns null if no test command can be inferred.
|
||||||
*/
|
*/
|
||||||
export function inferDefaultTestCommand(
|
export function inferDefaultTestCommand(
|
||||||
rootDir: string,
|
rootDir: string,
|
||||||
explicitTestCommand?: string,
|
explicitTestCommand?: string,
|
||||||
explicitBuildCommand?: string,
|
explicitBuildCommand?: string,
|
||||||
|
baseBranch?: string,
|
||||||
|
branch?: string,
|
||||||
): InferredTestCommand | null {
|
): InferredTestCommand | null {
|
||||||
// If explicit test command is set, use it (no inference needed)
|
// If explicit test command is set, use it (no inference needed)
|
||||||
if (explicitTestCommand?.trim()) {
|
if (explicitTestCommand?.trim()) {
|
||||||
@@ -771,49 +958,46 @@ export function inferDefaultTestCommand(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const buildSource = explicitBuildCommand?.trim() ? "explicit" : undefined;
|
||||||
|
|
||||||
// Infer test command from lock files
|
// Infer test command from lock files
|
||||||
if (existsSync(join(rootDir, "pnpm-lock.yaml"))) {
|
if (existsSync(join(rootDir, "pnpm-lock.yaml"))) {
|
||||||
// Monorepo heuristic: a pnpm-workspace.yaml means `pnpm test` will fan out
|
// Monorepo heuristic: if pnpm-workspace.yaml exists and we have git context,
|
||||||
// across every workspace package on every merge, which is usually far slower
|
// scope the command to only the packages touched by this branch's diff.
|
||||||
// than necessary. Warn so the user sets an explicit scoped testCommand
|
|
||||||
// (e.g. `pnpm -r --filter "...[main]" test`). We don't auto-scope because
|
|
||||||
// the default branch name isn't guaranteed and git context may be unavailable.
|
|
||||||
if (existsSync(join(rootDir, "pnpm-workspace.yaml"))) {
|
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(
|
mergerLog.warn(
|
||||||
`Inferred test command "pnpm test" in a pnpm workspace (${rootDir}). ` +
|
`Inferred test command "pnpm test" in a pnpm workspace (${rootDir}). ` +
|
||||||
`This runs the full monorepo suite on every merge. Consider setting an explicit ` +
|
`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\`.`,
|
`scoped testCommand in project settings, e.g. \`pnpm -r --filter "...[main]" test\`.`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return {
|
return { command: "pnpm test", testSource: "inferred", buildSource };
|
||||||
command: "pnpm test",
|
|
||||||
testSource: "inferred",
|
|
||||||
buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(join(rootDir, "yarn.lock"))) {
|
if (existsSync(join(rootDir, "yarn.lock"))) {
|
||||||
return {
|
return { command: "yarn test", testSource: "inferred", buildSource };
|
||||||
command: "yarn test",
|
|
||||||
testSource: "inferred",
|
|
||||||
buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(join(rootDir, "bun.lock")) || existsSync(join(rootDir, "bun.lockb"))) {
|
if (existsSync(join(rootDir, "bun.lock")) || existsSync(join(rootDir, "bun.lockb"))) {
|
||||||
return {
|
return { command: "bun test", testSource: "inferred", buildSource };
|
||||||
command: "bun test",
|
|
||||||
testSource: "inferred",
|
|
||||||
buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (existsSync(join(rootDir, "package-lock.json"))) {
|
if (existsSync(join(rootDir, "package-lock.json"))) {
|
||||||
return {
|
return { command: "npm test", testSource: "inferred", buildSource };
|
||||||
command: "npm test",
|
|
||||||
testSource: "inferred",
|
|
||||||
buildSource: explicitBuildCommand?.trim() ? "explicit" : undefined,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// No inference possible — return null, letting the caller decide what to do
|
// No inference possible — return null, letting the caller decide what to do
|
||||||
@@ -974,7 +1158,7 @@ async function runDeterministicVerification(
|
|||||||
taskId: string,
|
taskId: string,
|
||||||
testCommand?: string,
|
testCommand?: string,
|
||||||
buildCommand?: string,
|
buildCommand?: string,
|
||||||
testSource?: "explicit" | "inferred",
|
testSource?: "explicit" | "inferred" | "inferred-scoped",
|
||||||
buildSource?: "explicit" | "inferred",
|
buildSource?: "explicit" | "inferred",
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<VerificationResult> {
|
): Promise<VerificationResult> {
|
||||||
@@ -1027,7 +1211,7 @@ async function runDeterministicVerification(
|
|||||||
// ── End cache lookup ───────────────────────────────────────────────────
|
// ── End cache lookup ───────────────────────────────────────────────────
|
||||||
|
|
||||||
// Build source indicator for logging
|
// Build source indicator for logging
|
||||||
const testSourceLabel = testSource === "inferred" ? " [inferred]" : "";
|
const testSourceLabel = (testSource === "inferred" || testSource === "inferred-scoped") ? ` [${testSource}]` : "";
|
||||||
const buildSourceLabel = buildSource === "inferred" ? " [inferred]" : "";
|
const buildSourceLabel = buildSource === "inferred" ? " [inferred]" : "";
|
||||||
|
|
||||||
mergerLog.log(
|
mergerLog.log(
|
||||||
@@ -1035,9 +1219,10 @@ async function runDeterministicVerification(
|
|||||||
(hasTestCommand ? ` [test:${testSourceLabel} ${normalizedTestCommand}]` : "") +
|
(hasTestCommand ? ` [test:${testSourceLabel} ${normalizedTestCommand}]` : "") +
|
||||||
(hasBuildCommand ? ` [build:${buildSourceLabel} ${normalizedBuildCommand}]` : ""),
|
(hasBuildCommand ? ` [build:${buildSourceLabel} ${normalizedBuildCommand}]` : ""),
|
||||||
);
|
);
|
||||||
|
const testSourceDisplayLabel = (testSource === "inferred" || testSource === "inferred-scoped") ? ` [${testSource}]` : "";
|
||||||
const deterministicVerificationMessage =
|
const deterministicVerificationMessage =
|
||||||
"Running deterministic merge verification" +
|
"Running deterministic merge verification" +
|
||||||
(hasTestCommand ? ` (test${testSource === "inferred" ? " [inferred]" : ""}: ${normalizedTestCommand})` : "") +
|
(hasTestCommand ? ` (test${testSourceDisplayLabel}: ${normalizedTestCommand})` : "") +
|
||||||
(hasBuildCommand ? ` (build${buildSource === "inferred" ? " [inferred]" : ""}: ${normalizedBuildCommand})` : "");
|
(hasBuildCommand ? ` (build${buildSource === "inferred" ? " [inferred]" : ""}: ${normalizedBuildCommand})` : "");
|
||||||
await store.logEntry(taskId, deterministicVerificationMessage);
|
await store.logEntry(taskId, deterministicVerificationMessage);
|
||||||
await store.appendAgentLog(taskId, deterministicVerificationMessage, "text", undefined, "merger");
|
await store.appendAgentLog(taskId, deterministicVerificationMessage, "text", undefined, "merger");
|
||||||
@@ -1283,7 +1468,7 @@ async function attemptInMergeVerificationFix(
|
|||||||
fixAttemptNumber?: number,
|
fixAttemptNumber?: number,
|
||||||
testCommand?: string,
|
testCommand?: string,
|
||||||
buildCommand?: string,
|
buildCommand?: string,
|
||||||
testSource?: "explicit" | "inferred",
|
testSource?: "explicit" | "inferred" | "inferred-scoped",
|
||||||
buildSource?: "explicit" | "inferred",
|
buildSource?: "explicit" | "inferred",
|
||||||
fixModifiedFiles?: Set<string>,
|
fixModifiedFiles?: Set<string>,
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@@ -5019,7 +5204,7 @@ async function applyBranchCommitsPreservingHistory(params: {
|
|||||||
result: MergeResult;
|
result: MergeResult;
|
||||||
testCommand?: string;
|
testCommand?: string;
|
||||||
buildCommand?: string;
|
buildCommand?: string;
|
||||||
testSource?: "explicit" | "inferred";
|
testSource?: "explicit" | "inferred" | "inferred-scoped";
|
||||||
buildSource?: "explicit" | "inferred";
|
buildSource?: "explicit" | "inferred";
|
||||||
signal?: AbortSignal;
|
signal?: AbortSignal;
|
||||||
}): Promise<{ landedCommitCount: number; landedCommitShas: string[]; baseSha: string; fullySubsumedByMain: boolean; skippedEmptyCount: number }> {
|
}): Promise<{ landedCommitCount: number; landedCommitShas: string[]; baseSha: string; fullySubsumedByMain: boolean; skippedEmptyCount: number }> {
|
||||||
@@ -8281,8 +8466,15 @@ export async function aiMergeTask(
|
|||||||
const explicitBuildCommand = settings.buildCommand?.trim() || undefined;
|
const explicitBuildCommand = settings.buildCommand?.trim() || undefined;
|
||||||
|
|
||||||
// Infer default test command if explicit testCommand is not set
|
// Infer default test command if explicit testCommand is not set
|
||||||
// This ensures merge verification runs even when settings.testCommand is not configured
|
// This ensures merge verification runs even when settings.testCommand is not configured.
|
||||||
const inferredTest = inferDefaultTestCommand(rootDir, explicitTestCommand, explicitBuildCommand);
|
// Thread baseBranch + branch so pnpm workspaces can be scoped to changed packages.
|
||||||
|
const inferredTest = inferDefaultTestCommand(
|
||||||
|
rootDir,
|
||||||
|
explicitTestCommand,
|
||||||
|
explicitBuildCommand,
|
||||||
|
mergeTarget.branch,
|
||||||
|
branch,
|
||||||
|
);
|
||||||
const effectiveTestCommand = inferredTest?.command || explicitTestCommand;
|
const effectiveTestCommand = inferredTest?.command || explicitTestCommand;
|
||||||
const effectiveTestSource = inferredTest?.testSource;
|
const effectiveTestSource = inferredTest?.testSource;
|
||||||
const effectiveBuildCommand = explicitBuildCommand;
|
const effectiveBuildCommand = explicitBuildCommand;
|
||||||
@@ -9632,8 +9824,8 @@ interface MergeAttemptParams {
|
|||||||
mergeTargetBranch?: string;
|
mergeTargetBranch?: string;
|
||||||
testCommand?: string;
|
testCommand?: string;
|
||||||
buildCommand?: string;
|
buildCommand?: string;
|
||||||
/** Source of the test command: 'explicit' from settings or 'inferred' from project files */
|
/** Source of the test command: 'explicit' from settings or 'inferred'/'inferred-scoped' from project files */
|
||||||
testSource?: "explicit" | "inferred";
|
testSource?: "explicit" | "inferred" | "inferred-scoped";
|
||||||
/** Source of the build command: 'explicit' from settings or 'inferred' (future use) */
|
/** Source of the build command: 'explicit' from settings or 'inferred' (future use) */
|
||||||
buildSource?: "explicit" | "inferred";
|
buildSource?: "explicit" | "inferred";
|
||||||
/** Set when the pre-merge rebase recovery cascade (Layers 1–2) failed and
|
/** Set when the pre-merge rebase recovery cascade (Layers 1–2) failed and
|
||||||
|
|||||||
Reference in New Issue
Block a user