v16.3.0: locally installed binaries no longer resolved from node_modules/.bin
envgap__lint-staged__lint-staged-1736
01 / FAILURE SIGNATURE
As reported upstream
No identifying execution failure has been captured.
Not a benchmark task.
- In a clean container the reported failure did not reproduce, or the known fix did not make the project run.
02 / ENVIRONMENT RECIPE
- Base commit
9d6e827b0c55da5b091c989111f6c55dd76539d9- Manifest
package.json- Reproduce
Awaiting issue-specific recipe- Run under trace
Awaiting a meaningful runtime command
03 / ORIGINAL ISSUE TEXT
lint-staged/lint-staged #1736 · read the original issue
## Description
After upgrading from `16.2.7` to `16.3.1`, lint-staged no longer correctly prioritizes locally installed binaries (e.g. `eslint`) from `./node_modules/.bin`. Instead, globally installed versions are resolved first.
## Root cause
[PR #1698](https://github.com/lint-staged/lint-staged/pull/1698) replaced `nano-spawn` with `tinyexec` for spawning task processes.
`tinyexec` does handle `node_modules/.bin` resolution — [its docs](https://github.com/tinylibs/tinyexec#node-modulesbinaries) state that locally installed binaries are accessible by default. However, [`tinyexec`'s PATH construction](https://github.com/tinylibs/tinyexec/blob/main/src/env.ts) **appends** `node_modules/.bin` to the **end** of `PATH` using `Array.push()`:
```ts
// tinyexec src/env.ts — addNodeBinToPath
do {
parts.push(resolvePath(currentPath, 'node_modules', '.bin')); // push = append to end
lastPath = currentPath;
currentPath = dirname(currentPath);
} while (currentPath !== lastPath);
```
This means the resulting PATH looks like:
```
/usr/local/bin:...existing paths...:/project/node_modules/.bin
```
So when resolving `eslint`, the global `/usr/local/bin/eslint` is found **before** the local `./node_modules/.bin/eslint`.
The previous `nano-spawn` implementation used [`preferLocal: true`](https://github.com/sindresorhus/nano-spawn?tab=readme-ov-file#optionspreferlocal), which **prepends** `node_modules/.bin` to the **beginning** of `PATH`, ensuring local binaries always take priority over global ones.
### Key references
- `nano-spawn` behavior (before): [`lib/getSpawnedTask.js` prior to #1698, line 109](https://github.com/lint-staged/lint-staged/blob/e15178a1264942db1ccb9f96ec1b975815fcf880/lib/getSpawnedTask.js#L109) — `preferLocal: true` prepends `node_modules/.bin` to PATH
- `tinyexec` behavior (after): [`lib/getSpawnedTask.js` after #1698](https://github.com/lint-staged/lint-staged/blob/9809fee4801c746e0f57b87215434f75be796c8f/lib/getSpawnedTask.js) — delegates to tinyexec which appends `node_modules/.bin` to PATH
## Steps to reproduce
1. Have a globally installed ESLint at a different major version than the project-local one (e.g. global v9.9.1, local v9.39.3)
2. Configure lint-staged with `eslint --fix`
3. Upgrade lint-staged from `16.2.x` to `16.3.x`
4. Stage a `.js` file and attempt to commit
The global ESLint is invoked instead of the local one, causing errors when the project's ESLint config references rules that only exist in the local version.
## Suggested fix
This is arguably a bug in tinyexec (appending instead of prepending), but lint-staged could work around it by manually prepending `node_modules/.bin` to `PATH` in `lib/getSpawnedTask.js`, similar to what `nano-spawn` did with `preferLocal`:
```js
import path from 'node:path';
const localBinDir = path.resolve(cwd, 'node_modules', '.bin');
const tinyExecOptions = {
nodeOptions: {
cwd: /^git(\.exe)?/i.test(cmd) ? topLevelDir : cwd,
detached: true,
env: {
...process.env,
PATH: `${localBinDir}${path.delimiter}${process.env.PATH}`,
...(color ? { FORCE_COLOR: 'true' } : { NO_COLOR: 'true' }),
},
stdio: ['ignore'],
},
}
```
Alternatively, the upstream fix in tinyexec would be to change `push` to `unshift` in the PATH construction so `node_modules/.bin` takes priority.
## Environment
- lint-staged: `16.3.1` (regression introduced in `16.3.0`)
- Node: `22.19.0`
- OS: macOS04 / LABELS
Labels from the report text only; not yet run
No supported category has been assigned.
Label rules and the text that matched
[]