diff --git a/examples/pnpm-workspace-parent-upgrade/package.json b/examples/pnpm-workspace-parent-upgrade/package.json new file mode 100644 index 00000000..fada63b2 --- /dev/null +++ b/examples/pnpm-workspace-parent-upgrade/package.json @@ -0,0 +1,7 @@ +{ + "name": "cve-lite-example-pnpm-workspace-parent-upgrade", + "version": "1.0.0", + "private": true, + "description": "Intentionally vulnerable pnpm monorepo example. The packages/tooling member's dev dependency concurrently@9.2.1 transitively pulls the vulnerable shell-quote@1.8.3, fixable only by upgrading the parent (concurrently@9.2.4). Verifies CVE Lite CLI scopes the parent-upgrade fix command to the correct workspace member with --filter.", + "license": "MIT" +} diff --git a/examples/pnpm-workspace-parent-upgrade/pnpm-lock.yaml b/examples/pnpm-workspace-parent-upgrade/pnpm-lock.yaml new file mode 100644 index 00000000..ef299030 --- /dev/null +++ b/examples/pnpm-workspace-parent-upgrade/pnpm-lock.yaml @@ -0,0 +1,27 @@ +lockfileVersion: '9.0' + +importers: + .: + {} + + packages/tooling: + devDependencies: + concurrently: + specifier: 9.2.1 + version: 9.2.1 + +packages: + concurrently@9.2.1: + resolution: {integrity: sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==} + engines: {node: '>=18'} + hasBin: true + + shell-quote@1.8.3: + resolution: {integrity: sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==} + +snapshots: + concurrently@9.2.1: + dependencies: + shell-quote: 1.8.3 + + shell-quote@1.8.3: {} diff --git a/examples/pnpm-workspace-parent-upgrade/pnpm-workspace.yaml b/examples/pnpm-workspace-parent-upgrade/pnpm-workspace.yaml new file mode 100644 index 00000000..18ec407e --- /dev/null +++ b/examples/pnpm-workspace-parent-upgrade/pnpm-workspace.yaml @@ -0,0 +1,2 @@ +packages: + - 'packages/*' diff --git a/examples/yarn-workspace-parent-upgrade/package.json b/examples/yarn-workspace-parent-upgrade/package.json new file mode 100644 index 00000000..df78bece --- /dev/null +++ b/examples/yarn-workspace-parent-upgrade/package.json @@ -0,0 +1,10 @@ +{ + "name": "cve-lite-example-yarn-workspace-parent-upgrade", + "version": "1.0.0", + "private": true, + "description": "Intentionally vulnerable Yarn monorepo example. The packages/tooling member's dev dependency concurrently@9.2.1 transitively pulls the vulnerable shell-quote@1.8.3, fixable only by upgrading the parent (concurrently@9.2.4). Verifies CVE Lite CLI scopes the parent-upgrade fix command to the correct workspace member with `yarn workspace add`.", + "license": "MIT", + "workspaces": [ + "packages/*" + ] +} diff --git a/examples/yarn-workspace-parent-upgrade/packages/tooling/package.json b/examples/yarn-workspace-parent-upgrade/packages/tooling/package.json new file mode 100644 index 00000000..0af85194 --- /dev/null +++ b/examples/yarn-workspace-parent-upgrade/packages/tooling/package.json @@ -0,0 +1,8 @@ +{ + "name": "tooling", + "version": "1.0.0", + "private": true, + "devDependencies": { + "concurrently": "9.2.1" + } +} diff --git a/examples/yarn-workspace-parent-upgrade/yarn.lock b/examples/yarn-workspace-parent-upgrade/yarn.lock new file mode 100644 index 00000000..a0c7ca69 --- /dev/null +++ b/examples/yarn-workspace-parent-upgrade/yarn.lock @@ -0,0 +1,15 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +concurrently@9.2.1: + version "9.2.1" + resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.2.1.tgz#a1b2c3d4e5f60718293a4b5c6d7e8f9012345678" + integrity sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng== + dependencies: + shell-quote "1.8.3" + +shell-quote@1.8.3: + version "1.8.3" + resolved "https://registry.yarnpkg.com/shell-quote/-/shell-quote-1.8.3.tgz#b1c2d3e4f5a60718293a4b5c6d7e8f9012345678" + integrity sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw== diff --git a/src/remediation/fix-commands.ts b/src/remediation/fix-commands.ts index a55013da..78be966e 100644 --- a/src/remediation/fix-commands.ts +++ b/src/remediation/fix-commands.ts @@ -282,6 +282,7 @@ export function buildSuggestedFixCommandPlan( const chainForNpmRemediation = finding.chainResolution?.directDep === finding.recommendedNpmTransitiveRemediation.package ? finding.chainResolution : null; + const npmParentUpgradeWorkspaces = workspaceMap.get(finding.recommendedNpmTransitiveRemediation.package)?.filter(w => w !== ".") ?? []; upsertTarget(targetsByPackage, { package: finding.recommendedNpmTransitiveRemediation.package, currentVersion: finding.recommendedNpmTransitiveRemediation.currentVersion, @@ -299,6 +300,7 @@ export function buildSuggestedFixCommandPlan( remainingPaths: coverage.remainingPaths, usage: finding.usage ?? null, isDev: devLookup.get(`${finding.recommendedNpmTransitiveRemediation.package}@${finding.recommendedNpmTransitiveRemediation.currentVersion}`) ?? false, + workspaces: npmParentUpgradeWorkspaces.length > 0 ? npmParentUpgradeWorkspaces : undefined, chainProof: chainForNpmRemediation?.chain, chainSafeVersion: chainForNpmRemediation?.safeVersion, chainVulnerablePackage: chainForNpmRemediation ? finding.pkg.name : undefined, @@ -321,6 +323,7 @@ export function buildSuggestedFixCommandPlan( const chainForParentUpgrade = finding.chainResolution?.directDep === finding.recommendedParentUpgrade.package ? finding.chainResolution : null; + const parentUpgradeWorkspaces = workspaceMap.get(finding.recommendedParentUpgrade.package)?.filter(w => w !== ".") ?? []; upsertTarget(targetsByPackage, { package: finding.recommendedParentUpgrade.package, currentVersion: finding.recommendedParentUpgrade.currentVersion, @@ -338,6 +341,7 @@ export function buildSuggestedFixCommandPlan( remainingPaths: coverage.remainingPaths, usage: finding.usage ?? null, isDev: devLookup.get(`${finding.recommendedParentUpgrade.package}@${finding.recommendedParentUpgrade.currentVersion}`) ?? false, + workspaces: parentUpgradeWorkspaces.length > 0 ? parentUpgradeWorkspaces : undefined, chainProof: chainForParentUpgrade?.chain, chainSafeVersion: chainForParentUpgrade?.safeVersion, chainVulnerablePackage: chainForParentUpgrade ? finding.pkg.name : undefined, @@ -390,6 +394,7 @@ export function buildSuggestedFixCommandPlan( if (finding.chainResolution) { const isDev = devLookup.get(`${finding.chainResolution.directDep}@${finding.chainResolution.directDepCurrentVersion}`) ?? false; const coverage = calculatePathCoverage(finding.dependencyPaths, [finding.chainResolution.directDep]); + const chainWorkspaces = workspaceMap.get(finding.chainResolution.directDep)?.filter(w => w !== ".") ?? []; upsertTarget(targetsByPackage, { package: finding.chainResolution.directDep, currentVersion: finding.chainResolution.directDepCurrentVersion, @@ -406,6 +411,7 @@ export function buildSuggestedFixCommandPlan( chainVulnerablePackage: finding.pkg.name, usage: finding.usage ?? null, isDev, + workspaces: chainWorkspaces.length > 0 ? chainWorkspaces : undefined, }); continue; } @@ -445,6 +451,17 @@ export function buildSuggestedFixCommandPlan( return a.package.localeCompare(b.package); }); + // Some install targets carry an explicit `command` built without workspace + // context (e.g. a chain-resolution command). This raw field is exposed in JSON + // and embedded in the HTML report, so scope it to the declaring member too - + // parent-update commands are already workspace-aware and left untouched. + for (const target of targets) { + if (target.kind !== "parent-update" && target.command && target.workspaces?.length) { + const scoped = buildWorkspaceInstallCommands([target], packageManager); + if (scoped.length > 0) target.command = scoped.join(" && "); + } + } + const nowMs = options?.nowMs ?? Date.now(); if (scanInput.filePath && (packageManager === "npm" || packageManager === "pnpm" || packageManager === "yarn")) { const cooldown = readConfiguredCooldown(path.dirname(scanInput.filePath), packageManager); @@ -524,7 +541,7 @@ function buildPnpmLockfileRefreshCommand(pkg: string, workspaces?: string[]): st if (workspace === ".") { return `pnpm update --no-save ${pkg}`; } - return `pnpm -C ${workspace} update --no-save ${pkg}`; + return `pnpm --filter ./${workspace} update --no-save ${pkg}`; } export function commandPrefix(packageManager: SuggestedFixPackageManager): string { @@ -582,11 +599,17 @@ export function findSuggestedCommandForFinding( ): string | null { const target = findFixTargetForFinding(plan, finding); if (!target) return null; - if (target.command) return target.command; + // parent-update targets carry an explicit command that is already + // workspace-scoped (npm update --workspace / pnpm --filter ... update). + if (target.kind === "parent-update" && target.command) return target.command; + // For install targets (direct / parent-upgrade), a workspace membership must + // win over any flat explicit command (e.g. a chain-resolution command built + // without workspace context), mirroring the plan-level command builder. if (target.workspaces?.length) { const commands = buildWorkspaceInstallCommands([target], plan.packageManager); if (commands.length > 0) return commands.join(" && "); } + if (target.command) return target.command; const flag = target.isDev ? ` ${devFlag(plan.packageManager)}` : ""; return `${commandPrefix(plan.packageManager)}${flag} ${target.package}@${target.targetVersion}`; } @@ -842,8 +865,11 @@ function buildWorkspaceInstallCommands( const wsFlags = workspaces.map(ws => `-w ${ws}`).join(" "); commands.push(`npm install${flag}${wsFlags ? " " + wsFlags : ""} ${pkgArgs}`); } else if (packageManager === "pnpm") { + // pnpm's --filter selector is a CLI-level flag that precedes the command + // (`pnpm --filter ./client add pkg`), not a trailing argument. const wsFlags = workspaces.map(ws => `--filter ./${ws}`).join(" "); - commands.push(`pnpm add${flag}${wsFlags ? " " + wsFlags : ""} ${pkgArgs}`); + const prefix = wsFlags ? `pnpm ${wsFlags} add` : "pnpm add"; + commands.push(`${prefix}${flag} ${pkgArgs}`); } else if (packageManager === "yarn") { if (workspaces.length === 0) { commands.push(`yarn add${flag} ${pkgArgs}`); diff --git a/tests/remediation/fix-commands-workspace-scoping.test.ts b/tests/remediation/fix-commands-workspace-scoping.test.ts new file mode 100644 index 00000000..6bcf32b9 --- /dev/null +++ b/tests/remediation/fix-commands-workspace-scoping.test.ts @@ -0,0 +1,324 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { buildSuggestedFixCommandPlan, findFixTargetForFinding, findSuggestedCommandForFinding } from "../../src/remediation/fix-commands.js"; +import type { Finding, ScanInput } from "../../src/types.js"; + +function pnpmScanInput(filePath = "/tmp/pnpm-lock.yaml"): ScanInput { + return { + mode: "resolved-lockfile", + source: "pnpm-lock", + filePath, + packages: [], + notes: [], + warnings: [], + skippedDependencies: [], + }; +} + +// Writes a minimal pnpm-lock.yaml declaring each package in a workspace member, +// so buildPnpmWorkspaceMap attributes the package to that member. Returns the path. +function writePnpmWorkspaceLock(membersByPackage: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pnpm-ws-")); + const importers: Record = {}; + for (const [pkg, { member, version }] of Object.entries(membersByPackage)) { + (importers[member] ??= []).push( + ` '${pkg}':\n specifier: '*'\n version: ${version}`, + ); + } + const importerBlocks = Object.entries(importers) + .map(([member, deps]) => ` ${member}:\n dependencies:\n${deps.join("\n")}`) + .join("\n"); + const content = `lockfileVersion: '9.0'\nimporters:\n${importerBlocks}\n`; + const file = path.join(dir, "pnpm-lock.yaml"); + fs.writeFileSync(file, content); + return file; +} + +// A transitive finding whose fix is a within-range lockfile refresh, scoped to a +// single workspace member (workspaces attribution lives on the finding's +// remediation, so this needs no real lockfile). +function pnpmWithinRangeRefreshFinding(workspaces: string[]): Finding { + return { + pkg: { name: "js-cookie", version: "3.0.1", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-qjx8-664m-686j" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", "@aws-amplify/core", "js-cookie"]], + relationship: "transitive", + firstFixedVersion: "3.0.5", + recommendedNpmTransitiveRemediation: { + kind: "update-parent-within-range", + package: "js-cookie", + currentVersion: "3.0.1", + targetChildVersion: "3.0.5", + viaPath: ["project", "@aws-amplify/core", "js-cookie"], + reason: "@aws-amplify/core@6.16.1 already allows js-cookie@3.0.5 within the current dependency range", + workspaces, + }, + }; +} + +describe("buildSuggestedFixCommandPlan - pnpm workspace command scoping", () => { + it("scopes a within-range refresh to a single member with --filter, not -C", () => { + const finding = pnpmWithinRangeRefreshFinding(["client"]); + const plan = buildSuggestedFixCommandPlan([finding], pnpmScanInput()); + + const target = findFixTargetForFinding(plan!, finding); + expect(target?.command).toBe("pnpm --filter ./client update --no-save js-cookie"); + expect(target?.command).not.toContain("pnpm -C "); + }); + + it("emits a plain refresh command (no filter) when the package is at the workspace root", () => { + const finding = pnpmWithinRangeRefreshFinding(["."]); + const plan = buildSuggestedFixCommandPlan([finding], pnpmScanInput()); + + const target = findFixTargetForFinding(plan!, finding); + expect(target?.command).toBe("pnpm update --no-save js-cookie"); + }); + + it("emits a plain refresh command (no filter) when there is no workspace", () => { + const finding = pnpmWithinRangeRefreshFinding([]); + const plan = buildSuggestedFixCommandPlan([finding], pnpmScanInput()); + + const target = findFixTargetForFinding(plan!, finding); + expect(target?.command).toBe("pnpm update --recursive --no-save js-cookie"); + }); +}); + +// A transitive finding whose fix is a breaking parent upgrade (install), where +// the parent package is a direct dependency of a workspace member. +function pnpmParentUpgradeFinding(parent: string, currentVersion: string, targetVersion: string): Finding { + return { + pkg: { name: "some-vuln-transitive", version: "1.0.0", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-parent-upgrade" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", parent, "some-vuln-transitive"]], + relationship: "transitive", + firstFixedVersion: "2.0.0", + recommendedParentUpgrade: { + package: parent, + currentVersion, + targetVersion, + viaPath: ["project", parent, "some-vuln-transitive"], + vulnerablePackage: "some-vuln-transitive", + confidence: "verified", + reason: `${parent}@${targetVersion} resolves some-vuln-transitive to 2.0.0`, + }, + }; +} + +describe("buildSuggestedFixCommandPlan - pnpm workspace parent-upgrade scoping", () => { + it("scopes a parent-upgrade install to the member that declares it with --filter, not a flat root add", () => { + const lock = writePnpmWorkspaceLock({ + "@angular-devkit/build-angular": { member: "client", version: "21.2.6" }, + }); + const finding = pnpmParentUpgradeFinding("@angular-devkit/build-angular", "21.2.6", "22.1.0"); + + const plan = buildSuggestedFixCommandPlan([finding], pnpmScanInput(lock)); + + expect(plan?.command).toBe("pnpm --filter ./client add @angular-devkit/build-angular@22.1.0"); + expect(plan?.command).not.toBe("pnpm add @angular-devkit/build-angular@22.1.0"); + }); + + it("splits parent upgrades in different members into separate per-member commands", () => { + const lock = writePnpmWorkspaceLock({ + "@angular-devkit/build-angular": { member: "client", version: "21.2.6" }, + "@nestjs/platform-express": { member: "server", version: "11.0.0" }, + }); + const angular = pnpmParentUpgradeFinding("@angular-devkit/build-angular", "21.2.6", "22.1.0"); + const nest = pnpmParentUpgradeFinding("@nestjs/platform-express", "11.0.0", "11.1.28"); + + const plan = buildSuggestedFixCommandPlan([angular, nest], pnpmScanInput(lock)); + + expect(plan?.command).toContain("pnpm --filter ./client add @angular-devkit/build-angular@22.1.0"); + expect(plan?.command).toContain("pnpm --filter ./server add @nestjs/platform-express@11.1.28"); + // Never a single flat add mixing both members. + expect(plan?.command).not.toMatch(/pnpm add @angular-devkit\/build-angular@22\.1\.0 @nestjs\/platform-express/); + }); + + it("still emits a flat root add for a parent that is a genuine root-level dependency", () => { + const lock = writePnpmWorkspaceLock({ + "@angular-devkit/build-angular": { member: ".", version: "21.2.6" }, + }); + const finding = pnpmParentUpgradeFinding("@angular-devkit/build-angular", "21.2.6", "22.1.0"); + + const plan = buildSuggestedFixCommandPlan([finding], pnpmScanInput(lock)); + + expect(plan?.command).toBe("pnpm add @angular-devkit/build-angular@22.1.0"); + expect(plan?.command).not.toContain("--filter"); + }); +}); + +function npmScanInput(filePath: string): ScanInput { + return { + mode: "resolved-lockfile", + source: "package-lock", + filePath, + packages: [], + notes: [], + warnings: [], + skippedDependencies: [], + }; +} + +// Minimal package-lock.json declaring each package in a workspace member path. +function writeNpmWorkspaceLock(membersByPackage: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "npm-ws-")); + const packages: Record = { "": { name: "root" } }; + for (const [pkg, member] of Object.entries(membersByPackage)) { + packages[member] = { dependencies: { [pkg]: "^1.0.0" } }; + } + const file = path.join(dir, "package-lock.json"); + fs.writeFileSync(file, JSON.stringify({ lockfileVersion: 3, packages })); + return file; +} + +// npm's transitive resolver produces an "upgrade-parent-to-version" remediation +// (branch distinct from the pnpm-applicable recommendedParentUpgrade path). +function npmParentUpgradeFinding(parent: string, currentVersion: string, targetVersion: string): Finding { + return { + pkg: { name: "some-vuln-transitive", version: "1.0.0", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-npm-parent-upgrade" }], + severity: "high", + cveAliases: [], + dependencyPaths: [["project", parent, "some-vuln-transitive"]], + relationship: "transitive", + firstFixedVersion: "2.0.0", + recommendedNpmTransitiveRemediation: { + kind: "upgrade-parent-to-version", + package: parent, + currentVersion, + targetVersion, + targetChildVersion: "2.0.0", + viaPath: ["project", parent, "some-vuln-transitive"], + reason: `${parent}@${targetVersion} no longer allows some-vuln-transitive@1.0.0 and allows 2.0.0+`, + }, + }; +} + +// A transitive finding whose fix is a validated chain upgrade of the direct +// parent (the path a real pnpm workspace scan hits, distinct from the +// recommendedParentUpgrade/upgrade-parent-to-version branches). +function pnpmChainUpgradeFinding(directDep: string, currentVersion: string, targetVersion: string): Finding { + return { + pkg: { name: "shell-quote", version: "1.8.3", ecosystem: "npm" }, + vulnerabilities: [{ id: "GHSA-395f-4hp3-45gv" }], + severity: "critical", + cveAliases: [], + dependencyPaths: [["project", directDep, "shell-quote"]], + relationship: "transitive", + firstFixedVersion: "1.9.0", + chainResolution: { + directDep, + directDepCurrentVersion: currentVersion, + targetVersion, + chain: [], + safeVersion: "1.9.0", + command: `pnpm add ${directDep}@${targetVersion}`, + coveredPaths: 1, + totalPaths: 1, + }, + }; +} + +describe("buildSuggestedFixCommandPlan - pnpm workspace chain-upgrade scoping", () => { + it("scopes a validated chain parent-upgrade to the member that declares the direct dep", () => { + const lock = writePnpmWorkspaceLock({ + concurrently: { member: "packages/tooling", version: "9.2.1" }, + }); + const finding = pnpmChainUpgradeFinding("concurrently", "9.2.1", "9.2.4"); + + const plan = buildSuggestedFixCommandPlan([finding], pnpmScanInput(lock)); + + expect(plan?.command).toBe("pnpm --filter ./packages/tooling add concurrently@9.2.4"); + expect(plan?.command).not.toBe("pnpm add concurrently@9.2.4"); + }); + + // findSuggestedCommandForFinding feeds the per-finding runnableFixCommand used + // by JSON, the HTML per-finding view, and CycloneDX - it must be scoped too, + // not just the combined plan.command. + it("returns a workspace-scoped per-finding command (runnableFixCommand), not the flat chain command", () => { + const lock = writePnpmWorkspaceLock({ + concurrently: { member: "packages/tooling", version: "9.2.1" }, + }); + const finding = pnpmChainUpgradeFinding("concurrently", "9.2.1", "9.2.4"); + + const plan = buildSuggestedFixCommandPlan([finding], pnpmScanInput(lock))!; + const command = findSuggestedCommandForFinding(plan, finding); + + expect(command).toBe("pnpm --filter ./packages/tooling add concurrently@9.2.4"); + expect(command).not.toBe("pnpm add concurrently@9.2.4"); + }); + + // The raw per-target `command` field is exposed in JSON output and embedded in + // the HTML report's data, so it must be scoped too - no flat command anywhere. + it("normalizes the raw target command to the workspace-scoped form", () => { + const lock = writePnpmWorkspaceLock({ + concurrently: { member: "packages/tooling", version: "9.2.1" }, + }); + const finding = pnpmChainUpgradeFinding("concurrently", "9.2.1", "9.2.4"); + + const plan = buildSuggestedFixCommandPlan([finding], pnpmScanInput(lock))!; + const target = findFixTargetForFinding(plan, finding); + + expect(target?.command).toBe("pnpm --filter ./packages/tooling add concurrently@9.2.4"); + expect(target?.command).not.toBe("pnpm add concurrently@9.2.4"); + }); +}); + +describe("buildSuggestedFixCommandPlan - npm workspace parent-upgrade scoping", () => { + it("scopes an npm parent-upgrade install to the member that declares it with -w", () => { + const lock = writeNpmWorkspaceLock({ "eslint": "packages/api" }); + const finding = npmParentUpgradeFinding("eslint", "8.0.0", "9.0.0"); + + const plan = buildSuggestedFixCommandPlan([finding], npmScanInput(lock)); + + expect(plan?.command).toBe("npm install -w packages/api eslint@9.0.0"); + expect(plan?.command).not.toBe("npm install eslint@9.0.0"); + }); +}); + +function bunScanInput(filePath: string): ScanInput { + return { + mode: "resolved-lockfile", + source: "bun-lock", + filePath, + packages: [], + notes: [], + warnings: [], + skippedDependencies: [], + }; +} + +// Minimal bun.lock declaring each package in a workspace member. +function writeBunWorkspaceLock(membersByPackage: Record): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "bun-ws-")); + const workspaces: Record = { + "": { name: "root", workspaces: ["packages/*"] }, + }; + for (const [pkg, member] of Object.entries(membersByPackage)) { + workspaces[member] = { name: member.split("/").pop(), devDependencies: { [pkg]: "9.2.1" } }; + } + const file = path.join(dir, "bun.lock"); + fs.writeFileSync(file, JSON.stringify({ lockfileVersion: 0, workspaces, packages: {} })); + return file; +} + +// Bun's parser does not yet produce parent-upgrade recommendations for transitive +// findings (its dependency-path modeling is incomplete - tracked separately), so +// this path is not reachable through a real bun scan today. This test constructs +// the finding directly to verify that WHEN a bun parent-upgrade is produced, the +// fix command is correctly scoped to the workspace member with --filter. +describe("buildSuggestedFixCommandPlan - bun workspace parent-upgrade scoping", () => { + it("scopes a bun parent-upgrade install to the member that declares it with --filter", () => { + const lock = writeBunWorkspaceLock({ concurrently: "packages/tooling" }); + const finding = pnpmParentUpgradeFinding("concurrently", "9.2.1", "9.2.4"); + + const plan = buildSuggestedFixCommandPlan([finding], bunScanInput(lock)); + + expect(plan?.command).toBe("bun add --filter packages/tooling concurrently@9.2.4"); + expect(plan?.command).not.toBe("bun add concurrently@9.2.4"); + }); +});