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