prism-radar 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 ADDED
@@ -0,0 +1,7 @@
1
+ """
2
+ PRISM - Real-time topological blast radius and contract analysis for AI-generated code.
3
+ Know what will break before you commit.
4
+ """
5
+
6
+ __version__ = "2.0.0"
7
+ __author__ = "shenald-dev"
prism/cli.py ADDED
@@ -0,0 +1,553 @@
1
+ """
2
+ CLI entrypoint for PRISM - The Topological Blast Radius & Invariant Radar.
3
+ """
4
+
5
+ import json
6
+ import time
7
+ from pathlib import Path
8
+ from typing import Dict, Optional
9
+
10
+ import typer
11
+
12
+ from prism import __version__
13
+ from prism.core.blast import compute_blast_radius
14
+ from prism.core.config import load_config
15
+ from prism.core.git import discover_all_source_files, get_git_diff_lines, identify_modified_symbols
16
+ from prism.core.graph import DependencyGraph
17
+ from prism.core.hook import init_github_actions_workflow, install_git_pre_commit_hook
18
+ from prism.core.patch import PatchGenerator
19
+ from prism.core.remediation import generate_agent_remediation_prompt
20
+ from prism.core.tests import run_impacted_tests
21
+ from prism.core.visual import export_dot, export_mermaid
22
+ from prism.core.watch import LiveRadarWatcher
23
+ from prism.parsers.base import ParsedFile
24
+ from prism.parsers.js_ts_parser import parse_js_ts_file
25
+ from prism.parsers.python_parser import parse_python_file
26
+ from prism.ui.console import console, print_banner, render_report
27
+
28
+ app = typer.Typer(
29
+ help="๐Ÿ’Ž Real-time topological blast radius and contract analysis for AI-generated code. Know what will break before you commit.",
30
+ add_completion=False,
31
+ )
32
+
33
+ hook_app = typer.Typer(
34
+ name="hook",
35
+ help="๐Ÿช Manage Git pre-commit hooks for PRISM.",
36
+ no_args_is_help=True,
37
+ )
38
+ app.add_typer(hook_app, name="hook")
39
+
40
+ ci_app = typer.Typer(
41
+ name="ci",
42
+ help="๐Ÿค– Manage CI/CD automation workflows (GitHub Actions, SARIF).",
43
+ no_args_is_help=True,
44
+ )
45
+ app.add_typer(ci_app, name="ci")
46
+
47
+
48
+ def version_callback(value: bool):
49
+ if value:
50
+ console.print(f"[bold cyan]prism[/] version [bold green]{__version__}[/]")
51
+ raise typer.Exit()
52
+
53
+
54
+ @app.callback()
55
+ def common(
56
+ version: Optional[bool] = typer.Option(
57
+ None, "--version", "-v", help="Show version and exit.", callback=version_callback, is_eager=True
58
+ ),
59
+ ):
60
+ pass
61
+
62
+
63
+ def _collect_and_parse(
64
+ path: Path, include_tests: bool = True
65
+ ) -> tuple[Dict[str, ParsedFile], DependencyGraph, list[Path]]:
66
+ """Helper to discover and parse all supported files into parsed_files and graph."""
67
+ source_files = discover_all_source_files(path, include_tests=include_tests)
68
+ parsed_files: Dict[str, ParsedFile] = {}
69
+ graph = DependencyGraph()
70
+
71
+ for file_path in source_files:
72
+ rel_path = str(file_path.relative_to(path)).replace("\\", "/")
73
+ try:
74
+ code = file_path.read_text(encoding="utf-8", errors="replace")
75
+ except Exception:
76
+ continue
77
+
78
+ ext = file_path.suffix.lower()
79
+ if ext == ".py":
80
+ parsed = parse_python_file(rel_path, code)
81
+ elif ext in {".js", ".jsx", ".ts", ".tsx"}:
82
+ parsed = parse_js_ts_file(rel_path, code)
83
+ else:
84
+ continue
85
+
86
+ parsed_files[rel_path] = parsed
87
+ for sym in parsed.symbols:
88
+ graph.register_symbol(sym)
89
+ for call in parsed.call_sites:
90
+ graph.add_call_site(call)
91
+
92
+ return parsed_files, graph, source_files
93
+
94
+
95
+ @app.command(name="scan")
96
+ def scan(
97
+ path: Path = typer.Argument(
98
+ Path("."),
99
+ help="Target repository or directory to analyze.",
100
+ exists=True,
101
+ file_okay=False,
102
+ dir_okay=True,
103
+ resolve_path=True,
104
+ ),
105
+ all_symbols: bool = typer.Option(
106
+ False,
107
+ "--all",
108
+ "-a",
109
+ help="Analyze all symbols across the repository instead of only uncommitted / modified git changes.",
110
+ ),
111
+ base: Optional[str] = typer.Option(
112
+ None,
113
+ "--base",
114
+ help="Git base revision or branch to diff against (e.g. 'main', 'origin/main', or 'HEAD~1').",
115
+ ),
116
+ head: Optional[str] = typer.Option(
117
+ None,
118
+ "--head",
119
+ help="Git head revision when diffing a range against --base.",
120
+ ),
121
+ staged: bool = typer.Option(
122
+ False,
123
+ "--staged",
124
+ help="Only inspect staged git changes.",
125
+ ),
126
+ commit: Optional[str] = typer.Option(
127
+ None,
128
+ "--commit",
129
+ help="Inspect changes introduced by a specific git commit hash.",
130
+ ),
131
+ depth: int = typer.Option(
132
+ 5,
133
+ "--depth",
134
+ "-d",
135
+ help="Maximum depth for transitive caller graph traversal.",
136
+ ),
137
+ strict: bool = typer.Option(
138
+ False,
139
+ "--strict",
140
+ "-s",
141
+ help="Fail with exit code 1 if any broken contracts or critical blast risks are detected (ideal for CI / pre-commit).",
142
+ ),
143
+ json_output: bool = typer.Option(
144
+ False,
145
+ "--json",
146
+ "-j",
147
+ help="Output raw JSON results for CI/CD integrations or agent feedback loops.",
148
+ ),
149
+ output_format: str = typer.Option(
150
+ "terminal",
151
+ "--format",
152
+ "-f",
153
+ help="Output format: 'terminal', 'json', 'sarif', 'markdown', 'mermaid', or 'dot'.",
154
+ ),
155
+ output_file: Optional[Path] = typer.Option(
156
+ None,
157
+ "--output",
158
+ "-o",
159
+ help="Optional destination file path to save output.",
160
+ ),
161
+ include_tests: bool = typer.Option(
162
+ True,
163
+ "--tests/--no-tests",
164
+ help="Include test impact analysis in scan.",
165
+ ),
166
+ run_tests: bool = typer.Option(
167
+ False,
168
+ "--run-tests",
169
+ help="Automatically execute the impacted tests via their runner.",
170
+ ),
171
+ ):
172
+ """Compute topological blast radius, trace caller shockwaves, and check contract invariants."""
173
+ start_time = time.time()
174
+ config = load_config(path)
175
+
176
+ # Determine effective format
177
+ effective_format = "json" if json_output else output_format.lower()
178
+ if effective_format == "terminal" and config.default_format != "terminal" and not json_output:
179
+ effective_format = config.default_format
180
+
181
+ if effective_format == "terminal":
182
+ print_banner()
183
+
184
+ # 1. Discover and parse all source files
185
+ parsed_files, graph, source_files = _collect_and_parse(path, include_tests=include_tests)
186
+ if not source_files:
187
+ msg = "No supported source files (.py, .ts, .tsx, .js, .jsx) found."
188
+ if effective_format == "json":
189
+ print(json.dumps({"error": msg}))
190
+ elif effective_format == "terminal":
191
+ console.print(f"[bold yellow]{msg}[/]")
192
+ raise typer.Exit(0)
193
+
194
+ # 2. Identify modified symbols
195
+ if all_symbols:
196
+ modified_symbols = []
197
+ for p in parsed_files.values():
198
+ modified_symbols.extend(p.symbols)
199
+ else:
200
+ diff_lines = get_git_diff_lines(path, base=base, head=head, staged=staged, commit=commit)
201
+ modified_symbols = identify_modified_symbols(diff_lines, parsed_files)
202
+ # If no uncommitted changes found, fallback to informing user or full repo
203
+ if not modified_symbols:
204
+ if effective_format == "terminal":
205
+ console.print("[dim]No git changes detected for target. Analyzing all repository symbols...[/]\n")
206
+ for p in parsed_files.values():
207
+ modified_symbols.extend(p.symbols)
208
+
209
+ duration = time.time() - start_time
210
+
211
+ # 3. Compute Blast Radius
212
+ report = compute_blast_radius(
213
+ modified_symbols=modified_symbols,
214
+ graph=graph,
215
+ total_files_scanned=len(source_files),
216
+ duration_seconds=duration,
217
+ max_depth=depth,
218
+ include_tests=include_tests,
219
+ config=config,
220
+ )
221
+
222
+ # 4. Optional: Run Impacted Tests
223
+ if run_tests and report.test_impact:
224
+ if effective_format == "terminal":
225
+ console.print("[bold cyan]๐Ÿงช Executing impacted test suite...[/]")
226
+ report.test_impact = run_impacted_tests(report.test_impact, path)
227
+
228
+ # 5. Render Output
229
+ output_content = ""
230
+ if effective_format == "json":
231
+ output_content = json.dumps(report.to_dict(), indent=2)
232
+ if not output_file:
233
+ print(output_content)
234
+ elif effective_format == "sarif":
235
+ sarif_doc = report.to_sarif()
236
+ output_content = json.dumps(sarif_doc, indent=2)
237
+ if not output_file:
238
+ print(output_content)
239
+ elif effective_format == "markdown":
240
+ output_content = report.to_markdown()
241
+ if not output_file:
242
+ print(output_content)
243
+ elif effective_format == "mermaid":
244
+ output_content = report.to_mermaid()
245
+ if not output_file:
246
+ print(output_content)
247
+ elif effective_format == "dot":
248
+ output_content = report.to_dot()
249
+ if not output_file:
250
+ print(output_content)
251
+ else:
252
+ render_report(report)
253
+
254
+ if output_file:
255
+ output_file.write_text(output_content, encoding="utf-8")
256
+ if effective_format == "terminal":
257
+ console.print(f"[bold green]โœ” Saved report to {output_file}[/]")
258
+
259
+ # 6. Strict Mode Exit Code
260
+ is_strict = strict or config.strict
261
+ fail_on = config.fail_on
262
+
263
+ should_fail = False
264
+ if is_strict:
265
+ if fail_on == "ANY" and (report.impact_score > 0 or report.all_violations):
266
+ should_fail = True
267
+ elif fail_on == "MODERATE" and (report.risk_level in ("MODERATE", "ELEVATED", "CRITICAL") or report.all_violations):
268
+ should_fail = True
269
+ elif fail_on == "ELEVATED" and (report.risk_level in ("ELEVATED", "CRITICAL") or report.all_violations):
270
+ should_fail = True
271
+ else: # CRITICAL
272
+ should_fail = report.risk_level == "CRITICAL" or len(report.all_violations) > 0
273
+
274
+ if should_fail:
275
+ if effective_format == "terminal":
276
+ console.print(
277
+ f"\n[bold red]โŒ STRICT CHECK FAILED: {len(report.all_violations)} broken contract(s) detected "
278
+ f"or risk level is {report.risk_level}.[/]\n"
279
+ )
280
+ raise typer.Exit(1)
281
+
282
+
283
+ @app.command(name="visual")
284
+ @app.command(name="graph")
285
+ def visual_command(
286
+ path: Path = typer.Argument(
287
+ Path("."),
288
+ help="Target repository or directory to visualize.",
289
+ exists=True,
290
+ file_okay=False,
291
+ dir_okay=True,
292
+ resolve_path=True,
293
+ ),
294
+ format_type: str = typer.Option("mermaid", "--format", "-f", help="Diagram format: 'mermaid' or 'dot'."),
295
+ output_file: Optional[Path] = typer.Option(None, "--output", "-o", help="Destination file to save diagram."),
296
+ ):
297
+ """Generate visual DAG diagrams of the blast radius in Mermaid.js or Graphviz DOT."""
298
+ parsed_files, graph, source_files = _collect_and_parse(path, include_tests=True)
299
+ diff_lines = get_git_diff_lines(path)
300
+ modified = identify_modified_symbols(diff_lines, parsed_files)
301
+ if not modified:
302
+ for p in parsed_files.values():
303
+ modified.extend(p.symbols)
304
+
305
+ report = compute_blast_radius(
306
+ modified_symbols=modified,
307
+ graph=graph,
308
+ total_files_scanned=len(source_files),
309
+ duration_seconds=0.0,
310
+ include_tests=True,
311
+ )
312
+
313
+ if format_type.lower() == "dot":
314
+ diagram = export_dot(report)
315
+ else:
316
+ diagram = export_mermaid(report)
317
+
318
+ if output_file:
319
+ output_file.write_text(diagram, encoding="utf-8")
320
+ console.print(f"[bold green]โœ” Saved {format_type.upper()} diagram to {output_file}[/]")
321
+ else:
322
+ print(diagram)
323
+
324
+
325
+ @app.command(name="fix")
326
+ def fix_command(
327
+ path: Path = typer.Argument(
328
+ Path("."),
329
+ help="Target repository or directory to remediate.",
330
+ exists=True,
331
+ file_okay=False,
332
+ dir_okay=True,
333
+ resolve_path=True,
334
+ ),
335
+ apply_fixes: bool = typer.Option(False, "--apply", help="Apply remediations directly to caller files."),
336
+ patch_file: Optional[Path] = typer.Option(None, "--patch", help="Save unified diff patch to file."),
337
+ ):
338
+ """Auto-generate or apply callsite fixes for contract violations (unawaited coroutines, etc.)."""
339
+ parsed_files, graph, source_files = _collect_and_parse(path, include_tests=True)
340
+ diff_lines = get_git_diff_lines(path)
341
+ modified = identify_modified_symbols(diff_lines, parsed_files)
342
+ if not modified:
343
+ for p in parsed_files.values():
344
+ modified.extend(p.symbols)
345
+
346
+ report = compute_blast_radius(
347
+ modified_symbols=modified,
348
+ graph=graph,
349
+ total_files_scanned=len(source_files),
350
+ duration_seconds=0.0,
351
+ include_tests=True,
352
+ )
353
+
354
+ if not report.all_violations:
355
+ console.print("[bold green]โœจ No broken contracts found. Codebase is clean.[/]")
356
+ return
357
+
358
+ patch_str = PatchGenerator.generate_patch(report.all_violations, path)
359
+
360
+ if patch_file:
361
+ patch_file.write_text(patch_str, encoding="utf-8")
362
+ console.print(f"[bold green]โœ” Saved unified patch to {patch_file}[/]")
363
+ elif not apply_fixes:
364
+ if patch_str:
365
+ console.print("[bold cyan]๐Ÿ“‹ Proposed Call-site Remediation Patch:[/]\n")
366
+ console.print(patch_str)
367
+ console.print("\n[dim]Run 'prism fix --apply' to automatically modify caller files.[/]\n")
368
+ else:
369
+ console.print("[yellow]No automated patches available for current violations. Run 'prism explain'.[/]")
370
+
371
+ if apply_fixes:
372
+ count = PatchGenerator.apply_fixes(report.all_violations, path)
373
+ if count > 0:
374
+ console.print(f"[bold green]โœ” Successfully applied fixes to {count} callsite(s)![/]")
375
+ else:
376
+ console.print("[yellow]No automated fixes could be safely applied.[/]")
377
+
378
+
379
+ @app.command(name="watch")
380
+ def watch_command(
381
+ path: Path = typer.Argument(
382
+ Path("."),
383
+ help="Target repository to monitor in real-time.",
384
+ exists=True,
385
+ file_okay=False,
386
+ dir_okay=True,
387
+ resolve_path=True,
388
+ ),
389
+ interval: float = typer.Option(0.5, "--interval", "-i", help="Polling interval in seconds."),
390
+ ):
391
+ """Live continuous radar watcher daemon for real-time AI vibe-coding feedback."""
392
+ print_banner()
393
+ console.print(f"[bold cyan]๐Ÿ‘€ PRISM Live Radar active on {path.resolve()}[/] [dim](Ctrl+C to exit)[/]\n")
394
+
395
+ def _on_change():
396
+ console.clear()
397
+ print_banner()
398
+ console.print("[bold yellow]โšก Change detected! Recomputing blast radius...[/]\n")
399
+ scan(path=path)
400
+
401
+ watcher = LiveRadarWatcher(path, poll_interval=interval)
402
+ try:
403
+ watcher.watch(_on_change)
404
+ except KeyboardInterrupt:
405
+ console.print("\n[bold cyan]PRISM Live Radar stopped.[/]")
406
+
407
+
408
+ # --- Hook and CI commands ---
409
+
410
+
411
+ @hook_app.command(name="install")
412
+ def hook_install(
413
+ path: Path = typer.Argument(Path("."), help="Target git repository path."),
414
+ ):
415
+ """Install native pre-commit hook preventing commits with broken contracts."""
416
+ hook_path = install_git_pre_commit_hook(path)
417
+ console.print(f"[bold green]โœ” Installed PRISM pre-commit hook at {hook_path}[/]")
418
+
419
+
420
+ @ci_app.command(name="init")
421
+ def ci_init(
422
+ path: Path = typer.Argument(Path("."), help="Target repository path."),
423
+ ):
424
+ """Initialize GitHub Actions PRISM blast radius CI workflow."""
425
+ wf_path = init_github_actions_workflow(path)
426
+ console.print(f"[bold green]โœ” Generated GitHub Actions workflow at {wf_path}[/]")
427
+
428
+
429
+ @app.command(name="test-impact")
430
+ def test_impact_command(
431
+ path: Path = typer.Argument(
432
+ Path("."),
433
+ help="Target repository or directory to analyze.",
434
+ exists=True,
435
+ file_okay=False,
436
+ dir_okay=True,
437
+ resolve_path=True,
438
+ ),
439
+ run: bool = typer.Option(
440
+ False,
441
+ "--run",
442
+ "-r",
443
+ help="Execute the impacted tests immediately.",
444
+ ),
445
+ ):
446
+ """Pinpoint impacted tests for modified symbols and detect coverage gaps."""
447
+ parsed_files, graph, source_files = _collect_and_parse(path, include_tests=True)
448
+ diff_lines = get_git_diff_lines(path)
449
+ modified = identify_modified_symbols(diff_lines, parsed_files)
450
+ if not modified:
451
+ for p in parsed_files.values():
452
+ modified.extend(p.symbols)
453
+
454
+ report = compute_blast_radius(
455
+ modified_symbols=modified,
456
+ graph=graph,
457
+ total_files_scanned=len(source_files),
458
+ duration_seconds=0.0,
459
+ include_tests=True,
460
+ )
461
+
462
+ if not report.test_impact or not report.test_impact.impacted_test_cases:
463
+ console.print("[bold green]โœ” No active test callers found for modified symbols.[/]")
464
+ return
465
+
466
+ console.print(f"[bold cyan]๐Ÿงช IMPACTED TEST SUITE ({len(report.test_impact.impacted_test_cases)} tests):[/]\n")
467
+ for tc in report.test_impact.impacted_test_cases:
468
+ console.print(f" โ€ข [purple]{tc}[/]")
469
+
470
+ if report.test_impact.recommended_command:
471
+ import shlex
472
+ console.print(f"\n[bold green]Command to execute:[/] [white]{shlex.join(report.test_impact.recommended_command)}[/]\n")
473
+
474
+ if run:
475
+ console.print("[bold yellow]Running impacted tests...[/]\n")
476
+ updated = run_impacted_tests(report.test_impact, path)
477
+ status_color = "bold green" if updated.execution_success else "bold red"
478
+ console.print(f"[{status_color}]Result: {updated.execution_result}[/]")
479
+ if not updated.execution_success:
480
+ raise typer.Exit(1)
481
+
482
+
483
+ @app.command(name="explain")
484
+ def explain(
485
+ path: Path = typer.Argument(
486
+ Path("."),
487
+ help="Target repository or directory to analyze.",
488
+ exists=True,
489
+ file_okay=False,
490
+ dir_okay=True,
491
+ resolve_path=True,
492
+ ),
493
+ ):
494
+ """Generate high-density AI Agent remediation prompt context for self-healing callers."""
495
+ parsed_files, graph, source_files = _collect_and_parse(path, include_tests=True)
496
+ diff_lines = get_git_diff_lines(path)
497
+ modified = identify_modified_symbols(diff_lines, parsed_files)
498
+ if not modified:
499
+ for p in parsed_files.values():
500
+ modified.extend(p.symbols)
501
+
502
+ report = compute_blast_radius(
503
+ modified_symbols=modified,
504
+ graph=graph,
505
+ total_files_scanned=len(source_files),
506
+ duration_seconds=0.0,
507
+ include_tests=True,
508
+ )
509
+
510
+ prompt = generate_agent_remediation_prompt(
511
+ modified_symbols=modified,
512
+ violations=report.all_violations,
513
+ risk_level=report.risk_level,
514
+ impact_score=report.impact_score,
515
+ )
516
+ print(prompt)
517
+
518
+
519
+ @app.command(name="init")
520
+ def init(
521
+ path: Path = typer.Argument(
522
+ Path("."),
523
+ help="Target directory to create prism.toml config.",
524
+ ),
525
+ ):
526
+ """Initialize a starter prism.toml configuration in the target directory."""
527
+ target_file = path / "prism.toml"
528
+ if target_file.exists():
529
+ console.print(f"[bold yellow]Config file {target_file} already exists.[/]")
530
+ raise typer.Exit(0)
531
+
532
+ starter_config = """# PRISM Configuration File (v2.0)
533
+ # Documentation: https://github.com/shenald-dev/prism
534
+
535
+ [prism]
536
+ max_depth = 5
537
+ strict = false
538
+ fail_on = "CRITICAL"
539
+ ignore_symbols = ["__repr__", "__str__", "__init__"]
540
+ include_tests = true
541
+ default_format = "terminal"
542
+ """
543
+ target_file.write_text(starter_config, encoding="utf-8")
544
+ console.print(f"[bold green]โœ” Initialized starter configuration at {target_file}[/]")
545
+ console.print("\n[dim]Add PRISM to your .pre-commit-config.yaml or CI workflow for zero-latency invariant radar.[/]")
546
+
547
+
548
+ def main():
549
+ app()
550
+
551
+
552
+ if __name__ == "__main__":
553
+ main()
prism/core/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """Core graph, blast radius, contract analysis, and Git diff engines."""