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/__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
|