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,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"]