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 +7 -0
- prism/cli.py +553 -0
- prism/core/__init__.py +1 -0
- prism/core/blast.py +246 -0
- prism/core/config.py +111 -0
- prism/core/contracts.py +205 -0
- prism/core/git.py +193 -0
- prism/core/graph.py +145 -0
- prism/core/hook.py +86 -0
- prism/core/patch.py +94 -0
- prism/core/remediation.py +60 -0
- prism/core/sarif.py +113 -0
- prism/core/tests.py +116 -0
- prism/core/visual.py +126 -0
- prism/core/watch.py +58 -0
- prism/parsers/__init__.py +1 -0
- prism/parsers/base.py +99 -0
- prism/parsers/js_ts_parser.py +329 -0
- prism/parsers/python_parser.py +352 -0
- prism/ui/__init__.py +1 -0
- prism/ui/console.py +195 -0
- prism_radar-2.0.0.dist-info/METADATA +519 -0
- prism_radar-2.0.0.dist-info/RECORD +27 -0
- prism_radar-2.0.0.dist-info/WHEEL +5 -0
- prism_radar-2.0.0.dist-info/entry_points.txt +2 -0
- prism_radar-2.0.0.dist-info/licenses/LICENSE +21 -0
- prism_radar-2.0.0.dist-info/top_level.txt +1 -0
prism/__init__.py
ADDED
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."""
|