forgeoptimizer 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. forgecli/__init__.py +4 -0
  2. forgecli/build/__init__.py +135 -0
  3. forgecli/build/apply.py +218 -0
  4. forgecli/build/caveman_optimize.py +37 -0
  5. forgecli/build/diff_extract.py +218 -0
  6. forgecli/build/llm.py +167 -0
  7. forgecli/build/optimize.py +37 -0
  8. forgecli/build/pipeline.py +76 -0
  9. forgecli/build/retrieval.py +157 -0
  10. forgecli/build/summarize.py +76 -0
  11. forgecli/build/test_run.py +75 -0
  12. forgecli/builder/__init__.py +11 -0
  13. forgecli/builder/builder.py +53 -0
  14. forgecli/builder/editor.py +36 -0
  15. forgecli/builder/formatter.py +31 -0
  16. forgecli/cli/__init__.py +5 -0
  17. forgecli/cli/bootstrap.py +281 -0
  18. forgecli/cli/commands_graph.py +149 -0
  19. forgecli/cli/commands_wrappers.py +80 -0
  20. forgecli/cli/daemon.py +744 -0
  21. forgecli/cli/main.py +234 -0
  22. forgecli/cli/ui.py +56 -0
  23. forgecli/config/__init__.py +28 -0
  24. forgecli/config/loader.py +85 -0
  25. forgecli/config/settings.py +206 -0
  26. forgecli/config/writer.py +90 -0
  27. forgecli/core/__init__.py +35 -0
  28. forgecli/core/container.py +68 -0
  29. forgecli/core/context.py +54 -0
  30. forgecli/core/credentials.py +114 -0
  31. forgecli/core/errors.py +27 -0
  32. forgecli/core/events.py +67 -0
  33. forgecli/core/logging.py +47 -0
  34. forgecli/core/models.py +214 -0
  35. forgecli/core/plugins.py +54 -0
  36. forgecli/core/service.py +22 -0
  37. forgecli/docs/__init__.py +5 -0
  38. forgecli/docs/generator.py +115 -0
  39. forgecli/engine/__init__.py +105 -0
  40. forgecli/engine/context.py +163 -0
  41. forgecli/engine/defaults.py +89 -0
  42. forgecli/engine/events.py +185 -0
  43. forgecli/engine/execution.py +519 -0
  44. forgecli/engine/plugins.py +145 -0
  45. forgecli/engine/runner.py +158 -0
  46. forgecli/engine/stages/__init__.py +33 -0
  47. forgecli/engine/stages/caveman_optimizer.py +47 -0
  48. forgecli/engine/stages/context_optimizer.py +44 -0
  49. forgecli/engine/stages/execution_engine_stage.py +102 -0
  50. forgecli/engine/stages/git_engine.py +30 -0
  51. forgecli/engine/stages/intent_analyzer.py +38 -0
  52. forgecli/engine/stages/model_router.py +65 -0
  53. forgecli/engine/stages/planning_engine.py +45 -0
  54. forgecli/engine/stages/repository_analyzer.py +54 -0
  55. forgecli/engine/stages/validation_engine.py +87 -0
  56. forgecli/git/__init__.py +9 -0
  57. forgecli/git/repo.py +55 -0
  58. forgecli/git/service.py +45 -0
  59. forgecli/graph/__init__.py +56 -0
  60. forgecli/graph/backend_graphify.py +448 -0
  61. forgecli/graph/edge.py +19 -0
  62. forgecli/graph/graph.py +85 -0
  63. forgecli/graph/graphify.py +405 -0
  64. forgecli/graph/indexer.py +82 -0
  65. forgecli/graph/node.py +47 -0
  66. forgecli/graph/repository.py +164 -0
  67. forgecli/memory/__init__.py +12 -0
  68. forgecli/memory/cache.py +61 -0
  69. forgecli/memory/history.py +136 -0
  70. forgecli/memory/store.py +85 -0
  71. forgecli/optimizer/__init__.py +13 -0
  72. forgecli/optimizer/caveman/__init__.py +169 -0
  73. forgecli/optimizer/caveman/cli.py +62 -0
  74. forgecli/optimizer/caveman/decorator.py +67 -0
  75. forgecli/optimizer/caveman/factory.py +51 -0
  76. forgecli/optimizer/caveman/ruleset.py +156 -0
  77. forgecli/optimizer/caveman/state.py +50 -0
  78. forgecli/optimizer/chunker.py +85 -0
  79. forgecli/optimizer/optimizer.py +81 -0
  80. forgecli/optimizer/ponytail/__init__.py +181 -0
  81. forgecli/optimizer/ponytail/cli.py +159 -0
  82. forgecli/optimizer/ponytail/decorator.py +70 -0
  83. forgecli/optimizer/ponytail/factory.py +51 -0
  84. forgecli/optimizer/ponytail/ruleset.py +168 -0
  85. forgecli/optimizer/ponytail/state.py +51 -0
  86. forgecli/optimizer/ranker.py +37 -0
  87. forgecli/optimizer/summarizer.py +44 -0
  88. forgecli/orchestrator/__init__.py +706 -0
  89. forgecli/planner/__init__.py +56 -0
  90. forgecli/planner/agent.py +61 -0
  91. forgecli/planner/plan.py +65 -0
  92. forgecli/planner/planner.py +17 -0
  93. forgecli/planner/render.py +267 -0
  94. forgecli/planner/serialize.py +104 -0
  95. forgecli/planner/software.py +832 -0
  96. forgecli/platform/__init__.py +91 -0
  97. forgecli/platform/core.py +201 -0
  98. forgecli/platform/deps.py +361 -0
  99. forgecli/platform/paths.py +234 -0
  100. forgecli/platform/shell.py +176 -0
  101. forgecli/platform/update.py +253 -0
  102. forgecli/plugins/__init__.py +249 -0
  103. forgecli/prompts/__init__.py +11 -0
  104. forgecli/prompts/loader.py +28 -0
  105. forgecli/prompts/registry.py +32 -0
  106. forgecli/prompts/renderer.py +23 -0
  107. forgecli/providers/__init__.py +39 -0
  108. forgecli/providers/anthropic.py +207 -0
  109. forgecli/providers/base.py +204 -0
  110. forgecli/providers/builtin.py +26 -0
  111. forgecli/providers/conversation.py +74 -0
  112. forgecli/providers/google.py +290 -0
  113. forgecli/providers/http_base.py +207 -0
  114. forgecli/providers/mock.py +111 -0
  115. forgecli/providers/openai.py +206 -0
  116. forgecli/providers/openai_compatible.py +787 -0
  117. forgecli/providers/router.py +340 -0
  118. forgecli/providers/router_state.py +89 -0
  119. forgecli/review/__init__.py +45 -0
  120. forgecli/review/analyzer.py +124 -0
  121. forgecli/review/analyzers/__init__.py +1 -0
  122. forgecli/review/analyzers/architecture.py +255 -0
  123. forgecli/review/analyzers/complexity.py +162 -0
  124. forgecli/review/analyzers/dead_code.py +255 -0
  125. forgecli/review/analyzers/duplicates.py +161 -0
  126. forgecli/review/analyzers/performance.py +161 -0
  127. forgecli/review/analyzers/security.py +244 -0
  128. forgecli/review/finding.py +68 -0
  129. forgecli/review/report.py +321 -0
  130. forgecli/review/repository.py +130 -0
  131. forgecli/review/suggestions.py +98 -0
  132. forgecli/runtime/__init__.py +6 -0
  133. forgecli/runtime/cache_store.py +75 -0
  134. forgecli/runtime/mcp_config.py +118 -0
  135. forgecli/runtime/prepare.py +203 -0
  136. forgecli/runtime/wrappers.py +150 -0
  137. forgecli/sdk/__init__.py +132 -0
  138. forgecli/sdk/events.py +206 -0
  139. forgecli/sdk/interfaces.py +282 -0
  140. forgecli/sdk/loader.py +239 -0
  141. forgecli/sdk/manager.py +678 -0
  142. forgecli/sdk/manifest.py +395 -0
  143. forgecli/sdk/sandbox.py +247 -0
  144. forgecli/sdk/version.py +313 -0
  145. forgecli/templates/__init__.py +9 -0
  146. forgecli/templates/engine.py +37 -0
  147. forgecli/templates/registry.py +32 -0
  148. forgecli/utils/__init__.py +19 -0
  149. forgecli/utils/fs.py +121 -0
  150. forgecli/utils/ids.py +11 -0
  151. forgecli/utils/io.py +27 -0
  152. forgecli/utils/paths.py +78 -0
  153. forgecli/utils/stats.py +179 -0
  154. forgecli/utils/timing.py +25 -0
  155. forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
  156. forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
  157. forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
  158. forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
  159. forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,255 @@
