Skip to content

fix(linter/plugins): support plugins outside of workspace#18755

Merged
graphite-app[bot] merged 1 commit intomainfrom
om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace
Jan 31, 2026
Merged

fix(linter/plugins): support plugins outside of workspace#18755
graphite-app[bot] merged 1 commit intomainfrom
om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace

Conversation

@overlookmotel
Copy link
Member

@overlookmotel overlookmotel commented Jan 31, 2026

The problem

1. Bug with plugins outside of workspace directory

There was a bug in workspaces implementation. loadPlugin was assuming that the plugin file is within the workspace, but that's not necessarily the case. e.g.:

  • Workspace 1: /path/to/project/packages/foo
  • Workspace 2: /path/to/project/packages/bar
  • JS plugin: /path/to/project/node_modules/eslint-plugin-whatever

This problem arises because loadPlugin is determining which workspace to load a plugin for by search for the workspace whose root directory contains the plugin file.

2. Performance

Every call to loadPlugin, setupRuleConfigs, or lintFile was first searching through all workspaces to find the right one (getResponsibleWorkspace).

This is really expensive when there are lots of workspaces, and quite expensive even when there's only 1 because iterating hashsets is relatively slow.

This PR

This PR separates out CWD and workspace identifiers into 2 different concerns.

  • Each workspace has an ID which is unique.
  • Each workspace has a CWD (path to root directory as a file path).

The workspace ID is currently the workspace's root directory as a file://... URL. Using that as it's guaranteed unique, even for paths which are not valid UTF-8 (Path::to_string_lossy can produce the same &str for different paths). But ID it doesn't have to be the URI - we could change it later to an integer ID if we want.

Rust-side code passes the workspace ID to every call to loadPlugin, setupRuleConfigs, or lintFile.

In CLI, it just passes null as the ID. There's only ever 1 workspace in CLI, so no point in the expense of passing a String on every call.

These functions look up the CWD from the workspace ID in the cwds map.

Separating these 2 concerns solves both problems listed above:

  1. Plugins can be outside of the workspace's CWD (see added test).
  2. getResponsibleWorkspace is removed, solving the perf issue.

Note: The multiple hashmap lookups on each call to lintFile remain a perf drain. This is addressed in a later PR in this stack.

Copy link
Member Author

overlookmotel commented Jan 31, 2026

@codspeed-hq
Copy link

codspeed-hq bot commented Jan 31, 2026

Merging this PR will not alter performance

✅ 46 untouched benchmarks
⏩ 3 skipped benchmarks1


Comparing om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace (7ef5801) with main (5909085)2

Open in CodSpeed

Footnotes

  1. 3 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

  2. No successful run was found on main (b002aa5) during the generation of this report, so 5909085 was used instead as the comparison base. There might be some changes unrelated to this pull request in this report.

@overlookmotel overlookmotel marked this pull request as ready for review January 31, 2026 12:42
Copilot AI review requested due to automatic review settings January 31, 2026 12:42
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR decouples workspace identity from filesystem CWD for JS plugins, fixing plugin resolution when plugins live outside of the workspace root and reducing per-call workspace lookup costs.

Changes:

  • Introduces an explicit workspace_uri concept in the Rust linter core and JS bridge, threading it through create_workspace, loadPlugin, setupRuleConfigs, and lintFile instead of inferring workspace from file paths.
  • Updates JS plugin infrastructure to index rules/options by workspace ID and maintain a separate cwds map for CWDs, removing getResponsibleWorkspace and the path-based workspace resolution logic.
  • Adds end-to-end fixtures to verify that JS plugins located outside the current CWD are correctly loaded in CLI mode.

Reviewed changes

