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 ADDED
@@ -0,0 +1,87 @@
1
+ """
2
+ Prism - Local Pre-Commit Code Review System
3
+
4
+ A multi-agent code review pipeline that runs on the developer's machine
5
+ before code is pushed to GitHub. Each agent checks a specific concern.
6
+ """
7
+
8
+ import os
9
+ from pathlib import Path
10
+
11
+ __version__ = "2.0.0"
12
+
13
+ _ENV_LOADED = False
14
+
15
+
16
+ def _parse_env_file(path):
17
+ """Parse a .env file using only the standard library.
18
+
19
+ ``python-dotenv`` is an optional extra, so a plain ``pip install`` must not
20
+ depend on it: without this fallback a .env written by ``prism key set`` is
21
+ ignored and the AI agents report "no API key configured" with no clue why.
22
+ """
23
+ values = {}
24
+ try:
25
+ text = path.read_text(encoding="utf-8", errors="ignore")
26
+ except OSError:
27
+ return values
28
+
29
+ for raw in text.splitlines():
30
+ line = raw.strip()
31
+ if not line or line.startswith("#"):
32
+ continue
33
+ if line.startswith("export "):
34
+ line = line[len("export "):].lstrip()
35
+ if "=" not in line:
36
+ continue
37
+ key, _, value = line.partition("=")
38
+ key = key.strip()
39
+ if not key:
40
+ continue
41
+ value = value.strip()
42
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'):
43
+ value = value[1:-1]
44
+ values[key] = value
45
+ return values
46
+
47
+
48
+ def load_env():
49
+ """Load the nearest .env file into os.environ (idempotent).
50
+
51
+ Runs at package import so every entry point (CLI, LSP, library use) sees
52
+ the same provider keys. Previously only `prism.cli` loaded .env, so
53
+ `prism.lsp` or a direct `review_with_agent` call silently ran without a
54
+ key and reported "no API key configured".
55
+ """
56
+ global _ENV_LOADED
57
+ if _ENV_LOADED:
58
+ return
59
+ _ENV_LOADED = True
60
+
61
+ here = Path(__file__).resolve().parent
62
+ # Project-local .env wins over the tool's own config, so a developer can
63
+ # point PRISM at a different key without editing the install.
64
+ candidates = (
65
+ Path.cwd() / ".env",
66
+ Path.cwd() / ".config" / ".env",
67
+ here.parent / ".env",
68
+ here / ".env",
69
+ here.parent / ".config" / ".env",
70
+ here / ".config" / ".env",
71
+ here.parent.parent / ".env",
72
+ )
73
+ for candidate in candidates:
74
+ if not candidate.exists():
75
+ continue
76
+ try:
77
+ from dotenv import load_dotenv
78
+ except ImportError:
79
+ # Mirror python-dotenv's default: existing environment wins.
80
+ for key, value in _parse_env_file(candidate).items():
81
+ os.environ.setdefault(key, value)
82
+ else:
83
+ load_dotenv(candidate)
84
+ return
85
+
86
+
87
+ load_env()
prism/__main__.py ADDED
@@ -0,0 +1,13 @@
1
+ """Allow ``py -m prism`` and ``python -m prism`` to run the CLI.
2
+
3
+ The console script (``prism = prism.cli:main`` in pyproject) covers installs
4
+ made by pip; this module covers the case where a developer unzips the source
5
+ folder and runs the tool directly with the interpreter they already have, with
6
+ no install step of their own. Importing ``prism.cli`` runs the agent
7
+ registration decorators, same as the console entry point.
8
+ """
9
+
10
+ from prism.cli import main
11
+
12
+ if __name__ == "__main__":
13
+ main()
prism/agents.py ADDED
@@ -0,0 +1,191 @@
1
+ """
2
+ Agent registry, data model, config, and pipeline orchestration.
3
+ """
4
+
5
+ from dataclasses import dataclass, field
6
+ from enum import Enum
7
+ from typing import Dict, List, Optional
8
+ import logging
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class Severity(str, Enum):
14
+ CRITICAL = "critical"
15
+ HIGH = "high"
16
+ MEDIUM = "medium"
17
+ LOW = "low"
18
+ INFO = "info"
19
+
20
+
21
+ class Blocker(str, Enum):
22
+ BLOCKS = "blocks"
23
+ WARNS = "warns"
24
+ ALLOWS = "allows"
25
+
26
+
27
+ @dataclass
28
+ class Finding:
29
+ agent: str
30
+ severity: Severity
31
+ category: str
32
+ message: str
33
+ file: str
34
+ line: Optional[int] = None
35
+ suggestion: Optional[str] = None
36
+ suggested_code: Optional[str] = None
37
+ blocker: Blocker = Blocker.BLOCKS
38
+ auto_fixable: bool = False
39
+ confidence: float = 1.0
40
+ rule_id: Optional[str] = None
41
+ # Other agents that raised the same issue. Populated by dedupe_results();
42
+ # empty for a finding nothing else corroborated.
43
+ corroborated_by: List[str] = field(default_factory=list)
44
+
45
+
46
+ @dataclass
47
+ class AgentResult:
48
+ agent_name: str
49
+ findings: List[Finding] = field(default_factory=list)
50
+ passed: bool = True
51
+ duration_ms: float = 0.0
52
+
53
+ @property
54
+ def blocking_findings(self) -> List[Finding]:
55
+ return [f for f in self.findings if f.blocker == Blocker.BLOCKS]
56
+
57
+
58
+ class BaseAgent:
59
+ name: str = "base"
60
+ description: str = "Base agent"
61
+
62
+ def review(self, file_path: str, content: str, diff: str,
63
+ skeleton: str = "", line_count: int = None,
64
+ fast: bool = False) -> AgentResult:
65
+ import time
66
+ start = time.time()
67
+ findings = self._check(file_path, content, diff, skeleton, line_count, fast)
68
+ duration = (time.time() - start) * 1000
69
+ blocking = any(f.blocker == Blocker.BLOCKS for f in findings)
70
+ return AgentResult(
71
+ agent_name=self.name,
72
+ findings=findings,
73
+ passed=not blocking,
74
+ duration_ms=duration,
75
+ )
76
+
77
+ def _check(self, file_path: str, content: str, diff: str,
78
+ skeleton: str = "", line_count: int = None,
79
+ fast: bool = False) -> List[Finding]:
80
+ raise NotImplementedError
81
+
82
+
83
+ AGENT_REGISTRY: Dict[str, BaseAgent] = {}
84
+
85
+
86
+ def register_agent(cls: BaseAgent) -> BaseAgent:
87
+ AGENT_REGISTRY[cls.name] = cls()
88
+ return cls
89
+
90
+
91
+ def get_agent(name: str) -> Optional[BaseAgent]:
92
+ return AGENT_REGISTRY.get(name)
93
+
94
+
95
+ def list_agents() -> List[str]:
96
+ return list(AGENT_REGISTRY.keys())
97
+
98
+
99
+ def run_pipeline(agents: List[str], file_path: str, content: str,
100
+ diff: str, skeleton: str = "",
101
+ line_count: int = None, fast: bool = False) -> List[AgentResult]:
102
+ results = []
103
+ for agent_name in agents:
104
+ agent = get_agent(agent_name)
105
+ if agent is None:
106
+ logger.warning("Agent %s not found, skipping", agent_name)
107
+ continue
108
+ result = agent.review(file_path, content, diff, skeleton, line_count, fast)
109
+ results.append(result)
110
+ return results
111
+
112
+
113
+ def risk_score(file_path: str, content: str, diff: str, findings=None) -> float:
114
+ """Score 0.0 (low risk) to 1.0 (high risk) for a file.
115
+
116
+ Combines file characteristics with the severity of what was actually
117
+ found: a tiny file with a critical finding is high risk, so a 0.0 score
118
+ next to a blocking finding (which read as a contradiction to CI
119
+ consumers) cannot happen any more.
120
+ """
121
+ score = 0.0
122
+ name = file_path.lower()
123
+
124
+ if any(k in name for k in ["config", "secret", "env", "credential", "key", "auth"]):
125
+ score += 0.3
126
+
127
+ diff_lines = diff.count("\n")
128
+ if diff_lines > 200:
129
+ score += 0.3
130
+ elif diff_lines > 50:
131
+ score += 0.15
132
+
133
+ if any(m in content.lower() for m in ["generated by", "copilot", "chatgpt", "claude"]):
134
+ score += 0.2
135
+
136
+ if "test" in name or "spec" in name:
137
+ score -= 0.2
138
+
139
+ if file_path.endswith((".md", ".rst", ".txt", ".yaml", ".yml", ".json")):
140
+ score -= 0.1
141
+
142
+ weight = {Severity.CRITICAL: 0.25, Severity.HIGH: 0.1,
143
+ Severity.MEDIUM: 0.03, Severity.LOW: 0.0, Severity.INFO: 0.0}
144
+ for f in findings or []:
145
+ score += weight.get(f.severity, 0.0)
146
+
147
+ return round(max(0.0, min(1.0, score)), 2)
148
+
149
+
150
+ def quality_gates_met(results: List[AgentResult], config: dict) -> bool:
151
+ """Check if all quality gates pass.
152
+
153
+ Only high and critical findings block the commit (the ``block_on`` set).
154
+ A blocking finding means the change is not safe to commit, so it fails the
155
+ gate as well; otherwise ``quality_gates_passed`` could be true while the
156
+ review is blocked, which reads as a contradiction in JSON output.
157
+
158
+ The regex quality agent reports advisory rules as LOW/MEDIUM with
159
+ ``Blocker.WARNS``; those never block regardless of the gate thresholds, so
160
+ a style preference cannot stop a commit.
161
+ """
162
+ gates = config.get("quality_gates", {})
163
+ blocking_sev = set(config.get("block_on", ["critical", "high"]))
164
+ all_findings = [f for r in results for f in r.findings]
165
+
166
+ for f in all_findings:
167
+ if f.blocker == Blocker.BLOCKS and f.severity.value in blocking_sev:
168
+ return False
169
+
170
+ max_complexity = gates.get("max_complexity")
171
+ if max_complexity:
172
+ for f in all_findings:
173
+ if (f.category == "Complexity"
174
+ and f.severity.value in blocking_sev):
175
+ return False
176
+
177
+ max_file_length = gates.get("max_file_length")
178
+ if max_file_length:
179
+ for f in all_findings:
180
+ if (f.category == "File Length"
181
+ and f.severity.value in blocking_sev):
182
+ return False
183
+
184
+ min_coverage = gates.get("min_coverage")
185
+ if min_coverage:
186
+ for f in all_findings:
187
+ if (f.category == "Missing Tests"
188
+ and f.severity.value in blocking_sev):
189
+ return False
190
+
191
+ return True
prism/agents_policy.py ADDED
@@ -0,0 +1,277 @@
1
+ """
2
+ Policy Agent - Checks compliance with project policies,
3
+ license headers, copyright notices, and code ownership rules.
4
+
5
+ Rule IDs follow SonarQube convention: POL-XXX
6
+
7
+ These are style/policy preferences, not bugs, so everything here is LOW
8
+ severity and never blocks. The rules that are pure noise on most projects
9
+ (SPDX headers, print(), console.log, TODO(issue-ref) formatting) are off by
10
+ default and opt-in via prism.json:
11
+
12
+ "policy": {
13
+ "headers": true, # require copyright/SPDX headers
14
+ "banned_console": true, # flag console.log in JS/TS
15
+ "check_print": true, # flag print() outside CLI entry points
16
+ "check_todo_refs": true # enforce TODO(PROJECT-123) format
17
+ }
18
+ """
19
+
20
+ import re
21
+ from typing import List
22
+ from prism.agents import BaseAgent, Finding, Severity, Blocker, register_agent
23
+ from prism.langs import lang_for, strip_literals
24
+
25
+ # Copyright/SPDX header checks are opt-in via prism.json:
26
+ # "policy": {"headers": true}
27
+ # They fired on every source file, which is pure noise for projects that do
28
+ # not use SPDX headers.
29
+ HEADER_CHECKS_ENABLED = False
30
+
31
+ # Everything that is a preference rather than a defect is opt-in.
32
+ BANNED_CONSOLE_ENABLED = False
33
+ CHECK_PRINT_ENABLED = False
34
+ CHECK_TODO_REFS_ENABLED = False
35
+
36
+
37
+ @register_agent
38
+ class PolicyAgent(BaseAgent):
39
+ name = "policy"
40
+ description = "Checks compliance with project policies and standards"
41
+
42
+ def __init__(self, require_header: bool = True, allowed_licenses: list = None,
43
+ policy: dict = None):
44
+ self.require_header = require_header
45
+ self.allowed_licenses = allowed_licenses or ["MIT", "Apache-2.0", "BSD-3-Clause", "ISC"]
46
+ self._policy = policy or {}
47
+
48
+ @property
49
+ def policy(self):
50
+ """Policy options, kept on the instance so tests can inject them."""
51
+ return self._policy
52
+
53
+ def _check(self, file_path: str, content: str, diff: str,
54
+ skeleton: str = "", line_count: int = None,
55
+ fast: bool = False) -> List[Finding]:
56
+ findings: List[Finding] = []
57
+ lines = content.split("\n")
58
+ policy = self._policy
59
+
60
+ headers_on = HEADER_CHECKS_ENABLED or policy.get("headers", False)
61
+ console_on = BANNED_CONSOLE_ENABLED or policy.get("banned_console", False)
62
+ print_on = CHECK_PRINT_ENABLED or policy.get("check_print", False)
63
+ todo_refs_on = CHECK_TODO_REFS_ENABLED or policy.get("check_todo_refs", False)
64
+
65
+ if headers_on and self.require_header and lang_for(file_path) is not None:
66
+ first_10 = "\n".join(lines[:10])
67
+ if not re.search(r"Copyright\s+\(c\)", first_10):
68
+ findings.append(Finding(
69
+ agent=self.name,
70
+ severity=Severity.LOW,
71
+ category="Missing Header",
72
+ message="Missing copyright notice in file header",
73
+ file=file_path,
74
+ line=1,
75
+ suggestion="Add copyright header with year and author.",
76
+ blocker=Blocker.WARNS,
77
+ confidence=1.0,
78
+ rule_id="POL-101",
79
+ ))
80
+
81
+ banned = [
82
+ (r"debugger;", "Remove debugger statement", "POL-203"),
83
+ (r"alert\(", "Remove alert() - use proper UI components", "POL-204"),
84
+ ]
85
+ if console_on:
86
+ banned.append(
87
+ (r"console\.log\(", "Use structured logging instead of console.log",
88
+ "POL-201")
89
+ )
90
+ # print() is legitimate in CLI tools and entry points. Only flag it
91
+ # in library/module code that has no argparse or __main__ guard, and
92
+ # only when the developer opted in.
93
+ is_cli_entry = bool(
94
+ re.search(r'argparse|__main__|def main\b', content)
95
+ )
96
+ docstrings = _docstring_lines(content)
97
+ spec = lang_for(file_path)
98
+ for pattern, message, rule_id in banned:
99
+ for i, line in enumerate(lines):
100
+ if _matches_code(pattern, line, i + 1, docstrings, spec):
101
+ findings.append(Finding(
102
+ agent=self.name,
103
+ severity=Severity.LOW,
104
+ category="Banned Pattern",
105
+ message=message,
106
+ file=file_path,
107
+ line=i + 1,
108
+ suggestion=message,
109
+ blocker=Blocker.WARNS,
110
+ confidence=1.0,
111
+ rule_id=rule_id,
112
+ ))
113
+ if print_on and not is_cli_entry:
114
+ for i, line in enumerate(lines):
115
+ if _matches_code(r'print\(', line, i + 1, docstrings, spec):
116
+ findings.append(Finding(
117
+ agent=self.name,
118
+ severity=Severity.LOW,
119
+ category="Banned Pattern",
120
+ message="Use logging module instead of print()",
121
+ file=file_path,
122
+ line=i + 1,
123
+ suggestion="Use logging module instead of print()",
124
+ blocker=Blocker.WARNS,
125
+ confidence=1.0,
126
+ rule_id="POL-202",
127
+ ))
128
+
129
+ if todo_refs_on:
130
+ for i, line in enumerate(lines):
131
+ match = re.search(r'TODO\s*\((\w+)\)', strip_literals(line, spec))
132
+ if match:
133
+ issue_ref = match.group(1)
134
+ if not re.match(r'^[A-Z]+-\d+$', issue_ref):
135
+ findings.append(Finding(
136
+ agent=self.name,
137
+ severity=Severity.LOW,
138
+ category="Invalid Issue Reference",
139
+ message="TODO references '{}' - use format PROJECT-123".format(issue_ref),
140
+ file=file_path,
141
+ line=i + 1,
142
+ suggestion="Use valid issue tracker reference.",
143
+ blocker=Blocker.WARNS,
144
+ confidence=0.90,
145
+ rule_id="POL-205",
146
+ ))
147
+
148
+ if headers_on:
149
+ findings.extend(_check_license_header(content, file_path, self.allowed_licenses))
150
+ findings.extend(_check_branch_naming(file_path))
151
+
152
+ return findings
153
+
154
+
155
+ # SPDX-style license identifiers recognized in source headers.
156
+ SPDX_LICENSES = {
157
+ "MIT": r"MIT",
158
+ "Apache-2.0": r"Apache[\-\s]?2\.0",
159
+ "BSD-3-Clause": r"BSD[\-\s]?3[\-\s]?Clause",
160
+ "ISC": r"\bISC\b",
161
+ "GPL-3.0": r"GPL[\-\s]?3\.0",
162
+ "MPL-2.0": r"MPL[\-\s]?2\.0",
163
+ }
164
+
165
+
166
+ def _strip_literals(line: str, spec=None) -> str:
167
+ """Remove single-line string/regex literals so patterns only match code.
168
+
169
+ Which characters open a comment and a string is a property of the language:
170
+ a rule that only knew ``#`` read a commented-out ``debugger;`` in Go or Rust
171
+ as a real statement.
172
+ """
173
+ return strip_literals(line, spec)
174
+
175
+
176
+ def _docstring_lines(content: str) -> set:
177
+ """Line numbers (1-based) that belong to a module/class/function docstring.
178
+
179
+ A per-line stripper cannot know it is inside a triple-quoted block, so a
180
+ rule word quoted in a docstring (this module's own docs mention
181
+ `debugger;`) was reported as a real statement.
182
+ """
183
+ import ast
184
+
185
+ try:
186
+ tree = ast.parse(content)
187
+ except (SyntaxError, ValueError):
188
+ return set()
189
+
190
+ lines = set()
191
+
192
+ def _mark(node):
193
+ body = getattr(node, "body", None)
194
+ if not body:
195
+ return
196
+ first = body[0]
197
+ if (isinstance(first, ast.Expr)
198
+ and isinstance(first.value, ast.Constant)
199
+ and isinstance(first.value.value, str)):
200
+ for n in range(first.lineno, (first.end_lineno or first.lineno) + 1):
201
+ lines.add(n)
202
+
203
+ _mark(tree)
204
+ for node in ast.walk(tree):
205
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
206
+ _mark(node)
207
+ return lines
208
+
209
+
210
+ def _matches_code(pattern, line, lineno=None, docstrings=None, spec=None):
211
+ """True when ``pattern`` matches the line's code, ignoring literals."""
212
+ if docstrings and lineno in docstrings:
213
+ return False
214
+ return re.search(pattern, strip_literals(line, spec)) is not None
215
+
216
+
217
+ def _check_license_header(content: str, file_path: str, allowed_licenses: list) -> List[Finding]:
218
+ """Verify the file header declares an allowed SPDX license."""
219
+ if lang_for(file_path) is None:
220
+ return []
221
+ findings = []
222
+ first_20 = "\n".join(content.split("\n")[:20])
223
+ found = None
224
+ for lic, pattern in SPDX_LICENSES.items():
225
+ if re.search(pattern, first_20):
226
+ found = lic
227
+ break
228
+ if found is None:
229
+ findings.append(Finding(
230
+ agent="policy",
231
+ severity=Severity.MEDIUM,
232
+ category="Missing License",
233
+ message="No SPDX license identifier found in header",
234
+ file=file_path,
235
+ line=1,
236
+ suggestion="Add an SPDX license identifier (e.g. 'SPDX-License-Identifier: MIT').",
237
+ blocker=Blocker.WARNS,
238
+ confidence=0.80,
239
+ rule_id="POL-206",
240
+ ))
241
+ elif allowed_licenses and found not in allowed_licenses:
242
+ findings.append(Finding(
243
+ agent="policy",
244
+ severity=Severity.MEDIUM,
245
+ category="Disallowed License",
246
+ message="License '{}' is not in allowed list {}".format(found, allowed_licenses),
247
+ file=file_path,
248
+ line=1,
249
+ suggestion="Use one of: {}".format(", ".join(allowed_licenses)),
250
+ blocker=Blocker.WARNS,
251
+ confidence=0.85,
252
+ rule_id="POL-207",
253
+ ))
254
+ return findings
255
+
256
+
257
+ def _check_branch_naming(file_path: str) -> List[Finding]:
258
+ """Flag source files whose path implies a non-standard branch name."""
259
+ findings = []
260
+ # Only applies to files that live under a branch-shaped directory.
261
+ m = re.search(r'(?:^|[/\\])(feature|fix|hotfix|docs|chore|refactor|test|build|ci)(?:[/\\]|$)', file_path)
262
+ if m:
263
+ prefix = m.group(1)
264
+ if prefix not in ("feature", "fix", "hotfix", "docs", "chore", "refactor", "test", "build", "ci"):
265
+ findings.append(Finding(
266
+ agent="policy",
267
+ severity=Severity.LOW,
268
+ category="Branch Naming",
269
+ message="Branch prefix '{}' not in conventional set".format(prefix),
270
+ file=file_path,
271
+ line=1,
272
+ suggestion="Use: feature/, fix/, hotfix/, docs/, chore/, refactor/, test/, build/, ci/.",
273
+ blocker=Blocker.WARNS,
274
+ confidence=0.60,
275
+ rule_id="POL-208",
276
+ ))
277
+ return findings