prism-code-review 2.0.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.
- prism/__init__.py +87 -0
- prism/__main__.py +13 -0
- prism/agents.py +191 -0
- prism/agents_policy.py +277 -0
- prism/agents_quality.py +584 -0
- prism/agents_security.py +243 -0
- prism/agents_test.py +145 -0
- prism/ai_architecture.py +65 -0
- prism/ai_common.py +32 -0
- prism/ai_findings.py +181 -0
- prism/ai_graph.py +224 -0
- prism/ai_performance.py +65 -0
- prism/ai_pipeline.py +447 -0
- prism/ai_policy.py +65 -0
- prism/ai_providers.py +514 -0
- prism/ai_quality.py +65 -0
- prism/ai_security.py +65 -0
- prism/ai_test.py +65 -0
- prism/autofix.py +360 -0
- prism/cli.py +248 -0
- prism/cli_commands.py +711 -0
- prism/cli_config.py +202 -0
- prism/cli_review.py +558 -0
- prism/codebase_cache.py +155 -0
- prism/commands_common.py +365 -0
- prism/commands_reports.py +212 -0
- prism/commands_setup.py +703 -0
- prism/feedback.py +254 -0
- prism/ignore.py +344 -0
- prism/langs.py +251 -0
- prism/logging_config.py +52 -0
- prism/lsp.py +194 -0
- prism/modes.py +94 -0
- prism/reports.py +277 -0
- prism/token_usage.py +93 -0
- prism_code_review-2.0.0.dist-info/METADATA +1392 -0
- prism_code_review-2.0.0.dist-info/RECORD +41 -0
- prism_code_review-2.0.0.dist-info/WHEEL +5 -0
- prism_code_review-2.0.0.dist-info/entry_points.txt +2 -0
- prism_code_review-2.0.0.dist-info/licenses/LICENSE +21 -0
- prism_code_review-2.0.0.dist-info/top_level.txt +1 -0
prism/agents_quality.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Code Quality Agent - Checks complexity, cleanliness, and style.
|
|
3
|
+
|
|
4
|
+
Rule IDs follow SonarQube convention: QUAL-XXX
|
|
5
|
+
|
|
6
|
+
Thresholds are intentionally lenient. The regex pass runs on every commit, so
|
|
7
|
+
a strict rule set turns the pre-commit hook into a wall of warnings that
|
|
8
|
+
developers learn to ignore. Anything that is a style preference is LOW with
|
|
9
|
+
WARNS (advisory), and only a genuine complexity problem is HIGH.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import ast
|
|
13
|
+
import re
|
|
14
|
+
from typing import List
|
|
15
|
+
from prism.agents import BaseAgent, Finding, Severity, Blocker, register_agent
|
|
16
|
+
from prism.langs import lang_for, strip_source
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
MAX_FUNCTION_LENGTH = 80
|
|
20
|
+
MAX_FILE_LENGTH = 800
|
|
21
|
+
COMPLEXITY_THRESHOLD = 15
|
|
22
|
+
|
|
23
|
+
# Advisory checks: reported at most once per file (first occurrence) so a
|
|
24
|
+
# long file cannot produce 200 identical warnings.
|
|
25
|
+
MAX_PER_RULE = 3
|
|
26
|
+
|
|
27
|
+
# Which checks run. Structural limits (file/function length, complexity) and the
|
|
28
|
+
# cheap, high-signal style checks (naming, imports, TODO markers, line length)
|
|
29
|
+
# stay on; the purely subjective ones (docstrings, type hints, magic numbers,
|
|
30
|
+
# duplication) are off by default and opt-in via prism.json, e.g.
|
|
31
|
+
# {"quality": {"docstrings": true}}. Those produced a wall of warnings
|
|
32
|
+
# developers learned to ignore. The remaining opt-in checks stay capped by
|
|
33
|
+
# MAX_PER_RULE, so a long file cannot turn one rule into 200 warnings.
|
|
34
|
+
QUALITY_DEFAULTS = {
|
|
35
|
+
"file_length": True,
|
|
36
|
+
"function_length": True,
|
|
37
|
+
"complexity": True,
|
|
38
|
+
"naming": True,
|
|
39
|
+
"imports": True,
|
|
40
|
+
"duplication": False,
|
|
41
|
+
"todo_markers": True,
|
|
42
|
+
"line_length": True,
|
|
43
|
+
"magic_numbers": False,
|
|
44
|
+
"docstrings": False,
|
|
45
|
+
"type_hints": False,
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _count_lines_in_function(code_lines: list, start: int, spec=None) -> int:
|
|
50
|
+
"""Lines the function declared at ``start`` occupies.
|
|
51
|
+
|
|
52
|
+
The caller has already blanked comments and string literals across the whole
|
|
53
|
+
file, so the braces counted here are structure. A brace-delimited body (Go,
|
|
54
|
+
Rust, the C family) ends when the braces opened after the declaration close
|
|
55
|
+
again; an indented body (Python, Ruby) ends at the first line that is not
|
|
56
|
+
indented deeper than its declaration.
|
|
57
|
+
"""
|
|
58
|
+
if spec is not None and spec.body_style == "brace":
|
|
59
|
+
depth = 0
|
|
60
|
+
opened = False
|
|
61
|
+
for i in range(start, len(code_lines)):
|
|
62
|
+
for ch in code_lines[i]:
|
|
63
|
+
if ch == "{":
|
|
64
|
+
depth += 1
|
|
65
|
+
opened = True
|
|
66
|
+
elif ch == "}":
|
|
67
|
+
depth -= 1
|
|
68
|
+
if opened and depth <= 0:
|
|
69
|
+
return i - start + 1
|
|
70
|
+
return len(code_lines) - start
|
|
71
|
+
|
|
72
|
+
count = 1
|
|
73
|
+
indent = len(code_lines[start]) - len(code_lines[start].lstrip())
|
|
74
|
+
for i in range(start + 1, len(code_lines)):
|
|
75
|
+
line = code_lines[i]
|
|
76
|
+
if not line.strip():
|
|
77
|
+
continue
|
|
78
|
+
if len(line) - len(line.lstrip()) <= indent:
|
|
79
|
+
break
|
|
80
|
+
count += 1
|
|
81
|
+
return count
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cyclomatic_complexity(code_lines: list, spec=None) -> int:
|
|
85
|
+
"""Approximate cyclomatic complexity of already-stripped lines.
|
|
86
|
+
|
|
87
|
+
Counts decision points rather than every `and`/`or` occurrence: the old
|
|
88
|
+
version counted both plus every `if` in comments and strings, which
|
|
89
|
+
inflated simple functions past the threshold. The keywords are the ones the
|
|
90
|
+
language actually branches on, so a Go `select` counts and a Python `elif`
|
|
91
|
+
counts, and neither is charged to the other.
|
|
92
|
+
"""
|
|
93
|
+
words = spec.decision_words if spec else (
|
|
94
|
+
"if", "elif", "for", "while", "except", "with", "case")
|
|
95
|
+
ops = spec.logical_ops if spec else ("and", "or")
|
|
96
|
+
decision = re.compile(r"^\s*(?:" + "|".join(words) + r")\b")
|
|
97
|
+
logic = re.compile("|".join(
|
|
98
|
+
r"\b{}\b".format(op) if op.isalpha() else re.escape(op) for op in ops))
|
|
99
|
+
|
|
100
|
+
complexity = 1
|
|
101
|
+
for line in code_lines:
|
|
102
|
+
code = line.strip()
|
|
103
|
+
if not code:
|
|
104
|
+
continue
|
|
105
|
+
if decision.search(code):
|
|
106
|
+
complexity += 1
|
|
107
|
+
elif logic.search(code):
|
|
108
|
+
complexity += 1
|
|
109
|
+
return complexity
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
@register_agent
|
|
113
|
+
class QualityAgent(BaseAgent):
|
|
114
|
+
name = "quality"
|
|
115
|
+
description = "Checks code complexity, cleanliness, style, duplication, naming, imports"
|
|
116
|
+
|
|
117
|
+
def __init__(self):
|
|
118
|
+
self._enabled = dict(QUALITY_DEFAULTS)
|
|
119
|
+
|
|
120
|
+
def _on(self, name):
|
|
121
|
+
"""True when the ``name`` check is enabled (default dict is lenient)."""
|
|
122
|
+
return bool(self._enabled.get(name, QUALITY_DEFAULTS.get(name, False)))
|
|
123
|
+
|
|
124
|
+
def _check(self, file_path: str, content: str, diff: str,
|
|
125
|
+
skeleton: str = "", line_count: int = None,
|
|
126
|
+
fast: bool = False) -> List[Finding]:
|
|
127
|
+
findings: List[Finding] = []
|
|
128
|
+
lines = content.split("\n")
|
|
129
|
+
|
|
130
|
+
if self._on("file_length") and len(lines) > MAX_FILE_LENGTH:
|
|
131
|
+
findings.append(Finding(
|
|
132
|
+
agent=self.name,
|
|
133
|
+
severity=Severity.LOW,
|
|
134
|
+
category="File Length",
|
|
135
|
+
message="File has {} lines (max {})".format(len(lines), MAX_FILE_LENGTH),
|
|
136
|
+
file=file_path,
|
|
137
|
+
suggestion="Consider splitting into smaller modules.",
|
|
138
|
+
blocker=Blocker.WARNS,
|
|
139
|
+
confidence=1.0,
|
|
140
|
+
rule_id="QUAL-101",
|
|
141
|
+
))
|
|
142
|
+
|
|
143
|
+
spec = lang_for(file_path)
|
|
144
|
+
# Blank comments and literals once for the whole file: a brace inside a
|
|
145
|
+
# block comment is not structure, and the per-line stripper cannot see
|
|
146
|
+
# that the comment opened on an earlier line.
|
|
147
|
+
code_lines = strip_source(lines, spec) if spec is not None else lines
|
|
148
|
+
|
|
149
|
+
func_starts = []
|
|
150
|
+
if spec is not None and spec.func_decl:
|
|
151
|
+
for i, code in enumerate(code_lines):
|
|
152
|
+
if spec.func_decl.match(code):
|
|
153
|
+
func_starts.append(i)
|
|
154
|
+
|
|
155
|
+
for start in func_starts:
|
|
156
|
+
func_lines = _count_lines_in_function(code_lines, start, spec)
|
|
157
|
+
complexity = _cyclomatic_complexity(
|
|
158
|
+
code_lines[start:start + func_lines], spec)
|
|
159
|
+
|
|
160
|
+
if self._on("function_length") and func_lines > MAX_FUNCTION_LENGTH:
|
|
161
|
+
findings.append(Finding(
|
|
162
|
+
agent=self.name,
|
|
163
|
+
severity=Severity.LOW,
|
|
164
|
+
category="Function Length",
|
|
165
|
+
message="Function at line {} has {} lines (max {})".format(
|
|
166
|
+
start + 1, func_lines, MAX_FUNCTION_LENGTH),
|
|
167
|
+
file=file_path,
|
|
168
|
+
line=start + 1,
|
|
169
|
+
suggestion="Consider breaking into smaller helpers.",
|
|
170
|
+
blocker=Blocker.WARNS,
|
|
171
|
+
confidence=1.0,
|
|
172
|
+
rule_id="QUAL-102",
|
|
173
|
+
))
|
|
174
|
+
|
|
175
|
+
if self._on("complexity") and complexity > COMPLEXITY_THRESHOLD:
|
|
176
|
+
findings.append(Finding(
|
|
177
|
+
agent=self.name,
|
|
178
|
+
severity=Severity.MEDIUM,
|
|
179
|
+
category="Complexity",
|
|
180
|
+
message="Function at line {} has complexity {} (max {})".format(
|
|
181
|
+
start + 1, complexity, COMPLEXITY_THRESHOLD),
|
|
182
|
+
file=file_path,
|
|
183
|
+
line=start + 1,
|
|
184
|
+
suggestion="Reduce branching. Use early returns, lookup tables, or strategy pattern.",
|
|
185
|
+
blocker=Blocker.WARNS,
|
|
186
|
+
confidence=0.90,
|
|
187
|
+
rule_id="QUAL-103",
|
|
188
|
+
))
|
|
189
|
+
|
|
190
|
+
if self._on("todo_markers"):
|
|
191
|
+
findings.extend(_cap(_check_incomplete_markers(lines, file_path),
|
|
192
|
+
"QUAL-104"))
|
|
193
|
+
if self._on("line_length"):
|
|
194
|
+
findings.extend(_cap(_check_line_length(lines, file_path), "QUAL-105"))
|
|
195
|
+
|
|
196
|
+
# Advisory checks, each capped per file (see _cap).
|
|
197
|
+
if self._on("duplication"):
|
|
198
|
+
findings.extend(_cap(_check_duplication(content, file_path), "QUAL-106", 3))
|
|
199
|
+
if self._on("naming"):
|
|
200
|
+
findings.extend(_cap(_check_naming(content, file_path), "QUAL-107"))
|
|
201
|
+
if self._on("imports"):
|
|
202
|
+
findings.extend(_cap(_check_imports(content, file_path), "QUAL-108"))
|
|
203
|
+
if self._on("magic_numbers"):
|
|
204
|
+
findings.extend(_cap(_check_magic_numbers(content, file_path), "QUAL-109"))
|
|
205
|
+
if self._on("docstrings"):
|
|
206
|
+
findings.extend(_cap(_check_missing_docstrings(content, file_path), "QUAL-110"))
|
|
207
|
+
if self._on("type_hints"):
|
|
208
|
+
findings.extend(_cap(_check_missing_type_hints(content, file_path), "QUAL-111"))
|
|
209
|
+
|
|
210
|
+
return findings
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _cap(findings, rule_id, limit=MAX_PER_RULE):
|
|
214
|
+
"""Keep at most ``limit`` findings per rule id.
|
|
215
|
+
|
|
216
|
+
A 900-line file used to emit one warning per TODO, per long line and per
|
|
217
|
+
missing annotation. Three examples are enough to make the point; the rest
|
|
218
|
+
is noise the developer scrolls past.
|
|
219
|
+
"""
|
|
220
|
+
kept = []
|
|
221
|
+
seen = 0
|
|
222
|
+
for f in findings:
|
|
223
|
+
if f.rule_id == rule_id:
|
|
224
|
+
if seen >= limit:
|
|
225
|
+
continue
|
|
226
|
+
seen += 1
|
|
227
|
+
kept.append(f)
|
|
228
|
+
return kept
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _check_incomplete_markers(lines, file_path):
|
|
232
|
+
"""Report TODO/FIXME/HACK/XXX markers (capped per file)."""
|
|
233
|
+
findings = []
|
|
234
|
+
for i, line in enumerate(lines):
|
|
235
|
+
if len(findings) >= MAX_PER_RULE:
|
|
236
|
+
break
|
|
237
|
+
match = re.search(r'\b(TODO|FIXME|HACK|XXX|BUG)\b', line)
|
|
238
|
+
if match:
|
|
239
|
+
findings.append(Finding(
|
|
240
|
+
agent="quality",
|
|
241
|
+
severity=Severity.LOW,
|
|
242
|
+
category="Incomplete Code",
|
|
243
|
+
message="Found '{}' marker".format(match.group(1)),
|
|
244
|
+
file=file_path,
|
|
245
|
+
line=i + 1,
|
|
246
|
+
suggestion="Resolve before committing.",
|
|
247
|
+
blocker=Blocker.WARNS,
|
|
248
|
+
confidence=1.0,
|
|
249
|
+
rule_id="QUAL-104",
|
|
250
|
+
))
|
|
251
|
+
return findings
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _check_line_length(lines, file_path):
|
|
255
|
+
"""Report lines over 120 characters (capped per file)."""
|
|
256
|
+
findings = []
|
|
257
|
+
for i, line in enumerate(lines):
|
|
258
|
+
if len(findings) >= MAX_PER_RULE:
|
|
259
|
+
break
|
|
260
|
+
if len(line) > 120:
|
|
261
|
+
findings.append(Finding(
|
|
262
|
+
agent="quality",
|
|
263
|
+
severity=Severity.LOW,
|
|
264
|
+
category="Line Length",
|
|
265
|
+
message="Line {} has {} characters (max 120)".format(i + 1, len(line)),
|
|
266
|
+
file=file_path,
|
|
267
|
+
line=i + 1,
|
|
268
|
+
suggestion="Break long lines for readability.",
|
|
269
|
+
blocker=Blocker.WARNS,
|
|
270
|
+
confidence=1.0,
|
|
271
|
+
rule_id="QUAL-105",
|
|
272
|
+
))
|
|
273
|
+
return findings
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _check_magic_numbers(content: str, file_path: str) -> List[Finding]:
|
|
277
|
+
"""Flag hardcoded numeric constants used in non-trivial contexts.
|
|
278
|
+
|
|
279
|
+
Only flags numbers that appear in comparisons, arithmetic, indexing,
|
|
280
|
+
or function-call arguments — not bare loop counters like `i = 0`
|
|
281
|
+
or `x = 1`. This avoids the false-positive storm that pure literal
|
|
282
|
+
scanning produces (e.g. one 9000-line file producing 8000+ findings).
|
|
283
|
+
"""
|
|
284
|
+
if not file_path.endswith(".py"):
|
|
285
|
+
return []
|
|
286
|
+
findings = []
|
|
287
|
+
lines = content.split("\n")
|
|
288
|
+
allowed = {"0", "1", "2", "3", "-1", "0.0", "1.0", "100", "1000",
|
|
289
|
+
"1024", "3600", "60", "300", "8080", "3000", "5000",
|
|
290
|
+
"0.5", "0.25", "0.8", "0.95", "255", "128", "64", "32",
|
|
291
|
+
"24", "48", "72", "96", "100", "200", "400", "500", "1000",
|
|
292
|
+
"0.1", "0.2", "0.3", "0.4", "0.6", "0.7", "0.9"}
|
|
293
|
+
# Match a 2+ digit literal that is NOT the sole RHS of a `name =` assignment.
|
|
294
|
+
# The negative lookahead rejects `foo = 123` and `i += 1` counters.
|
|
295
|
+
pattern = re.compile(
|
|
296
|
+
r'(?<![\w.])(\d{2,})(?![\w.])'
|
|
297
|
+
)
|
|
298
|
+
for i, line in enumerate(lines):
|
|
299
|
+
stripped = line.strip()
|
|
300
|
+
if stripped.startswith("#"):
|
|
301
|
+
continue
|
|
302
|
+
if stripped.startswith(("import ", "from ")):
|
|
303
|
+
continue
|
|
304
|
+
# Skip bare assignment lines where the number is the only RHS value
|
|
305
|
+
# (loop counters, list seeding). Still check lines with operators.
|
|
306
|
+
if "=" in line and not any(op in line for op in ("==", "!=", "<=", ">=", "+", "-", "*", "/", "%", "[", "(", ",")):
|
|
307
|
+
continue
|
|
308
|
+
for match in pattern.finditer(line):
|
|
309
|
+
num = match.group(1)
|
|
310
|
+
if num in allowed:
|
|
311
|
+
continue
|
|
312
|
+
findings.append(Finding(
|
|
313
|
+
agent="quality",
|
|
314
|
+
severity=Severity.LOW,
|
|
315
|
+
category="Magic Number",
|
|
316
|
+
message="Magic number '{}' on line {}".format(num, i + 1),
|
|
317
|
+
file=file_path,
|
|
318
|
+
line=i + 1,
|
|
319
|
+
suggestion="Consider a named constant if this value carries meaning.",
|
|
320
|
+
blocker=Blocker.WARNS,
|
|
321
|
+
confidence=0.40,
|
|
322
|
+
rule_id="QUAL-109",
|
|
323
|
+
))
|
|
324
|
+
return findings
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _check_missing_docstrings(content: str, file_path: str) -> List[Finding]:
|
|
328
|
+
"""Flag public functions/classes without docstrings."""
|
|
329
|
+
if not file_path.endswith(".py"):
|
|
330
|
+
return []
|
|
331
|
+
findings = []
|
|
332
|
+
try:
|
|
333
|
+
tree = ast.parse(content)
|
|
334
|
+
except (SyntaxError, ValueError):
|
|
335
|
+
return findings
|
|
336
|
+
|
|
337
|
+
for node in ast.iter_child_nodes(tree):
|
|
338
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
|
|
339
|
+
if node.name.startswith("_"):
|
|
340
|
+
continue
|
|
341
|
+
body = node.body
|
|
342
|
+
has_docstring = (
|
|
343
|
+
len(body) > 0 and
|
|
344
|
+
isinstance(body[0], ast.Expr) and
|
|
345
|
+
isinstance(body[0].value, ast.Constant)
|
|
346
|
+
and isinstance(body[0].value.value, str)
|
|
347
|
+
)
|
|
348
|
+
if not has_docstring:
|
|
349
|
+
findings.append(Finding(
|
|
350
|
+
agent="quality",
|
|
351
|
+
severity=Severity.LOW,
|
|
352
|
+
category="Missing Docstring",
|
|
353
|
+
message="{} '{}' has no docstring".format(
|
|
354
|
+
"Class" if isinstance(node, ast.ClassDef) else "Function",
|
|
355
|
+
node.name),
|
|
356
|
+
file=file_path,
|
|
357
|
+
line=node.lineno,
|
|
358
|
+
suggestion="Add a docstring describing purpose, params, and return.",
|
|
359
|
+
blocker=Blocker.WARNS,
|
|
360
|
+
confidence=0.85,
|
|
361
|
+
rule_id="QUAL-110",
|
|
362
|
+
))
|
|
363
|
+
return findings
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _check_missing_type_hints(content: str, file_path: str) -> List[Finding]:
|
|
367
|
+
"""Flag public functions without return type annotations."""
|
|
368
|
+
if not file_path.endswith(".py"):
|
|
369
|
+
return []
|
|
370
|
+
findings = []
|
|
371
|
+
try:
|
|
372
|
+
tree = ast.parse(content)
|
|
373
|
+
except (SyntaxError, ValueError):
|
|
374
|
+
return findings
|
|
375
|
+
|
|
376
|
+
for node in ast.iter_child_nodes(tree):
|
|
377
|
+
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
378
|
+
if node.name.startswith("_"):
|
|
379
|
+
continue
|
|
380
|
+
if node.returns is None:
|
|
381
|
+
findings.append(Finding(
|
|
382
|
+
agent="quality",
|
|
383
|
+
severity=Severity.LOW,
|
|
384
|
+
category="Missing Type Hint",
|
|
385
|
+
message="Function '{}' has no return type annotation".format(node.name),
|
|
386
|
+
file=file_path,
|
|
387
|
+
line=node.lineno,
|
|
388
|
+
suggestion="Add a return type annotation.",
|
|
389
|
+
blocker=Blocker.WARNS,
|
|
390
|
+
confidence=0.75,
|
|
391
|
+
rule_id="QUAL-111",
|
|
392
|
+
))
|
|
393
|
+
return findings
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _check_duplication(content: str, file_path: str) -> List[Finding]:
|
|
397
|
+
"""Detect code duplication (4+ lines repeated).
|
|
398
|
+
|
|
399
|
+
Capped at 20 findings per file to avoid the false-positive storm that
|
|
400
|
+
short repeated blocks produce (e.g. a CLI with many `print(" [x] ...")`
|
|
401
|
+
lines would otherwise generate 100+ findings).
|
|
402
|
+
"""
|
|
403
|
+
findings = []
|
|
404
|
+
lines = [l.strip() for l in content.split("\n") if l.strip()]
|
|
405
|
+
seen = {}
|
|
406
|
+
for i in range(len(lines)):
|
|
407
|
+
if len(findings) >= 20:
|
|
408
|
+
break
|
|
409
|
+
for length in range(4, min(8, len(lines) - i + 1)):
|
|
410
|
+
block = "\n".join(lines[i:i + length])
|
|
411
|
+
if block in seen:
|
|
412
|
+
findings.append(Finding(
|
|
413
|
+
agent="quality",
|
|
414
|
+
severity=Severity.MEDIUM,
|
|
415
|
+
category="Duplication",
|
|
416
|
+
message="Duplicate block ({} lines) at line {}".format(
|
|
417
|
+
length, seen[block] + 1),
|
|
418
|
+
file=file_path,
|
|
419
|
+
line=i + 1,
|
|
420
|
+
suggestion="Extract repeated code into a function.",
|
|
421
|
+
blocker=Blocker.WARNS,
|
|
422
|
+
confidence=0.85,
|
|
423
|
+
rule_id="QUAL-106",
|
|
424
|
+
))
|
|
425
|
+
break
|
|
426
|
+
else:
|
|
427
|
+
seen[block] = i
|
|
428
|
+
return findings
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def _check_naming(content: str, file_path: str) -> List[Finding]:
|
|
432
|
+
"""Check naming conventions.
|
|
433
|
+
|
|
434
|
+
The convention is a property of the language, so a language that declares
|
|
435
|
+
none gets no finding rather than being judged by someone else's rules.
|
|
436
|
+
"""
|
|
437
|
+
spec = lang_for(file_path)
|
|
438
|
+
if spec is None or spec.naming is None:
|
|
439
|
+
return []
|
|
440
|
+
findings = []
|
|
441
|
+
lines = content.split("\n")
|
|
442
|
+
|
|
443
|
+
for i, line in enumerate(lines):
|
|
444
|
+
if spec.naming == "snake":
|
|
445
|
+
# Check for CamelCase variables (not class/function names)
|
|
446
|
+
# Valid: snake_case_with_underscores or single_word
|
|
447
|
+
# Invalid: camelCase or PascalCase variables
|
|
448
|
+
match = re.match(r'^(\s*)([a-zA-Z_]\w*)\s*=\s*', line)
|
|
449
|
+
if match:
|
|
450
|
+
var_name = match.group(2)
|
|
451
|
+
# Skip dunder and single underscore prefixes
|
|
452
|
+
if var_name.startswith("__") or var_name.startswith("_"):
|
|
453
|
+
continue
|
|
454
|
+
# Check for camelCase (lowercase start, then uppercase inside)
|
|
455
|
+
if re.match(r'^[a-z]+[A-Z]', var_name):
|
|
456
|
+
findings.append(Finding(
|
|
457
|
+
agent="quality",
|
|
458
|
+
severity=Severity.LOW,
|
|
459
|
+
category="Naming Convention",
|
|
460
|
+
message="Variable '{}' should be snake_case, not camelCase".format(var_name),
|
|
461
|
+
file=file_path,
|
|
462
|
+
line=i + 1,
|
|
463
|
+
suggestion="Use snake_case for variable names.",
|
|
464
|
+
blocker=Blocker.WARNS,
|
|
465
|
+
confidence=0.80,
|
|
466
|
+
rule_id="QUAL-107",
|
|
467
|
+
))
|
|
468
|
+
elif spec.naming == "camel":
|
|
469
|
+
match = re.match(r'^(\s*)(var|let|const)\s+(\w+)', line)
|
|
470
|
+
if match:
|
|
471
|
+
var_name = match.group(3)
|
|
472
|
+
if "_" in var_name and not var_name.startswith("_"):
|
|
473
|
+
findings.append(Finding(
|
|
474
|
+
agent="quality",
|
|
475
|
+
severity=Severity.LOW,
|
|
476
|
+
category="Naming Convention",
|
|
477
|
+
message="Variable '{}' should be camelCase, not snake_case".format(var_name),
|
|
478
|
+
file=file_path,
|
|
479
|
+
line=i + 1,
|
|
480
|
+
suggestion="Use camelCase for variable names.",
|
|
481
|
+
blocker=Blocker.WARNS,
|
|
482
|
+
confidence=0.80,
|
|
483
|
+
rule_id="QUAL-107",
|
|
484
|
+
))
|
|
485
|
+
|
|
486
|
+
return findings
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def _check_imports(content: str, file_path: str) -> List[Finding]:
|
|
490
|
+
"""Check import ordering and unused imports."""
|
|
491
|
+
findings = []
|
|
492
|
+
lines = content.split("\n")
|
|
493
|
+
|
|
494
|
+
# Strip comments and strings to avoid false positives
|
|
495
|
+
code_lines = []
|
|
496
|
+
for line in lines:
|
|
497
|
+
# Remove inline comments
|
|
498
|
+
in_string = False
|
|
499
|
+
string_char = None
|
|
500
|
+
cleaned = []
|
|
501
|
+
i = 0
|
|
502
|
+
while i < len(line):
|
|
503
|
+
ch = line[i]
|
|
504
|
+
if not in_string and ch in ('"', "'"):
|
|
505
|
+
in_string = True
|
|
506
|
+
string_char = ch
|
|
507
|
+
cleaned.append(" ")
|
|
508
|
+
elif in_string and ch == string_char:
|
|
509
|
+
in_string = False
|
|
510
|
+
cleaned.append(" ")
|
|
511
|
+
elif in_string:
|
|
512
|
+
cleaned.append(" ")
|
|
513
|
+
elif ch == "#":
|
|
514
|
+
break # rest of line is comment
|
|
515
|
+
else:
|
|
516
|
+
cleaned.append(ch)
|
|
517
|
+
i += 1
|
|
518
|
+
code_lines.append("".join(cleaned))
|
|
519
|
+
|
|
520
|
+
code_content = "\n".join(code_lines)
|
|
521
|
+
|
|
522
|
+
imports = []
|
|
523
|
+
for i, line in enumerate(lines):
|
|
524
|
+
if re.match(r'^\s*(import |from )', line):
|
|
525
|
+
imports.append((i, line.strip()))
|
|
526
|
+
|
|
527
|
+
for i, imp_line in imports:
|
|
528
|
+
if file_path.endswith(".py"):
|
|
529
|
+
# Handle: import module
|
|
530
|
+
match = re.match(r'^import (\w+)', imp_line)
|
|
531
|
+
if match:
|
|
532
|
+
module = match.group(1)
|
|
533
|
+
usage_count = code_content.count(module)
|
|
534
|
+
if usage_count <= 1:
|
|
535
|
+
findings.append(Finding(
|
|
536
|
+
agent="quality",
|
|
537
|
+
severity=Severity.LOW,
|
|
538
|
+
category="Unused Import",
|
|
539
|
+
message="Import '{}' may be unused".format(module),
|
|
540
|
+
file=file_path,
|
|
541
|
+
line=i + 1,
|
|
542
|
+
suggestion="Remove unused import.",
|
|
543
|
+
blocker=Blocker.WARNS,
|
|
544
|
+
confidence=0.70,
|
|
545
|
+
rule_id="QUAL-108",
|
|
546
|
+
))
|
|
547
|
+
# Handle: from module import name
|
|
548
|
+
match = re.match(r'^from (\w+) import', imp_line)
|
|
549
|
+
if match:
|
|
550
|
+
module = match.group(1)
|
|
551
|
+
# Check if imported names are used, not just the module name
|
|
552
|
+
# Extract imported names: from module import name1, name2
|
|
553
|
+
names_match = re.search(r'import\s+(.+)$', imp_line)
|
|
554
|
+
if names_match:
|
|
555
|
+
names_str = names_match.group(1)
|
|
556
|
+
# Split by comma, handle "module as alias"
|
|
557
|
+
imported_names = []
|
|
558
|
+
for part in names_str.split(","):
|
|
559
|
+
part = part.strip()
|
|
560
|
+
# Handle "name as alias"
|
|
561
|
+
name = re.split(r'\s+as\s+', part)[0].strip()
|
|
562
|
+
if name:
|
|
563
|
+
imported_names.append(name)
|
|
564
|
+
# Check if any imported name is used in code
|
|
565
|
+
any_used = False
|
|
566
|
+
for name in imported_names:
|
|
567
|
+
if code_content.count(name) > 1:
|
|
568
|
+
any_used = True
|
|
569
|
+
break
|
|
570
|
+
if not any_used and imported_names:
|
|
571
|
+
findings.append(Finding(
|
|
572
|
+
agent="quality",
|
|
573
|
+
severity=Severity.LOW,
|
|
574
|
+
category="Unused Import",
|
|
575
|
+
message="Import from '{}' may be unused".format(module),
|
|
576
|
+
file=file_path,
|
|
577
|
+
line=i + 1,
|
|
578
|
+
suggestion="Remove unused import.",
|
|
579
|
+
blocker=Blocker.WARNS,
|
|
580
|
+
confidence=0.70,
|
|
581
|
+
rule_id="QUAL-108",
|
|
582
|
+
))
|
|
583
|
+
|
|
584
|
+
return findings
|