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_graph.py ADDED
@@ -0,0 +1,224 @@
1
+ """Function-level call graph for cross-file reasoning.
2
+
3
+ Scans all .py files in a repo and builds a graph mapping each file to its
4
+ imports, its dependents, and the functions it defines plus who calls them.
5
+ This gives the AI "who calls whom" awareness so it can reason about the
6
+ blast radius of changing a specific function.
7
+ """
8
+
9
+ import os
10
+ import ast
11
+ from pathlib import Path
12
+
13
+ # Process-level cache: {repo_path: (signature, graph)}.
14
+ # build_import_graph walks and parses the whole repo on every call, which made
15
+ # the LSP server take 5.5s per file. The graph is reused while the repo is
16
+ # unchanged: a cheap stat signature (path, mtime, size) is compared on every
17
+ # call, so long-lived processes (LSP, watch mode) self-invalidate after edits
18
+ # without every caller having to remember to call invalidate_graph_cache().
19
+ _graph_cache = {}
20
+
21
+ # Directories that never contain first-party source worth indexing.
22
+ _SKIP_DIRS = {
23
+ ".git", ".hg", ".svn", "__pycache__", ".venv", "venv", "env",
24
+ "node_modules", "site-packages", ".mypy_cache", ".pytest_cache",
25
+ ".tox", "dist", "build", ".eggs",
26
+ }
27
+
28
+
29
+ def _is_skipped(path):
30
+ return any(part in _SKIP_DIRS for part in path.parts)
31
+
32
+
33
+ def _graph_signature(py_files):
34
+ """Stat-only fingerprint of the scanned files.
35
+
36
+ Returns None when a file disappears mid-scan, which forces a rebuild.
37
+ """
38
+ signature = []
39
+ for py_file in py_files:
40
+ try:
41
+ st = py_file.stat()
42
+ except OSError:
43
+ return None
44
+ signature.append((str(py_file), st.st_mtime_ns, st.st_size))
45
+ return tuple(signature)
46
+
47
+
48
+ def build_import_graph(repo_path):
49
+ """Build a function-level call graph for cross-file reasoning.
50
+
51
+ Returns {file_path: {'imports': [...], 'imported_by': [...],
52
+ 'functions': {name: {'line', 'calls', 'called_by'}}}}
53
+ """
54
+ repo = Path(repo_path)
55
+ if not repo.exists():
56
+ return {}
57
+
58
+ key = str(repo.resolve())
59
+ py_files = [p for p in repo.rglob("*.py") if not _is_skipped(p)]
60
+ signature = _graph_signature(py_files)
61
+
62
+ cached = _graph_cache.get(key)
63
+ if cached is not None and signature is not None and cached[0] == signature:
64
+ return cached[1]
65
+
66
+ graph = _build_graph(repo, py_files)
67
+ _graph_cache[key] = (signature, graph)
68
+ return graph
69
+
70
+
71
+ def invalidate_graph_cache(repo_path=None):
72
+ """Drop the cached graph. Call from LSP on textDocument/didChange."""
73
+ if repo_path is None:
74
+ _graph_cache.clear()
75
+ else:
76
+ _graph_cache.pop(str(Path(repo_path).resolve()), None)
77
+
78
+
79
+ def _resolve_import(imp, pkg_parts):
80
+ """Resolve an import string to a dotted module path within the repo.
81
+
82
+ Absolute imports are returned as-is. Relative imports (stored with a
83
+ leading-dot prefix, e.g. ``.mod`` or ``..pkg``) are resolved against the
84
+ importing file's package parts.
85
+ """
86
+ if not imp:
87
+ return None
88
+ if not imp.startswith("."):
89
+ return imp
90
+ level = len(imp) - len(imp.lstrip("."))
91
+ rest = imp[level:]
92
+ if level > len(pkg_parts):
93
+ return None
94
+ base = pkg_parts[:len(pkg_parts) - (level - 1)]
95
+ parts = list(base)
96
+ if rest:
97
+ parts.append(rest)
98
+ return ".".join(parts) if parts else None
99
+
100
+
101
+ def _build_graph(repo, py_files=None):
102
+ """Uncached graph builder. See build_import_graph for the return shape."""
103
+ graph = {}
104
+ if py_files is None:
105
+ py_files = [p for p in repo.rglob("*.py") if not _is_skipped(p)]
106
+
107
+ # First pass: collect imports and functions per file
108
+ for py_file in py_files:
109
+ try:
110
+ tree = ast.parse(py_file.read_text(errors='ignore'))
111
+ imports = []
112
+ for node in ast.walk(tree):
113
+ if isinstance(node, ast.Import):
114
+ imports.extend(a.name for a in node.names)
115
+ elif isinstance(node, ast.ImportFrom):
116
+ prefix = "." * (node.level or 0)
117
+ imports.append(prefix + (node.module or ""))
118
+
119
+ functions = {}
120
+ for node in ast.iter_child_nodes(tree):
121
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
122
+ calls = set()
123
+ for child in ast.walk(node):
124
+ if isinstance(child, ast.Call):
125
+ if isinstance(child.func, ast.Name):
126
+ calls.add(child.func.id)
127
+ elif isinstance(child.func, ast.Attribute):
128
+ calls.add(child.func.attr)
129
+ functions[node.name] = {
130
+ 'line': node.lineno,
131
+ 'calls': [c for c in calls if c],
132
+ 'called_by': [],
133
+ }
134
+
135
+ graph[str(py_file)] = {
136
+ 'imports': imports,
137
+ 'imported_by': [],
138
+ 'functions': functions,
139
+ }
140
+ except (SyntaxError, ValueError):
141
+ continue
142
+
143
+ # Second pass: resolve cross-file imports. Index each module by its dotted
144
+ # path so resolution is O(1) per import (was O(imports * files)).
145
+ module_index = {}
146
+ for py_file in py_files:
147
+ rel = py_file.relative_to(repo)
148
+ parts = list(rel.with_suffix("").parts)
149
+ if parts and parts[-1] == "__init__":
150
+ parts = parts[:-1]
151
+ module_index[".".join(parts)] = str(py_file)
152
+
153
+ for file_key, info in graph.items():
154
+ py_file = Path(file_key)
155
+ pkg_parts = list(py_file.relative_to(repo).parts[:-1])
156
+ for imp in info['imports']:
157
+ target = _resolve_import(imp, pkg_parts)
158
+ if not target:
159
+ continue
160
+ other_str = module_index.get(target)
161
+ if not other_str or other_str == file_key:
162
+ continue
163
+ other = graph.get(other_str)
164
+ if other is None:
165
+ continue
166
+ if file_key not in other['imported_by']:
167
+ other['imported_by'].append(file_key)
168
+
169
+ # Third pass: build function-level called_by edges. Index functions by
170
+ # name once instead of scanning every file for every call (was O(F*C*N)).
171
+ func_index = {}
172
+ for file_name, info in graph.items():
173
+ for func_name, func_info in info.get("functions", {}).items():
174
+ func_index.setdefault(func_name, []).append((file_name, func_info))
175
+
176
+ for caller_file, info in graph.items():
177
+ for func_name, func_info in info.get("functions", {}).items():
178
+ for called in func_info.get("calls", []):
179
+ for other_file, other_info in func_index.get(called, []):
180
+ if other_file == caller_file:
181
+ continue
182
+ other_info["called_by"].append(
183
+ (caller_file, func_name, func_info["line"])
184
+ )
185
+
186
+ return graph
187
+
188
+
189
+ def get_cross_file_context(file_path, graph, repo_path):
190
+ """Get cross-file context for a given file.
191
+
192
+ Returns a string describing what this file imports, what files import
193
+ it, and which functions are called by other files (blast radius).
194
+ """
195
+ if not graph:
196
+ return ""
197
+
198
+ info = graph.get(str(Path(file_path).resolve()))
199
+ if not info:
200
+ for k, v in graph.items():
201
+ if k.endswith(str(file_path)) or str(file_path).endswith(k):
202
+ info = v
203
+ break
204
+ if not info:
205
+ return ""
206
+
207
+ parts = []
208
+ if info.get('imports'):
209
+ parts.append("This file imports: {}".format(", ".join(info['imports'][:10])))
210
+ if info.get('imported_by'):
211
+ parts.append("Files that import this file: {}".format(
212
+ ", ".join(info['imported_by'][:5])
213
+ ))
214
+
215
+ for func_name, func_info in info.get('functions', {}).items():
216
+ for cf, cf_func, cf_line in func_info.get('called_by', [])[:3]:
217
+ parts.append("Function {}() (line {}) is called by: {}:{}()@{}".format(
218
+ func_name, func_info['line'],
219
+ cf.split(os.sep)[-1].replace('.py', ''), cf_func, cf_line
220
+ ))
221
+
222
+ if not parts:
223
+ return ""
224
+ return "\n## Cross-File Context\n" + "\n".join(parts) + "\n"
@@ -0,0 +1,65 @@
1
+ """
2
+ AI Performance Agent - Uses Gemini for performance code review.
3
+
4
+ Catches: slow queries, N+1 problems, memory leaks, inefficient algorithms,
5
+ blocking I/O in async code, and unnecessary object creation.
6
+
7
+ Rule IDs follow SonarQube convention: PERF-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 performance engineer with 12+ years of experience optimizing large-scale systems.
19
+ You review code for performance issues that affect response times, throughput, and resource usage.
20
+
21
+ Focus on:
22
+ 1. N+1 query problems (fetching related data in a loop instead of a single join)
23
+ 2. Inefficient algorithms (O(n2) when O(n) is possible, unnecessary nested loops)
24
+ 3. Memory issues (unbounded collections, large object retention, memory leaks)
25
+ 4. Blocking I/O in async contexts (synchronous file/network calls in async functions)
26
+ 5. Unnecessary object creation (repeated allocations in hot paths, string concatenation in loops)
27
+ 6. Database performance (missing indexes, unbounded queries, missing LIMIT/OFFSET)
28
+ 7. Caching opportunities (repeated expensive computations that could be cached)
29
+ 8. Parallelization opportunities (independent operations that could run concurrently)
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 performance issue.
35
+ 4. Focus on issues that will actually impact performance at scale.
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 performance assessment"
52
+ }
53
+ """
54
+
55
+
56
+ @register_agent
57
+ class AIPerformanceAgent(BaseAgent):
58
+ name = "ai_performance"
59
+ description = "AI-powered performance 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)