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