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.
- 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 +149 -0
- forgecli/cli/commands_wrappers.py +80 -0
- forgecli/cli/daemon.py +744 -0
- forgecli/cli/main.py +234 -0
- forgecli/cli/ui.py +56 -0
- forgecli/config/__init__.py +28 -0
- forgecli/config/loader.py +85 -0
- forgecli/config/settings.py +206 -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 +214 -0
- forgecli/core/plugins.py +54 -0
- forgecli/core/service.py +22 -0
- forgecli/docs/__init__.py +5 -0
- forgecli/docs/generator.py +115 -0
- forgecli/engine/__init__.py +105 -0
- forgecli/engine/context.py +163 -0
- forgecli/engine/defaults.py +89 -0
- forgecli/engine/events.py +185 -0
- forgecli/engine/execution.py +519 -0
- forgecli/engine/plugins.py +145 -0
- forgecli/engine/runner.py +158 -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 +102 -0
- forgecli/engine/stages/git_engine.py +30 -0
- forgecli/engine/stages/intent_analyzer.py +38 -0
- forgecli/engine/stages/model_router.py +65 -0
- forgecli/engine/stages/planning_engine.py +45 -0
- forgecli/engine/stages/repository_analyzer.py +54 -0
- forgecli/engine/stages/validation_engine.py +87 -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 +448 -0
- forgecli/graph/edge.py +19 -0
- forgecli/graph/graph.py +85 -0
- forgecli/graph/graphify.py +405 -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 +136 -0
- forgecli/memory/store.py +85 -0
- forgecli/optimizer/__init__.py +13 -0
- forgecli/optimizer/caveman/__init__.py +169 -0
- forgecli/optimizer/caveman/cli.py +62 -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 +50 -0
- forgecli/optimizer/chunker.py +85 -0
- forgecli/optimizer/optimizer.py +81 -0
- forgecli/optimizer/ponytail/__init__.py +181 -0
- forgecli/optimizer/ponytail/cli.py +159 -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 +51 -0
- forgecli/optimizer/ranker.py +37 -0
- forgecli/optimizer/summarizer.py +44 -0
- forgecli/orchestrator/__init__.py +706 -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 +267 -0
- forgecli/planner/serialize.py +104 -0
- forgecli/planner/software.py +832 -0
- forgecli/platform/__init__.py +91 -0
- forgecli/platform/core.py +201 -0
- forgecli/platform/deps.py +361 -0
- forgecli/platform/paths.py +234 -0
- forgecli/platform/shell.py +176 -0
- forgecli/platform/update.py +253 -0
- forgecli/plugins/__init__.py +249 -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 +207 -0
- forgecli/providers/base.py +204 -0
- forgecli/providers/builtin.py +26 -0
- forgecli/providers/conversation.py +74 -0
- forgecli/providers/google.py +290 -0
- forgecli/providers/http_base.py +207 -0
- forgecli/providers/mock.py +111 -0
- forgecli/providers/openai.py +206 -0
- forgecli/providers/openai_compatible.py +787 -0
- forgecli/providers/router.py +340 -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 +255 -0
- forgecli/review/analyzers/complexity.py +162 -0
- forgecli/review/analyzers/dead_code.py +255 -0
- forgecli/review/analyzers/duplicates.py +161 -0
- forgecli/review/analyzers/performance.py +161 -0
- forgecli/review/analyzers/security.py +244 -0
- forgecli/review/finding.py +68 -0
- forgecli/review/report.py +321 -0
- forgecli/review/repository.py +130 -0
- forgecli/review/suggestions.py +98 -0
- forgecli/runtime/__init__.py +6 -0
- forgecli/runtime/cache_store.py +75 -0
- forgecli/runtime/mcp_config.py +118 -0
- forgecli/runtime/prepare.py +203 -0
- forgecli/runtime/wrappers.py +150 -0
- forgecli/sdk/__init__.py +132 -0
- forgecli/sdk/events.py +206 -0
- forgecli/sdk/interfaces.py +282 -0
- forgecli/sdk/loader.py +239 -0
- forgecli/sdk/manager.py +678 -0
- forgecli/sdk/manifest.py +395 -0
- forgecli/sdk/sandbox.py +247 -0
- forgecli/sdk/version.py +313 -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 +121 -0
- forgecli/utils/ids.py +11 -0
- forgecli/utils/io.py +27 -0
- forgecli/utils/paths.py +78 -0
- forgecli/utils/stats.py +179 -0
- forgecli/utils/timing.py +25 -0
- forgeoptimizer-0.1.0.dist-info/METADATA +177 -0
- forgeoptimizer-0.1.0.dist-info/RECORD +159 -0
- forgeoptimizer-0.1.0.dist-info/WHEEL +4 -0
- forgeoptimizer-0.1.0.dist-info/entry_points.txt +2 -0
- forgeoptimizer-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Duplicate-code detector.
|
|
2
|
+
|
|
3
|
+
A small, fast shingle-based detector that finds near-duplicate
|
|
4
|
+
``N``-line blocks across files in the same project. It uses a
|
|
5
|
+
rolling hash of normalized tokens; two blocks are considered
|
|
6
|
+
duplicates when they share at least ``min_match`` hashes.
|
|
7
|
+
|
|
8
|
+
This is not a full-blown clone detector; it intentionally trades
|
|
9
|
+
recall for speed and determinism.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import tokenize
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
from collections.abc import Iterable
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from io import StringIO
|
|
19
|
+
from typing import ClassVar
|
|
20
|
+
|
|
21
|
+
from forgecli.review.analyzer import AnalysisContext, Analyzer
|
|
22
|
+
from forgecli.review.finding import Finding, Severity
|
|
23
|
+
|
|
24
|
+
_DEFAULT_LINE_THRESHOLD: int = 6
|
|
25
|
+
_DEFAULT_MIN_MATCH: int = 3
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class DuplicatesAnalyzer(Analyzer):
|
|
30
|
+
"""Detect near-duplicate code blocks across files."""
|
|
31
|
+
|
|
32
|
+
name: ClassVar[str] = "duplicates"
|
|
33
|
+
category: ClassVar[str] = "duplicates"
|
|
34
|
+
line_threshold: int = _DEFAULT_LINE_THRESHOLD
|
|
35
|
+
min_match: int = _DEFAULT_MIN_MATCH
|
|
36
|
+
max_findings: int = 50
|
|
37
|
+
|
|
38
|
+
def run(self, context: AnalysisContext) -> list[Finding]:
|
|
39
|
+
hashes: dict[int, list[tuple[str, int]]] = defaultdict(list)
|
|
40
|
+
for file in context.files:
|
|
41
|
+
tokens = _tokenize_normalized(file.text)
|
|
42
|
+
for index, shingle in _shingles(tokens, self.line_threshold):
|
|
43
|
+
key = _hash(shingle)
|
|
44
|
+
hashes[key].append((str(file.path), index + 1))
|
|
45
|
+
|
|
46
|
+
# Coalesce occurrences: we only need to know which (file, line)
|
|
47
|
+
# ranges share at least min_match shingles with another range.
|
|
48
|
+
# The naive pair-of-pairs approach explodes; instead we group
|
|
49
|
+
# by (file_a, file_b) and report only the first overlapping
|
|
50
|
+
# range per pair.
|
|
51
|
+
seen_pairs: set[tuple[str, str, int, int]] = set()
|
|
52
|
+
per_pair_ranges: dict[tuple[str, str], int] = {}
|
|
53
|
+
findings: list[Finding] = []
|
|
54
|
+
|
|
55
|
+
for _key, occurrences in hashes.items():
|
|
56
|
+
if len(occurrences) < 2:
|
|
57
|
+
continue
|
|
58
|
+
# Group occurrences into connected components where two
|
|
59
|
+
# occurrences in the same file are connected if they're
|
|
60
|
+
# within line_threshold of each other.
|
|
61
|
+
ranges: list[tuple[str, int, int]] = []
|
|
62
|
+
sorted_occ = sorted(occurrences, key=lambda o: (o[0], o[1]))
|
|
63
|
+
for path, line in sorted_occ:
|
|
64
|
+
if ranges and ranges[-1][0] == path and line - ranges[-1][2] < self.line_threshold:
|
|
65
|
+
start_path, start_line, _end = ranges[-1]
|
|
66
|
+
ranges[-1] = (start_path, start_line, line)
|
|
67
|
+
else:
|
|
68
|
+
ranges.append((path, line, line))
|
|
69
|
+
# For each (file, file) pair, count distinct overlapping ranges.
|
|
70
|
+
for i in range(len(ranges)):
|
|
71
|
+
for j in range(i + 1, len(ranges)):
|
|
72
|
+
a_path, a_start, _a_end = ranges[i]
|
|
73
|
+
b_path, b_start, _b_end = ranges[j]
|
|
74
|
+
if a_path == b_path:
|
|
75
|
+
continue
|
|
76
|
+
pair = (a_path, b_path)
|
|
77
|
+
if pair in per_pair_ranges and per_pair_ranges[pair] >= self.min_match:
|
|
78
|
+
continue
|
|
79
|
+
per_pair_ranges[pair] = per_pair_ranges.get(pair, 0) + 1
|
|
80
|
+
if per_pair_ranges[pair] >= self.min_match:
|
|
81
|
+
key_pair = (
|
|
82
|
+
a_path,
|
|
83
|
+
b_path,
|
|
84
|
+
min(a_start, b_start),
|
|
85
|
+
max(a_start, b_start),
|
|
86
|
+
)
|
|
87
|
+
if key_pair in seen_pairs:
|
|
88
|
+
continue
|
|
89
|
+
seen_pairs.add(key_pair)
|
|
90
|
+
if len(findings) >= self.max_findings:
|
|
91
|
+
return findings
|
|
92
|
+
findings.append(
|
|
93
|
+
Finding(
|
|
94
|
+
rule_id="DUP001",
|
|
95
|
+
category="duplicates",
|
|
96
|
+
severity=Severity.LOW,
|
|
97
|
+
message=(
|
|
98
|
+
f"~{self.line_threshold}-line block near "
|
|
99
|
+
f"{a_path}:{a_start} duplicates "
|
|
100
|
+
f"{b_path}:{b_start}."
|
|
101
|
+
),
|
|
102
|
+
path=a_path,
|
|
103
|
+
line=a_start,
|
|
104
|
+
suggestion=("Extract the shared block into a helper."),
|
|
105
|
+
extra={
|
|
106
|
+
"other_path": b_path,
|
|
107
|
+
"other_line": b_start,
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
)
|
|
111
|
+
return findings
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
# Helpers
|
|
116
|
+
# ---------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _tokenize_normalized(text: str) -> list[str]:
|
|
120
|
+
"""Return a normalized token stream: identifiers mapped to ``id``,
|
|
121
|
+
literals dropped, whitespace collapsed.
|
|
122
|
+
"""
|
|
123
|
+
tokens: list[str] = []
|
|
124
|
+
try:
|
|
125
|
+
for token in tokenize.generate_tokens(StringIO(text).readline):
|
|
126
|
+
if token.type in (tokenize.NL, tokenize.NEWLINE, tokenize.INDENT, tokenize.DEDENT):
|
|
127
|
+
continue
|
|
128
|
+
if token.type == tokenize.COMMENT:
|
|
129
|
+
continue
|
|
130
|
+
if token.type == tokenize.STRING or token.type == tokenize.NUMBER:
|
|
131
|
+
tokens.append("lit")
|
|
132
|
+
elif token.type == tokenize.NAME:
|
|
133
|
+
tokens.append("id")
|
|
134
|
+
else:
|
|
135
|
+
tokens.append(token.string.strip())
|
|
136
|
+
except (tokenize.TokenError, IndentationError):
|
|
137
|
+
return tokens
|
|
138
|
+
return [t for t in tokens if t]
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _shingles(tokens: list[str], size: int) -> Iterable[tuple[int, list[str]]]:
|
|
142
|
+
"""Yield ``(start_index, shingle)`` pairs of ``size`` consecutive tokens."""
|
|
143
|
+
if len(tokens) < size:
|
|
144
|
+
return
|
|
145
|
+
for index in range(len(tokens) - size + 1):
|
|
146
|
+
yield index, tokens[index : index + size]
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _hash(shingle: Iterable[str]) -> int:
|
|
150
|
+
"""Stable 32-bit hash for a shingle."""
|
|
151
|
+
import hashlib
|
|
152
|
+
|
|
153
|
+
payload = "\x1f".join(shingle).encode("utf-8")
|
|
154
|
+
digest = hashlib.blake2b(payload, digest_size=4).digest()
|
|
155
|
+
return int.from_bytes(digest, "big", signed=False)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
__all__ = ["DuplicatesAnalyzer"]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# Silence unused-import warnings.
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Performance analyzer.
|
|
2
|
+
|
|
3
|
+
A small heuristic linter for the patterns that commonly degrade
|
|
4
|
+
runtime in Python code:
|
|
5
|
+
|
|
6
|
+
* ``for`` loops nested three or more levels deep (a code-smell that
|
|
7
|
+
often indicates an O(n^3) algorithm);
|
|
8
|
+
* ``open(...)`` or ``Path.read_text()`` calls inside an ``async def``
|
|
9
|
+
function (blocking I/O on the event loop);
|
|
10
|
+
* ``time.sleep`` inside an ``async def`` (blocks the loop);
|
|
11
|
+
* ``list(...)`` or ``dict(...)`` around an already-iterable generator
|
|
12
|
+
inside a tight loop;
|
|
13
|
+
* ``subprocess`` calls without an explicit timeout.
|
|
14
|
+
|
|
15
|
+
The analyzer never measures actual runtime; it flags patterns that are
|
|
16
|
+
*usually* a problem and lets a human decide.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import ast
|
|
22
|
+
from dataclasses import dataclass
|
|
23
|
+
from typing import ClassVar
|
|
24
|
+
|
|
25
|
+
from forgecli.review.analyzer import AnalysisContext, Analyzer
|
|
26
|
+
from forgecli.review.finding import Finding, Severity
|
|
27
|
+
|
|
28
|
+
_BLOCKING_NAMES: frozenset[str] = frozenset(
|
|
29
|
+
{
|
|
30
|
+
"open",
|
|
31
|
+
"read_text",
|
|
32
|
+
"read_bytes",
|
|
33
|
+
"write_text",
|
|
34
|
+
"write_bytes",
|
|
35
|
+
"read",
|
|
36
|
+
"write",
|
|
37
|
+
}
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class PerformanceAnalyzer(Analyzer):
|
|
43
|
+
"""Heuristic performance smells."""
|
|
44
|
+
|
|
45
|
+
name: ClassVar[str] = "performance"
|
|
46
|
+
category: ClassVar[str] = "performance"
|
|
47
|
+
nested_loop_threshold: int = 3
|
|
48
|
+
|
|
49
|
+
def run(self, context: AnalysisContext) -> list[Finding]:
|
|
50
|
+
findings: list[Finding] = []
|
|
51
|
+
for file in context.files:
|
|
52
|
+
findings.extend(self._scan_async_blocking(file))
|
|
53
|
+
findings.extend(self._scan_nested_loops(file))
|
|
54
|
+
return findings
|
|
55
|
+
|
|
56
|
+
def _scan_async_blocking(self, file) -> list[Finding]:
|
|
57
|
+
out: list[Finding] = []
|
|
58
|
+
try:
|
|
59
|
+
tree = ast.parse(file.text)
|
|
60
|
+
except SyntaxError:
|
|
61
|
+
return out
|
|
62
|
+
for function in _walk_async_functions(tree):
|
|
63
|
+
for node in ast.walk(function):
|
|
64
|
+
if not isinstance(node, ast.Call):
|
|
65
|
+
continue
|
|
66
|
+
name = _call_name(node.func)
|
|
67
|
+
if name is None:
|
|
68
|
+
continue
|
|
69
|
+
if name in _BLOCKING_NAMES:
|
|
70
|
+
out.append(
|
|
71
|
+
Finding(
|
|
72
|
+
rule_id="PERF001",
|
|
73
|
+
category="performance",
|
|
74
|
+
severity=Severity.MEDIUM,
|
|
75
|
+
message=(
|
|
76
|
+
f"Blocking call to {name}() inside an async "
|
|
77
|
+
"function blocks the event loop."
|
|
78
|
+
),
|
|
79
|
+
path=str(file.path),
|
|
80
|
+
line=node.lineno,
|
|
81
|
+
suggestion=(
|
|
82
|
+
"Use the async equivalent (aiofiles, "
|
|
83
|
+
"asyncio.to_thread, etc.) or move the call "
|
|
84
|
+
"out of the async path."
|
|
85
|
+
),
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
if name in {"time.sleep", "requests.get", "requests.post"}:
|
|
89
|
+
out.append(
|
|
90
|
+
Finding(
|
|
91
|
+
rule_id="PERF002",
|
|
92
|
+
category="performance",
|
|
93
|
+
severity=Severity.MEDIUM,
|
|
94
|
+
message=(f"Sync {name}() in async code blocks the event loop."),
|
|
95
|
+
path=str(file.path),
|
|
96
|
+
line=node.lineno,
|
|
97
|
+
suggestion=("Use asyncio.sleep / an async HTTP client."),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return out
|
|
101
|
+
|
|
102
|
+
def _scan_nested_loops(self, file) -> list[Finding]:
|
|
103
|
+
out: list[Finding] = []
|
|
104
|
+
try:
|
|
105
|
+
tree = ast.parse(file.text)
|
|
106
|
+
except SyntaxError:
|
|
107
|
+
return out
|
|
108
|
+
for node in ast.walk(tree):
|
|
109
|
+
depth, deepest = _loop_depth(node)
|
|
110
|
+
if depth >= self.nested_loop_threshold:
|
|
111
|
+
out.append(
|
|
112
|
+
Finding(
|
|
113
|
+
rule_id="PERF010",
|
|
114
|
+
category="performance",
|
|
115
|
+
severity=Severity.LOW,
|
|
116
|
+
message=(
|
|
117
|
+
f"Loops nested {depth} levels deep; "
|
|
118
|
+
"consider refactoring to reduce complexity."
|
|
119
|
+
),
|
|
120
|
+
path=str(file.path),
|
|
121
|
+
line=getattr(deepest, "lineno", 1),
|
|
122
|
+
suggestion=(
|
|
123
|
+
"Extract the inner loop into a helper, or "
|
|
124
|
+
"vectorize with itertools / numpy."
|
|
125
|
+
),
|
|
126
|
+
extra={"depth": depth},
|
|
127
|
+
)
|
|
128
|
+
)
|
|
129
|
+
return out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _walk_async_functions(tree: ast.AST) -> list[ast.AsyncFunctionDef]:
|
|
133
|
+
return [node for node in ast.walk(tree) if isinstance(node, ast.AsyncFunctionDef)]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _call_name(node: ast.AST) -> str | None:
|
|
137
|
+
if isinstance(node, ast.Name):
|
|
138
|
+
return node.id
|
|
139
|
+
if isinstance(node, ast.Attribute):
|
|
140
|
+
base = _call_name(node.value)
|
|
141
|
+
if base is None:
|
|
142
|
+
return None
|
|
143
|
+
return f"{base}.{node.attr}"
|
|
144
|
+
return None
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _loop_depth(node: ast.AST) -> tuple[int, ast.AST]:
|
|
148
|
+
"""Return ``(depth, deepest_node)`` for the deepest loop in ``node``."""
|
|
149
|
+
best_depth = 0
|
|
150
|
+
best_node = node
|
|
151
|
+
if isinstance(node, (ast.For, ast.AsyncFor, ast.While)):
|
|
152
|
+
best_depth = 1
|
|
153
|
+
for child in ast.iter_child_nodes(node):
|
|
154
|
+
depth, deepest = _loop_depth(child)
|
|
155
|
+
if depth + (1 if isinstance(node, (ast.For, ast.AsyncFor, ast.While)) else 0) > best_depth:
|
|
156
|
+
best_depth = depth + (1 if isinstance(node, (ast.For, ast.AsyncFor, ast.While)) else 0)
|
|
157
|
+
best_node = deepest
|
|
158
|
+
return best_depth, best_node
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
__all__ = ["PerformanceAnalyzer"]
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""Security analyzer.
|
|
2
|
+
|
|
3
|
+
A small, regex/AST-based linter for the kinds of issues that show up
|
|
4
|
+
in real-world Python projects:
|
|
5
|
+
|
|
6
|
+
* hard-coded secrets (API keys, tokens, passwords);
|
|
7
|
+
* unsafe calls to :func:`os.system`, :func:`subprocess.Popen` with
|
|
8
|
+
``shell=True``, or :func:`eval`/:func:`exec`;
|
|
9
|
+
* ``pickle.load`` on untrusted data;
|
|
10
|
+
* weak hashing (``md5``, ``sha1``) for security-sensitive use;
|
|
11
|
+
* ``assert`` statements in production code (stripped by ``python -O``).
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import ast
|
|
17
|
+
import re
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from typing import ClassVar
|
|
20
|
+
|
|
21
|
+
from forgecli.review.analyzer import AnalysisContext, Analyzer
|
|
22
|
+
from forgecli.review.finding import Finding, Severity
|
|
23
|
+
|
|
24
|
+
_SECRET_PATTERNS: tuple[tuple[str, str, re.Pattern[str]], ...] = (
|
|
25
|
+
(
|
|
26
|
+
"SEC001",
|
|
27
|
+
"AWS access key id",
|
|
28
|
+
re.compile(r"\bAKIA[0-9A-Z]{16}\b"),
|
|
29
|
+
),
|
|
30
|
+
(
|
|
31
|
+
"SEC002",
|
|
32
|
+
"Generic API key assignment",
|
|
33
|
+
re.compile(
|
|
34
|
+
r"(?i)(?:api[_-]?key|secret|token|password|passwd|pwd)"
|
|
35
|
+
r"\s*[:=]\s*['\"][A-Za-z0-9_\-]{16,}['\"]"
|
|
36
|
+
),
|
|
37
|
+
),
|
|
38
|
+
(
|
|
39
|
+
"SEC003",
|
|
40
|
+
"PEM private key block",
|
|
41
|
+
re.compile(r"-----BEGIN (?:RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----"),
|
|
42
|
+
),
|
|
43
|
+
(
|
|
44
|
+
"SEC004",
|
|
45
|
+
"Slack/bot token",
|
|
46
|
+
re.compile(r"\bxox[baprs]-[A-Za-z0-9-]{10,}\b"),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
_UNSAFE_CALLS: tuple[tuple[str | None, str, str], ...] = (
|
|
52
|
+
# (qualified-prefix-or-None, attr-or-None, rule_id)
|
|
53
|
+
# Module-qualified calls.
|
|
54
|
+
("os", "system", "SEC010"),
|
|
55
|
+
("os", "popen", "SEC011"),
|
|
56
|
+
("subprocess", "call", "SEC012"),
|
|
57
|
+
("subprocess", "Popen", "SEC013"),
|
|
58
|
+
("pickle", "load", "SEC017"),
|
|
59
|
+
("pickle", "loads", "SEC018"),
|
|
60
|
+
("marshal", "load", "SEC019"),
|
|
61
|
+
("marshal", "loads", "SEC020"),
|
|
62
|
+
# Builtins (no qualifier).
|
|
63
|
+
(None, "eval", "SEC014"),
|
|
64
|
+
(None, "exec", "SEC015"),
|
|
65
|
+
(None, "compile", "SEC016"),
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
_WEAK_HASH_NAMES: frozenset[str] = frozenset({"md5", "sha1"})
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class SecurityAnalyzer(Analyzer):
|
|
74
|
+
"""Find hard-coded secrets and unsafe calls."""
|
|
75
|
+
|
|
76
|
+
name: ClassVar[str] = "security"
|
|
77
|
+
category: ClassVar[str] = "security"
|
|
78
|
+
_EXTRA_RULES: ClassVar[tuple[tuple[str, str, re.Pattern[str]], ...]] = ()
|
|
79
|
+
|
|
80
|
+
def run(self, context: AnalysisContext) -> list[Finding]:
|
|
81
|
+
findings: list[Finding] = []
|
|
82
|
+
for file in context.files:
|
|
83
|
+
findings.extend(self._scan_secrets(file))
|
|
84
|
+
findings.extend(self._scan_unsafe_calls(file))
|
|
85
|
+
findings.extend(self._scan_weak_hash(file))
|
|
86
|
+
findings.extend(self._scan_asserts(file))
|
|
87
|
+
return findings
|
|
88
|
+
|
|
89
|
+
def _scan_secrets(self, file) -> list[Finding]:
|
|
90
|
+
out: list[Finding] = []
|
|
91
|
+
for index, line in enumerate(file.lines, start=1):
|
|
92
|
+
for rule_id, label, pattern in _SECRET_PATTERNS:
|
|
93
|
+
if pattern.search(line):
|
|
94
|
+
out.append(
|
|
95
|
+
Finding(
|
|
96
|
+
rule_id=rule_id,
|
|
97
|
+
category="security",
|
|
98
|
+
severity=Severity.CRITICAL,
|
|
99
|
+
message=f"Possible hard-coded {label}.",
|
|
100
|
+
path=str(file.path),
|
|
101
|
+
line=index,
|
|
102
|
+
suggestion=(
|
|
103
|
+
"Move the secret to an environment variable or a secret manager."
|
|
104
|
+
),
|
|
105
|
+
)
|
|
106
|
+
)
|
|
107
|
+
return out
|
|
108
|
+
|
|
109
|
+
def _scan_unsafe_calls(self, file) -> list[Finding]:
|
|
110
|
+
out: list[Finding] = []
|
|
111
|
+
try:
|
|
112
|
+
tree = ast.parse(file.text)
|
|
113
|
+
except SyntaxError:
|
|
114
|
+
return out
|
|
115
|
+
for node in ast.walk(tree):
|
|
116
|
+
if not isinstance(node, ast.Call):
|
|
117
|
+
continue
|
|
118
|
+
qualified = _qualified_name(node.func)
|
|
119
|
+
if qualified is None:
|
|
120
|
+
continue
|
|
121
|
+
for module, attr, rule_id in _UNSAFE_CALLS:
|
|
122
|
+
target = f"{module}.{attr}" if module else attr
|
|
123
|
+
if qualified != target:
|
|
124
|
+
continue
|
|
125
|
+
out.append(
|
|
126
|
+
Finding(
|
|
127
|
+
rule_id=rule_id,
|
|
128
|
+
category="security",
|
|
129
|
+
severity=Severity.HIGH,
|
|
130
|
+
message=f"Unsafe call to {qualified}().",
|
|
131
|
+
path=str(file.path),
|
|
132
|
+
line=node.lineno,
|
|
133
|
+
suggestion=_suggest_for(rule_id),
|
|
134
|
+
)
|
|
135
|
+
)
|
|
136
|
+
if qualified in {"subprocess.call", "subprocess.Popen"} and any(
|
|
137
|
+
isinstance(kw.value, ast.Constant) and kw.value.value is True
|
|
138
|
+
for kw in node.keywords
|
|
139
|
+
if kw.arg == "shell"
|
|
140
|
+
):
|
|
141
|
+
out.append(
|
|
142
|
+
Finding(
|
|
143
|
+
rule_id="SEC013",
|
|
144
|
+
category="security",
|
|
145
|
+
severity=Severity.CRITICAL,
|
|
146
|
+
message=(f"{qualified}(shell=True) is vulnerable to command injection."),
|
|
147
|
+
path=str(file.path),
|
|
148
|
+
line=node.lineno,
|
|
149
|
+
suggestion=(
|
|
150
|
+
"Pass a list of arguments and drop shell=True; "
|
|
151
|
+
"use shlex.quote() only on fully-trusted input."
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
)
|
|
155
|
+
return out
|
|
156
|
+
|
|
157
|
+
def _scan_weak_hash(self, file) -> list[Finding]:
|
|
158
|
+
out: list[Finding] = []
|
|
159
|
+
try:
|
|
160
|
+
tree = ast.parse(file.text)
|
|
161
|
+
except SyntaxError:
|
|
162
|
+
return out
|
|
163
|
+
for node in ast.walk(tree):
|
|
164
|
+
if not isinstance(node, ast.Call):
|
|
165
|
+
continue
|
|
166
|
+
name = _qualified_name(node.func)
|
|
167
|
+
if name is None:
|
|
168
|
+
continue
|
|
169
|
+
short = name.rsplit(".", 1)[-1]
|
|
170
|
+
if short in _WEAK_HASH_NAMES:
|
|
171
|
+
out.append(
|
|
172
|
+
Finding(
|
|
173
|
+
rule_id="SEC021",
|
|
174
|
+
category="security",
|
|
175
|
+
severity=Severity.MEDIUM,
|
|
176
|
+
message=(f"{name} is a weak hash; prefer sha256 or sha3_256."),
|
|
177
|
+
path=str(file.path),
|
|
178
|
+
line=node.lineno,
|
|
179
|
+
suggestion=(
|
|
180
|
+
"Use hashlib.sha256() (or stronger) for any security-sensitive use."
|
|
181
|
+
),
|
|
182
|
+
)
|
|
183
|
+
)
|
|
184
|
+
return out
|
|
185
|
+
|
|
186
|
+
def _scan_asserts(self, file) -> list[Finding]:
|
|
187
|
+
out: list[Finding] = []
|
|
188
|
+
try:
|
|
189
|
+
tree = ast.parse(file.text)
|
|
190
|
+
except SyntaxError:
|
|
191
|
+
return out
|
|
192
|
+
for node in ast.walk(tree):
|
|
193
|
+
if not isinstance(node, ast.Assert):
|
|
194
|
+
continue
|
|
195
|
+
out.append(
|
|
196
|
+
Finding(
|
|
197
|
+
rule_id="SEC022",
|
|
198
|
+
category="security",
|
|
199
|
+
severity=Severity.LOW,
|
|
200
|
+
message=(
|
|
201
|
+
"assert statement is removed under `python -O`; "
|
|
202
|
+
"don't use it for runtime checks."
|
|
203
|
+
),
|
|
204
|
+
path=str(file.path),
|
|
205
|
+
line=node.lineno,
|
|
206
|
+
suggestion="Raise an explicit exception instead.",
|
|
207
|
+
)
|
|
208
|
+
)
|
|
209
|
+
return out
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _qualified_name(node: ast.AST) -> str | None:
|
|
213
|
+
"""Return the dotted name for a callable node, or None."""
|
|
214
|
+
if isinstance(node, ast.Name):
|
|
215
|
+
return node.id
|
|
216
|
+
if isinstance(node, ast.Attribute):
|
|
217
|
+
base = _qualified_name(node.value)
|
|
218
|
+
if base is None:
|
|
219
|
+
return None
|
|
220
|
+
return f"{base}.{node.attr}"
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _suggest_for(rule_id: str) -> str:
|
|
225
|
+
return {
|
|
226
|
+
"SEC010": "Use subprocess.run([...], shell=False) instead of os.system.",
|
|
227
|
+
"SEC011": "Use subprocess.run([...], shell=False) instead of os.popen.",
|
|
228
|
+
"SEC012": "Pass a list of arguments and shell=False to subprocess.call.",
|
|
229
|
+
"SEC013": "Pass a list of arguments and shell=False to subprocess.Popen.",
|
|
230
|
+
"SEC014": "Avoid eval(); use ast.literal_eval() or explicit parsing.",
|
|
231
|
+
"SEC015": "Avoid exec() on dynamic input; it's a remote-code vector.",
|
|
232
|
+
"SEC016": "Avoid compile() on user input.",
|
|
233
|
+
"SEC017": "Use json or a safe deserializer; pickle is unsafe on untrusted data.",
|
|
234
|
+
"SEC018": "Use json or a safe deserializer; pickle is unsafe on untrusted data.",
|
|
235
|
+
"SEC019": "marshal is not a safe deserialization format for untrusted data.",
|
|
236
|
+
"SEC020": "marshal is not a safe deserialization format for untrusted data.",
|
|
237
|
+
}.get(rule_id, "Replace with a safer alternative.")
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
__all__ = ["SecurityAnalyzer"]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
# Silence unused-import warnings for symbols only used in some branches.
|
|
244
|
+
_ = field
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Finding and Severity value types shared by all review analyzers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from enum import Enum
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Severity(str, Enum):
|
|
11
|
+
"""Severity levels for review findings."""
|
|
12
|
+
|
|
13
|
+
INFO = "info"
|
|
14
|
+
LOW = "low"
|
|
15
|
+
MEDIUM = "medium"
|
|
16
|
+
HIGH = "high"
|
|
17
|
+
CRITICAL = "critical"
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def weight(self) -> int:
|
|
21
|
+
"""Numeric weight used for sorting and aggregation."""
|
|
22
|
+
return {
|
|
23
|
+
Severity.INFO: 0,
|
|
24
|
+
Severity.LOW: 1,
|
|
25
|
+
Severity.MEDIUM: 2,
|
|
26
|
+
Severity.HIGH: 3,
|
|
27
|
+
Severity.CRITICAL: 4,
|
|
28
|
+
}[self]
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def is_blocking(self) -> bool:
|
|
32
|
+
"""A blocking finding is one we should refuse to merge."""
|
|
33
|
+
return self is Severity.CRITICAL
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Finding:
|
|
38
|
+
"""A single review observation.
|
|
39
|
+
|
|
40
|
+
The ``category`` is one of: ``"security"``, ``"performance"``,
|
|
41
|
+
``"architecture"``, ``"complexity"``, ``"dead-code"``,
|
|
42
|
+
``"duplicates"``, ``"suggestions"``.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
rule_id: str
|
|
46
|
+
category: str
|
|
47
|
+
severity: Severity
|
|
48
|
+
message: str
|
|
49
|
+
path: str | None = None
|
|
50
|
+
line: int | None = None
|
|
51
|
+
suggestion: str | None = None
|
|
52
|
+
extra: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
|
|
54
|
+
def to_dict(self) -> dict[str, Any]:
|
|
55
|
+
"""Return a JSON-serializable view of this finding."""
|
|
56
|
+
return {
|
|
57
|
+
"rule_id": self.rule_id,
|
|
58
|
+
"category": self.category,
|
|
59
|
+
"severity": self.severity.value,
|
|
60
|
+
"message": self.message,
|
|
61
|
+
"path": self.path,
|
|
62
|
+
"line": self.line,
|
|
63
|
+
"suggestion": self.suggestion,
|
|
64
|
+
"extra": self.extra,
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
__all__ = ["Finding", "Severity"]
|