← All tasks
javascriptlint-staged/lint-staged #1736Not a task: not reproduced

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: macOS
Continue on GitHub ↗

04 / LABELS

Labels from the report text only; not yet run

No supported category has been assigned.

Label rules and the text that matched
[]