Skip to content

fix(eslint): trace flat-config plugin imports through workspace-internal config packages#266

Merged
BartWaardenburg merged 1 commit intofallow-rs:mainfrom
fmguerreiro:upstream/eslint-flat-config
May 3, 2026
Merged

fix(eslint): trace flat-config plugin imports through workspace-internal config packages#266
BartWaardenburg merged 1 commit intofallow-rs:mainfrom
fmguerreiro:upstream/eslint-flat-config

Conversation

@fmguerreiro
Copy link
Copy Markdown
Contributor

Problem

In Turborepo/Nx monorepos that centralize ESLint config in a workspace package, every workspace gets false unused-devdep flags for the plugins that the shared config imports. Pattern:

```js
// apps/foo/eslint.config.mjs
import next from '@scope/eslint-config/next';
export default [...next];

// packages/eslint-config/next.js
import reactPlugin from 'eslint-plugin-react';
export default [{ plugins: { react: reactPlugin } }];
```

```json
// apps/foo/package.json
{
"devDependencies": {
"eslint": "^9",
"eslint-plugin-react": "^7",
"@scope/eslint-config": "*"
}
}
```

eslint-plugin-react is flagged as unused-devdep in apps/foo/package.json, even though the workspace package transitively imports it.

Fix

Two cooperating changes:

1. crates/core/src/plugins/eslint.rs

  • find_package_dir walks up start.ancestors() checking node_modules/<pkg>/package.json. Bounded by MAX_NODE_MODULES_WALK_DEPTH = 8 so the walk cannot escape into the host filesystem. Handles Turborepo/pnpm hoisting where deps live in the monorepo root rather than per-workspace.
  • read_package_entry_for_specifier resolves @scope/pkg/subpath imports via the package's exports map, falling back to .js/.mjs/.cjs extension probing on the bare subpath name.
  • resolve_package_entry returns Option<String> — when no exports-map entry and no extension probe matches, returns None so the caller skips the subpath cleanly. Replaces an earlier silent-fallthrough that would synthesize a guessed path and rely on read_to_string to fail.

2. crates/core/src/plugins/registry/mod.rs

Adds "eslint" to the must_parse_workspace_config_when_root_active allowlist (already contains docusaurus, jest, tanstack-router, vitest). Without this, when ESLint is already active at the monorepo root, every workspace's eslint.config.* is silently skipped by run_workspace_fast, and the resolver from (1) never gets called. Discovered via integration testing: the resolver was correct in isolation but never invoked at runtime.

Tests

  • 38 existing eslint plugin tests still pass.
  • find_package_dir_finds_local_install_at_depth_zero: walk does not skip a package co-located with the workspace.
  • find_package_dir_returns_none_when_walk_finds_nothing: walk-up failure returns None cleanly.
  • run_workspace_fast_eslint_config_parsed_when_eslint_active_at_root: full integration test reproducing the real-world Turborepo layout (hoisted @scope/eslint-config package + workspace eslint.config.mjs + skip_config_plugins containing "eslint") and asserting eslint-plugin-react appears in referenced_dependencies.

40 ESLint tests pass total. Other plugins not regressed.

Known follow-ups (out of scope)

  • resolve_exports_subpath does not handle wildcard patterns ("./features/*": "./src/features/*.js"). Increasingly common in design-system packages. Filed as separate concern.
  • When ESLint is active at both root and workspace, referenced_dependencies accumulates duplicates from both passes. Final consumption already deduplicates via FxHashSet<&str>, so this is a memory bloat issue rather than correctness. If the maintainers prefer pre-dedup at aggregation time, happy to follow up.

Verified on

cargo test --workspace, cargo clippy --workspace --all-targets -- -D warnings, cargo fmt --all -- --check. Real-world: this fork-pinned binary running in a Turborepo monorepo CI cleared the false-positive unused-devdep flags for eslint-config-next and eslint-plugin-prettier while still correctly flagging a genuinely-unused eslint-plugin-unused-imports.

…nal config packages

In Turborepo/Nx monorepos that centralize ESLint config in a workspace
package, every workspace gets false `unused-devdep` flags for the
plugins that the shared config imports.

Pattern:

    // apps/foo/eslint.config.mjs
    import next from '@scope/eslint-config/next';
    export default [...next];

    // packages/eslint-config/next.js
    import reactPlugin from 'eslint-plugin-react';
    export default [{ plugins: { react: reactPlugin } }];

    // apps/foo/package.json
    {
      "devDependencies": {
        "eslint": "^9",
        "eslint-plugin-react": "^7",
        "@scope/eslint-config": "*"
      }
    }

`eslint-plugin-react` is flagged as unused-devdep in
`apps/foo/package.json` even though the workspace package transitively
imports it.

Two changes work together:

1. `crates/core/src/plugins/eslint.rs` — `extract_eslint_config` walks
   up the directory tree to find hoisted `node_modules` (Turborepo/pnpm
   do not co-locate workspace deps under the workspace's own
   `node_modules`), and `read_package_entry_for_specifier` resolves
   subpath imports like `@scope/eslint-config/next` via the package's
   `exports` map or by probing `.js`/`.mjs`/`.cjs` extensions on the
   bare subpath name.

2. `crates/core/src/plugins/registry/mod.rs` — adds `"eslint"` to the
   `must_parse_workspace_config_when_root_active` allowlist. Without
   this, when ESLint is already active at the monorepo root, every
   workspace's `eslint.config.*` is silently skipped by
   `run_workspace_fast`, and the (1) resolver never gets called.
   Discovered via integration testing: the resolver was correct in
   isolation but never invoked at runtime.

Tests:
- Existing `eslint.rs` unit tests cover the resolver (38 passing).
- New `run_workspace_fast_eslint_config_parsed_when_eslint_active_at_root`
  registry test reproduces the real-world Turborepo layout (hoisted
  `@scope/eslint-config` package + workspace eslint.config.mjs +
  skip_config_plugins containing 'eslint') and asserts
  `eslint-plugin-react` appears in `referenced_dependencies`.
@BartWaardenburg BartWaardenburg merged commit 9726715 into fallow-rs:main May 3, 2026
18 checks passed
@BartWaardenburg
Copy link
Copy Markdown
Collaborator

Released in v2.63.0. Thanks @fmguerreiro.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants