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.
File without changes
File without changes
@@ -0,0 +1,467 @@
1
+ import os
2
+ import re
3
+ from bisect import bisect_left
4
+ from collections.abc import Collection, Iterator
5
+ from dataclasses import dataclass
6
+ from functools import lru_cache
7
+ from http import HTTPStatus
8
+ from typing import Any
9
+
10
+ import structlog
11
+ import tree_sitter_javascript as tsjavascript
12
+ import tree_sitter_python as tspython
13
+ import tree_sitter_typescript as tstypescript
14
+ from tree_sitter import Language, Node, Parser
15
+
16
+ from pr_analyzer.analyzer.line_lexer import LineParts, split_line
17
+ from pr_analyzer.diff import count_changes, iter_added_lines
18
+ from pr_analyzer.models import FileChange, Suggestion
19
+
20
+ log = structlog.get_logger(__name__)
21
+
22
+
23
+ # for the treesitter
24
+
25
+ LANGUAGE_MAP = {
26
+ ".py": "python",
27
+ ".js": "javascript",
28
+ ".jsx": "javascript",
29
+ ".mjs": "javascript",
30
+ ".cjs": "javascript",
31
+ ".ts": "typescript",
32
+ ".tsx": "tsx",
33
+ }
34
+
35
+ PYTHON = {".py"}
36
+ JS_TS = {".js", ".jsx", ".mjs", ".cjs", ".ts", ".tsx"}
37
+ # style rules only make sense in source code, not in docs, lockfiles or data files
38
+ CODE_EXTENSIONS = PYTHON | JS_TS | {
39
+ ".java", ".go", ".rb", ".php", ".cs", ".rs", ".kt", ".swift",
40
+ ".c", ".h", ".cc", ".cpp", ".hpp", ".scala", ".sh",
41
+ }
42
+
43
+ # numbers whose meaning is plain wherever they appear
44
+ WELL_KNOWN_NUMBERS = frozenset({
45
+ 10, 100, 1000, # powers of ten: percentages, scaling, ms
46
+ 24, 60, 3600, 86400, # hours in a day; seconds in a minute/hour/day
47
+ 16, 32, 64, 128, 256, 512, 1024, 2048, 4096, 8192, 65536, # bit widths and buffer sizes
48
+ 255, 65535, # the largest 8- and 16-bit values
49
+ 80, 443, # the HTTP and HTTPS ports
50
+ }) | {int(status) for status in HTTPStatus} # standard HTTP status codes, 100 to 511
51
+ # int() refuses a literal of thousands of digits, and no well-known number is that long
52
+ WELL_KNOWN_DIGITS = len(str(max(WELL_KNOWN_NUMBERS)))
53
+ # `timeout=30`, `retries = 5`, `"port": 8080`, `{ port: 8080 }`: the name right before the number
54
+ # says what it means. Comparisons (==, <=, ...) and augmented assignments (+=) don't name it.
55
+ NAMED_BY = re.compile(r"(?:\b[A-Za-z_]\w*|[\"'\]])\s*(?::|(?<![=!<>+\-*/%&|^~:@])=)\s*[-+]?$")
56
+ # How far before a number to look for the name or the `[` that explains it. A line of a usual
57
+ # length is searched in full; searching a whole generated line again for each of its thousands of
58
+ # numbers took minutes.
59
+ NUMBER_CONTEXT = 120
60
+
61
+
62
+ def in_subscript(code: str, pos: int) -> bool:
63
+ """Whether `pos` is inside [...] that follows a name, a call or a string: an index or a slice
64
+ such as `line[:72]` or `parts[-2]`, not a list literal."""
65
+ opening = code.rfind("[", max(0, pos - NUMBER_CONTEXT), pos)
66
+ if opening < 1 or code.find("]", opening, pos) != -1:
67
+ return False
68
+ before = code[opening - 1]
69
+ return before.isalnum() or before in "_)]\"'"
70
+
71
+
72
+ def explained_number(code: str, match: re.Match[str]) -> bool:
73
+ """A number a reader understands without a named constant."""
74
+ digits, start = match.group(), match.start()
75
+ return (
76
+ (len(digits) <= WELL_KNOWN_DIGITS and int(digits) in WELL_KNOWN_NUMBERS)
77
+ or NAMED_BY.search(code, max(0, start - NUMBER_CONTEXT), start) is not None
78
+ or in_subscript(code, start)
79
+ )
80
+
81
+
82
+ # Each rule runs on one added line at a time (the text after the leading "+"). Its "id" is the
83
+ # stable name .pr-analyzer.toml uses to switch it off. Its "scope" picks the views of the line it
84
+ # reads (see line_lexer): "code" leaves out comments and the contents of strings, "code+comments"
85
+ # only the contents of strings. An optional "exempt" callable can wave off individual matches.
86
+ # Hardcoded secrets are not checked here: the security scanner owns them, and checking them in
87
+ # both places used to penalise the same line twice.
88
+ PATTERNS: list[dict[str, Any]] = [
89
+ {
90
+ "id": "todo-comment",
91
+ "regex": re.compile(r"\b(TODO|FIXME)\b"),
92
+ "extensions": CODE_EXTENSIONS,
93
+ # In a string the marker is data, such as a status value, not a note to the team. The
94
+ # middle lines of a docstring or a /* */ comment read as code, so code counts too.
95
+ "scope": "code+comments",
96
+ "severity": "LOW",
97
+ "category": "style",
98
+ "title": "TODO/FIXME comment added",
99
+ "description": "This line introduces a TODO or FIXME marker. Consider "
100
+ "tracking this in an issue tracker instead of leaving "
101
+ "it in code.",
102
+ },
103
+ {
104
+ "id": "console-log",
105
+ "regex": re.compile(r"\bconsole\.log\s*\("),
106
+ "extensions": JS_TS,
107
+ "scope": "code",
108
+ "severity": "LOW",
109
+ "category": "style",
110
+ "title": "console.log left in code",
111
+ "description": "Debug logging via console.log was added. Consider "
112
+ "removing it or replacing it with a proper logger "
113
+ "before merging.",
114
+ },
115
+ {
116
+ "id": "print-call",
117
+ "regex": re.compile(r"(?<![\w.])print\s*\("),
118
+ "extensions": PYTHON,
119
+ "scope": "code",
120
+ "severity": "LOW",
121
+ "category": "style",
122
+ "title": "print() left in code",
123
+ "description": "A print() call was added. In production code this "
124
+ "is usually better handled through structured "
125
+ "logging.",
126
+ },
127
+ {
128
+ "id": "bare-except",
129
+ "regex": re.compile(r"^\s*except\s*:"),
130
+ "extensions": PYTHON,
131
+ "scope": "code",
132
+ "severity": "MEDIUM",
133
+ "category": "security",
134
+ "title": "Bare except clause",
135
+ "description": "A bare 'except:' catches every exception, including "
136
+ "ones you don't expect (KeyboardInterrupt, "
137
+ "SystemExit). Catch a specific exception type "
138
+ "instead.",
139
+ },
140
+ {
141
+ "id": "magic-number",
142
+ # an integer of two or more digits, not part of a name or a decimal fraction
143
+ "regex": re.compile(r"(?<=[^\w.])\d{2,}\b"),
144
+ "extensions": CODE_EXTENSIONS,
145
+ "scope": "code",
146
+ "skip_tests": True,
147
+ # defining a named constant (_PRIVATE ones too) is the fix this rule asks for, and a
148
+ # version check such as `sys.version_info >= (3, 11)` names its own numbers
149
+ "skip_if": re.compile(r"^\s*_*[A-Z][A-Z0-9_]*\s*(:[^=]*)?=|(?i:(?:\b|_)version)"),
150
+ "exempt": explained_number,
151
+ "severity": "LOW",
152
+ "category": "style",
153
+ "title": "Possible magic number",
154
+ "description": "A numeric literal was added without an obvious "
155
+ "named constant. If this number has meaning, "
156
+ "consider naming it.",
157
+ },
158
+ {
159
+ "id": "missing-return-type",
160
+ "regex": re.compile(r"^\s*(async\s+)?def\s+\w+\s*\([^)]*\)\s*:"),
161
+ "extensions": PYTHON,
162
+ "scope": "code",
163
+ # test functions rarely annotate a return type; flagging each one buried real findings
164
+ "skip_tests": True,
165
+ "severity": "LOW",
166
+ "category": "style",
167
+ "title": "Function missing return type hint",
168
+ "description": "This function definition has no '->' return type "
169
+ "annotation. Note: this pattern alone can't check "
170
+ "parameter hints, and signatures that wrap over "
171
+ "several lines are not checked.",
172
+ },
173
+ ]
174
+
175
+ # McCabe complexity = 1 + the number of decision points in a function
176
+ FUNCTION_NODES = {
177
+ "python": {"function_definition"},
178
+ "javascript": {
179
+ "function_declaration", "function_expression", "arrow_function",
180
+ "method_definition", "generator_function_declaration", "generator_function",
181
+ },
182
+ }
183
+ DECISION_NODES = {
184
+ "python": {
185
+ "if_statement", "elif_clause", "for_statement", "while_statement", "except_clause",
186
+ "case_clause", "conditional_expression", "boolean_operator", "for_in_clause", "if_clause",
187
+ },
188
+ "javascript": {
189
+ "if_statement", "for_statement", "for_in_statement", "while_statement", "do_statement",
190
+ "switch_case", "catch_clause", "ternary_expression",
191
+ },
192
+ }
193
+ for _name in ("typescript", "tsx"):
194
+ FUNCTION_NODES[_name] = FUNCTION_NODES["javascript"]
195
+ DECISION_NODES[_name] = DECISION_NODES["javascript"]
196
+ # in JS/TS, && || ?? are binary_expression nodes, told apart by their operator
197
+ LOGICAL_OPERATORS = {"&&", "||", "??"}
198
+
199
+ COMPLEXITY_MEDIUM = 15
200
+ COMPLEXITY_HIGH = 30
201
+ MAX_FILE_LINES = 500
202
+ MAX_LINE_LENGTH = 120
203
+
204
+ # the checks that compare against a threshold rather than match a pattern
205
+ LONG_LINE_RULE = "long-line"
206
+ COMPLEXITY_RULE = "complexity"
207
+ FILE_LENGTH_RULE = "file-length"
208
+ RULE_IDS = tuple(p["id"] for p in PATTERNS) + (LONG_LINE_RULE, COMPLEXITY_RULE, FILE_LENGTH_RULE)
209
+
210
+
211
+ @dataclass(frozen=True)
212
+ class Thresholds:
213
+ """Limits the static checks compare against; .pr-analyzer.toml can change each one."""
214
+ complexity_medium: int = COMPLEXITY_MEDIUM # a function above this is MEDIUM...
215
+ complexity_high: int = COMPLEXITY_HIGH # ...and above this, HIGH
216
+ max_file_lines: int = MAX_FILE_LINES
217
+ max_line_length: int = MAX_LINE_LENGTH
218
+
219
+
220
+ def file_extension(filename: str) -> str:
221
+ return os.path.splitext(filename)[1].lower()
222
+
223
+
224
+ def is_test_file(filename: str) -> bool:
225
+ parts = filename.replace("\\", "/").lower().split("/")
226
+ name = parts[-1]
227
+ return (
228
+ any(part in ("test", "tests", "__tests__", "spec") for part in parts[:-1])
229
+ or name.startswith("test_")
230
+ or re.search(r"(_test\.py|\.(test|spec)\.[cm]?[jt]sx?)$", name) is not None
231
+ )
232
+
233
+
234
+ def language_name(filename: str) -> str | None:
235
+ return LANGUAGE_MAP.get(file_extension(filename))
236
+
237
+
238
+ @lru_cache(maxsize=None)
239
+ def _load_language(name: str) -> Language:
240
+ if name == "python":
241
+ return Language(tspython.language())
242
+ if name == "javascript":
243
+ return Language(tsjavascript.language())
244
+ if name == "typescript":
245
+ return Language(tstypescript.language_typescript())
246
+ if name == "tsx":
247
+ return Language(tstypescript.language_tsx())
248
+ raise ValueError(f"no tree-sitter grammar for {name!r}")
249
+
250
+
251
+ def get_language(filename: str) -> Language | None:
252
+ name = language_name(filename)
253
+ return _load_language(name) if name else None
254
+
255
+
256
+ def count_complexity(node: Node, language: str = "python") -> int:
257
+ """Count decision points under `node`. Nested functions are skipped: they are measured
258
+ on their own. Walks iteratively, so deeply nested code can't hit the recursion limit."""
259
+ decisions = DECISION_NODES[language]
260
+ functions = FUNCTION_NODES[language]
261
+ count = 0
262
+ stack = list(node.children)
263
+ while stack:
264
+ current = stack.pop()
265
+ if current.type in functions:
266
+ continue
267
+ if current.type in decisions:
268
+ count += 1
269
+ elif current.type == "binary_expression":
270
+ operator = current.child_by_field_name("operator")
271
+ if operator is not None and operator.type in LOGICAL_OPERATORS:
272
+ count += 1
273
+ stack.extend(current.children)
274
+ return count
275
+
276
+
277
+ def iter_functions(root: Node, language: str) -> Iterator[Node]:
278
+ functions = FUNCTION_NODES[language]
279
+ stack = [root]
280
+ while stack:
281
+ node = stack.pop()
282
+ if node.type in functions:
283
+ yield node
284
+ stack.extend(node.children)
285
+
286
+
287
+ def function_name(node: Node) -> str:
288
+ name = node.child_by_field_name("name")
289
+ if name is None and node.parent is not None and node.parent.type == "variable_declarator":
290
+ name = node.parent.child_by_field_name("name") # const handler = () => {...}
291
+ if name is None or name.text is None:
292
+ return "<anonymous>"
293
+ return name.text.decode("utf-8", errors="replace")
294
+
295
+
296
+ def patterns_for(filename: str, disabled: Collection[str] = ()) -> list[dict[str, Any]]:
297
+ """The line rules that apply to this file and aren't switched off."""
298
+ extension = file_extension(filename)
299
+ test_file = is_test_file(filename)
300
+ return [
301
+ p for p in PATTERNS
302
+ if p["id"] not in disabled
303
+ and extension in p["extensions"]
304
+ and not (test_file and p.get("skip_tests"))
305
+ ]
306
+
307
+
308
+ def line_matches(pattern: dict[str, Any], parts: LineParts) -> bool:
309
+ views = (parts.code, parts.comments) if pattern["scope"] == "code+comments" else (parts.code,)
310
+ return any(view_matches(pattern, view) for view in views)
311
+
312
+
313
+ def view_matches(pattern: dict[str, Any], text: str) -> bool:
314
+ skip_if = pattern.get("skip_if")
315
+ if skip_if is not None and skip_if.search(text):
316
+ return False
317
+ exempt = pattern.get("exempt")
318
+ return any(exempt is None or not exempt(text, m) for m in pattern["regex"].finditer(text))
319
+
320
+
321
+ def pattern_finding(file: FileChange, pattern: dict[str, Any], line_no: int) -> Suggestion:
322
+ return Suggestion(
323
+ filename=file.filename,
324
+ severity=pattern["severity"],
325
+ category=pattern["category"],
326
+ title=pattern["title"],
327
+ description=pattern["description"],
328
+ line=line_no,
329
+ source="static",
330
+ rule_id=pattern["id"],
331
+ )
332
+
333
+
334
+ def long_line_finding(file: FileChange, line_no: int, limit: int) -> Suggestion:
335
+ return Suggestion(
336
+ filename=file.filename,
337
+ severity="LOW",
338
+ category="style",
339
+ title=f"Line exceeds {limit} characters",
340
+ description="This added line is unusually long, which can hurt readability. "
341
+ "Consider breaking it up.",
342
+ line=line_no,
343
+ source="static",
344
+ rule_id=LONG_LINE_RULE,
345
+ )
346
+
347
+
348
+ class StaticAnalyzer:
349
+
350
+ def analyze(
351
+ self,
352
+ files: list[FileChange],
353
+ disabled: Collection[str] = (),
354
+ thresholds: Thresholds = Thresholds(),
355
+ ) -> list[Suggestion]:
356
+ """Run every static check on the files. `disabled` holds rule ids (see RULE_IDS) to skip."""
357
+ suggestions = []
358
+ for file in files:
359
+ if file.patch is None or file.status == "removed":
360
+ continue
361
+ suggestions.extend(self.scan_patterns(file, disabled, thresholds.max_line_length))
362
+ suggestions.extend(self.scan_tree_sitter(file, disabled, thresholds))
363
+
364
+ log.info("static analysis complete", total_suggestions=len(suggestions))
365
+ return suggestions
366
+
367
+ def scan_patterns(
368
+ self, file: FileChange, disabled: Collection[str] = (), max_line_length: int = MAX_LINE_LENGTH
369
+ ) -> list[Suggestion]:
370
+ if file.patch is None:
371
+ return []
372
+ extension = file_extension(file.filename)
373
+ patterns = patterns_for(file.filename, disabled)
374
+ check_length = LONG_LINE_RULE not in disabled and extension in CODE_EXTENSIONS
375
+
376
+ suggestions: list[Suggestion] = []
377
+ for line_no, text in iter_added_lines(file.patch):
378
+ parts = split_line(text, extension)
379
+ suggestions.extend(
380
+ pattern_finding(file, p, line_no) for p in patterns if line_matches(p, parts)
381
+ )
382
+ if check_length and len(text) > max_line_length:
383
+ suggestions.append(long_line_finding(file, line_no, max_line_length))
384
+ return suggestions
385
+
386
+ def scan_tree_sitter(
387
+ self, file: FileChange, disabled: Collection[str] = (), thresholds: Thresholds = Thresholds()
388
+ ) -> list[Suggestion]:
389
+ if file.raw_content is None:
390
+ return []
391
+ language = language_name(file.filename)
392
+ if language is None:
393
+ return []
394
+
395
+ suggestions = []
396
+ if COMPLEXITY_RULE not in disabled:
397
+ try:
398
+ tree = Parser(_load_language(language)).parse(file.raw_content.encode("utf-8"))
399
+ suggestions.extend(
400
+ self._complex_functions(file, tree.root_node, language, thresholds)
401
+ )
402
+ except Exception as e: # a parser problem must not stop the rest of the analysis
403
+ log.warning("tree_sitter_failed", filename=file.filename, error=str(e))
404
+
405
+ if FILE_LENGTH_RULE not in disabled:
406
+ suggestions.extend(self._file_length(file, file.raw_content, thresholds.max_file_lines))
407
+ return suggestions
408
+
409
+ @staticmethod
410
+ def _file_length(file: FileChange, content: str, limit: int) -> list[Suggestion]:
411
+ # Only when this PR is what takes the file past the limit: a file that was already long
412
+ # used to be reported on every PR that touched it, whatever the change.
413
+ lines = len(content.splitlines())
414
+ added, removed = count_changes(file.patch or "")
415
+ before = lines - added + removed
416
+ if lines <= limit or before > limit:
417
+ return []
418
+ if before <= 0:
419
+ grew = f"This new file has {lines} lines"
420
+ else:
421
+ grew = f"This change takes the file from {before} to {lines} lines"
422
+ return [Suggestion(
423
+ filename=file.filename,
424
+ severity="LOW",
425
+ category="complexity",
426
+ title="File too long",
427
+ description=f"{grew}, past the limit of {limit}. Consider splitting it into smaller "
428
+ f"modules.",
429
+ line=None,
430
+ source="static",
431
+ rule_id=FILE_LENGTH_RULE,
432
+ )]
433
+
434
+ def _complex_functions(
435
+ self, file: FileChange, root: Node, language: str, thresholds: Thresholds
436
+ ) -> list[Suggestion]:
437
+ # only report functions this PR touches; untouched legacy code isn't the PR's fault
438
+ added = sorted(line_no for line_no, _ in iter_added_lines(file.patch or ""))
439
+ high, medium = thresholds.complexity_high, thresholds.complexity_medium
440
+ suggestions = []
441
+ for function in iter_functions(root, language):
442
+ start, end = function.start_point[0] + 1, function.end_point[0] + 1
443
+ i = bisect_left(added, start)
444
+ if i == len(added) or added[i] > end:
445
+ continue
446
+
447
+ complexity = 1 + count_complexity(function, language)
448
+ if complexity > high:
449
+ severity, title, limit = "HIGH", "Very high cyclomatic complexity", high
450
+ elif complexity > medium:
451
+ severity, title, limit = "MEDIUM", "High cyclomatic complexity", medium
452
+ else:
453
+ continue
454
+
455
+ suggestions.append(Suggestion(
456
+ filename=file.filename,
457
+ severity=severity,
458
+ category="complexity",
459
+ title=title,
460
+ description=f"`{function_name(function)}` has a cyclomatic complexity of "
461
+ f"{complexity} (above {limit}). Consider breaking it into "
462
+ f"smaller functions.",
463
+ line=start,
464
+ source="static",
465
+ rule_id=COMPLEXITY_RULE,
466
+ ))
467
+ return suggestions