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
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""Tell code, string literals and comments apart on one line of source.
|
|
2
|
+
|
|
3
|
+
The line rules look at one added line at a time. A rule about a call must not fire on text inside
|
|
4
|
+
a string or a comment, and the rule about to-do markers must not fire on what a string holds, so
|
|
5
|
+
a line is split into two views of the same length:
|
|
6
|
+
|
|
7
|
+
line: x = run(y) # run("1") is fine
|
|
8
|
+
code: x = run(y)
|
|
9
|
+
comments: # run("1") is fine
|
|
10
|
+
|
|
11
|
+
In the code view a string keeps its quotes and prefix but its contents become spaces
|
|
12
|
+
(`"SELECT " + id` -> `" " + id`), so rules about how a string is used, such as the
|
|
13
|
+
SQL-injection rule, still see it. The replacement fields of an f-string or a template literal are
|
|
14
|
+
code, so they stay; only the text around them, and a format spec, is blanked
|
|
15
|
+
(`f"id {run(x):>8}"` -> `f" {run(x) }"`, `` `a ${run(b)}` `` -> `` ` ${run(b)}` ``).
|
|
16
|
+
|
|
17
|
+
Python, JavaScript/TypeScript and C-family files are lexed; for any other language both views are
|
|
18
|
+
the whole line, which is how every rule matched before. Some lines are comments as a whole: a line
|
|
19
|
+
starting with `*` (the middle of a /* */ block, which started on an earlier line), a Python line
|
|
20
|
+
holding only a docstring (a note, not data) and, in C, C++ and C#, a line starting with `#` (a
|
|
21
|
+
preprocessor directive such as `#define` or `#pragma`).
|
|
22
|
+
|
|
23
|
+
Limits, since each line is read alone:
|
|
24
|
+
|
|
25
|
+
- a line inside a multi-line string (a docstring's middle lines, a template literal spanning
|
|
26
|
+
lines) or inside a /* */ block is read as code, unless it starts like a comment (`*`, `#`, `//`);
|
|
27
|
+
- JavaScript regex literals aren't recognised, so a quote inside one hides the rest of the line;
|
|
28
|
+
- in C-family files only double-quoted strings and character literals ('"') are recognised.
|
|
29
|
+
"""
|
|
30
|
+
import re
|
|
31
|
+
from collections.abc import Mapping
|
|
32
|
+
from dataclasses import dataclass, field, replace
|
|
33
|
+
|
|
34
|
+
COMMENT_PREFIXES = ("#", "//", "/*", "*")
|
|
35
|
+
# replacement fields nested deeper than this (never seen in real code) end the lexing of a line,
|
|
36
|
+
# so a crafted line can't exhaust the recursion limit
|
|
37
|
+
MAX_NESTING = 20
|
|
38
|
+
|
|
39
|
+
_STRING = r'"(?:\\(?:.|$)|[^"\\])*(?:"|$)' # an unclosed string runs to the end of the line
|
|
40
|
+
_SINGLE_QUOTED = r"'(?:\\(?:.|$)|[^'\\])*(?:'|$)"
|
|
41
|
+
_TRIPLE_QUOTED = r'"""(?:\\(?:.|$)|[^\\])*?(?:"""|$)' r"|'''(?:\\(?:.|$)|[^\\])*?(?:'''|$)"
|
|
42
|
+
_BLOCK_COMMENT = r"/\*.*?(?:\*/|$)"
|
|
43
|
+
# inside a replacement field brackets are counted, so the field ends at its own closing brace
|
|
44
|
+
_BRACKETS = r"|(?P<open>[(\[{])|(?P<close>[)\]}])"
|
|
45
|
+
# a format spec is text up to the end of its field, apart from the fields nested in it
|
|
46
|
+
_FORMAT_SPEC = re.compile(r"[^{}]*")
|
|
47
|
+
_REST_OF_LINE = re.compile(r".*")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(frozen=True)
|
|
51
|
+
class Syntax:
|
|
52
|
+
"""How a family of languages writes comments and strings."""
|
|
53
|
+
tokens: re.Pattern[str] # a comment, a string, or the start of an interpolated string
|
|
54
|
+
field_tokens: re.Pattern[str] # the same plus brackets, inside a replacement field
|
|
55
|
+
comment_lines: tuple[str, ...] = ("*",) # a line starting with one of these is a comment
|
|
56
|
+
docstring: re.Pattern[str] | None = None # a line this fully matches is a comment too
|
|
57
|
+
# The text of an interpolated string up to a replacement field, by (quote, raw), and what
|
|
58
|
+
# opens a field: Python's f-strings and t-strings and JavaScript's template literals have them.
|
|
59
|
+
template_text: Mapping[tuple[str, bool], re.Pattern[str]] = field(default_factory=dict)
|
|
60
|
+
field_opener: str = ""
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _fstring_text(quote: str, raw: bool) -> re.Pattern[str]:
|
|
64
|
+
"""An f-string's text up to a replacement field, its closing quote or the end of the line.
|
|
65
|
+
`{{` and `}}` stand for a brace, a backslash never escapes `{`, and `\\N{...}` names a
|
|
66
|
+
character unless the string is raw."""
|
|
67
|
+
q = quote[0]
|
|
68
|
+
named = "" if raw else r"\\N\{[^}]*\}?|"
|
|
69
|
+
lone_quote = f"|{q}(?!{q}{q})" if len(quote) == 3 else ""
|
|
70
|
+
return re.compile(
|
|
71
|
+
"(?:" + named + r"\\(?:[^{]|(?=\{)|$)|\{\{|\}|[^\\{}" + q + "]" + lone_quote + ")*"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
_PYTHON_TOKENS = (
|
|
76
|
+
r"(?P<comment>#.*)"
|
|
77
|
+
# an f-string or a t-string: its replacement fields hold code, so its body is lexed apart
|
|
78
|
+
r"|(?P<template>(?<!\w)(?P<prefix>[rRbBuU]?[fFtT][rRbBuU]?)(?P<quote>'''|\"\"\"|'|\"))"
|
|
79
|
+
rf"|(?P<string>{_TRIPLE_QUOTED}|{_STRING}|{_SINGLE_QUOTED})"
|
|
80
|
+
)
|
|
81
|
+
PYTHON = Syntax(
|
|
82
|
+
tokens=re.compile(_PYTHON_TOKENS),
|
|
83
|
+
# a ":" outside brackets ends a field's expression and starts its format spec
|
|
84
|
+
field_tokens=re.compile(_PYTHON_TOKENS + _BRACKETS + r"|(?P<colon>:)"),
|
|
85
|
+
# a triple-quoted string alone on its line, closed there or not
|
|
86
|
+
docstring=re.compile(
|
|
87
|
+
r'\s*[rRuU]?(?:"""(?:\\(?:.|$)|[^\\"]|"(?!""))*(?:"""\s*(?:#.*)?)?'
|
|
88
|
+
r"|'''(?:\\(?:.|$)|[^\\']|'(?!''))*(?:'''\s*(?:#.*)?)?)"
|
|
89
|
+
),
|
|
90
|
+
template_text={
|
|
91
|
+
(quote, raw): _fstring_text(quote, raw)
|
|
92
|
+
for quote in ('"', "'", '"""', "'''") for raw in (False, True)
|
|
93
|
+
},
|
|
94
|
+
field_opener="{",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
_JAVASCRIPT_TOKENS = (
|
|
98
|
+
rf"(?P<comment>//.*|{_BLOCK_COMMENT})"
|
|
99
|
+
r"|(?P<template>(?P<quote>`))"
|
|
100
|
+
rf"|(?P<string>{_STRING}|{_SINGLE_QUOTED})"
|
|
101
|
+
)
|
|
102
|
+
JAVASCRIPT = Syntax(
|
|
103
|
+
tokens=re.compile(_JAVASCRIPT_TOKENS),
|
|
104
|
+
field_tokens=re.compile(_JAVASCRIPT_TOKENS + _BRACKETS),
|
|
105
|
+
template_text={("`", False): re.compile(r"(?:\\(?:.|$)|\$(?!\{)|[^\\$`])*")},
|
|
106
|
+
field_opener="${",
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
# Single quotes hold one character ('"', '\n'); a lone one is a Rust lifetime ('a).
|
|
110
|
+
_C_TOKENS = re.compile(
|
|
111
|
+
rf"(?P<comment>//.*|{_BLOCK_COMMENT})|(?P<string>{_STRING}|'(?:\\.|[^'\\])')"
|
|
112
|
+
)
|
|
113
|
+
C_FAMILY = Syntax(tokens=_C_TOKENS, field_tokens=_C_TOKENS)
|
|
114
|
+
# a line starting with "#" is a preprocessor directive (#define, #pragma, #region), not code
|
|
115
|
+
C_PREPROCESSED = replace(C_FAMILY, comment_lines=("*", "#"))
|
|
116
|
+
|
|
117
|
+
# PHP isn't lexed: its '...' strings and # comments would need a lexer of their own.
|
|
118
|
+
LEXERS = {".py": PYTHON}
|
|
119
|
+
LEXERS.update(dict.fromkeys((".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"), JAVASCRIPT))
|
|
120
|
+
LEXERS.update(dict.fromkeys((".java", ".go", ".kt", ".swift", ".scala", ".rs"), C_FAMILY))
|
|
121
|
+
LEXERS.update(dict.fromkeys((".c", ".h", ".cc", ".cpp", ".hpp", ".cs"), C_PREPROCESSED))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@dataclass(frozen=True)
|
|
125
|
+
class LineParts:
|
|
126
|
+
code: str # string contents and comments blanked out
|
|
127
|
+
comments: str # only the comments; everything else blanked out
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def is_comment(text: str) -> bool:
|
|
131
|
+
return text.lstrip().startswith(COMMENT_PREFIXES)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def split_line(text: str, extension: str) -> LineParts:
|
|
135
|
+
syntax = LEXERS.get(extension)
|
|
136
|
+
if syntax is None:
|
|
137
|
+
# Without a lexer, any comment prefix marks a whole-line comment, as it always did.
|
|
138
|
+
return LineParts(" " * len(text), text) if is_comment(text) else LineParts(text, text)
|
|
139
|
+
if text.lstrip().startswith(syntax.comment_lines) or (
|
|
140
|
+
syntax.docstring is not None and syntax.docstring.fullmatch(text)
|
|
141
|
+
):
|
|
142
|
+
return LineParts(" " * len(text), text)
|
|
143
|
+
return _Lexer(text, syntax).split()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def blank_string(literal: str) -> str:
|
|
147
|
+
"""Keep a string literal's quotes and blank everything between them."""
|
|
148
|
+
quote = literal[:3] if literal[:3] in ('"""', "'''") else literal[0]
|
|
149
|
+
closed = len(literal) >= 2 * len(quote) and literal.endswith(quote)
|
|
150
|
+
inside = len(literal) - len(quote) * (2 if closed else 1)
|
|
151
|
+
return quote + " " * inside + (quote if closed else "")
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class _Lexer:
|
|
155
|
+
"""Builds the two views of one line from left to right."""
|
|
156
|
+
|
|
157
|
+
def __init__(self, text: str, syntax: Syntax) -> None:
|
|
158
|
+
self.text = text
|
|
159
|
+
self.syntax = syntax
|
|
160
|
+
self.code_view: list[str] = []
|
|
161
|
+
self.comments_view: list[str] = []
|
|
162
|
+
self.nesting = 0 # replacement fields open around the current position
|
|
163
|
+
|
|
164
|
+
def split(self) -> LineParts:
|
|
165
|
+
self.lex_code(0)
|
|
166
|
+
return LineParts("".join(self.code_view), "".join(self.comments_view))
|
|
167
|
+
|
|
168
|
+
def keep_code(self, chunk: str) -> None:
|
|
169
|
+
self.code_view.append(chunk)
|
|
170
|
+
self.comments_view.append(" " * len(chunk))
|
|
171
|
+
|
|
172
|
+
def keep_comment(self, chunk: str) -> None:
|
|
173
|
+
self.code_view.append(" " * len(chunk))
|
|
174
|
+
self.comments_view.append(chunk)
|
|
175
|
+
|
|
176
|
+
def skip_text(self, pattern: re.Pattern[str], pos: int) -> int:
|
|
177
|
+
"""Blank the string text `pattern` matches at `pos` in both views; return where it ends."""
|
|
178
|
+
match = pattern.match(self.text, pos)
|
|
179
|
+
end = match.end() if match else pos
|
|
180
|
+
self.code_view.append(" " * (end - pos))
|
|
181
|
+
self.comments_view.append(" " * (end - pos))
|
|
182
|
+
return end
|
|
183
|
+
|
|
184
|
+
def lex_code(self, pos: int, in_field: bool = False) -> int:
|
|
185
|
+
"""Lex code from `pos` to the end of the line or, in a replacement field, up to the `}`
|
|
186
|
+
that closes the field or the `:` that starts its format spec. Returns where it stopped."""
|
|
187
|
+
tokens = self.syntax.field_tokens if in_field else self.syntax.tokens
|
|
188
|
+
depth = 0 # brackets opened inside the field
|
|
189
|
+
while (token := tokens.search(self.text, pos)) is not None:
|
|
190
|
+
self.keep_code(self.text[pos:token.start()])
|
|
191
|
+
pos = token.end()
|
|
192
|
+
kind, lexeme = token.lastgroup, token.group()
|
|
193
|
+
if kind == "comment":
|
|
194
|
+
self.keep_comment(lexeme)
|
|
195
|
+
elif kind == "string":
|
|
196
|
+
self.keep_code(blank_string(lexeme))
|
|
197
|
+
elif kind == "template":
|
|
198
|
+
pos = self.lex_template(token)
|
|
199
|
+
elif kind == "open":
|
|
200
|
+
depth += 1
|
|
201
|
+
self.keep_code(lexeme)
|
|
202
|
+
elif depth == 0 and lexeme in ("}", ":"):
|
|
203
|
+
return token.start()
|
|
204
|
+
else: # a bracket closing inside the field, or a colon inside brackets
|
|
205
|
+
depth = max(depth - 1, 0) if kind == "close" else depth
|
|
206
|
+
self.keep_code(lexeme)
|
|
207
|
+
self.keep_code(self.text[pos:])
|
|
208
|
+
return len(self.text)
|
|
209
|
+
|
|
210
|
+
def lex_template(self, opening: re.Match[str]) -> int:
|
|
211
|
+
"""Lex an f-string or template literal from its opening: blank its text and keep the code
|
|
212
|
+
in its replacement fields. Returns where the string ends."""
|
|
213
|
+
self.keep_code(opening.group()) # the prefix and the quote
|
|
214
|
+
quote = opening["quote"]
|
|
215
|
+
raw = "r" in (opening.groupdict().get("prefix") or "").lower()
|
|
216
|
+
literal = self.syntax.template_text[quote, raw]
|
|
217
|
+
opener = self.syntax.field_opener
|
|
218
|
+
pos = self.skip_text(literal, opening.end())
|
|
219
|
+
while pos < len(self.text) and not self.text.startswith(quote, pos):
|
|
220
|
+
self.keep_code(opener)
|
|
221
|
+
pos = self.skip_text(literal, self.lex_field(pos + len(opener)))
|
|
222
|
+
if pos < len(self.text):
|
|
223
|
+
self.keep_code(quote)
|
|
224
|
+
pos += len(quote)
|
|
225
|
+
return pos
|
|
226
|
+
|
|
227
|
+
def lex_field(self, pos: int) -> int:
|
|
228
|
+
"""Lex a replacement field from just after its opener: its code, then in Python its
|
|
229
|
+
format spec, up to and including the closing brace. Returns where the field ends."""
|
|
230
|
+
if self.nesting == MAX_NESTING: # the rest of the line counts as text
|
|
231
|
+
return self.skip_text(_REST_OF_LINE, pos)
|
|
232
|
+
self.nesting += 1
|
|
233
|
+
pos = self.lex_code(pos, in_field=True)
|
|
234
|
+
if self.text.startswith(":", pos):
|
|
235
|
+
pos = self.lex_format_spec(pos)
|
|
236
|
+
self.nesting -= 1
|
|
237
|
+
if self.text.startswith("}", pos):
|
|
238
|
+
self.keep_code("}")
|
|
239
|
+
pos += 1
|
|
240
|
+
return pos
|
|
241
|
+
|
|
242
|
+
def lex_format_spec(self, pos: int) -> int:
|
|
243
|
+
"""A format spec (`:>8`) is text, apart from the fields nested in it (`:{width}`).
|
|
244
|
+
Returns where it ends: at the brace closing its field, or the end of the line."""
|
|
245
|
+
pos = self.skip_text(_FORMAT_SPEC, pos)
|
|
246
|
+
while self.text.startswith("{", pos):
|
|
247
|
+
self.keep_code("{")
|
|
248
|
+
pos = self.skip_text(_FORMAT_SPEC, self.lex_field(pos + 1))
|
|
249
|
+
return pos
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
from collections.abc import Collection
|
|
2
|
+
|
|
3
|
+
import structlog
|
|
4
|
+
from pr_analyzer.models import QualityScore, Suggestion
|
|
5
|
+
log = structlog.get_logger(__name__)
|
|
6
|
+
|
|
7
|
+
SEVERITY_PENALTIES = {
|
|
8
|
+
"CRITICAL": 40,
|
|
9
|
+
"HIGH": 20,
|
|
10
|
+
"MEDIUM": 8,
|
|
11
|
+
"LOW" : 3,
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
DIMENSION_WEIGHTS = {
|
|
15
|
+
"security" : 0.35,
|
|
16
|
+
"style" : 0.15,
|
|
17
|
+
"complexity" : 0.25,
|
|
18
|
+
"documentation" : 0.15,
|
|
19
|
+
"testing" : 0.10,
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
# a category outside the five dimensions is scored as style, the lowest-weighted one, rather
|
|
23
|
+
# than silently ignored
|
|
24
|
+
FALLBACK_DIMENSION = "style"
|
|
25
|
+
|
|
26
|
+
GRADE_THRESHOLDS = [
|
|
27
|
+
(97, "A+"), (93, "A"), (90, "A-"),
|
|
28
|
+
(87, "B+"), (83, "B"), (80, "B-"),
|
|
29
|
+
(77, "C+"), (73, "C"), (70, "C-"),
|
|
30
|
+
(65, "D"), (0, "F"),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
# the default; a project's .pr-analyzer.toml can name other severities in `block_on`
|
|
34
|
+
BLOCK_SEVERITY = frozenset({"CRITICAL", "HIGH"})
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _severity(s: Suggestion) -> str:
|
|
38
|
+
return str(s.severity).strip().upper()
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class Scorer:
|
|
42
|
+
def score(
|
|
43
|
+
self,
|
|
44
|
+
suggestions: list[Suggestion],
|
|
45
|
+
base: float | None = None,
|
|
46
|
+
block_on: Collection[str] = BLOCK_SEVERITY,
|
|
47
|
+
) -> QualityScore:
|
|
48
|
+
|
|
49
|
+
dimension_groups = self._group_by_dimension(suggestions)
|
|
50
|
+
dimension_scores = {
|
|
51
|
+
dim: self._score_dimension(dimension_groups[dim])
|
|
52
|
+
for dim in DIMENSION_WEIGHTS
|
|
53
|
+
}
|
|
54
|
+
# round once, before grading, so the score shown and the grade always agree
|
|
55
|
+
weighted_score = round(self._compute_weighted_scores(dimension_scores), 1)
|
|
56
|
+
grade = self._map_grade(weighted_score)
|
|
57
|
+
check_passed = self._check_passed(suggestions, block_on)
|
|
58
|
+
delta = self._compute_delta(weighted_score, base)
|
|
59
|
+
summary = self._write_summary(suggestions, weighted_score, check_passed)
|
|
60
|
+
|
|
61
|
+
critical_count = sum(1 for s in suggestions if _severity(s) == "CRITICAL")
|
|
62
|
+
high_count = sum(1 for s in suggestions if _severity(s) == "HIGH")
|
|
63
|
+
|
|
64
|
+
return QualityScore(
|
|
65
|
+
overall_score=weighted_score,
|
|
66
|
+
grade=grade,
|
|
67
|
+
passed = check_passed,
|
|
68
|
+
summary=summary,
|
|
69
|
+
delta=delta,
|
|
70
|
+
dimension_scores={dim: round(value, 1) for dim, value in dimension_scores.items()},
|
|
71
|
+
critical_count= critical_count,
|
|
72
|
+
high_count=high_count
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _group_by_dimension(self, suggestions: list[Suggestion]) -> dict:
|
|
76
|
+
groups: dict[str, list[Suggestion]] = {}
|
|
77
|
+
|
|
78
|
+
for dim in DIMENSION_WEIGHTS:
|
|
79
|
+
groups[dim] = []
|
|
80
|
+
|
|
81
|
+
for s in suggestions:
|
|
82
|
+
category = str(s.category).strip().lower()
|
|
83
|
+
groups[category if category in groups else FALLBACK_DIMENSION].append(s)
|
|
84
|
+
|
|
85
|
+
return groups
|
|
86
|
+
|
|
87
|
+
def _score_dimension(self, suggestions: list[Suggestion]) -> float:
|
|
88
|
+
score = 100.0
|
|
89
|
+
for s in suggestions:
|
|
90
|
+
penalty = SEVERITY_PENALTIES.get(_severity(s))
|
|
91
|
+
if penalty is None:
|
|
92
|
+
# an unknown severity used to raise KeyError and abort the whole run
|
|
93
|
+
log.warning("unknown_severity_ignored", severity=s.severity, title=s.title)
|
|
94
|
+
continue
|
|
95
|
+
confidence = min(max(s.confidence, 0.0), 1.0)
|
|
96
|
+
score -= penalty * confidence
|
|
97
|
+
return max(score, 0.0)
|
|
98
|
+
|
|
99
|
+
def _compute_weighted_scores(self, dimension_scores:dict)-> float:
|
|
100
|
+
weighted_scores = sum(dimension_scores[dim]* DIMENSION_WEIGHTS[dim] for dim in DIMENSION_WEIGHTS)
|
|
101
|
+
return weighted_scores
|
|
102
|
+
|
|
103
|
+
def _map_grade(self, score: float) -> str:
|
|
104
|
+
for minimum, grade in GRADE_THRESHOLDS:
|
|
105
|
+
if score >= minimum:
|
|
106
|
+
return grade
|
|
107
|
+
return "F"
|
|
108
|
+
|
|
109
|
+
def _check_passed(
|
|
110
|
+
self, suggestions: list[Suggestion], block_on: Collection[str] = BLOCK_SEVERITY
|
|
111
|
+
) -> bool:
|
|
112
|
+
return not any(_severity(s) in block_on for s in suggestions)
|
|
113
|
+
|
|
114
|
+
def _compute_delta(self, overall: float, base: float | None) -> float | None:
|
|
115
|
+
if base is None:
|
|
116
|
+
return None
|
|
117
|
+
return round(overall - base, 2)
|
|
118
|
+
|
|
119
|
+
def _write_summary(self, suggestions: list[Suggestion], score: float, passed: bool = True) -> str:
|
|
120
|
+
critical = sum(1 for s in suggestions if _severity(s) == "CRITICAL")
|
|
121
|
+
high = sum(1 for s in suggestions if _severity(s) == "HIGH")
|
|
122
|
+
|
|
123
|
+
if critical:
|
|
124
|
+
return f"{critical} critical issue(s) found — review required"
|
|
125
|
+
if high:
|
|
126
|
+
return f"{high} high severity issue(s) found"
|
|
127
|
+
if not passed:
|
|
128
|
+
# a project that blocks on MEDIUM must not see "Clean pass" next to BLOCKED
|
|
129
|
+
return f"{len(suggestions)} issue(s) found, including blocking ones"
|
|
130
|
+
if score >= 90:
|
|
131
|
+
return "Clean pass — no significant issues"
|
|
132
|
+
return f"{len(suggestions)} issue(s) found across multiple files"
|