fix(linter/plugins): support plugins outside of workspace#18755
Conversation
Merging this PR will not alter performance
Comparing Footnotes
|
There was a problem hiding this comment.
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_uriconcept in the Rust linter core and JS bridge, threading it throughcreate_workspace,loadPlugin,setupRuleConfigs, andlintFileinstead of inferring workspace from file paths. - Updates JS plugin infrastructure to index rules/options by workspace ID and maintain a separate
cwdsmap for CWDs, removinggetResponsibleWorkspaceand 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.
ac5c41b to
94aa384
Compare
94aa384 to
e9e63ad
Compare
ce78b95 to
0c06484
Compare
|
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! |
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).
0c06484 to
b002aa5
Compare
e9e63ad to
7ef5801
Compare
7ef5801 to
048b513
Compare
048b513 to
7ef5801
Compare
|
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. |
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.
7ef5801 to
005910a
Compare
# 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]>

The problem
1. Bug with plugins outside of workspace directory
There was a bug in workspaces implementation.
loadPluginwas assuming that the plugin file is within the workspace, but that's not necessarily the case. e.g.:/path/to/project/packages/foo/path/to/project/packages/bar/path/to/project/node_modules/eslint-plugin-whateverThis problem arises because
loadPluginis 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, orlintFilewas 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.
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_lossycan produce the same&strfor 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, orlintFile.In CLI, it just passes
nullas the ID. There's only ever 1 workspace in CLI, so no point in the expense of passing aStringon every call.These functions look up the CWD from the workspace ID in the
cwdsmap.Separating these 2 concerns solves both problems listed above:
getResponsibleWorkspaceis removed, solving the perf issue.Note: The multiple hashmap lookups on each call to
lintFileremain a perf drain. This is addressed in a later PR in this stack.