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/__init__.py
ADDED
|
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
|