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

1"""Core dead code detection logic.""" 

2 

3from __future__ import annotations 

4 

5from pathlib import Path 

6from collections import defaultdict 

7from dataclasses import dataclass, field 

8from typing import TYPE_CHECKING 

9 

10if TYPE_CHECKING: 

11 from tree_sitter import Node 

12 

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 

17 

18 

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 

27 

28 

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() 

35 

36 parser_map = { 

37 "python": PythonDeadCodeParser(), 

38 "typescript": TypeScriptDeadCodeParser(), 

39 "go": GoDeadCodeParser(), 

40 } 

41 

42 parser = parser_map.get(lang) 

43 if not parser: 

44 return graph, all_abstract_classes, all_abstract_methods 

45 

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 } 

51 

52 parse_func = parse_func_map.get(lang) 

53 if not parse_func: 

54 return graph, all_abstract_classes, all_abstract_methods 

55 

56 # First pass: collect all definitions and abstract info 

57 for filepath in files: 

58 source = filepath.read_text() 

59 root = parse_func(source) 

60 

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) 

64 

65 all_abstract_classes.update(abstract_classes) 

66 all_abstract_methods.update(abstract_methods) 

67 

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 

77 

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 

94 

95 # Second pass: collect all references 

96 for filepath in files: 

97 source = filepath.read_text() 

98 root = parse_func(source) 

99 

100 refs = parser.extract_references(root, source) 

101 

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)) 

110 

111 return graph, all_abstract_classes, all_abstract_methods 

112 

113 

114def _parse_python(source: str): 

115 from parsers.python import parse 

116 

117 return parse(source) 

118 

119 

120def _parse_typescript(source: str): 

121 from parsers.typescript import parse 

122 

123 return parse(source) 

124 

125 

126def _parse_go(source: str): 

127 from parsers.go import parse 

128 

129 return parse(source) 

130 

131 

132def find_unused_imports(graph: dict) -> list[DeadCodeViolation]: 

133 violations = [] 

134 

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 ) 

151 

152 return violations 

153 

154 

155def find_dead_symbols( 

156 graph: dict, abstract_classes: set, abstract_methods: set, lang: str = "python" 

157) -> list[DeadCodeViolation]: 

158 violations = [] 

159 

160 # Dunder methods are automatically called by Python 

161 def is_dunder_method(name: str) -> bool: 

162 return name.startswith("__") and name.endswith("__") 

163 

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)) 

170 

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 

181 

182 # Skip exported symbols in TypeScript and Go 

183 if info.exported: 

184 continue 

185 

186 # Check if there's an import with the same name that has references 

187 total_refs = len(info.references) 

188 

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) 

194 

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 ) 

208 

209 return violations 

210 

211 

212def find_dead_code( 

213 source_files: list[Path], lang: str = "python" 

214) -> list[DeadCodeViolation]: 

215 violations = [] 

216 

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 ) 

224 

225 violations.sort(key=lambda v: (v.line, v.symbol or "")) 

226 

227 return violations