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, 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#[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 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 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 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 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 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}