Repository: oxc-project/oxc
Pull request #10835: fix(linter): delay merging of oxlintrc configs
Author: login: camc314
Labels: name: C-bug
description: Category - Bug
name: A-linter
description: Area - Linter
name: A-cli
description: Area - CLI
Created: 2025-05-06T16:05:51Z | Merged: 2025-05-21T17:24:24Z


fixes #10760 

Linked issue #10760: linter: `oxlint` does the follow the complete `extends` chain.
### What version of Oxlint are you using?

0.16.8

### What command did you run?

oxlint --fix --deny-warnings

### What does your `.oxlintrc.json` config file look like?

```jsonc
// .oxlintrc.json
{
    "$schema": "./node_modules/oxlint/configuration_schema.json",
    "extends": ["./.oxlintrc.extended.json"]
}
```

```jsonc
// .oxlintrc.extended.json
{
    "$schema": "./node_modules/oxlint/configuration_schema.json",
    "extends": ["./.oxlintrc.base.json"]
}
```

```jsonc
// .oxlintrc.base.json
{
    "$schema": "./node_modules/oxlint/configuration_schema.json",
    "plugins": ["typescript", "unicorn", "perf", "oxc", "import", "promise", "node", "vitest", "jest"],
    "categories": {
        "correctness": "off",
        "suspicious": "off",
        "pedantic": "off",
        "perf": "off",
        "style": "off",
        "restriction": "off",
        "nursery": "off"
    },
    "ignorePatterns": ["**/node_modules", "pnpm-lock.yaml"],
    "rules": {
        "no-unused-vars": "off"
    }
}
```

### What happened?

Hello,

I've found that `oxlint` does not go through the full chain of `extends`, instead it stops after the first.
I don't know if that's by design, but such a chain of extends can be common, mostly in monorepo scenarios.

I have created a minimal repository where this can be observed https://github.com/ValentinGurkov/oxlint-extends-chain

The chain of `extends` is as follows: `.oxlintrc.json` -> `.oxlintrc.extended.json` -> `.oxlintrc.base.json` and `.oxlintrc.base.json` disables `no-unused-vars` which seems to be enabled by default.  The configuration from `.oxlintrc.base.json` does not get applied and warning is reported for `no-unsed-vars` on the file in the repository,

If I extend directly `.oxlintrc.base.json` and skip `.oxlintrc.extended.json`, then the rule is applied.

Diff:
diff --git a/apps/oxlint/src/lint.rs b/apps/oxlint/src/lint.rs
index 048c496a07928..1f54645da0d4e 100644
--- a/apps/oxlint/src/lint.rs
+++ b/apps/oxlint/src/lint.rs
@@ -180,7 +180,11 @@ impl Runner for LintRunner {
             FxHashMap::default()
         };
 
