From 5aadabad147a11f7c50f148a883f368505ab496f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:38:19 +0000 Subject: [PATCH 1/2] ruff_python_dto_check: add class_with_decorator matcher Data-shaped Python (dataclasses, attrs, pydantic) lives in decorated classes, which the harvester could not see: it matched only top-level decorated functions. The new kind matches top-level decorated classes with the same decorator selector and emits class.name, class.bases, class.decorators, class.fields (annotated assignments), class.methods, class.body.source and class.range.*, plus the existing decorator.* paths. The function matcher now applies only function rules (it previously ran every rule regardless of kind), the decorator selector is shared by both matchers, harvest_with_config merges both in source order, and the AST hash locates its statement by start line so class bundles hash their own body instead of an empty one. Existing function hashes and golden outputs are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMMCqUkM2QW7zqLC2NJ --- crates/ruff_python_dto_check/README.md | 7 +- .../examples/dataclass.config.json | 19 ++ .../schemas/ruff-py-dto.config.schema.json | 4 +- .../src/bin/ruff_py_dto.rs | 8 +- crates/ruff_python_dto_check/src/config.rs | 6 +- crates/ruff_python_dto_check/src/emit.rs | 156 +++++++++++++-- .../src/matcher/class_with_decorator.rs | 102 ++++++++++ .../src/matcher/decorator.rs | 80 ++++++++ .../src/matcher/function_with_decorator.rs | 90 +-------- .../ruff_python_dto_check/src/matcher/mod.rs | 22 ++- .../ruff_python_dto_check/src/observations.rs | 18 +- .../tests/class_matcher_test.rs | 178 ++++++++++++++++++ 12 files changed, 577 insertions(+), 113 deletions(-) create mode 100644 crates/ruff_python_dto_check/examples/dataclass.config.json create mode 100644 crates/ruff_python_dto_check/src/matcher/class_with_decorator.rs create mode 100644 crates/ruff_python_dto_check/src/matcher/decorator.rs create mode 100644 crates/ruff_python_dto_check/tests/class_matcher_test.rs diff --git a/crates/ruff_python_dto_check/README.md b/crates/ruff_python_dto_check/README.md index 5176666b06506..8b69d28e67ea2 100644 --- a/crates/ruff_python_dto_check/README.md +++ b/crates/ruff_python_dto_check/README.md @@ -79,10 +79,13 @@ detection without calling anything a duplicate). Current implementation: -- One matcher kind: `function_with_decorator` +- Two matcher kinds: `function_with_decorator` and `class_with_decorator` + (top-level definitions only; the class name is reported in `function_name`) - Flask-style route detection via `@.route(...)` in the default profile - Config-driven emit fields (`def.name`, `def.params`, `def.body.source`, - `decorator.args[0]`, `decorator.kwargs.`, etc.) + `decorator.args[0]`, `decorator.kwargs.`, etc.; for classes + `class.name`, `class.bases`, `class.decorators`, `class.fields`, + `class.methods`, `class.body.source`, `class.range.line_start`/`line_end`) - Per-family observation block with set-algebra comparisons and percentile distributions (no advisory English strings — pure content encoding) - Preflight subcommand with decorator histogram + framework fingerprint diff --git a/crates/ruff_python_dto_check/examples/dataclass.config.json b/crates/ruff_python_dto_check/examples/dataclass.config.json new file mode 100644 index 0000000000000..ecc94f248552c --- /dev/null +++ b/crates/ruff_python_dto_check/examples/dataclass.config.json @@ -0,0 +1,19 @@ +{ + "match": [ + { + "id": "dataclass", + "kind": "class_with_decorator", + "decorator": { "name": "dataclass" }, + "emit": { + "name": "class.name", + "bases": "class.bases", + "fields": "class.fields", + "methods": "class.methods", + "decorators": "class.decorators" + } + } + ], + "group": { + "family_from_filename": { "regex": "^(?P.+)\\.py$" } + } +} diff --git a/crates/ruff_python_dto_check/schemas/ruff-py-dto.config.schema.json b/crates/ruff_python_dto_check/schemas/ruff-py-dto.config.schema.json index 6ccbfb5f1df7d..ffd4768448f27 100644 --- a/crates/ruff_python_dto_check/schemas/ruff-py-dto.config.schema.json +++ b/crates/ruff_python_dto_check/schemas/ruff-py-dto.config.schema.json @@ -47,8 +47,8 @@ }, "kind": { "type": "string", - "enum": ["function_with_decorator"], - "description": "AST shape to match. Only function_with_decorator is implemented in this iteration." + "enum": ["function_with_decorator", "class_with_decorator"], + "description": "AST shape to match: a top-level decorated function or a top-level decorated class." }, "decorator": { "$ref": "#/$defs/DecoratorSelector", diff --git a/crates/ruff_python_dto_check/src/bin/ruff_py_dto.rs b/crates/ruff_python_dto_check/src/bin/ruff_py_dto.rs index 6c2cffad9ae5f..200dc39b478a5 100644 --- a/crates/ruff_python_dto_check/src/bin/ruff_py_dto.rs +++ b/crates/ruff_python_dto_check/src/bin/ruff_py_dto.rs @@ -147,7 +147,7 @@ fn run_harvest( ) -> Result<()> { use ruff_python_dto_check::bundle::write_family_bundles; use ruff_python_dto_check::config::Config; - use ruff_python_dto_check::matcher::function_with_decorator::harvest_module_with_config; + use ruff_python_dto_check::matcher::harvest_with_config; use ruff_python_dto_check::observations::attach_observations; let cfg = Config::from_path(config_path).map_err(|e| anyhow::anyhow!("{e}"))?; @@ -189,7 +189,7 @@ fn run_harvest( let Ok(source) = std::fs::read_to_string(path) else { continue; }; - let bundles = harvest_module_with_config(&rel, &source, &cfg); + let bundles = harvest_with_config(&rel, &source, &cfg); if !bundles.is_empty() { source_map.insert(rel.clone(), source); for b in bundles { @@ -249,11 +249,11 @@ fn run_preflight(root: &std::path::Path, out: Option<&std::path::Path>) -> Resul fn run_harvest_one(config_path: &std::path::Path, rel: &str, file: &std::path::Path) -> Result<()> { use ruff_python_dto_check::config::Config; - use ruff_python_dto_check::matcher::function_with_decorator::harvest_module_with_config; + use ruff_python_dto_check::matcher::harvest_with_config; let cfg = Config::from_path(config_path).map_err(|e| anyhow::anyhow!("{e}"))?; let source = std::fs::read_to_string(file)?; - let bundles = harvest_module_with_config(rel, &source, &cfg); + let bundles = harvest_with_config(rel, &source, &cfg); let json = serde_json::to_string_pretty(&bundles)?; // Binary output to stdout is expected behavior for harvest-one subcommand. #[expect( diff --git a/crates/ruff_python_dto_check/src/config.rs b/crates/ruff_python_dto_check/src/config.rs index 7bab0b669120c..37eebe0131d30 100644 --- a/crates/ruff_python_dto_check/src/config.rs +++ b/crates/ruff_python_dto_check/src/config.rs @@ -99,7 +99,7 @@ pub struct MatchRule { pub id: String, /// AST shape this rule targets. pub kind: MatchKind, - /// Optional decorator selector (required for `function_with_decorator`). + /// Optional decorator selector (required for `function_with_decorator` and `class_with_decorator`). pub decorator: Option, /// Output field name → dot-path expression. Stable key order preserved. pub emit: BTreeMap, @@ -108,6 +108,7 @@ pub struct MatchRule { #[derive(Debug, Clone, PartialEq, Eq)] pub enum MatchKind { FunctionWithDecorator, + ClassWithDecorator, } #[derive(Debug, Clone, Default)] @@ -191,9 +192,10 @@ struct RawFamilyFromFilename { fn validate_match_rule(raw: RawMatchRule, idx: usize) -> Result { let kind = match raw.kind.as_str() { "function_with_decorator" => MatchKind::FunctionWithDecorator, + "class_with_decorator" => MatchKind::ClassWithDecorator, other => { return Err(ConfigError::Validation(format!( - "match[{idx}].kind: unknown value {other:?}; only \"function_with_decorator\" is implemented" + "match[{idx}].kind: unknown value {other:?}; expected \"function_with_decorator\" or \"class_with_decorator\"" ))); } }; diff --git a/crates/ruff_python_dto_check/src/emit.rs b/crates/ruff_python_dto_check/src/emit.rs index f6e63785e2079..3e93e1c754ecb 100644 --- a/crates/ruff_python_dto_check/src/emit.rs +++ b/crates/ruff_python_dto_check/src/emit.rs @@ -1,4 +1,4 @@ -//! Dot-path expression evaluator over a matched AST function context. +//! Dot-path expression evaluator over a matched AST function or class context. //! //! Supported dot-paths for `function_with_decorator`: //! - `def.name` → function name string @@ -11,10 +11,24 @@ //! - `decorator.kwargs.` → keyword argument by name //! - `decorator.raw` → full decorator text including `@` //! +//! Supported dot-paths for `class_with_decorator`: +//! - `class.name` → class name string +//! - `class.bases` → JSON array of the raw source text of each base +//! and keyword (`"Base"`, `"metaclass=M"`), in source order +//! - `class.decorators` → JSON array of decorator raw strings +//! - `class.range.line_start` / `class.range.line_end` → 1-based lines +//! - `class.body.source` → raw source text of the body +//! - `class.fields` → JSON array of `{name, annotation, default}` for +//! each top-level body `AnnAssign` whose target is a bare name; annotation +//! and default are raw source text (default is `null` when absent). +//! `ClassVar` and similar annotations are not filtered out. +//! - `class.methods` → JSON array of top-level `def` names in the body +//! - `decorator.raw`, `decorator.args[]`, `decorator.kwargs.` as above +//! //! Path is emitted as `null` if absent. Type mismatches emit the raw value //! and a `_type_note` sibling field — never panics. -use ruff_python_ast::{Expr, StmtFunctionDef}; +use ruff_python_ast::{Decorator, Expr, Stmt, StmtClassDef, StmtFunctionDef}; use ruff_source_file::LineIndex; use ruff_text_size::Ranged; use serde_json::Value; @@ -28,13 +42,22 @@ pub struct FunctionContext<'a> { pub line_index: &'a LineIndex, } +/// All context available when evaluating emit paths for a matched class. +pub struct ClassContext<'a> { + pub class: &'a StmtClassDef, + /// The specific decorator that triggered the match rule. + pub matched_decorator: &'a Decorator, + pub source: &'a str, + pub line_index: &'a LineIndex, +} + /// Evaluate a dot-path expression against a function context. /// Returns `Value::Null` if the path is absent or evaluates to nothing. pub fn eval_path(path: &str, ctx: &FunctionContext<'_>) -> Value { if let Some(rest) = path.strip_prefix("def.") { eval_def_path(rest, ctx) } else if let Some(rest) = path.strip_prefix("decorator.") { - eval_decorator_path(rest, ctx) + eval_decorator_path(rest, ctx.matched_decorator, ctx.source) } else { Value::Null } @@ -103,18 +126,121 @@ fn eval_def_path(path: &str, ctx: &FunctionContext<'_>) -> Value { } } -fn eval_decorator_path(path: &str, ctx: &FunctionContext<'_>) -> Value { +/// Evaluate a dot-path expression against a class context. +/// Returns `Value::Null` if the path is absent or evaluates to nothing. +pub fn eval_class_path(path: &str, ctx: &ClassContext<'_>) -> Value { + if let Some(rest) = path.strip_prefix("class.") { + eval_class_member(rest, ctx) + } else if let Some(rest) = path.strip_prefix("decorator.") { + eval_decorator_path(rest, ctx.matched_decorator, ctx.source) + } else { + Value::Null + } +} + +fn eval_class_member(path: &str, ctx: &ClassContext<'_>) -> Value { + let class = ctx.class; + match path { + "name" => Value::String(class.name.id.to_string()), + "bases" => { + let mut parts: Vec<(usize, String)> = Vec::new(); + if let Some(arguments) = &class.arguments { + parts.extend( + arguments + .args + .iter() + .map(|a| (a.range().start().to_usize(), raw_text(a, ctx.source))), + ); + parts.extend( + arguments + .keywords + .iter() + .map(|k| (k.range().start().to_usize(), raw_text(k, ctx.source))), + ); + } + parts.sort_by_key(|(start, _)| *start); + Value::Array(parts.into_iter().map(|(_, t)| Value::String(t)).collect()) + } + "body.source" => { + if let Some(first_stmt) = class.body.first() { + let start = first_stmt.range().start().to_usize(); + let end = class.range().end().to_usize(); + if start <= end && end <= ctx.source.len() { + return Value::String(ctx.source[start..end].to_string()); + } + } + Value::Null + } + "decorators" => Value::Array( + class + .decorator_list + .iter() + .map(|d| Value::String(raw_text(d, ctx.source))) + .collect(), + ), + "range.line_start" => { + let line = ctx.line_index.line_index(class.range().start()).get(); + Value::Number(line.into()) + } + "range.line_end" => { + let line = ctx.line_index.line_index(class.range().end()).get(); + Value::Number(line.into()) + } + "fields" => Value::Array( + class + .body + .iter() + .filter_map(|stmt| { + let Stmt::AnnAssign(ann) = stmt else { + return None; + }; + let Expr::Name(target) = &*ann.target else { + return None; + }; + let default = ann + .value + .as_deref() + .map_or(Value::Null, |v| Value::String(raw_text(v, ctx.source))); + let mut field = serde_json::Map::new(); + field.insert("name".to_string(), Value::String(target.id.to_string())); + field.insert( + "annotation".to_string(), + Value::String(raw_text(ann.annotation.as_ref(), ctx.source)), + ); + field.insert("default".to_string(), default); + Some(Value::Object(field)) + }) + .collect(), + ), + "methods" => Value::Array( + class + .body + .iter() + .filter_map(|stmt| match stmt { + Stmt::FunctionDef(f) => Some(Value::String(f.name.id.to_string())), + _ => None, + }) + .collect(), + ), + _ => Value::Null, + } +} + +/// Raw source text of a node. +fn raw_text(node: &impl Ranged, source: &str) -> String { + source[node.range().to_std_range()].to_string() +} + +fn eval_decorator_path(path: &str, dec: &Decorator, source: &str) -> Value { if path == "raw" { - let start = ctx.matched_decorator.range().start().to_usize(); - let end = ctx.matched_decorator.range().end().to_usize(); - return Value::String(ctx.source[start..end].to_string()); + return Value::String(raw_text(dec, source)); } // decorator.args[] if let Some(rest) = path.strip_prefix("args[") { if let Some(idx_str) = rest.strip_suffix(']') { if let Ok(idx) = idx_str.parse::() { - return eval_decorator_arg(idx, ctx); + return eval_decorator_arg(idx, dec, source); } } return Value::Null; @@ -122,24 +248,24 @@ fn eval_decorator_path(path: &str, ctx: &FunctionContext<'_>) -> Value { // decorator.kwargs. if let Some(kw_name) = path.strip_prefix("kwargs.") { - return eval_decorator_kwarg(kw_name, ctx); + return eval_decorator_kwarg(kw_name, dec, source); } Value::Null } -fn eval_decorator_arg(idx: usize, ctx: &FunctionContext<'_>) -> Value { - let Expr::Call(call) = &ctx.matched_decorator.expression else { +fn eval_decorator_arg(idx: usize, dec: &Decorator, source: &str) -> Value { + let Expr::Call(call) = &dec.expression else { return Value::Null; }; let Some(arg) = call.arguments.args.get(idx) else { return Value::Null; }; - expr_to_value(arg, ctx.source) + expr_to_value(arg, source) } -fn eval_decorator_kwarg(name: &str, ctx: &FunctionContext<'_>) -> Value { - let Expr::Call(call) = &ctx.matched_decorator.expression else { +fn eval_decorator_kwarg(name: &str, dec: &Decorator, source: &str) -> Value { + let Expr::Call(call) = &dec.expression else { return Value::Null; }; for kw in &call.arguments.keywords { @@ -147,7 +273,7 @@ fn eval_decorator_kwarg(name: &str, ctx: &FunctionContext<'_>) -> Value { continue; }; if kw_name.id.as_str() == name { - return expr_to_value(&kw.value, ctx.source); + return expr_to_value(&kw.value, source); } } Value::Null diff --git a/crates/ruff_python_dto_check/src/matcher/class_with_decorator.rs b/crates/ruff_python_dto_check/src/matcher/class_with_decorator.rs new file mode 100644 index 0000000000000..b80dbea1268b9 --- /dev/null +++ b/crates/ruff_python_dto_check/src/matcher/class_with_decorator.rs @@ -0,0 +1,102 @@ +//! Matcher for `class_with_decorator` kind. +//! +//! Matches top-level class definitions decorated with a decorator that +//! satisfies the rule's `decorator` selector, then evaluates the `emit` +//! dot-path expressions to produce an [`EmittedBundle`]. Like the function +//! matcher, only module-level statements are visited: classes nested in +//! classes or functions are out of scope. The class name is stored in +//! [`EmittedBundle::function_name`]. + +use std::collections::BTreeMap; + +use ruff_python_ast::{Stmt, StmtClassDef}; +use ruff_python_parser::parse_module; +use ruff_source_file::LineIndex; +use ruff_text_size::Ranged; +use serde_json::Value; + +use crate::bundle::EmittedBundle; +use crate::config::{Config, MatchKind, MatchRule}; +use crate::emit::{ClassContext, eval_class_path}; +use crate::matcher::decorator::find_matching_decorator; +use crate::matcher::function_with_decorator::resolve_family; + +/// Parse one Python source file and emit one [`EmittedBundle`] per matched +/// `class_with_decorator` rule and class. Rules of other kinds are skipped. +pub fn harvest_module_with_config( + source_file: &str, + source: &str, + config: &Config, +) -> Vec { + let Ok(parsed) = parse_module(source) else { + return Vec::new(); + }; + let line_index = LineIndex::from_source_text(source); + let mut out = Vec::new(); + + for stmt in &parsed.syntax().body { + let Stmt::ClassDef(class) = stmt else { + continue; + }; + for rule in config + .match_rules + .iter() + .filter(|r| r.kind == MatchKind::ClassWithDecorator) + { + if let Some(bundle) = + try_match_class(source_file, source, &line_index, class, rule, config) + { + out.push(bundle); + } + } + } + out +} + +/// Try to match a single class against a single rule. +fn try_match_class( + source_file: &str, + source: &str, + line_index: &LineIndex, + class: &StmtClassDef, + rule: &MatchRule, + config: &Config, +) -> Option { + let matched_dec = find_matching_decorator(&class.decorator_list, rule.decorator.as_ref())?; + + let ctx = ClassContext { + class, + matched_decorator: matched_dec, + source, + line_index, + }; + + let mut fields: BTreeMap = BTreeMap::new(); + for (field_name, path) in &rule.emit { + fields.insert(field_name.clone(), eval_class_path(path, &ctx)); + } + + let line_start = + u32::try_from(line_index.line_index(class.range().start()).get()).unwrap_or(u32::MAX); + let line_end = + u32::try_from(line_index.line_index(class.range().end()).get()).unwrap_or(u32::MAX); + + let all_decorators: Vec = class + .decorator_list + .iter() + .map(|d| source[d.range().to_std_range()].to_string()) + .collect(); + + Some(EmittedBundle { + match_id: rule.id.clone(), + file: source_file.to_string(), + function_name: class.name.id.to_string(), + family: resolve_family(source_file, config), + line_start, + line_end, + body_lines: line_end.saturating_sub(line_start) + 1, + all_decorators, + fields, + comparison_within_family: None, + }) +} diff --git a/crates/ruff_python_dto_check/src/matcher/decorator.rs b/crates/ruff_python_dto_check/src/matcher/decorator.rs new file mode 100644 index 0000000000000..ee6c07104709a --- /dev/null +++ b/crates/ruff_python_dto_check/src/matcher/decorator.rs @@ -0,0 +1,80 @@ +//! Decorator selector matching shared by the decorated-function and +//! decorated-class matchers. + +use ruff_python_ast::{Decorator, Expr}; + +use crate::config::DecoratorSelector; + +/// Find the first decorator in `decorators` that satisfies `selector`. +pub(crate) fn find_matching_decorator<'a>( + decorators: &'a [Decorator], + selector: Option<&DecoratorSelector>, +) -> Option<&'a Decorator> { + decorators + .iter() + .find(|&dec| decorator_matches(dec, selector)) +} + +/// Check if a decorator satisfies a selector. +pub(crate) fn decorator_matches(dec: &Decorator, sel: Option<&DecoratorSelector>) -> bool { + let Some(sel) = sel else { + // No selector → match any decorated definition. + return true; + }; + if sel.any { + return true; + } + + let min_pos = sel.min_positional_args.unwrap_or(0); + + if let Some(attr_name) = &sel.attribute { + if decorator_has_attribute(dec, attr_name) { + return call_has_min_positional(dec, min_pos); + } + } + + if let Some(bare_name) = &sel.name { + if decorator_has_bare_name(dec, bare_name) { + return call_has_min_positional(dec, min_pos); + } + } + + false +} + +/// `@bp.route(...)` → attribute = "route" +fn decorator_has_attribute(dec: &Decorator, attr_name: &str) -> bool { + if let Expr::Call(call) = &dec.expression + && let Expr::Attribute(attr) = &*call.func + { + return attr.attr.id.as_str() == attr_name; + } + // Non-call decorator with attribute access: `@bp.route` (no parens). + if let Expr::Attribute(attr) = &dec.expression { + return attr.attr.id.as_str() == attr_name; + } + false +} + +/// `@login_required` → name = "`login_required`" +fn decorator_has_bare_name(dec: &Decorator, name: &str) -> bool { + if let Expr::Call(call) = &dec.expression + && let Expr::Name(n) = &*call.func + { + return n.id.as_str() == name; + } + if let Expr::Name(n) = &dec.expression { + return n.id.as_str() == name; + } + false +} + +fn call_has_min_positional(dec: &Decorator, min: u32) -> bool { + if min == 0 { + return true; + } + if let Expr::Call(call) = &dec.expression { + return u32::try_from(call.arguments.args.len()).unwrap_or(u32::MAX) >= min; + } + false +} diff --git a/crates/ruff_python_dto_check/src/matcher/function_with_decorator.rs b/crates/ruff_python_dto_check/src/matcher/function_with_decorator.rs index 991be6d924f8d..6403e32bffc25 100644 --- a/crates/ruff_python_dto_check/src/matcher/function_with_decorator.rs +++ b/crates/ruff_python_dto_check/src/matcher/function_with_decorator.rs @@ -7,18 +7,19 @@ use std::collections::BTreeMap; use std::path::Path; -use ruff_python_ast::{Decorator, Expr, Stmt, StmtFunctionDef}; +use ruff_python_ast::{Stmt, StmtFunctionDef}; use ruff_python_parser::parse_module; use ruff_source_file::LineIndex; use ruff_text_size::Ranged; use serde_json::Value; use crate::bundle::EmittedBundle; -use crate::config::{Config, DecoratorSelector, MatchRule}; +use crate::config::{Config, MatchKind, MatchRule}; use crate::emit::{FunctionContext, eval_path}; +use crate::matcher::decorator::find_matching_decorator; /// Parse one Python source file and emit one [`EmittedBundle`] per matched -/// function-with-decorator rule combination. +/// `function_with_decorator` rule and function. Rules of other kinds are skipped. pub fn harvest_module_with_config( source_file: &str, source: &str, @@ -34,7 +35,11 @@ pub fn harvest_module_with_config( let Stmt::FunctionDef(func) = stmt else { continue; }; - for rule in &config.match_rules { + for rule in config + .match_rules + .iter() + .filter(|r| r.kind == MatchKind::FunctionWithDecorator) + { if let Some(bundle) = try_match_function(source_file, source, &line_index, func, rule, config) { @@ -54,7 +59,7 @@ fn try_match_function( rule: &MatchRule, config: &Config, ) -> Option { - let matched_dec = find_matching_decorator(func, rule)?; + let matched_dec = find_matching_decorator(&func.decorator_list, rule.decorator.as_ref())?; let ctx = FunctionContext { func, @@ -102,81 +107,6 @@ fn try_match_function( }) } -/// Find the first decorator on `func` that satisfies the rule's selector. -fn find_matching_decorator<'a>( - func: &'a StmtFunctionDef, - rule: &MatchRule, -) -> Option<&'a Decorator> { - let sel = rule.decorator.as_ref(); - func.decorator_list - .iter() - .find(|&dec| decorator_matches(dec, sel)) -} - -/// Check if a decorator satisfies a selector. -fn decorator_matches(dec: &Decorator, sel: Option<&DecoratorSelector>) -> bool { - let Some(sel) = sel else { - // No selector → match any decorated function. - return true; - }; - if sel.any { - return true; - } - - let min_pos = sel.min_positional_args.unwrap_or(0); - - if let Some(attr_name) = &sel.attribute { - if decorator_has_attribute(dec, attr_name) { - return call_has_min_positional(dec, min_pos); - } - } - - if let Some(bare_name) = &sel.name { - if decorator_has_bare_name(dec, bare_name) { - return call_has_min_positional(dec, min_pos); - } - } - - false -} - -/// `@bp.route(...)` → attribute = "route" -fn decorator_has_attribute(dec: &Decorator, attr_name: &str) -> bool { - if let Expr::Call(call) = &dec.expression - && let Expr::Attribute(attr) = &*call.func - { - return attr.attr.id.as_str() == attr_name; - } - // Non-call decorator with attribute access: `@bp.route` (no parens). - if let Expr::Attribute(attr) = &dec.expression { - return attr.attr.id.as_str() == attr_name; - } - false -} - -/// `@login_required` → name = "`login_required`" -fn decorator_has_bare_name(dec: &Decorator, name: &str) -> bool { - if let Expr::Call(call) = &dec.expression - && let Expr::Name(n) = &*call.func - { - return n.id.as_str() == name; - } - if let Expr::Name(n) = &dec.expression { - return n.id.as_str() == name; - } - false -} - -fn call_has_min_positional(dec: &Decorator, min: u32) -> bool { - if min == 0 { - return true; - } - if let Expr::Call(call) = &dec.expression { - return u32::try_from(call.arguments.args.len()).unwrap_or(u32::MAX) >= min; - } - false -} - /// Resolve family name from `source_file` path using the config's group rule. pub fn resolve_family(source_file: &str, config: &Config) -> String { let filename = Path::new(source_file) diff --git a/crates/ruff_python_dto_check/src/matcher/mod.rs b/crates/ruff_python_dto_check/src/matcher/mod.rs index d4ff299c6d843..5145b583131f9 100644 --- a/crates/ruff_python_dto_check/src/matcher/mod.rs +++ b/crates/ruff_python_dto_check/src/matcher/mod.rs @@ -1,5 +1,21 @@ -//! Matcher trait and dispatch over config-driven match rules. -//! -//! Phase B fills this in. Phase A stub: empty module keeps the crate compiling. +//! Config-driven matchers and their combined entry point. +pub mod class_with_decorator; +pub(crate) mod decorator; pub mod function_with_decorator; + +use crate::bundle::EmittedBundle; +use crate::config::Config; + +/// Run every matcher kind over one source file and return the bundles in +/// source order (stable sort on `line_start`). +pub fn harvest_with_config(source_file: &str, source: &str, config: &Config) -> Vec { + let mut out = function_with_decorator::harvest_module_with_config(source_file, source, config); + out.extend(class_with_decorator::harvest_module_with_config( + source_file, + source, + config, + )); + out.sort_by_key(|b| b.line_start); + out +} diff --git a/crates/ruff_python_dto_check/src/observations.rs b/crates/ruff_python_dto_check/src/observations.rs index c59147c29c3f8..7375033013d70 100644 --- a/crates/ruff_python_dto_check/src/observations.rs +++ b/crates/ruff_python_dto_check/src/observations.rs @@ -2,7 +2,8 @@ //! //! # AST hash algorithm //! -//! Post-order walk of the function body (a `Suite`), emitting one token per +//! Post-order walk of the body (a `Suite`) of the function or class starting +//! on the bundle's `line_start`, emitting one token per //! node of the form `()`. SHA-256 of the concatenated //! token string becomes `ast_hash_self`. This is deterministic, captures //! structural shape, and does not depend on identifier names or whitespace. @@ -23,6 +24,8 @@ use sha2::{Digest, Sha256}; use ruff_python_ast::{Expr, ExprContext, Stmt}; use ruff_python_parser::parse_module; +use ruff_source_file::LineIndex; +use ruff_text_size::Ranged; use crate::bundle::{ComparisonWithinFamily, Distribution, EmittedBundle}; @@ -160,12 +163,17 @@ fn compute_ast_hash(bundle: &EmittedBundle, source_map: &BTreeMap &func.body, + Stmt::ClassDef(class) => &class.body, + _ => continue, + }; + let start_line = line_index.line_index(stmt.range().start()).get(); + if u32::try_from(start_line).is_ok_and(|l| l == bundle.line_start) { + for s in body { walk_stmt(s, &mut tokens); } break; diff --git a/crates/ruff_python_dto_check/tests/class_matcher_test.rs b/crates/ruff_python_dto_check/tests/class_matcher_test.rs new file mode 100644 index 0000000000000..df4e5a08230f8 --- /dev/null +++ b/crates/ruff_python_dto_check/tests/class_matcher_test.rs @@ -0,0 +1,178 @@ +//! `class_with_decorator` matcher: selection, emit paths, ordering, hashing. + +use std::collections::BTreeMap; + +use serde_json::json; + +use ruff_python_dto_check::bundle::EmittedBundle; +use ruff_python_dto_check::config::Config; +use ruff_python_dto_check::matcher::function_with_decorator; +use ruff_python_dto_check::matcher::harvest_with_config; +use ruff_python_dto_check::observations::attach_observations; + +const FIXTURE_PATH: &str = "models.py"; + +const FIXTURE: &str = r#" +from dataclasses import dataclass + +@dataclass +class A(Base, metaclass=M): + x: int + y: str = "d" + + def m(self): + return self.x + +@dataclass +def f(): + pass + +@dataclass(frozen=True) +class B: + z: float + +class C: + c: int + +@other +class D: + d: int + +class E: + @dataclass + class Inner: + i: int +"#; + +const CLASS_RULE: &str = r#"{ + "id": "dc", + "kind": "class_with_decorator", + "decorator": {"name": "dataclass"}, + "emit": { + "name": "class.name", + "bases": "class.bases", + "fields": "class.fields", + "methods": "class.methods", + "frozen": "decorator.kwargs.frozen", + "decorators": "class.decorators", + "line_start": "class.range.line_start" + } +}"#; + +const FUNCTION_RULE: &str = r#"{ + "id": "fn", + "kind": "function_with_decorator", + "decorator": {"name": "dataclass"}, + "emit": {"name": "def.name"} +}"#; + +fn config(rules: &[&str]) -> Config { + let json = format!(r#"{{"match": [{}]}}"#, rules.join(",")); + Config::from_json_str(&json).expect("config parses") +} + +fn names(bundles: &[EmittedBundle]) -> Vec<&str> { + bundles.iter().map(|b| b.function_name.as_str()).collect() +} + +#[test] +fn class_rule_matches_only_top_level_decorated_classes() { + let cfg = config(&[CLASS_RULE]); + let bundles = harvest_with_config(FIXTURE_PATH, FIXTURE, &cfg); + assert_eq!(names(&bundles), vec!["A", "B"]); +} + +#[test] +fn class_emit_paths_evaluate_exactly() { + let cfg = config(&[CLASS_RULE]); + let bundles = harvest_with_config(FIXTURE_PATH, FIXTURE, &cfg); + let a = &bundles[0]; + let b = &bundles[1]; + + assert_eq!(a.fields["name"], json!("A")); + assert_eq!(a.fields["bases"], json!(["Base", "metaclass=M"])); + assert_eq!( + a.fields["fields"], + json!([ + {"name": "x", "annotation": "int", "default": null}, + {"name": "y", "annotation": "str", "default": "\"d\""} + ]) + ); + assert_eq!(a.fields["methods"], json!(["m"])); + assert_eq!(a.fields["decorators"], json!(["@dataclass"])); + assert_eq!(a.fields["frozen"], json!(null)); + assert_eq!(a.family, "models"); + assert_eq!(a.match_id, "dc"); + + assert_eq!(b.fields["frozen"], json!(true)); + assert_eq!(b.fields["bases"], json!([])); + assert_eq!(b.fields["decorators"], json!(["@dataclass(frozen=True)"])); + assert_eq!( + b.fields["fields"], + json!([{"name": "z", "annotation": "float", "default": null}]) + ); + assert_eq!(b.fields["methods"], json!([])); +} + +#[test] +fn function_rule_does_not_match_classes() { + let cfg = config(&[FUNCTION_RULE]); + let bundles = harvest_with_config(FIXTURE_PATH, FIXTURE, &cfg); + assert_eq!(names(&bundles), vec!["f"]); + + let direct = function_with_decorator::harvest_module_with_config(FIXTURE_PATH, FIXTURE, &cfg); + assert_eq!(names(&direct), vec!["f"]); +} + +#[test] +fn mixed_rules_are_merged_in_source_order() { + let cfg = config(&[FUNCTION_RULE, CLASS_RULE]); + let bundles = harvest_with_config(FIXTURE_PATH, FIXTURE, &cfg); + assert_eq!(names(&bundles), vec!["A", "f", "B"]); + assert_eq!( + bundles + .iter() + .map(|b| b.match_id.as_str()) + .collect::>(), + vec!["dc", "fn", "dc"] + ); +} + +#[test] +fn unknown_kind_error_names_both_kinds() { + let err = Config::from_json_str(r#"{"match": [{"id": "x", "kind": "nope", "emit": {}}]}"#) + .expect_err("must fail"); + let msg = err.to_string(); + assert!(msg.contains("function_with_decorator"), "got: {msg}"); + assert!(msg.contains("class_with_decorator"), "got: {msg}"); +} + +#[test] +fn class_ast_hashes_are_distinct_and_non_empty() { + const EMPTY_SHA256: &str = + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"; + + let cfg = config(&[CLASS_RULE]); + let bundles = harvest_with_config(FIXTURE_PATH, FIXTURE, &cfg); + let mut family_map: BTreeMap> = BTreeMap::new(); + for b in bundles { + family_map.entry(b.family.clone()).or_default().push(b); + } + let mut source_map: BTreeMap = BTreeMap::new(); + source_map.insert(FIXTURE_PATH.to_string(), FIXTURE.to_string()); + attach_observations(&mut family_map, &source_map); + + let family = &family_map["models"]; + let hash_of = |name: &str| -> String { + family + .iter() + .find(|b| b.function_name == name) + .and_then(|b| b.comparison_within_family.as_ref()) + .map(|c| c.ast_hash_self.clone()) + .expect("hash attached") + }; + let (a, b) = (hash_of("A"), hash_of("B")); + assert_ne!(a, b); + assert_ne!(a, EMPTY_SHA256); + assert_ne!(b, EMPTY_SHA256); +} From 721ca371c99a97acc3c0c22a91b041c459465b68 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:46:06 +0000 Subject: [PATCH 2/2] ruff_python_dto_check: indent README list continuation for mdformat Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XAqKMMCqUkM2QW7zqLC2NJ --- crates/ruff_python_dto_check/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_python_dto_check/README.md b/crates/ruff_python_dto_check/README.md index 8b69d28e67ea2..12992ff0c5430 100644 --- a/crates/ruff_python_dto_check/README.md +++ b/crates/ruff_python_dto_check/README.md @@ -80,7 +80,7 @@ detection without calling anything a duplicate). Current implementation: - Two matcher kinds: `function_with_decorator` and `class_with_decorator` - (top-level definitions only; the class name is reported in `function_name`) + (top-level definitions only; the class name is reported in `function_name`) - Flask-style route detection via `@.route(...)` in the default profile - Config-driven emit fields (`def.name`, `def.params`, `def.body.source`, `decorator.args[0]`, `decorator.kwargs.`, etc.; for classes