prism-code-review 2.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.
- prism/__init__.py +87 -0
- prism/__main__.py +13 -0
- prism/agents.py +191 -0
- prism/agents_policy.py +277 -0
- prism/agents_quality.py +584 -0
- prism/agents_security.py +243 -0
- prism/agents_test.py +145 -0
- prism/ai_architecture.py +65 -0
- prism/ai_common.py +32 -0
- prism/ai_findings.py +181 -0
- prism/ai_graph.py +224 -0
- prism/ai_performance.py +65 -0
- prism/ai_pipeline.py +447 -0
- prism/ai_policy.py +65 -0
- prism/ai_providers.py +514 -0
- prism/ai_quality.py +65 -0
- prism/ai_security.py +65 -0
- prism/ai_test.py +65 -0
- prism/autofix.py +360 -0
- prism/cli.py +248 -0
- prism/cli_commands.py +711 -0
- prism/cli_config.py +202 -0
- prism/cli_review.py +558 -0
- prism/codebase_cache.py +155 -0
- prism/commands_common.py +365 -0
- prism/commands_reports.py +212 -0
- prism/commands_setup.py +703 -0
- prism/feedback.py +254 -0
- prism/ignore.py +344 -0
- prism/langs.py +251 -0
- prism/logging_config.py +52 -0
- prism/lsp.py +194 -0
- prism/modes.py +94 -0
- prism/reports.py +277 -0
- prism/token_usage.py +93 -0
- prism_code_review-2.0.0.dist-info/METADATA +1392 -0
- prism_code_review-2.0.0.dist-info/RECORD +41 -0
- prism_code_review-2.0.0.dist-info/WHEEL +5 -0
- prism_code_review-2.0.0.dist-info/entry_points.txt +2 -0
- prism_code_review-2.0.0.dist-info/licenses/LICENSE +21 -0
- prism_code_review-2.0.0.dist-info/top_level.txt +1 -0
prism/ai_test.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI Test Agent - Uses Gemini for test coverage review.
|
|
3
|
+
|
|
4
|
+
Catches: missing test coverage, weak test cases, untested code paths,
|
|
5
|
+
and test quality issues.
|
|
6
|
+
|
|
7
|
+
Rule IDs follow SonarQube convention: TST-XXX
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
from typing import List
|
|
12
|
+
from prism.agents import BaseAgent, Finding, register_agent
|
|
13
|
+
from prism.ai_common import review_with_agent
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
SYSTEM_PROMPT = """
|
|
18
|
+
You are a software testing expert with 10+ years of experience in test automation.
|
|
19
|
+
You review code for test coverage gaps and test quality issues.
|
|
20
|
+
|
|
21
|
+
Focus on:
|
|
22
|
+
1. Untested code paths (functions without corresponding tests)
|
|
23
|
+
2. Weak test cases (tests that don't actually verify behavior)
|
|
24
|
+
3. Missing edge cases (null inputs, empty inputs, boundary values)
|
|
25
|
+
4. Missing error handling tests (what happens when things go wrong?)
|
|
26
|
+
5. Test isolation issues (tests that depend on shared state)
|
|
27
|
+
6. Slow tests (tests that perform I/O, network calls, or database access)
|
|
28
|
+
7. Missing assertions (tests that don't verify expected outcomes)
|
|
29
|
+
8. Untested public APIs (exported functions with no test coverage)
|
|
30
|
+
|
|
31
|
+
Rules:
|
|
32
|
+
1. Return ONLY valid JSON. No markdown, no extra text.
|
|
33
|
+
2. Be specific. Each finding must include: severity, category, message, file, line, suggestion, suggested_code.
|
|
34
|
+
3. For suggested_code, provide the actual test code that fixes the issue.
|
|
35
|
+
4. Focus on actual coverage gaps, not test style preferences.
|
|
36
|
+
5. If you find no issues, return empty findings array.
|
|
37
|
+
|
|
38
|
+
Schema:
|
|
39
|
+
{
|
|
40
|
+
"findings": [
|
|
41
|
+
{
|
|
42
|
+
"severity": "critical" | "high" | "medium" | "low",
|
|
43
|
+
"category": "string",
|
|
44
|
+
"message": "string",
|
|
45
|
+
"file": "string",
|
|
46
|
+
"line": number,
|
|
47
|
+
"suggestion": "string",
|
|
48
|
+
"suggested_code": "string or null"
|
|
49
|
+
}
|
|
50
|
+
],
|
|
51
|
+
"summary": "2-3 sentence overall test coverage assessment"
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@register_agent
|
|
57
|
+
class AITestAgent(BaseAgent):
|
|
58
|
+
name = "ai_test"
|
|
59
|
+
description = "AI-powered test coverage reviewer using Gemini"
|
|
60
|
+
|
|
61
|
+
def _check(self, file_path: str, content: str, diff: str,
|
|
62
|
+
skeleton: str = "", line_count: int = None,
|
|
63
|
+
fast: bool = False) -> List[Finding]:
|
|
64
|
+
return review_with_agent(SYSTEM_PROMPT, file_path, content,
|
|
65
|
+
self.name, skeleton, line_count, diff, fast)
|
prism/autofix.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Auto-fix engine - Generates corrected code for auto-fixable findings.
|
|
3
|
+
|
|
4
|
+
Based on GitHub Copilot's "suggested changes" and CodeRabbit's "One-Click Fix":
|
|
5
|
+
- AI generates corrected code snippets
|
|
6
|
+
- Developer reviews before applying
|
|
7
|
+
- Changes are attributed to the developer, not committed automatically
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import List, Optional, Tuple
|
|
12
|
+
from prism.agents import Finding
|
|
13
|
+
from prism.langs import lang_for
|
|
14
|
+
from prism.logging_config import logger
|
|
15
|
+
|
|
16
|
+
# A declaration is a name preceded by modifiers, a type, or a qualifier
|
|
17
|
+
# (`const token`, `String apiKey`, `self.password`). Taking the name from the
|
|
18
|
+
# start of the line got the boundary wrong: `String apiKey` produced
|
|
19
|
+
# `StringapiKey` and `\tapiKey :=` produced the single character `y`.
|
|
20
|
+
_SECRET_ASSIGN = re.compile(
|
|
21
|
+
r'^(?P<head>\s*(?:[\w$]+(?:[\s*&.]*|::))*?)'
|
|
22
|
+
r'(?P<name>\$?[A-Za-z_]\w*)'
|
|
23
|
+
r'(?P<op>\s*(?::=|(?<![=!<>:])=(?!=)|:(?!=))\s*)'
|
|
24
|
+
r'(?P<q>["\'])(?P<lit>.*?)(?P=q)(?P<tail>.*)$')
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _is_valid_python(source: str) -> bool:
|
|
28
|
+
"""True when source parses as a complete Python module."""
|
|
29
|
+
try:
|
|
30
|
+
compile(source, "<prism-autofix>", "exec")
|
|
31
|
+
except (SyntaxError, ValueError):
|
|
32
|
+
return False
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def generate_fix(finding: Finding, original_content: str) -> Optional[str]:
|
|
37
|
+
"""
|
|
38
|
+
Generate a corrected version of the line(s) for a finding.
|
|
39
|
+
|
|
40
|
+
Returns the fixed code string, or None if no fix is possible.
|
|
41
|
+
"""
|
|
42
|
+
lines = original_content.split("\n")
|
|
43
|
+
line_idx = (finding.line or 1) - 1
|
|
44
|
+
|
|
45
|
+
if line_idx < 0 or line_idx >= len(lines):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
original_line = lines[line_idx]
|
|
49
|
+
fix_type = _classify_fix(finding)
|
|
50
|
+
spec = _spec_for(finding)
|
|
51
|
+
|
|
52
|
+
if fix_type == "remove_line":
|
|
53
|
+
return _remove_line(lines, line_idx)
|
|
54
|
+
|
|
55
|
+
if fix_type == "replace_secret":
|
|
56
|
+
return _replace_secret(original_line, finding, spec)
|
|
57
|
+
|
|
58
|
+
if fix_type == "remove_eval":
|
|
59
|
+
return _remove_eval(original_line, finding, spec)
|
|
60
|
+
|
|
61
|
+
if fix_type == "remove_exec":
|
|
62
|
+
return _remove_exec(original_line, finding, spec)
|
|
63
|
+
|
|
64
|
+
if fix_type == "add_logging":
|
|
65
|
+
return _replace_print_with_logging(original_line, finding, spec)
|
|
66
|
+
|
|
67
|
+
if fix_type == "remove_debugger":
|
|
68
|
+
return _remove_debugger(original_line, finding, spec)
|
|
69
|
+
|
|
70
|
+
if fix_type == "remove_console_log":
|
|
71
|
+
return _comment_out(original_line, finding, spec)
|
|
72
|
+
|
|
73
|
+
if fix_type == "add_test_docstring":
|
|
74
|
+
return _add_test_docstring(lines, line_idx, finding)
|
|
75
|
+
|
|
76
|
+
return None
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _spec_for(finding):
|
|
80
|
+
"""The language of the file a finding came from, or None if it is not source."""
|
|
81
|
+
return lang_for(getattr(finding, "file", "") or "")
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _comment_prefix(spec):
|
|
85
|
+
"""How a language starts a comment, for fixes that must disable a line."""
|
|
86
|
+
if spec is None or not spec.line_comments:
|
|
87
|
+
return "#"
|
|
88
|
+
return spec.line_comments[0]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _is_python(spec):
|
|
92
|
+
return spec is None or spec.name == "python"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _classify_fix(finding: Finding) -> str:
|
|
96
|
+
"""Classify what kind of fix is needed.
|
|
97
|
+
|
|
98
|
+
Rule IDs are authoritative when present. The message fallbacks use word
|
|
99
|
+
boundaries so "evaluation" no longer looks like eval() and "keyword" no
|
|
100
|
+
longer looks like an API key.
|
|
101
|
+
"""
|
|
102
|
+
rule_id = finding.rule_id or ""
|
|
103
|
+
msg = finding.message.lower()
|
|
104
|
+
|
|
105
|
+
if rule_id == "SECU-201":
|
|
106
|
+
return "remove_eval"
|
|
107
|
+
if rule_id == "SECU-202":
|
|
108
|
+
return "remove_exec"
|
|
109
|
+
if re.match(r"^SECU-1\d\d$", rule_id):
|
|
110
|
+
return "replace_secret"
|
|
111
|
+
if rule_id == "POL-203":
|
|
112
|
+
return "remove_debugger"
|
|
113
|
+
if rule_id == "POL-202":
|
|
114
|
+
return "add_logging"
|
|
115
|
+
# QUAL-110 is "missing docstring": a real docstring needs the function's
|
|
116
|
+
# purpose, which cannot be invented from the name. `add_test_docstring`
|
|
117
|
+
# only ever applied to TST-102, and routing QUAL-110 here produced a
|
|
118
|
+
# suggestion that just echoed the def line back.
|
|
119
|
+
if rule_id == "TST-102":
|
|
120
|
+
return "add_test_docstring"
|
|
121
|
+
|
|
122
|
+
if re.search(r"\beval\s*\(", msg):
|
|
123
|
+
return "remove_eval"
|
|
124
|
+
if re.search(r"\bexec\s*\(", msg):
|
|
125
|
+
return "remove_exec"
|
|
126
|
+
if re.search(r"\bdebugger\b", msg):
|
|
127
|
+
return "remove_debugger"
|
|
128
|
+
if re.search(r"console\.log", msg):
|
|
129
|
+
return "remove_console_log"
|
|
130
|
+
if re.search(r"\bprint\s*\(", msg):
|
|
131
|
+
return "add_logging"
|
|
132
|
+
if re.search(r"\b(api[_-]?secret|secret|password|passwd|pwd|token)\b", msg):
|
|
133
|
+
return "replace_secret"
|
|
134
|
+
# "docstring" alone is not enough: only test docstrings can be generated.
|
|
135
|
+
if re.search(r"\bdocstring\b", msg) and "test" in msg:
|
|
136
|
+
return "add_test_docstring"
|
|
137
|
+
|
|
138
|
+
return "unknown"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _remove_line(lines: list, idx: int) -> str:
|
|
142
|
+
"""Remove a line entirely."""
|
|
143
|
+
return ""
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _replace_secret(line: str, finding: Finding, spec=None) -> str:
|
|
147
|
+
"""Replace a hardcoded secret with a read from the environment.
|
|
148
|
+
|
|
149
|
+
The declaration is left as it is and only the literal is swapped, so
|
|
150
|
+
``apiKey := "..."`` keeps its ``:=`` and its trailing ``;`` while the value
|
|
151
|
+
becomes the language's own lookup (``os.Getenv("APIKEY")`` in Go,
|
|
152
|
+
``process.env.APIKEY`` in JS). Emitting ``os.getenv`` into a Go file would
|
|
153
|
+
not compile, which is worse than offering no fix at all.
|
|
154
|
+
"""
|
|
155
|
+
env_read = spec.env_read if spec is not None else 'os.getenv("{name}")'
|
|
156
|
+
match = _SECRET_ASSIGN.match(line)
|
|
157
|
+
if not match:
|
|
158
|
+
return line
|
|
159
|
+
# PHP declares `$key`; the sigil is not part of the variable's name.
|
|
160
|
+
name = re.sub(r"[^A-Za-z0-9]", "_", match.group("name").lstrip("$")).upper()
|
|
161
|
+
return (match.group("head") + match.group("name") + match.group("op")
|
|
162
|
+
+ env_read.format(name=name) + match.group("tail"))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _remove_dangerous_call(line: str, name: str, spec=None) -> str:
|
|
166
|
+
"""Replace a dangerous call with a syntactically valid, inert statement.
|
|
167
|
+
|
|
168
|
+
Commenting out only the call left fragments such as
|
|
169
|
+
``return # eval() removed`` followed by ``# x)``, which is confusing and
|
|
170
|
+
silently changes control flow. Emit a complete statement instead.
|
|
171
|
+
|
|
172
|
+
``pass`` and ``return None`` are Python, so elsewhere the line is disabled
|
|
173
|
+
with the language's own comment marker: it cannot be wrong about syntax
|
|
174
|
+
because nothing new is executed.
|
|
175
|
+
"""
|
|
176
|
+
indent = re.match(r"^(\s*)", line).group(1)
|
|
177
|
+
stripped = line.strip()
|
|
178
|
+
if not _is_python(spec):
|
|
179
|
+
return "{}{} {}() removed - security risk: {}".format(
|
|
180
|
+
indent, _comment_prefix(spec), name, stripped)
|
|
181
|
+
if re.match(r"^return\s+" + name + r"\s*\(", stripped):
|
|
182
|
+
return "{}return None # {}() removed - security risk".format(indent, name)
|
|
183
|
+
match = re.match(r"^([A-Za-z_]\w*)\s*=\s*" + name + r"\s*\(", stripped)
|
|
184
|
+
if match:
|
|
185
|
+
return "{}{} = None # {}() removed - security risk".format(
|
|
186
|
+
indent, match.group(1), name)
|
|
187
|
+
if re.match(r"^" + name + r"\s*\(", stripped):
|
|
188
|
+
return "{}pass # {}() removed - security risk".format(indent, name)
|
|
189
|
+
return "{}# {}() removed - security risk: {}".format(indent, name, stripped)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _remove_eval(line: str, finding: Finding, spec=None) -> str:
|
|
193
|
+
"""Replace eval() with an inert statement."""
|
|
194
|
+
return _remove_dangerous_call(line, "eval", spec)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _remove_exec(line: str, finding: Finding, spec=None) -> str:
|
|
198
|
+
"""Replace exec() with an inert statement."""
|
|
199
|
+
return _remove_dangerous_call(line, "exec", spec)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _replace_print_with_logging(line: str, finding: Finding, spec=None) -> str:
|
|
203
|
+
"""Replace print() with logging.info() (import is ensured later).
|
|
204
|
+
|
|
205
|
+
``logging`` is Python's module; in another language the line is disabled and
|
|
206
|
+
the developer is pointed at the language's own logger.
|
|
207
|
+
"""
|
|
208
|
+
match = re.match(r'^(\s*)print\((.*)\)', line)
|
|
209
|
+
if match and _is_python(spec):
|
|
210
|
+
indent, content = match.groups()
|
|
211
|
+
return '{}logging.info({})'.format(indent, content)
|
|
212
|
+
if match:
|
|
213
|
+
indent, content = match.groups()
|
|
214
|
+
return "{}{} print() removed - use a logging framework: {}".format(
|
|
215
|
+
indent, _comment_prefix(spec), content)
|
|
216
|
+
return line
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _remove_debugger(line: str, finding: Finding, spec=None) -> str:
|
|
220
|
+
"""Comment out a debugger statement.
|
|
221
|
+
|
|
222
|
+
Only matches `debugger;` when it is the statement itself, not when the
|
|
223
|
+
text appears inside a string or regex literal (e.g. a rule definition
|
|
224
|
+
containing the word "debugger"), which would corrupt the line.
|
|
225
|
+
"""
|
|
226
|
+
# A quoted literal before the keyword means it is data, not code.
|
|
227
|
+
stripped = line.lstrip()
|
|
228
|
+
if stripped.startswith(('"', "'", "r'", 'r"', "b'", 'b"', "f'", 'f"')):
|
|
229
|
+
return line
|
|
230
|
+
prefix = _comment_prefix(spec)
|
|
231
|
+
return re.sub(r'^(\s*)debugger;', r'\g<1>' + prefix + ' debugger removed', line)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _comment_out(line: str, finding=None, spec=None) -> str:
|
|
235
|
+
"""Comment out a line, using the language's own comment marker."""
|
|
236
|
+
if spec is None:
|
|
237
|
+
spec = _spec_for(finding)
|
|
238
|
+
return re.sub(r'^', _comment_prefix(spec) + " ", line, count=1)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _add_test_docstring(lines: list, idx: int, finding: Finding) -> str:
|
|
242
|
+
"""Add a docstring to a test function."""
|
|
243
|
+
line = lines[idx]
|
|
244
|
+
# Match def test_name(...): optionally followed by content on same line
|
|
245
|
+
match = re.match(r'^(\s*def\s+test_\w+\(.*\):)', line)
|
|
246
|
+
if match:
|
|
247
|
+
rest = line[match.end():].strip()
|
|
248
|
+
indent = " "
|
|
249
|
+
docstring = '\n{}"""Add a description of what this test validates."""'.format(indent)
|
|
250
|
+
if rest:
|
|
251
|
+
return match.group(1) + " " + rest + docstring
|
|
252
|
+
return match.group(1) + docstring
|
|
253
|
+
return line
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def apply_fixes(fixes: List[Tuple[Finding, str]], content: str) -> str:
|
|
257
|
+
"""
|
|
258
|
+
Apply multiple fixes to content.
|
|
259
|
+
|
|
260
|
+
Args:
|
|
261
|
+
fixes: List of (finding, fixed_line) tuples
|
|
262
|
+
content: Original file content
|
|
263
|
+
|
|
264
|
+
Returns:
|
|
265
|
+
Modified content
|
|
266
|
+
"""
|
|
267
|
+
lines = content.split("\n")
|
|
268
|
+
for finding, fixed_line in fixes:
|
|
269
|
+
line_idx = (finding.line or 1) - 1
|
|
270
|
+
if 0 <= line_idx < len(lines):
|
|
271
|
+
lines[line_idx] = fixed_line
|
|
272
|
+
# `import os` is Python's way of getting the lookup; adding it to a Go or
|
|
273
|
+
# JS file would be a syntax error in a language it does not belong to.
|
|
274
|
+
spec = _spec_for(fixes[0][0]) if fixes else None
|
|
275
|
+
if _is_python(spec):
|
|
276
|
+
fixed = _ensure_imports("\n".join(lines))
|
|
277
|
+
elif spec is not None and spec.name == "go":
|
|
278
|
+
# `os.Getenv` needs `import "os"`. A single import statement is legal
|
|
279
|
+
# anywhere after the package clause, so this cannot break the file.
|
|
280
|
+
fixed = _ensure_go_imports("\n".join(lines))
|
|
281
|
+
else:
|
|
282
|
+
fixed = "\n".join(lines)
|
|
283
|
+
|
|
284
|
+
# The heuristics are line-local, so a fix can still produce unparsable
|
|
285
|
+
# code (e.g. inside a multi-line expression). Never hand back a broken
|
|
286
|
+
# file: fall back to the original and let the developer fix it manually.
|
|
287
|
+
if _is_python(spec) and _is_valid_python(content) \
|
|
288
|
+
and not _is_valid_python(fixed):
|
|
289
|
+
logger.warning(
|
|
290
|
+
"Auto-fix rejected: generated code for %s would not compile",
|
|
291
|
+
", ".join(str((f.line or 0)) for f, _ in fixes),
|
|
292
|
+
)
|
|
293
|
+
return content
|
|
294
|
+
return fixed
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def _ensure_go_imports(content: str) -> str:
|
|
298
|
+
"""Add ``import "os"`` when a generated fix calls ``os.Getenv``.
|
|
299
|
+
|
|
300
|
+
Go has no implicit imports, so a fix that calls ``os.Getenv`` in a file
|
|
301
|
+
that does not already import ``os`` would not build. Any ``import`` after
|
|
302
|
+
the package clause is legal, including a second one, so this is a plain
|
|
303
|
+
insertion rather than an edit of an existing import block.
|
|
304
|
+
"""
|
|
305
|
+
if not re.search(r"\bos\.Getenv\b", content):
|
|
306
|
+
return content
|
|
307
|
+
# Either form: `import "os"`, or the spec on its own line inside `import (...)`.
|
|
308
|
+
# Importing the same package twice is a compile error in Go, so this guard
|
|
309
|
+
# has to see through the parenthesised form.
|
|
310
|
+
spec_line = r'^\s*(?:\w+\s+)?"os"\s*$'
|
|
311
|
+
if re.search(r'^\s*import\s+(?:\w+\s+)?"os"\s*$', content, re.MULTILINE) or \
|
|
312
|
+
re.search(spec_line, content, re.MULTILINE):
|
|
313
|
+
return content
|
|
314
|
+
lines = content.split("\n")
|
|
315
|
+
for i, line in enumerate(lines):
|
|
316
|
+
if re.match(r"^\s*package\s+\w+", line):
|
|
317
|
+
lines.insert(i + 1, "")
|
|
318
|
+
lines.insert(i + 2, 'import "os"')
|
|
319
|
+
return "\n".join(lines)
|
|
320
|
+
return content
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _ensure_imports(content: str) -> str:
|
|
324
|
+
"""Add imports required by generated fixes (e.g. os for os.getenv)."""
|
|
325
|
+
needs = []
|
|
326
|
+
if re.search(r"\bos\.(?:getenv|environ)\b", content) and \
|
|
327
|
+
not re.search(r"^\s*import\s+os\b", content, re.MULTILINE):
|
|
328
|
+
needs.append("import os")
|
|
329
|
+
if re.search(r"\blogging\.\w+", content) and \
|
|
330
|
+
not re.search(r"^\s*import\s+logging\b", content, re.MULTILINE):
|
|
331
|
+
needs.append("import logging")
|
|
332
|
+
if not needs:
|
|
333
|
+
return content
|
|
334
|
+
|
|
335
|
+
lines = content.split("\n")
|
|
336
|
+
insert_at = 0
|
|
337
|
+
if lines and lines[0].startswith("#!"):
|
|
338
|
+
insert_at = 1
|
|
339
|
+
if insert_at < len(lines):
|
|
340
|
+
stripped = lines[insert_at].lstrip()
|
|
341
|
+
if stripped.startswith(('"""', "'''")):
|
|
342
|
+
quote = stripped[:3]
|
|
343
|
+
if stripped.count(quote) >= 2 and len(stripped) > 3:
|
|
344
|
+
insert_at += 1
|
|
345
|
+
else:
|
|
346
|
+
for j in range(insert_at + 1, len(lines)):
|
|
347
|
+
if quote in lines[j]:
|
|
348
|
+
insert_at = j + 1
|
|
349
|
+
break
|
|
350
|
+
while insert_at < len(lines) and (
|
|
351
|
+
lines[insert_at].strip() == "" or lines[insert_at].lstrip().startswith("#")
|
|
352
|
+
):
|
|
353
|
+
insert_at += 1
|
|
354
|
+
lines[insert_at:insert_at] = needs
|
|
355
|
+
return "\n".join(lines)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def get_auto_fixable(findings: List[Finding]) -> List[Finding]:
|
|
359
|
+
"""Filter findings that can be auto-fixed."""
|
|
360
|
+
return [f for f in findings if _classify_fix(f) != "unknown"]
|
prism/cli.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
"""PRISM CLI entry point.
|
|
2
|
+
|
|
3
|
+
This module is a thin facade: it builds the argparse parser and dispatches to
|
|
4
|
+
the command handlers. The implementation is split across:
|
|
5
|
+
- cli_config.py : config, git helpers, deviation policy
|
|
6
|
+
- cli_review.py : review orchestration, report formatting, JSON output
|
|
7
|
+
- cli_commands.py : check / check-staged / check-diff, self-tests
|
|
8
|
+
- commands_common.py : helpers shared by the command handlers
|
|
9
|
+
- commands_reports.py: reports, cache, config, stats, dashboard
|
|
10
|
+
- commands_setup.py : key, init, hook, version, help, agents
|
|
11
|
+
|
|
12
|
+
Importing this module runs the @register_agent decorators for the
|
|
13
|
+
deterministic regex agents so they are available alongside the AI agents.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import sys
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
# Add parent directory to path so prism imports work when run as executable
|
|
21
|
+
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
22
|
+
|
|
23
|
+
# Load .env file if it exists (for GEMINI_API_KEY and other settings).
|
|
24
|
+
# prism/__init__.py already does this at package import; the call is kept
|
|
25
|
+
# explicit (and idempotent) so this module works even if imported oddly.
|
|
26
|
+
from prism import load_env as _load_env
|
|
27
|
+
_load_env()
|
|
28
|
+
|
|
29
|
+
# Importing these modules runs their @register_agent decorators so the
|
|
30
|
+
# deterministic regex agents are available alongside the AI agents.
|
|
31
|
+
from prism import agents_policy, agents_quality, agents_security, agents_test
|
|
32
|
+
|
|
33
|
+
_registered_agents = (agents_policy, agents_quality, agents_security,
|
|
34
|
+
agents_test)
|
|
35
|
+
|
|
36
|
+
from prism.cli_commands import (
|
|
37
|
+
_run_self_tests, _run_benchmark, _cmd_check, _cmd_check_staged,
|
|
38
|
+
_cmd_check_diff, _cmd_scan,
|
|
39
|
+
)
|
|
40
|
+
from prism.ai_providers import PROVIDERS as _PROVIDERS
|
|
41
|
+
from prism.commands_reports import (
|
|
42
|
+
_cmd_reports, _cmd_report, _cmd_delete_report, _cmd_clear_reports,
|
|
43
|
+
_cmd_dashboard, _cmd_stats, _cmd_config, _cmd_policy, _cmd_feedback,
|
|
44
|
+
_cmd_suppressed, _cmd_cache,
|
|
45
|
+
)
|
|
46
|
+
from prism.commands_setup import (
|
|
47
|
+
_cmd_key, _cmd_init, _cmd_install_hook, _cmd_uninstall_hook, _cmd_version,
|
|
48
|
+
_cmd_help, _cmd_agents, _cmd_ignore, _cmd_mode, _cmd_doctor,
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_parser():
|
|
53
|
+
"""Build the argparse parser with all subcommands."""
|
|
54
|
+
parser = argparse.ArgumentParser(
|
|
55
|
+
prog="prism", description="PRISM - Pre-Commit Code Review"
|
|
56
|
+
)
|
|
57
|
+
parser.add_argument(
|
|
58
|
+
"--effort", choices=["lite", "balanced", "max"],
|
|
59
|
+
default="balanced", help="AI review effort level",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--version", action="store_true",
|
|
63
|
+
help="Show the PRISM version and exit (same as `prism version`)",
|
|
64
|
+
)
|
|
65
|
+
sub = parser.add_subparsers(dest="command")
|
|
66
|
+
|
|
67
|
+
check = sub.add_parser("check", help="Review a file")
|
|
68
|
+
check.add_argument("file", help="File to review")
|
|
69
|
+
check.add_argument("--fix", action="store_true", help="Apply auto-fixable changes")
|
|
70
|
+
check.add_argument("--yes", action="store_true",
|
|
71
|
+
help="Apply --fix changes without the confirmation prompt")
|
|
72
|
+
check.add_argument("--json", action="store_true", help="Output results as JSON")
|
|
73
|
+
check.add_argument("--prompt", action="store_true",
|
|
74
|
+
help="Ask developer about medium/low findings")
|
|
75
|
+
check.add_argument("--effort", choices=["lite", "balanced", "max"],
|
|
76
|
+
default="balanced", help="AI review effort level")
|
|
77
|
+
check.add_argument("--fast", action="store_true",
|
|
78
|
+
help="Use two-pass summarization for large files")
|
|
79
|
+
check.add_argument("--no-dashboard", action="store_true",
|
|
80
|
+
help="Do not open the HTML dashboard after review")
|
|
81
|
+
|
|
82
|
+
check_staged = sub.add_parser("check-staged", help="Review staged git changes")
|
|
83
|
+
check_staged.add_argument("--json", action="store_true", help="Output results as JSON")
|
|
84
|
+
check_staged.add_argument("--prompt", action="store_true",
|
|
85
|
+
help="Ask developer about medium/low findings")
|
|
86
|
+
check_staged.add_argument("--effort", choices=["lite", "balanced", "max"],
|
|
87
|
+
default="balanced", help="AI review effort level")
|
|
88
|
+
check_staged.add_argument("--fast", action="store_true",
|
|
89
|
+
help="Use two-pass summarization for large files")
|
|
90
|
+
check_staged.add_argument("--dashboard", action="store_true",
|
|
91
|
+
help="Open visual dashboard after review")
|
|
92
|
+
check_staged.add_argument("--test", action="store_true",
|
|
93
|
+
help="Run pytest on changed Python files before review")
|
|
94
|
+
check_staged.add_argument("--no-dashboard", action="store_true",
|
|
95
|
+
help="Do not open the HTML dashboard after review")
|
|
96
|
+
|
|
97
|
+
check_diff = sub.add_parser(
|
|
98
|
+
"check-diff", aliases=["diff"],
|
|
99
|
+
help="Review the diff against a base branch (PR review)")
|
|
100
|
+
check_diff.add_argument("--base", default="origin/main",
|
|
101
|
+
help="Base ref to diff against (default: origin/main)")
|
|
102
|
+
check_diff.add_argument("--json", action="store_true", help="Output results as JSON")
|
|
103
|
+
check_diff.add_argument("--effort", choices=["lite", "balanced", "max"],
|
|
104
|
+
default="balanced", help="AI review effort level")
|
|
105
|
+
check_diff.add_argument("--fast", action="store_true",
|
|
106
|
+
help="Use two-pass summarization for large files")
|
|
107
|
+
check_diff.add_argument("--no-dashboard", action="store_true",
|
|
108
|
+
help="Do not open the HTML dashboard after review")
|
|
109
|
+
|
|
110
|
+
sub.add_parser("init", help="Create prism.json config")
|
|
111
|
+
sub.add_parser("install-hook", help="Install pre-commit hook")
|
|
112
|
+
sub.add_parser("policy", help="Show deviation policy")
|
|
113
|
+
sub.add_parser("feedback", help="Show feedback loop stats")
|
|
114
|
+
sub.add_parser("suppressed", help="Show historically suppressed rules")
|
|
115
|
+
sub.add_parser("reports", help="Show review history")
|
|
116
|
+
sub.add_parser("report", help="Show a specific report by ID").add_argument("id")
|
|
117
|
+
sub.add_parser("dashboard", help="Open visual report dashboard in browser")
|
|
118
|
+
sub.add_parser("doctor", help="Check environment and provider setup")
|
|
119
|
+
|
|
120
|
+
scan_cmd = sub.add_parser(
|
|
121
|
+
"scan",
|
|
122
|
+
help="Review every file under a path (whole-project audit)")
|
|
123
|
+
scan_cmd.add_argument("path", nargs="?", default=".",
|
|
124
|
+
help="Root directory to scan (default: .)")
|
|
125
|
+
scan_cmd.add_argument("--json", action="store_true",
|
|
126
|
+
help="Output results as JSON")
|
|
127
|
+
scan_cmd.add_argument("--effort", choices=["lite", "balanced", "max"],
|
|
128
|
+
default="balanced", help="AI review effort level")
|
|
129
|
+
scan_cmd.add_argument("--fast", action="store_true",
|
|
130
|
+
help="Use two-pass summarization for large files")
|
|
131
|
+
scan_cmd.add_argument("--no-dashboard", action="store_true",
|
|
132
|
+
help="Do not open the HTML dashboard after review")
|
|
133
|
+
|
|
134
|
+
delete_cmd = sub.add_parser("delete-report", help="Delete a specific report")
|
|
135
|
+
delete_cmd.add_argument("id", help="Report ID to delete")
|
|
136
|
+
clear_cmd = sub.add_parser("clear-reports", help="Delete ALL reports")
|
|
137
|
+
clear_cmd.add_argument("--force", action="store_true", help="Skip confirmation")
|
|
138
|
+
|
|
139
|
+
agents_cmd = sub.add_parser("agents", help="List available agents")
|
|
140
|
+
agents_cmd.add_argument("--enable", nargs="+", metavar="NAME",
|
|
141
|
+
help="Enable one or more agents")
|
|
142
|
+
agents_cmd.add_argument("--disable", nargs="+", metavar="NAME",
|
|
143
|
+
help="Disable one or more agents")
|
|
144
|
+
|
|
145
|
+
sub.add_parser("help", help="Show all commands and their uses")
|
|
146
|
+
sub.add_parser("version", help="Show PRISM version")
|
|
147
|
+
sub.add_parser("uninstall-hook", help="Remove pre-commit hook")
|
|
148
|
+
sub.add_parser("stats", help="Show review statistics")
|
|
149
|
+
|
|
150
|
+
ignore_cmd = sub.add_parser(
|
|
151
|
+
"ignore",
|
|
152
|
+
help="Manage files skipped by pre-commit review (.prismignore)")
|
|
153
|
+
ignore_cmd.add_argument("action",
|
|
154
|
+
choices=["list", "add", "remove", "reset"],
|
|
155
|
+
nargs="?", default="list",
|
|
156
|
+
help="What to do (default: list)")
|
|
157
|
+
ignore_cmd.add_argument("patterns", nargs="*",
|
|
158
|
+
help="Pattern(s) to add or remove")
|
|
159
|
+
|
|
160
|
+
mode_cmd = sub.add_parser(
|
|
161
|
+
"mode",
|
|
162
|
+
help="Show or switch the agent mode (all / regex / none)")
|
|
163
|
+
mode_cmd.add_argument("mode", nargs="?",
|
|
164
|
+
choices=["all", "regex", "none"],
|
|
165
|
+
help="Mode to switch to (omit to show the current one)")
|
|
166
|
+
|
|
167
|
+
config_cmd = sub.add_parser("config", help="Show or manage configuration")
|
|
168
|
+
config_cmd.add_argument("--set", nargs=2, metavar=("KEY", "VALUE"),
|
|
169
|
+
help="Set a config value")
|
|
170
|
+
config_cmd.add_argument("--get", metavar="KEY", help="Get a config value")
|
|
171
|
+
|
|
172
|
+
test_cmd = sub.add_parser("test", help="Run self-tests")
|
|
173
|
+
test_cmd.add_argument("--quick", action="store_true",
|
|
174
|
+
help="Run only quick tests (skip API calls)")
|
|
175
|
+
|
|
176
|
+
benchmark_cmd = sub.add_parser("benchmark", help="Benchmark agent performance")
|
|
177
|
+
benchmark_cmd.add_argument("--file", help="File to benchmark against")
|
|
178
|
+
benchmark_cmd.add_argument("--iterations", type=int, default=3,
|
|
179
|
+
help="Number of iterations")
|
|
180
|
+
|
|
181
|
+
cache_cmd = sub.add_parser("cache", help="Manage the codebase cache")
|
|
182
|
+
cache_cmd.add_argument("action", choices=["stats", "clear", "status"],
|
|
183
|
+
help="Cache action to perform")
|
|
184
|
+
|
|
185
|
+
key_cmd = sub.add_parser("key", help="Manage API keys")
|
|
186
|
+
key_cmd.add_argument("action", choices=["set", "delete", "status"],
|
|
187
|
+
help="Key action to perform")
|
|
188
|
+
key_cmd.add_argument("--provider", choices=list(_PROVIDERS),
|
|
189
|
+
default=None,
|
|
190
|
+
help="AI provider (default: gemini for set)")
|
|
191
|
+
key_cmd.add_argument("--key", help="API key value (required for set)")
|
|
192
|
+
key_cmd.add_argument("--global", dest="global_", action="store_true",
|
|
193
|
+
help="Write the key to the install folder's .env "
|
|
194
|
+
"(tool-wide, visible from every project) "
|
|
195
|
+
"instead of the current project")
|
|
196
|
+
|
|
197
|
+
return parser
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_COMMANDS = {
|
|
201
|
+
"agents": _cmd_agents,
|
|
202
|
+
"help": _cmd_help,
|
|
203
|
+
"version": _cmd_version,
|
|
204
|
+
"uninstall-hook": _cmd_uninstall_hook,
|
|
205
|
+
"stats": _cmd_stats,
|
|
206
|
+
"config": _cmd_config,
|
|
207
|
+
"init": _cmd_init,
|
|
208
|
+
"install-hook": _cmd_install_hook,
|
|
209
|
+
"policy": _cmd_policy,
|
|
210
|
+
"feedback": _cmd_feedback,
|
|
211
|
+
"suppressed": _cmd_suppressed,
|
|
212
|
+
"reports": _cmd_reports,
|
|
213
|
+
"dashboard": _cmd_dashboard,
|
|
214
|
+
"report": _cmd_report,
|
|
215
|
+
"delete-report": _cmd_delete_report,
|
|
216
|
+
"clear-reports": _cmd_clear_reports,
|
|
217
|
+
"cache": _cmd_cache,
|
|
218
|
+
"check": _cmd_check,
|
|
219
|
+
"check-staged": _cmd_check_staged,
|
|
220
|
+
"check-diff": _cmd_check_diff,
|
|
221
|
+
"diff": _cmd_check_diff, # documented alias for check-diff
|
|
222
|
+
"scan": _cmd_scan,
|
|
223
|
+
"doctor": _cmd_doctor,
|
|
224
|
+
"test": _run_self_tests,
|
|
225
|
+
"benchmark": _run_benchmark,
|
|
226
|
+
"key": _cmd_key,
|
|
227
|
+
"ignore": _cmd_ignore,
|
|
228
|
+
"mode": _cmd_mode,
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main():
|
|
233
|
+
parser = build_parser()
|
|
234
|
+
args = parser.parse_args()
|
|
235
|
+
|
|
236
|
+
if getattr(args, "version", False):
|
|
237
|
+
_cmd_version(args)
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
handler = _COMMANDS.get(args.command)
|
|
241
|
+
if handler is None:
|
|
242
|
+
parser.print_help()
|
|
243
|
+
return
|
|
244
|
+
handler(args)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
if __name__ == "__main__":
|
|
248
|
+
main()
|