-        enable_plugins.apply_overrides(&mut oxlintrc.plugins);
+        {
+            let mut plugins = oxlintrc.plugins.unwrap_or_default();
+            enable_plugins.apply_overrides(&mut plugins);
+            oxlintrc.plugins = Some(plugins);
+        }
 
         let oxlintrc_for_print = if misc_options.print_config || basic_options.init {
             Some(oxlintrc.clone())
diff --git a/apps/oxlint/src/snapshots/fixtures__extends_config_--config relative_paths__extends_extends_config.json console.js@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__extends_config_--config relative_paths__extends_extends_config.json console.js@oxlint.snap
index f895571d05880..d36d7e290b588 100644
--- a/apps/oxlint/src/snapshots/fixtures__extends_config_--config relative_paths__extends_extends_config.json console.js@oxlint.snap	
+++ b/apps/oxlint/src/snapshots/fixtures__extends_config_--config relative_paths__extends_extends_config.json console.js@oxlint.snap	
@@ -14,7 +14,7 @@ working directory: fixtures/extends_config
   help: Delete this console statement.
 
 Found 0 warnings and 1 error.
-Finished in <variable>ms on 1 file with 103 rules using 1 threads.
+Finished in <variable>ms on 1 file with 102 rules using 1 threads.
 ----------
 CLI result: LintFoundErrors
 ----------
diff --git a/apps/oxlint/src/snapshots/fixtures__extends_config_overrides@oxlint.snap b/apps/oxlint/src/snapshots/fixtures__extends_config_overrides@oxlint.snap
index dd0cf95ea3c52..d470982ed2650 100644
--- a/apps/oxlint/src/snapshots/fixtures__extends_config_overrides@oxlint.snap
+++ b/apps/oxlint/src/snapshots/fixtures__extends_config_overrides@oxlint.snap
@@ -1,6 +1,5 @@
 ---
 source: apps/oxlint/src/tester.rs
-snapshot_kind: text
 ---
 ########## 
 arguments: overrides
@@ -22,6 +21,15 @@ working directory: fixtures/extends_config
    `----
   help: Use `unknown` instead, this will force you to explicitly, and safely, assert the type is correct.
 
+  ! ]8;;https://oxc.rs/docs/guide/usage/linter/rules/jsx_a11y/anchor-is-valid.html\eslint-plugin-jsx-a11y(anchor-is-valid)]8;;\: Missing `href` attribute for the `a` element.
+   ,-[overrides/test.tsx:2:11]
+ 1 | function component(): any {
+ 2 |   return <a>click here</a>;
+   :           ^
+ 3 | }
+   `----
+  help: Provide an `href` for the `a` element.
+
   x ]8;;https://oxc.rs/docs/guide/usage/linter/rules/jsx_a11y/anchor-ambiguous-text.html\eslint-plugin-jsx-a11y(anchor-ambiguous-text)]8;;\: Ambiguous text within anchor, screen reader users rely on link text for context.
    ,-[overrides/test.tsx:2:10]
  1 | function component(): any {
@@ -31,7 +39,7 @@ working directory: fixtures/extends_config
    `----
   help: Avoid using ambiguous text like "click here", replace it with more descriptive text that provides context.
 
-Found 0 warnings and 3 errors.
+Found 1 warning and 3 errors.
 Finished in <variable>ms on 2 files using 1 threads.
 ----------
 CLI result: LintFoundErrors
diff --git a/crates/oxc_linter/src/config/categories.rs b/crates/oxc_linter/src/config/categories.rs
index 5985be28324a6..7565471432793 100644
--- a/crates/oxc_linter/src/config/categories.rs
+++ b/crates/oxc_linter/src/config/categories.rs
@@ -1,4 +1,7 @@
-use std::{borrow::Cow, ops::Deref};
+use std::{
+    borrow::Cow,
+    ops::{Deref, DerefMut},
+};
 
 use rustc_hash::FxHashMap;
 use schemars::JsonSchema;
@@ -18,6 +21,12 @@ impl Deref for OxlintCategories {
     }
 }
 
+impl DerefMut for OxlintCategories {
+    fn deref_mut(&mut self) -> &mut Self::Target {
+        &mut self.0
+    }
+}
+
 impl OxlintCategories {
     pub fn filters(&self) -> impl Iterator<Item = LintFilter> + '_ {
         self.iter().map(|(category, severity)| LintFilter::new(*severity, *category).unwrap())
diff --git a/crates/oxc_linter/src/config/config_builder.rs b/crates/oxc_linter/src/config/config_builder.rs
index 9639bb4dbe21d..d6346b8cf2e29 100644
--- a/crates/oxc_linter/src/config/config_builder.rs
+++ b/crates/oxc_linter/src/config/config_builder.rs
@@ -78,99 +78,70 @@ impl ConfigStoreBuilder {
         start_empty: bool,
         oxlintrc: Oxlintrc,
     ) -> Result<Self, ConfigBuilderError> {
-        // TODO(refactor); can we make this function infallible, and move all the error handling to
-        // the `build` method?
-        let Oxlintrc {
-            plugins,
-            settings,
-            env,
-            globals,
-            categories,
-            rules: oxlintrc_rules,
-            overrides,
-            path,
-            ignore_patterns: _,
-            extends,
-        } = oxlintrc;
-
-        let config = LintConfig { plugins, settings, env, globals, path: Some(path) };
-        let rules =
-            if start_empty { FxHashMap::default() } else { Self::warn_correctness(plugins) };
-        let cache = RulesCache::new(config.plugins);
-        let mut builder = Self { rules, config, overrides, cache };
+        // TODO: this can be cached to avoid re-computing the same oxlintrc
+        fn resolve_oxlintrc_config(config: Oxlintrc) -> Result<Oxlintrc, ConfigBuilderError> {
+            let path = config.path.clone();
+            let root_path = path.parent();
+            let extends = config.extends.clone();
+
+            let mut oxlintrc = config;
+
+            for path in extends.iter().rev() {
+                if path.starts_with("eslint:") || path.starts_with("plugin:") {
+                    // `eslint:` and `plugin:` named configs are not supported
+                    continue;
+                }
+                // if path does not include a ".", then we will heuristically skip it since it
+                // kind of looks like it might be a named config
+                if !path.to_string_lossy().contains('.') {
+                    continue;
+                }
 
-        for filter in categories.filters() {
-            builder = builder.with_filter(&filter);
-        }
+                let path = match root_path {
+                    Some(p) => &p.join(path),
+                    None => path,
+                };
 
-        {
-            if !extends.is_empty() {
-                let config_path = builder.config.path.clone();
-                let config_path_parent = config_path.as_ref().and_then(|p| p.parent());
-
-                for path in &extends {
-                    if path.starts_with("eslint:") || path.starts_with("plugin:") {
-                        // eslint: and plugin: named configs are not supported
-                        continue;
-                    }
-                    // if path does not include a ".", then we will heuristically skip it since it
-                    // kind of looks like it might be a named config
-                    if !path.to_string_lossy().contains('.') {
-                        continue;
+                let extends_oxlintrc = Oxlintrc::from_file(path).map_err(|e| {
+                    ConfigBuilderError::InvalidConfigFile {
+                        file: path.display().to_string(),
+                        reason: e.to_string(),
                     }
+                })?;
 
-                    // resolve path relative to config path
-                    let path = match config_path_parent {
-                        Some(config_file_path) => &config_file_path.join(path),
-                        None => path,
-                    };
-                    // TODO: throw an error if this is a self-referential extend
-                    // TODO(perf): use a global config cache to avoid re-parsing the same file multiple times
-                    match Oxlintrc::from_file(path) {
-                        Ok(extended_config) => {
-                            // TODO(refactor): can we merge this together? seems redundant to use `override_rules` and then
-                            // use `ConfigStoreBuilder`, but we don't have a better way of loading rules from config files other than that.
-                            // Use `override_rules` to apply rule configurations and add/remove rules as needed
-                            extended_config
-                                .rules
-                                .override_rules(&mut builder.rules, &builder.cache.borrow());
-                            // Use `ConfigStoreBuilder` to load extended config files and then apply rules from those
-                            let mut extended_config_store =
-                                ConfigStoreBuilder::from_oxlintrc(true, extended_config)?;
-                            let rules = std::mem::take(&mut extended_config_store.rules);
-                            builder = builder.with_rules(rules);
-
-                            // Handle plugin inheritance
-                            let parent_plugins = extended_config_store.plugins();
-                            let child_plugins = builder.plugins();
-
-                            if child_plugins == LintPlugins::default() {
-                                // If child has default plugins, inherit from parent
-                                builder = builder.with_plugins(parent_plugins);
-                            } else if child_plugins != LintPlugins::empty() {
-                                // If child specifies plugins, combine with parent's plugins
-                                builder = builder.with_plugins(child_plugins.union(parent_plugins));
-                            }
-
-                            if !extended_config_store.overrides.is_empty() {
-                                let overrides =
-                                    std::mem::take(&mut extended_config_store.overrides);
-                                builder = builder.with_overrides(overrides);
-                            }
-                        }
-                        Err(err) => {
-                            return Err(ConfigBuilderError::InvalidConfigFile {
-                                file: path.display().to_string(),
-                                reason: err.to_string(),
-                            });
-                        }
-                    }
-                }
+                let extends = resolve_oxlintrc_config(extends_oxlintrc)?;
+
+                oxlintrc = oxlintrc.merge(extends);
             }
+            Ok(oxlintrc)
+        }
+
+        let oxlintrc = resolve_oxlintrc_config(oxlintrc)?;
 
+        let rules = if start_empty {
+            FxHashMap::default()
+        } else {
+            Self::warn_correctness(oxlintrc.plugins.unwrap_or_default())
+        };
+
+        let config = LintConfig {
+            plugins: oxlintrc.plugins.unwrap_or_default(),
+            settings: oxlintrc.settings,
+            env: oxlintrc.env,
+            globals: oxlintrc.globals,
+            path: Some(oxlintrc.path),
+        };
+        let cache = RulesCache::new(config.plugins);
+        let mut builder = Self { rules, config, overrides: oxlintrc.overrides, cache };
+
+        for filter in oxlintrc.categories.filters() {
+            builder = builder.with_filter(&filter);
+        }
+
+        {
             let all_rules = builder.cache.borrow();
 
-            oxlintrc_rules.override_rules(&mut builder.rules, all_rules.as_slice());
+            oxlintrc.rules.override_rules(&mut builder.rules, all_rules.as_slice());
         }
 
         Ok(builder)
@@ -218,14 +189,6 @@ impl ConfigStoreBuilder {
         self
     }
 
-    pub(crate) fn with_rules<R: IntoIterator<Item = (RuleEnum, AllowWarnDeny)>>(
-        mut self,
-        rules: R,
-    ) -> Self {
-        self.rules.extend(rules);
-        self
-    }
-
     /// Appends an override to the end of the current list of overrides.
     pub fn with_overrides<O: IntoIterator<Item = OxlintOverride>>(mut self, overrides: O) -> Self {
         self.overrides.extend(overrides);
diff --git a/crates/oxc_linter/src/config/config_store.rs b/crates/oxc_linter/src/config/config_store.rs
index fd9175009ce53..8f8bf7836d891 100644
--- a/crates/oxc_linter/src/config/config_store.rs
+++ b/crates/oxc_linter/src/config/config_store.rs
@@ -142,11 +142,14 @@ impl Config {
     }
 }
 
-/// Resolves a lint configuration for a given file, by applying overrides based on the file's path.
+/// Stores the configuration state for the linter including:
+/// 1. the root configuration (base)
+/// 2. any nested configurations (`nested_configs`)
+///
+/// If an explicit config has been provided `-c config.json`, then `nested_configs` will be empty
 #[derive(Debug, Clone)]
 pub struct ConfigStore {
     base: Config,
-
     nested_configs: FxHashMap<PathBuf, Config>,
 }
 
diff --git a/crates/oxc_linter/src/config/mod.rs b/crates/oxc_linter/src/config/mod.rs
index fab4d0472f9f5..4cac7ced47d9f 100644
--- a/crates/oxc_linter/src/config/mod.rs
+++ b/crates/oxc_linter/src/config/mod.rs
@@ -36,7 +36,7 @@ pub struct LintConfig {
 impl From<Oxlintrc> for LintConfig {
     fn from(config: Oxlintrc) -> Self {
         Self {
-            plugins: config.plugins,
+            plugins: config.plugins.unwrap_or_default(),
             settings: config.settings,
             env: config.env,
             globals: config.globals,
diff --git a/crates/oxc_linter/src/config/overrides.rs b/crates/oxc_linter/src/config/overrides.rs
index 0761713d268aa..fab21d083a45b 100644
--- a/crates/oxc_linter/src/config/overrides.rs
+++ b/crates/oxc_linter/src/config/overrides.rs
@@ -92,7 +92,7 @@ pub struct OxlintOverride {
 ///
 /// Thin wrapper around [`globset::GlobSet`] because that struct doesn't implement Serialize or schemars
 /// traits.
-#[derive(Clone, Debug, Default)]
+#[derive(Clone, Default)]
 pub struct GlobSet {
     /// Raw patterns from the config. Inefficient, but required for [serialization](Serialize),
     /// which in turn is required for `--print-config`.
@@ -126,6 +126,12 @@ impl GlobSet {
     }
 }
 
+impl std::fmt::Debug for GlobSet {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_tuple("GlobSet").field(&self.raw).finish()
+    }
+}
+
 impl Serialize for GlobSet {
     fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
         self.raw.serialize(serializer)
diff --git a/crates/oxc_linter/src/config/oxlintrc.rs b/crates/oxc_linter/src/config/oxlintrc.rs
index 51647b5579856..620ff565e6787 100644
--- a/crates/oxc_linter/src/config/oxlintrc.rs
+++ b/crates/oxc_linter/src/config/oxlintrc.rs
@@ -3,6 +3,7 @@ use std::{
     path::{Path, PathBuf},
 };
 
+use rustc_hash::FxHashMap;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 
@@ -62,7 +63,7 @@ use super::{
 #[serde(default)]
 #[non_exhaustive]
 pub struct Oxlintrc {
-    pub plugins: LintPlugins,
+    pub plugins: Option<LintPlugins>,
     pub categories: OxlintCategories,
     /// Example
     ///
@@ -147,6 +148,53 @@ impl Oxlintrc {
 
         Ok(config)
     }
+
+    /// Merges two [Oxlintrc] files together
+    /// [Self] takes priority over `other`
+    #[must_use]
+    pub fn merge(&self, other: Oxlintrc) -> Oxlintrc {
+        let mut categories = other.categories.clone();
+        categories.extend(self.categories.iter());
+
+        let rules = self
+            .rules
+            .rules
+            .iter()
+            .chain(&other.rules.rules)
+            .fold(FxHashMap::default(), |mut rules_set, rule| {
+                if rules_set.contains_key(&(&rule.plugin_name, &rule.rule_name)) {
+                    return rules_set;
+                }
+                rules_set.insert((&rule.plugin_name, &rule.rule_name), rule);
+                rules_set
+            })
+            .values()
+            .map(|rule| (**rule).clone())
+            .collect::<Vec<_>>();
+
+        let settings = self.settings.clone();
+        let env = self.env.clone();
+        let globals = self.globals.clone();
+
+        let mut overrides = self.overrides.clone();
+        overrides.extend(other.overrides);
+
+        Oxlintrc {
+            plugins: self.plugins.map_or_else(
+                || other.plugins,
+                |p| Some(other.plugins.map_or_else(|| p, |p2| p2.union(p))),
+            ),
+            categories,
+            rules: OxlintRules::new(rules),
+            settings,
+            env,
+            globals,
+            overrides,
+            path: self.path.clone(),
+            ignore_patterns: self.ignore_patterns.clone(),
+            extends: self.extends.clone(),
+        }
+    }
 }
 
 fn is_json_ext(ext: &str) -> bool {
@@ -162,7 +210,7 @@ mod test {
     #[test]
     fn test_oxlintrc_de_empty() {
         let config: Oxlintrc = serde_json::from_value(json!({})).unwrap();
-        assert_eq!(config.plugins, LintPlugins::default());
+        assert_eq!(config.plugins, None);
         assert_eq!(config.rules, OxlintRules::default());
         assert!(config.rules.is_empty());
         assert_eq!(config.settings, OxlintSettings::default());
@@ -174,29 +222,29 @@ mod test {
     #[test]
     fn test_oxlintrc_de_plugins_empty_array() {
         let config: Oxlintrc = serde_json::from_value(json!({ "plugins": [] })).unwrap();
-        assert_eq!(config.plugins, LintPlugins::empty());
+        assert_eq!(config.plugins, Some(LintPlugins::empty()));
     }
 
     #[test]
     fn test_oxlintrc_empty_config_plugins() {
         let config: Oxlintrc = serde_json::from_str(r"{}").unwrap();
-        assert_eq!(config.plugins, LintPlugins::default());
+        assert_eq!(config.plugins, None);
     }
 
     #[test]
     fn test_oxlintrc_specifying_plugins_will_override() {
         let config: Oxlintrc = serde_json::from_str(r#"{ "plugins": ["react", "oxc"] }"#).unwrap();
-        assert_eq!(config.plugins, LintPlugins::REACT.union(LintPlugins::OXC));
+        assert_eq!(config.plugins, Some(LintPlugins::REACT.union(LintPlugins::OXC)));
         let config: Oxlintrc =
             serde_json::from_str(r#"{ "plugins": ["typescript", "unicorn"] }"#).unwrap();
-        assert_eq!(config.plugins, LintPlugins::TYPESCRIPT.union(LintPlugins::UNICORN));
+        assert_eq!(config.plugins, Some(LintPlugins::TYPESCRIPT.union(LintPlugins::UNICORN)));
         let config: Oxlintrc =
             serde_json::from_str(r#"{ "plugins": ["typescript", "unicorn", "react", "oxc", "import", "jsdoc", "jest", "vitest", "jsx-a11y", "nextjs", "react-perf", "promise", "node"] }"#).unwrap();
-        assert_eq!(config.plugins, LintPlugins::all());
+        assert_eq!(config.plugins, Some(LintPlugins::all()));
 
         let config: Oxlintrc =
             serde_json::from_str(r#"{ "plugins": ["typescript", "@typescript-eslint"] }"#).unwrap();
-        assert_eq!(config.plugins, LintPlugins::TYPESCRIPT);
+        assert_eq!(config.plugins, Some(LintPlugins::TYPESCRIPT));
     }
 
     #[test]
diff --git a/crates/oxc_linter/src/snapshots/schema_json.snap b/crates/oxc_linter/src/snapshots/schema_json.snap
index 3ea196fb60124..8ab4cef206ee1 100644
--- a/crates/oxc_linter/src/snapshots/schema_json.snap
+++ b/crates/oxc_linter/src/snapshots/schema_json.snap
@@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
-        "react",
-        "unicorn",
-        "typescript",
-        "oxc"
-      ],
-      "allOf": [
+      "default": null,
+      "anyOf": [
         {
           "$ref": "#/definitions/LintPlugins"
+        },
+        {
+          "type": "null"
         }
       ]
     },
diff --git a/npm/oxlint/configuration_schema.json b/npm/oxlint/configuration_schema.json
index 850b434d321ba..6413dd8ea49e9 100644
--- a/npm/oxlint/configuration_schema.json
+++ b/npm/oxlint/configuration_schema.json
@@ -56,15 +56,13 @@
       ]
     },
     "plugins": {
-      "default": [
-        "react",
-        "unicorn",
-        "typescript",
-        "oxc"
-      ],
-      "allOf": [
+      "default": null,
+      "anyOf": [
         {
           "$ref": "#/definitions/LintPlugins"
+        },
+        {
+          "type": "null"
         }
       ]
     },
diff --git a/tasks/website/src/linter/snapshots/schema_markdown.snap b/tasks/website/src/linter/snapshots/schema_markdown.snap
index 99396743ba0f5..6efb85194c70f 100644
--- a/tasks/website/src/linter/snapshots/schema_markdown.snap
+++ b/tasks/website/src/linter/snapshots/schema_markdown.snap
@@ -225,15 +225,6 @@ type: `object`
 See [Oxlint Rules](https://oxc.rs/docs/guide/usage/linter/rules.html)
 
 
-## plugins
-
-type: `string[]`
-
-default: `["react", "unicorn", "typescript", "oxc"]`
-
-
-
-
 ## rules
 
 type: `object`


Reviews:
user: login: camc314
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T13:20:28Z
user: login: graphite-app[bot]
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-09T13:21:21Z
user: login: Sysix
body: I did not test it, only looked into the code. :) 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-10T08:30:32Z
user: login: camc314
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-10T09:25:39Z
user: login: camc314
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T20:19:27Z
user: login: camc314
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-13T20:21:48Z
user: login: Sysix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T21:51:11Z
user: login: Sysix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T21:53:16Z
user: login: Sysix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-13T22:00:23Z
user: login: camc314
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-14T12:46:03Z
user: login: Sysix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-14T20:12:34Z
user: login: camc314
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T08:38:00Z
user: login: camc314
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-15T13:36:11Z
user: login: Sysix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-16T07:31:58Z
user: login: Sysix
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-05-17T13:23:23Z
user: login: camc314
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-18T23:26:22Z
user: login: Sysix
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-19T17:14:42Z
user: login: camchenry
body: I just caught up on this issue, and I think deferring the rule merging makes sense, as long as the perf hit isn't too bad (if any). Have we done any local testing on this?
state: APPROVED
author_association: MEMBER
submitted_at: 2025-05-20T02:30:03Z

Review comments:
diff_hunk: @@ -4,36 +4,17 @@ use std::{
     path::Path,
 };
 
-use nonmax::NonMaxU32;
 use schemars::{JsonSchema, r#gen, schema::Schema};
 use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
 
-use oxc_index::{Idx, IndexVec};
-
 use crate::{LintPlugins, OxlintEnv, OxlintGlobals, config::OxlintRules};
 
-#[derive(Debug, Clone, Copy, Eq, PartialEq, Ord, PartialOrd, Hash)]
-pub struct OverrideId(NonMaxU32);
path: crates/oxc_linter/src/config/overrides.rs
user: login: camc314
body: since we never index directly into this anymore, it doesn't need to be an `IndexVec`
created_at: 2025-05-08T13:09:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -14,7 +14,7 @@ working directory: fixtures/extends_config
   help: Delete this console statement.
 
 Found 0 warnings and 1 error.
-Finished in <variable>ms on 1 file with 103 rules using 1 threads.
+Finished in <variable>ms on 1 file with 102 rules using 1 threads.
path: apps/oxlint/src/snapshots/fixtures__extends_config_--config relative_paths__extends_extends_config.json console.js@oxlint.snap
user: login: camc314
body: previous behaviour was incorrect here.

previously `no-debugger` error was enabled, however it is explicitly disabled by:

https://github.com/oxc-project/oxc/blob/fc748490e99346230d8208a1fd1c64aabd160382/apps/oxlint/fixtures/extends_config/rules_config.json#L3
created_at: 2025-05-08T13:11:04Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,7 +30,16 @@ working directory: fixtures/extends_config
    `----
   help: Avoid using ambiguous text like "click here", replace it with more descriptive text that provides context.
 
-Found 0 warnings and 3 errors.
+  ! ]8;;https://oxc.rs/docs/guide/usage/linter/rules/jsx_a11y/anchor-is-valid.html\eslint-plugin-jsx-a11y(anchor-is-valid)]8;;\: Missing `href` attribute for the `a` element.
path: apps/oxlint/src/snapshots/fixtures__extends_config_overrides@oxlint.snap
user: login: camc314
body: this is a correctness rule, that I think should've been enabled before, but wasn't
created_at: 2025-05-09T13:18:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -85,6 +85,13 @@ impl Config {
         let mut env = self.base.config.env.clone();
         let mut globals = self.base.config.globals.clone();
         let mut plugins = self.base.config.plugins;
+
+        for override_config in overrides_to_apply.clone() {
+            if let Some(override_plugins) = override_config.plugins {
+                plugins |= override_plugins;
+            }
+        }
+
path: crates/oxc_linter/src/config/config_store.rs
user: login: camc314
body: we need to loop over twice, once here to get the merged set of enabled plugins, so that we don't filter out rules before the plugin is added below
created_at: 2025-05-09T13:19:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -87,99 +87,70 @@ impl ConfigStoreBuilder {
         start_empty: bool,
         oxlintrc: Oxlintrc,
     ) -> Result<Self, ConfigBuilderError> {
-        // TODO(refactor); can we make this function infallible, and move all the error handling to
-        // the `build` method?
-        let Oxlintrc {
-            plugins,
-            settings,
-            env,
-            globals,
-            categories,
-            rules: oxlintrc_rules,
-            overrides,
-            path,
-            ignore_patterns: _,
-            extends,
-        } = oxlintrc;
-
-        let config = LintConfig { plugins, settings, env, globals, path: Some(path) };
-        let rules =
-            if start_empty { FxHashSet::default() } else { Self::warn_correctness(plugins) };
-        let cache = RulesCache::new(config.plugins);
-        let mut builder = Self { rules, config, overrides, cache };
+        // TODO: this can be cached to avoid re-conputing the same oxlintrc
path: crates/oxc_linter/src/config/config_builder.rs
user: login: graphite-app[bot]
body: There's a typo in this comment: `re-conputing` should be `re-computing`
```suggestion
        // TODO: this can be cached to avoid re-computing the same oxlintrc
```
  

*Spotted by [Diamond](https://app.graphite.dev/diamond/?org=oxc-project&ref=ai-review-comment)*<i class='graphite__hidden'><br /><br />Is this helpful? React 👍 or 👎 to let us know.</i>
created_at: 2025-05-09T13:21:20Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,7 +63,7 @@ use super::{
 #[serde(default)]
 #[non_exhaustive]
 pub struct Oxlintrc {
-    pub plugins: LintPlugins,
+    pub plugins: Option<LintPlugins>,
path: crates/oxc_linter/src/config/oxlintrc.rs
user: login: Sysix
body: We have `LintPlugins::empty()`, does it need to be wrapped in `Option<>`?
created_at: 2025-05-09T13:34:16Z
author_association: MEMBER
diff_hunk: @@ -141,6 +142,59 @@ impl Oxlintrc {
 
         Ok(config)
     }
+
+    /// Merges two [Oxlintrc] files together
+    /// [Self] takes priority over `other`
+    #[must_use]
+    pub fn merge(&self, other: Oxlintrc) -> Oxlintrc {
+        let mut categories = other.categories.clone();
+        categories.extend(self.categories.iter());
+
+        let rules = self
+            .rules
+            .rules
+            .iter()
+            .chain(&other.rules.rules)
+            .fold(FxHashMap::default(), |mut rules_set, rule| {
+                if rules_set.contains_key(&(&rule.plugin_name, &rule.rule_name)) {
+                    return rules_set;
+                }
+                rules_set.insert((&rule.plugin_name, &rule.rule_name), rule);
+                rules_set
+            })
+            .values()
+            .map(|rule| (**rule).clone())
+            .collect::<Vec<_>>();
+
+        let settings = self.settings.clone();
+        let env = self.env.clone();
+        let globals = self.globals.clone();
+
+        let mut overrides = self.overrides.clone();
+        overrides.extend(other.overrides);
+
+        Oxlintrc {
+            plugins: self.plugins.map_or_else(
+                || other.plugins,
+                |p| Some(other.plugins.map_or_else(|| p, |p2| p2.union(p))),
+            ),
+            categories,
+            rules: OxlintRules::new(rules),
+            settings,
+            env,
+            globals,
+            overrides,
+            path: self.path.clone(),
+            ignore_patterns: self
path: crates/oxc_linter/src/config/oxlintrc.rs
user: login: Sysix
body: I do not think this will work as expected.
When `folder/xyz/` has `**/ignore`, the `root` should not ignore the folder.

Problems will happen too when ignore pattern starts with `/`.
created_at: 2025-05-10T08:23:46Z
author_association: MEMBER
diff_hunk: @@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
path: crates/oxc_linter/src/snapshots/schema_json.snap
user: login: Sysix
body: I liked that it can not be null, and the default is an prefilled array:
![oxlint-plugin-default](https://github.com/user-attachments/assets/d845e403-5e47-4d91-8185-e68fa3bbe3ca)

created_at: 2025-05-10T08:26:21Z
author_association: MEMBER
diff_hunk: @@ -141,6 +142,59 @@ impl Oxlintrc {
 
         Ok(config)
     }
+
+    /// Merges two [Oxlintrc] files together
+    /// [Self] takes priority over `other`
+    #[must_use]
+    pub fn merge(&self, other: Oxlintrc) -> Oxlintrc {
path: crates/oxc_linter/src/config/oxlintrc.rs
user: login: Sysix
body: Do we want to write tests for this function?
created_at: 2025-05-10T08:29:11Z
author_association: MEMBER
diff_hunk: @@ -141,6 +142,59 @@ impl Oxlintrc {
 
         Ok(config)
     }
+
+    /// Merges two [Oxlintrc] files together
+    /// [Self] takes priority over `other`
+    #[must_use]
+    pub fn merge(&self, other: Oxlintrc) -> Oxlintrc {
+        let mut categories = other.categories.clone();
+        categories.extend(self.categories.iter());
+
+        let rules = self
+            .rules
+            .rules
+            .iter()
+            .chain(&other.rules.rules)
+            .fold(FxHashMap::default(), |mut rules_set, rule| {
+                if rules_set.contains_key(&(&rule.plugin_name, &rule.rule_name)) {
+                    return rules_set;
+                }
+                rules_set.insert((&rule.plugin_name, &rule.rule_name), rule);
+                rules_set
+            })
+            .values()
+            .map(|rule| (**rule).clone())
+            .collect::<Vec<_>>();
+
+        let settings = self.settings.clone();
+        let env = self.env.clone();
+        let globals = self.globals.clone();
+
+        let mut overrides = self.overrides.clone();
+        overrides.extend(other.overrides);
+
+        Oxlintrc {
+            plugins: self.plugins.map_or_else(
+                || other.plugins,
+                |p| Some(other.plugins.map_or_else(|| p, |p2| p2.union(p))),
+            ),
+            categories,
+            rules: OxlintRules::new(rules),
+            settings,
+            env,
+            globals,
+            overrides,
+            path: self.path.clone(),
+            ignore_patterns: self
path: crates/oxc_linter/src/config/oxlintrc.rs
user: login: camc314
body: yes you're right i need to normalise the paths and make them absolute relative to the config file i think
created_at: 2025-05-10T09:25:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
path: crates/oxc_linter/src/snapshots/schema_json.snap
user: login: camc314
body: i wonder whether i can represent it in json schema as non-nullable.

the problem is, if the root oxlint config is has no "plugins", we want to use the plugins from the extends (no defaults), and if the root oxlint config does have plugins we want to merge. although maybe this is not correct, and even if extends exists, we should apply defaults.

thoughts?
created_at: 2025-05-13T20:19:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,7 +63,7 @@ use super::{
 #[serde(default)]
 #[non_exhaustive]
 pub struct Oxlintrc {
-    pub plugins: LintPlugins,
+    pub plugins: Option<LintPlugins>,
path: crates/oxc_linter/src/config/oxlintrc.rs
user: login: camc314
body: ah see https://github.com/oxc-project/oxc/pull/10835#discussion_r2087567181

basically whenmerging the two configs:
 - if the base is `None`, we should use the plugins from the extends file (previously we check if base had default and replace [here](https://github.com/oxc-project/oxc/blob/79819cc12b38a1169b793305f93a6bb4a40cf56c/crates/oxc_linter/src/config/config_builder.rs#L156-L159), 
 - if the base is `None` (no extends) we use default
created_at: 2025-05-13T20:21:48Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
path: crates/oxc_linter/src/snapshots/schema_json.snap
user: login: Sysix
body: I think we can solve the JSON schema problem on its own. I found some code in this project where some custom logic is implemented.
I do not see a problem for `oxlint`, but with `oxc_language_server`. (including the other comment)
Every `.oxlintrc.json` config should be presented as an own "`oxlint` project". (not implemented, but thoughts?)
When we found the first `.oxlintrc.json` we use this as a baseline and do not search the parents directories.

I did not make many thought, but I think we should define an own `RootOxlintrc` structure where all configuration starts to be resolved.
Maybe we do not want to support a specific oxlint config key, I think the rust code should reflect that.

created_at: 2025-05-13T21:51:11Z
author_association: MEMBER
diff_hunk: @@ -62,7 +63,7 @@ use super::{
 #[serde(default)]
 #[non_exhaustive]
 pub struct Oxlintrc {
-    pub plugins: LintPlugins,
+    pub plugins: Option<LintPlugins>,
path: crates/oxc_linter/src/config/oxlintrc.rs
user: login: Sysix
body: join this into an own topic: https://github.com/oxc-project/oxc/pull/10835#discussion_r2087685775
created_at: 2025-05-13T21:53:16Z
author_association: MEMBER
diff_hunk: @@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
path: crates/oxc_linter/src/snapshots/schema_json.snap
user: login: Sysix
body: @camchenry you implemented the nested configs. Do you have any thoughts how the language server should handle this at the end?
created_at: 2025-05-13T22:00:23Z
author_association: MEMBER
diff_hunk: @@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
path: crates/oxc_linter/src/snapshots/schema_json.snap
user: login: camc314
body: > Every .oxlintrc.json config should be presented as an own "oxlint project". (not implemented, but thoughts?)

not 100% sure what you mean by this? for the nested configs stuff, we have to be aware of other configs to resolve the final one.

For reference, we've now got this structure, which stores all of the configs, when you need the particular config for a file, you can just call `resolve` which will return the resolved linter state for that file. Effectivly the `base` property in that structure is the root oxlint config.

https://github.com/oxc-project/oxc/blob/5c6172bdcbafa2d7b4b2947211dde3338d6be9d7/crates/oxc_linter/src/config/config_store.rs#L28
created_at: 2025-05-14T12:46:03Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
path: crates/oxc_linter/src/snapshots/schema_json.snap
user: login: Sysix
body: I think I mixed things up (extend vs nested configs) yesterday, but the language server has atm problems with `ignorePatterns`. Because the base config is only be looked at when the ignore-check happens. In nested config context, this means a default `Oxlintrc` struct.

You can see this when looking into the `editors/vscode/fixtures` code. They should be ignored by `editor/vscode/.oxlintrc.json`. But they still lint in the editor. Copying the file into the root of the project will not resolve this problem. 
Only defining `oxc.configPath` will help.

`oxlint` and `oxc_language_server` are handling the ignore-part with custom flags + looking into the config `ingorePatterns`. 
The missing `ignorePatterns` support is what bugs me^^



created_at: 2025-05-14T20:12:34Z
author_association: MEMBER
diff_hunk: @@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
path: crates/oxc_linter/src/snapshots/schema_json.snap
user: login: camc314
body: so if we, when merging, make all the paths absolute, and combind them, it should be ok?
created_at: 2025-05-15T08:37:59Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
path: crates/oxc_linter/src/snapshots/schema_json.snap
user: login: camc314
body: > You can see this when looking into the editors/vscode/fixtures code. They should be ignored by editor/vscode/.oxlintrc.json. But they still lint in the editor. Copying the file into the root of the project will not resolve this problem.

If i do:
```
$ cd ediors/vscode
$ npx oxlint@latest .
Found 0 warnings and 0 errors.
Finished in 4ms on 12 files using 12 threads.
```

which is expected right? so that feels like a bug specific to language server?
created_at: 2025-05-15T13:36:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -60,15 +60,13 @@ expression: json
       ]
     },
     "plugins": {
-      "default": [
path: crates/oxc_linter/src/snapshots/schema_json.snap
user: login: Sysix
body: Running `npx` oxlint` on the root will show the errors in CLI too.
Will create a new issue because this is an offtopic 👍 
created_at: 2025-05-16T07:31:58Z
author_association: MEMBER
diff_hunk: @@ -141,6 +142,59 @@ impl Oxlintrc {
 
         Ok(config)
     }
+
+    /// Merges two [Oxlintrc] files together
+    /// [Self] takes priority over `other`
+    #[must_use]
+    pub fn merge(&self, other: Oxlintrc) -> Oxlintrc {
+        let mut categories = other.categories.clone();
+        categories.extend(self.categories.iter());
+
+        let rules = self
+            .rules
+            .rules
+            .iter()
+            .chain(&other.rules.rules)
+            .fold(FxHashMap::default(), |mut rules_set, rule| {
+                if rules_set.contains_key(&(&rule.plugin_name, &rule.rule_name)) {
+                    return rules_set;
+                }
+                rules_set.insert((&rule.plugin_name, &rule.rule_name), rule);
+                rules_set
+            })
+            .values()
+            .map(|rule| (**rule).clone())
+            .collect::<Vec<_>>();
+
+        let settings = self.settings.clone();
+        let env = self.env.clone();
+        let globals = self.globals.clone();
+
+        let mut overrides = self.overrides.clone();
+        overrides.extend(other.overrides);
+
+        Oxlintrc {
+            plugins: self.plugins.map_or_else(
+                || other.plugins,
+                |p| Some(other.plugins.map_or_else(|| p, |p2| p2.union(p))),
+            ),
+            categories,
+            rules: OxlintRules::new(rules),
+            settings,
+            env,
+            globals,
+            overrides,
+            path: self.path.clone(),
+            ignore_patterns: self
path: crates/oxc_linter/src/config/oxlintrc.rs
user: login: Sysix
body: @camc314 Will approve the changes when we do not append `ignore_patterns` :) 
This does not work in main and can be look later at. Ignoring is a bigger problem (see #11067)

The tests can be done later too. 
created_at: 2025-05-17T13:23:23Z
author_association: MEMBER
diff_hunk: @@ -141,6 +142,59 @@ impl Oxlintrc {
 
         Ok(config)
     }
+
+    /// Merges two [Oxlintrc] files together
+    /// [Self] takes priority over `other`
+    #[must_use]
+    pub fn merge(&self, other: Oxlintrc) -> Oxlintrc {
+        let mut categories = other.categories.clone();
+        categories.extend(self.categories.iter());
+
+        let rules = self
+            .rules
+            .rules
+            .iter()
+            .chain(&other.rules.rules)
+            .fold(FxHashMap::default(), |mut rules_set, rule| {
+                if rules_set.contains_key(&(&rule.plugin_name, &rule.rule_name)) {
+                    return rules_set;
+                }
+                rules_set.insert((&rule.plugin_name, &rule.rule_name), rule);
+                rules_set
+            })
+            .values()
+            .map(|rule| (**rule).clone())
+            .collect::<Vec<_>>();
+
+        let settings = self.settings.clone();
+        let env = self.env.clone();
+        let globals = self.globals.clone();
+
+        let mut overrides = self.overrides.clone();
+        overrides.extend(other.overrides);
+
+        Oxlintrc {
+            plugins: self.plugins.map_or_else(
+                || other.plugins,
+                |p| Some(other.plugins.map_or_else(|| p, |p2| p2.union(p))),
+            ),
+            categories,
+            rules: OxlintRules::new(rules),
+            settings,
+            env,
+            globals,
+            overrides,
+            path: self.path.clone(),
+            ignore_patterns: self
path: crates/oxc_linter/src/config/oxlintrc.rs
user: login: camc314
body: thanks 🙏 fixed in the latest revision

the existsing e2e tests _should_ cover this, but i agree we can always write more
created_at: 2025-05-18T23:26:21Z
author_association: CONTRIBUTOR

PR comments:
user: login: camc314
created_at: 2025-05-06T16:06:07Z
body: * **#11219** <a href="https://app.graphite.dev/github/pr/oxc-project/oxc/11219?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* **#10835** <a href="https://app.graphite.dev/github/pr/oxc-project/oxc/10835?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a> 👈 <a href="https://app.graphite.dev/github/pr/oxc-project/oxc/10835?utm_source=stack-comment-view-in-graphite" target="_blank">(View in Graphite)</a>
* **#11057** <a href="https://app.graphite.dev/github/pr/oxc-project/oxc/11057?utm_source=stack-comment-icon" target="_blank"><img src="https://static.graphite.dev/graphite-32x32-black.png" alt="Graphite" width="10px" height="10px"/></a>
* `main`
<hr />

## How to use the Graphite Merge Queue

Add either label to this PR to merge it via the merge queue:
- <kbd>0-merge</kbd> - adds this PR to the back of the merge queue
- <kbd>hotfix</kbd> - for urgent hot fixes, skip the queue and merge this PR next

You must have a Graphite account in order to use the merge queue. Sign up using [this link](https://app.graphite.dev/invite/github/oxc-project?ref=merge-queue-instructions-in-stack-comment&prId=10835).

<h2></h2>
<sub>

An organization admin has enabled the [Graphite Merge Queue](https://graphite.dev/docs/graphite-merge-queue) in this repository.

</sub>

<sub>

Please do not merge from GitHub as this will restart CI on PRs being processed by the merge queue.

</sub>

<h2></h2>

This stack of pull requests is managed by <a href="https://graphite.dev?utm-source=stack-comment"><b>Graphite</b></a>. Learn more about <a href="https://stacking.dev/?utm_source=stack-comment">stacking</a>.
<!-- Current dependencies on/for this PR: -->
author_association: CONTRIBUTOR
user: login: codspeed-hq[bot]
created_at: 2025-05-06T16:14:28Z
body: <!-- __CODSPEED_PERFORMANCE_REPORT_COMMENT__ -->
<!-- __CODSPEED_INSTRUMENTATION_PERFORMANCE_REPORT_COMMENT__ -->

## [CodSpeed Instrumentation Performance Report](https://codspeed.io/oxc-project/oxc/branches/c%2F05-06-fix_linter_delay_merging_of_oxlintrc_configs)

### Merging #10835 will **not alter performance**

<sub>Comparing <code>c/05-06-fix_linter_delay_merging_of_oxlintrc_configs</code> (e8470d9) with <code>main</code> (cef5452)</sub>



### Summary

`✅ 38` untouched benchmarks  




author_association: NONE
user: login: camc314
created_at: 2025-05-21T15:10:58Z
body: been testin this on the esisting integration tests, i've also got a couple of configs locally I've been testing this on.
author_association: CONTRIBUTOR
user: login: Sysix
created_at: 2025-05-21T17:15:53Z
body: Can we merge this :)? 
#10373 is waiting for it.
author_association: MEMBER
user: login: camc314
created_at: 2025-05-21T17:17:25Z
body: ### Merge activity

* **May 21, 5:17 PM UTC**: The merge label '0-merge' was detected. This PR will be added to the [Graphite merge queue](https://app.graphite.dev/merges?org=oxc-project&repo=oxc) once it meets the requirements.
* **May 21, 5:17 PM UTC**: `camc314` added this pull request to the [Graphite merge queue](https://app.graphite.dev/merges?org=oxc-project&repo=oxc).
* **May 21, 5:24 PM UTC**: Merged by the [Graphite merge queue](https://app.graphite.dev/merges?org=oxc-project&repo=oxc).

author_association: CONTRIBUTOR
user: login: camc314
created_at: 2025-05-21T17:17:36Z
body: yep i meant to add the tag earlier but must not have pressed the button
author_association: CONTRIBUTOR