Skip to content

fix: resolve plugin paths from extended configs#9233

Open
soconnor-seeq wants to merge 1 commit intobiomejs:mainfrom
soconnor-seeq:fix/extended-plugin-paths
Open

fix: resolve plugin paths from extended configs#9233
soconnor-seeq wants to merge 1 commit intobiomejs:mainfrom
soconnor-seeq:fix/extended-plugin-paths

Conversation

@soconnor-seeq
Copy link

This PR was created with AI assistance (Codex).

Note

(from the human) I don’t know Rust at all & all these changes were implemented via Codex with my guidance. I've built the binary and tested it locally using my monorepo as a test case (see example layout below).

Summary

Fixes plugin path resolution for plugins listed in extended config files and adds @-specifier support via package.json resolution, while preserving legacy non‑@ behavior.

Context

Previously, plugin paths from extended configs were normalized only after all configs were merged, so relative plugin paths were resolved against the entry config rather than the config file where they were declared. This change normalizes plugin paths at load time so extended configs resolve their own relative plugin paths correctly.

My use case is a PNPM monorepo with a shared tools package. extends resolves fine, but custom Grit plugins declared in the shared config were not resolved relative to the common/tools package.

Example layout
repo/
  package-a/
    package.json
    biome.json
  package-b/
    package.json
    biome.json
  package-tools/
    package.json
    biome-common.json
    plugins/
      plugin-a.grit

package-a/biome.json:

{
  "extends": ["@my-scope/package-tools/biome-common.json"]
}

package-b/biome.json:

{
  "extends": ["@my-scope/package-tools/biome-common.json"]
}

package-tools/biome-common.json:

{
  "plugins": ["./plugins/plugin-a.grit"]
}

package-tools/package.json (PNPM workspace structure, abbreviated):

{
  "name": "@my-scope/package-tools",
  "private": true
}

package-a/package.json / package-b/package.json:

{
  "name": "@my-scope/package-a",
  "private": true
}

Test Plan

  • cargo test -p biome_service should_normalize_plugin_paths_in_extended_configuration
  • cargo test -p biome_service plugins_support_package_specifiers_via_package_json
  • cargo test -p biome_cli --test main -- tests/ config.rs::extends_config_extends_external_plugins_without_relative_paths tests/ config.rs::extends_config_extends_plugins_with_relative_paths tests/ config.rs::extends_config_extends_plugins_only_from_base
  • cargo test -p biome_lsp --lib

Docs

Not applicable.

Future Enhancement

Note that the changes in this PR are 100% backward‑compatible with the existing plugin file resolution (the current "extend"-ed plugins paths are broken ATM). Any non‑@ plugin specifiers still use the current behaviour (so a simple-path like "plugins": ["foo.grit"] resolves as before — relative to the entry config — only ./ or ../-prefixes are treated as explicit relative paths to the declaring config).

We could allow a small-ish "breaking" change to allow unscoped package names to resolve via package.json or node_modules. This could be done via an explicit ./ or ../ (or sniff the file system, package.json, etc) to distinguish file paths from package names. The benefit here would be to make it more obvious between: a) a relative plugin ./ or ../-prefixed path or b) a "external" package (either via workspaces or npmjs.com).

@changeset-bot
Copy link

changeset-bot bot commented Feb 24, 2026

🦋 Changeset detected

Latest commit: bc8b25a

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 13 packages
Name Type
@biomejs/biome Patch
@biomejs/cli-win32-x64 Patch
@biomejs/cli-win32-arm64 Patch
@biomejs/cli-darwin-x64 Patch
@biomejs/cli-darwin-arm64 Patch
@biomejs/cli-linux-x64 Patch
@biomejs/cli-linux-arm64 Patch
@biomejs/cli-linux-x64-musl Patch
@biomejs/cli-linux-arm64-musl Patch
@biomejs/wasm-web Patch
@biomejs/wasm-bundler Patch
@biomejs/wasm-nodejs Patch
@biomejs/backend-jsonrpc Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions github-actions bot added the A-Project Area: project label Feb 24, 2026
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 24, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e9d5a9f and bc8b25a.

