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/cli_commands.py
ADDED
|
@@ -0,0 +1,711 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import concurrent.futures
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from prism import token_usage
|
|
10
|
+
from prism.agents import run_pipeline, quality_gates_met
|
|
11
|
+
from prism.ai_common import build_skeleton
|
|
12
|
+
from prism.feedback import record_commit, record_fix
|
|
13
|
+
from prism.autofix import generate_fix, get_auto_fixable
|
|
14
|
+
from prism.logging_config import logger
|
|
15
|
+
from prism.reports import save_report
|
|
16
|
+
from prism.codebase_cache import (
|
|
17
|
+
get_cached_findings, get_changed_files, cache_findings,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from prism.cli_config import load_config, get_staged_files, run_changed_tests, get_file_diff, get_diff_files, get_diff_for_file
|
|
21
|
+
from prism.ignore import filter_files
|
|
22
|
+
from prism.cli_review import review_file, format_report, json_output
|
|
23
|
+
|
|
24
|
+
from prism.commands_common import (
|
|
25
|
+
_git_commit_info, _agent_table, _set_agent_enabled, _save_config,
|
|
26
|
+
_apply_fixes_with_preview, _cached_risk_score, _findings_to_agent_results,
|
|
27
|
+
_empty_staged_output, _finalize_review, _review_one, _finding_to_dict,
|
|
28
|
+
_dashboard_enabled,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
# The command handlers live in the sibling modules, but this module is the
|
|
32
|
+
# documented import location for them (``from prism.cli_commands import
|
|
33
|
+
# _cmd_check``), so they are re-exported here rather than at every call site.
|
|
34
|
+
from prism.commands_reports import (
|
|
35
|
+
_cmd_reports, _cmd_report, _cmd_delete_report, _cmd_clear_reports,
|
|
36
|
+
_cmd_dashboard, _cmd_stats, _cmd_config, _cmd_policy, _cmd_feedback,
|
|
37
|
+
_cmd_suppressed, _cmd_cache,
|
|
38
|
+
)
|
|
39
|
+
from prism.commands_setup import (
|
|
40
|
+
_cmd_key, _is_placeholder_value, _get_active_provider, _cmd_init,
|
|
41
|
+
_cmd_install_hook, _cmd_uninstall_hook, _cmd_version, _cmd_help,
|
|
42
|
+
_cmd_agents, _cmd_ignore, _cmd_mode, _cmd_doctor,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"_run_self_tests", "_run_benchmark", "_cmd_check", "_cmd_check_staged",
|
|
47
|
+
"_cmd_check_diff", "_cmd_scan", "_cmd_reports", "_cmd_report",
|
|
48
|
+
"_cmd_delete_report",
|
|
49
|
+
"_cmd_clear_reports", "_cmd_dashboard", "_cmd_stats", "_cmd_config",
|
|
50
|
+
"_cmd_policy", "_cmd_feedback", "_cmd_suppressed", "_cmd_cache", "_cmd_key",
|
|
51
|
+
"_is_placeholder_value", "_get_active_provider", "_cmd_init",
|
|
52
|
+
"_cmd_install_hook", "_cmd_uninstall_hook", "_cmd_version", "_cmd_help",
|
|
53
|
+
"_cmd_agents", "_cmd_ignore", "_cmd_mode", "_cmd_doctor",
|
|
54
|
+
"_git_commit_info", "_agent_table", "_set_agent_enabled", "_save_config",
|
|
55
|
+
"_apply_fixes_with_preview",
|
|
56
|
+
"_cached_risk_score", "_findings_to_agent_results", "_empty_staged_output",
|
|
57
|
+
"_finalize_review", "_review_one",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _run_self_tests(args):
|
|
62
|
+
"""Run self-tests to verify the system works correctly."""
|
|
63
|
+
import pathlib
|
|
64
|
+
|
|
65
|
+
quick = getattr(args, 'quick', False)
|
|
66
|
+
print("PRISM Self-Tests")
|
|
67
|
+
print("=" * 50)
|
|
68
|
+
passed = 0
|
|
69
|
+
failed = 0
|
|
70
|
+
|
|
71
|
+
def check(name, condition):
|
|
72
|
+
nonlocal passed, failed
|
|
73
|
+
if condition:
|
|
74
|
+
print(" [PASS] {}".format(name))
|
|
75
|
+
passed += 1
|
|
76
|
+
else:
|
|
77
|
+
print(" [FAIL] {}".format(name))
|
|
78
|
+
failed += 1
|
|
79
|
+
|
|
80
|
+
try:
|
|
81
|
+
from prism.ai_common import (
|
|
82
|
+
build_skeleton, chunk_content, get_tier_for_file,
|
|
83
|
+
verify_findings, build_import_graph,
|
|
84
|
+
get_cross_file_context,
|
|
85
|
+
)
|
|
86
|
+
check("imports", True)
|
|
87
|
+
except Exception as e:
|
|
88
|
+
check("imports: {}".format(e), False)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
sk = build_skeleton("test.py", "import os\nclass Foo:\n def bar(self): pass")
|
|
92
|
+
check("skeleton builder", "File: test.py" in sk and "Class Foo" in sk)
|
|
93
|
+
check("non-python skeleton", build_skeleton("test.md", "# Hello") == "")
|
|
94
|
+
|
|
95
|
+
content = "\n".join(["line {}".format(i) for i in range(1, 501)])
|
|
96
|
+
chunks = chunk_content(content, max_lines=400, overlap=20)
|
|
97
|
+
check("chunking 500 lines -> 2 chunks", len(chunks) == 2)
|
|
98
|
+
|
|
99
|
+
check("tier 100 lines", get_tier_for_file(100)[0] == 1.0)
|
|
100
|
+
check("tier 500 lines", get_tier_for_file(500)[0] == 1.5)
|
|
101
|
+
check("tier 5000 lines", get_tier_for_file(5000)[0] == 2.0)
|
|
102
|
+
check("tier 15000 lines", get_tier_for_file(15000)[0] == 3.0)
|
|
103
|
+
|
|
104
|
+
from prism.agents import Finding, Severity, Blocker
|
|
105
|
+
findings = [Finding(
|
|
106
|
+
agent="test", severity=Severity.HIGH, category="C",
|
|
107
|
+
message="test", file="test.py", line=999,
|
|
108
|
+
suggestion="fix", blocker=Blocker.BLOCKS, confidence=0.85,
|
|
109
|
+
rule_id="TEST-AI-001"
|
|
110
|
+
)]
|
|
111
|
+
result = verify_findings(findings, "test.py", "x = 1\n")
|
|
112
|
+
check("verify drops out-of-bounds", len(result) == 0)
|
|
113
|
+
|
|
114
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
115
|
+
p = pathlib.Path(tmp)
|
|
116
|
+
(p / "a.py").write_text("import b\ndef foo(): pass\n")
|
|
117
|
+
(p / "b.py").write_text("def bar(): pass\n")
|
|
118
|
+
graph = build_import_graph(tmp)
|
|
119
|
+
check("import graph has 2 files", len(graph) == 2)
|
|
120
|
+
ctx = get_cross_file_context(str(p / "a.py"), graph, tmp)
|
|
121
|
+
check("cross-file context", "imports" in ctx.lower() or "import" in ctx.lower())
|
|
122
|
+
|
|
123
|
+
from prism.agents import AGENT_REGISTRY
|
|
124
|
+
import importlib
|
|
125
|
+
for mod_name in [
|
|
126
|
+
"prism.ai_security", "prism.ai_quality", "prism.ai_policy",
|
|
127
|
+
"prism.ai_test", "prism.ai_architecture", "prism.ai_performance",
|
|
128
|
+
"prism.ai_providers", "prism.ai_findings", "prism.ai_graph",
|
|
129
|
+
"prism.ai_pipeline",
|
|
130
|
+
"prism.agents_security", "prism.agents_quality",
|
|
131
|
+
"prism.agents_policy", "prism.agents_test",
|
|
132
|
+
]:
|
|
133
|
+
importlib.import_module(mod_name)
|
|
134
|
+
check("10+ agents registered", len(AGENT_REGISTRY) >= 10)
|
|
135
|
+
|
|
136
|
+
config = load_config()
|
|
137
|
+
check("config has agents", "agents" in config)
|
|
138
|
+
|
|
139
|
+
if not quick:
|
|
140
|
+
try:
|
|
141
|
+
code = "x = 1\ny = 2\n"
|
|
142
|
+
with tempfile.NamedTemporaryFile(suffix=".py", mode="w", delete=False) as f:
|
|
143
|
+
f.write(code)
|
|
144
|
+
path = f.name
|
|
145
|
+
sk = build_skeleton(path, code)
|
|
146
|
+
results = run_pipeline(["security", "quality"], path, code, code, sk)
|
|
147
|
+
check("pipeline runs", len(results) == 2)
|
|
148
|
+
os.unlink(path)
|
|
149
|
+
except Exception as e:
|
|
150
|
+
check("pipeline: {}".format(e), False)
|
|
151
|
+
|
|
152
|
+
print()
|
|
153
|
+
print("Results: {} passed, {} failed".format(passed, failed))
|
|
154
|
+
if failed > 0:
|
|
155
|
+
sys.exit(1)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _run_benchmark(args):
|
|
159
|
+
"""Benchmark agent performance on a file."""
|
|
160
|
+
import time
|
|
161
|
+
|
|
162
|
+
file_path = getattr(args, 'file', None)
|
|
163
|
+
iterations = getattr(args, 'iterations', 3)
|
|
164
|
+
|
|
165
|
+
if not file_path:
|
|
166
|
+
print("Please specify a file: prism benchmark --file <path>")
|
|
167
|
+
return
|
|
168
|
+
|
|
169
|
+
path = Path(file_path)
|
|
170
|
+
if not path.exists():
|
|
171
|
+
print("File not found: {}".format(file_path))
|
|
172
|
+
return
|
|
173
|
+
|
|
174
|
+
content = path.read_text(errors="ignore")
|
|
175
|
+
sk = build_skeleton(file_path, content)
|
|
176
|
+
|
|
177
|
+
print("PRISM Benchmark: {}".format(file_path))
|
|
178
|
+
print("=" * 50)
|
|
179
|
+
print("File size: {} lines, {} chars".format(content.count("\n") + 1, len(content)))
|
|
180
|
+
print("Iterations: {}".format(iterations))
|
|
181
|
+
print()
|
|
182
|
+
|
|
183
|
+
agents = ["security", "quality", "policy", "test"]
|
|
184
|
+
|
|
185
|
+
for agent_name in agents:
|
|
186
|
+
times = []
|
|
187
|
+
for i in range(iterations):
|
|
188
|
+
start = time.time()
|
|
189
|
+
results = run_pipeline([agent_name], file_path, content, content, sk)
|
|
190
|
+
elapsed = (time.time() - start) * 1000
|
|
191
|
+
times.append(elapsed)
|
|
192
|
+
findings = len(results[0].findings) if results else 0
|
|
193
|
+
print(" {} iter {}: {:.0f}ms, {} findings".format(
|
|
194
|
+
agent_name, i + 1, elapsed, findings))
|
|
195
|
+
|
|
196
|
+
avg = sum(times) / len(times)
|
|
197
|
+
print(" {} average: {:.0f}ms".format(agent_name, avg))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _collect_fixes(file_path, findings):
|
|
201
|
+
"""Return (path, content, fixes) for findings the autofix engine can apply."""
|
|
202
|
+
path = Path(file_path)
|
|
203
|
+
try:
|
|
204
|
+
content = path.read_text(errors="ignore")
|
|
205
|
+
except OSError:
|
|
206
|
+
return path, "", []
|
|
207
|
+
fixes = []
|
|
208
|
+
for f in findings:
|
|
209
|
+
fixed = generate_fix(f, content)
|
|
210
|
+
if fixed is not None:
|
|
211
|
+
fixes.append((f, fixed))
|
|
212
|
+
return path, content, fixes
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _check_prompt_fixes(args, results):
|
|
216
|
+
"""Offer auto-fixes for medium/low findings (prism check --prompt)."""
|
|
217
|
+
all_findings = [f for r in results for f in r.findings]
|
|
218
|
+
non_blocking = [f for f in all_findings
|
|
219
|
+
if f.severity.value not in ("critical", "high")]
|
|
220
|
+
if not non_blocking:
|
|
221
|
+
return
|
|
222
|
+
|
|
223
|
+
print("\n" + "=" * 50)
|
|
224
|
+
print("Medium/Low findings detected:")
|
|
225
|
+
print("=" * 50)
|
|
226
|
+
for i, f in enumerate(non_blocking):
|
|
227
|
+
print(" [{}] {} {}".format(i + 1, f.severity.value.upper(), f.category))
|
|
228
|
+
print(" Problem: {}".format(f.message))
|
|
229
|
+
if f.suggestion:
|
|
230
|
+
print(" Suggestion: {}".format(f.suggestion))
|
|
231
|
+
if f.suggested_code:
|
|
232
|
+
print(" Code fix:")
|
|
233
|
+
for code_line in f.suggested_code.split("\n"):
|
|
234
|
+
print(" {}".format(code_line))
|
|
235
|
+
print(" File: {}:{}".format(f.file, f.line))
|
|
236
|
+
print()
|
|
237
|
+
|
|
238
|
+
# One-line prompt: the old multi-line menu was printed even when stdin was
|
|
239
|
+
# closed, then abandoned at the EOF.
|
|
240
|
+
try:
|
|
241
|
+
choice = input(
|
|
242
|
+
"What would you like to do? "
|
|
243
|
+
"(a)pply auto-fixes / (s)kip / (c)ancel [s]: "
|
|
244
|
+
).strip().lower()
|
|
245
|
+
except EOFError:
|
|
246
|
+
print("(non-interactive mode - skipping prompts)")
|
|
247
|
+
choice = "s"
|
|
248
|
+
|
|
249
|
+
if choice == "a":
|
|
250
|
+
auto_fixable = get_auto_fixable(non_blocking)
|
|
251
|
+
path, content, fixes = _collect_fixes(args.file, auto_fixable)
|
|
252
|
+
if not fixes:
|
|
253
|
+
print("No auto-fixable findings.")
|
|
254
|
+
return
|
|
255
|
+
if _apply_fixes_with_preview(path, content, fixes, assume_yes=True):
|
|
256
|
+
for fixed_finding, _ in fixes:
|
|
257
|
+
record_fix(fixed_finding.rule_id or "", True)
|
|
258
|
+
print("Applied {} auto-fix(es).".format(len(fixes)))
|
|
259
|
+
else:
|
|
260
|
+
print("No auto-fixable changes written.")
|
|
261
|
+
elif choice == "c":
|
|
262
|
+
print("Commit cancelled.")
|
|
263
|
+
sys.exit(1)
|
|
264
|
+
else:
|
|
265
|
+
print("Skipping. Commit will proceed as-is.")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _check_direct_fixes(args, results):
|
|
269
|
+
"""Apply auto-fixes for ``prism check --fix`` (no prompt)."""
|
|
270
|
+
auto_fixable = get_auto_fixable([f for r in results for f in r.findings])
|
|
271
|
+
path, content, fixes = _collect_fixes(args.file, auto_fixable)
|
|
272
|
+
if not fixes:
|
|
273
|
+
if not args.json:
|
|
274
|
+
print("")
|
|
275
|
+
print("No auto-fixable findings.")
|
|
276
|
+
return
|
|
277
|
+
applied = _apply_fixes_with_preview(
|
|
278
|
+
path, content, fixes,
|
|
279
|
+
assume_yes=getattr(args, "yes", False),
|
|
280
|
+
json_mode=bool(getattr(args, "json", False)),
|
|
281
|
+
)
|
|
282
|
+
if applied:
|
|
283
|
+
for fixed_finding, _ in fixes:
|
|
284
|
+
record_fix(fixed_finding.rule_id or "", True)
|
|
285
|
+
if not args.json:
|
|
286
|
+
print("Applied {} auto-fix(es) to {}".format(len(fixes), args.file))
|
|
287
|
+
print("Review the changes before committing.")
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _save_check_report(args, results, config, score, all_findings_list):
|
|
291
|
+
"""Persist the single-file report and exit with the commit decision."""
|
|
292
|
+
_, blocking = format_report(results, config, {args.file: score})
|
|
293
|
+
gates_ok = quality_gates_met(results, config)
|
|
294
|
+
allowed = blocking == 0 and gates_ok
|
|
295
|
+
commit_hash, branch = _git_commit_info()
|
|
296
|
+
report_id = save_report(
|
|
297
|
+
files_reviewed=[args.file],
|
|
298
|
+
findings=all_findings_list,
|
|
299
|
+
risk_score=score,
|
|
300
|
+
blocking=blocking,
|
|
301
|
+
commit_allowed=allowed,
|
|
302
|
+
commit_hash=commit_hash,
|
|
303
|
+
branch=branch,
|
|
304
|
+
# Keep the persisted report in step with the --json output and the
|
|
305
|
+
# dashboard, which all read this same ledger snapshot.
|
|
306
|
+
token_usage=token_usage.summary(),
|
|
307
|
+
)
|
|
308
|
+
record_commit(all_findings_list, commit_hash)
|
|
309
|
+
if not args.json:
|
|
310
|
+
print("")
|
|
311
|
+
print(token_usage.format_summary())
|
|
312
|
+
print("Report saved: .prism-reports/{}.json".format(report_id))
|
|
313
|
+
# Consistent with check-staged/check-diff: open the dashboard unless
|
|
314
|
+
# the developer opted out (or we are in CI / non-interactive).
|
|
315
|
+
if _dashboard_enabled(args):
|
|
316
|
+
from prism.commands_reports import build_dashboard
|
|
317
|
+
build_dashboard(open_browser=True)
|
|
318
|
+
sys.exit(0 if allowed else 1)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _cmd_check(args):
|
|
322
|
+
if not Path(args.file).exists():
|
|
323
|
+
message = "File not found: {}".format(args.file)
|
|
324
|
+
# A JSON consumer (CI, IDE) must never receive prose: emit a valid
|
|
325
|
+
# envelope and keep the non-zero exit code for the error itself.
|
|
326
|
+
if getattr(args, "json", False):
|
|
327
|
+
print(json.dumps({
|
|
328
|
+
"file": args.file,
|
|
329
|
+
"error": message,
|
|
330
|
+
"total_findings": 0,
|
|
331
|
+
"blocking": 0,
|
|
332
|
+
"quality_gates_passed": True,
|
|
333
|
+
"commit_allowed": True,
|
|
334
|
+
"findings": [],
|
|
335
|
+
}, indent=2))
|
|
336
|
+
else:
|
|
337
|
+
print(message, file=sys.stderr)
|
|
338
|
+
sys.exit(2)
|
|
339
|
+
|
|
340
|
+
config = load_config()
|
|
341
|
+
token_usage.reset()
|
|
342
|
+
results, score = review_file(args.file, config, args.effort, args.fast)
|
|
343
|
+
try:
|
|
344
|
+
content = Path(args.file).read_text(errors="ignore")
|
|
345
|
+
except OSError:
|
|
346
|
+
content = ""
|
|
347
|
+
all_findings_list = [
|
|
348
|
+
_finding_to_dict(f, content)
|
|
349
|
+
for r in results
|
|
350
|
+
for f in r.findings
|
|
351
|
+
]
|
|
352
|
+
|
|
353
|
+
if args.json:
|
|
354
|
+
output = json_output(results, config, {args.file: score}, args.file)
|
|
355
|
+
output["token_usage"] = token_usage.summary()
|
|
356
|
+
print(json.dumps(output, indent=2, default=str))
|
|
357
|
+
else:
|
|
358
|
+
report, blocking = format_report(results, config, {args.file: score})
|
|
359
|
+
print(report)
|
|
360
|
+
|
|
361
|
+
if args.prompt and blocking == 0:
|
|
362
|
+
if not sys.stdin.isatty():
|
|
363
|
+
print("(non-interactive mode - skipping prompts)")
|
|
364
|
+
else:
|
|
365
|
+
_check_prompt_fixes(args, results)
|
|
366
|
+
|
|
367
|
+
if args.fix:
|
|
368
|
+
_check_direct_fixes(args, results)
|
|
369
|
+
|
|
370
|
+
_save_check_report(args, results, config, score, all_findings_list)
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _prompt_staged_fixes(all_results, config):
|
|
374
|
+
"""Offer to apply auto-fixes for medium/low findings before finalizing.
|
|
375
|
+
|
|
376
|
+
Mirrors ``prism check --prompt`` for a multi-file staged review. It only
|
|
377
|
+
runs when there are no blocking findings (a blocked commit should be fixed
|
|
378
|
+
by the developer, not auto-patched) and stdin is a terminal.
|
|
379
|
+
"""
|
|
380
|
+
findings = [f for r in all_results for f in r.findings]
|
|
381
|
+
blocking_sev = set(config.get("block_on", ["critical", "high"]))
|
|
382
|
+
if any(f.blocker.value == "blocks" and f.severity.value in blocking_sev
|
|
383
|
+
for f in findings):
|
|
384
|
+
return
|
|
385
|
+
|
|
386
|
+
non_blocking = [f for f in findings
|
|
387
|
+
if f.severity.value not in ("critical", "high")]
|
|
388
|
+
auto_fixable = get_auto_fixable(non_blocking)
|
|
389
|
+
if not auto_fixable:
|
|
390
|
+
return
|
|
391
|
+
if not sys.stdin.isatty():
|
|
392
|
+
print("(non-interactive mode - skipping prompts)")
|
|
393
|
+
return
|
|
394
|
+
|
|
395
|
+
by_file = {}
|
|
396
|
+
for f in auto_fixable:
|
|
397
|
+
by_file.setdefault(f.file, []).append(f)
|
|
398
|
+
|
|
399
|
+
print("")
|
|
400
|
+
print("=" * 50)
|
|
401
|
+
print("Auto-fixable medium/low findings:")
|
|
402
|
+
print("=" * 50)
|
|
403
|
+
for f in auto_fixable:
|
|
404
|
+
print(" [{}] {} {}:{}".format(
|
|
405
|
+
f.severity.value.upper(), f.rule_id, f.file, f.line))
|
|
406
|
+
print(" {}".format(f.message))
|
|
407
|
+
if f.suggested_code:
|
|
408
|
+
for code_line in f.suggested_code.split("\n"):
|
|
409
|
+
print(" {}".format(code_line))
|
|
410
|
+
|
|
411
|
+
try:
|
|
412
|
+
choice = input(
|
|
413
|
+
"Apply {} auto-fix(es)? (y/N) ".format(len(auto_fixable))
|
|
414
|
+
).strip().lower()
|
|
415
|
+
except EOFError:
|
|
416
|
+
print("(non-interactive mode - skipping prompts)")
|
|
417
|
+
return
|
|
418
|
+
if choice not in ("y", "yes"):
|
|
419
|
+
print("Skipping. Commit will proceed as-is.")
|
|
420
|
+
return
|
|
421
|
+
|
|
422
|
+
applied = 0
|
|
423
|
+
fixed_paths = []
|
|
424
|
+
for path_str, fs in by_file.items():
|
|
425
|
+
path = Path(path_str)
|
|
426
|
+
if not path.exists():
|
|
427
|
+
continue
|
|
428
|
+
content = path.read_text(errors="ignore")
|
|
429
|
+
fixes = []
|
|
430
|
+
for f in fs:
|
|
431
|
+
fixed = generate_fix(f, content)
|
|
432
|
+
if fixed is not None:
|
|
433
|
+
fixes.append((f, fixed))
|
|
434
|
+
if not fixes:
|
|
435
|
+
continue
|
|
436
|
+
if _apply_fixes_with_preview(path, content, fixes, assume_yes=True):
|
|
437
|
+
for fixed_finding, _ in fixes:
|
|
438
|
+
record_fix(fixed_finding.rule_id or "", True)
|
|
439
|
+
applied += len(fixes)
|
|
440
|
+
fixed_paths.append(str(path))
|
|
441
|
+
|
|
442
|
+
if applied:
|
|
443
|
+
print("Applied {} auto-fix(es).".format(applied))
|
|
444
|
+
# The fix rewrote the working tree but the index still holds the old
|
|
445
|
+
# content, so the commit would ship the unfixed version. Re-stage.
|
|
446
|
+
if _restage(fixed_paths):
|
|
447
|
+
print("Re-staged {} file(s) for commit.".format(len(fixed_paths)))
|
|
448
|
+
else:
|
|
449
|
+
print("Could not re-stage; run `git add` before committing.")
|
|
450
|
+
else:
|
|
451
|
+
print("No auto-fixable changes written.")
|
|
452
|
+
return fixed_paths
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def _restage(paths):
|
|
456
|
+
"""``git add`` the given files so a commit includes the applied fixes."""
|
|
457
|
+
try:
|
|
458
|
+
proc = subprocess.run(
|
|
459
|
+
["git", "add", "--"] + list(paths),
|
|
460
|
+
capture_output=True, text=True)
|
|
461
|
+
return proc.returncode == 0
|
|
462
|
+
except Exception:
|
|
463
|
+
return False
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _review_and_store(f, per_file, cache, config, args, use_diff=True):
|
|
467
|
+
"""Review one staged file and store its result for the final report."""
|
|
468
|
+
diff = get_file_diff(f) if use_diff else ""
|
|
469
|
+
results, score, file_findings = _review_one(
|
|
470
|
+
f, config, args.effort, args.fast, diff)
|
|
471
|
+
per_file[f] = {
|
|
472
|
+
"findings": list(file_findings),
|
|
473
|
+
"results": results,
|
|
474
|
+
"score": score,
|
|
475
|
+
}
|
|
476
|
+
cache_findings(f, file_findings, cache)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def _review_staged_parallel(files_to_review, per_file, cache, config, args,
|
|
480
|
+
status, use_diff=True):
|
|
481
|
+
"""Review files concurrently (max 5 workers) and record progress."""
|
|
482
|
+
if not files_to_review:
|
|
483
|
+
return
|
|
484
|
+
max_workers = min(5, len(files_to_review))
|
|
485
|
+
with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
486
|
+
futures = {
|
|
487
|
+
executor.submit(_review_and_store, f, per_file, cache, config,
|
|
488
|
+
args, use_diff): f
|
|
489
|
+
for f in files_to_review
|
|
490
|
+
}
|
|
491
|
+
for future in concurrent.futures.as_completed(futures):
|
|
492
|
+
f = futures[future]
|
|
493
|
+
try:
|
|
494
|
+
future.result()
|
|
495
|
+
status(" [scanned] {}".format(f))
|
|
496
|
+
except Exception as exc:
|
|
497
|
+
logger.error("Failed to review %s: %s", f, exc)
|
|
498
|
+
status(" [error] {}: {}".format(f, exc))
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _cmd_check_staged(args):
|
|
502
|
+
json_mode = bool(getattr(args, "json", False))
|
|
503
|
+
token_usage.reset()
|
|
504
|
+
|
|
505
|
+
def _status(msg):
|
|
506
|
+
print(msg, file=sys.stderr if json_mode else sys.stdout)
|
|
507
|
+
|
|
508
|
+
config = load_config()
|
|
509
|
+
staged = get_staged_files(apply_ignore=False)
|
|
510
|
+
files, skipped = filter_files(staged)
|
|
511
|
+
if skipped:
|
|
512
|
+
_status("Skipped (ignored): {}".format(", ".join(skipped)))
|
|
513
|
+
if not files:
|
|
514
|
+
if json_mode:
|
|
515
|
+
print(json.dumps(_empty_staged_output(files, skipped), indent=2))
|
|
516
|
+
else:
|
|
517
|
+
_status("No staged files to review.")
|
|
518
|
+
sys.exit(0)
|
|
519
|
+
|
|
520
|
+
# Test execution (fast-path: changed files only)
|
|
521
|
+
test_results = None
|
|
522
|
+
if getattr(args, "test", False):
|
|
523
|
+
_status("Running tests on changed Python files...")
|
|
524
|
+
test_results = run_changed_tests(files)
|
|
525
|
+
if test_results.get("failed", 0) > 0:
|
|
526
|
+
_status(" [FAIL] {} test(s) failed".format(test_results["failed"]))
|
|
527
|
+
elif test_results.get("passed", 0) > 0:
|
|
528
|
+
_status(" [PASS] {} test(s) passed".format(test_results["passed"]))
|
|
529
|
+
else:
|
|
530
|
+
_status(" [INFO] {}".format(test_results.get("output", "no output")))
|
|
531
|
+
|
|
532
|
+
# Use the codebase cache to skip re-scanning files that did not change
|
|
533
|
+
try:
|
|
534
|
+
_changed, unchanged, cache = get_changed_files()
|
|
535
|
+
except Exception:
|
|
536
|
+
unchanged, cache = [], {}
|
|
537
|
+
|
|
538
|
+
# Track results per file so a post-fix re-review can replace just that
|
|
539
|
+
# file's findings before the report is written.
|
|
540
|
+
per_file = {}
|
|
541
|
+
files_to_review = []
|
|
542
|
+
for f in files:
|
|
543
|
+
cached = get_cached_findings(f, cache)
|
|
544
|
+
# An empty list is a valid cache entry (file reviewed, no findings),
|
|
545
|
+
# so test for None rather than truthiness.
|
|
546
|
+
if cached is not None and f in unchanged:
|
|
547
|
+
per_file[f] = {
|
|
548
|
+
"findings": list(cached),
|
|
549
|
+
"results": _findings_to_agent_results(cached),
|
|
550
|
+
"score": _cached_risk_score(f, cached),
|
|
551
|
+
}
|
|
552
|
+
_status(" [cached] {}".format(f))
|
|
553
|
+
else:
|
|
554
|
+
files_to_review.append(f)
|
|
555
|
+
|
|
556
|
+
_review_staged_parallel(files_to_review, per_file, cache, config, args,
|
|
557
|
+
_status)
|
|
558
|
+
|
|
559
|
+
if getattr(args, "prompt", False) and not json_mode:
|
|
560
|
+
fixed = _prompt_staged_fixes(
|
|
561
|
+
[r for entry in per_file.values() for r in entry["results"]], config)
|
|
562
|
+
# Re-review the fixed files so the report reflects post-fix state.
|
|
563
|
+
for f in fixed or []:
|
|
564
|
+
try:
|
|
565
|
+
_review_and_store(f, per_file, cache, config, args)
|
|
566
|
+
_status(" [re-reviewed] {}".format(f))
|
|
567
|
+
except Exception as exc:
|
|
568
|
+
logger.error("Failed to re-review %s: %s", f, exc)
|
|
569
|
+
|
|
570
|
+
all_results = [r for entry in per_file.values() for r in entry["results"]]
|
|
571
|
+
all_findings_list = [x for entry in per_file.values()
|
|
572
|
+
for x in entry["findings"]]
|
|
573
|
+
risk_scores = {f: entry["score"] for f, entry in per_file.items()}
|
|
574
|
+
|
|
575
|
+
_finalize_review(all_results, all_findings_list, risk_scores, files,
|
|
576
|
+
config, json_mode, test_results, args)
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def _cmd_check_diff(args):
|
|
580
|
+
"""Review the diff between a base ref and HEAD (PR-style review)."""
|
|
581
|
+
json_mode = bool(getattr(args, "json", False))
|
|
582
|
+
token_usage.reset()
|
|
583
|
+
|
|
584
|
+
def _status(msg):
|
|
585
|
+
print(msg, file=sys.stderr if json_mode else sys.stdout)
|
|
586
|
+
|
|
587
|
+
base = getattr(args, "base", None) or os.getenv("PRISM_BASE") or "origin/main"
|
|
588
|
+
|
|
589
|
+
def _ref_ok(ref):
|
|
590
|
+
try:
|
|
591
|
+
return subprocess.run(
|
|
592
|
+
["git", "rev-parse", "--verify", ref],
|
|
593
|
+
capture_output=True, text=True
|
|
594
|
+
).returncode == 0
|
|
595
|
+
except Exception:
|
|
596
|
+
return False
|
|
597
|
+
|
|
598
|
+
if not _ref_ok(base):
|
|
599
|
+
for candidate in ("origin/HEAD", "origin/main", "origin/master", "HEAD~1"):
|
|
600
|
+
if _ref_ok(candidate):
|
|
601
|
+
base = candidate
|
|
602
|
+
break
|
|
603
|
+
else:
|
|
604
|
+
print("Cannot resolve base ref '{}'".format(base), file=sys.stderr)
|
|
605
|
+
sys.exit(2)
|
|
606
|
+
|
|
607
|
+
files = [f for f in get_diff_files(base) if Path(f).exists()]
|
|
608
|
+
# check-staged honours .prismignore (get_staged_files applies it); check-diff
|
|
609
|
+
# must do the same or a docs-only PR fails the review on README wording.
|
|
610
|
+
files, skipped = filter_files(files)
|
|
611
|
+
if skipped:
|
|
612
|
+
_status("Skipped (ignored): {} file(s)".format(len(skipped)))
|
|
613
|
+
if not files:
|
|
614
|
+
if json_mode:
|
|
615
|
+
print(json.dumps(_empty_staged_output(files, skipped), indent=2))
|
|
616
|
+
else:
|
|
617
|
+
_status("No changed files to review.")
|
|
618
|
+
sys.exit(0)
|
|
619
|
+
|
|
620
|
+
config = load_config()
|
|
621
|
+
all_results = []
|
|
622
|
+
all_findings_list = []
|
|
623
|
+
risk_scores = {}
|
|
624
|
+
for f in files:
|
|
625
|
+
diff = get_diff_for_file(base, f)
|
|
626
|
+
try:
|
|
627
|
+
results, score, file_findings = _review_one(
|
|
628
|
+
f, config, args.effort, args.fast, diff
|
|
629
|
+
)
|
|
630
|
+
except Exception as exc:
|
|
631
|
+
logger.error("Failed to review %s: %s", f, exc)
|
|
632
|
+
_status(" [error] {}: {}".format(f, exc))
|
|
633
|
+
continue
|
|
634
|
+
all_results.extend(results)
|
|
635
|
+
risk_scores[f] = score
|
|
636
|
+
all_findings_list.extend(file_findings)
|
|
637
|
+
_status(" [scanned] {}".format(f))
|
|
638
|
+
|
|
639
|
+
_finalize_review(all_results, all_findings_list, risk_scores, files,
|
|
640
|
+
config, json_mode, args=args)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _cmd_scan(args):
|
|
644
|
+
"""Review every source file under a path (whole-project audit).
|
|
645
|
+
|
|
646
|
+
Unlike ``check-staged``/``check-diff``, this reviews the full file content
|
|
647
|
+
(no diff) so it is useful for onboarding a big project or a nightly audit.
|
|
648
|
+
The ignore list and codebase cache still apply, so unchanged files are
|
|
649
|
+
skipped on repeat runs.
|
|
650
|
+
"""
|
|
651
|
+
json_mode = bool(getattr(args, "json", False))
|
|
652
|
+
token_usage.reset()
|
|
653
|
+
|
|
654
|
+
def _status(msg):
|
|
655
|
+
print(msg, file=sys.stderr if json_mode else sys.stdout)
|
|
656
|
+
|
|
657
|
+
root = Path(getattr(args, "path", None) or ".")
|
|
658
|
+
if not root.exists():
|
|
659
|
+
print("Path not found: {}".format(root), file=sys.stderr)
|
|
660
|
+
sys.exit(2)
|
|
661
|
+
|
|
662
|
+
skip_dirs = {".git", "__pycache__", "node_modules", ".venv", "venv",
|
|
663
|
+
".mypy_cache", ".pytest_cache", "dist", "build"}
|
|
664
|
+
files = [
|
|
665
|
+
str(p).replace("\\", "/") for p in sorted(root.rglob("*"))
|
|
666
|
+
if p.is_file() and not (set(p.parts) & skip_dirs)
|
|
667
|
+
]
|
|
668
|
+
files, skipped = filter_files(files)
|
|
669
|
+
if skipped:
|
|
670
|
+
_status("Skipped (ignored): {} file(s)".format(len(skipped)))
|
|
671
|
+
if not files:
|
|
672
|
+
if json_mode:
|
|
673
|
+
print(json.dumps(_empty_staged_output(files, skipped), indent=2))
|
|
674
|
+
else:
|
|
675
|
+
_status("No reviewable files found under {}.".format(root))
|
|
676
|
+
sys.exit(0)
|
|
677
|
+
|
|
678
|
+
config = load_config()
|
|
679
|
+
try:
|
|
680
|
+
_changed, unchanged, cache = get_changed_files()
|
|
681
|
+
except Exception:
|
|
682
|
+
unchanged, cache = [], {}
|
|
683
|
+
|
|
684
|
+
per_file = {}
|
|
685
|
+
files_to_review = []
|
|
686
|
+
for f in files:
|
|
687
|
+
cached = get_cached_findings(f, cache)
|
|
688
|
+
# An empty list is a valid cache entry (file reviewed, no findings),
|
|
689
|
+
# so test for None rather than truthiness.
|
|
690
|
+
if cached is not None and f in unchanged:
|
|
691
|
+
per_file[f] = {
|
|
692
|
+
"findings": list(cached),
|
|
693
|
+
"results": _findings_to_agent_results(cached),
|
|
694
|
+
"score": _cached_risk_score(f, cached),
|
|
695
|
+
}
|
|
696
|
+
else:
|
|
697
|
+
files_to_review.append(f)
|
|
698
|
+
|
|
699
|
+
_status("Scanning {} file(s) under {}...".format(len(files), root))
|
|
700
|
+
_review_staged_parallel(files_to_review, per_file, cache, config, args,
|
|
701
|
+
_status, use_diff=False)
|
|
702
|
+
|
|
703
|
+
all_results = [r for entry in per_file.values() for r in entry["results"]]
|
|
704
|
+
all_findings_list = [x for entry in per_file.values()
|
|
705
|
+
for x in entry["findings"]]
|
|
706
|
+
risk_scores = {f: entry["score"] for f, entry in per_file.items()}
|
|
707
|
+
_status("Scanned {} file(s), {} finding(s).".format(
|
|
708
|
+
len(per_file), len(all_findings_list)))
|
|
709
|
+
|
|
710
|
+
_finalize_review(all_results, all_findings_list, risk_scores, files,
|
|
711
|
+
config, json_mode, None, args, audit=True)
|