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_providers.py ADDED
@@ -0,0 +1,514 @@
1
+ """LLM provider initialization and API call dispatch.
2
+
3
+ Supports gemini, claude, and openai. Provider is selected via the
4
+ MODEL_PROVIDER env var (default: gemini). Each provider returns a
5
+ (client, provider_name, call_fn) tuple; call_fn is bound to the right
6
+ API shape so pipeline code never branches on provider.
7
+ """
8
+
9
+ import os
10
+ import json
11
+ import re
12
+ import time
13
+ import random
14
+ import logging
15
+ import importlib
16
+ import concurrent.futures
17
+
18
+ from prism import token_usage
19
+
20
+ def _int_env(name, default):
21
+ """Read an int env var, falling back to the default when unset or not a number.
22
+
23
+ A typo in a tuning variable must not take the whole CLI down at import time.
24
+ """
25
+ try:
26
+ return int(os.getenv(name, default))
27
+ except (TypeError, ValueError):
28
+ return int(default)
29
+
30
+
31
+ # Per-attempt wall-clock cap. A provider that accepts the socket but never
32
+ # answers (or an HTTP client that ignores its timeout) must not stall the CLI
33
+ # for minutes: every attempt is abandoned after this many seconds.
34
+ API_TIMEOUT = _int_env("PRISM_API_TIMEOUT", 30)
35
+ MAX_RETRIES = _int_env("PRISM_MAX_RETRIES", 2)
36
+
37
+ # How much file content is sent to the model in one call. Truncation is a cost
38
+ # control, not a correctness limit: the skeleton and the import graph carry the
39
+ # structure, so a large file still gets reviewed.
40
+ MAX_CONTENT_CHARS = _int_env("PRISM_MAX_CONTENT_CHARS", 16000)
41
+
42
+ # Retry pacing: exponential backoff with jitter, capped by a total budget so a
43
+ # bad provider cannot stall the CLI. Defaults keep a single flaky call well
44
+ # under a second of sleeping while still absorbing transient 504s.
45
+ RETRY_BASE_DELAY = float(os.getenv("PRISM_RETRY_BASE_DELAY", "0.2"))
46
+ RETRY_MAX_DELAY = float(os.getenv("PRISM_RETRY_MAX_DELAY", "1.0"))
47
+ RETRY_BUDGET = float(os.getenv("PRISM_RETRY_BUDGET", "5"))
48
+
49
+ # Simple circuit breaker: after repeated failures, fail fast for a cooldown
50
+ # instead of making every agent wait through the full retry ladder.
51
+ CIRCUIT_THRESHOLD = _int_env("PRISM_CIRCUIT_THRESHOLD", 5)
52
+ CIRCUIT_COOLDOWN = float(os.getenv("PRISM_CIRCUIT_COOLDOWN", "60"))
53
+
54
+ _logger = logging.getLogger(__name__)
55
+ _no_key_warned = False
56
+ _circuit_failures = 0
57
+ _circuit_open_until = 0.0
58
+
59
+ # Why get_model() most recently returned None. A missing SDK and a missing key
60
+ # both produce (None, None, None), so callers used to report "no API key" for
61
+ # both — a developer who ran `pip install prism_code_review-2.0.0-py3-none-any.whl`
62
+ # (no extras) was told to set a key that was already set. The reason is kept
63
+ # here so the pipeline can name the actual problem.
64
+ _MODEL_FAILURE = {"reason": None, "provider": None, "detail": ""}
65
+
66
+ # Which pip extra supplies the SDK for each client shape. pyproject declares
67
+ # gemini, claude and openai; the five OpenAI-compatible providers reuse the
68
+ # `openai` client, so they share one extra.
69
+ _EXTRA_BY_KIND = {"gemini": "gemini", "claude": "claude", "openai": "openai"}
70
+
71
+
72
+ def _set_model_failure(reason, provider=None, detail=""):
73
+ """Record why get_model() failed so callers can explain it accurately."""
74
+ _MODEL_FAILURE["reason"] = reason
75
+ _MODEL_FAILURE["provider"] = provider
76
+ _MODEL_FAILURE["detail"] = detail
77
+
78
+
79
+ def last_model_failure():
80
+ """Return (reason, provider, detail) for the most recent failure.
81
+
82
+ ``reason`` is one of "no_key", "no_sdk", "unknown_provider", or None when
83
+ the last call succeeded. ``provider`` is the resolved provider name, which
84
+ get_model() cannot return alongside the None triple, so callers can name
85
+ the provider in the message even though every value came back None.
86
+ """
87
+ return (_MODEL_FAILURE["reason"], _MODEL_FAILURE["provider"],
88
+ _MODEL_FAILURE["detail"])
89
+
90
+
91
+ def sdk_extra(provider):
92
+ """Return the pip extra that installs the SDK for ``provider``, or None."""
93
+ spec = PROVIDERS.get(_resolve_provider(provider))
94
+ if spec is None:
95
+ return None
96
+ return _EXTRA_BY_KIND.get(spec["kind"])
97
+
98
+
99
+ # Distribution name on PyPI. The "install it with" hint is printed by three
100
+ # modules, so the name lives here once instead of being repeated per call site.
101
+ DIST_NAME = "prism-code-review"
102
+
103
+
104
+ def install_hint(extra="all"):
105
+ """Return the pip command that installs ``extra`` of this distribution."""
106
+ return "pip install '{}[{}]'".format(DIST_NAME, extra)
107
+
108
+
109
+ def sdk_install_hint(provider):
110
+ """Return an actionable install command for a provider's missing SDK."""
111
+ extra = sdk_extra(provider)
112
+ if not extra:
113
+ return None
114
+ return install_hint(extra)
115
+
116
+ # Provider catalogue. `kind` selects the client shape; `key`/`model` are the
117
+ # env vars that hold the API key and model override. `base_url` (+ optional
118
+ # `base_url_default`) targets an OpenAI-compatible endpoint, which is how the
119
+ # free tiers (OpenRouter, Groq) and paid alternatives (Together, DeepSeek,
120
+ # Mistral) are supported without new client code.
121
+ PROVIDERS = {
122
+ "gemini": {
123
+ "kind": "gemini", "key": "GEMINI_API_KEY",
124
+ "model": "GEMINI_MODEL", "default_model": "gemini-3.5-flash-lite",
125
+ },
126
+ "claude": {
127
+ "kind": "claude", "key": "ANTHROPIC_API_KEY",
128
+ "model": "CLAUDE_MODEL", "default_model": "claude-3-5-sonnet-20241022",
129
+ },
130
+ "openai": {
131
+ "kind": "openai", "key": "OPENAI_API_KEY",
132
+ "model": "OPENAI_MODEL", "default_model": "gpt-4o",
133
+ "base_url": "OPENAI_BASE_URL",
134
+ },
135
+ "openrouter": {
136
+ "kind": "openai", "key": "OPENROUTER_API_KEY",
137
+ "model": "OPENROUTER_MODEL", "default_model": "openai/gpt-4o-mini",
138
+ "base_url": "OPENROUTER_BASE_URL",
139
+ "base_url_default": "https://openrouter.ai/api/v1",
140
+ },
141
+ "groq": {
142
+ "kind": "openai", "key": "GROQ_API_KEY",
143
+ "model": "GROQ_MODEL", "default_model": "llama-3.3-70b-versatile",
144
+ "base_url": "GROQ_BASE_URL",
145
+ "base_url_default": "https://api.groq.com/openai/v1",
146
+ },
147
+ "together": {
148
+ "kind": "openai", "key": "TOGETHER_API_KEY",
149
+ "model": "TOGETHER_MODEL",
150
+ "default_model": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
151
+ "base_url": "TOGETHER_BASE_URL",
152
+ "base_url_default": "https://api.together.xyz/v1",
153
+ },
154
+ "deepseek": {
155
+ "kind": "openai", "key": "DEEPSEEK_API_KEY",
156
+ "model": "DEEPSEEK_MODEL", "default_model": "deepseek-chat",
157
+ "base_url": "DEEPSEEK_BASE_URL",
158
+ "base_url_default": "https://api.deepseek.com",
159
+ },
160
+ "mistral": {
161
+ "kind": "openai", "key": "MISTRAL_API_KEY",
162
+ "model": "MISTRAL_MODEL", "default_model": "mistral-large-latest",
163
+ "base_url": "MISTRAL_BASE_URL",
164
+ "base_url_default": "https://api.mistral.ai/v1",
165
+ },
166
+ }
167
+
168
+ PROVIDER_ALIASES = {
169
+ "anthropic": "claude", "google": "gemini",
170
+ "gpt": "openai", "open-ai": "openai",
171
+ }
172
+
173
+ # Which provider/model the current call belongs to, for token accounting.
174
+ _ACTIVE_MODEL = {"provider": "gemini", "model": "gemini-3.5-flash-lite"}
175
+
176
+
177
+ def _warn_no_key(provider, env_var):
178
+ """Print a one-time, actionable message when no API key is configured.
179
+
180
+ Returning None silently made every AI agent return [] with no indication
181
+ why — a developer would assume their code is clean. This makes the
182
+ failure loud instead.
183
+ """
184
+ global _no_key_warned
185
+ if _no_key_warned:
186
+ return
187
+ _no_key_warned = True
188
+ _logger.warning(
189
+ "No API key for %s. AI agents are disabled. "
190
+ "Run `prism init` to scaffold .env, then add %s=your_key. "
191
+ "Deterministic regex agents still run with --effort lite.",
192
+ provider, env_var,
193
+ )
194
+
195
+
196
+ def _is_placeholder_key(key):
197
+ """Reject obviously fake keys so tests and misconfigured installs fail fast."""
198
+ if not key:
199
+ return True
200
+ lowered = key.lower()
201
+ return any(marker in lowered for marker in (
202
+ "your-", "example", "placeholder", "changeme", "xxx",
203
+ "api-key-here", "token-here", "secret-here",
204
+ ))
205
+
206
+
207
+ def _resolve_provider(provider):
208
+ """Normalize a provider name (aliases included) or read MODEL_PROVIDER."""
209
+ provider = (provider or os.getenv("MODEL_PROVIDER", "gemini")).lower()
210
+ return PROVIDER_ALIASES.get(provider, provider)
211
+
212
+
213
+ def _record_usage(input_tokens, output_tokens, prompt_text, response_text):
214
+ """Record one call's tokens, estimating when the provider gave none."""
215
+ provider = _ACTIVE_MODEL["provider"]
216
+ model = _ACTIVE_MODEL["model"]
217
+ if input_tokens or output_tokens:
218
+ token_usage.record(provider, model, input_tokens, output_tokens)
219
+ else:
220
+ token_usage.record(
221
+ provider, model,
222
+ token_usage.estimate_tokens(prompt_text),
223
+ token_usage.estimate_tokens(response_text),
224
+ estimated=True,
225
+ )
226
+
227
+
228
+ def get_model(provider=None, model_name=None):
229
+ """Lazy-load an LLM client. Returns (client, provider, call_fn) or (None, None, None)."""
230
+ provider = _resolve_provider(provider)
231
+ spec = PROVIDERS.get(provider)
232
+ if spec is None:
233
+ _logger.error("Unknown MODEL_PROVIDER: %s", provider)
234
+ _set_model_failure("unknown_provider", provider, provider)
235
+ return None, None, None
236
+
237
+ model_name = model_name or os.getenv(spec["model"]) or spec["default_model"]
238
+ api_key = os.getenv(spec["key"])
239
+ if not api_key or _is_placeholder_key(api_key):
240
+ _warn_no_key(provider, spec["key"])
241
+ _set_model_failure("no_key", provider, spec["key"])
242
+ return None, None, None
243
+
244
+ _ACTIVE_MODEL["provider"] = provider
245
+ _ACTIVE_MODEL["model"] = model_name
246
+ kind = spec["kind"]
247
+
248
+ if kind == "gemini":
249
+ try:
250
+ import google.generativeai as genai
251
+ genai.configure(api_key=api_key)
252
+ return genai.GenerativeModel(model_name), provider, _call_fn
253
+ except Exception as exc:
254
+ _logger.error("Failed to initialize Gemini: %s", exc)
255
+ _set_model_failure("no_sdk", provider, str(exc))
256
+ return None, None, None
257
+
258
+ if kind == "claude":
259
+ try:
260
+ anthropic = __import__("anthropic")
261
+ return anthropic.Anthropic(api_key=api_key), provider, _call_claude
262
+ except Exception as exc:
263
+ _logger.error("Failed to initialize Claude: %s", exc)
264
+ _set_model_failure("no_sdk", provider, str(exc))
265
+ return None, None, None
266
+
267
+ # openai / OpenAI-compatible
268
+ try:
269
+ openai = importlib.import_module("openai")
270
+ kwargs = {"api_key": api_key}
271
+ base_url = (os.getenv(spec.get("base_url", ""))
272
+ or spec.get("base_url_default"))
273
+ if base_url:
274
+ kwargs["base_url"] = base_url
275
+ return openai.OpenAI(**kwargs), provider, _call_openai
276
+ except Exception as exc:
277
+ _logger.error("Failed to initialize %s: %s", provider, exc)
278
+ _set_model_failure("no_sdk", provider, str(exc))
279
+ return None, None, None
280
+
281
+
282
+ def _build_prompt(system_prompt, file_path, content, skeleton="", cross_file_context=""):
283
+ """Assemble the user prompt from system + context + skeleton + content."""
284
+ parts = []
285
+ if cross_file_context:
286
+ parts.append(cross_file_context)
287
+ if skeleton:
288
+ parts.append("\n## Structural Skeleton (table of contents for this file)\n" + skeleton)
289
+ parts.append("\n## File: {}\n".format(file_path))
290
+ parts.append("\n## Content\n{}".format(content[:MAX_CONTENT_CHARS]))
291
+ return system_prompt + "\n" + "\n".join(parts)
292
+
293
+
294
+ def _call_fn(model, system_prompt, file_path, content, agent_name,
295
+ skeleton="", timeout=None, cross_file_context=""):
296
+ """Call Gemini API with retry logic and circuit breaker."""
297
+ if timeout is None:
298
+ timeout = API_TIMEOUT
299
+ prompt = _build_prompt(system_prompt, file_path, content, skeleton, cross_file_context)
300
+
301
+ def _do_generate(prompt_text, _timeout=timeout):
302
+ # Ask for JSON at the API level: it removes the markdown fences and
303
+ # prose preambles that used to make chunked reviews fail parsing.
304
+ response = model.generate_content(
305
+ prompt_text,
306
+ generation_config={"response_mime_type": "application/json"},
307
+ request_options={"timeout": _timeout},
308
+ )
309
+ text = response.text or "{}"
310
+ usage = getattr(response, "usage_metadata", None)
311
+ _record_usage(
312
+ getattr(usage, "prompt_token_count", 0),
313
+ getattr(usage, "candidates_token_count", 0),
314
+ prompt_text, text,
315
+ )
316
+ return text
317
+
318
+ return _call_with_retry(_do_generate, prompt, agent_name, timeout)
319
+
320
+
321
+ def _call_claude(model, system_prompt, file_path, content, agent_name,
322
+ skeleton="", timeout=None, cross_file_context=""):
323
+ """Call Claude API with retry logic and circuit breaker."""
324
+ if timeout is None:
325
+ timeout = API_TIMEOUT
326
+ user_prompt = _build_prompt("", file_path, content, skeleton, cross_file_context)
327
+
328
+ def _do_call():
329
+ response = model.messages.create(
330
+ model=_ACTIVE_MODEL["model"],
331
+ max_tokens=4096,
332
+ system=system_prompt,
333
+ messages=[{"role": "user", "content": user_prompt}],
334
+ )
335
+ text = response.content[0].text
336
+ usage = getattr(response, "usage", None)
337
+ _record_usage(
338
+ getattr(usage, "input_tokens", 0),
339
+ getattr(usage, "output_tokens", 0),
340
+ user_prompt, text,
341
+ )
342
+ return text
343
+
344
+ return _call_with_retry(_do_call, None, agent_name, timeout)
345
+
346
+
347
+ def _call_openai(model, system_prompt, file_path, content, agent_name,
348
+ skeleton="", timeout=None, cross_file_context=""):
349
+ """Call OpenAI API with retry logic and circuit breaker."""
350
+ if timeout is None:
351
+ timeout = API_TIMEOUT
352
+ user_prompt = _build_prompt("", file_path, content, skeleton, cross_file_context)
353
+
354
+ def _do_call():
355
+ response = model.chat.completions.create(
356
+ model=_ACTIVE_MODEL["model"],
357
+ messages=[
358
+ {"role": "system", "content": system_prompt},
359
+ {"role": "user", "content": user_prompt},
360
+ ],
361
+ response_format={"type": "json_object"},
362
+ )
363
+ text = response.choices[0].message.content
364
+ usage = getattr(response, "usage", None)
365
+ _record_usage(
366
+ getattr(usage, "prompt_tokens", 0),
367
+ getattr(usage, "completion_tokens", 0),
368
+ user_prompt, text,
369
+ )
370
+ return text
371
+
372
+ return _call_with_retry(_do_call, None, agent_name, timeout)
373
+
374
+
375
+ def _parse_json_response(text):
376
+ """Extract a JSON object from a model response.
377
+
378
+ Models sometimes wrap JSON in prose or markdown fences, or emit trailing
379
+ commas. Return the parsed dict, or None if no valid object is present.
380
+ """
381
+ if not text:
382
+ return None
383
+ cleaned = str(text).replace("```json", "").replace("```", "").strip()
384
+ try:
385
+ return json.loads(cleaned)
386
+ except (json.JSONDecodeError, TypeError):
387
+ pass
388
+
389
+ start = cleaned.find("{")
390
+ end = cleaned.rfind("}")
391
+ if start == -1 or end <= start:
392
+ return None
393
+ candidate = cleaned[start:end + 1]
394
+ try:
395
+ return json.loads(candidate)
396
+ except json.JSONDecodeError:
397
+ pass
398
+ repaired = re.sub(r",\s*([}\]])", r"\1", candidate)
399
+ try:
400
+ return json.loads(repaired)
401
+ except json.JSONDecodeError:
402
+ return None
403
+
404
+
405
+ def _circuit_open():
406
+ return time.monotonic() < _circuit_open_until
407
+
408
+
409
+ def _record_failure():
410
+ global _circuit_failures, _circuit_open_until
411
+ _circuit_failures += 1
412
+ if _circuit_failures >= CIRCUIT_THRESHOLD:
413
+ _circuit_open_until = time.monotonic() + CIRCUIT_COOLDOWN
414
+ _logger.error(
415
+ "Provider circuit opened after %d consecutive failures; "
416
+ "pausing AI calls for %ds", _circuit_failures, int(CIRCUIT_COOLDOWN)
417
+ )
418
+
419
+
420
+ def _record_success():
421
+ global _circuit_failures, _circuit_open_until
422
+ _circuit_failures = 0
423
+ _circuit_open_until = 0.0
424
+
425
+
426
+ def _retry_delay(attempt, deadline):
427
+ """Backoff with jitter, never sleeping past the total retry budget."""
428
+ remaining = deadline - time.monotonic()
429
+ if remaining <= 0:
430
+ return 0.0
431
+ delay = min(RETRY_MAX_DELAY, RETRY_BASE_DELAY * (2 ** attempt))
432
+ delay = min(delay, remaining)
433
+ jitter_cap = min(0.1, max(0.0, remaining - delay))
434
+ return delay + random.uniform(0, jitter_cap)
435
+
436
+
437
+ def _invoke_with_deadline(call_fn, prompt, timeout):
438
+ """Run a provider call in a worker thread and abandon it after `timeout`.
439
+
440
+ The provider SDK's own timeout is not always honoured (a hung TCP
441
+ connection can block past it), and the retry ladder cannot help while the
442
+ first call is still running. Bounding the wall clock here keeps every
443
+ agent's worst case at `(MAX_RETRIES + 1) * timeout`.
444
+ """
445
+ if prompt is not None:
446
+ def _run():
447
+ return call_fn(prompt)
448
+ else:
449
+ def _run():
450
+ return call_fn()
451
+
452
+ executor = concurrent.futures.ThreadPoolExecutor(max_workers=1)
453
+ future = executor.submit(_run)
454
+ try:
455
+ return future.result(timeout=timeout)
456
+ except concurrent.futures.TimeoutError:
457
+ _logger.error("provider call exceeded %.0fs; abandoning attempt", timeout)
458
+ raise TimeoutError("provider call exceeded {:.0f}s".format(timeout))
459
+ finally:
460
+ try:
461
+ executor.shutdown(wait=False, cancel_futures=True)
462
+ except TypeError: # Python < 3.9 has no cancel_futures
463
+ executor.shutdown(wait=False)
464
+
465
+
466
+ def _call_with_retry(call_fn, prompt, agent_name, timeout):
467
+ """Shared retry + JSON parse logic for all providers.
468
+
469
+ Retries on transport errors *and* malformed JSON, with capped backoff and
470
+ a circuit breaker. Always returns a dict; failures carry `_failed`.
471
+ """
472
+ deadline = time.monotonic() + RETRY_BUDGET
473
+ last_error = "unknown error"
474
+ error_kind = "transport"
475
+ attempts = 0
476
+
477
+ for attempt in range(MAX_RETRIES + 1):
478
+ attempts += 1
479
+ if _circuit_open():
480
+ _logger.error("%s skipped: provider circuit is open", agent_name)
481
+ return {"findings": [], "summary": "Provider temporarily unavailable",
482
+ "_failed": True, "_error": "provider circuit is open",
483
+ "_error_kind": "circuit_open"}
484
+
485
+ attempt_timeout = max(1.0, timeout or API_TIMEOUT)
486
+ try:
487
+ text = _invoke_with_deadline(call_fn, prompt, attempt_timeout)
488
+ data = _parse_json_response(text)
489
+ if data is not None:
490
+ _record_success()
491
+ return data
492
+ last_error = "model did not return valid JSON"
493
+ error_kind = "invalid_json"
494
+ except Exception as exc:
495
+ last_error = str(exc)
496
+ error_kind = "transport"
497
+
498
+ if attempt >= MAX_RETRIES:
499
+ break
500
+
501
+ delay = _retry_delay(attempt, deadline)
502
+ if delay <= 0:
503
+ _logger.warning("%s retry budget exhausted after %s", agent_name, last_error)
504
+ break
505
+ _logger.warning(
506
+ "%s attempt %d/%d failed: %s. Retrying in %.1fs...",
507
+ agent_name, attempt + 1, MAX_RETRIES + 1, last_error, delay
508
+ )
509
+ time.sleep(delay)
510
+
511
+ _record_failure()
512
+ message = "API error after {} attempt(s): {}".format(attempts, last_error)
513
+ return {"findings": [], "summary": message, "_failed": True, "_error": message,
514
+ "_error_kind": error_kind}
prism/ai_quality.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ AI Quality Agent - Uses Gemini for code quality review.
3
+
4
+ Catches: code complexity, duplication, maintainability issues, naming problems,
5
+ dead code, poor architecture, and technical debt indicators.
6
+
7
+ Rule IDs follow SonarQube convention: QUAL-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 senior software engineer with expertise in code quality and maintainability.
19
+ You review code for quality issues that automated tools and pattern matching cannot detect.
20
+
21
+ Focus on:
22
+ 1. Code complexity (deep nesting, long functions, high cyclomatic complexity)
23
+ 2. Code duplication (repeated logic that should be extracted)
24
+ 3. Maintainability issues (hardcoded values, magic numbers, unclear intent)
25
+ 4. Naming problems (confusing variable names, inconsistent conventions)
26
+ 5. Dead code (unused functions, unreachable code, commented-out blocks)
27
+ 6. Poor architecture (tight coupling, missing abstraction, God functions)
28
+ 7. Technical debt markers (TODO, FIXME, HACK, XXX)
29
+ 8. Inconsistent style and formatting that hurts readability
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. Ignore minor style preferences. Focus on issues that actually affect maintainability.
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 quality assessment"
52
+ }
53
+ """
54
+
55
+
56
+ @register_agent
57
+ class AIQualityAgent(BaseAgent):
58
+ name = "ai_quality"
59
+ description = "AI-powered code quality 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)
prism/ai_security.py ADDED
@@ -0,0 +1,65 @@
1
+ """
2
+ AI Security Agent - Uses Gemini for security code review.
3
+
4
+ Catches: hardcoded secrets, injection vulnerabilities, authentication flaws,
5
+ authorization issues, cryptographic weaknesses, and insecure dependencies.
6
+
7
+ Rule IDs follow SonarQube convention: SECU-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 an expert application security engineer with 15+ years of experience.
19
+ You review code for security vulnerabilities that pattern matching cannot detect.
20
+
21
+ Focus on:
22
+ 1. Hardcoded secrets (API keys, passwords, tokens, private keys)
23
+ 2. Injection vulnerabilities (SQL, command, XSS, LDAP, template)
24
+ 3. Authentication and authorization flaws
25
+ 4. Cryptographic weaknesses (weak algorithms, hardcoded salts, insecure randomness)
26
+ 5. Insecure dependencies and known CVE patterns
27
+ 6. Data exposure (sensitive data in logs, error messages, URLs)
28
+ 7. Race conditions and concurrency issues
29
+ 8. Insecure deserialization
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. Ignore style, formatting, naming conventions, and minor code cleanliness.
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 security assessment"
52
+ }
53
+ """
54
+
55
+
56
+ @register_agent
57
+ class AISecurityAgent(BaseAgent):
58
+ name = "ai_security"
59
+ description = "AI-powered security 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)