pr-analyzer-cli 0.1.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.
- pr_analyzer/__init__.py +0 -0
- pr_analyzer/analyzer/__init__.py +0 -0
- pr_analyzer/analyzer/ast_analyzer.py +467 -0
- pr_analyzer/analyzer/line_lexer.py +249 -0
- pr_analyzer/analyzer/scorer.py +132 -0
- pr_analyzer/cache.py +373 -0
- pr_analyzer/cli.py +171 -0
- pr_analyzer/config.py +50 -0
- pr_analyzer/diff.py +74 -0
- pr_analyzer/exceptions.py +41 -0
- pr_analyzer/github/__init__.py +0 -0
- pr_analyzer/github/client.py +398 -0
- pr_analyzer/github/markdown.py +199 -0
- pr_analyzer/github/ratelimit.py +277 -0
- pr_analyzer/llm/__init__.py +0 -0
- pr_analyzer/llm/chunking.py +149 -0
- pr_analyzer/llm/engine.py +336 -0
- pr_analyzer/llm/prompts.py +109 -0
- pr_analyzer/llm/review.py +141 -0
- pr_analyzer/logging_config.py +21 -0
- pr_analyzer/models.py +65 -0
- pr_analyzer/pipeline.py +273 -0
- pr_analyzer/project_config.py +266 -0
- pr_analyzer/reporting/__init__.py +0 -0
- pr_analyzer/reporting/rich_reporter.py +244 -0
- pr_analyzer/security/__init__.py +0 -0
- pr_analyzer/security/dep_auditor.py +179 -0
- pr_analyzer/security/scanner.py +272 -0
- pr_analyzer/security/semgrep_runner.py +190 -0
- pr_analyzer/security/tools.py +22 -0
- pr_analyzer/suppression.py +52 -0
- pr_analyzer_cli-0.1.0.dist-info/METADATA +598 -0
- pr_analyzer_cli-0.1.0.dist-info/RECORD +36 -0
- pr_analyzer_cli-0.1.0.dist-info/WHEEL +4 -0
- pr_analyzer_cli-0.1.0.dist-info/entry_points.txt +3 -0
- pr_analyzer_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
pr_analyzer/pipeline.py
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from collections import Counter
|
|
3
|
+
from dataclasses import replace
|
|
4
|
+
|
|
5
|
+
import structlog
|
|
6
|
+
|
|
7
|
+
from pr_analyzer.analyzer.ast_analyzer import StaticAnalyzer
|
|
8
|
+
from pr_analyzer.analyzer.scorer import Scorer
|
|
9
|
+
from pr_analyzer.cache import open_cache
|
|
10
|
+
from pr_analyzer.config import settings
|
|
11
|
+
from pr_analyzer.diff import commentable_lines
|
|
12
|
+
from pr_analyzer.exceptions import PRAnalyzerError
|
|
13
|
+
from pr_analyzer.github.client import GithubClient
|
|
14
|
+
from pr_analyzer.github.markdown import code_span, neutralise_line
|
|
15
|
+
from pr_analyzer.llm.engine import LLM_RULE, LLMEngine
|
|
16
|
+
from pr_analyzer.models import AnalysisResult, FileChange, PRSummary, Suggestion
|
|
17
|
+
from pr_analyzer.project_config import (
|
|
18
|
+
CONFIG_FILE,
|
|
19
|
+
CONFIG_RULE,
|
|
20
|
+
ProjectConfig,
|
|
21
|
+
config_findings,
|
|
22
|
+
read_project_config,
|
|
23
|
+
severity_rank,
|
|
24
|
+
)
|
|
25
|
+
from pr_analyzer.security.scanner import SecurityScanner
|
|
26
|
+
from pr_analyzer.suppression import ignored_warning, split_ignored
|
|
27
|
+
|
|
28
|
+
log = structlog.get_logger(__name__)
|
|
29
|
+
|
|
30
|
+
# findings without an inline comment that are still listed in the review body
|
|
31
|
+
MAX_LISTED_IN_REVIEW = 20
|
|
32
|
+
# files named in the warning about ignore_paths; any beyond this are only counted
|
|
33
|
+
MAX_LISTED_SKIPPED = 10
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def gets_inline_comment(s: Suggestion, min_severity: str = "MEDIUM") -> bool:
|
|
37
|
+
# LOW findings are only counted in the review body by default: a comment on every one of
|
|
38
|
+
# them buried the findings that matter. A project can move the line in .pr-analyzer.toml.
|
|
39
|
+
return severity_rank(s.severity) >= severity_rank(min_severity)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def skipped_files_warning(skipped: list[FileChange]) -> str:
|
|
43
|
+
names = ", ".join(f.filename for f in skipped[:MAX_LISTED_SKIPPED])
|
|
44
|
+
if len(skipped) > MAX_LISTED_SKIPPED:
|
|
45
|
+
names += f" and {len(skipped) - MAX_LISTED_SKIPPED} more"
|
|
46
|
+
return f"Skipped {len(skipped)} file(s) matching ignore_paths in {CONFIG_FILE}: {names}"
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def invalid_config_warning(source: str, problems: list[str]) -> str:
|
|
50
|
+
return f"{source} is invalid, so the default settings were used: " + "; ".join(problems)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def check_pr_config(pr: PRSummary, warnings: list[str]) -> list[Suggestion]:
|
|
54
|
+
"""Findings for the problems in the .pr-analyzer.toml this PR adds or changes, if any. That
|
|
55
|
+
copy is checked whatever ignore_paths says, and never applied (see _project_config_for)."""
|
|
56
|
+
changed = next(
|
|
57
|
+
(f for f in pr.files if f.filename == CONFIG_FILE and f.status != "removed"), None
|
|
58
|
+
)
|
|
59
|
+
if changed is None:
|
|
60
|
+
return []
|
|
61
|
+
if changed.raw_content is None:
|
|
62
|
+
warnings.append(f"Could not check the {CONFIG_FILE} this PR changes: its content "
|
|
63
|
+
"isn't available")
|
|
64
|
+
return []
|
|
65
|
+
return config_findings(changed.raw_content)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def findings_off_the_diff(findings: list[Suggestion]) -> list[str]:
|
|
69
|
+
# review-body lines for findings that should have had an inline comment but whose line
|
|
70
|
+
# isn't part of the diff. File names and titles can be shaped by the PR, so they're
|
|
71
|
+
# neutralised (see github/markdown.py), as are the counted titles and the notes below.
|
|
72
|
+
if not findings:
|
|
73
|
+
return []
|
|
74
|
+
lines = ["", f"**Findings not attached to a diff line ({len(findings)}):**"]
|
|
75
|
+
for s in findings[:MAX_LISTED_IN_REVIEW]:
|
|
76
|
+
where = f"{s.filename}:{s.line}" if s.line else s.filename
|
|
77
|
+
lines.append(f"- **{s.severity}** {code_span(where)} — {neutralise_line(s.title)}")
|
|
78
|
+
if len(findings) > MAX_LISTED_IN_REVIEW:
|
|
79
|
+
lines.append(f"- … and {len(findings) - MAX_LISTED_IN_REVIEW} more")
|
|
80
|
+
return lines
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def low_severity_counts(findings: list[Suggestion], min_severity: str = "MEDIUM") -> list[str]:
|
|
84
|
+
# findings below the inline-comment threshold: LOW ones, unless a project moved it
|
|
85
|
+
if not findings:
|
|
86
|
+
return []
|
|
87
|
+
# the rule id is what .pr-analyzer.toml's `disable` takes, for a rule a project finds noisy
|
|
88
|
+
counts = Counter((s.title, s.rule_id) for s in findings).most_common()
|
|
89
|
+
kind = "Low-severity findings" if min_severity == "MEDIUM" else f"Findings below {min_severity}"
|
|
90
|
+
lines = ["", f"**{kind} ({len(findings)}), counted but not commented inline:**"]
|
|
91
|
+
lines += [
|
|
92
|
+
f"- {count} × {neutralise_line(title)}" + (f" (`{rule}`)" if rule else "")
|
|
93
|
+
for (title, rule), count in counts[:MAX_LISTED_IN_REVIEW]
|
|
94
|
+
]
|
|
95
|
+
if len(counts) > MAX_LISTED_IN_REVIEW:
|
|
96
|
+
rest = sum(count for _, count in counts[MAX_LISTED_IN_REVIEW:])
|
|
97
|
+
lines.append(f"- … and {rest} more")
|
|
98
|
+
return lines
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class AnalysisPipeline:
|
|
102
|
+
def __init__(
|
|
103
|
+
self, skip_llm: bool = False, post_review: bool = False, use_cache: bool = True
|
|
104
|
+
) -> None:
|
|
105
|
+
# file contents and LLM replies; --no-cache (use_cache=False) neither reads nor writes it
|
|
106
|
+
self.cache = open_cache(enabled=use_cache)
|
|
107
|
+
self.github = GithubClient(cache=self.cache)
|
|
108
|
+
self.static = StaticAnalyzer()
|
|
109
|
+
self.security = SecurityScanner()
|
|
110
|
+
self.llm = LLMEngine(cache=self.cache)
|
|
111
|
+
self.scorer = Scorer()
|
|
112
|
+
self.skip_llm = skip_llm
|
|
113
|
+
self.post_review = post_review
|
|
114
|
+
# settings from --config; None reads .pr-analyzer.toml from each PR's base branch
|
|
115
|
+
self.project_config: ProjectConfig | None = None
|
|
116
|
+
|
|
117
|
+
def close(self) -> None:
|
|
118
|
+
self.github.close()
|
|
119
|
+
self.cache.close()
|
|
120
|
+
|
|
121
|
+
def run(self, url: str, skip_llm: bool | None = None) -> AnalysisResult:
|
|
122
|
+
# the constructor's flag applies unless a caller overrides it for this run; run() used
|
|
123
|
+
# to ignore it, so --skip-llm had no effect
|
|
124
|
+
skip_llm = self.skip_llm if skip_llm is None else skip_llm
|
|
125
|
+
start = time.perf_counter()
|
|
126
|
+
warnings: list[str] = []
|
|
127
|
+
|
|
128
|
+
log.info("Fetching PR")
|
|
129
|
+
pr = self.github.fetch_pr(url)
|
|
130
|
+
log.info("PR fetched successfully", repo=pr.repo, number=pr.number, files=len(pr.files))
|
|
131
|
+
|
|
132
|
+
config = self._project_config_for(pr, warnings)
|
|
133
|
+
# a broken .pr-analyzer.toml in the PR blocks it, so it can't be merged unnoticed
|
|
134
|
+
pr_config_findings = check_pr_config(pr, warnings) if config.enabled(CONFIG_RULE) else []
|
|
135
|
+
# files matching ignore_paths aren't analysed at all, so they never reach the LLM either
|
|
136
|
+
skipped = [f for f in pr.files if config.ignores(f.filename)]
|
|
137
|
+
files = [f for f in pr.files if not config.ignores(f.filename)]
|
|
138
|
+
if skipped:
|
|
139
|
+
warnings.append(skipped_files_warning(skipped))
|
|
140
|
+
|
|
141
|
+
log.info("Static analysis starting..")
|
|
142
|
+
static_analysis = self.static.analyze(files, config.disable, config.thresholds)
|
|
143
|
+
|
|
144
|
+
log.info("Security analysis starting..")
|
|
145
|
+
security_analysis = self.security.scan(files, config.disable)
|
|
146
|
+
warnings.extend(self.security.warnings)
|
|
147
|
+
|
|
148
|
+
# findings on lines marked `pr-analyzer: ignore` are dropped and named in a warning
|
|
149
|
+
existing_findings, ignored = split_ignored(static_analysis + security_analysis, files)
|
|
150
|
+
|
|
151
|
+
llm_suggestions: list[Suggestion] = []
|
|
152
|
+
llm_provider_used = None
|
|
153
|
+
if skip_llm or not config.enabled(LLM_RULE):
|
|
154
|
+
log.info("skipping LLM analysis")
|
|
155
|
+
else:
|
|
156
|
+
log.info("running LLM analysis")
|
|
157
|
+
reviewed = replace(pr, files=files) if skipped else pr
|
|
158
|
+
try:
|
|
159
|
+
llm_suggestions = self.llm.analyze(reviewed, existing_findings)
|
|
160
|
+
llm_provider_used = settings.llm_provider
|
|
161
|
+
warnings.extend(self.llm.warnings) # e.g. files left out over LLM_TOKEN_BUDGET
|
|
162
|
+
except Exception as e: # the LLM review is optional; keep the deterministic results
|
|
163
|
+
log.warning("llm_failed", error=str(e))
|
|
164
|
+
warnings.append(f"LLM review skipped: {e}")
|
|
165
|
+
log.info("LLM analysis done", count=len(llm_suggestions))
|
|
166
|
+
|
|
167
|
+
llm_suggestions, ignored_llm = split_ignored(llm_suggestions, files)
|
|
168
|
+
ignored += ignored_llm
|
|
169
|
+
if ignored:
|
|
170
|
+
warnings.append(ignored_warning(ignored))
|
|
171
|
+
|
|
172
|
+
# the config findings carry text from the PR, so they stay out of the LLM's prompt; and
|
|
173
|
+
# with no line, no ignore marker can drop them
|
|
174
|
+
all_suggestions = pr_config_findings + existing_findings + llm_suggestions
|
|
175
|
+
|
|
176
|
+
log.info("Scoring: ")
|
|
177
|
+
score = self.scorer.score(all_suggestions, block_on=config.block_on)
|
|
178
|
+
|
|
179
|
+
result = AnalysisResult(
|
|
180
|
+
pr=pr,
|
|
181
|
+
suggestions=all_suggestions,
|
|
182
|
+
score=score,
|
|
183
|
+
duration=round(time.perf_counter() - start, 2),
|
|
184
|
+
llm_provider_used=llm_provider_used,
|
|
185
|
+
warnings=warnings,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
if self.post_review:
|
|
189
|
+
self._post_github_review(result, config.inline_min_severity)
|
|
190
|
+
|
|
191
|
+
log.info("pipeline complete",
|
|
192
|
+
score=score.overall_score,
|
|
193
|
+
grade=score.grade,
|
|
194
|
+
passed=score.passed,
|
|
195
|
+
duration=result.duration
|
|
196
|
+
)
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
def _project_config_for(self, pr: PRSummary, warnings: list[str]) -> ProjectConfig:
|
|
200
|
+
# --config wins (the CLI stops on an invalid one); otherwise the copy on the PR's base
|
|
201
|
+
# branch, never the PR's own, so a PR can't loosen its own checks. No file there means
|
|
202
|
+
# the defaults, and so does an invalid one, with a warning: stopping instead used to fail
|
|
203
|
+
# every PR until the file was fixed, the PR fixing it included.
|
|
204
|
+
if self.project_config is not None:
|
|
205
|
+
return self.project_config
|
|
206
|
+
raw = self.github.fetch_file(pr.repo, CONFIG_FILE, pr.base_branch)
|
|
207
|
+
if raw is None:
|
|
208
|
+
return ProjectConfig()
|
|
209
|
+
config, problems = read_project_config(raw)
|
|
210
|
+
if problems:
|
|
211
|
+
warning = invalid_config_warning(f"{CONFIG_FILE} on {pr.base_branch}", problems)
|
|
212
|
+
log.warning("project_config_invalid", warning=warning) # on stderr, as well
|
|
213
|
+
warnings.append(warning)
|
|
214
|
+
return ProjectConfig()
|
|
215
|
+
log.info("project config loaded", file=CONFIG_FILE, ref=pr.base_branch)
|
|
216
|
+
return config
|
|
217
|
+
|
|
218
|
+
def _post_github_review(self, result: AnalysisResult, min_severity: str = "MEDIUM") -> None:
|
|
219
|
+
comments = self._review_comments(result, min_severity)
|
|
220
|
+
body = self._build_review_body(result, comments, min_severity)
|
|
221
|
+
|
|
222
|
+
log.info("posting github review", comments=len(comments))
|
|
223
|
+
try:
|
|
224
|
+
self.github.post_review(result.pr, body, comments)
|
|
225
|
+
except PRAnalyzerError as e:
|
|
226
|
+
# the analysis itself succeeded; report the posting problem rather than losing it
|
|
227
|
+
log.warning("review_post_failed", error=str(e))
|
|
228
|
+
result.warnings.append(f"Could not post the GitHub review: {e}")
|
|
229
|
+
|
|
230
|
+
def _review_comments(
|
|
231
|
+
self, result: AnalysisResult, min_severity: str = "MEDIUM"
|
|
232
|
+
) -> list[tuple[Suggestion, int]]:
|
|
233
|
+
# GitHub accepts inline comments only on lines that appear in the diff
|
|
234
|
+
diff_lines = {f.filename: commentable_lines(f.patch) for f in result.pr.files if f.patch}
|
|
235
|
+
comments = []
|
|
236
|
+
for s in result.suggestions:
|
|
237
|
+
if not gets_inline_comment(s, min_severity):
|
|
238
|
+
continue
|
|
239
|
+
if s.line is not None and s.line in diff_lines.get(s.filename, set()):
|
|
240
|
+
comments.append((s, s.line))
|
|
241
|
+
return comments
|
|
242
|
+
|
|
243
|
+
def _build_review_body(
|
|
244
|
+
self,
|
|
245
|
+
result: AnalysisResult,
|
|
246
|
+
comments: list[tuple[Suggestion, int]],
|
|
247
|
+
min_severity: str = "MEDIUM",
|
|
248
|
+
) -> str:
|
|
249
|
+
score = result.score
|
|
250
|
+
status = "✅ PASSED" if score.passed else "❌ BLOCKED"
|
|
251
|
+
|
|
252
|
+
lines = [
|
|
253
|
+
"## PR Analysis Report",
|
|
254
|
+
"",
|
|
255
|
+
"| Score | Grade | Status | Critical | High |",
|
|
256
|
+
"|-------|-------|--------|----------|------|",
|
|
257
|
+
f"| {score.overall_score:.1f} | {score.grade} | {status} | {score.critical_count} | {score.high_count} |",
|
|
258
|
+
"",
|
|
259
|
+
f"**Summary:** {score.summary}",
|
|
260
|
+
]
|
|
261
|
+
|
|
262
|
+
inline = {id(s) for s, _ in comments}
|
|
263
|
+
commentable = [s for s in result.suggestions if gets_inline_comment(s, min_severity)]
|
|
264
|
+
lines += findings_off_the_diff([s for s in commentable if id(s) not in inline])
|
|
265
|
+
lines += low_severity_counts(
|
|
266
|
+
[s for s in result.suggestions if not gets_inline_comment(s, min_severity)], min_severity
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
if result.warnings:
|
|
270
|
+
lines += ["", "**Notes:**"] + [f"- {neutralise_line(w)}" for w in result.warnings]
|
|
271
|
+
|
|
272
|
+
lines += ["", "---", f"*Generated by pr-analyzer — {result.duration:.2f}s*"]
|
|
273
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
"""Per-repository settings from a `.pr-analyzer.toml` file.
|
|
2
|
+
|
|
3
|
+
disable = ["magic-number", "llm"] # rule ids and whole stages to switch off
|
|
4
|
+
ignore_paths = ["docs/**", "*.min.js"] # files to leave out of the analysis
|
|
5
|
+
block_on = ["CRITICAL", "HIGH"] # severities that block the PR
|
|
6
|
+
inline_min_severity = "MEDIUM" # lowest severity that gets an inline comment
|
|
7
|
+
|
|
8
|
+
[thresholds]
|
|
9
|
+
complexity_medium = 15 # a function above this is MEDIUM...
|
|
10
|
+
complexity_high = 30 # ...and above this, HIGH
|
|
11
|
+
max_file_lines = 500
|
|
12
|
+
max_line_length = 120
|
|
13
|
+
|
|
14
|
+
Every key is optional. When a PR is analysed the file is read from the PR's base branch, never
|
|
15
|
+
from the PR itself, so a PR can't loosen its own checks; `--config PATH` names a local file
|
|
16
|
+
instead. A missing file means the defaults. An invalid --config file is an error that names every
|
|
17
|
+
problem. An invalid file on the base branch is ignored for the defaults, with a warning, so it
|
|
18
|
+
can't stop every PR, the one that fixes it included; and a PR that adds or changes the file has
|
|
19
|
+
its copy checked, never applied, each problem a finding (CONFIG_RULE) that blocks it.
|
|
20
|
+
"""
|
|
21
|
+
import dataclasses
|
|
22
|
+
import difflib
|
|
23
|
+
import json
|
|
24
|
+
import re
|
|
25
|
+
import tomllib
|
|
26
|
+
from collections.abc import Iterable
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from functools import lru_cache
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from pr_analyzer.analyzer import ast_analyzer
|
|
33
|
+
from pr_analyzer.analyzer.ast_analyzer import Thresholds
|
|
34
|
+
from pr_analyzer.exceptions import ConfigError
|
|
35
|
+
from pr_analyzer.llm.engine import LLM_RULE
|
|
36
|
+
from pr_analyzer.models import Suggestion
|
|
37
|
+
from pr_analyzer.security import scanner
|
|
38
|
+
|
|
39
|
+
CONFIG_FILE = ".pr-analyzer.toml"
|
|
40
|
+
# the rule id of problems in the copy of CONFIG_FILE that a PR adds or changes
|
|
41
|
+
CONFIG_RULE = "config"
|
|
42
|
+
|
|
43
|
+
# lowest first, so a severity's position is its rank
|
|
44
|
+
SEVERITIES = ("LOW", "MEDIUM", "HIGH", "CRITICAL")
|
|
45
|
+
RULE_IDS = frozenset(ast_analyzer.RULE_IDS + scanner.RULE_IDS + (LLM_RULE, CONFIG_RULE))
|
|
46
|
+
KEYS = ("disable", "ignore_paths", "block_on", "inline_min_severity", "thresholds")
|
|
47
|
+
THRESHOLD_KEYS = tuple(f.name for f in dataclasses.fields(Thresholds))
|
|
48
|
+
|
|
49
|
+
# The part of .gitignore's syntax that ignore_paths supports, matched as git matches it: `*` and
|
|
50
|
+
# `?` stay within one path segment, and `**` as a whole segment crosses directories (`**/` any
|
|
51
|
+
# number of them, a final `/**` everything inside); any other run of asterisks is a `*`, and
|
|
52
|
+
# everything else is literal. The syntax in _UNSUPPORTED is rejected instead.
|
|
53
|
+
_GLOB_TOKEN = re.compile(
|
|
54
|
+
r"(?P<dirs>(?:\A|(?<=/))\*{2,}/)|(?P<rest>(?:\A|(?<=/))\*{2,}\Z)|(?P<star>\*+)|(?P<one>\?)"
|
|
55
|
+
r"|[^*?]+"
|
|
56
|
+
)
|
|
57
|
+
_GLOB_PARTS = {"dirs": "(?:.*/)?", "rest": ".*", "star": "[^/]*", "one": "[^/]"}
|
|
58
|
+
# syntax that git reads differently from the literal text glob_regex would make of it
|
|
59
|
+
_UNSUPPORTED = (
|
|
60
|
+
(re.compile(r"\A!"), "uses negation (a leading !), which isn't supported: ignore_paths can "
|
|
61
|
+
"only leave files out"),
|
|
62
|
+
(re.compile(r"[\[\]]"), "uses [ or ] (a character class in git), which isn't supported: "
|
|
63
|
+
"list each name, or use ? for any one character"),
|
|
64
|
+
(re.compile(r"\\"), "uses a backslash, which isn't supported: there are no escapes, and / "
|
|
65
|
+
"separates directories"),
|
|
66
|
+
# git's own matcher treats foo**/bar inconsistently: across directories or within one
|
|
67
|
+
(re.compile(r"[^/*]\*{2,}/[^/]"), "has ** inside a name before a /, which isn't supported: "
|
|
68
|
+
"write foo*/bar for one directory, or foo*/**/bar for any "
|
|
69
|
+
"number"),
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@dataclass(frozen=True)
|
|
74
|
+
class ProjectConfig:
|
|
75
|
+
disable: frozenset[str] = frozenset()
|
|
76
|
+
ignore_paths: tuple[str, ...] = ()
|
|
77
|
+
block_on: frozenset[str] = frozenset({"CRITICAL", "HIGH"})
|
|
78
|
+
inline_min_severity: str = "MEDIUM"
|
|
79
|
+
thresholds: Thresholds = Thresholds()
|
|
80
|
+
|
|
81
|
+
def enabled(self, rule_id: str) -> bool:
|
|
82
|
+
return rule_id not in self.disable
|
|
83
|
+
|
|
84
|
+
def ignores(self, path: str) -> bool:
|
|
85
|
+
"""Whether the file matches one of ignore_paths."""
|
|
86
|
+
return any(glob_regex(pattern).fullmatch(path) for pattern in self.ignore_paths)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@lru_cache(maxsize=None)
|
|
90
|
+
def glob_regex(pattern: str) -> re.Pattern[str]:
|
|
91
|
+
"""Match repo paths the way .gitignore does, for the syntax ignore_paths accepts: a pattern
|
|
92
|
+
with no `/` except a trailing one matches at any depth, one with a leading or inner `/` only
|
|
93
|
+
from the repository root, a trailing `/` only directories, and a pattern that matches a
|
|
94
|
+
directory covers every file below it."""
|
|
95
|
+
anchored = "/" in pattern.rstrip("/")
|
|
96
|
+
directory_only = pattern.endswith("/")
|
|
97
|
+
body = "".join(
|
|
98
|
+
_GLOB_PARTS[token.lastgroup] if token.lastgroup else re.escape(token.group())
|
|
99
|
+
for token in _GLOB_TOKEN.finditer(pattern.strip("/"))
|
|
100
|
+
)
|
|
101
|
+
prefix = "" if anchored else "(?:.*/)?"
|
|
102
|
+
suffix = "/.*" if directory_only else "(?:/.*)?"
|
|
103
|
+
return re.compile(prefix + body + suffix)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def severity_rank(severity: str) -> int:
|
|
107
|
+
"""Position in SEVERITIES; -1 for anything unknown, which ranks below every real one."""
|
|
108
|
+
normalized = str(severity).strip().upper()
|
|
109
|
+
return SEVERITIES.index(normalized) if normalized in SEVERITIES else -1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def load_project_config(path: str | Path) -> ProjectConfig:
|
|
113
|
+
"""Settings from a local file, as named by --config. Unlike the base-branch lookup, a missing
|
|
114
|
+
file is an error here: someone asked for it by name."""
|
|
115
|
+
try:
|
|
116
|
+
raw = Path(path).read_bytes()
|
|
117
|
+
except OSError as e:
|
|
118
|
+
raise ConfigError(f"Can't read {path}: {e.strerror or e}") from e
|
|
119
|
+
return parse_project_config(raw, source=str(path))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def parse_project_config(raw: bytes | str, source: str = CONFIG_FILE) -> ProjectConfig:
|
|
123
|
+
"""Settings the run must use, such as a --config file: any problem is an error naming all."""
|
|
124
|
+
config, problems = read_project_config(raw)
|
|
125
|
+
if problems:
|
|
126
|
+
raise ConfigError(f"{source} is invalid:\n" + "\n".join(f" - {p}" for p in problems))
|
|
127
|
+
return config
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def read_project_config(raw: bytes | str) -> tuple[ProjectConfig, list[str]]:
|
|
131
|
+
"""The settings in a file and every problem with it. With any problem the settings are the
|
|
132
|
+
defaults: the valid part of a broken file isn't what the project chose either."""
|
|
133
|
+
try:
|
|
134
|
+
text = raw.decode("utf-8") if isinstance(raw, bytes) else raw
|
|
135
|
+
data = tomllib.loads(text)
|
|
136
|
+
except UnicodeDecodeError:
|
|
137
|
+
return ProjectConfig(), ["the file is not UTF-8 text"]
|
|
138
|
+
except tomllib.TOMLDecodeError as e:
|
|
139
|
+
return ProjectConfig(), [f"the file is not valid TOML: {e}"]
|
|
140
|
+
|
|
141
|
+
problems: list[str] = []
|
|
142
|
+
values = _validate(data, problems)
|
|
143
|
+
return (ProjectConfig() if problems else ProjectConfig(**values)), problems
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def config_findings(text: str) -> list[Suggestion]:
|
|
147
|
+
"""A HIGH finding for each problem in the copy of CONFIG_FILE that a PR adds or changes. The
|
|
148
|
+
copy is only checked, never applied; once merged, an invalid file is ignored on every PR until
|
|
149
|
+
it is fixed, so its problems block the PR that brings them in."""
|
|
150
|
+
_, problems = read_project_config(text)
|
|
151
|
+
if "\ufffd" in text:
|
|
152
|
+
# a PR's files arrive as text, with U+FFFD where the bytes weren't UTF-8; read as bytes
|
|
153
|
+
# from the base branch once merged, such a file is rejected outright
|
|
154
|
+
problems.insert(0, "the file has U+FFFD replacement characters, so it is probably not "
|
|
155
|
+
"UTF-8 text")
|
|
156
|
+
return [
|
|
157
|
+
Suggestion(
|
|
158
|
+
filename=CONFIG_FILE,
|
|
159
|
+
severity="HIGH",
|
|
160
|
+
category="style",
|
|
161
|
+
title=f"Invalid config: {problem}",
|
|
162
|
+
description=(
|
|
163
|
+
f"This PR's {CONFIG_FILE} is invalid: {problem}. A PR's copy of the file is only "
|
|
164
|
+
"checked, never applied. Once merged, an invalid file is ignored, and every PR is "
|
|
165
|
+
"analysed with the default settings until it is fixed."
|
|
166
|
+
),
|
|
167
|
+
line=None,
|
|
168
|
+
source="static",
|
|
169
|
+
rule_id=CONFIG_RULE,
|
|
170
|
+
)
|
|
171
|
+
for problem in problems
|
|
172
|
+
]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _validate(data: dict[str, Any], problems: list[str]) -> dict[str, Any]:
|
|
176
|
+
problems += [unknown("key", key, KEYS) for key in data if key not in KEYS]
|
|
177
|
+
values: dict[str, Any] = {}
|
|
178
|
+
if "disable" in data:
|
|
179
|
+
values["disable"] = frozenset(_rule_ids(data["disable"], problems))
|
|
180
|
+
if "ignore_paths" in data:
|
|
181
|
+
values["ignore_paths"] = tuple(_ignore_paths(data["ignore_paths"], problems))
|
|
182
|
+
if "block_on" in data:
|
|
183
|
+
values["block_on"] = frozenset(_severities("block_on", data["block_on"], problems))
|
|
184
|
+
if "inline_min_severity" in data:
|
|
185
|
+
values["inline_min_severity"] = _severity(
|
|
186
|
+
"inline_min_severity", data["inline_min_severity"], problems
|
|
187
|
+
)
|
|
188
|
+
if "thresholds" in data:
|
|
189
|
+
values["thresholds"] = _thresholds(data["thresholds"], problems)
|
|
190
|
+
return values
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def unknown(kind: str, name: str, known: Iterable[str]) -> str:
|
|
194
|
+
"""'unknown rule id "magic-numbers" (did you mean "magic-number"?)'"""
|
|
195
|
+
# compare as lower-case kebab-case, so MAGIC_NUMBER and ignore-paths find their match
|
|
196
|
+
by_form = {k.lower().replace("_", "-"): k for k in known}
|
|
197
|
+
close = difflib.get_close_matches(str(name).lower().replace("_", "-"), list(by_form), n=1)
|
|
198
|
+
if close:
|
|
199
|
+
return f"unknown {kind} {name!r} (did you mean {by_form[close[0]]!r}?)"
|
|
200
|
+
return f"unknown {kind} {name!r}; use one of: {', '.join(sorted(by_form.values()))}"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _strings(key: str, value: Any, problems: list[str]) -> list[str]:
|
|
204
|
+
if isinstance(value, list) and all(isinstance(item, str) for item in value):
|
|
205
|
+
return value
|
|
206
|
+
problems.append(f'{key} must be a list of strings, e.g. {key} = ["..."]')
|
|
207
|
+
return []
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _rule_ids(value: Any, problems: list[str]) -> list[str]:
|
|
211
|
+
ids = _strings("disable", value, problems)
|
|
212
|
+
problems += [
|
|
213
|
+
f"disable: {unknown('rule id', rule, RULE_IDS)}" for rule in ids if rule not in RULE_IDS
|
|
214
|
+
]
|
|
215
|
+
return [rule for rule in ids if rule in RULE_IDS]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _ignore_paths(value: Any, problems: list[str]) -> list[str]:
|
|
219
|
+
patterns = _strings("ignore_paths", value, problems)
|
|
220
|
+
found = {pattern: _pattern_problems(pattern) for pattern in patterns}
|
|
221
|
+
problems += [f"ignore_paths: {p!r} {why}" for p, whys in found.items() for why in whys]
|
|
222
|
+
return [p for p in patterns if not found[p]]
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _pattern_problems(pattern: str) -> list[str]:
|
|
226
|
+
if not pattern.strip("/"):
|
|
227
|
+
return ["is not a path pattern"]
|
|
228
|
+
# rejected rather than read literally: each would quietly match something other than what
|
|
229
|
+
# the same line does in a .gitignore
|
|
230
|
+
return [why for syntax, why in _UNSUPPORTED if syntax.search(pattern)]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _severity(key: str, value: Any, problems: list[str]) -> str:
|
|
234
|
+
if isinstance(value, str) and severity_rank(value) >= 0:
|
|
235
|
+
return value.strip().upper()
|
|
236
|
+
problems.append(f"{key}: {value!r} is not a severity; use one of {', '.join(SEVERITIES)}")
|
|
237
|
+
return ""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _severities(key: str, value: Any, problems: list[str]) -> list[str]:
|
|
241
|
+
severities = (_severity(key, item, problems) for item in _strings(key, value, problems))
|
|
242
|
+
return [s for s in severities if s]
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _thresholds(value: Any, problems: list[str]) -> Thresholds:
|
|
246
|
+
if not isinstance(value, dict):
|
|
247
|
+
problems.append("thresholds must be a table: a [thresholds] section")
|
|
248
|
+
return Thresholds()
|
|
249
|
+
limits: dict[str, int] = {}
|
|
250
|
+
for key, number in value.items():
|
|
251
|
+
if key not in THRESHOLD_KEYS:
|
|
252
|
+
problems.append(f"thresholds: {unknown('key', key, THRESHOLD_KEYS)}")
|
|
253
|
+
elif isinstance(number, bool) or not isinstance(number, int) or number < 1:
|
|
254
|
+
# shown the way TOML writes it: true, "120", 99.5
|
|
255
|
+
shown = json.dumps(number, default=str)
|
|
256
|
+
problems.append(f"thresholds.{key} must be a whole number above 0, not {shown}")
|
|
257
|
+
else:
|
|
258
|
+
limits[key] = number
|
|
259
|
+
|
|
260
|
+
thresholds = dataclasses.replace(Thresholds(), **limits)
|
|
261
|
+
if thresholds.complexity_medium > thresholds.complexity_high:
|
|
262
|
+
problems.append(
|
|
263
|
+
f"thresholds.complexity_medium ({thresholds.complexity_medium}) must not be above "
|
|
264
|
+
f"complexity_high ({thresholds.complexity_high})"
|
|
265
|
+
)
|
|
266
|
+
return thresholds
|
|
File without changes
|