Skip to main content

lean_ctx/core/
smells.rs

1//! Code smell detection engine.
2//!
3//! Runs structural rules against the Property Graph (SQLite) and tree-sitter
4//! data to identify dead code, high complexity, god files, fan-out skew, etc.
5//! Each rule is a pure function: `&Connection -> Vec<SmellFinding>`.
6
7use rusqlite::Connection;
8use serde::Serialize;
9
10#[derive(Debug, Clone, Serialize)]
11pub struct SmellFinding {
12    pub rule: &'static str,
13    pub severity: Severity,
14    pub file_path: String,
15    pub symbol: Option<String>,
16    pub line: Option<usize>,
17    pub message: String,
18    pub metric: Option<f64>,
19}
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
22#[serde(rename_all = "lowercase")]
23pub enum Severity {
24    Info,
25    Warning,
26    Error,
27}
28
29#[derive(Debug, Clone, Serialize)]
30pub struct SmellSummary {
31    pub rule: &'static str,
32    pub description: &'static str,
33    pub findings: usize,
34}
35
36pub struct SmellConfig {
37    pub long_function_lines: usize,
38    pub long_file_lines: usize,
39    pub god_file_symbols: usize,
40    pub fan_out_threshold: usize,
41}
42
43impl Default for SmellConfig {
44    fn default() -> Self {
45        Self {
46            long_function_lines: 100,
47            long_file_lines: 500,
48            god_file_symbols: 30,
49            fan_out_threshold: 15,
50        }
51    }
52}
53
54pub static RULES: &[(&str, &str)] = &[
55    ("dead_code", "Symbols defined but never referenced"),
56    ("long_function", "Functions exceeding line threshold"),
57    ("long_file", "Files exceeding line threshold"),
58    ("god_file", "Files with excessive symbol count"),
59    ("fan_out_skew", "Functions calling too many other symbols"),
60    (
61        "duplicate_definitions",
62        "Same symbol name defined in multiple files",
63    ),
64    (
65        "untested_function",
66        "Exported symbols without test coverage",
67    ),
68    (
69        "cyclomatic_complexity",
70        "Functions with high branching complexity",
71    ),
72];
73
74pub fn scan_all(conn: &Connection, cfg: &SmellConfig) -> Vec<SmellFinding> {
75    let mut all = Vec::new();
76    for &(rule, _) in RULES {
77        all.extend(scan_rule(conn, rule, cfg));
78    }
79    all
80}
81
82pub fn scan_rule(conn: &Connection, rule: &str, cfg: &SmellConfig) -> Vec<SmellFinding> {
83    match rule {
84        "dead_code" => detect_dead_code(conn),
85        "long_function" => detect_long_functions(conn, cfg.long_function_lines),
86        "long_file" => detect_long_files(conn, cfg.long_file_lines),
87        "god_file" => detect_god_files(conn, cfg.god_file_symbols),
88        "fan_out_skew" => detect_fan_out(conn, cfg.fan_out_threshold),
89        "duplicate_definitions" => detect_duplicate_definitions(conn),
90        "untested_function" => detect_untested(conn),
91        "cyclomatic_complexity" => detect_cyclomatic_complexity(conn),
92        _ => Vec::new(),
93    }
94}
95
96pub fn summarize(findings: &[SmellFinding]) -> Vec<SmellSummary> {
97    RULES
98        .iter()
99        .map(|&(rule, desc)| SmellSummary {
100            rule,
101            description: desc,
102            findings: findings.iter().filter(|f| f.rule == rule).count(),
103        })
104        .collect()
105}
106
107/// Symbols with no incoming `calls`/`type_ref`/`imports` edge.
108///
109/// Each finding carries a confidence signal (encoded via severity + an
110/// evidence note) so callers can tell a genuinely-dead private symbol from one
111/// that is merely *available* across files. The latter — exported symbols whose
112/// module is imported elsewhere — are reported at `Info` ("verify") rather than
113/// `Warning`, because a missing reference there can be an unresolved
114/// dynamic/re-export usage rather than true death (see GH #365).
115fn detect_dead_code(conn: &Connection) -> Vec<SmellFinding> {
116    let sql = "
117        SELECT n.name, n.file_path, n.line_start,
118               EXISTS(
119                   SELECT 1 FROM edges e2
120                   WHERE e2.source_id = n.id AND e2.kind = 'exports'
121               ) AS exported,
122               EXISTS(
123                   SELECT 1 FROM edges e3
124                   JOIN nodes f ON f.id = e3.target_id
125                   WHERE e3.kind = 'imports'
126                     AND f.kind = 'file'
127                     AND f.file_path = n.file_path
128               ) AS file_imported
129        FROM nodes n
130        WHERE n.kind = 'symbol'
131          AND n.file_path NOT LIKE '%test%'
132          AND n.file_path NOT LIKE '%spec%'
133          AND n.name NOT IN ('main', 'new', 'default', 'fmt', 'drop', '<module>')
134          AND n.id NOT IN (
135              SELECT DISTINCT e.target_id FROM edges e
136              WHERE e.kind IN ('calls', 'type_ref', 'imports')
137          )
138        ORDER BY n.file_path, n.line_start
139        LIMIT 200
140    ";
141    let mut findings = Vec::new();
142    let Ok(mut stmt) = conn.prepare(sql) else {
143        return findings;
144    };
145    let Ok(rows) = stmt.query_map([], |row| {
146        Ok((
147            row.get::<_, String>(0)?,
148            row.get::<_, String>(1)?,
149            row.get::<_, Option<i64>>(2)?,
150            row.get::<_, bool>(3)?,
151            row.get::<_, bool>(4)?,
152        ))
153    }) else {
154        return findings;
155    };
156    for (name, path, line, exported, file_imported) in rows.flatten() {
157        let (severity, confidence) = if exported && file_imported {
158            (
159                Severity::Info,
160                "low confidence: exported and its module is imported elsewhere — \
161                 may be referenced via an unresolved import or dynamic access",
162            )
163        } else if exported {
164            (
165                Severity::Warning,
166                "high confidence: exported but its module is never imported",
167            )
168        } else {
169            (
170                Severity::Warning,
171                "high confidence: private symbol with no references",
172            )
173        };
174        findings.push(SmellFinding {
175            rule: "dead_code",
176            severity,
177            file_path: path.clone(),
178            symbol: Some(name.clone()),
179            line: line.map(|l| l as usize),
180            message: format!("'{name}' defined in {path} but never referenced ({confidence})"),
181            metric: None,
182        });
183    }
184    findings
185}
186
187fn detect_long_functions(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
188    let sql = format!(
189        "SELECT n.name, n.file_path, n.line_start,
190                (n.line_end - n.line_start) AS span
191         FROM nodes n
192         WHERE n.kind = 'symbol'
193           AND n.line_start IS NOT NULL
194           AND n.line_end IS NOT NULL
195           AND (n.line_end - n.line_start) > {threshold}
196         ORDER BY span DESC
197         LIMIT 100"
198    );
199    query_findings_with_metric(
200        conn,
201        &sql,
202        "long_function",
203        Severity::Warning,
204        |name, _path, _line, metric| {
205            format!("'{name}' is {metric:.0} lines (threshold: {threshold})")
206        },
207    )
208}
209
210fn detect_long_files(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
211    let sql = format!(
212        "SELECT n.name, n.file_path, NULL,
213                CAST(n.metadata AS INTEGER) AS line_count
214         FROM nodes n
215         WHERE n.kind = 'file'
216           AND n.metadata IS NOT NULL
217           AND CAST(n.metadata AS INTEGER) > {threshold}
218         ORDER BY line_count DESC
219         LIMIT 100"
220    );
221    query_findings_with_metric(
222        conn,
223        &sql,
224        "long_file",
225        Severity::Info,
226        |_name, path, _line, metric| {
227            format!("{path} has {metric:.0} lines (threshold: {threshold})")
228        },
229    )
230}
231
232fn detect_god_files(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
233    let sql = format!(
234        "SELECT COUNT(*) AS sym_count, n.file_path
235         FROM nodes n
236         WHERE n.kind = 'symbol'
237         GROUP BY n.file_path
238         HAVING sym_count > {threshold}
239         ORDER BY sym_count DESC
240         LIMIT 50"
241    );
242    let mut findings = Vec::new();
243    let Ok(mut stmt) = conn.prepare(&sql) else {
244        return findings;
245    };
246    let Ok(rows) = stmt.query_map([], |row| {
247        Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
248    }) else {
249        return findings;
250    };
251    for row in rows.flatten() {
252        let (count, path) = row;
253        findings.push(SmellFinding {
254            rule: "god_file",
255            severity: Severity::Warning,
256            file_path: path.clone(),
257            symbol: None,
258            line: None,
259            message: format!("{path} has {count} symbols (threshold: {threshold})"),
260            metric: Some(count as f64),
261        });
262    }
263    findings
264}
265
266fn detect_fan_out(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
267    let sql = format!(
268        "SELECT n.name, n.file_path, n.line_start, COUNT(e.id) AS call_count
269         FROM nodes n
270         JOIN edges e ON e.source_id = n.id AND e.kind = 'calls'
271         WHERE n.kind = 'symbol'
272         GROUP BY n.id
273         HAVING call_count > {threshold}
274         ORDER BY call_count DESC
275         LIMIT 100"
276    );
277    query_findings_with_metric(
278        conn,
279        &sql,
280        "fan_out_skew",
281        Severity::Warning,
282        |name, _path, _line, metric| {
283            format!("'{name}' calls {metric:.0} symbols (threshold: {threshold})")
284        },
285    )
286}
287
288fn detect_duplicate_definitions(conn: &Connection) -> Vec<SmellFinding> {
289    let sql = "
290        SELECT n.name, GROUP_CONCAT(n.file_path, ', ') AS files, COUNT(*) AS cnt
291        FROM nodes n
292        WHERE n.kind = 'symbol'
293          AND n.name NOT IN ('new', 'default', 'fmt', 'from', 'into', 'drop', 'clone', 'eq')
294        GROUP BY n.name
295        HAVING cnt > 1
296        ORDER BY cnt DESC
297        LIMIT 50
298    ";
299    let mut findings = Vec::new();
300    let Ok(mut stmt) = conn.prepare(sql) else {
301        return findings;
302    };
303    let Ok(rows) = stmt.query_map([], |row| {
304        Ok((
305            row.get::<_, String>(0)?,
306            row.get::<_, String>(1)?,
307            row.get::<_, i64>(2)?,
308        ))
309    }) else {
310        return findings;
311    };
312    for row in rows.flatten() {
313        let (name, files, count) = row;
314        findings.push(SmellFinding {
315            rule: "duplicate_definitions",
316            severity: Severity::Info,
317            file_path: files.clone(),
318            symbol: Some(name.clone()),
319            line: None,
320            message: format!("'{name}' defined in {count} files: {files}"),
321            metric: Some(count as f64),
322        });
323    }
324    findings
325}
326
327fn detect_untested(conn: &Connection) -> Vec<SmellFinding> {
328    let sql = "
329        SELECT n.name, n.file_path, n.line_start
330        FROM nodes n
331        WHERE n.kind = 'symbol'
332          AND n.file_path NOT LIKE '%test%'
333          AND n.file_path NOT LIKE '%spec%'
334          AND n.metadata LIKE '%export%'
335          AND n.id NOT IN (
336              SELECT DISTINCT e.source_id FROM edges e WHERE e.kind = 'tested_by'
337          )
338          AND n.id NOT IN (
339              SELECT DISTINCT e.target_id FROM edges e WHERE e.kind = 'tested_by'
340          )
341        ORDER BY n.file_path, n.line_start
342        LIMIT 100
343    ";
344    query_findings(
345        conn,
346        sql,
347        "untested_function",
348        Severity::Info,
349        |name, path, _line| format!("'{name}' in {path} has no test coverage"),
350    )
351}
352
353fn detect_cyclomatic_complexity(conn: &Connection) -> Vec<SmellFinding> {
354    #[cfg(feature = "tree-sitter")]
355    {
356        detect_cyclomatic_tree_sitter(conn)
357    }
358    #[cfg(not(feature = "tree-sitter"))]
359    {
360        detect_cyclomatic_heuristic(conn)
361    }
362}
363
364/// Span × calls proxy when the `tree-sitter` feature is off (no AST available).
365#[cfg(not(feature = "tree-sitter"))]
366fn detect_cyclomatic_heuristic(conn: &Connection) -> Vec<SmellFinding> {
367    let sql = "
368        SELECT n.name, n.file_path, n.line_start,
369               (n.line_end - n.line_start) AS span,
370               (SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND e.kind = 'calls') AS calls
371        FROM nodes n
372        WHERE n.kind = 'symbol'
373          AND n.line_start IS NOT NULL
374          AND n.line_end IS NOT NULL
375          AND (n.line_end - n.line_start) > 20
376        ORDER BY (span * 0.3 + calls * 0.7) DESC
377        LIMIT 100
378    ";
379    let mut findings = Vec::new();
380    let Ok(mut stmt) = conn.prepare(sql) else {
381        return findings;
382    };
383    let Ok(rows) = stmt.query_map([], |row| {
384        Ok((
385            row.get::<_, String>(0)?,
386            row.get::<_, String>(1)?,
387            row.get::<_, Option<i64>>(2)?,
388            row.get::<_, i64>(3)?,
389            row.get::<_, i64>(4)?,
390        ))
391    }) else {
392        return findings;
393    };
394    for row in rows.flatten() {
395        let (name, path, line, span, calls) = row;
396        let complexity_proxy = (span as f64) * 0.3 + (calls as f64) * 0.7;
397        if complexity_proxy < 10.0 {
398            continue;
399        }
400        let severity = if complexity_proxy > 30.0 {
401            Severity::Error
402        } else if complexity_proxy > 20.0 {
403            Severity::Warning
404        } else {
405            Severity::Info
406        };
407        findings.push(SmellFinding {
408            rule: "cyclomatic_complexity",
409            severity,
410            file_path: path,
411            symbol: Some(name.clone()),
412            line: line.map(|l| l as usize),
413            message: format!(
414                "'{name}' complexity proxy {complexity_proxy:.1} (span={span}, calls={calls})"
415            ),
416            metric: Some(complexity_proxy),
417        });
418    }
419    findings
420}
421
422#[cfg(feature = "tree-sitter")]
423fn detect_cyclomatic_tree_sitter(conn: &Connection) -> Vec<SmellFinding> {
424    use std::collections::HashMap;
425    use std::path::Path;
426
427    const WARN_CC: u32 = 11;
428    const ERR_CC: u32 = 21;
429
430    let sql = "
431        SELECT DISTINCT n.file_path
432        FROM nodes n
433        WHERE n.kind = 'symbol'
434          AND n.file_path IS NOT NULL
435          AND length(trim(n.file_path)) > 0
436        LIMIT 400
437    ";
438    let mut paths = Vec::new();
439    let Ok(mut stmt) = conn.prepare(sql) else {
440        return Vec::new();
441    };
442    let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) else {
443        return Vec::new();
444    };
445    for row in rows.flatten() {
446        paths.push(row);
447    }
448
449    let mut per_file: HashMap<String, Vec<crate::core::cyclomatic::FunctionComplexity>> =
450        HashMap::new();
451
452    for path in paths {
453        if per_file.contains_key(&path) {
454            continue;
455        }
456        let Ok(content) = std::fs::read_to_string(&path) else {
457            continue;
458        };
459        let Some(ext) = Path::new(&path).extension().and_then(|e| e.to_str()) else {
460            continue;
461        };
462        let Some(metrics) = crate::core::cyclomatic::cyclomatic_per_function(&content, ext) else {
463            continue;
464        };
465        per_file.insert(path, metrics);
466    }
467
468    let mut findings = Vec::new();
469    for (path, metrics) in per_file {
470        for m in metrics {
471            if m.cyclomatic < WARN_CC {
472                continue;
473            }
474            let severity = if m.cyclomatic >= ERR_CC {
475                Severity::Error
476            } else {
477                Severity::Warning
478            };
479            findings.push(SmellFinding {
480                rule: "cyclomatic_complexity",
481                severity,
482                file_path: path.clone(),
483                symbol: Some(m.name.clone()),
484                line: Some(m.line),
485                message: format!(
486                    "'{}' cyclomatic complexity {} (thresholds: warning {WARN_CC}, error {ERR_CC})",
487                    m.name, m.cyclomatic
488                ),
489                metric: Some(f64::from(m.cyclomatic)),
490            });
491        }
492    }
493
494    findings.sort_by(|a, b| {
495        b.metric
496            .unwrap_or(0.0)
497            .partial_cmp(&a.metric.unwrap_or(0.0))
498            .unwrap_or(std::cmp::Ordering::Equal)
499    });
500    findings.truncate(100);
501    findings
502}
503
504fn query_findings(
505    conn: &Connection,
506    sql: &str,
507    rule: &'static str,
508    severity: Severity,
509    msg_fn: impl Fn(&str, &str, Option<usize>) -> String,
510) -> Vec<SmellFinding> {
511    let mut findings = Vec::new();
512    let Ok(mut stmt) = conn.prepare(sql) else {
513        return findings;
514    };
515    let Ok(rows) = stmt.query_map([], |row| {
516        Ok((
517            row.get::<_, String>(0)?,
518            row.get::<_, String>(1)?,
519            row.get::<_, Option<i64>>(2)?,
520        ))
521    }) else {
522        return findings;
523    };
524    for row in rows.flatten() {
525        let (name, path, line) = row;
526        let line_usize = line.map(|l| l as usize);
527        findings.push(SmellFinding {
528            rule,
529            severity,
530            file_path: path.clone(),
531            symbol: Some(name.clone()),
532            line: line_usize,
533            message: msg_fn(&name, &path, line_usize),
534            metric: None,
535        });
536    }
537    findings
538}
539
540fn query_findings_with_metric(
541    conn: &Connection,
542    sql: &str,
543    rule: &'static str,
544    severity: Severity,
545    msg_fn: impl Fn(&str, &str, Option<usize>, f64) -> String,
546) -> Vec<SmellFinding> {
547    let mut findings = Vec::new();
548    let Ok(mut stmt) = conn.prepare(sql) else {
549        return findings;
550    };
551    let Ok(rows) = stmt.query_map([], |row| {
552        Ok((
553            row.get::<_, String>(0)?,
554            row.get::<_, String>(1)?,
555            row.get::<_, Option<i64>>(2)?,
556            row.get::<_, f64>(3)?,
557        ))
558    }) else {
559        return findings;
560    };
561    for row in rows.flatten() {
562        let (name, path, line, metric) = row;
563        let line_usize = line.map(|l| l as usize);
564        findings.push(SmellFinding {
565            rule,
566            severity,
567            file_path: path.clone(),
568            symbol: Some(name.clone()),
569            line: line_usize,
570            message: msg_fn(&name, &path, line_usize, metric),
571            metric: Some(metric),
572        });
573    }
574    findings
575}
576
577#[cfg(test)]
578mod tests {
579    use super::*;
580    use crate::core::property_graph::{CodeGraph, Edge, EdgeKind, Node, NodeKind};
581
582    fn setup_graph() -> CodeGraph {
583        let g = CodeGraph::open_in_memory().unwrap();
584
585        let file_a = g.upsert_node(&Node::file("src/main.rs")).unwrap();
586        let file_b = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
587        let file_c = g
588            .upsert_node(&Node::file("src/utils.rs").with_metadata("600"))
589            .unwrap();
590
591        let sym_used = g
592            .upsert_node(
593                &Node::symbol("process", "src/lib.rs", NodeKind::Symbol).with_lines(10, 50),
594            )
595            .unwrap();
596        let sym_dead = g
597            .upsert_node(
598                &Node::symbol("unused_helper", "src/lib.rs", NodeKind::Symbol).with_lines(60, 80),
599            )
600            .unwrap();
601        let sym_long = g
602            .upsert_node(
603                &Node::symbol("mega_function", "src/utils.rs", NodeKind::Symbol).with_lines(1, 200),
604            )
605            .unwrap();
606
607        g.upsert_edge(&Edge::new(file_a, file_b, EdgeKind::Imports))
608            .unwrap();
609        g.upsert_edge(&Edge::new(file_a, sym_used, EdgeKind::Calls))
610            .unwrap();
611
612        // sym_dead has no incoming edges -> dead code
613        let _ = sym_dead;
614        let _ = sym_long;
615        let _ = file_c;
616
617        g
618    }
619
620    #[test]
621    fn dead_code_detection() {
622        let g = setup_graph();
623        let findings = detect_dead_code(g.connection());
624        let dead: Vec<_> = findings
625            .iter()
626            .filter(|f| f.symbol.as_deref() == Some("unused_helper"))
627            .collect();
628        assert!(!dead.is_empty(), "Should detect unused_helper as dead code");
629    }
630
631    #[test]
632    fn dead_code_class_with_incoming_call_is_not_flagged() {
633        // Regression for GH #365: a class that is imported and instantiated
634        // cross-file must NOT be reported as dead code, the synthetic <module>
635        // caller must never be flagged, and remaining findings must carry a
636        // confidence signal (Info for exported-and-imported, Warning for private).
637        //   models/engine.py: class Engine / Orphan / _private  (Defines)
638        //   app.py: Engine(...) (Calls -> Engine) + imports models/engine.py
639        let g = CodeGraph::open_in_memory().unwrap();
640        let engine_file = g.upsert_node(&Node::file("models/engine.py")).unwrap();
641        let app_file = g.upsert_node(&Node::file("app.py")).unwrap();
642
643        let mk = |name: &str, lo: usize, hi: usize| {
644            g.upsert_node(
645                &Node::symbol(name, "models/engine.py", NodeKind::Symbol).with_lines(lo, hi),
646            )
647            .unwrap()
648        };
649        let engine = mk("Engine", 1, 6);
650        let orphan = mk("Orphan", 8, 12);
651        let private_dead = mk("_private", 14, 18);
652        // Synthetic module-level caller node, as emitted for top-level calls.
653        let module_caller = g
654            .upsert_node(&Node::symbol("<module>", "app.py", NodeKind::Symbol))
655            .unwrap();
656
657        for sym in [engine, orphan, private_dead] {
658            g.upsert_edge(&Edge::new(engine_file, sym, EdgeKind::Defines))
659                .unwrap();
660        }
661        // Engine + Orphan are exported; the module is imported by app.py.
662        g.upsert_edge(&Edge::new(engine, engine_file, EdgeKind::Exports))
663            .unwrap();
664        g.upsert_edge(&Edge::new(orphan, engine_file, EdgeKind::Exports))
665            .unwrap();
666        g.upsert_edge(&Edge::new(app_file, engine_file, EdgeKind::Imports))
667            .unwrap();
668        // Engine is actually instantiated -> incoming Calls edge.
669        g.upsert_edge(&Edge::new(module_caller, engine, EdgeKind::Calls))
670            .unwrap();
671
672        let findings = detect_dead_code(g.connection());
673        let by_name = |n: &str| findings.iter().find(|f| f.symbol.as_deref() == Some(n));
674
675        assert!(
676            by_name("Engine").is_none(),
677            "instantiated class must not be dead"
678        );
679        assert!(
680            by_name("<module>").is_none(),
681            "synthetic <module> must never be flagged"
682        );
683
684        let orphan_f = by_name("Orphan").expect("unused exported class should still be reported");
685        assert_eq!(
686            orphan_f.severity,
687            Severity::Info,
688            "exported + imported module = low confidence (Info)"
689        );
690        assert!(orphan_f.message.contains("low confidence"));
691
692        let priv_f = by_name("_private").expect("unused private symbol should be reported");
693        assert_eq!(
694            priv_f.severity,
695            Severity::Warning,
696            "private symbol = high confidence (Warning)"
697        );
698        assert!(priv_f.message.contains("high confidence"));
699    }
700
701    #[test]
702    fn long_function_detection() {
703        let g = setup_graph();
704        let findings = detect_long_functions(g.connection(), 100);
705        let long: Vec<_> = findings
706            .iter()
707            .filter(|f| f.symbol.as_deref() == Some("mega_function"))
708            .collect();
709        assert!(!long.is_empty(), "Should detect mega_function as too long");
710    }
711
712    #[test]
713    fn long_file_detection() {
714        let g = setup_graph();
715        let findings = detect_long_files(g.connection(), 500);
716        let long: Vec<_> = findings
717            .iter()
718            .filter(|f| f.file_path == "src/utils.rs")
719            .collect();
720        assert!(
721            !long.is_empty(),
722            "Should detect src/utils.rs as long file (600 lines)"
723        );
724    }
725
726    #[test]
727    fn scan_all_returns_findings() {
728        let g = setup_graph();
729        let cfg = SmellConfig::default();
730        let all = scan_all(g.connection(), &cfg);
731        assert!(!all.is_empty(), "Should find at least one smell");
732    }
733
734    #[test]
735    fn summarize_groups_by_rule() {
736        let g = setup_graph();
737        let cfg = SmellConfig::default();
738        let all = scan_all(g.connection(), &cfg);
739        let summary = summarize(&all);
740        assert_eq!(summary.len(), RULES.len());
741        for s in &summary {
742            assert!(!s.description.is_empty());
743        }
744    }
745}