Coverage for dead_code/core.py: 19%

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

2

3 from __future__ import annotations

4

5 from pathlib import Path

6 from collections import defaultdict

7 from dataclasses import dataclass, field

8 from typing import TYPE_CHECKING

9

10 if TYPE_CHECKING:

11 from tree_sitter import Node

12

13 from models import DeadCodeViolation

14 from dead_code.parsers.python import PythonDeadCodeParser

15 from dead_code.parsers.typescript import TypeScriptDeadCodeParser

16 from dead_code.parsers.go import GoDeadCodeParser

17

18

19@dataclass

20 class 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

29 def 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

114 def _parse_python(source: str):

115 from parsers.python import parse

116

117 return parse(source)

118

119

120 def _parse_typescript(source: str):

121 from parsers.typescript import parse

122

123 return parse(source)

124

125

126 def _parse_go(source: str):

127 from parsers.go import parse

128

129 return parse(source)

130

131

132 def 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

155 def 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

212 def 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