diff --git a/crates/ruff_python_dto_check/README.md b/crates/ruff_python_dto_check/README.md index 5176666b06506..12992ff0c5430 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); +}