📒 Files selected for processing (1)
  • .changeset/fix-extended-plugin-paths.md
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-extended-plugin-paths.md

Walkthrough

This patch resolves plugin paths in extended configuration files relative to their extended config location rather than the root config directory. A new private helper function normalises plugin paths across a configuration and its overrides, consolidating previously inline logic. The function is invoked when processing extended configurations prior to final assembly, and test coverage has been expanded to verify normalisation in extended config scenarios.

Possibly related PRs

Suggested labels

A-CLI, A-LSP

Suggested reviewers

  • ematipico
  • dyc3
  • arendjr
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: fixing plugin path resolution in extended configuration files.
Description check ✅ Passed The description comprehensively explains the bug, rationale, implementation details, test coverage, and use case for resolving plugin paths from extended configs.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (1)
crates/biome_service/src/workspace.tests.rs (1)

566-646: Optional: make the assertion order‑agnostic. It’ll be a touch less brittle if diagnostic ordering changes later.

♻️ Possible tweak
-    let rendered = print_diagnostic_to_string(&Error::from(result.diagnostics[0].clone()));
-    assert!(rendered.contains("Prefer object spread instead of `Object.assign()`"));
+    let rendered_any = result.diagnostics.iter().any(|diag| {
+        print_diagnostic_to_string(&Error::from(diag.clone()))
+            .contains("Prefer object spread instead of `Object.assign()`")
+    });
+    assert!(rendered_any);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_service/src/workspace.tests.rs` around lines 566 - 646, The test
plugins_support_package_specifiers_via_package_json currently assumes the
expected diagnostic is at result.diagnostics[0], which is brittle; update the
final assertion to be order‑agnostic by checking that any diagnostic in
result.diagnostics contains the message ("Prefer object spread instead of
`Object.assign()`") instead of indexing the first element—use
result.diagnostics.iter().any(...) or equivalent over each diagnostic
(render/convert each diagnostic to string as done with
print_diagnostic_to_string/Error::from) and assert that returns true while
keeping the existing checks like assert_eq!(result.errors, 0) and that
diagnostics is not empty.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@crates/biome_service/src/workspace.tests.rs`:
- Around line 566-646: The test
plugins_support_package_specifiers_via_package_json currently assumes the
expected diagnostic is at result.diagnostics[0], which is brittle; update the
final assertion to be order‑agnostic by checking that any diagnostic in
result.diagnostics contains the message ("Prefer object spread instead of
`Object.assign()`") instead of indexing the first element—use
result.diagnostics.iter().any(...) or equivalent over each diagnostic
(render/convert each diagnostic to string as done with
print_diagnostic_to_string/Error::from) and assert that returns true while
keeping the existing checks like assert_eq!(result.errors, 0) and that
diagnostics is not empty.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between edb536a and 3339791.

📒 Files selected for processing (4)
  • .changeset/fix-extended-plugin-paths.md
  • crates/biome_service/src/configuration.rs
  • crates/biome_service/src/workspace.tests.rs
  • crates/biome_service/src/workspace/server.rs

@ematipico
Copy link
Member

What bug are we fixing? Is there one already?

@ematipico
Copy link
Member

so relative plugin paths were resolved against the entry config rather than the config file where they were declared

But that's how it should be, because it works the same as the rest of the other resolution paths. Is there a specific reason why we should diverge from that?

@soconnor-seeq
Copy link
Author

What bug are we fixing? Is there one already?

Oh does there need to be a bug / issue created before a PR?

But that's how it should be, because it works the same as the rest of the other resolution paths. Is there a specific reason why we should diverge from that?

The current implementation makes it impossible for me to make a common tools package w/ a standard monorepo-biome-config that can be "extend"-ed from other packages. I have custom Grit plugins in a common tools package that were all being resolved to the "entry" biome.json file (e.g. @my-scope/package-a's) location.

This change makes it so that relative URLs are resolved relative to the biome.json they are defined in. E.g. in a common-tools-package I could have biome.json w/ plugins: ['./foo.grit'] w/ a foo.grit file living alongside that common-tools-package's biome.json.

@soconnor-seeq
Copy link
Author

I don't know why I didn't come across it before but re-searching issues / discussion it looks like this has come up before.

@ematipico
Copy link
Member

I'm willing to accept this PR, considering we have multiple reports, however I think we should also update the documentation of the plugins and the documentation of the configuration, because we're adding an "exception to the rule".

People usually are in charge of documentation. Docs are in biomejs/website repository

@soconnor-seeq
Copy link
Author

I think we should also update the documentation of the plugins and the documentation of the configuration, because we're adding an "exception to the rule".
People usually are in charge of documentation. Docs are in biomejs/website repository

Can do! I've created the website documentation change PR.

@ematipico ematipico self-assigned this Feb 26, 2026
@soconnor-seeq soconnor-seeq force-pushed the fix/extended-plugin-paths branch from 3339791 to e9d5a9f Compare February 26, 2026 16:43
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
crates/biome_service/src/workspace/server.rs (3)

823-858: Consider adding rustdoc for resolve_plugin_specifier.

This method has nuanced behaviour (handling absolute paths, relative specifiers, @-scoped packages, and biome-manifest special-casing). A brief rustdoc would help future maintainers understand the resolution semantics without diving into the implementation.

As per coding guidelines: "Use inline rustdoc documentation for rules, assists, and their options."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_service/src/workspace/server.rs` around lines 823 - 858, Add a
concise rustdoc for the resolve_plugin_specifier method explaining its
resolution rules: that it returns absolute/relative specifiers as-is, treats
non-@ specifiers as plain paths, uses ResolveOptions (RESOLVE_OPTIONS) for
`@-scoped` packages with condition names ("biome","default"), default file
"biome-manifest" and extensions ("jsonc","grit","js","mjs"), and that it
special-cases "biome-manifest.jsonc" to return the parent directory; mention the
error behavior (returns PluginDiagnostic via PluginDiagnostic::cant_resolve on
resolve failures). Reference the function name resolve_plugin_specifier and the
RESOLVE_OPTIONS constant in the doc.

796-818: Consider caching resolved paths to avoid double resolution.

The plugin specifiers are resolved twice: once in load_plugins and again here for cache lookup. While this works correctly, if plugin resolution becomes more expensive (e.g., with complex package.json lookups), you might want to store the original-to-resolved mapping.

That said, for the current scope this is perfectly acceptable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_service/src/workspace/server.rs` around lines 796 - 818, The
code double-resolves plugin specifiers (once in load_plugins and again here) —
introduce and use a short-lived mapping from original specifier to resolved path
so resolution happens only once: when resolving plugins (where
resolve_plugin_specifier is currently called), store the mapping (e.g.,
HashMap<String, String> keyed by the PluginConfiguration input) and then update
this code path to look up resolved paths from that map instead of calling
resolve_plugin_specifier again; ensure the resolved collection you pass into
cache.get_analyzer_plugins is built from that map (maintain the existing Plugins
wrapper and continue to push PluginConfiguration::Path(resolved) into resolved.

847-857: Edge case: parent() returning None yields the file path, not a directory.

If the resolved path is somehow just biome-manifest.jsonc without any parent (unlikely but possible), unwrap_or(resolved.as_path()) returns the file path itself rather than a meaningful directory.

Consider making this explicit or adding a debug assertion:

🔧 Suggested fix
         if resolved
             .file_name()
             .is_some_and(|name| name == "biome-manifest.jsonc")
         {
-            Ok(resolved
-                .parent()
-                .unwrap_or(resolved.as_path())
-                .to_path_buf())
+            Ok(resolved.parent().map_or_else(
+                || {
+                    // This shouldn't happen in practice - a manifest file should always have a parent
+                    debug_assert!(false, "biome-manifest.jsonc resolved without parent directory");
+                    resolved.to_path_buf()
+                },
+                |parent| parent.to_path_buf(),
+            ))
         } else {
             Ok(resolved)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/biome_service/src/workspace/server.rs` around lines 847 - 857, When
handling the case where resolved.file_name() == "biome-manifest.jsonc", make the
parent-none case explicit: instead of using unwrap_or(resolved.as_path()) (which
returns the file path), check resolved.parent() and if Some(parent) return
parent.to_path_buf(), otherwise add a debug_assert!(resolved.parent().is_some())
and return a safe fallback directory (for example std::env::current_dir() or an
appropriate error) so the function always returns a directory path rather than
the file itself; update the branch that references resolved, file_name(),
parent(), unwrap_or(), and to_path_buf() accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@crates/biome_service/src/workspace/server.rs`:
- Around line 823-858: Add a concise rustdoc for the resolve_plugin_specifier
method explaining its resolution rules: that it returns absolute/relative
specifiers as-is, treats non-@ specifiers as plain paths, uses ResolveOptions
(RESOLVE_OPTIONS) for `@-scoped` packages with condition names
("biome","default"), default file "biome-manifest" and extensions
("jsonc","grit","js","mjs"), and that it special-cases "biome-manifest.jsonc" to
return the parent directory; mention the error behavior (returns
PluginDiagnostic via PluginDiagnostic::cant_resolve on resolve failures).
Reference the function name resolve_plugin_specifier and the RESOLVE_OPTIONS
constant in the doc.
- Around line 796-818: The code double-resolves plugin specifiers (once in
load_plugins and again here) — introduce and use a short-lived mapping from
original specifier to resolved path so resolution happens only once: when
resolving plugins (where resolve_plugin_specifier is currently called), store
the mapping (e.g., HashMap<String, String> keyed by the PluginConfiguration
input) and then update this code path to look up resolved paths from that map
instead of calling resolve_plugin_specifier again; ensure the resolved
collection you pass into cache.get_analyzer_plugins is built from that map
(maintain the existing Plugins wrapper and continue to push
PluginConfiguration::Path(resolved) into resolved.
- Around line 847-857: When handling the case where resolved.file_name() ==
"biome-manifest.jsonc", make the parent-none case explicit: instead of using
unwrap_or(resolved.as_path()) (which returns the file path), check
resolved.parent() and if Some(parent) return parent.to_path_buf(), otherwise add
a debug_assert!(resolved.parent().is_some()) and return a safe fallback
directory (for example std::env::current_dir() or an appropriate error) so the
function always returns a directory path rather than the file itself; update the
branch that references resolved, file_name(), parent(), unwrap_or(), and
to_path_buf() accordingly.

ℹ️ Review info

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3339791 and e9d5a9f.

📒 Files selected for processing (4)
  • .changeset/fix-extended-plugin-paths.md
  • crates/biome_service/src/configuration.rs
  • crates/biome_service/src/workspace.tests.rs
  • crates/biome_service/src/workspace/server.rs
✅ Files skipped from review due to trivial changes (1)
  • .changeset/fix-extended-plugin-paths.md
🚧 Files skipped from review as they are similar to previous changes (2)
  • crates/biome_service/src/workspace.tests.rs
  • crates/biome_service/src/configuration.rs

@soconnor-seeq
Copy link
Author

@ematipico (sorry for the discussion / noise on this PR but) I am not wondering if this PR's approach is correct. The PR #8524 made it so root-configs resolve plugins relative to the root-config, this PR makes it so extends-ed plugin references are resolved relative to the biome.json they are defined in. With just this additional PR merged as-is (well after feedback, etc) we'll have two code paths for resolving plugins. Should this PR instead normalize these two cases of relative-plugin-path resolution?

Also it looks like the plugins array is changing from simple strings to also support an object syntax. Should this PR's relative path resolution also apply to "path"s defined in that object syntax (a.k.a. when should this PR land after either of those two other plugins-array-can-have-objects PRs?)

@soconnor-seeq soconnor-seeq force-pushed the fix/extended-plugin-paths branch from e9d5a9f to bc8b25a Compare February 26, 2026 21:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-Project Area: project

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants