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.
@@ -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
@@ -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"