Copilot reviewed 22 out of 23 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
napi/playground/src/lib.rs Updates ConfigStoreBuilder::from_oxlintrc call to pass the new workspace_uri parameter, keeping the playground path in sync with the core API.
crates/oxc_linter/src/tester.rs Adjusts test helper linter construction to account for the extended from_oxlintrc signature, ensuring tests compile with the new workspace API.
crates/oxc_linter/src/lib.rs Adds an optional workspace_uri field to Linter and passes it through to the external JS lint_file callback to identify the workspace on the JS side.
crates/oxc_linter/src/external_plugin_store.rs Extends the JSON sent to JS with workspaceUri, and updates setup_rule_configs/ConfigSer::new to carry both cwd and workspace ID.
crates/oxc_linter/src/external_linter.rs Extends callback type aliases for load_plugin and lint_file to include an optional workspace URI, forming the core of the new workspace-aware external linter API.
crates/oxc_linter/src/config/config_builder.rs Threads workspace_uri into ConfigStoreBuilder::from_oxlintrc and load_external_plugin, passing the workspace ID to JS when loading plugins.
apps/oxlint/test/fixtures/plugin_outside_cwd/subdirectory/files/index.js Adds a test JS file containing a debugger; statement to be flagged by the external plugin.
apps/oxlint/test/fixtures/plugin_outside_cwd/subdirectory/.oxlintrc.json Configures the test to use a JS plugin located outside the configured CWD and enables its rule.
apps/oxlint/test/fixtures/plugin_outside_cwd/plugin.ts Implements a minimal JS plugin (basic-custom-plugin/no-debugger) used to validate loading plugins outside the workspace CWD.
apps/oxlint/test/fixtures/plugin_outside_cwd/output.snap.md Defines the expected CLI output for the plugin-outside-CWD scenario, verifying correct diagnostics and warnings.
apps/oxlint/test/fixtures/plugin_outside_cwd/options.json Sets cwd: "subdirectory" so that the plugin’s file path lies outside the workspace CWD for the regression test.
apps/oxlint/src/run.rs Updates N-API callback type definitions (JsLoadPluginCb, JsLintFileCb, JsCreateWorkspaceCb, JsDestroyWorkspaceCb) to use workspace URIs instead of directories and to pass workspaceUri through to JS.
apps/oxlint/src/lsp/server_linter.rs Switches LSP-side workspace creation/destruction to use URIs, passes workspace URIs into config loading and JS plugin setup, and initializes Linter instances with their corresponding workspace_uri.
apps/oxlint/src/lint.rs For CLI, uses a dummy workspace URI and passes workspace_uri: None into config building and rule-config setup, ensuring the single-workspace CLI path works with the new API.
apps/oxlint/src/js_plugins/external_linter.rs Extends the JS-bridge wrappers for create_workspace, destroy_workspace, load_plugin, and lint_file to accept and forward workspace URIs between Rust and JS.
apps/oxlint/src/config_loader.rs Adds a workspace_uri field to ConfigLoader and propagates it into ConfigStoreBuilder::from_oxlintrc, so nested configs also load plugins with the correct workspace ID.
apps/oxlint/src-js/workspace/index.ts Refactors the workspace model to treat workspaces as opaque IDs stored in a Set, removes path-based workspace resolution helpers, and adds getCliWorkspace for the single-workspace CLI case.
apps/oxlint/src-js/plugins/options.ts Changes options storage to be keyed by workspace ID, adds a cwds map from workspace ID to CWD, and adjusts setOptions to use workspaceUri (or getCliWorkspace in CLI mode).
apps/oxlint/src-js/plugins/load.ts Removes URL-based workspace detection, adds a workspaceUri parameter to loadPlugin/registerPlugin, and uses workspace IDs (or the CLI workspace) to index registeredRules.
apps/oxlint/src-js/plugins/lint.ts Accepts a workspaceUri when linting, uses cwds and workspace ID to set up the file context, and looks up rules/options by workspace ID instead of computing the “responsible” workspace from file path.
apps/oxlint/src-js/package/rule_tester.ts Adapts the rule tester to the new workspace model by using a dummy workspace URI, passing workspaceUri: null to options, and calling lintFileImpl with workspaceUri: null.
apps/oxlint/src-js/cli.ts Plumbs workspaceUri through the CLI’s loadPluginWrapper and lintFileWrapper so that the native Rust side can distinguish workspaces while keeping CLI’s single-workspace usage simple.
apps/oxlint/src-js/bindings.d.ts Updates type declarations for JsLoadPluginCb and JsLintFileCb to accept an optional workspaceUri argument, keeping TS types aligned with the Rust N-API definitions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

@overlookmotel overlookmotel force-pushed the om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace branch from ac5c41b to 94aa384 Compare January 31, 2026 13:49
@camc314 camc314 changed the base branch from om/01-31-test_linter_plugins_add_cwd_option_to_tests to graphite-base/18755 January 31, 2026 14:44
@camc314 camc314 force-pushed the om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace branch from 94aa384 to e9e63ad Compare January 31, 2026 14:47
@camc314 camc314 force-pushed the graphite-base/18755 branch from ce78b95 to 0c06484 Compare January 31, 2026 14:47
@camc314 camc314 changed the base branch from graphite-base/18755 to om/01-31-test_linter_plugins_add_cwd_option_to_tests January 31, 2026 14:47
@overlookmotel overlookmotel marked this pull request as draft January 31, 2026 14:51
@overlookmotel
Copy link
Member Author

overlookmotel commented Jan 31, 2026

