Coverage for dead_code / core.py: 19%
125 statements
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 15:04 -0800
« prev ^ index » next coverage.py v7.13.3, created at 2026-02-08 15:04 -0800
1"""Core dead code detection logic."""
3from __future__ import annotations
5from pathlib import Path
6from collections import defaultdict
7from dataclasses import dataclass, field
8from typing import TYPE_CHECKING
10if TYPE_CHECKING:
11 from tree_sitter import Node
13from models import DeadCodeViolation
14from dead_code.parsers.python import PythonDeadCodeParser
15from dead_code.parsers.typescript import TypeScriptDeadCodeParser
16from dead_code.parsers.go import GoDeadCodeParser
19@dataclass
20class SymbolInfo:
21 name: str = ""
22 def_type: str = ""
23 definitions: dict[str, list[int]] = field(default_factory=dict) # filepath -> lines
24 references: list[tuple] = field(default_factory=list)
25 definitions_count: int = 0
26 exported: bool = False
29def build_cross_reference_graph(
30 files: list[Path], lang: str = "python"
31) -> tuple[dict, set, set]:
32 graph: dict[str, SymbolInfo] = {}
33 all_abstract_classes = set()
34 all_abstract_methods = set()
36 parser_map = {
37 "python": PythonDeadCodeParser(),
38 "typescript": TypeScriptDeadCodeParser(),
39 "go": GoDeadCodeParser(),
40 }
42 parser = parser_map.get(lang)
43 if not parser:
44 return graph, all_abstract_classes, all_abstract_methods
46 parse_func_map = {
47 "python": lambda src: _parse_python(src),
48 "typescript": lambda src: _parse_typescript(src),
49 "go": lambda src: _parse_go(src),
50 }
52 parse_func = parse_func_map.get(lang)
53 if not parse_func:
54 return graph, all_abstract_classes, all_abstract_methods
56 # First pass: collect all definitions and abstract info
57 for filepath in files:
58 source = filepath.read_text()
59 root = parse_func(source)
61 imports = parser.extract_imports(root, source)
62 definitions = parser.extract_definitions(root, source)
63 abstract_classes, abstract_methods = parser.extract_abstract_info(root, source)
65 all_abstract_classes.update(abstract_classes)
66 all_abstract_methods.update(abstract_methods)
68 for imp in imports:
69 symbol_key_name = imp.alias or imp.name
70 symbol_key = f"{lang}:import:{symbol_key_name}"
71 if symbol_key not in graph:
72 graph[symbol_key] = SymbolInfo(name=symbol_key_name, def_type="import")
73 if str(filepath) not in graph[symbol_key].definitions:
74 graph[symbol_key].definitions[str(filepath)] = []
75 graph[symbol_key].definitions[str(filepath)].append(imp.line)
76 graph[symbol_key].definitions_count += 1
78 for definition in definitions:
79 symbol_key = f"{lang}:{definition.definition_type}:{definition.name}"
80 if symbol_key not in graph:
81 graph[symbol_key] = SymbolInfo(
82 name=definition.name,
83 def_type=definition.definition_type,
84 exported=definition.exported,
85 )
86 else:
87 graph[symbol_key].exported = (
88 graph[symbol_key].exported or definition.exported
89 )
90 if str(filepath) not in graph[symbol_key].definitions:
91 graph[symbol_key].definitions[str(filepath)] = []
92 graph[symbol_key].definitions[str(filepath)].append(definition.line)
93 graph[symbol_key].definitions_count += 1
95 # Second pass: collect all references
96 for filepath in files:
97 source = filepath.read_text()
98 root = parse_func(source)
100 refs = parser.extract_references(root, source)
102 for ref in refs:
103 filepath_str = str(filepath)
104 for symbol_key, info in graph.items():
105 if ref.name == info.name:
106 def_lines = info.definitions.get(filepath_str, [])
107 is_self_reference = ref.line in def_lines
108 if not is_self_reference:
109 info.references.append((filepath_str, ref.line))
111 return graph, all_abstract_classes, all_abstract_methods
114def _parse_python(source: str):
115 from parsers.python import parse
117 return parse(source)
120def _parse_typescript(source: str):
121 from parsers.typescript import parse
123 return parse(source)
126def _parse_go(source: str):
127 from parsers.go import parse
129 return parse(source)
132def find_unused_imports(graph: dict) -> list[DeadCodeViolation]:
133 violations = []
135 for symbol_key, info in graph.items():
136 if info.def_type == "import":
137 refs_count = len(info.references)
138 if refs_count == 0:
139 for filepath, lines in info.definitions.items():
140 for line in lines:
141 confidence = "high"
142 violations.append(
143 DeadCodeViolation(
144 type="unused_import",
145 symbol=info.name,
146 location=filepath,
147 line=line,
148 confidence=confidence,
149 )
150 )
152 return violations
155def find_dead_symbols(
156 graph: dict, abstract_classes: set, abstract_methods: set, lang: str = "python"
157) -> list[DeadCodeViolation]:
158 violations = []
160 # Dunder methods are automatically called by Python
161 def is_dunder_method(name: str) -> bool:
162 return name.startswith("__") and name.endswith("__")
164 # Build a mapping from symbol name to all entries with the same name
165 name_to_entries = {}
166 for symbol_key, info in graph.items():
167 if info.name not in name_to_entries:
168 name_to_entries[info.name] = []
169 name_to_entries[info.name].append((symbol_key, info))
171 for symbol_key, info in graph.items():
172 if info.def_type in ("function", "class"):
173 # Skip abstract classes and abstract methods
174 if info.name in abstract_classes:
175 continue
176 if info.def_type == "function" and info.name in abstract_methods:
177 continue
178 # Skip dunder methods (special methods called by Python)
179 if info.def_type == "function" and is_dunder_method(info.name):
180 continue
182 # Skip exported symbols in TypeScript and Go
183 if info.exported:
184 continue
186 # Check if there's an import with the same name that has references
187 total_refs = len(info.references)
189 # Look for import entries with the same name
190 if info.name in name_to_entries:
191 for other_key, other_info in name_to_entries[info.name]:
192 if other_info.def_type == "import":
193 total_refs += len(other_info.references)
195 if total_refs == 0:
196 confidence = "high" if info.definitions_count == 1 else "medium"
197 for filepath, lines in info.definitions.items():
198 for line in lines:
199 violations.append(
200 DeadCodeViolation(
201 type=f"dead_{info.def_type}",
202 symbol=info.name,
203 location=filepath,
204 line=line,
205 confidence=confidence,
206 )
207 )
209 return violations
212def find_dead_code(
213 source_files: list[Path], lang: str = "python"
214) -> list[DeadCodeViolation]:
215 violations = []
217 graph, abstract_classes, abstract_methods = build_cross_reference_graph(
218 source_files, lang
219 )
220 violations.extend(find_unused_imports(graph))
221 violations.extend(
222 find_dead_symbols(graph, abstract_classes, abstract_methods, lang)
223 )
225 violations.sort(key=lambda v: (v.line, v.symbol or ""))
227 return violations