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/agents_security.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Security Agent - Scans for hardcoded secrets, dangerous patterns,
|
|
3
|
+
insecure dependencies, and common vulnerability signatures.
|
|
4
|
+
|
|
5
|
+
Rule IDs follow SonarQube convention: SECU-XXX
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import re
|
|
9
|
+
from typing import List
|
|
10
|
+
from prism.agents import BaseAgent, Finding, Severity, Blocker, register_agent
|
|
11
|
+
from prism.langs import LANGUAGES, lang_for
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
SECRET_PATTERNS = [
|
|
15
|
+
(r'(?i)(api[_-]?key|apikey)\s*[:=]\s*["\']([A-Za-z0-9_\-]{16,})["\']', "API Key", "SECU-101"),
|
|
16
|
+
# Prefixed names are the norm in real config (DB_PASSWORD, STRIPE_SECRET,
|
|
17
|
+
# SESSION_KEY); requiring the bare name at line start missed them all.
|
|
18
|
+
(r'(?i)(secret[_-]?key|secretkey|session[_-]?key|sessionkey)\s*[:=]\s*["\']([A-Za-z0-9_\-]{16,})["\']', "Secret Key", "SECU-102"),
|
|
19
|
+
(r'(?i)^\s*(?:[A-Za-z0-9]+[_-])?(?:password|passwd|pwd)\s*[:=]\s*["\']([^\s\'\"\n]{8,})["\']', "Password", "SECU-103"),
|
|
20
|
+
(r'(?i)(token|access_token)\s*[:=]\s*["\']([A-Za-z0-9_\-\.]{20,})["\']', "Auth Token", "SECU-104"),
|
|
21
|
+
(r'AKIA[0-9A-Z]{16}', "AWS Access Key ID", "SECU-105"),
|
|
22
|
+
(r'(?i)(github|gitlab)_token\s*[:=]\s*["\']([A-Za-z0-9_\-]{20,})["\']', "Git Token", "SECU-106"),
|
|
23
|
+
(r'(?i)(private_key|privatekey)\s*[:=]\s*["\']([^\s\'\"\n]{20,})["\']', "Private Key", "SECU-107"),
|
|
24
|
+
(r'(?i)(mysql|postgres|mongodb)://[^\s\'\"<>]+', "Database Connection String", "SECU-108"),
|
|
25
|
+
# --- Gitleaks-style provider-specific secrets (researched from gitleaks, trufflehog, detect-secrets) ---
|
|
26
|
+
(r'(?i)slack[_-]?token\s*[:=]\s*["\']([xox[baprs]-[A-Za-z0-9\-]{10,})["\']', "Slack Token", "SECU-109"),
|
|
27
|
+
(r'(?i)slack[_-]?webhook\s*[:=]\s*["\'](https://hooks\.slack\.com/services/[A-Za-z0-9/]+)["\']', "Slack Webhook URL", "SECU-110"),
|
|
28
|
+
# Real Stripe test keys are ~16 chars after the prefix (sk_test_51H8...),
|
|
29
|
+
# so a 24-char minimum let them through.
|
|
30
|
+
(r'(?i)(sk|pk)_(live|test)_[A-Za-z0-9]{12,}', "Stripe Key", "SECU-111"),
|
|
31
|
+
(r'(?i)twilio[_-]?(auth[_-]?token|sid)\s*[:=]\s*["\']([A-Za-z0-9]{20,})["\']', "Twilio Credential", "SECU-112"),
|
|
32
|
+
(r'(?i)sendgrid[_-]?api[_-]?key\s*[:=]\s*["\']([A-Za-z0-9_\-\.]{20,})["\']', "SendGrid API Key", "SECU-113"),
|
|
33
|
+
(r'(?i)(google[_-]?api[_-]?key|gcp[_-]?key)\s*[:=]\s*["\']([A-Za-z0-9_\-]{20,})["\']', "Google API Key", "SECU-114"),
|
|
34
|
+
(r'AIza[0-9A-Za-z_\-]{35}', "Google API Key (literal)", "SECU-115"),
|
|
35
|
+
(r'-----BEGIN (RSA |EC |DSA |OPENSSH )?PRIVATE KEY-----', "PEM Private Key Block", "SECU-116"),
|
|
36
|
+
(r'(?i)jwt[_-]?secret\s*[:=]\s*["\']([^\s\'\"\n]{8,})["\']', "JWT Secret", "SECU-117"),
|
|
37
|
+
(r'(?i)(npm[_-]?token|npm[_-]?access[_-]?token)\s*[:=]\s*["\']([A-Za-z0-9_\-]{20,})["\']', "npm Token", "SECU-118"),
|
|
38
|
+
(r'(?i)(heroku|digitalocean)[_-]?(api[_-]?key|token)\s*[:=]\s*["\']([A-Za-z0-9_\-]{20,})["\']', "Cloud Provider Token", "SECU-119"),
|
|
39
|
+
(r'(?i)(auth[_-]?token|authtoken)\s*[:=]\s*["\']([A-Za-z0-9_\-\.]{16,})["\']', "Auth Token (generic)", "SECU-120"),
|
|
40
|
+
(r'(?i)(client[_-]?secret)\s*[:=]\s*["\']([^\s\'\"\n]{16,})["\']', "Client Secret", "SECU-121"),
|
|
41
|
+
(r'(?i)(access[_-]?key[_-]?id)\s*[:=]\s*["\']([A-Za-z0-9]{16,})["\']', "Access Key ID", "SECU-122"),
|
|
42
|
+
# Unquoted dotenv-style assignments (PASSWORD=hunter2). Quoted values are
|
|
43
|
+
# covered by the provider-specific patterns above; this catches .env files,
|
|
44
|
+
# which are the most common place for real secrets to leak.
|
|
45
|
+
(r'(?i)^\s*(?:\w+[_-])?(?:api[_-]?key|apikey|secret[_-]?(?:access[_-]?)?key|secretkey|password|passwd|pwd|token|access[_-]?key[_-]?id|access[_-]?token|auth[_-]?token|client[_-]?secret)\s*[:=]\s*([^\s"\']{6,})\s*$', "Unquoted Dotenv Secret", "SECU-123"),
|
|
46
|
+
# The generic patterns expect `name = "value"` or `name: "value"`. A C
|
|
47
|
+
# preprocessor constant and a shell export say the same thing with a keyword
|
|
48
|
+
# in front, and both were invisible.
|
|
49
|
+
(r'(?i)^\s*#define\s+\w*(?:key|token|secret|password|passwd|pwd)\s+"([^"\n]{8,})"', "C Preprocessor Secret", "SECU-124"),
|
|
50
|
+
(r'(?i)^\s*export\s+\w*(?:key|token|secret|password|passwd|pwd)\s*=\s*([^\s"\']{8,})', "Shell Exported Secret", "SECU-125"),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
# `name := "value"` declares a variable in Go the way `name = "value"` assigns
|
|
54
|
+
# one elsewhere. The generic patterns only knew `:` and `=`, so a literal secret
|
|
55
|
+
# in a short declaration was invisible; widening the operator once covers every
|
|
56
|
+
# pattern above.
|
|
57
|
+
SECRET_PATTERNS = [
|
|
58
|
+
(pattern.replace(r"\s*[:=]\s*", r"\s*(?::=|[:=])\s*"), label, rule_id)
|
|
59
|
+
for pattern, label, rule_id in SECRET_PATTERNS
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
# The base list is Python/Node-shaped. These are the same defect classes in the
|
|
63
|
+
# languages a regex can see without a parser, keyed by LanguageSpec.name.
|
|
64
|
+
_LANG_DANGEROUS = {
|
|
65
|
+
"go": [
|
|
66
|
+
(r'exec\.Command\(\s*"(?:/bin/|/usr/bin/)?(?:ba)?sh"', "Shell command via sh - command injection risk", "SECU-220"),
|
|
67
|
+
(r'template\.HTML\s*\(', "Unescaped HTML in template - XSS risk", "SECU-221"),
|
|
68
|
+
],
|
|
69
|
+
"java": [
|
|
70
|
+
(r'Runtime\.getRuntime\(\)\.exec\s*\(', "Runtime.exec - command injection risk", "SECU-222"),
|
|
71
|
+
(r'MessageDigest\.getInstance\(\s*"MD5"', "Use of MD5 - weak hash", "SECU-223"),
|
|
72
|
+
],
|
|
73
|
+
"csharp": [
|
|
74
|
+
(r'\bProcess\.Start\s*\(', "Process.Start - command injection risk", "SECU-224"),
|
|
75
|
+
(r'\bMD5\.Create\s*\(', "Use of MD5 - weak hash", "SECU-225"),
|
|
76
|
+
],
|
|
77
|
+
"c": [
|
|
78
|
+
(r'\bsystem\s*\(', "Use of system() - command injection risk", "SECU-226"),
|
|
79
|
+
(r'\bgets\s*\(', "Use of gets() - unbounded read, buffer overflow", "SECU-227"),
|
|
80
|
+
(r'\bstrcpy\s*\(', "Use of strcpy() - unbounded copy, buffer overflow", "SECU-228"),
|
|
81
|
+
],
|
|
82
|
+
"cpp": [
|
|
83
|
+
(r'\bsystem\s*\(', "Use of system() - command injection risk", "SECU-226"),
|
|
84
|
+
(r'\bgets\s*\(', "Use of gets() - unbounded read, buffer overflow", "SECU-227"),
|
|
85
|
+
(r'\bstrcpy\s*\(', "Use of strcpy() - unbounded copy, buffer overflow", "SECU-228"),
|
|
86
|
+
],
|
|
87
|
+
"rust": [
|
|
88
|
+
(r'Command::new\(\s*"(?:/bin/|/usr/bin/)?(?:ba)?sh"', "Shell command via sh - command injection risk", "SECU-229"),
|
|
89
|
+
(r'\bmd5\s*::', "Use of MD5 - weak hash", "SECU-230"),
|
|
90
|
+
],
|
|
91
|
+
"php": [
|
|
92
|
+
(r'\b(?:system|exec|shell_exec|passthru|proc_open)\s*\(', "Shell execution - command injection risk", "SECU-231"),
|
|
93
|
+
(r'\bmd5\s*\(', "Use of MD5 - weak hash", "SECU-232"),
|
|
94
|
+
],
|
|
95
|
+
"ruby": [
|
|
96
|
+
(r'\bsystem\s*\(', "Use of system() - command injection risk", "SECU-233"),
|
|
97
|
+
(r'Digest::MD5\b', "Use of MD5 - weak hash", "SECU-234"),
|
|
98
|
+
],
|
|
99
|
+
"javascript": [
|
|
100
|
+
(r'dangerouslySetInnerHTML', "dangerouslySetInnerHTML - XSS risk", "SECU-235"),
|
|
101
|
+
(r'child_process\.exec\s*\(', "child_process.exec - command injection risk", "SECU-236"),
|
|
102
|
+
],
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# Gitleaks-style false-positive filtering, split into two tiers:
|
|
106
|
+
# SECRET_VALUE_ALLOWLIST — obvious placeholders inside the secret itself
|
|
107
|
+
# (e.g. "xxx", "changeme", "your_key_here").
|
|
108
|
+
# Kept narrow on purpose so real-looking keys like
|
|
109
|
+
# AKIAIOSFODNN7EXAMPLE still get flagged.
|
|
110
|
+
# SECRET_LINE_ALLOWLIST — the surrounding line reads like a test fixture,
|
|
111
|
+
# env-var lookup, or config template.
|
|
112
|
+
SECRET_VALUE_ALLOWLIST = [
|
|
113
|
+
r'(?i)xxx|yyy|zzz|changeme|placeholder|your[_-]?key|dummy[_-]?key|sample[_-]?key',
|
|
114
|
+
r'(?i)<your[_-]?[^>]*>|<insert[^>]*>',
|
|
115
|
+
r'\{\{[^}]*\}\}',
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
SECRET_LINE_ALLOWLIST = [
|
|
119
|
+
r'(?i)test[_-]?key|mock[_-]?key|fake[_-]?key|dev[_-]?key',
|
|
120
|
+
r'(?i)os\.getenv|os\.environ|process\.env|import\.env',
|
|
121
|
+
r'\.env\b|\.env\.example|\.env\.template',
|
|
122
|
+
r'(?i)redacted|sanitized|scrubbed',
|
|
123
|
+
]
|
|
124
|
+
|
|
125
|
+
DANGEROUS_PATTERNS = [
|
|
126
|
+
(r"\beval\s*\(", "Use of eval() - code injection risk", "SECU-201"),
|
|
127
|
+
(r"\bexec\s*\(", "Use of exec() - code injection risk", "SECU-202"),
|
|
128
|
+
(r"subprocess\.(call|run|Popen)\s*\([^)]*shell\s*=\s*True", "Shell=True in subprocess - command injection risk", "SECU-203"),
|
|
129
|
+
(r"os\.system\s*\(", "Use of os.system() - command injection risk", "SECU-204"),
|
|
130
|
+
(r"pickle\.loads?\s*\(", "Use of pickle - deserialization risk", "SECU-205"),
|
|
131
|
+
(r"yaml\.load\s*\(", "Use of yaml.load() without SafeLoader - deserialization risk", "SECU-206"),
|
|
132
|
+
(r"yaml\.load\s*\([^)]*Loader\s*=\s*c\.SafeLoader", "yaml.load with SafeLoader (safe, no finding)", "SECU-206-SAFE"),
|
|
133
|
+
(r"SELECT\s+.+\s+FROM\s+.+\s+WHERE\s+.+\s*=\s*['\"].*\+", "SQL injection - string concatenation in query", "SECU-207"),
|
|
134
|
+
(r"document\.getElementById\([^)]*\)\.innerHTML\s*=", "XSS - innerHTML assignment", "SECU-208"),
|
|
135
|
+
(r"\.\w+\s*\+\s*['\"]", "Potential XSS - string concatenation in HTML", "SECU-209"),
|
|
136
|
+
(r"md5\s*\(", "Use of MD5 - weak hash", "SECU-210"),
|
|
137
|
+
(r"\bsha1\s*\(", "Use of SHA1 - weak hash", "SECU-211"),
|
|
138
|
+
(r"random\.random\(\)\s*\)?\s*==|random\.randint\(\s*0\s*,", "Insecure randomness (not crypto-safe)", "SECU-212"),
|
|
139
|
+
(r"requests\.(get|post|put|delete)\s*\([^)]*verify\s*=\s*False", "TLS verification disabled", "SECU-213"),
|
|
140
|
+
(r"chmod\s+777|os\.chmod\([^)]*0o?777", "Overly permissive file permissions (777)", "SECU-214"),
|
|
141
|
+
(r"harvest|harvesting|harvested", "Possible data exfiltration keyword", "SECU-215"),
|
|
142
|
+
]
|
|
143
|
+
|
|
144
|
+
INSECURE_DEPENDENCIES = {
|
|
145
|
+
"lodash": ("Use lodash-es or individual packages", "SECU-301"),
|
|
146
|
+
"moment": ("Replace with date-fns or luxon", "SECU-302"),
|
|
147
|
+
"axios": ("Use fetch or got", "SECU-303"),
|
|
148
|
+
"request": ("Deprecated - use got or node-fetch", "SECU-304"),
|
|
149
|
+
"node-uuid": ("Replace with crypto.randomUUID()", "SECU-305"),
|
|
150
|
+
"uuid": ("Use crypto.randomUUID() instead", "SECU-306"),
|
|
151
|
+
"request-promise": ("Use native fetch or got", "SECU-307"),
|
|
152
|
+
"body-parser": ("Use express.json() directly", "SECU-308"),
|
|
153
|
+
"helmet": ("Outdated - use express-helmet or helmet v5+", "SECU-309"),
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@register_agent
|
|
158
|
+
class SecurityAgent(BaseAgent):
|
|
159
|
+
name = "security"
|
|
160
|
+
description = "Scans for secrets, dangerous patterns, and vulnerabilities"
|
|
161
|
+
|
|
162
|
+
def _check(self, file_path: str, content: str, diff: str,
|
|
163
|
+
skeleton: str = "", line_count: int = None,
|
|
164
|
+
fast: bool = False) -> List[Finding]:
|
|
165
|
+
findings: List[Finding] = []
|
|
166
|
+
lines = content.split("\n")
|
|
167
|
+
current_file = file_path
|
|
168
|
+
spec = lang_for(file_path)
|
|
169
|
+
|
|
170
|
+
# Reading a secret from the environment is a lookup, not a hardcoded
|
|
171
|
+
# value. Every language spells it differently, so a Go or Java
|
|
172
|
+
# `getenv` used to be reported as a leaked secret.
|
|
173
|
+
line_allowlist = SECRET_LINE_ALLOWLIST + [
|
|
174
|
+
pattern for lang in LANGUAGES for pattern in lang.env_lookup
|
|
175
|
+
]
|
|
176
|
+
dangerous = list(DANGEROUS_PATTERNS)
|
|
177
|
+
if spec is not None:
|
|
178
|
+
dangerous += _LANG_DANGEROUS.get(spec.name, [])
|
|
179
|
+
|
|
180
|
+
for i, line in enumerate(lines, 1):
|
|
181
|
+
for pattern, label, rule_id in SECRET_PATTERNS:
|
|
182
|
+
match = re.search(pattern, line, re.IGNORECASE)
|
|
183
|
+
if not match:
|
|
184
|
+
continue
|
|
185
|
+
# Gitleaks-style two-tier false-positive filtering.
|
|
186
|
+
# Tier 1 (value): obvious placeholders inside the secret itself
|
|
187
|
+
# — kept narrow so real-looking keys (e.g. AKIAIOSFODNN7EXAMPLE)
|
|
188
|
+
# still get flagged.
|
|
189
|
+
# Tier 2 (line): the surrounding line reads like a test fixture,
|
|
190
|
+
# env-var lookup, or config template.
|
|
191
|
+
captured = match.group(match.lastindex) if match.lastindex else ""
|
|
192
|
+
if any(re.search(v, captured, re.IGNORECASE)
|
|
193
|
+
for v in SECRET_VALUE_ALLOWLIST):
|
|
194
|
+
continue
|
|
195
|
+
if any(re.search(a, line, re.IGNORECASE)
|
|
196
|
+
for a in line_allowlist):
|
|
197
|
+
continue
|
|
198
|
+
findings.append(Finding(
|
|
199
|
+
agent=self.name,
|
|
200
|
+
severity=Severity.CRITICAL,
|
|
201
|
+
category="Secret Exposure",
|
|
202
|
+
message="Possible {} detected".format(label.lower()),
|
|
203
|
+
file=current_file,
|
|
204
|
+
line=i,
|
|
205
|
+
suggestion="Remove hardcoded secret. Use environment variables or a secrets manager.",
|
|
206
|
+
blocker=Blocker.BLOCKS,
|
|
207
|
+
confidence=0.85,
|
|
208
|
+
rule_id=rule_id,
|
|
209
|
+
))
|
|
210
|
+
|
|
211
|
+
for pattern, label, rule_id in dangerous:
|
|
212
|
+
if rule_id == "SECU-206-SAFE":
|
|
213
|
+
continue # positive match, not a finding
|
|
214
|
+
if re.search(pattern, line, re.IGNORECASE):
|
|
215
|
+
findings.append(Finding(
|
|
216
|
+
agent=self.name,
|
|
217
|
+
severity=Severity.HIGH,
|
|
218
|
+
category="Dangerous Pattern",
|
|
219
|
+
message=label,
|
|
220
|
+
file=current_file,
|
|
221
|
+
line=i,
|
|
222
|
+
suggestion="Review this pattern for security risks.",
|
|
223
|
+
blocker=Blocker.BLOCKS,
|
|
224
|
+
confidence=0.80,
|
|
225
|
+
rule_id=rule_id,
|
|
226
|
+
))
|
|
227
|
+
|
|
228
|
+
if file_path.endswith(("package.json", "requirements.txt", "Cargo.toml", "go.mod")):
|
|
229
|
+
for dep, (advice, rule_id) in INSECURE_DEPENDENCIES.items():
|
|
230
|
+
if dep in content:
|
|
231
|
+
findings.append(Finding(
|
|
232
|
+
agent=self.name,
|
|
233
|
+
severity=Severity.MEDIUM,
|
|
234
|
+
category="Insecure Dependency",
|
|
235
|
+
message="Use of '{}' - {}".format(dep, advice),
|
|
236
|
+
file=current_file,
|
|
237
|
+
suggestion=advice,
|
|
238
|
+
blocker=Blocker.WARNS,
|
|
239
|
+
confidence=0.90,
|
|
240
|
+
rule_id=rule_id,
|
|
241
|
+
))
|
|
242
|
+
|
|
243
|
+
return findings
|
prism/agents_test.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Test Agent - Validates that tests exist, tests pass, and measures coverage.
|
|
3
|
+
|
|
4
|
+
Rule IDs follow SonarQube convention: TST-XXX
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import ast
|
|
8
|
+
import re
|
|
9
|
+
from typing import List
|
|
10
|
+
from prism.agents import BaseAgent, Finding, Severity, Blocker, register_agent
|
|
11
|
+
from prism.feedback import measure_coverage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@register_agent
|
|
15
|
+
class TestAgent(BaseAgent):
|
|
16
|
+
name = "test"
|
|
17
|
+
description = "Checks test coverage and validates test files"
|
|
18
|
+
|
|
19
|
+
def _check(self, file_path: str, content: str, diff: str,
|
|
20
|
+
skeleton: str = "", line_count: int = None,
|
|
21
|
+
fast: bool = False) -> List[Finding]:
|
|
22
|
+
findings: List[Finding] = []
|
|
23
|
+
lines = content.split("\n")
|
|
24
|
+
is_test_file = "test" in file_path.lower() or "spec" in file_path.lower()
|
|
25
|
+
is_python = file_path.endswith(".py")
|
|
26
|
+
|
|
27
|
+
# TST-101 is a WARNING, not a blocker. Missing tests are important to
|
|
28
|
+
# surface but should never block a commit — that would make the
|
|
29
|
+
# tool unusable on its own codebase (every module without a paired
|
|
30
|
+
# test file would fail). Developers see the warning and decide.
|
|
31
|
+
if not is_test_file and is_python and len(lines) > 20:
|
|
32
|
+
findings.append(Finding(
|
|
33
|
+
agent=self.name,
|
|
34
|
+
severity=Severity.MEDIUM,
|
|
35
|
+
category="Missing Tests",
|
|
36
|
+
message="No test file found for {}".format(file_path),
|
|
37
|
+
file=file_path,
|
|
38
|
+
suggestion="Add unit tests before committing.",
|
|
39
|
+
blocker=Blocker.WARNS,
|
|
40
|
+
confidence=0.75,
|
|
41
|
+
rule_id="TST-101",
|
|
42
|
+
))
|
|
43
|
+
|
|
44
|
+
if is_test_file:
|
|
45
|
+
test_bodies = re.findall(
|
|
46
|
+
r'def\s+(test_\w+)\(.*\):\s*\n\s*"""(.*?)"""', content, re.DOTALL
|
|
47
|
+
)
|
|
48
|
+
for name, docstring in test_bodies:
|
|
49
|
+
if not docstring.strip():
|
|
50
|
+
findings.append(Finding(
|
|
51
|
+
agent=self.name,
|
|
52
|
+
severity=Severity.LOW,
|
|
53
|
+
category="Unclear Test",
|
|
54
|
+
message="Test '{}' has no docstring".format(name),
|
|
55
|
+
file=file_path,
|
|
56
|
+
line=1,
|
|
57
|
+
suggestion="Add a description of what the test validates.",
|
|
58
|
+
blocker=Blocker.WARNS,
|
|
59
|
+
confidence=0.85,
|
|
60
|
+
rule_id="TST-102",
|
|
61
|
+
))
|
|
62
|
+
findings.extend(_check_weak_assertions(content, file_path))
|
|
63
|
+
findings.extend(_check_test_naming(content, file_path))
|
|
64
|
+
|
|
65
|
+
# Measure actual coverage. This is a repo-wide metric (runs pytest on the
|
|
66
|
+
# whole project), not a per-file one — running it on every file review
|
|
67
|
+
# made the LSP server take 5.6s per file. It now only runs in
|
|
68
|
+
# balanced/max effort (fast=False); lite mode skips it entirely.
|
|
69
|
+
if not fast and is_test_file:
|
|
70
|
+
try:
|
|
71
|
+
cov = measure_coverage()
|
|
72
|
+
if cov.get("coverage", 0) > 0 and cov["coverage"] < 0.80:
|
|
73
|
+
findings.append(Finding(
|
|
74
|
+
agent=self.name,
|
|
75
|
+
severity=Severity.MEDIUM,
|
|
76
|
+
category="Low Coverage",
|
|
77
|
+
message="Test coverage is {:.0f}% (minimum 80%)".format(
|
|
78
|
+
cov["coverage"] * 100),
|
|
79
|
+
file=file_path,
|
|
80
|
+
suggestion="Add more unit tests to improve coverage.",
|
|
81
|
+
blocker=Blocker.WARNS,
|
|
82
|
+
confidence=1.0,
|
|
83
|
+
rule_id="TST-103",
|
|
84
|
+
))
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
|
|
88
|
+
return findings
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _check_weak_assertions(content: str, file_path: str) -> List[Finding]:
|
|
92
|
+
"""Flag assertions that always pass or are otherwise meaningless."""
|
|
93
|
+
findings = []
|
|
94
|
+
lines = content.split("\n")
|
|
95
|
+
weak = [
|
|
96
|
+
(r'\bassert\s+True\b', "assert True always passes"),
|
|
97
|
+
(r'\bassert\s+1\b', "assert 1 always passes"),
|
|
98
|
+
(r'\bassert\s+0\b', "assert 0 always fails"),
|
|
99
|
+
(r'\bassert\s+None\b', "assert None always fails"),
|
|
100
|
+
(r'\bassert\s+False\b', "assert False always fails"),
|
|
101
|
+
(r'\bassert\s+\w+\s*!=\s*\w+\b', "assert x != y compares two variables (may be vacuous)"),
|
|
102
|
+
]
|
|
103
|
+
for i, line in enumerate(lines):
|
|
104
|
+
for pattern, message in weak:
|
|
105
|
+
if re.search(pattern, line):
|
|
106
|
+
findings.append(Finding(
|
|
107
|
+
agent="test",
|
|
108
|
+
severity=Severity.MEDIUM,
|
|
109
|
+
category="Weak Assertion",
|
|
110
|
+
message=message,
|
|
111
|
+
file=file_path,
|
|
112
|
+
line=i + 1,
|
|
113
|
+
suggestion="Assert on a meaningful condition.",
|
|
114
|
+
blocker=Blocker.WARNS,
|
|
115
|
+
confidence=0.80,
|
|
116
|
+
rule_id="TST-104",
|
|
117
|
+
))
|
|
118
|
+
break
|
|
119
|
+
return findings
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _check_test_naming(content: str, file_path: str) -> List[Finding]:
|
|
123
|
+
"""Flag test functions whose names don't follow conventions."""
|
|
124
|
+
findings = []
|
|
125
|
+
try:
|
|
126
|
+
tree = ast.parse(content)
|
|
127
|
+
except (SyntaxError, ValueError):
|
|
128
|
+
return findings
|
|
129
|
+
for node in ast.walk(tree):
|
|
130
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
131
|
+
if not node.name.startswith("test_"):
|
|
132
|
+
if "test" in node.name.lower():
|
|
133
|
+
findings.append(Finding(
|
|
134
|
+
agent="test",
|
|
135
|
+
severity=Severity.LOW,
|
|
136
|
+
category="Test Naming",
|
|
137
|
+
message="Test function '{}' should use 'test_' prefix".format(node.name),
|
|
138
|
+
file=file_path,
|
|
139
|
+
line=node.lineno,
|
|
140
|
+
suggestion="Name tests as test_<scenario>_<expected>.",
|
|
141
|
+
blocker=Blocker.WARNS,
|
|
142
|
+
confidence=0.75,
|
|
143
|
+
rule_id="TST-105",
|
|
144
|
+
))
|
|
145
|
+
return findings
|
prism/ai_architecture.py
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AI Architecture Agent - Uses Gemini for architectural code review.
|
|
3
|
+
|
|
4
|
+
Catches: architectural smells, design pattern violations, coupling issues,
|
|
5
|
+
modularity problems, and scalability concerns.
|
|
6
|
+
|
|
7
|
+
Rule IDs follow SonarQube convention: ARC-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 architect with 15+ years of experience designing large-scale systems.
|
|
19
|
+
You review code for architectural issues that affect maintainability, scalability, and extensibility.
|
|
20
|
+
|
|
21
|
+
Focus on:
|
|
22
|
+
1. Tight coupling (classes/functions that do too much, depend on too many other components)
|
|
23
|
+
2. Poor modularity (code that should be split into separate modules)
|
|
24
|
+
3. God functions (single functions that do 10+ unrelated things)
|
|
25
|
+
4. Missing abstraction (repeated patterns that should be interfaces or base classes)
|
|
26
|
+
5. Layer violations (lower layers calling higher layers, creating cycles)
|
|
27
|
+
6. Scalability concerns (in-memory state, unbounded collections, synchronous I/O in hot paths)
|
|
28
|
+
7. Design pattern violations (using the wrong pattern for the problem)
|
|
29
|
+
8. Hidden dependencies (functions relying on global state or hidden side effects)
|
|
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 architectural issue.
|
|
35
|
+
4. Focus on issues that will cause real problems as the system grows.
|
|
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 architectural assessment"
|
|
52
|
+
}
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@register_agent
|
|
57
|
+
class AIArchitectureAgent(BaseAgent):
|
|
58
|
+
name = "ai_architecture"
|
|
59
|
+
description = "AI-powered architecture 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_common.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Shared AI agent utilities — public API facade.
|
|
2
|
+
|
|
3
|
+
Implementation is split across:
|
|
4
|
+
- ai_providers.py : provider init, API call dispatch, retry/circuit-breaker
|
|
5
|
+
- ai_findings.py : JSON parsing, finding verification, cross-check
|
|
6
|
+
- ai_graph.py : import graph and cross-file context
|
|
7
|
+
- ai_pipeline.py : chunking, tiers, two-pass summarization, review_with_agent
|
|
8
|
+
|
|
9
|
+
This module re-exports the names call sites already import so backwards
|
|
10
|
+
compatibility is preserved. New code should import from the submodules
|
|
11
|
+
directly.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
from prism.ai_providers import (
|
|
17
|
+
get_model, API_TIMEOUT, MAX_RETRIES, MAX_CONTENT_CHARS,
|
|
18
|
+
)
|
|
19
|
+
from prism.ai_findings import parse_findings, verify_findings
|
|
20
|
+
from prism.ai_graph import build_import_graph, get_cross_file_context
|
|
21
|
+
from prism.ai_pipeline import (
|
|
22
|
+
chunk_content, build_skeleton, get_tier_for_file, review_with_agent,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"get_model", "parse_findings", "verify_findings",
|
|
29
|
+
"build_import_graph", "get_cross_file_context", "chunk_content",
|
|
30
|
+
"build_skeleton", "get_tier_for_file", "review_with_agent",
|
|
31
|
+
"API_TIMEOUT", "MAX_RETRIES", "MAX_CONTENT_CHARS",
|
|
32
|
+
]
|
prism/ai_findings.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Parse LLM JSON into Finding objects and verify them before delivery.
|
|
2
|
+
|
|
3
|
+
Verification catches hallucinated line numbers, invalid suggested code,
|
|
4
|
+
duplicates, and findings whose message references code not at the claimed
|
|
5
|
+
line. Categories with a deterministic regex equivalent also get a
|
|
6
|
+
cross-check (the CodeRabbit "verification agent" pattern).
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
import hashlib
|
|
11
|
+
import logging
|
|
12
|
+
|
|
13
|
+
from prism.langs import lang_for
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
_CROSS_CHECK_CATEGORIES = {
|
|
18
|
+
"Secret Exposure",
|
|
19
|
+
"Dangerous Pattern",
|
|
20
|
+
"Duplication",
|
|
21
|
+
"File Length",
|
|
22
|
+
"Todo",
|
|
23
|
+
"Naming Convention",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
_AGENT_PREFIX = {
|
|
27
|
+
"ai_security": "SECU",
|
|
28
|
+
"ai_quality": "QUAL",
|
|
29
|
+
"ai_policy": "POL",
|
|
30
|
+
"ai_test": "TST",
|
|
31
|
+
"ai_architecture": "ARC",
|
|
32
|
+
"ai_performance": "PERF",
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _is_python(file_path):
|
|
37
|
+
"""True when a suggested fix for ``file_path`` must parse as Python."""
|
|
38
|
+
spec = lang_for(file_path)
|
|
39
|
+
return spec is None or spec.name == "python"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _finding_id(prefix, item, file_path):
|
|
43
|
+
"""Deterministic per-instance ID derived from the finding's content.
|
|
44
|
+
|
|
45
|
+
Using a content hash instead of a sequence number guarantees uniqueness
|
|
46
|
+
across calls — chunked reviews call parse_findings once per chunk, so a
|
|
47
|
+
sequence counter would restart and emit duplicate SECU-AI-001 IDs. The
|
|
48
|
+
hash is stable across runs, so the same finding gets the same ID.
|
|
49
|
+
"""
|
|
50
|
+
raw = "{}|{}|{}|{}".format(
|
|
51
|
+
file_path,
|
|
52
|
+
item.get("line"),
|
|
53
|
+
item.get("message", ""),
|
|
54
|
+
item.get("category", ""),
|
|
55
|
+
)
|
|
56
|
+
digest = hashlib.sha256(raw.encode("utf-8")).hexdigest()[:4]
|
|
57
|
+
return "{}-AI-{}".format(prefix, digest)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_findings(result, file_path, agent_name):
|
|
61
|
+
"""Parse LLM JSON response into Finding objects with unique rule IDs."""
|
|
62
|
+
from prism.agents import Finding, Severity, Blocker
|
|
63
|
+
|
|
64
|
+
prefix = _AGENT_PREFIX.get(agent_name, agent_name.upper()[:4])
|
|
65
|
+
findings = []
|
|
66
|
+
for item in result.get("findings", []):
|
|
67
|
+
try:
|
|
68
|
+
line_val = item.get("line")
|
|
69
|
+
if line_val is not None:
|
|
70
|
+
try:
|
|
71
|
+
line_val = int(line_val)
|
|
72
|
+
except (ValueError, TypeError):
|
|
73
|
+
line_val = None
|
|
74
|
+
|
|
75
|
+
sev_str = str(item.get("severity", "low")).lower()
|
|
76
|
+
confidence = item.get("confidence", 0.85)
|
|
77
|
+
try:
|
|
78
|
+
confidence = float(confidence)
|
|
79
|
+
except (ValueError, TypeError):
|
|
80
|
+
confidence = 0.85
|
|
81
|
+
|
|
82
|
+
findings.append(Finding(
|
|
83
|
+
agent=agent_name,
|
|
84
|
+
severity=Severity(sev_str),
|
|
85
|
+
category=item.get("category", agent_name),
|
|
86
|
+
message=item.get("message", ""),
|
|
87
|
+
file=item.get("file", file_path),
|
|
88
|
+
line=line_val,
|
|
89
|
+
suggestion=item.get("suggestion"),
|
|
90
|
+
suggested_code=item.get("suggested_code"),
|
|
91
|
+
blocker=Blocker.BLOCKS if sev_str in ("critical", "high") else Blocker.WARNS,
|
|
92
|
+
confidence=confidence,
|
|
93
|
+
rule_id=_finding_id(prefix, item, file_path),
|
|
94
|
+
))
|
|
95
|
+
except Exception as exc:
|
|
96
|
+
logger.warning("Finding parse error: %s", exc)
|
|
97
|
+
return findings
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def verify_findings(findings, file_path, content):
|
|
101
|
+
"""Validate AI-generated findings before they reach the user."""
|
|
102
|
+
if not findings:
|
|
103
|
+
return []
|
|
104
|
+
|
|
105
|
+
lines = content.split("\n")
|
|
106
|
+
max_line = len(lines)
|
|
107
|
+
verified, seen = [], set()
|
|
108
|
+
|
|
109
|
+
for f in findings:
|
|
110
|
+
if f.line is not None and (f.line < 1 or f.line > max_line):
|
|
111
|
+
logger.warning("Dropped: line %s out of bounds (%d lines)", f.line, max_line)
|
|
112
|
+
continue
|
|
113
|
+
if not file_path or '\x00' in file_path:
|
|
114
|
+
logger.warning("Dropped: invalid file path '%s'", file_path)
|
|
115
|
+
continue
|
|
116
|
+
|
|
117
|
+
key = (f.agent, f.line, f.message.strip().lower()[:100])
|
|
118
|
+
if key in seen:
|
|
119
|
+
logger.warning("Dropped duplicate: %s", f.message[:80])
|
|
120
|
+
continue
|
|
121
|
+
seen.add(key)
|
|
122
|
+
|
|
123
|
+
if f.suggested_code and _is_python(f.file):
|
|
124
|
+
# Only a Python fix can be parsed here. Rejecting a Go or Rust
|
|
125
|
+
# snippet for not being valid Python would discard a correct finding
|
|
126
|
+
# along with the suggestion attached to it.
|
|
127
|
+
try:
|
|
128
|
+
import ast
|
|
129
|
+
ast.parse(f.suggested_code)
|
|
130
|
+
except SyntaxError:
|
|
131
|
+
logger.warning("Dropped: invalid suggested_code: %s", f.suggested_code[:80])
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
if f.line is not None and f.line <= max_line:
|
|
135
|
+
nearby = "\n".join(lines[max(0, f.line - 3):f.line + 3])
|
|
136
|
+
code_words = {
|
|
137
|
+
w for w in re.findall(r'[a-zA-Z_][a-zA-Z0-9_]{2,}', f.message)
|
|
138
|
+
if '_' in w or re.match(r'^[a-z]+[A-Z]', w)
|
|
139
|
+
}
|
|
140
|
+
if code_words and not any(w.lower() in nearby.lower() for w in code_words):
|
|
141
|
+
logger.warning("Dropped: message references code not at line %s", f.line)
|
|
142
|
+
continue
|
|
143
|
+
|
|
144
|
+
if f.category in _CROSS_CHECK_CATEGORIES:
|
|
145
|
+
confirmed, reason = _cross_check(f, lines)
|
|
146
|
+
if not confirmed:
|
|
147
|
+
f.confidence = max(0.1, f.confidence * 0.5)
|
|
148
|
+
logger.info("Verification: no deterministic support (%s) — confidence %.2f",
|
|
149
|
+
reason, f.confidence)
|
|
150
|
+
|
|
151
|
+
verified.append(f)
|
|
152
|
+
|
|
153
|
+
logger.info("Verification: %d/%d findings passed", len(verified), len(findings))
|
|
154
|
+
return verified
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _cross_check(finding, lines):
|
|
158
|
+
"""Cross-check an AI finding against deterministic regex patterns."""
|
|
159
|
+
line_no = finding.line
|
|
160
|
+
if line_no is None or line_no < 1 or line_no > len(lines):
|
|
161
|
+
return False, "no line to check"
|
|
162
|
+
line_text = lines[line_no - 1]
|
|
163
|
+
|
|
164
|
+
if finding.category in ("Secret Exposure", "Dangerous Pattern"):
|
|
165
|
+
try:
|
|
166
|
+
from prism.agents_security import SECRET_PATTERNS, DANGEROUS_PATTERNS
|
|
167
|
+
except Exception:
|
|
168
|
+
return False, "security patterns unavailable"
|
|
169
|
+
for pattern, _label, _rule_id in SECRET_PATTERNS + DANGEROUS_PATTERNS:
|
|
170
|
+
if re.search(pattern, line_text, re.IGNORECASE):
|
|
171
|
+
return True, "regex pattern matches at claimed line"
|
|
172
|
+
|
|
173
|
+
if finding.category == "File Length" and len(line_text) > 120:
|
|
174
|
+
return True, "line exceeds 120 chars"
|
|
175
|
+
if finding.category == "Todo" and re.search(r'TODO|FIXME|XXX', line_text):
|
|
176
|
+
return True, "TODO marker found"
|
|
177
|
+
if finding.category == "Naming Convention" and finding.file and finding.file.endswith(".py"):
|
|
178
|
+
if re.search(r'\b[a-z]+[A-Z]\w*\s*=', line_text):
|
|
179
|
+
return True, "camelCase variable found"
|
|
180
|
+
|
|
181
|
+
return False, "no deterministic pattern supports this finding"
|