diff --git a/crates/ty/tests/cli/rule_selection.rs b/crates/ty/tests/cli/rule_selection.rs index a0be66d18279c..4f1af154c1e2b 100644 --- a/crates/ty/tests/cli/rule_selection.rs +++ b/crates/ty/tests/cli/rule_selection.rs @@ -1092,3 +1092,129 @@ fn configuration_all_rules() -> anyhow::Result<()> { Ok(()) } + +/// In TOML, key order in a table is not semantically meaningful, so specific rules should +/// still override `all` even if they sort lexicographically before `all`. +#[test] +fn configuration_all_rules_with_rule_sorting_before_all() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [tool.ty.rules] + all = "warn" + abstract-method-in-final-class = "error" + "#, + ), + ( + "test.py", + r#" + from typing import final + from abc import ABC, abstractmethod + + class Base(ABC): + @abstractmethod + def foo(self) -> int: + raise NotImplementedError + + @final + class Derived(Base): + pass + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[abstract-method-in-final-class]: Final class `Derived` has unimplemented abstract methods + --> test.py:11:7 + | + 10 | @final + 11 | class Derived(Base): + | ^^^^^^^ `foo` is unimplemented + 12 | pass + | + ::: test.py:7:9 + | + 5 | class Base(ABC): + 6 | @abstractmethod + 7 | def foo(self) -> int: + | --- `foo` declared as abstract on superclass `Base` + 8 | raise NotImplementedError + | + info: rule `abstract-method-in-final-class` was selected in the configuration file + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} + +/// Same TOML key ordering issue, but within an override's `[rules]` table. +/// `abstract-method-in-final-class` sorts before `all` lexicographically, but +/// the specific rule should still take precedence over `all`. +#[test] +fn overrides_all_rules_with_rule_sorting_before_all() -> anyhow::Result<()> { + let case = CliTest::with_files([ + ( + "pyproject.toml", + r#" + [[tool.ty.overrides]] + include = ["src/**"] + + [tool.ty.overrides.rules] + all = "warn" + abstract-method-in-final-class = "error" + "#, + ), + ( + "src/test.py", + r#" + from typing import final + from abc import ABC, abstractmethod + + class Base(ABC): + @abstractmethod + def foo(self) -> int: + raise NotImplementedError + + @final + class Derived(Base): + pass + "#, + ), + ])?; + + assert_cmd_snapshot!(case.command(), @" + success: false + exit_code: 1 + ----- stdout ----- + error[abstract-method-in-final-class]: Final class `Derived` has unimplemented abstract methods + --> src/test.py:11:7 + | + 10 | @final + 11 | class Derived(Base): + | ^^^^^^^ `foo` is unimplemented + 12 | pass + | + ::: src/test.py:7:9 + | + 5 | class Base(ABC): + 6 | @abstractmethod + 7 | def foo(self) -> int: + | --- `foo` declared as abstract on superclass `Base` + 8 | raise NotImplementedError + | + info: rule `abstract-method-in-final-class` was selected in the configuration file + + Found 1 diagnostic + + ----- stderr ----- + "); + + Ok(()) +} diff --git a/crates/ty_project/src/metadata/options.rs b/crates/ty_project/src/metadata/options.rs index 862fdebb84287..7e900e5a978db 100644 --- a/crates/ty_project/src/metadata/options.rs +++ b/crates/ty_project/src/metadata/options.rs @@ -22,6 +22,7 @@ use ruff_python_ast::PythonVersion; use rustc_hash::FxHasher; use serde::{Deserialize, Serialize}; use std::borrow::Cow; +use std::cmp::Ordering; use std::fmt::{self, Debug, Display}; use std::hash::BuildHasherDefault; use std::ops::Deref; @@ -107,10 +108,42 @@ pub struct Options { impl Options { pub fn from_toml_str(content: &str, source: ValueSource) -> Result { let _guard = ValueSourceGuard::new(source, true); - let options = toml::from_str(content)?; + let mut options: Self = toml::from_str(content)?; + options.prioritize_all_selectors(); Ok(options) } + /// Ensures that the `all` selector is applied before per-rule selectors + /// in all rule tables (top-level and overrides). + /// + /// This must be called after deserializing from TOML and before any + /// [`Combine::combine`] calls, because TOML tables are unordered and the + /// `toml` crate sorts keys lexicographically. + pub(crate) fn prioritize_all_selectors(&mut self) { + // Stable sort that moves all `all` selectors before non-`all` selectors + // while preserving relative order among non-`all` entries. + let sort = |rules: &mut Rules| { + rules.inner.sort_by( + |key_a, _, key_b, _| match (**key_a == "all", **key_b == "all") { + (true, false) => Ordering::Less, + (false, true) => Ordering::Greater, + _ => Ordering::Equal, + }, + ); + }; + + if let Some(rules) = &mut self.rules { + sort(rules); + } + if let Some(overrides) = &mut self.overrides { + for override_option in &mut overrides.0 { + if let Some(rules) = &mut override_option.rules { + sort(rules); + } + } + } + } + pub fn deserialize_with<'de, D>(source: ValueSource, deserializer: D) -> Result where D: serde::Deserializer<'de>, diff --git a/crates/ty_project/src/metadata/pyproject.rs b/crates/ty_project/src/metadata/pyproject.rs index 21dd5f02380d4..ca25d05a617fb 100644 --- a/crates/ty_project/src/metadata/pyproject.rs +++ b/crates/ty_project/src/metadata/pyproject.rs @@ -35,7 +35,16 @@ impl PyProject { source: ValueSource, ) -> Result { let _guard = ValueSourceGuard::new(source, true); - toml::from_str(content).map_err(PyProjectError::TomlSyntax) + let mut pyproject: Self = toml::from_str(content).map_err(PyProjectError::TomlSyntax)?; + // TOML tables are unordered and the `toml` crate sorts keys + // lexicographically. Normalize rule order so that the `all` selector + // is applied before per-rule selectors. + if let Some(tool) = &mut pyproject.tool { + if let Some(ty) = &mut tool.ty { + ty.prioritize_all_selectors(); + } + } + Ok(pyproject) } }