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/ai_pipeline.py ADDED
@@ -0,0 +1,447 @@
1
+ """Chunking, file-size tiers, two-pass summarization, and the review pipeline.
2
+
3
+ This module owns the file-size adaptation logic (single call vs chunked vs
4
+ two-pass) and the top-level `review_with_agent` entry point used by every
5
+ AI agent. It depends on `ai_providers` for the LLM client and `ai_findings`
6
+ for parsing/verification.
7
+ """
8
+
9
+ import os
10
+ import re
11
+ import time
12
+ import logging
13
+ import concurrent.futures
14
+
15
+ from prism.ai_providers import (
16
+ get_model, API_TIMEOUT, last_model_failure, sdk_install_hint, install_hint,
17
+ )
18
+ from prism.ai_findings import parse_findings, verify_findings, _AGENT_PREFIX
19
+ from prism.agents import Finding, Severity, Blocker
20
+ from prism.langs import lang_for, strip_literals
21
+
22
+ logger = logging.getLogger(__name__)
23
+
24
+ CHUNK_OVERLAP = 20
25
+
26
+
27
+ def _error_finding(agent_name, file_path, message, blocking=False):
28
+ """Surface an AI failure as a visible finding.
29
+
30
+ Silently returning [] made a failed agent look like a clean review. A
31
+ provider outage (transport error, 504, invalid JSON) is blocking: letting
32
+ it pass means the gate reports "clean" for a review that never happened.
33
+ A missing API key is not an outage -- it is an unconfigured install -- so
34
+ it stays non-blocking and only bites when AI review was requested.
35
+ """
36
+ prefix = _AGENT_PREFIX.get(agent_name, agent_name.upper()[:4])
37
+ return Finding(
38
+ agent=agent_name,
39
+ severity=Severity.HIGH if blocking else Severity.LOW,
40
+ category="AI Error",
41
+ message=message,
42
+ file=file_path,
43
+ line=None,
44
+ suggestion="Check the provider, API key, and network, then re-run.",
45
+ blocker=Blocker.BLOCKS if blocking else Blocker.ALLOWS,
46
+ confidence=1.0,
47
+ rule_id="{}-AI-ERR".format(prefix),
48
+ )
49
+
50
+
51
+ def _failed_message(result):
52
+ """Return (error, blocking) if the provider call failed, else (None, False).
53
+
54
+ A provider outage -- transport error, timeout, open circuit -- blocks:
55
+ the review never happened, so "no findings" would be a lie. A model that
56
+ answered with prose instead of JSON is a formatting problem, not an
57
+ outage; retrying already happened, and one chatty chunk must not fail an
58
+ otherwise clean review.
59
+ """
60
+ if isinstance(result, dict) and result.get("_failed"):
61
+ message = str(result.get("_error") or result.get("summary") or "unknown error")
62
+ blocking = result.get("_error_kind") != "invalid_json"
63
+ return message, blocking
64
+ return None, False
65
+
66
+ # File-size tiers: (threshold_lines, timeout_multiplier, whole_file_agents)
67
+ TIERS = [
68
+ (400, 1.0, []),
69
+ (2000, 1.5, ["ai_test", "ai_policy"]),
70
+ (10000, 2.0, ["ai_test", "ai_policy", "ai_security", "ai_performance"]),
71
+ (999999, 3.0, ["ai_test", "ai_policy", "ai_security", "ai_performance", "ai_quality"]),
72
+ ]
73
+
74
+
75
+ def get_tier_for_file(line_count):
76
+ """Return (timeout_multiplier, whole_file_agents) for a file of given line count."""
77
+ for threshold, multiplier, whole_file in TIERS:
78
+ if line_count <= threshold:
79
+ return multiplier, whole_file
80
+ return TIERS[-1][1], TIERS[-1][2]
81
+
82
+
83
+ def chunk_content(content, max_lines=400, overlap=20):
84
+ """Split content into overlapping chunks. Returns list of (start_line, chunk_text)."""
85
+ lines = content.split("\n")
86
+ if len(lines) <= max_lines:
87
+ return [(1, content)]
88
+
89
+ chunks = []
90
+ start = 0
91
+ while start < len(lines):
92
+ end = min(start + max_lines, len(lines))
93
+ chunks.append((start + 1, "\n".join(lines[start:end])))
94
+ if end >= len(lines):
95
+ break
96
+ start = end - overlap
97
+ return chunks
98
+
99
+
100
+ def build_skeleton(file_path, content):
101
+ """Extract a compact structural summary from source.
102
+
103
+ Returns a skeleton showing imports, classes, functions, and line numbers.
104
+ Python is parsed with ``ast``. Every other known language falls back to a
105
+ scan of its declarations, because the model reviews the file either way and
106
+ a skeleton is what tells it where the functions are -- without one, a Go or
107
+ Rust file was sent to the model with no structure at all.
108
+ """
109
+ spec = lang_for(file_path)
110
+ if spec is None:
111
+ return ""
112
+ if spec.name == "python":
113
+ return _python_skeleton(file_path, content)
114
+ return _decl_skeleton(file_path, content, spec)
115
+
116
+
117
+ def _python_skeleton(file_path, content):
118
+ import ast
119
+
120
+ try:
121
+ tree = ast.parse(content)
122
+ except (SyntaxError, ValueError):
123
+ return ""
124
+
125
+ skeleton = ["File: {}".format(file_path)]
126
+ imports = []
127
+ for node in ast.walk(tree):
128
+ if isinstance(node, ast.Import):
129
+ imports.extend(a.name for a in node.names)
130
+ elif isinstance(node, ast.ImportFrom):
131
+ imports.append(node.module or "")
132
+ if imports:
133
+ skeleton.append("Imports: {}".format(", ".join(imports)))
134
+
135
+ for node in ast.iter_child_nodes(tree):
136
+ if isinstance(node, ast.ClassDef):
137
+ methods = [
138
+ n.name for n in node.body
139
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
140
+ ]
141
+ skeleton.append("Class {} (line {})".format(node.name, node.lineno))
142
+ for m in methods:
143
+ skeleton.append(" - {}".format(m))
144
+ elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
145
+ skeleton.append("Function {} (line {})".format(node.name, node.lineno))
146
+ return "\n".join(skeleton)
147
+
148
+
149
+ _DECL_CALL = re.compile(r"(\w+)\s*\(")
150
+ _DECL_KEYWORD = re.compile(
151
+ r"^\s*(?:(?:export|default|public|private|protected|static|final|abstract|"
152
+ r"async|pub|unsafe|extern|suspend|override|open|internal|mutating)\s+)*"
153
+ r"(?:def|func|fn|function|fun|class|struct|enum|trait|interface|type)\b\s*")
154
+ _DECL_RECEIVER = re.compile(r"^\([^)]*\)\s*")
155
+ _DECL_ASSIGN = re.compile(r"^\s*(?:(?:const|let|var)\s+)?(\w+)\s*=")
156
+
157
+
158
+ def _decl_name(code):
159
+ """The name a declaration line introduces.
160
+
161
+ A keyword (``func``, ``class``) is followed by the name, except in Go where
162
+ a method puts its receiver first. A line with no keyword is either a call
163
+ style declaration (``int main() {``) or an assigned function expression
164
+ (``const b = (x) => x``).
165
+ """
166
+ rest = _DECL_KEYWORD.sub("", code)
167
+ if rest != code:
168
+ match = re.match(r"(\w+)", _DECL_RECEIVER.sub("", rest))
169
+ if match:
170
+ return match.group(1)
171
+ # An assigned function expression names itself before the `=`, which is
172
+ # earlier than the parameter list, so it is tried first.
173
+ match = _DECL_ASSIGN.match(code) or _DECL_CALL.search(code)
174
+ return match.group(1) if match else "?"
175
+
176
+
177
+ def _decl_skeleton(file_path, content, spec):
178
+ """Outline a non-Python file from the declarations the language uses."""
179
+ skeleton = ["File: {}".format(file_path)]
180
+ classes, functions = [], []
181
+ for i, line in enumerate(content.split("\n"), 1):
182
+ code = strip_literals(line, spec)
183
+ if spec.class_decl is not None and spec.class_decl.match(code):
184
+ classes.append("{} (line {})".format(_decl_name(code), i))
185
+ elif spec.func_decl is not None and spec.func_decl.match(code):
186
+ functions.append("{} (line {})".format(_decl_name(code), i))
187
+ if classes:
188
+ skeleton.append("Classes: {}".format(", ".join(classes)))
189
+ if functions:
190
+ skeleton.append("Functions: {}".format(", ".join(functions)))
191
+ return "\n".join(skeleton)
192
+
193
+
194
+ def _build_line_map(diff_text):
195
+ """Map each line of a unified diff to the new-file line it represents.
196
+
197
+ Returns a list the same length as ``diff_text.split("\\n")``; each entry is
198
+ the 1-based new-file line for that diff line, or ``None`` for hunk headers
199
+ and removed lines. The model only sees the diff, so its 1-based line
200
+ numbers index into this list.
201
+ """
202
+ mapping = []
203
+ new_line = None
204
+ for line in diff_text.split("\n"):
205
+ if line.startswith("@@"):
206
+ match = re.search(r"\+(\d+)(?:,\d+)?", line)
207
+ new_line = int(match.group(1)) if match else None
208
+ mapping.append(None)
209
+ continue
210
+ if new_line is None:
211
+ mapping.append(None)
212
+ continue
213
+ if line.startswith("+++") or line.startswith("---"):
214
+ mapping.append(None)
215
+ continue
216
+ if line.startswith("+"):
217
+ mapping.append(new_line)
218
+ new_line += 1
219
+ elif line.startswith("-"):
220
+ mapping.append(None)
221
+ else:
222
+ mapping.append(new_line)
223
+ new_line += 1
224
+ return mapping
225
+
226
+
227
+ def _remap_one(finding, line_map):
228
+ """Translate a diff-relative finding line into a real file line."""
229
+ if not finding.line:
230
+ return
231
+ index = finding.line - 1
232
+ if 0 <= index < len(line_map) and line_map[index]:
233
+ finding.line = line_map[index]
234
+
235
+
236
+ def _remap_findings(findings, line_map):
237
+ for finding in findings:
238
+ _remap_one(finding, line_map)
239
+
240
+
241
+ def _review_chunks(call_fn, model, system_prompt, file_path, chunks, agent_name,
242
+ skeleton, timeout, cross_file_context, line_map):
243
+ """Call the model once per chunk, offsetting and remapping line numbers.
244
+
245
+ Returns ``(findings, failure)``: ``failure`` is ``(error, blocking)`` when
246
+ every chunk failed and no finding survived.
247
+ """
248
+ all_findings = []
249
+ chunk_failed = None
250
+ for chunk_start, chunk_text in chunks:
251
+ result = call_fn(
252
+ model, system_prompt, file_path,
253
+ chunk_text, agent_name, skeleton or "", timeout,
254
+ cross_file_context,
255
+ )
256
+ error, blocking = _failed_message(result)
257
+ if error:
258
+ logger.error("%s chunk at line %d failed: %s",
259
+ agent_name, chunk_start, error)
260
+ if chunk_failed is None:
261
+ chunk_failed = (error, blocking)
262
+ continue
263
+ for finding in parse_findings(result, file_path, agent_name):
264
+ if finding.line:
265
+ finding.line = finding.line + chunk_start - 1
266
+ if line_map:
267
+ _remap_one(finding, line_map)
268
+ all_findings.append(finding)
269
+
270
+ if chunk_failed and not all_findings:
271
+ return [], chunk_failed
272
+
273
+ seen = set()
274
+ deduped = []
275
+ for finding in all_findings:
276
+ key = (finding.file, finding.line, finding.rule_id)
277
+ if key not in seen:
278
+ seen.add(key)
279
+ deduped.append(finding)
280
+ return deduped, None
281
+
282
+
283
+ def _call_and_finalize(call_fn, model, system_prompt, file_path, prompt,
284
+ agent_name, skeleton, timeout, cross_file_context,
285
+ finalize):
286
+ """Run one model call and turn a transport error into a blocking finding."""
287
+ result = call_fn(model, system_prompt, file_path, prompt, agent_name,
288
+ skeleton, timeout, cross_file_context)
289
+ error, blocking = _failed_message(result)
290
+ if error:
291
+ logger.error("%s failed: %s", agent_name, error)
292
+ return [_error_finding(agent_name, file_path,
293
+ "{} failed: {}".format(agent_name, error),
294
+ blocking=blocking)]
295
+ return finalize(result)
296
+
297
+
298
+ def review_with_agent(system_prompt, file_path, content, agent_name,
299
+ skeleton="", line_count=None, diff="", fast=False):
300
+ """Full pipeline: get model, call LLM, parse findings, verify.
301
+
302
+ Uses `diff` (git diff hunks) when available; falls back to full content.
303
+ Findings are remapped from diff lines to real new-file lines, so a PR
304
+ review never sends the whole file to the model. Adapts to file size via
305
+ TIERS. When `fast=True`, whole-file agents use two-pass summarization
306
+ instead of running on every chunk.
307
+ """
308
+ model, provider, call_fn = get_model()
309
+ if model is None:
310
+ reason, failed_provider, _detail = last_model_failure()
311
+ name = failed_provider or provider
312
+ if reason == "no_sdk":
313
+ # The key is fine; the SDK is missing. Say so, and say which pip
314
+ # extra fixes it — "no API key" sent developers to re-enter a key
315
+ # that was already correct.
316
+ hint = sdk_install_hint(name) or install_hint()
317
+ message = "{} SDK not installed. Install it with: {}".format(
318
+ name, hint)
319
+ elif reason == "unknown_provider":
320
+ message = "unknown MODEL_PROVIDER '{}'".format(name)
321
+ else:
322
+ message = "no API key configured for provider"
323
+ logger.info("%s skipped: %s", agent_name, message)
324
+ return [_error_finding(agent_name, file_path, message)]
325
+
326
+ has_diff = bool(diff and diff.strip())
327
+ review_content = diff if has_diff else content
328
+ line_map = _build_line_map(review_content) if has_diff else None
329
+
330
+ if line_count is None or has_diff:
331
+ line_count = review_content.count("\n") + 1
332
+
333
+ repo_path = os.getenv("PRISM_REPO_PATH", os.getcwd())
334
+ from prism.ai_graph import build_import_graph, get_cross_file_context
335
+ graph = build_import_graph(repo_path)
336
+ cross_file_context = get_cross_file_context(file_path, graph, repo_path)
337
+
338
+ tier_multiplier, whole_file_agents = get_tier_for_file(line_count)
339
+ needs_whole_file = agent_name in whole_file_agents
340
+
341
+ timeout = min(120, int(API_TIMEOUT * tier_multiplier))
342
+ env_timeout = os.getenv("PRISM_TIMEOUT")
343
+ if env_timeout:
344
+ try:
345
+ timeout = int(env_timeout)
346
+ except ValueError:
347
+ pass
348
+
349
+ def _finalize(raw):
350
+ findings = parse_findings(raw, file_path, agent_name)
351
+ if line_map:
352
+ _remap_findings(findings, line_map)
353
+ return verify_findings(findings, file_path, content)
354
+
355
+ try:
356
+ chunks = chunk_content(review_content)
357
+ if len(chunks) == 1:
358
+ return _call_and_finalize(
359
+ call_fn, model, system_prompt, file_path, review_content,
360
+ agent_name, skeleton, timeout, cross_file_context, _finalize)
361
+
362
+ if needs_whole_file and fast:
363
+ logger.info(
364
+ "%s: whole-file agent, summarizing %d chunks first "
365
+ "(%d-line file, timeout=%ds)",
366
+ agent_name, len(chunks), line_count, timeout
367
+ )
368
+ summaries = _summarize_chunks(
369
+ model, chunks, file_path, skeleton, timeout, call_fn, provider
370
+ )
371
+ return _call_and_finalize(
372
+ call_fn, model, system_prompt, file_path,
373
+ "## Chunk Summaries\n{}".format(summaries),
374
+ agent_name, skeleton, timeout, cross_file_context, _finalize)
375
+
376
+ logger.info(
377
+ "%s: %d-line file split into %d chunks (timeout=%ds)",
378
+ agent_name, line_count, len(chunks), timeout
379
+ )
380
+ deduped, failed = _review_chunks(
381
+ call_fn, model, system_prompt, file_path, chunks, agent_name,
382
+ skeleton, timeout, cross_file_context, line_map)
383
+ if failed:
384
+ error, blocking = failed
385
+ return [_error_finding(agent_name, file_path,
386
+ "{} failed: {}".format(agent_name, error),
387
+ blocking=blocking)]
388
+ return verify_findings(deduped, file_path, content)
389
+
390
+ except Exception as exc:
391
+ logger.error("%s pipeline failed: %s", agent_name, exc)
392
+ return [_error_finding(agent_name, file_path,
393
+ "{} pipeline failed: {}".format(agent_name, exc),
394
+ blocking=True)]
395
+
396
+
397
+ def _summarize_chunks(model, chunks, file_path, skeleton, timeout, call_fn=None, provider="gemini"):
398
+ """Summarize each chunk in parallel. Returns a compressed string."""
399
+ summary_prompt = (
400
+ "Summarize this code chunk in 1-2 sentences. "
401
+ "Focus on: what functions/classes exist, what they do, "
402
+ "and any notable patterns (security, performance, policy). "
403
+ 'Return ONLY JSON of the form {"summary": "..."}.\n\n'
404
+ )
405
+ if skeleton:
406
+ summary_prompt += "File structure:\n{}\n\n".format(skeleton)
407
+
408
+ summaries = []
409
+ with concurrent.futures.ThreadPoolExecutor(max_workers=5) as executor:
410
+ futures = {}
411
+ for i, (start, text) in enumerate(chunks):
412
+ prompt = summary_prompt + "Chunk {} (lines {}-{})\n{}".format(
413
+ i + 1, start, start + len(text.split("\n")) - 1, text[:16000]
414
+ )
415
+ futures[executor.submit(_safe_generate, model, prompt, timeout, call_fn, provider)] = i
416
+
417
+ for future in concurrent.futures.as_completed(futures):
418
+ i = futures[future]
419
+ try:
420
+ summaries.append((i, future.result()))
421
+ except Exception as exc:
422
+ logger.warning("Chunk %d summary failed: %s", i + 1, exc)
423
+ summaries.append((i, "[summary failed]"))
424
+
425
+ summaries.sort()
426
+ return "\n".join(
427
+ "Chunk {}: {}".format(i + 1, txt) for i, txt in summaries
428
+ )
429
+
430
+
431
+ def _safe_generate(model, prompt, timeout, call_fn=None, provider="gemini"):
432
+ """Generate content with retry. Returns text or empty string."""
433
+ for attempt in range(4):
434
+ try:
435
+ if call_fn is not None:
436
+ # Pass the prompt as the content argument; the provider
437
+ # dispatch builds the final request from it.
438
+ result = call_fn(model, "", "", prompt, "summarize", "", timeout, "")
439
+ if isinstance(result, dict):
440
+ return (result.get("summary") or "").strip()
441
+ return str(result).strip()
442
+ response = model.generate_content(prompt, request_options={"timeout": timeout})
443
+ return (response.text or "").strip()
444
+ except Exception:
445
+ if attempt < 3:
446
+ time.sleep(2 ** attempt)
447
+ return ""
prism/ai_policy.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ AI Policy Agent - Uses Gemini for policy and compliance review.
3
+
4
+ Catches: missing documentation, banned patterns, license issues,
5
+ coding standard violations, and compliance gaps.
6
+
7
+ Rule IDs follow SonarQube convention: POL-XXX
8
+ """
9
+
10
+ import logging
11
+ from typing import List
12
+ from prism.agents import BaseAgent, Finding, register_agent
13
+ from prism.ai_common import review_with_agent
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ SYSTEM_PROMPT = """
18
+ You are a software policy and compliance expert. You review code for adherence
19
+ to organizational policies, industry standards, and regulatory requirements.
20
+
21
+ Focus on:
22
+ 1. Missing documentation (no file headers, no function docstrings, no comments for complex logic)
23
+ 2. Banned patterns (print() for debugging, TODO/FIXME markers left in code, hardcoded configurations)
24
+ 3. License and copyright compliance (missing license headers, third-party code without attribution)
25
+ 4. Coding standard violations (inconsistent formatting, non-standard library usage)
26
+ 5. Logging and monitoring gaps (missing log statements for important operations)
27
+ 6. Error handling policies (unhandled exceptions, silent failures, generic catch blocks)
28
+ 7. Dependency policies (known vulnerable libraries, deprecated APIs)
29
+ 8. Accessibility and inclusivity (in code comments, error messages, UI strings)
30
+
31
+ Rules:
32
+ 1. Return ONLY valid JSON. No markdown, no extra text.
33
+ 2. Be specific. Each finding must include: severity, category, message, file, line, suggestion, suggested_code.
34
+ 3. For suggested_code, provide the actual code that fixes the issue.
35
+ 4. Focus on actual policy violations, not style preferences.
36
+ 5. If you find no issues, return empty findings array.
37
+
38
+ Schema:
39
+ {
40
+ "findings": [
41
+ {
42
+ "severity": "critical" | "high" | "medium" | "low",
43
+ "category": "string",
44
+ "message": "string",
45
+ "file": "string",
46
+ "line": number,
47
+ "suggestion": "string",
48
+ "suggested_code": "string or null"
49
+ }
50
+ ],
51
+ "summary": "2-3 sentence overall policy compliance assessment"
52
+ }
53
+ """
54
+
55
+
56
+ @register_agent
57
+ class AIPolicyAgent(BaseAgent):
58
+ name = "ai_policy"
59
+ description = "AI-powered policy and compliance reviewer using Gemini"
60
+
61
+ def _check(self, file_path: str, content: str, diff: str,
62
+ skeleton: str = "", line_count: int = None,
63
+ fast: bool = False) -> List[Finding]:
64
+ return review_with_agent(SYSTEM_PROMPT, file_path, content,
65
+ self.name, skeleton, line_count, diff, fast)