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.
@@ -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