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