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/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)