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,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"