I've marked this as draft as there's a CI fail on #18763 and unclear where it's coming from exactly. Probably another PR in this stack. So I think we should pause merging from this PR onwards until we've tracked it down.

Oh wait, now it's failing on this PR too!

@graphite-app graphite-app bot changed the base branch from om/01-31-test_linter_plugins_add_cwd_option_to_tests to graphite-base/18755 January 31, 2026 14:56
graphite-app bot pushed a commit that referenced this pull request Jan 31, 2026
Add a `cwd` option to tests which causes test runner to run Oxlint from within a subfolder inside a fixture dir, instead of root of the fixture dir. This is used for the test added in next PR (#18755).
@graphite-app graphite-app bot force-pushed the graphite-base/18755 branch from 0c06484 to b002aa5 Compare January 31, 2026 15:03
@graphite-app graphite-app bot force-pushed the om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace branch from e9e63ad to 7ef5801 Compare January 31, 2026 15:03
@graphite-app graphite-app bot changed the base branch from graphite-base/18755 to main January 31, 2026 15:03
@graphite-app graphite-app bot force-pushed the om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace branch from 7ef5801 to 048b513 Compare January 31, 2026 15:04
@overlookmotel overlookmotel force-pushed the om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace branch from 048b513 to 7ef5801 Compare January 31, 2026 15:14
@overlookmotel overlookmotel marked this pull request as ready for review January 31, 2026 15:21
@overlookmotel
Copy link
Member Author

Actually, the CI fails only relate to LSP, and we disabled JS plugins in LSP except in tests. So I think we could merge the stack and then hunt down the bug causing VSCode test fails later on.

@camc314 camc314 added the 0-merge Merge with Graphite Merge Queue label Jan 31, 2026
Copy link
Contributor

camc314 commented Jan 31, 2026

Merge activity

## The problem

### 1. Bug with plugins outside of workspace directory

There was a bug in workspaces implementation. `loadPlugin` was assuming that the plugin file is within the workspace, but that's not necessarily the case. e.g.:

* Workspace 1: `/path/to/project/packages/foo`
* Workspace 2: `/path/to/project/packages/bar`
* JS plugin: `/path/to/project/node_modules/eslint-plugin-whatever`

This problem arises because `loadPlugin` is determining which workspace to load a plugin for by search for the workspace whose root directory contains the plugin file.

### 2. Performance

Every call to `loadPlugin`, `setupRuleConfigs`, or `lintFile` was first searching through all workspaces to find the right one (`getResponsibleWorkspace`).

This is really expensive when there are lots of workspaces, and quite expensive even when there's only 1 because iterating hashsets is relatively slow.

## This PR

This PR separates out CWD and workspace identifiers into 2 different concerns.

* Each workspace has an ID which is unique.
* Each workspace has a CWD (path to root directory as a file path).

The workspace ID is currently the workspace's root directory as a `file://...` URL. Using that as it's guaranteed unique, even for paths which are not valid UTF-8 (`Path::to_string_lossy` can produce the same `&str` for different paths). But ID it doesn't have to be the URI - we could change it later to an integer ID if we want.

Rust-side code passes the workspace ID to every call to `loadPlugin`, `setupRuleConfigs`, or `lintFile`.

In CLI, it just passes `null` as the ID. There's only ever 1 workspace in CLI, so no point in the expense of passing a `String` on every call.

These functions look up the CWD from the workspace ID in the `cwds` map.

Separating these 2 concerns solves both problems listed above:

1. Plugins can be outside of the workspace's CWD (see added test).
2. `getResponsibleWorkspace` is removed, solving the perf issue.

Note: The multiple hashmap lookups on each call to `lintFile` remain a perf drain. This is addressed in a later PR in this stack.
@graphite-app graphite-app bot force-pushed the om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace branch from 7ef5801 to 005910a Compare January 31, 2026 15:27
@graphite-app graphite-app bot merged commit 005910a into main Jan 31, 2026
22 checks passed
@graphite-app graphite-app bot deleted the om/01-31-fix_linter_plugins_support_plugins_outside_of_workspace branch January 31, 2026 15:33
@graphite-app graphite-app bot removed the 0-merge Merge with Graphite Merge Queue label Jan 31, 2026
camc314 added a commit that referenced this pull request Feb 2, 2026
# Oxlint
### 💥 BREAKING CHANGES

- b34a155 linter/plugins: [**BREAKING**] `RuleTester` set
`context.filename` to absolute path (#18702) (overlookmotel)

### 🚀 Features

- 1753209 linter/vscode: Run extension when JS configs are detected
(#18832) (camc314)
- c962dd2 linter/lsp: Implement support for oxlint.config.ts (#18826)
(camc314)
- da32203 linter: Auto generate oxlint.config.ts types (#18597)
(camc314)
- 19b4df7 oxlint: Introduce `defineConfig` helper (#18596) (camc314)
- ea97231 linter: Implement `oxlint.config.ts` support (#17563)
(camc314)
- 17ca42d linter: Implement `react/no-multi-comp` rule. (#18794)
(connorshea)
- 88f30e0 linter/plugins: Move eslint compatible plugin conversion to
`eslintCompatPlugin` function (#18791) (overlookmotel)
- 2a72794 linter/plugins: `RuleTester` take `cwd` property (#18756)
(overlookmotel)
- 9f533db linter: Add `find_prev_token_within` method for token search
(#18769) (camc314)
- 772ea70 linter: Introduce `load_js_configs` napi callback (#18767)
(camc314)
- e9690c1 linter: Introduce `DiscoveredConfig` in preparation for JS
configs (#18674) (camc314)
- 558b588 linter/prefer-namespace-keyword: Move to correctness (#18733)
(camc314)
- 7a5c268 oxlint/lsp: Support `jsPlugins` (#17840) (Sysix)
- c07497c linter/prefer-modern-dom-apis: Implement suggestion (#17965)
(Mikhail Baev)
- 8531bc9 linter: Implement `prefer-const` (#18687) (camchenry)
- 8670b18 parser: Error on ambient class accessor implementations
(#18592) (camc314)
- 6b8a5ae linter: Add `eslint-plugin-import/no-nodejs-modules` rule
(#18006) (Mikhail Baev)
- 04f400d linter/no-duplicates: Add support for `considerQueryString`
option (#18657) (camc314)
- 3b7f260 linter/consistent-generic-constructor: Implement fixer
(#18616) (camc314)
- 794f9e4 linter/prefer-exponentation-operator: Implement suggestion
(#18602) (camc314)
- 773d916 linter: `eslint/sort_keys` ignore leading and trailing spreads
in auto-fix (#18485) (Lonami)
- 20d4ede linter: Implement `import/no-relative-parent-imports` rule
(#18513) (Valentin Maerten)
- 0da45ef vscode: Fallback to globally installed oxlint/oxfmt packages
(#18007) (Sysix)

### 🐛 Bug Fixes

- a3417b1 linter/plugins: Clear state when reloading workspace (#18837)
(overlookmotel)
- c879992 linter: Error on arrays passed in as config (#18822) (camc314)
- 5c80422 linter/tsdown: Ensure relative path for globals import starts
with `./` (#18820) (camc314)
- 7419dfb linter: Remove invalid debug assersion, add test (#18819)
(camc314)
- 0ca6269 ci: Fix the repo path normalization logic for tests on
Windows. (#18815) (connorshea)
- c7b0a65 linter: Fix config option docs for `react/jsx-boolean-value`
rule. (#18811) (connorshea)
- cce374e linter/prefer-const: Replace entire declaration over just the
`let` kw (#18814) (camc314)
- 41f92d1 linter: Error when given config options for a lint rule that
has no config options defined. (#18809) (connorshea)
- 0867a36 linter/consistent-index-object-style: False positive with
mapped + generic types (#18801) (camc314)
- 1d34b42 linter: Fix 32 bit build (#18783) (camc314)
- 95df577 linter/plugins: Handle error from `destroyWorkspace` (#18763)
(overlookmotel)
- b3261dc linter: Fix the curly rule config to enforce the shape of the
config and emit correct docs (#18743) (connorshea)
- d981978 linter/plugins: Use non-blocking mode when calling
`destroyWorkspace` (#18762) (overlookmotel)
- 3f43d4c linter: Accept bools as valid values for `fixable` (#18772)
(camc314)
- 005910a linter/plugins: Support plugins outside of workspace (#18755)
(overlookmotel)
- fd92711 vscode: Use `fsPath` for workspace mapping (#18728) (Sysix)
- 358b2c1 linter/consistent-generic-constructors: Check bounds when
searching for `:` token (#18745) (connorshea)
- abd0c28 linter/capitalized-comments: Fix generated rule option docs
(#18748) (connorshea)
- d90a9f6 linter: Add more tests for `prefer-const`'s fixer and fix its
invalid behavior. (#18747) (connorshea)
- f82011b oxlint/lsp: Disable JS plugins support in LSP except in tests
(#18727) (overlookmotel)
- 94505c8 linter/jest: Change `prefer-spy-on` autofix to suggestion
(#18152) (Ben Lowery)
- 6ec1112 linter: Mark unused disable directive fix as suggestion
(#18703) (ddmoney420)
- 49609ec linter/no-useless-constructor: Consider argument
transformation as used (#18706) (ddmoney420)
- 40218de linter: Fix behavior of
jsx-a11y/no-static-element-interactions rule. (#17817) (connorshea)
- db9751d linter/no-html-link-for-pages: Handle `target=_blank`
correctly (#18693) (camc314)
- e440b78 linter/plugins: Pass all args to CFG event handlers when 2
rules use same handler (#18683) (overlookmotel)
- b393430 linter/curly: Fix multi-or-nest and consistent conflict
(#18660) (camc314)
- 2e1fbc2 linter/plugins: Implement `context.parserPath` (#18644)
(overlookmotel)
- 34951ed linter/plugins: `filename` option takes precedence over
`parserOptions.lang` in `RuleTester` (#18643) (overlookmotel)
- 28df160 linter/plugins: Allow line number passed to `report` to be 0
(#18642) (overlookmotel)
- 14fabec vscode: Use built-in `getWorkspaceFolder` for detecting the
right workspace of a given uri (#18583) (Sysix)
- 0ff4cea oxlint/cli: Report error when nested config could not be
parsed (#18504) (Sysix)

### ⚡ Performance

- 9862224 linter/plugins: Reduce cost of workspaces (#18758)
(overlookmotel)
- 6bc0bde linter: Remove string allocation (#18725) (overlookmotel)
- 3a6b41e linter/plugins: Replace ESLint Traverser with lightweight
traverseNode (#18529) (Rintaro Itokawa)

### 📚 Documentation

- dd1a653 linter: Fix doc comment for ignoreStateless config option.
(#18808) (connorshea)
- 5909085 linter/plugins: Add doc comments (#18753) (overlookmotel)
- ffe53a3 linter: Update lint function docs (#18766) (camc314)
- b82faec linter: Glob for any css module for no-unassigned-import
(#18713) (Ben Stickley)
- cd86347 linter: Mark some react rules as unsupported, misc docs
improvements (#18617) (connorshea)
- 23401d8 linter: Update fixes and suggestions status for tsgolint rules
(#18619) (camchenry)
# Oxfmt
### 🚀 Features

- ee30de9 oxfmt: Add config migration from biome (#18638) (Luca Fischer)

### 🐛 Bug Fixes

- e754b18 oxfmt/migrate-prettier: Set `experimentalSortPackagejson:
false` by default (#18831) (leaysgur)
- a83c266 formatter: Keep decorated function pattern hugged when params
break (#18830) (Dunqing)
- 0c8efe1 formatter: Quote numeric property keys with `quoteProps:
consistent` (#18803) (Dunqing)
- 9c14c3e formatter: Ignore comment does not work for sequence
expressions in arrow function body (#18799) (Dunqing)
- 54984ae formatter: Handle leading comments in arrow function sequence
expressions (#18798) (Dunqing)
- 61bb2b5 formatter: Correctly expand JSX returned from arrow callbacks
in JSX expression containers (#18797) (Dunqing)
- 34ee194 formatter: Tailwindcss sorting doesn't work for object
property keys (#18773) (Dunqing)
- 48f1e35 oxfmt: Prevent ThreadsafeFunction crash on Node.js exit
(#18723) (Boshen)
- e96adca formatter: Follow Prettier's approach for for-in initializer
parentheses (#18695) (Dunqing)
- 1215a6f formatter: Preserve quote for class property key in TypeScript
(#18692) (Dunqing)
- 059acae formatter: Incorrect comments placement for union type in
`TSTypeIntersection` (#18690) (Dunqing)
- c3d05c1 formatter,oxfmt: Handle CRLF with embedded formatting (#18686)
(leaysgur)
- 7cb3085 formatter: Preserve comments on rest elements (#18649)
(Dunqing)
- 21984dd formatter: Preserve type cast comments on rest parameters
(#18648) (Dunqing)
- 2f70254 formatter: Don't add extra semicolon on suppressed class
properties (#18631) (Dunqing)
- ac1ff4e oxfmt: Use `empty_line` IR for empty xxx-in-js line (#18623)
(leaysgur)
- 8f76900 oxfmt: Dedent xxx-in-js templates before calling prettier
(#18622) (leaysgur)
- 6b726ef oxfmt: Trim whitespace only xxx-in-js templates (#18621)
(leaysgur)

Co-authored-by: camc314 <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-cli Area - CLI A-linter Area - Linter A-linter-plugins Area - Linter JS plugins C-bug Category - Bug

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

Comments