From 63758c526e67d397d3f298a4ffd90a85a308a58c Mon Sep 17 00:00:00 2001 From: PerishCode Date: Sun, 7 Jun 2026 21:56:19 +0800 Subject: [PATCH] naming: add affix pressure rule --- crates/flavor-cli/src/naming/mod.rs | 117 ++- crates/flavor-cli/src/plugins/language.rs | 26 +- .../flavor-cli/src/plugins/language/name.rs | 17 +- .../flavor-cli/src/plugins/language/python.rs | 7 +- crates/flavor-cli/src/rules.rs | 11 + crates/flavor-cli/tests/unit/naming.rs | 89 +- .../flavor-plugin-svelte/src/markup/parser.rs | 88 +- .../src/parser/modules.rs | 84 +- .../src/parser/statements.rs | 56 +- .../flavor-plugin-typescript/tests/parser.rs | 801 ++++++++---------- .../flavor-plugin-vue/src/template/parser.rs | 74 +- 11 files changed, 769 insertions(+), 601 deletions(-) diff --git a/crates/flavor-cli/src/naming/mod.rs b/crates/flavor-cli/src/naming/mod.rs index ff181ab..796ec50 100644 --- a/crates/flavor-cli/src/naming/mod.rs +++ b/crates/flavor-cli/src/naming/mod.rs @@ -1,9 +1,15 @@ use crate::{ config::RuleSettings, model::{issue, Issue}, - rules::PAYLOAD_MAX_WORDS, + rules::{PAYLOAD_MAX_WORDS, PAYLOAD_MIN_OCCURRENCES}, }; +const DEFAULT_AFFIX_MIN_OCCURRENCES: usize = 15; +const MAX_EXAMPLES: usize = 5; +const IGNORED_AFFIXES: &[&str] = &[ + "new", "default", "test", "tests", "ok", "run", "runs", "main", "mod", +]; + pub(crate) fn check_name( issues: &mut Vec, rule: &RuleSettings, @@ -31,11 +37,53 @@ pub(crate) fn check_name( )); } +#[derive(Debug, Clone, Eq, PartialEq)] +pub(crate) struct NameFact { + pub(crate) kind: String, + pub(crate) name: String, + pub(crate) line: usize, +} + +pub(crate) fn check_affix_pressure( + issues: &mut Vec, + rule: &RuleSettings, + path: &str, + names: &[NameFact], +) { + if !rule.enabled { + return; + } + let min_occurrences = rule + .usize(PAYLOAD_MIN_OCCURRENCES) + .unwrap_or(DEFAULT_AFFIX_MIN_OCCURRENCES); + for bucket in affix_buckets(names) { + if bucket.names.len() < min_occurrences { + continue; + } + issues.push(issue( + rule.severity, + rule.id, + path, + Some(bucket.line), + format!( + "{} {} names share the {} `{}`; consider whether `{}` wants to move from the name into a module, namespace, type, or factory; if the remaining names do not form a clear family, `{}` may be too broad and should be named more sharply; examples: {}", + bucket.names.len(), + bucket.kind, + bucket.side.label(), + bucket.affix, + bucket.affix, + bucket.affix, + examples(&bucket.names), + ), + )); + } +} + pub(crate) fn count_name_words(name: &str) -> usize { split_name_words(name).len() } -fn split_name_words(name: &str) -> Vec { +pub(crate) fn split_name_words(name: &str) -> Vec { let mut words = Vec::new(); let normalized = name .strip_prefix("r#") @@ -53,6 +101,71 @@ fn split_name_words(name: &str) -> Vec { words } +#[derive(Debug, Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] +enum AffixSide { + Prefix, + Suffix, +} + +impl AffixSide { + fn label(self) -> &'static str { + match self { + AffixSide::Prefix => "prefix", + AffixSide::Suffix => "suffix", + } + } +} + +#[derive(Debug, Clone, Eq, PartialEq)] +struct AffixBucket { + kind: String, + side: AffixSide, + affix: String, + line: usize, + names: Vec, +} + +fn affix_buckets(names: &[NameFact]) -> Vec { + use std::collections::BTreeMap; + + let mut buckets = BTreeMap::<(String, AffixSide, String), AffixBucket>::new(); + for fact in names { + let words = split_name_words(&fact.name); + if words.len() < 2 { + continue; + } + for (side, affix) in [ + (AffixSide::Prefix, words.first().unwrap()), + (AffixSide::Suffix, words.last().unwrap()), + ] { + let affix = affix.to_ascii_lowercase(); + if IGNORED_AFFIXES.contains(&affix.as_str()) { + continue; + } + let key = (fact.kind.clone(), side, affix.clone()); + let bucket = buckets.entry(key).or_insert_with(|| AffixBucket { + kind: fact.kind.clone(), + side, + affix, + line: fact.line, + names: Vec::new(), + }); + bucket.line = bucket.line.min(fact.line); + bucket.names.push(fact.name.clone()); + } + } + buckets.into_values().collect() +} + +fn examples(names: &[String]) -> String { + names + .iter() + .take(MAX_EXAMPLES) + .map(|name| format!("`{name}`")) + .collect::>() + .join(", ") +} + fn split_camel_part(part: &str, words: &mut Vec) { let chars: Vec = part.chars().collect(); let mut current = String::new(); diff --git a/crates/flavor-cli/src/plugins/language.rs b/crates/flavor-cli/src/plugins/language.rs index 0dfd38b..9b10a8f 100644 --- a/crates/flavor-cli/src/plugins/language.rs +++ b/crates/flavor-cli/src/plugins/language.rs @@ -22,12 +22,12 @@ use crate::{ model::{issue, Issue}, plugins::{AnalysisContext, PluginOutput, ProductSet, SourceFileScope}, rules::{ - DISPATCH_BRANCH_TOO_LONG, NAMING_TOO_MANY_WORDS, PAYLOAD_ALLOWED_INTRINSICS, - PAYLOAD_MAX_BLOCKS, PAYLOAD_MAX_LINES, PAYLOAD_PRIMITIVE_SOURCES, RUST_PARSE_ERROR, - RUST_TESTS_IN_SOURCE, SHAPE_REPEATED_TOKEN_PATTERN, SVELTE_COMPONENT_TOO_LONG, - SVELTE_PARSE_ERROR, SVELTE_SCRIPT_TOO_LONG, SVELTE_STYLE_TOO_LONG, - SVELTE_TEMPLATE_TOO_COMPLEX, TSX_NO_INTRINSICS, TSX_REQUIRES_PRIMITIVE, TS_PARSE_ERROR, - VUE_PARSE_ERROR, + DISPATCH_BRANCH_TOO_LONG, NAMING_AFFIX_PRESSURE, NAMING_TOO_MANY_WORDS, + PAYLOAD_ALLOWED_INTRINSICS, PAYLOAD_MAX_BLOCKS, PAYLOAD_MAX_LINES, + PAYLOAD_PRIMITIVE_SOURCES, RUST_PARSE_ERROR, RUST_TESTS_IN_SOURCE, + SHAPE_REPEATED_TOKEN_PATTERN, SVELTE_COMPONENT_TOO_LONG, SVELTE_PARSE_ERROR, + SVELTE_SCRIPT_TOO_LONG, SVELTE_STYLE_TOO_LONG, SVELTE_TEMPLATE_TOO_COMPLEX, + TSX_NO_INTRINSICS, TSX_REQUIRES_PRIMITIVE, TS_PARSE_ERROR, VUE_PARSE_ERROR, }, }; use flavor_core::{Fact, ProductDiagnostic}; @@ -52,10 +52,14 @@ pub(crate) fn analyze_rust_source<'a>(context: &AnalysisContext<'a>) -> PluginOu let name_rule = context .config .rule(scope.relative, NodeKind::File, NAMING_TOO_MANY_WORDS); + let affix_rule = context + .config + .rule(scope.relative, NodeKind::File, NAMING_AFFIX_PRESSURE); check_name_facts( &context.products, "rust", &name_rule, + &affix_rule, scope.path, &mut issues, ); @@ -164,7 +168,15 @@ fn analyze_typescript_products( check_tsx_rules(config, scope, products, issues); let name_rule = config.rule(scope.relative, NodeKind::File, NAMING_TOO_MANY_WORDS); - check_name_facts(products, "typescript", &name_rule, scope.path, issues); + let affix_rule = config.rule(scope.relative, NodeKind::File, NAMING_AFFIX_PRESSURE); + check_name_facts( + products, + "typescript", + &name_rule, + &affix_rule, + scope.path, + issues, + ); let dispatch_rule = config.rule(scope.relative, NodeKind::File, DISPATCH_BRANCH_TOO_LONG); check_dispatch_branches( diff --git a/crates/flavor-cli/src/plugins/language/name.rs b/crates/flavor-cli/src/plugins/language/name.rs index dc05866..5a0e984 100644 --- a/crates/flavor-cli/src/plugins/language/name.rs +++ b/crates/flavor-cli/src/plugins/language/name.rs @@ -1,4 +1,9 @@ -use crate::{config::RuleSettings, model::Issue, naming::check_name, plugins::ProductSet}; +use crate::{ + config::RuleSettings, + model::Issue, + naming::{check_affix_pressure, check_name, NameFact}, + plugins::ProductSet, +}; const NAME_FACT_KEYS: &[&str] = &[ "name.function", @@ -11,9 +16,11 @@ pub(super) fn check_name_facts( products: &ProductSet, grammar_id: &'static str, rule: &RuleSettings, + affix_rule: &RuleSettings, path: &str, issues: &mut Vec, ) { + let mut affix_names = Vec::new(); for key in NAME_FACT_KEYS { for fact in products.facts(grammar_id, key) { let Some(name) = fact.text("name") else { @@ -27,6 +34,14 @@ pub(super) fn check_name_facts( .or_else(|| fact.text("kind")) .unwrap_or("name"); check_name(issues, rule, path, line, kind, name); + if matches!(key, &"name.function" | &"name.method") { + affix_names.push(NameFact { + kind: kind.to_string(), + name: name.to_string(), + line, + }); + } } } + check_affix_pressure(issues, affix_rule, path, &affix_names); } diff --git a/crates/flavor-cli/src/plugins/language/python.rs b/crates/flavor-cli/src/plugins/language/python.rs index 3fdb362..d454a0b 100644 --- a/crates/flavor-cli/src/plugins/language/python.rs +++ b/crates/flavor-cli/src/plugins/language/python.rs @@ -2,7 +2,8 @@ use crate::{ config::NodeKind, plugins::{AnalysisContext, PluginOutput}, rules::{ - DISPATCH_BRANCH_TOO_LONG, FUNCTION_TOO_LONG, NAMING_TOO_MANY_WORDS, PYTHON_PARSE_ERROR, + DISPATCH_BRANCH_TOO_LONG, FUNCTION_TOO_LONG, NAMING_AFFIX_PRESSURE, NAMING_TOO_MANY_WORDS, + PYTHON_PARSE_ERROR, }, }; @@ -28,10 +29,14 @@ pub(crate) fn analyze_python_source<'a>(context: &AnalysisContext<'a>) -> Plugin let name_rule = context .config .rule(scope.relative, NodeKind::File, NAMING_TOO_MANY_WORDS); + let affix_rule = context + .config + .rule(scope.relative, NodeKind::File, NAMING_AFFIX_PRESSURE); check_name_facts( &context.products, "python", &name_rule, + &affix_rule, scope.path, &mut issues, ); diff --git a/crates/flavor-cli/src/rules.rs b/crates/flavor-cli/src/rules.rs index 20396a5..302341a 100644 --- a/crates/flavor-cli/src/rules.rs +++ b/crates/flavor-cli/src/rules.rs @@ -6,6 +6,7 @@ use serde_json::Value; use crate::model::Severity; pub(crate) const NAMING_TOO_MANY_WORDS: &str = "core/naming/too-many-words"; +pub(crate) const NAMING_AFFIX_PRESSURE: &str = "core/naming/affix-pressure"; pub(crate) const DISPATCH_BRANCH_TOO_LONG: &str = "core/dispatch/branch-too-long"; pub(crate) const FUNCTION_TOO_LONG: &str = "core/function/too-long"; pub(crate) const FS_CHILDREN_SHAPE: &str = "core/fs/children-shape"; @@ -81,6 +82,15 @@ pub(crate) fn descriptor(rule_id: &str) -> Option { bad_flavor: "Names may be carrying scenario, path, or assertion context that belongs near an owner boundary.", action_hint: "Consider lifting repeated context into a namespace, object, class, module, impl block, or test module before shortening names.", }), + NAMING_AFFIX_PRESSURE => Some(RuleDescriptor { + id: NAMING_AFFIX_PRESSURE, + target: RuleTarget::File, + default_enabled: true, + default_severity: Severity::Warning, + default_payload: payload([(PAYLOAD_MIN_OCCURRENCES, 15)]), + bad_flavor: "A repeated naming prefix or suffix may be acting like structure while still living inside identifiers.", + action_hint: "Consider moving the affix into a module, namespace, type, or factory; if the remaining names do not form a clear family, sharpen the repeated word instead.", + }), DISPATCH_BRANCH_TOO_LONG => Some(RuleDescriptor { id: DISPATCH_BRANCH_TOO_LONG, target: RuleTarget::File, @@ -299,6 +309,7 @@ pub(crate) fn descriptor(rule_id: &str) -> Option { pub(crate) fn known_rule_ids() -> Vec<&'static str> { vec![ NAMING_TOO_MANY_WORDS, + NAMING_AFFIX_PRESSURE, DISPATCH_BRANCH_TOO_LONG, FUNCTION_TOO_LONG, FS_CHILDREN_SHAPE, diff --git a/crates/flavor-cli/tests/unit/naming.rs b/crates/flavor-cli/tests/unit/naming.rs index a86700f..af30ba1 100644 --- a/crates/flavor-cli/tests/unit/naming.rs +++ b/crates/flavor-cli/tests/unit/naming.rs @@ -1,5 +1,7 @@ use std::{ + fs, path::{Path, PathBuf}, + sync::atomic::{AtomicUsize, Ordering}, sync::LazyLock, }; @@ -9,10 +11,11 @@ use crate::{ naming::count_name_words, path_match::path_string, plugins::{PluginHost, Scope}, - rules::{DISPATCH_BRANCH_TOO_LONG, VUE_PARSE_ERROR}, + rules::{DISPATCH_BRANCH_TOO_LONG, NAMING_AFFIX_PRESSURE, VUE_PARSE_ERROR}, }; static CONFIG: LazyLock = LazyLock::new(|| GuardConfig::core(PathBuf::from("."))); +static TEMP_SEQ: AtomicUsize = AtomicUsize::new(0); #[test] fn counts_name_words() { @@ -77,6 +80,51 @@ fn ts_detects_long_names() { .contains("controllerRuntimeResultValueText")); } +#[test] +fn ignores_small_affix_buckets() { + let relative = Path::new("sample.rs"); + let issues = source_issues( + relative, + r#" +fn name_fact() {} +fn line_count_fact() {} +fn descriptor_block_fact() {} +fn embedded_script_fact() {} +fn named_span_fact() {} +"#, + ); + + assert!(!issues + .iter() + .any(|issue| issue.rule == NAMING_AFFIX_PRESSURE)); +} + +#[test] +fn reports_affix_pressure() { + let relative = Path::new("sample.rs"); + let config = config_with_affix_threshold(3); + let issues = source_issues_with_config( + &config, + relative, + r#" +fn parse_token() {} +fn parse_statement() {} +fn parse_expression() {} +"#, + ); + + let issue = issues + .iter() + .find(|issue| issue.rule == NAMING_AFFIX_PRESSURE) + .expect("affix pressure issue"); + assert_eq!(issue.line, Some(2)); + assert!(issue + .message + .contains("3 function names share the prefix `parse`")); + assert!(issue.message.contains("move from the name into")); + assert!(issue.message.contains("too broad")); +} + #[test] fn vue_offsets_lines() { let relative = Path::new("Sample.vue"); @@ -168,14 +216,51 @@ fn flags_switch_case() { } fn source_issues(relative: &Path, source: &str) -> Vec { + source_issues_with_config(&CONFIG, relative, source) +} + +fn source_issues_with_config(config: &GuardConfig, relative: &Path, source: &str) -> Vec { let host = PluginHost::bundled(); let path = path_string(relative); let kind = source_file_kind(relative).expect("test source should have supported extension"); let mut issues = Vec::new(); host.run_scope( - &CONFIG, + config, Scope::source_file(relative, &path, source, kind), &mut issues, ); issues } + +fn config_with_affix_threshold(min_occurrences: usize) -> GuardConfig { + let root = temp_root("naming-affix"); + fs::create_dir_all(&root).unwrap(); + let path = root.join("flavor.toml"); + fs::write( + &path, + format!( + r#"[scan] +include = ["**/*.rs"] + +[[overrides]] +match = "**/*.rs" +kind = "file" + +[overrides.rules."core/naming/affix-pressure".payload] +min_occurrences = {min_occurrences} +"# + ), + ) + .unwrap(); + let config = GuardConfig::from_file(root.clone(), &path).unwrap(); + fs::remove_dir_all(root).unwrap(); + config +} + +fn temp_root(slug: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "flavor-{slug}-{}-{}", + std::process::id(), + TEMP_SEQ.fetch_add(1, Ordering::Relaxed) + )) +} diff --git a/crates/flavor-plugin-svelte/src/markup/parser.rs b/crates/flavor-plugin-svelte/src/markup/parser.rs index 9b3c982..3033150 100644 --- a/crates/flavor-plugin-svelte/src/markup/parser.rs +++ b/crates/flavor-plugin-svelte/src/markup/parser.rs @@ -40,77 +40,77 @@ impl<'a> MarkupParser<'a> { fn parse(mut self) -> SvelteMarkupAst { self.builder.start_node(kind::ROOT); - self.parse_children(None); + self.children(None); self.builder.finish_node(); SvelteMarkupAst::new(self.builder.finish(), self.diagnostics) } - fn parse_children(&mut self, parent_tag: Option<&str>) -> bool { + fn children(&mut self, parent_tag: Option<&str>) -> bool { while self.cursor < self.source.len() { if self.source[self.cursor..].starts_with(" bool { + fn block_children(&mut self, keyword: &str) -> bool { while self.cursor < self.source.len() { if self.source[self.cursor..].starts_with("{/") { let close_keyword = self.peek_block_keyword(2); if close_keyword.as_deref() == Some(keyword) { - self.parse_block_tag(kind::BLOCK_CLOSE, 2); + self.block_tag(kind::BLOCK_CLOSE, 2); return true; } return false; } if self.source[self.cursor..].starts_with("{:") { - self.parse_block_tag(kind::BLOCK_BRANCH, 2); + self.block_tag(kind::BLOCK_BRANCH, 2); } else { - self.parse_child(); + self.child(); } } false } - fn parse_child(&mut self) { + fn child(&mut self) { if self.source[self.cursor..].starts_with("