forgeoptimizer 1.0.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.
- forgecli/__init__.py +4 -0
- forgecli/build/__init__.py +135 -0
- forgecli/build/apply.py +218 -0
- forgecli/build/caveman_optimize.py +37 -0
- forgecli/build/diff_extract.py +218 -0
- forgecli/build/llm.py +167 -0
- forgecli/build/optimize.py +37 -0
- forgecli/build/pipeline.py +76 -0
- forgecli/build/retrieval.py +157 -0
- forgecli/build/summarize.py +76 -0
- forgecli/build/test_run.py +75 -0
- forgecli/builder/__init__.py +11 -0
- forgecli/builder/builder.py +53 -0
- forgecli/builder/editor.py +36 -0
- forgecli/builder/formatter.py +31 -0
- forgecli/cli/__init__.py +5 -0
- forgecli/cli/bootstrap.py +281 -0
- forgecli/cli/commands_graph.py +137 -0
- forgecli/cli/commands_wrappers.py +63 -0
- forgecli/cli/main.py +122 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +204 -0
- forgecli/config/writer.py +90 -0
- forgecli/core/__init__.py +35 -0
- forgecli/core/container.py +68 -0
- forgecli/core/context.py +54 -0
- forgecli/core/credentials.py +114 -0
- forgecli/core/errors.py +27 -0
- forgecli/core/events.py +67 -0
- forgecli/core/logging.py +47 -0
- forgecli/core/models.py +159 -0
- forgecli/core/plugins.py +56 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +119 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +171 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +526 -0
- forgecli/engine/plugins.py +146 -0
- forgecli/engine/runner.py +155 -0
- forgecli/engine/stages/__init__.py +33 -0
- forgecli/engine/stages/caveman_optimizer.py +47 -0
- forgecli/engine/stages/context_optimizer.py +44 -0
- forgecli/engine/stages/execution_engine_stage.py +108 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +66 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +89 -0
- forgecli/git/__init__.py +9 -0
- forgecli/git/repo.py +55 -0
- forgecli/git/service.py +45 -0
- forgecli/graph/__init__.py +56 -0
- forgecli/graph/backend_graphify.py +453 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +412 -0
- forgecli/graph/indexer.py +82 -0
- forgecli/graph/node.py +47 -0
- forgecli/graph/repository.py +164 -0
- forgecli/memory/__init__.py +12 -0
- forgecli/memory/cache.py +61 -0
- forgecli/memory/history.py +138 -0
- forgecli/memory/store.py +87 -0
- forgecli/optimizer/__init__.py +14 -0
- forgecli/optimizer/caveman/__init__.py +173 -0
- forgecli/optimizer/caveman/cli.py +64 -0
- forgecli/optimizer/caveman/decorator.py +67 -0
- forgecli/optimizer/caveman/factory.py +51 -0
- forgecli/optimizer/caveman/ruleset.py +156 -0
- forgecli/optimizer/caveman/state.py +52 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +183 -0
- forgecli/optimizer/ponytail/cli.py +168 -0
- forgecli/optimizer/ponytail/decorator.py +70 -0
- forgecli/optimizer/ponytail/factory.py +51 -0
- forgecli/optimizer/ponytail/ruleset.py +168 -0
- forgecli/optimizer/ponytail/state.py +53 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +707 -0
- forgecli/planner/__init__.py +56 -0
- forgecli/planner/agent.py +61 -0
- forgecli/planner/plan.py +65 -0
- forgecli/planner/planner.py +17 -0
- forgecli/planner/render.py +271 -0
- forgecli/planner/serialize.py +106 -0
- forgecli/planner/software.py +834 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +354 -0
- forgecli/platform/paths.py +236 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +252 -0
- forgecli/plugins/__init__.py +251 -0
- forgecli/prompts/__init__.py +11 -0
- forgecli/prompts/loader.py +28 -0
- forgecli/prompts/registry.py +32 -0
- forgecli/prompts/renderer.py +23 -0
- forgecli/providers/__init__.py +39 -0
- forgecli/providers/anthropic.py +206 -0
- forgecli/providers/base.py +206 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +78 -0
- forgecli/providers/google.py +295 -0
- forgecli/providers/http_base.py +211 -0
- forgecli/providers/mock.py +113 -0
- forgecli/providers/openai.py +202 -0
- forgecli/providers/openai_compatible.py +728 -0
- forgecli/providers/router.py +345 -0
- forgecli/providers/router_state.py +89 -0
- forgecli/review/__init__.py +45 -0
- forgecli/review/analyzer.py +124 -0
- forgecli/review/analyzers/__init__.py +1 -0
- forgecli/review/analyzers/architecture.py +258 -0
- forgecli/review/analyzers/complexity.py +167 -0
- forgecli/review/analyzers/dead_code.py +262 -0
- forgecli/review/analyzers/duplicates.py +163 -0
- forgecli/review/analyzers/performance.py +165 -0
- forgecli/review/analyzers/security.py +251 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +311 -0
- forgecli/review/repository.py +131 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +77 -0
- forgecli/runtime/prepare.py +199 -0
- forgecli/runtime/wrappers.py +152 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +243 -0
- forgecli/sdk/manager.py +693 -0
- forgecli/sdk/manifest.py +397 -0
- forgecli/sdk/sandbox.py +197 -0
- forgecli/sdk/version.py +316 -0
- forgecli/templates/__init__.py +9 -0
- forgecli/templates/engine.py +37 -0
- forgecli/templates/registry.py +32 -0
- forgecli/utils/__init__.py +19 -0
- forgecli/utils/fs.py +91 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +70 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-1.0.0.dist-info/METADATA +169 -0
- forgeoptimizer-1.0.0.dist-info/RECORD +156 -0
- forgeoptimizer-1.0.0.dist-info/WHEEL +4 -0
- forgeoptimizer-1.0.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,258 @@
|
|
|
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=(
|
|
175
|
+
"Use the public API exposed by the "
|
|
176
|
+
"intended module."
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
)
|
|
180
|
+
return out
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
# ---------------------------------------------------------------------------
|
|
184
|
+
# Helpers
|
|
185
|
+
# ---------------------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _top_level_layer(path) -> str | None:
|
|
189
|
+
"""Return the layer name for a path under the project source root."""
|
|
190
|
+
parts = path.parts
|
|
191
|
+
# Look for "forgecli" in the path; the next part is the layer.
|
|
192
|
+
try:
|
|
193
|
+
idx = parts.index("forgecli")
|
|
194
|
+
except ValueError:
|
|
195
|
+
return None
|
|
196
|
+
if idx + 1 >= len(parts):
|
|
197
|
+
return None
|
|
198
|
+
return parts[idx + 1]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _imported_module(node: ast.AST) -> str | None:
|
|
202
|
+
"""Return the dotted module name for an import node, or None."""
|
|
203
|
+
if isinstance(node, ast.Import):
|
|
204
|
+
for alias in node.names:
|
|
205
|
+
return alias.name
|
|
206
|
+
if isinstance(node, ast.ImportFrom):
|
|
207
|
+
if node.module is None:
|
|
208
|
+
return None
|
|
209
|
+
return node.module
|
|
210
|
+
return None
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _layer_of(module: str, layer_index: dict[str, int]) -> str | None:
|
|
214
|
+
"""Return the layer name for a dotted module, or None."""
|
|
215
|
+
if not module:
|
|
216
|
+
return None
|
|
217
|
+
parts = module.split(".")
|
|
218
|
+
if "forgecli" in parts:
|
|
219
|
+
idx = parts.index("forgecli")
|
|
220
|
+
if idx + 1 < len(parts):
|
|
221
|
+
candidate = parts[idx + 1]
|
|
222
|
+
if candidate in layer_index:
|
|
223
|
+
return candidate
|
|
224
|
+
# External modules: not a layer.
|
|
225
|
+
return None
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _find_cycles(graph: dict[str, set[str]]) -> list[list[str]]:
|
|
229
|
+
"""Return a list of cycles in ``graph`` (each cycle as a list of nodes)."""
|
|
230
|
+
cycles: list[list[str]] = []
|
|
231
|
+
seen_cycles: set[tuple[str, ...]] = set()
|
|
232
|
+
for start in graph:
|
|
233
|
+
visited: set[str] = set()
|
|
234
|
+
|
|
235
|
+
def dfs(node: str, stack: list[str], visited: set[str]) -> None:
|
|
236
|
+
if node in visited:
|
|
237
|
+
if node in stack:
|
|
238
|
+
cycle_start = stack.index(node)
|
|
239
|
+
cycle = stack[cycle_start:]
|
|
240
|
+
key = tuple(sorted(cycle))
|
|
241
|
+
if key not in seen_cycles:
|
|
242
|
+
seen_cycles.add(key)
|
|
243
|
+
cycles.append(cycle)
|
|
244
|
+
return
|
|
245
|
+
visited.add(node)
|
|
246
|
+
stack.append(node)
|
|
247
|
+
for neighbor in graph.get(node, ()):
|
|
248
|
+
dfs(neighbor, stack, visited)
|
|
249
|
+
stack.pop()
|
|
250
|
+
|
|
251
|
+
dfs(start, [], visited)
|
|
252
|
+
return cycles
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
__all__ = ["ArchitectureAnalyzer"]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
# Silence unused-import warnings.
|
|
@@ -0,0 +1,167 @@
|
|
|
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 "
|
|
74
|
+
f"(>{self.max_function_lines})."
|
|
75
|
+
),
|
|
76
|
+
path=str(file.path),
|
|
77
|
+
line=function.lineno,
|
|
78
|
+
suggestion="Split into smaller helpers; extract branches.",
|
|
79
|
+
extra={"lines": line_count},
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
if params > self.max_parameters:
|
|
83
|
+
out.append(
|
|
84
|
+
Finding(
|
|
85
|
+
rule_id="CPLX002",
|
|
86
|
+
category="complexity",
|
|
87
|
+
severity=Severity.LOW,
|
|
88
|
+
message=(
|
|
89
|
+
f"Function {name} takes {params} parameters "
|
|
90
|
+
f"(>{self.max_parameters}); consider a parameter object."
|
|
91
|
+
),
|
|
92
|
+
path=str(file.path),
|
|
93
|
+
line=function.lineno,
|
|
94
|
+
suggestion="Bundle related parameters into a dataclass.",
|
|
95
|
+
extra={"params": params},
|
|
96
|
+
)
|
|
97
|
+
)
|
|
98
|
+
if cyclomatic > self.max_cyclomatic:
|
|
99
|
+
out.append(
|
|
100
|
+
Finding(
|
|
101
|
+
rule_id="CPLX003",
|
|
102
|
+
category="complexity",
|
|
103
|
+
severity=Severity.MEDIUM,
|
|
104
|
+
message=(
|
|
105
|
+
f"Function {name} has cyclomatic complexity "
|
|
106
|
+
f"{cyclomatic} (>{self.max_cyclomatic})."
|
|
107
|
+
),
|
|
108
|
+
path=str(file.path),
|
|
109
|
+
line=function.lineno,
|
|
110
|
+
suggestion=(
|
|
111
|
+
"Decompose the function along its decision points."
|
|
112
|
+
),
|
|
113
|
+
extra={"cyclomatic": cyclomatic},
|
|
114
|
+
)
|
|
115
|
+
)
|
|
116
|
+
return out
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _walk_functions(tree: ast.AST) -> list[ast.FunctionDef | ast.AsyncFunctionDef]:
|
|
120
|
+
return [
|
|
121
|
+
node
|
|
122
|
+
for node in ast.walk(tree)
|
|
123
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef))
|
|
124
|
+
]
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _link_parents(root: ast.AST, parents: dict[ast.AST, ast.AST]) -> None:
|
|
128
|
+
"""Populate ``parents[child] = parent`` for every AST node in ``root``."""
|
|
129
|
+
for parent in ast.walk(root):
|
|
130
|
+
for child in ast.iter_child_nodes(parent):
|
|
131
|
+
parents[child] = parent
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _function_length(function) -> int:
|
|
135
|
+
if function.end_lineno is None or function.lineno is None:
|
|
136
|
+
return 0
|
|
137
|
+
return function.end_lineno - function.lineno + 1
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _cyclomatic_complexity(function) -> int:
|
|
141
|
+
"""Approximate cyclomatic complexity: 1 + number of decision points."""
|
|
142
|
+
complexity = 1
|
|
143
|
+
for node in ast.walk(function):
|
|
144
|
+
if isinstance(node, _BRANCH_NODES):
|
|
145
|
+
complexity += 1
|
|
146
|
+
if isinstance(node, ast.BoolOp):
|
|
147
|
+
# Each `and`/`or` operator adds an extra edge.
|
|
148
|
+
complexity += len(node.values) - 1
|
|
149
|
+
if isinstance(node, ast.comprehension):
|
|
150
|
+
# Comprehensions have implicit branches.
|
|
151
|
+
complexity += sum(1 for _ in node.ifs)
|
|
152
|
+
if isinstance(node, ast.ExceptHandler):
|
|
153
|
+
complexity += 1
|
|
154
|
+
return complexity
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _qualified_function_name(function, parents) -> str:
|
|
158
|
+
"""Best-effort qualified name for a function definition."""
|
|
159
|
+
parts: list[str] = [function.name]
|
|
160
|
+
parent = parents.get(function)
|
|
161
|
+
while isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
162
|
+
parts.append(parent.name)
|
|
163
|
+
parent = parents.get(parent)
|
|
164
|
+
return ".".join(reversed(parts))
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
__all__ = ["ComplexityAnalyzer"]
|
|
@@ -0,0 +1,262 @@
|
|
|
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=(
|
|
173
|
+
f"{kind} {name!r} is defined in {module} but never "
|
|
174
|
+
"referenced."
|
|
175
|
+
),
|
|
176
|
+
path=str(path),
|
|
177
|
+
line=line,
|
|
178
|
+
suggestion=(
|
|
179
|
+
"Delete the symbol, or expose it in __all__ if it "
|
|
180
|
+
"is part of the public API."
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
return findings
|
|
185
|
+
|
|
186
|
+
def _collect_definitions(
|
|
187
|
+
self,
|
|
188
|
+
tree: ast.AST,
|
|
189
|
+
module: str,
|
|
190
|
+
file,
|
|
191
|
+
definitions: dict,
|
|
192
|
+
all_overrides: dict,
|
|
193
|
+
) -> None:
|
|
194
|
+
for node in ast.iter_child_nodes(tree):
|
|
195
|
+
if isinstance(node, ast.Assign):
|
|
196
|
+
# Capture module-level __all__ assignments.
|
|
197
|
+
for target in node.targets:
|
|
198
|
+
if (
|
|
199
|
+
isinstance(target, ast.Name)
|
|
200
|
+
and target.id == "__all__"
|
|
201
|
+
and isinstance(node.value, (ast.List, ast.Tuple))
|
|
202
|
+
):
|
|
203
|
+
names: set[str] = set()
|
|
204
|
+
for element in node.value.elts:
|
|
205
|
+
if isinstance(element, ast.Constant) and isinstance(
|
|
206
|
+
element.value, str
|
|
207
|
+
):
|
|
208
|
+
names.add(element.value)
|
|
209
|
+
all_overrides[module] = names
|
|
210
|
+
continue
|
|
211
|
+
if isinstance(node, ast.FunctionDef):
|
|
212
|
+
definitions[(module, node.name)] = (file.path, node.lineno, "Function")
|
|
213
|
+
elif isinstance(node, ast.AsyncFunctionDef):
|
|
214
|
+
definitions[(module, node.name)] = (
|
|
215
|
+
file.path,
|
|
216
|
+
node.lineno,
|
|
217
|
+
"Async function",
|
|
218
|
+
)
|
|
219
|
+
elif isinstance(node, ast.ClassDef):
|
|
220
|
+
definitions[(module, node.name)] = (file.path, node.lineno, "Class")
|
|
221
|
+
elif (
|
|
222
|
+
isinstance(node, ast.AnnAssign)
|
|
223
|
+
and isinstance(node.target, ast.Name)
|
|
224
|
+
and isinstance(node.value, ast.AST)
|
|
225
|
+
):
|
|
226
|
+
definitions[(module, node.target.id)] = (
|
|
227
|
+
file.path,
|
|
228
|
+
node.lineno,
|
|
229
|
+
"Variable",
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
def _collect_references(
|
|
233
|
+
self, tree: ast.AST, module: str, references: dict
|
|
234
|
+
) -> None:
|
|
235
|
+
for node in ast.walk(tree):
|
|
236
|
+
if isinstance(node, ast.Name):
|
|
237
|
+
references[(module, node.id)] += 1
|
|
238
|
+
elif isinstance(node, ast.Attribute):
|
|
239
|
+
base: ast.expr = node
|
|
240
|
+
while isinstance(base, ast.Attribute):
|
|
241
|
+
base = base.value
|
|
242
|
+
if isinstance(base, ast.Name):
|
|
243
|
+
# `self.foo` and `module.foo` both reference `foo` on
|
|
244
|
+
# the same module; we count it once.
|
|
245
|
+
references[(module, node.attr)] += 1
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _module_of(path) -> str | None:
|
|
249
|
+
parts = path.parts
|
|
250
|
+
try:
|
|
251
|
+
idx = parts.index("forgecli")
|
|
252
|
+
except ValueError:
|
|
253
|
+
return None
|
|
254
|
+
relevant = parts[idx:]
|
|
255
|
+
if relevant[-1] == "__init__.py":
|
|
256
|
+
relevant = relevant[:-1]
|
|
257
|
+
elif relevant[-1].endswith(".py"):
|
|
258
|
+
relevant = (*relevant[:-1], relevant[-1][:-3])
|
|
259
|
+
return ".".join(relevant)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
__all__ = ["DeadCodeAnalyzer"]
|