1
+ """Architecture analyzer.
2
+
3
+ Two heuristics:
4
+
5
+ * **Layering**: enforce a "lower layers cannot import higher layers"
6
+ rule. By default we use the top-level ``forgecli`` package layout
7
+ (core -> utils -> providers -> graph -> optimizer -> builder ->
8
+ review) and forbid e.g. ``core`` from importing ``graph``. Users can
9
+ override the layer ordering via the ``layers`` attribute.
10
+ * **Forbidden imports**: flag any import from a configurable
11
+ blacklist (default empty). The analyzer is conservative: it never
12
+ blocks legitimate patterns.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import ast
18
+ from collections import defaultdict
19
+ from dataclasses import dataclass
20
+ from typing import ClassVar
21
+
22
+ from forgecli.review.analyzer import AnalysisContext, Analyzer
23
+ from forgecli.review.finding import Finding, Severity
24
+
25
+ _DEFAULT_LAYERS: tuple[str, ...] = (
26
+ "core",
27
+ "config",
28
+ "utils",
29
+ "providers",
30
+ "graph",
31
+ "optimizer",
32
+ "builder",
33
+ "review",
34
+ "planner",
35
+ "git",
36
+ "memory",
37
+ "prompts",
38
+ "templates",
39
+ "cli",
40
+ "commit",
41
+ "build",
42
+ )
43
+
44
+
45
+ @dataclass
46
+ class ArchitectureAnalyzer(Analyzer):
47
+ """Enforce a layer ordering and detect circular imports."""
48
+
49
+ name: ClassVar[str] = "architecture"
50
+ category: ClassVar[str] = "architecture"
51
+ layers: tuple[str, ...] = _DEFAULT_LAYERS
52
+ forbidden_imports: tuple[str, ...] = ()
53
+
54
+ def run(self, context: AnalysisContext) -> list[Finding]:
55
+ findings: list[Finding] = []
56
+ findings.extend(self._scan_layering(context))
57
+ findings.extend(self._scan_circular_imports(context))
58
+ findings.extend(self._scan_forbidden_imports(context))
59
+ return findings
60
+
61
+ # ------------------------------------------------------------------
62
+ # Layering
63
+ # ------------------------------------------------------------------
64
+
65
+ def _scan_layering(self, context: AnalysisContext) -> list[Finding]:
66
+ out: list[Finding] = []
67
+ layer_index = {layer: index for index, layer in enumerate(self.layers)}
68
+ for file in context.files:
69
+ try:
70
+ tree = ast.parse(file.text)
71
+ except SyntaxError:
72
+ continue
73
+ file_layer = _top_level_layer(file.path)
74
+ if file_layer is None or file_layer not in layer_index:
75
+ continue
76
+ for node in ast.walk(tree):
77
+ imported = _imported_module(node)
78
+ if imported is None:
79
+ continue
80
+ target_layer = _layer_of(imported, layer_index)
81
+ if target_layer is None:
82
+ continue
83
+ if layer_index[target_layer] <= layer_index[file_layer]:
84
+ continue
85
+ out.append(
86
+ Finding(
87
+ rule_id="ARCH001",
88
+ category="architecture",
89
+ severity=Severity.MEDIUM,
90
+ message=(
91
+ f"Layer '{file_layer}' imports from higher layer "
92
+ f"'{target_layer}' ({imported})."
93
+ ),
94
+ path=str(file.path),
95
+ line=getattr(node, "lineno", 1),
96
+ suggestion=(
97
+ "Move the dependency, or invert the call so the "
98
+ "lower layer exposes a callback."
99
+ ),
100
+ )
101
+ )
102
+ return out
103
+
104
+ def _scan_circular_imports(self, context: AnalysisContext) -> list[Finding]:
105
+ """Detect cycles in the package-level import graph.
106
+
107
+ For each ``forgecli/<layer>/...`` file we record the set of
108
+ other layers it imports. A cycle is reported as a single
109
+ finding describing the cycle.
110
+ """
111
+ graph: dict[str, set[str]] = defaultdict(set)
112
+ for file in context.files:
113
+ layer = _top_level_layer(file.path)
114
+ if layer is None or layer not in self.layers:
115
+ continue
116
+ try:
117
+ tree = ast.parse(file.text)
118
+ except SyntaxError:
119
+ continue
120
+ for node in ast.walk(tree):
121
+ imported = _imported_module(node)
122
+ if imported is None:
123
+ continue
124
+ target = _layer_of(imported, {layer: idx for idx, layer in enumerate(self.layers)})
125
+ if target is None or target == layer:
126
+ continue
127
+ graph[layer].add(target)
128
+ out: list[Finding] = []
129
+ for cycle in _find_cycles(graph):
130
+ message = " -> ".join([*cycle, cycle[0]])
131
+ out.append(
132
+ Finding(
133
+ rule_id="ARCH002",
134
+ category="architecture",
135
+ severity=Severity.MEDIUM,
136
+ message=f"Circular import between layers: {message}",
137
+ path=None,
138
+ line=None,
139
+ suggestion=(
140
+ "Break the cycle by extracting a shared interface "
141
+ "or by deferring the import inside the function."
142
+ ),
143
+ extra={"cycle": cycle},
144
+ )
145
+ )
146
+ return out
147
+
148
+ def _scan_forbidden_imports(self, context: AnalysisContext) -> list[Finding]:
149
+ if not self.forbidden_imports:
150
+ return []
151
+ out: list[Finding] = []
152
+ for file in context.files:
153
+ try:
154
+ tree = ast.parse(file.text)
155
+ except SyntaxError:
156
+ continue
157
+ for node in ast.walk(tree):
158
+ imported = _imported_module(node)
159
+ if imported is None:
160
+ continue
161
+ for forbidden in self.forbidden_imports:
162
+ if imported == forbidden or imported.startswith(forbidden + "."):
163
+ out.append(
164
+ Finding(
165
+ rule_id="ARCH003",
166
+ category="architecture",
167
+ severity=Severity.HIGH,
168
+ message=(
169
+ f"Forbidden import: '{imported}' is not "
170
+ f"allowed in this project."
171
+ ),
172
+ path=str(file.path),
173
+ line=getattr(node, "lineno", 1),
174
+ suggestion=("Use the public API exposed by the intended module."),
175
+ )
176
+ )
177
+ return out
178
+
179
+
180
+ # ---------------------------------------------------------------------------
181
+ # Helpers
182
+ # ---------------------------------------------------------------------------
183
+
184
+
185
+ def _top_level_layer(path) -> str | None:
186
+ """Return the layer name for a path under the project source root."""
187
+ parts = path.parts
188
+ # Look for "forgecli" in the path; the next part is the layer.
189
+ try:
190
+ idx = parts.index("forgecli")
191
+ except ValueError:
192
+ return None
193
+ if idx + 1 >= len(parts):
194
+ return None
195
+ return parts[idx + 1]
196
+
197
+
198
+ def _imported_module(node: ast.AST) -> str | None:
199
+ """Return the dotted module name for an import node, or None."""
200
+ if isinstance(node, ast.Import):
201
+ for alias in node.names:
202
+ return alias.name
203
+ if isinstance(node, ast.ImportFrom):
204
+ if node.module is None:
205
+ return None
206
+ return node.module
207
+ return None
208
+
209
+
210
+ def _layer_of(module: str, layer_index: dict[str, int]) -> str | None:
211
+ """Return the layer name for a dotted module, or None."""
212
+ if not module:
213
+ return None
214
+ parts = module.split(".")
215
+ if "forgecli" in parts:
216
+ idx = parts.index("forgecli")
217
+ if idx + 1 < len(parts):
218
+ candidate = parts[idx + 1]
219
+ if candidate in layer_index:
220
+ return candidate
221
+ # External modules: not a layer.
222
+ return None
223
+
224
+
225
+ def _find_cycles(graph: dict[str, set[str]]) -> list[list[str]]:
226
+ """Return a list of cycles in ``graph`` (each cycle as a list of nodes)."""
227
+ cycles: list[list[str]] = []
228
+ seen_cycles: set[tuple[str, ...]] = set()
229
+ for start in graph:
230
+ visited: set[str] = set()
231
+
232
+ def dfs(node: str, stack: list[str], visited: set[str]) -> None:
233
+ if node in visited:
234
+ if node in stack:
235
+ cycle_start = stack.index(node)
236
+ cycle = stack[cycle_start:]
237
+ key = tuple(sorted(cycle))
238
+ if key not in seen_cycles:
239
+ seen_cycles.add(key)
240
+ cycles.append(cycle)
241
+ return
242
+ visited.add(node)
243
+ stack.append(node)
244
+ for neighbor in graph.get(node, ()):
245
+ dfs(neighbor, stack, visited)
246
+ stack.pop()
247
+
248
+ dfs(start, [], visited)
249
+ return cycles
250
+
251
+
252
+ __all__ = ["ArchitectureAnalyzer"]
253
+
254
+
255
+ # Silence unused-import warnings.
@@ -0,0 +1,162 @@
1
+ """Complexity analyzer.
2
+
3
+ Reports per-function:
4
+
5
+ * **line count** — anything over a threshold (default 80) is flagged;
6
+ * **parameter count** — anything over 5 is flagged;
7
+ * **cyclomatic complexity** — branches + boolean operators + 1.
8
+
9
+ The numbers are intentionally conservative; this is a smoke-detector
10
+ for "this function has gotten too big" rather than a precise metric.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import ast
16
+ from dataclasses import dataclass
17
+ from typing import ClassVar
18
+
19
+ from forgecli.review.analyzer import AnalysisContext, Analyzer
20
+ from forgecli.review.finding import Finding, Severity
21
+
22
+ _BRANCH_NODES: tuple[type[ast.AST], ...] = (
23
+ ast.If,
24
+ ast.For,
25
+ ast.AsyncFor,
26
+ ast.While,
27
+ ast.ExceptHandler,
28
+ ast.With,
29
+ ast.AsyncWith,
30
+ ast.BoolOp,
31
+ ast.IfExp,
32
+ ast.Match,
33
+ )
34
+
35
+
36
+ @dataclass
37
+ class ComplexityAnalyzer(Analyzer):
38
+ """Cyclomatic-ish complexity and size metrics per function."""
39
+
40
+ name: ClassVar[str] = "complexity"
41
+ category: ClassVar[str] = "complexity"
42
+ max_function_lines: int = 80
43
+ max_parameters: int = 5
44
+ max_cyclomatic: int = 12
45
+
46
+ def run(self, context: AnalysisContext) -> list[Finding]:
47
+ findings: list[Finding] = []
48
+ parents: dict[ast.AST, ast.AST] = {}
49
+ for file in context.files:
50
+ try:
51
+ tree = ast.parse(file.text)
52
+ except SyntaxError:
53
+ continue
54
+ _link_parents(tree, parents)
55
+ for function in _walk_functions(tree):
56
+ findings.extend(self._check_function(file, function, parents))
57
+ return findings
58
+
59
+ def _check_function(self, file, function, parents) -> list[Finding]:
60
+ out: list[Finding] = []
61
+ line_count = _function_length(function)
62
+ params = len(function.args.args) + len(function.args.kwonlyargs)
63
+ cyclomatic = _cyclomatic_complexity(function)
64
+ name = _qualified_function_name(function, parents)
65
+
66
+ if line_count > self.max_function_lines:
67
+ out.append(
68
+ Finding(
69
+ rule_id="CPLX001",
70
+ category="complexity",
71
+ severity=Severity.LOW,
72
+ message=(
73
+ f"Function {name} is {line_count} lines long (>{self.max_function_lines})."
74
+ ),
75
+ path=str(file.path),
76
+ line=function.lineno,
77
+ suggestion="Split into smaller helpers; extract branches.",
78
+ extra={"lines": line_count},
79
+ )
80
+ )
81
+ if params > self.max_parameters:
82
+ out.append(
83
+ Finding(
84
+ rule_id="CPLX002",
85
+ category="complexity",
86
+ severity=Severity.LOW,
87
+ message=(
88
+ f"Function {name} takes {params} parameters "
89
+ f"(>{self.max_parameters}); consider a parameter object."
90
+ ),
91
+ path=str(file.path),
92
+ line=function.lineno,
93
+ suggestion="Bundle related parameters into a dataclass.",
94
+ extra={"params": params},
95
+ )
96
+ )
97
+ if cyclomatic > self.max_cyclomatic:
98
+ out.append(
99
+ Finding(
100
+ rule_id="CPLX003",
101
+ category="complexity",
102
+ severity=Severity.MEDIUM,
103
+ message=(
104
+ f"Function {name} has cyclomatic complexity "
105
+ f"{cyclomatic} (>{self.max_cyclomatic})."
106
+ ),
107
+ path=str(file.path),
108
+ line=function.lineno,
109
+ suggestion=("Decompose the function along its decision points."),
110
+ extra={"cyclomatic": cyclomatic},
111
+ )
112
+ )
113
+ return out
114
+
115
+
116
+ def _walk_functions(tree: ast.AST) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
117
+ return [
118
+ node for node in ast.walk(tree) if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
119
+ ]
120
+
121
+
122
+ def _link_parents(root: ast.AST, parents: dict[ast.AST, ast.AST]) -> None:
123
+ """Populate ``parents[child] = parent`` for every AST node in ``root``."""
124
+ for parent in ast.walk(root):
125
+ for child in ast.iter_child_nodes(parent):
126
+ parents[child] = parent
127
+
128
+
129
+ def _function_length(function) -> int:
130
+ if function.end_lineno is None or function.lineno is None:
131
+ return 0
132
+ return function.end_lineno - function.lineno + 1
133
+
134
+
135
+ def _cyclomatic_complexity(function) -> int:
136
+ """Approximate cyclomatic complexity: 1 + number of decision points."""
137
+ complexity = 1
138
+ for node in ast.walk(function):
139
+ if isinstance(node, _BRANCH_NODES):
140
+ complexity += 1
141
+ if isinstance(node, ast.BoolOp):
142
+ # Each `and`/`or` operator adds an extra edge.
143
+ complexity += len(node.values) - 1
144
+ if isinstance(node, ast.comprehension):
145
+ # Comprehensions have implicit branches.
146
+ complexity += sum(1 for _ in node.ifs)
147
+ if isinstance(node, ast.ExceptHandler):
148
+ complexity += 1
149
+ return complexity
150
+
151
+
152
+ def _qualified_function_name(function, parents) -> str:
153
+ """Best-effort qualified name for a function definition."""
154
+ parts: list[str] = [function.name]
155
+ parent = parents.get(function)
156
+ while isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
157
+ parts.append(parent.name)
158
+ parent = parents.get(parent)
159
+ return ".".join(reversed(parts))
160
+
161
+
162
+ __all__ = ["ComplexityAnalyzer"]
@@ -0,0 +1,255 @@
1
+ """Dead-code analyzer.
2
+
3
+ Detects:
4
+
5
+ * private (``_foo``) functions/classes defined at module level but
6
+ never referenced anywhere in the project (other than their
7
+ definition);
8
+ * ``__all__``-listed symbols that are not actually defined in the
9
+ module.
10
+
11
+ The analyzer is conservative: ``__init__``, dunder methods,
12
+ ``__all__`` re-exports, and pytest-style ``test_*`` functions are
13
+ ignored. The analyzer also ignores private symbols whose name is
14
+ listed in a module-level ``__all__`` tuple (re-exported).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import ast
20
+ from collections import defaultdict
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import ClassVar
24
+
25
+ from forgecli.review.analyzer import AnalysisContext, Analyzer
26
+ from forgecli.review.finding import Finding, Severity
27
+
28
+ _DUNDER_NAMES: frozenset[str] = frozenset(
29
+ {
30
+ "__init__",
31
+ "__del__",
32
+ "__repr__",
33
+ "__str__",
34
+ "__bytes__",
35
+ "__format__",
36
+ "__lt__",
37
+ "__le__",
38
+ "__eq__",
39
+ "__ne__",
40
+ "__gt__",
41
+ "__ge__",
42
+ "__hash__",
43
+ "__bool__",
44
+ "__call__",
45
+ "__len__",
46
+ "__length_hint__",
47
+ "__getitem__",
48
+ "__setitem__",
49
+ "__delitem__",
50
+ "__iter__",
51
+ "__next__",
52
+ "__reversed__",
53
+ "__contains__",
54
+ "__add__",
55
+ "__sub__",
56
+ "__mul__",
57
+ "__matmul__",
58
+ "__truediv__",
59
+ "__floordiv__",
60
+ "__mod__",
61
+ "__divmod__",
62
+ "__pow__",
63
+ "__lshift__",
64
+ "__rshift__",
65
+ "__and__",
66
+ "__or__",
67
+ "__xor__",
68
+ "__radd__",
69
+ "__rsub__",
70
+ "__rmul__",
71
+ "__rmatmul__",
72
+ "__rtruediv__",
73
+ "__rfloordiv__",
74
+ "__rmod__",
75
+ "__rdivmod__",
76
+ "__rpow__",
77
+ "__rlshift__",
78
+ "__rrshift__",
79
+ "__rand__",
80
+ "__ror__",
81
+ "__rxor__",
82
+ "__iadd__",
83
+ "__isub__",
84
+ "__imul__",
85
+ "__imatmul__",
86
+ "__itruediv__",
87
+ "__ifloordiv__",
88
+ "__imod__",
89
+ "__ipow__",
90
+ "__ilshift__",
91
+ "__irshift__",
92
+ "__iand__",
93
+ "__ior__",
94
+ "__ixor__",
95
+ "__neg__",
96
+ "__pos__",
97
+ "__abs__",
98
+ "__invert__",
99
+ "__complex__",
100
+ "__int__",
101
+ "__float__",
102
+ "__index__",
103
+ "__round__",
104
+ "__trunc__",
105
+ "__floor__",
106
+ "__ceil__",
107
+ "__enter__",
108
+ "__exit__",
109
+ "__await__",
110
+ "__aiter__",
111
+ "__anext__",
112
+ "__aenter__",
113
+ "__aexit__",
114
+ "__set_name__",
115
+ "__class_getitem__",
116
+ "__init_subclass__",
117
+ "__class__",
118
+ "__dict__",
119
+ "__doc__",
120
+ "__module__",
121
+ "__qualname__",
122
+ "__annotations__",
123
+ "__slots__",
124
+ "__weakref__",
125
+ }
126
+ )
127
+
128
+
129
+ @dataclass
130
+ class DeadCodeAnalyzer(Analyzer):
131
+ """Detect private symbols that are never referenced."""
132
+
133
+ name: ClassVar[str] = "dead-code"
134
+ category: ClassVar[str] = "dead-code"
135
+
136
+ def run(self, context: AnalysisContext) -> list[Finding]:
137
+ # Collect definitions and references.
138
+ definitions: dict[tuple[str, str], tuple[Path, int, str]] = {}
139
+ references: dict[tuple[str, str], int] = defaultdict(int)
140
+ # Maps module name -> set of private names in __all__
141
+ all_overrides: dict[str, set[str]] = {}
142
+
143
+ for file in context.files:
144
+ module = _module_of(file.path)
145
+ if module is None:
146
+ continue
147
+ try:
148
+ tree = ast.parse(file.text)
149
+ except SyntaxError:
150
+ continue
151
+ self._collect_definitions(tree, module, file, definitions, all_overrides)
152
+ self._collect_references(tree, module, references)
153
+
154
+ findings: list[Finding] = []
155
+ for key, (path, line, kind) in definitions.items():
156
+ module, name = key
157
+ if not name.startswith("_"):
158
+ continue
159
+ if name.startswith("__") and name.endswith("__"):
160
+ continue
161
+ if name in _DUNDER_NAMES:
162
+ continue
163
+ if name in all_overrides.get(module, set()):
164
+ continue
165
+ if references.get(key, 0) > 0:
166
+ continue
167
+ findings.append(
168
+ Finding(
169
+ rule_id="DEAD001",
170
+ category="dead-code",
171
+ severity=Severity.LOW,
172
+ message=(f"{kind} {name!r} is defined in {module} but never referenced."),
173
+ path=str(path),
174
+ line=line,
175
+ suggestion=(
176
+ "Delete the symbol, or expose it in __all__ if it "
177
+ "is part of the public API."
178
+ ),
179
+ )
180
+ )
181
+ return findings
182
+
183
+ def _collect_definitions(
184
+ self,
185
+ tree: ast.AST,
186
+ module: str,
187
+ file,
188
+ definitions: dict,
189
+ all_overrides: dict,
190
+ ) -> None:
191
+ for node in ast.iter_child_nodes(tree):
192
+ if isinstance(node, ast.Assign):
193
+ # Capture module-level __all__ assignments.
194
+ for target in node.targets:
195
+ if (
196
+ isinstance(target, ast.Name)
197
+ and target.id == "__all__"
198
+ and isinstance(node.value, (ast.List, ast.Tuple))
199
+ ):
200
+ names: set[str] = set()
201
+ for element in node.value.elts:
202
+ if isinstance(element, ast.Constant) and isinstance(element.value, str):
203
+ names.add(element.value)
204
+ all_overrides[module] = names
205
+ continue
206
+ if isinstance(node, ast.FunctionDef):
207
+ definitions[(module, node.name)] = (file.path, node.lineno, "Function")
208
+ elif isinstance(node, ast.AsyncFunctionDef):
209
+ definitions[(module, node.name)] = (
210
+ file.path,
211
+ node.lineno,
212
+ "Async function",
213
+ )
214
+ elif isinstance(node, ast.ClassDef):
215
+ definitions[(module, node.name)] = (file.path, node.lineno, "Class")
216
+ elif (
217
+ isinstance(node, ast.AnnAssign)
218
+ and isinstance(node.target, ast.Name)
219
+ and isinstance(node.value, ast.AST)
220
+ ):
221
+ definitions[(module, node.target.id)] = (
222
+ file.path,
223
+ node.lineno,
224
+ "Variable",
225
+ )
226
+
227
+ def _collect_references(self, tree: ast.AST, module: str, references: dict) -> None:
228
+ for node in ast.walk(tree):
229
+ if isinstance(node, ast.Name):
230
+ references[(module, node.id)] += 1
231
+ elif isinstance(node, ast.Attribute):
232
+ base: ast.expr = node
233
+ while isinstance(base, ast.Attribute):
234
+ base = base.value
235
+ if isinstance(base, ast.Name):
236
+ # `self.foo` and `module.foo` both reference `foo` on
237
+ # the same module; we count it once.
238
+ references[(module, node.attr)] += 1
239
+
240
+
241
+ def _module_of(path) -> str | None:
242
+ parts = path.parts
243
+ try:
244
+ idx = parts.index("forgecli")
245
+ except ValueError:
246
+ return None
247
+ relevant = parts[idx:]
248
+ if relevant[-1] == "__init__.py":
249
+ relevant = relevant[:-1]
250
+ elif relevant[-1].endswith(".py"):
251
+ relevant = (*relevant[:-1], relevant[-1][:-3])
252
+ return ".".join(relevant)
253
+
254
+
255
+ __all__ = ["DeadCodeAnalyzer"]