Skip to main content

ryo_suggest/spec/
group_inconsistency.rs

1//! SpecGroupInconsistency - Detects mixed Groups in same module
2//!
3//! This rule checks that all Spec TypeAliases in a module use the same Group.
4
5use std::collections::HashMap;
6
7use ryo_analysis::context::AnalysisContext;
8use ryo_analysis::{SymbolId, SymbolKind, SymbolPath};
9
10use super::SpecSuggest;
11use crate::lint::{LintDetails, LintSuggest};
12use crate::{
13    LintSeverity, MutationSpec, OpportunityId, SafetyLevel, Suggest, SuggestCategory,
14    SuggestLocation, SuggestOpportunity, SuggestResult,
15};
16
17/// SpecGroupInconsistency rule
18///
19/// Detects modules where Spec TypeAliases use different Groups.
20///
21/// # Rule Code
22/// RS004 (Ryo Spec)
23///
24/// # Detection
25/// 1. Group Spec TypeAliases by their parent module
26/// 2. Extract the Group name from each Spec definition
27/// 3. Report modules with multiple different Groups
28///
29/// # Example Violation
30/// ```ignore
31/// // src/domain/task.rs
32/// type TaskSpec = Spec<DomainGroup, Task>;      // Uses DomainGroup
33/// type UserSpec = Spec<OtherGroup, User>;       // Uses OtherGroup - inconsistent!
34/// ```
35///
36/// # Fix
37/// This is a report-only rule. Manual review is required to decide
38/// which Group should be used consistently.
39pub struct SpecGroupInconsistency {
40    /// Suffix pattern to identify Spec TypeAliases (default: "Spec")
41    spec_suffix: String,
42}
43
44impl SpecGroupInconsistency {
45    pub fn new() -> Self {
46        Self {
47            spec_suffix: "Spec".to_string(),
48        }
49    }
50
51    /// Create with custom suffix pattern
52    pub fn with_suffix(suffix: impl Into<String>) -> Self {
53        Self {
54            spec_suffix: suffix.into(),
55        }
56    }
57
58    /// Extract group name from a Spec TypeAlias using code graph dependencies
59    ///
60    /// Looks for types used by this symbol that might be Group markers.
61    /// Returns the first type that looks like a Group (ends with "Group").
62    fn extract_group_name(&self, ctx: &AnalysisContext, symbol_id: SymbolId) -> Option<String> {
63        let typeflow = ctx.typeflow_graph();
64
65        // Look for types used by this symbol (via TypeFlow)
66        for used_id in typeflow.types_used_by(symbol_id) {
67            if let Some(path) = ctx.registry.path(used_id) {
68                let name = path.name();
69                // Heuristic: Group types often end with "Group"
70                if name.ends_with("Group") {
71                    return Some(name.to_string());
72                }
73            }
74        }
75
76        None
77    }
78}
79
80impl SpecSuggest for SpecGroupInconsistency {
81    fn spec_suffix(&self) -> &str {
82        &self.spec_suffix
83    }
84}
85
86impl Default for SpecGroupInconsistency {
87    fn default() -> Self {
88        Self::new()
89    }
90}
91
92impl Suggest for SpecGroupInconsistency {
93    fn name(&self) -> &'static str {
94        "spec-group-inconsistency"
95    }
96
97    fn description(&self) -> &str {
98        "Detects modules where Spec TypeAliases use different Groups"
99    }
100
101    fn category(&self) -> SuggestCategory {
102        SuggestCategory::Lint
103    }
104
105    fn safety_level(&self) -> SafetyLevel {
106        SafetyLevel::Manual // Requires manual decision on which Group to use
107    }
108
109    fn priority_weight(&self) -> f32 {
110        1.5 // Medium-high priority for consistency
111    }
112
113    fn detect(&self, ctx: &AnalysisContext, symbols: &[SymbolId]) -> Vec<SuggestOpportunity> {
114        let mut opportunities = Vec::new();
115        let mut next_id = 0u32;
116
117        // Collect Spec TypeAliases grouped by module
118        // Map: module_path -> Vec<(symbol_id, alias_name, group_name)>
119        let mut module_specs: HashMap<SymbolPath, Vec<(SymbolId, String, Option<String>)>> =
120            HashMap::new();
121
122        // If specific symbols provided, check only those
123        let symbols_to_check: Vec<SymbolId> = if symbols.is_empty() {
124            ctx.registry.iter_by_kind(SymbolKind::TypeAlias).collect()
125        } else {
126            symbols.to_vec()
127        };
128
129        for symbol_id in symbols_to_check {
130            let path = match ctx.registry.path(symbol_id) {
131                Some(p) => p,
132                None => continue,
133            };
134
135            let alias_name = path.name();
136
137            // Check if this looks like a Spec TypeAlias
138            if !self.is_spec_alias(alias_name) {
139                continue;
140            }
141
142            // Get module path
143            let module_path = match self.get_module_path(path) {
144                Some(mp) => mp,
145                None => continue,
146            };
147
148            // Extract group name
149            let group_name = self.extract_group_name(ctx, symbol_id);
150
151            module_specs.entry(module_path).or_default().push((
152                symbol_id,
153                alias_name.to_string(),
154                group_name,
155            ));
156        }
157
158        // Check each module for group inconsistencies
159        for (module_path, specs) in module_specs {
160            // Skip modules with only one Spec
161            if specs.len() < 2 {
162                continue;
163            }
164
165            // Collect unique groups in this module
166            let groups: Vec<_> = specs.iter().filter_map(|(_, _, g)| g.as_ref()).collect();
167
168            // Check if there are multiple different groups
169            let unique_groups: std::collections::HashSet<_> = groups.iter().collect();
170            if unique_groups.len() <= 1 {
171                continue; // All specs use the same group (or none have detectable groups)
172            }
173
174            // Found inconsistency - create opportunity for each inconsistent Spec
175            // Get the most common group as the "expected" one
176            let mut group_counts: HashMap<&String, usize> = HashMap::new();
177            for g in &groups {
178                *group_counts.entry(g).or_insert(0) += 1;
179            }
180            let expected_group = group_counts
181                .iter()
182                .max_by_key(|(_, count)| *count)
183                .map(|(g, _)| (*g).clone());
184
185            // Create opportunities for specs that don't match the expected group
186            for (symbol_id, alias_name, group_name) in &specs {
187                let spec_group = match group_name {
188                    Some(g) => g,
189                    None => continue,
190                };
191
192                if expected_group.as_ref() == Some(spec_group) {
193                    continue; // This one matches the expected group
194                }
195
196                let Some(location) = SuggestLocation::from_context(ctx, *symbol_id) else {
197                    continue;
198                };
199
200                let all_groups: Vec<_> = unique_groups.iter().map(|g| g.as_str()).collect();
201
202                let opp = self.create_lint_opportunity(
203                    OpportunityId::new(next_id),
204                    vec![*symbol_id],
205                    location,
206                    format!(
207                        "Module `{}` has mixed Spec Groups: `{}` uses `{}` but module also has `{}`",
208                        module_path,
209                        alias_name,
210                        spec_group,
211                        expected_group.as_deref().unwrap_or("unknown")
212                    ),
213                    LintDetails {
214                        suggestion: Some(format!(
215                            "Consider using `{}` for all Specs in this module",
216                            expected_group.as_deref().unwrap_or("a consistent Group")
217                        )),
218                        expected: Some(format!(
219                            "All Specs use `{}`",
220                            expected_group.as_deref().unwrap_or("same Group")
221                        )),
222                        actual: Some(format!("Groups found: {}", all_groups.join(", "))),
223                    },
224                );
225
226                opportunities.push(opp);
227                next_id += 1;
228            }
229        }
230
231        opportunities
232    }
233
234    fn to_mutation_specs(
235        &self,
236        _ctx: &AnalysisContext,
237        _opportunity: &SuggestOpportunity,
238    ) -> SuggestResult<Vec<MutationSpec>> {
239        // Report-only rule - no automatic fix
240        Ok(Vec::new())
241    }
242}
243
244impl LintSuggest for SpecGroupInconsistency {
245    fn code(&self) -> &'static str {
246        "RS004"
247    }
248
249    fn default_severity(&self) -> LintSeverity {
250        LintSeverity::Warning
251    }
252}
253
254#[cfg(test)]
255mod tests {
256    use super::*;
257
258    #[test]
259    fn test_is_spec_alias() {
260        let rule = SpecGroupInconsistency::new();
261
262        assert!(rule.is_spec_alias("TaskSpec"));
263        assert!(rule.is_spec_alias("UserSpec"));
264        assert!(!rule.is_spec_alias("Spec"));
265        assert!(!rule.is_spec_alias("Task"));
266    }
267
268    #[test]
269    fn test_custom_suffix() {
270        let rule = SpecGroupInconsistency::with_suffix("Domain");
271
272        assert!(rule.is_spec_alias("TaskDomain"));
273        assert!(!rule.is_spec_alias("TaskSpec"));
274    }
275}