From 06464af794fec00dc8981415546b4eedcd4f1cdd Mon Sep 17 00:00:00 2001 From: ychampion Date: Thu, 9 Jul 2026 23:24:55 +0000 Subject: [PATCH] fix(nm): keep direct dependency bins authoritative Constraint: Preserve stable traversal order while preventing transitive bin overwrites. Confidence: high Scope-risk: narrow Tested: node-modules acceptance tests; yarn test:lint; yarn typecheck:all; yarn constraints; yarn version check Not-tested: Windows-specific shim execution locally --- .yarn/versions/a46c1d8d.yml | 5 ++++ .../one-dep-alias-bins-1.0.0/package.json | 7 +++++ .../sources/node-modules.test.ts | 29 +++++++++++++++++++ .../plugin-nm/sources/NodeModulesLinker.ts | 4 ++- 4 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 .yarn/versions/a46c1d8d.yml create mode 100644 packages/acceptance-tests/pkg-tests-fixtures/packages/one-dep-alias-bins-1.0.0/package.json diff --git a/.yarn/versions/a46c1d8d.yml b/.yarn/versions/a46c1d8d.yml new file mode 100644 index 000000000000..56d640eee2f1 --- /dev/null +++ b/.yarn/versions/a46c1d8d.yml @@ -0,0 +1,5 @@ +releases: + "@yarnpkg/plugin-nm": patch + +declined: + - "@yarnpkg/cli" diff --git a/packages/acceptance-tests/pkg-tests-fixtures/packages/one-dep-alias-bins-1.0.0/package.json b/packages/acceptance-tests/pkg-tests-fixtures/packages/one-dep-alias-bins-1.0.0/package.json new file mode 100644 index 000000000000..9976bd6756ef --- /dev/null +++ b/packages/acceptance-tests/pkg-tests-fixtures/packages/one-dep-alias-bins-1.0.0/package.json @@ -0,0 +1,7 @@ +{ + "name": "one-dep-alias-bins", + "version": "1.0.0", + "dependencies": { + "@fixture/old": "npm:has-bin-entries@1.0.0" + } +} diff --git a/packages/acceptance-tests/pkg-tests-specs/sources/node-modules.test.ts b/packages/acceptance-tests/pkg-tests-specs/sources/node-modules.test.ts index 333f4704bcf8..6ca60d7b0cc3 100644 --- a/packages/acceptance-tests/pkg-tests-specs/sources/node-modules.test.ts +++ b/packages/acceptance-tests/pkg-tests-specs/sources/node-modules.test.ts @@ -1,5 +1,6 @@ import {WindowsLinkType} from '@yarnpkg/core'; import {xfs, npath, PortablePath, ppath, Filename} from '@yarnpkg/fslib'; +import {parseSyml} from '@yarnpkg/parsers'; const { @@ -150,6 +151,34 @@ describe(`Node_Modules`, () => { ), ); + test(`should prefer direct dependency bins over transitive dependency bins`, + makeTemporaryEnv( + { + dependencies: { + [`@fixture/native`]: `npm:has-bin-entries@2.0.0`, + [`has-bin-entries`]: `npm:one-dep-alias-bins@1.0.0`, + }, + }, + { + nodeLinker: `node-modules`, + }, + async ({path, run}) => { + await run(`install`); + + const installState = parseSyml(await xfs.readFilePromise(ppath.join(path, `node_modules/.yarn-state.yml` as PortablePath), `utf8`)); + const rootState = Object.values(installState).find(({locations}) => locations?.includes(``)); + + expect(rootState?.bin?.[`.`]?.[`has-bin-entries-with-relative-require`]).toEqual(`@fixture/native/bin-with-relative-require.js`); + + if (process.platform !== `win32`) { + await expect(run(`node`, `${path}/node_modules/.bin/has-bin-entries-with-relative-require`)).resolves.toMatchObject({ + stdout: `2.0.0\n`, + }); + } + }, + ), + ); + test(`should support dependency via link: protocol to a missing folder`, makeTemporaryEnv( { diff --git a/packages/plugin-nm/sources/NodeModulesLinker.ts b/packages/plugin-nm/sources/NodeModulesLinker.ts index 7e5c6f005228..320e8c5c5bba 100644 --- a/packages/plugin-nm/sources/NodeModulesLinker.ts +++ b/packages/plugin-nm/sources/NodeModulesLinker.ts @@ -1053,7 +1053,9 @@ async function createBinSymlinkMap(installState: NodeModulesLocatorMap, location for (const [childLocation, childNode] of node.children) { const childSymlinks = getBinSymlinks(ppath.join(location, childLocation), parentLocatorLocation, childNode); for (const [name, symlinkTarget] of childSymlinks) { - symlinks.set(name, symlinkTarget); + if (!symlinks.has(name)) { + symlinks.set(name, symlinkTarget); + } } } }