1use 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
107fn detect_dead_code(conn: &Connection) -> Vec<SmellFinding> {
116 let sql = "
117 SELECT n.name, p.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_id = n.file_id
128 ) AS file_imported
129 FROM nodes n
130 JOIN paths p ON p.id = n.file_id
131 WHERE n.kind = 'symbol'
132 AND p.path NOT LIKE '%test%'
133 AND p.path NOT LIKE '%spec%'
134 AND n.name NOT IN ('main', 'new', 'default', 'fmt', 'drop', '<module>')
135 AND n.id NOT IN (
136 SELECT DISTINCT e.target_id FROM edges e
137 WHERE e.kind IN ('calls', 'type_ref', 'imports')
138 )
139 ORDER BY p.path, n.line_start
140 LIMIT 200
141 ";
142 let mut findings = Vec::new();
143 let Ok(mut stmt) = conn.prepare(sql) else {
144 return findings;
145 };
146 let Ok(rows) = stmt.query_map([], |row| {
147 Ok((
148 row.get::<_, String>(0)?,
149 row.get::<_, String>(1)?,
150 row.get::<_, Option<i64>>(2)?,
151 row.get::<_, bool>(3)?,
152 row.get::<_, bool>(4)?,
153 ))
154 }) else {
155 return findings;
156 };
157 for (name, path, line, exported, file_imported) in rows.flatten() {
158 let (severity, confidence) = if exported && file_imported {
159 (
160 Severity::Info,
161 "low confidence: exported and its module is imported elsewhere — \
162 may be referenced via an unresolved import or dynamic access",
163 )
164 } else if exported {
165 (
166 Severity::Warning,
167 "high confidence: exported but its module is never imported",
168 )
169 } else {
170 (
171 Severity::Warning,
172 "high confidence: private symbol with no references",
173 )
174 };
175 findings.push(SmellFinding {
176 rule: "dead_code",
177 severity,
178 file_path: path.clone(),
179 symbol: Some(name.clone()),
180 line: line.map(|l| l as usize),
181 message: format!("'{name}' defined in {path} but never referenced ({confidence})"),
182 metric: None,
183 });
184 }
185 findings
186}
187
188fn detect_long_functions(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
189 let sql = format!(
190 "SELECT n.name, p.path, n.line_start,
191 (n.line_end - n.line_start) AS span
192 FROM nodes n
193 JOIN paths p ON p.id = n.file_id
194 WHERE n.kind = 'symbol'
195 AND n.line_start IS NOT NULL
196 AND n.line_end IS NOT NULL
197 AND (n.line_end - n.line_start) > {threshold}
198 ORDER BY span DESC
199 LIMIT 100"
200 );
201 query_findings_with_metric(
202 conn,
203 &sql,
204 "long_function",
205 Severity::Warning,
206 |name, _path, _line, metric| {
207 format!("'{name}' is {metric:.0} lines (threshold: {threshold})")
208 },
209 )
210}
211
212fn detect_long_files(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
213 let sql = format!(
214 "SELECT n.name, p.path, NULL,
215 CAST(n.metadata AS INTEGER) AS line_count
216 FROM nodes n
217 JOIN paths p ON p.id = n.file_id
218 WHERE n.kind = 'file'
219 AND n.metadata IS NOT NULL
220 AND CAST(n.metadata AS INTEGER) > {threshold}
221 ORDER BY line_count DESC
222 LIMIT 100"
223 );
224 query_findings_with_metric(
225 conn,
226 &sql,
227 "long_file",
228 Severity::Info,
229 |_name, path, _line, metric| {
230 format!("{path} has {metric:.0} lines (threshold: {threshold})")
231 },
232 )
233}
234
235fn detect_god_files(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
236 let sql = format!(
237 "SELECT COUNT(*) AS sym_count, p.path
238 FROM nodes n
239 JOIN paths p ON p.id = n.file_id
240 WHERE n.kind = 'symbol'
241 GROUP BY p.path
242 HAVING sym_count > {threshold}
243 ORDER BY sym_count DESC
244 LIMIT 50"
245 );
246 let mut findings = Vec::new();
247 let Ok(mut stmt) = conn.prepare(&sql) else {
248 return findings;
249 };
250 let Ok(rows) = stmt.query_map([], |row| {
251 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
252 }) else {
253 return findings;
254 };
255 for row in rows.flatten() {
256 let (count, path) = row;
257 findings.push(SmellFinding {
258 rule: "god_file",
259 severity: Severity::Warning,
260 file_path: path.clone(),
261 symbol: None,
262 line: None,
263 message: format!("{path} has {count} symbols (threshold: {threshold})"),
264 metric: Some(count as f64),
265 });
266 }
267 findings
268}
269
270fn detect_fan_out(conn: &Connection, threshold: usize) -> Vec<SmellFinding> {
271 let sql = format!(
272 "SELECT n.name, p.path, n.line_start, COUNT(e.id) AS call_count
273 FROM nodes n
274 JOIN paths p ON p.id = n.file_id
275 JOIN edges e ON e.source_id = n.id AND e.kind = 'calls'
276 WHERE n.kind = 'symbol'
277 GROUP BY n.id
278 HAVING call_count > {threshold}
279 ORDER BY call_count DESC
280 LIMIT 100"
281 );
282 query_findings_with_metric(
283 conn,
284 &sql,
285 "fan_out_skew",
286 Severity::Warning,
287 |name, _path, _line, metric| {
288 format!("'{name}' calls {metric:.0} symbols (threshold: {threshold})")
289 },
290 )
291}
292
293fn detect_duplicate_definitions(conn: &Connection) -> Vec<SmellFinding> {
294 let sql = "
295 SELECT n.name, GROUP_CONCAT(p.path, ', ') AS files, COUNT(*) AS cnt
296 FROM nodes n
297 JOIN paths p ON p.id = n.file_id
298 WHERE n.kind = 'symbol'
299 AND n.name NOT IN ('new', 'default', 'fmt', 'from', 'into', 'drop', 'clone', 'eq')
300 GROUP BY n.name
301 HAVING cnt > 1
302 ORDER BY cnt DESC
303 LIMIT 50
304 ";
305 let mut findings = Vec::new();
306 let Ok(mut stmt) = conn.prepare(sql) else {
307 return findings;
308 };
309 let Ok(rows) = stmt.query_map([], |row| {
310 Ok((
311 row.get::<_, String>(0)?,
312 row.get::<_, String>(1)?,
313 row.get::<_, i64>(2)?,
314 ))
315 }) else {
316 return findings;
317 };
318 for row in rows.flatten() {
319 let (name, files, count) = row;
320 findings.push(SmellFinding {
321 rule: "duplicate_definitions",
322 severity: Severity::Info,
323 file_path: files.clone(),
324 symbol: Some(name.clone()),
325 line: None,
326 message: format!("'{name}' defined in {count} files: {files}"),
327 metric: Some(count as f64),
328 });
329 }
330 findings
331}
332
333fn detect_untested(conn: &Connection) -> Vec<SmellFinding> {
334 let sql = "
335 SELECT n.name, p.path, n.line_start
336 FROM nodes n
337 JOIN paths p ON p.id = n.file_id
338 WHERE n.kind = 'symbol'
339 AND p.path NOT LIKE '%test%'
340 AND p.path NOT LIKE '%spec%'
341 AND n.metadata LIKE '%export%'
342 AND n.id NOT IN (
343 SELECT DISTINCT e.source_id FROM edges e WHERE e.kind = 'tested_by'
344 )
345 AND n.id NOT IN (
346 SELECT DISTINCT e.target_id FROM edges e WHERE e.kind = 'tested_by'
347 )
348 ORDER BY p.path, n.line_start
349 LIMIT 100
350 ";
351 query_findings(
352 conn,
353 sql,
354 "untested_function",
355 Severity::Info,
356 |name, path, _line| format!("'{name}' in {path} has no test coverage"),
357 )
358}
359
360fn detect_cyclomatic_complexity(conn: &Connection) -> Vec<SmellFinding> {
361 #[cfg(feature = "tree-sitter")]
362 {
363 detect_cyclomatic_tree_sitter(conn)
364 }
365 #[cfg(not(feature = "tree-sitter"))]
366 {
367 detect_cyclomatic_heuristic(conn)
368 }
369}
370
371#[cfg(not(feature = "tree-sitter"))]
373fn detect_cyclomatic_heuristic(conn: &Connection) -> Vec<SmellFinding> {
374 let sql = "
375 SELECT n.name, p.path, n.line_start,
376 (n.line_end - n.line_start) AS span,
377 (SELECT COUNT(*) FROM edges e WHERE e.source_id = n.id AND e.kind = 'calls') AS calls
378 FROM nodes n
379 JOIN paths p ON p.id = n.file_id
380 WHERE n.kind = 'symbol'
381 AND n.line_start IS NOT NULL
382 AND n.line_end IS NOT NULL
383 AND (n.line_end - n.line_start) > 20
384 ORDER BY (span * 0.3 + calls * 0.7) DESC
385 LIMIT 100
386 ";
387 let mut findings = Vec::new();
388 let Ok(mut stmt) = conn.prepare(sql) else {
389 return findings;
390 };
391 let Ok(rows) = stmt.query_map([], |row| {
392 Ok((
393 row.get::<_, String>(0)?,
394 row.get::<_, String>(1)?,
395 row.get::<_, Option<i64>>(2)?,
396 row.get::<_, i64>(3)?,
397 row.get::<_, i64>(4)?,
398 ))
399 }) else {
400 return findings;
401 };
402 for row in rows.flatten() {
403 let (name, path, line, span, calls) = row;
404 let complexity_proxy = (span as f64) * 0.3 + (calls as f64) * 0.7;
405 if complexity_proxy < 10.0 {
406 continue;
407 }
408 let severity = if complexity_proxy > 30.0 {
409 Severity::Error
410 } else if complexity_proxy > 20.0 {
411 Severity::Warning
412 } else {
413 Severity::Info
414 };
415 findings.push(SmellFinding {
416 rule: "cyclomatic_complexity",
417 severity,
418 file_path: path,
419 symbol: Some(name.clone()),
420 line: line.map(|l| l as usize),
421 message: format!(
422 "'{name}' complexity proxy {complexity_proxy:.1} (span={span}, calls={calls})"
423 ),
424 metric: Some(complexity_proxy),
425 });
426 }
427 findings
428}
429
430#[cfg(feature = "tree-sitter")]
431fn detect_cyclomatic_tree_sitter(conn: &Connection) -> Vec<SmellFinding> {
432 use std::collections::HashMap;
433 use std::path::Path;
434
435 const WARN_CC: u32 = 11;
436 const ERR_CC: u32 = 21;
437
438 let sql = "
439 SELECT DISTINCT p.path
440 FROM nodes n
441 JOIN paths p ON p.id = n.file_id
442 WHERE n.kind = 'symbol'
443 AND p.path IS NOT NULL
444 AND length(trim(p.path)) > 0
445 LIMIT 400
446 ";
447 let mut paths = Vec::new();
448 let Ok(mut stmt) = conn.prepare(sql) else {
449 return Vec::new();
450 };
451 let Ok(rows) = stmt.query_map([], |row| row.get::<_, String>(0)) else {
452 return Vec::new();
453 };
454 for row in rows.flatten() {
455 paths.push(row);
456 }
457
458 let mut per_file: HashMap<String, Vec<crate::core::cyclomatic::FunctionComplexity>> =
459 HashMap::new();
460
461 for path in paths {
462 if per_file.contains_key(&path) {
463 continue;
464 }
465 let Ok(content) = std::fs::read_to_string(&path) else {
466 continue;
467 };
468 let Some(ext) = Path::new(&path).extension().and_then(|e| e.to_str()) else {
469 continue;
470 };
471 let Some(metrics) = crate::core::cyclomatic::cyclomatic_per_function(&content, ext) else {
472 continue;
473 };
474 per_file.insert(path, metrics);
475 }
476
477 let mut findings = Vec::new();
478 for (path, metrics) in per_file {
479 for m in metrics {
480 if m.cyclomatic < WARN_CC {
481 continue;
482 }
483 let severity = if m.cyclomatic >= ERR_CC {
484 Severity::Error
485 } else {
486 Severity::Warning
487 };
488 findings.push(SmellFinding {
489 rule: "cyclomatic_complexity",
490 severity,
491 file_path: path.clone(),
492 symbol: Some(m.name.clone()),
493 line: Some(m.line),
494 message: format!(
495 "'{}' cyclomatic complexity {} (thresholds: warning {WARN_CC}, error {ERR_CC})",
496 m.name, m.cyclomatic
497 ),
498 metric: Some(f64::from(m.cyclomatic)),
499 });
500 }
501 }
502
503 findings.sort_by(|a, b| {
504 b.metric
505 .unwrap_or(0.0)
506 .partial_cmp(&a.metric.unwrap_or(0.0))
507 .unwrap_or(std::cmp::Ordering::Equal)
508 });
509 findings.truncate(100);
510 findings
511}
512
513fn query_findings(
514 conn: &Connection,
515 sql: &str,
516 rule: &'static str,
517 severity: Severity,
518 msg_fn: impl Fn(&str, &str, Option<usize>) -> String,
519) -> Vec<SmellFinding> {
520 let mut findings = Vec::new();
521 let Ok(mut stmt) = conn.prepare(sql) else {
522 return findings;
523 };
524 let Ok(rows) = stmt.query_map([], |row| {
525 Ok((
526 row.get::<_, String>(0)?,
527 row.get::<_, String>(1)?,
528 row.get::<_, Option<i64>>(2)?,
529 ))
530 }) else {
531 return findings;
532 };
533 for row in rows.flatten() {
534 let (name, path, line) = row;
535 let line_usize = line.map(|l| l as usize);
536 findings.push(SmellFinding {
537 rule,
538 severity,
539 file_path: path.clone(),
540 symbol: Some(name.clone()),
541 line: line_usize,
542 message: msg_fn(&name, &path, line_usize),
543 metric: None,
544 });
545 }
546 findings
547}
548
549fn query_findings_with_metric(
550 conn: &Connection,
551 sql: &str,
552 rule: &'static str,
553 severity: Severity,
554 msg_fn: impl Fn(&str, &str, Option<usize>, f64) -> String,
555) -> Vec<SmellFinding> {
556 let mut findings = Vec::new();
557 let Ok(mut stmt) = conn.prepare(sql) else {
558 return findings;
559 };
560 let Ok(rows) = stmt.query_map([], |row| {
561 Ok((
562 row.get::<_, String>(0)?,
563 row.get::<_, String>(1)?,
564 row.get::<_, Option<i64>>(2)?,
565 row.get::<_, f64>(3)?,
566 ))
567 }) else {
568 return findings;
569 };
570 for row in rows.flatten() {
571 let (name, path, line, metric) = row;
572 let line_usize = line.map(|l| l as usize);
573 findings.push(SmellFinding {
574 rule,
575 severity,
576 file_path: path.clone(),
577 symbol: Some(name.clone()),
578 line: line_usize,
579 message: msg_fn(&name, &path, line_usize, metric),
580 metric: Some(metric),
581 });
582 }
583 findings
584}
585
586#[cfg(test)]
587mod tests {
588 use super::*;
589 use crate::core::property_graph::{CodeGraph, Edge, EdgeKind, Node, NodeKind};
590
591 fn setup_graph() -> CodeGraph {
592 let g = CodeGraph::open_in_memory().unwrap();
593
594 let file_a = g.upsert_node(&Node::file("src/main.rs")).unwrap();
595 let file_b = g.upsert_node(&Node::file("src/lib.rs")).unwrap();
596 let file_c = g
597 .upsert_node(&Node::file("src/utils.rs").with_metadata("600"))
598 .unwrap();
599
600 let sym_used = g
601 .upsert_node(
602 &Node::symbol("process", "src/lib.rs", NodeKind::Symbol).with_lines(10, 50),
603 )
604 .unwrap();
605 let sym_dead = g
606 .upsert_node(
607 &Node::symbol("unused_helper", "src/lib.rs", NodeKind::Symbol).with_lines(60, 80),
608 )
609 .unwrap();
610 let sym_long = g
611 .upsert_node(
612 &Node::symbol("mega_function", "src/utils.rs", NodeKind::Symbol).with_lines(1, 200),
613 )
614 .unwrap();
615
616 g.upsert_edge(&Edge::new(file_a, file_b, EdgeKind::Imports))
617 .unwrap();
618 g.upsert_edge(&Edge::new(file_a, sym_used, EdgeKind::Calls))
619 .unwrap();
620
621 let _ = sym_dead;
623 let _ = sym_long;
624 let _ = file_c;
625
626 g
627 }
628
629 #[test]
630 fn dead_code_detection() {
631 let g = setup_graph();
632 let findings = detect_dead_code(g.connection());
633 let dead: Vec<_> = findings
634 .iter()
635 .filter(|f| f.symbol.as_deref() == Some("unused_helper"))
636 .collect();
637 assert!(!dead.is_empty(), "Should detect unused_helper as dead code");
638 }
639
640 #[test]
641 fn dead_code_class_with_incoming_call_is_not_flagged() {
642 let g = CodeGraph::open_in_memory().unwrap();
649 let engine_file = g.upsert_node(&Node::file("models/engine.py")).unwrap();
650 let app_file = g.upsert_node(&Node::file("app.py")).unwrap();
651
652 let mk = |name: &str, lo: usize, hi: usize| {
653 g.upsert_node(
654 &Node::symbol(name, "models/engine.py", NodeKind::Symbol).with_lines(lo, hi),
655 )
656 .unwrap()
657 };
658 let engine = mk("Engine", 1, 6);
659 let orphan = mk("Orphan", 8, 12);
660 let private_dead = mk("_private", 14, 18);
661 let module_caller = g
663 .upsert_node(&Node::symbol("<module>", "app.py", NodeKind::Symbol))
664 .unwrap();
665
666 for sym in [engine, orphan, private_dead] {
667 g.upsert_edge(&Edge::new(engine_file, sym, EdgeKind::Defines))
668 .unwrap();
669 }
670 g.upsert_edge(&Edge::new(engine, engine_file, EdgeKind::Exports))
672 .unwrap();
673 g.upsert_edge(&Edge::new(orphan, engine_file, EdgeKind::Exports))
674 .unwrap();
675 g.upsert_edge(&Edge::new(app_file, engine_file, EdgeKind::Imports))
676 .unwrap();
677 g.upsert_edge(&Edge::new(module_caller, engine, EdgeKind::Calls))
679 .unwrap();
680
681 let findings = detect_dead_code(g.connection());
682 let by_name = |n: &str| findings.iter().find(|f| f.symbol.as_deref() == Some(n));
683
684 assert!(
685 by_name("Engine").is_none(),
686 "instantiated class must not be dead"
687 );
688 assert!(
689 by_name("<module>").is_none(),
690 "synthetic <module> must never be flagged"
691 );
692
693 let orphan_f = by_name("Orphan").expect("unused exported class should still be reported");
694 assert_eq!(
695 orphan_f.severity,
696 Severity::Info,
697 "exported + imported module = low confidence (Info)"
698 );
699 assert!(orphan_f.message.contains("low confidence"));
700
701 let priv_f = by_name("_private").expect("unused private symbol should be reported");
702 assert_eq!(
703 priv_f.severity,
704 Severity::Warning,
705 "private symbol = high confidence (Warning)"
706 );
707 assert!(priv_f.message.contains("high confidence"));
708 }
709
710 #[test]
711 fn long_function_detection() {
712 let g = setup_graph();
713 let findings = detect_long_functions(g.connection(), 100);
714 let long: Vec<_> = findings
715 .iter()
716 .filter(|f| f.symbol.as_deref() == Some("mega_function"))
717 .collect();
718 assert!(!long.is_empty(), "Should detect mega_function as too long");
719 }
720
721 #[test]
722 fn long_file_detection() {
723 let g = setup_graph();
724 let findings = detect_long_files(g.connection(), 500);
725 let long: Vec<_> = findings
726 .iter()
727 .filter(|f| f.file_path == "src/utils.rs")
728 .collect();
729 assert!(
730 !long.is_empty(),
731 "Should detect src/utils.rs as long file (600 lines)"
732 );
733 }
734
735 #[test]
736 fn scan_all_returns_findings() {
737 let g = setup_graph();
738 let cfg = SmellConfig::default();
739 let all = scan_all(g.connection(), &cfg);
740 assert!(!all.is_empty(), "Should find at least one smell");
741 }
742
743 #[test]
744 fn summarize_groups_by_rule() {
745 let g = setup_graph();
746 let cfg = SmellConfig::default();
747 let all = scan_all(g.connection(), &cfg);
748 let summary = summarize(&all);
749 assert_eq!(summary.len(), RULES.len());
750 for s in &summary {
751 assert!(!s.description.is_empty());
752 }
753 }
754}