FN-9046: fix scoped pnpm dependent selector

Correct workspace merge verification to test changed packages with their dependents.

- Use pnpm's valid leading dependents selector for scoped merge tests.
- Cover single, multiple, and shell-metacharacter package selectors.
- Add a patch changeset for the verification fix.

Files changed:
 .changeset/fn-9046-pnpm-filter-selector.md         |  7 +++++
 .../src/__tests__/merger-verification.test.ts      | 33 +++++++++++++++++++---
 .../src/merge/merger-workspace-test-commands.ts    | 11 ++++++--
 3 files changed, 45 insertions(+), 6 deletions(-)

Fusion-Task-Id: FN-9046

Fusion-Task-Lineage: 07186296-36b7-42bb-aaa8-739dbf5e00d5

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-14 21:32:58 -07:00
parent 52a28d39ff
commit 010e619628
3 changed files with 45 additions and 6 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Merge verification now runs tests for packages depending on a changed package.
category: fix
dev: `deriveScopedPnpmTestCommand` now uses `...<pkg>` instead of malformed `<pkg>...^` selectors.

View File

@@ -3000,7 +3000,8 @@ describe("inferDefaultTestCommand — pnpm workspace scoping", () => {
});
const result = inferDefaultTestCommand("/tmp/root", undefined, undefined, "main", "fusion/fn-123");
expect(result?.command).toBe(`pnpm --filter '@fusion/dashboard...^' test`);
expect(result?.command).toBe(`pnpm --filter '...@fusion/dashboard' test`);
expect(result?.command).not.toMatch(/\.\.\.\^/);
expect(result?.testSource).toBe("inferred-scoped");
expect(mockedExecSync).toHaveBeenCalledWith(
"git diff --name-only 'main'...'fusion/fn-123'",
@@ -3036,12 +3037,36 @@ describe("inferDefaultTestCommand — pnpm workspace scoping", () => {
});
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?.command).toBe(
"pnpm --filter '...@fusion/dashboard' --filter '...@fusion/engine' test",
);
expect(result?.command).not.toMatch(/\.\.\.\^/);
expect(result?.testSource).toBe("inferred-scoped");
});
it("keeps a metacharacter package name shell-quoted inside a dependents selector", () => {
mockedExistsSync.mockImplementation((p: any) => {
const path = String(p);
return path.includes("pnpm-lock.yaml") || path.includes("pnpm-workspace.yaml") || path.includes("package.json");
});
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: "@evil/pkg'; rm -rf /" });
return JSON.stringify({ name: "unknown" });
});
mockedReaddirSync.mockReturnValue([
{ name: "dashboard", isDirectory: () => true },
] as any);
mockedExecSync.mockImplementation((cmd: any) => String(cmd).includes("git diff --name-only")
? "packages/dashboard/src/index.ts\n"
: "");
const result = inferDefaultTestCommand("/tmp/root", undefined, undefined, "main", "fusion/fn-123");
expect(result?.command).toBe("pnpm --filter '...@evil/pkg'\\''; rm -rf /' test");
expect(result?.command).not.toMatch(/\.\.\.\^/);
});
it("falls back to pnpm test (inferred) when all changes are root-only files", () => {
mockedExistsSync.mockImplementation((p: any) => {
const path = String(p);

View File

@@ -228,11 +228,18 @@ export function deriveScopedPnpmTestCommand(rootDir: string, baseBranch: string,
}
// 5. Compose the scoped pnpm command
// `...^` includes dependents (packages that import the changed packages).
/*
* FNXC:Verification 2026-08-15-04:18:
* pnpm `name...` selects a package with dependencies, `...name` selects it
* with dependents, and `...^name` excludes the package from its dependents.
* Merge verification needs `...name` so changed-package tests run alongside
* dependent tests; the former malformed trailing `name...^` could produce a
* zero-package, zero-test green verification.
*/
// 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(" ");
const filters = packageNames.map((name) => `--filter ${quoteArg(`...${name}`)}`).join(" ");
return `pnpm ${filters} test`;
}