claude-dev-env 1.24.0 → 1.25.1
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.
- package/CLAUDE.md +5 -18
- package/docs/CODE_RULES.md +14 -1
- package/hooks/blocking/_gh_body_arg_utils.py +171 -13
- package/hooks/blocking/code-rules-enforcer.py +490 -15
- package/hooks/blocking/gh-body-arg-blocker.py +27 -21
- package/hooks/blocking/pr-description-enforcer.py +247 -11
- package/hooks/blocking/tdd-enforcer.py +208 -13
- package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +116 -0
- package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +231 -0
- package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +51 -0
- package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +55 -0
- package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +144 -0
- package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +102 -0
- package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +76 -0
- package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +176 -0
- package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +112 -0
- package/hooks/blocking/test_gh_body_arg_blocker.py +229 -2
- package/hooks/blocking/test_pr_description_enforcer.py +193 -3
- package/hooks/blocking/test_tdd_enforcer.py +249 -0
- package/hooks/validators/exempt_paths.py +99 -0
- package/hooks/validators/magic_value_checks.py +126 -26
- package/hooks/validators/test_magic_value_checks.py +356 -2
- package/package.json +1 -1
- package/rules/gh-body-file.md +11 -2
- package/skills/searching-obsidian-vault/SKILL.md +131 -0
|
@@ -10,13 +10,17 @@ Checks (blocking):
|
|
|
10
10
|
5. Magic values (literals in function bodies)
|
|
11
11
|
6. E2E test naming (no online/offline in test names)
|
|
12
12
|
7. Constants outside config (UPPER_SNAKE = in non-config files)
|
|
13
|
+
8. Boolean naming (is_/has_/should_/can_ prefix required)
|
|
13
14
|
|
|
14
15
|
Advisory only (non-blocking):
|
|
15
16
|
- File line count: stderr warning at 400 lines (soft) and 1000 lines (hard)
|
|
16
17
|
"""
|
|
18
|
+
import ast
|
|
19
|
+
import io
|
|
17
20
|
import json
|
|
18
21
|
import re
|
|
19
22
|
import sys
|
|
23
|
+
import tokenize
|
|
20
24
|
from typing import Optional
|
|
21
25
|
|
|
22
26
|
PYTHON_EXTENSIONS = {".py"}
|
|
@@ -24,14 +28,24 @@ JAVASCRIPT_EXTENSIONS = {".js", ".ts", ".tsx", ".jsx"}
|
|
|
24
28
|
ALL_CODE_EXTENSIONS = PYTHON_EXTENSIONS | JAVASCRIPT_EXTENSIONS
|
|
25
29
|
|
|
26
30
|
CONFIG_PATH_PATTERNS = {"config/", "config\\", "/config.", "\\config.", "settings.py"}
|
|
27
|
-
TEST_PATH_PATTERNS = {"test_", "_test.", ".
|
|
28
|
-
HOOK_INFRASTRUCTURE_PATTERNS = {"/.claude/hooks/", "\\.claude\\hooks\\", "\\.claude/hooks/"}
|
|
31
|
+
TEST_PATH_PATTERNS = {"test_", "_test.", ".test.", ".spec.", "/tests/", "\\tests\\", "/tests.py", "\\tests.py"}
|
|
32
|
+
HOOK_INFRASTRUCTURE_PATTERNS = {"/.claude/hooks/", "\\.claude\\hooks\\", "\\.claude/hooks/", "/packages/claude-dev-env/hooks/", "\\packages\\claude-dev-env\\hooks\\"}
|
|
29
33
|
WORKFLOW_REGISTRY_PATTERNS = {"/workflow/", "\\workflow\\", "_tab.py", "/states.py", "\\states.py", "/modules.py", "\\modules.py"}
|
|
30
34
|
MIGRATION_PATH_PATTERNS = {"/migrations/", "\\migrations\\"}
|
|
31
35
|
|
|
32
36
|
ADVISORY_LINE_THRESHOLD_SOFT = 400
|
|
33
37
|
ADVISORY_LINE_THRESHOLD_HARD = 1000
|
|
34
38
|
|
|
39
|
+
BOOLEAN_NAME_PREFIXES: tuple[str, ...] = ("is_", "has_", "should_", "can_")
|
|
40
|
+
BOOLEAN_NAMING_ISSUE_CAP = 3
|
|
41
|
+
UPPER_SNAKE_CONSTANT_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]*$")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
TYPE_CHECKING_BLOCK_PATTERN = re.compile(r"^(?P<indent>\s*)if\s+(typing\.)?TYPE_CHECKING\s*:\s*$")
|
|
45
|
+
IMPORT_STATEMENT_PREFIXES: tuple[str, ...] = ("import ", "from ")
|
|
46
|
+
NOT_INSIDE_TYPE_CHECKING_BLOCK = -1
|
|
47
|
+
MAX_ISSUES_PER_CHECK = 3
|
|
48
|
+
|
|
35
49
|
|
|
36
50
|
def get_file_extension(file_path: str) -> str:
|
|
37
51
|
"""Extract lowercase file extension."""
|
|
@@ -56,6 +70,9 @@ def is_config_file(file_path: str) -> bool:
|
|
|
56
70
|
def is_test_file(file_path: str) -> bool:
|
|
57
71
|
"""Check if file is a test file."""
|
|
58
72
|
path_lower = file_path.lower()
|
|
73
|
+
basename_lower = path_lower.replace("\\", "/").rsplit("/", 1)[-1]
|
|
74
|
+
if basename_lower == "conftest.py":
|
|
75
|
+
return True
|
|
59
76
|
return any(pattern in path_lower for pattern in TEST_PATH_PATTERNS)
|
|
60
77
|
|
|
61
78
|
|
|
@@ -265,43 +282,81 @@ def check_comment_changes(old_content: str, new_content: str, file_path: str) ->
|
|
|
265
282
|
|
|
266
283
|
|
|
267
284
|
def check_imports_at_top(content: str) -> list[str]:
|
|
268
|
-
"""Check for imports inside functions (Python only).
|
|
269
|
-
|
|
285
|
+
"""Check for imports inside functions (Python only).
|
|
286
|
+
|
|
287
|
+
An import lexically inside an ``if TYPE_CHECKING:`` block is exempt.
|
|
288
|
+
An import inside a function body is flagged even if the file uses TYPE_CHECKING
|
|
289
|
+
elsewhere at module scope.
|
|
290
|
+
|
|
291
|
+
Only the innermost ``if TYPE_CHECKING:`` block is tracked: a second, nested
|
|
292
|
+
``if TYPE_CHECKING:`` header overwrites the outer block's indent so that when
|
|
293
|
+
control dedents back to the outer block's body, the tracker resets.
|
|
294
|
+
|
|
295
|
+
Known limitation: nested ``if TYPE_CHECKING:`` blocks are NOT supported. After
|
|
296
|
+
a nested inner block ends, subsequent lines at the OUTER block's body indent
|
|
297
|
+
are treated as outside any TYPE_CHECKING scope, so function-body imports there
|
|
298
|
+
WILL be flagged as violations even though they are lexically guarded by the
|
|
299
|
+
outer block. Rewrite to a single top-level ``if TYPE_CHECKING:`` block to avoid
|
|
300
|
+
this false positive. Nested TYPE_CHECKING blocks are rare in practice, so this
|
|
301
|
+
simpler single-level tracking is preferred over maintaining a stack of indent
|
|
302
|
+
levels. The pinned behavior is covered by
|
|
303
|
+
``test_should_track_only_innermost_type_checking_block``.
|
|
304
|
+
"""
|
|
305
|
+
issues: list[str] = []
|
|
270
306
|
lines = content.split("\n")
|
|
271
307
|
inside_function = False
|
|
272
308
|
function_indent = 0
|
|
309
|
+
type_checking_block_indent = NOT_INSIDE_TYPE_CHECKING_BLOCK
|
|
273
310
|
|
|
274
|
-
for line_number,
|
|
275
|
-
stripped =
|
|
311
|
+
for line_number, each_line in enumerate(lines, 1):
|
|
312
|
+
stripped = each_line.strip()
|
|
276
313
|
|
|
277
314
|
if not stripped:
|
|
278
315
|
continue
|
|
279
316
|
|
|
280
|
-
|
|
281
|
-
|
|
317
|
+
current_indent = len(each_line) - len(each_line.lstrip())
|
|
318
|
+
|
|
319
|
+
if type_checking_block_indent != NOT_INSIDE_TYPE_CHECKING_BLOCK:
|
|
320
|
+
if current_indent <= type_checking_block_indent:
|
|
321
|
+
type_checking_block_indent = NOT_INSIDE_TYPE_CHECKING_BLOCK
|
|
322
|
+
|
|
323
|
+
type_checking_match = TYPE_CHECKING_BLOCK_PATTERN.match(each_line)
|
|
324
|
+
if type_checking_match:
|
|
325
|
+
type_checking_block_indent = len(type_checking_match.group("indent"))
|
|
326
|
+
continue
|
|
327
|
+
|
|
328
|
+
function_match = re.match(r"^(\s*)(async\s+)?def\s+\w+", each_line)
|
|
329
|
+
if function_match:
|
|
282
330
|
inside_function = True
|
|
283
|
-
function_indent = len(
|
|
331
|
+
function_indent = len(function_match.group(1)) if function_match.group(1) else 0
|
|
284
332
|
continue
|
|
285
333
|
|
|
286
334
|
if inside_function:
|
|
287
|
-
current_indent = len(line) - len(line.lstrip())
|
|
288
335
|
if current_indent <= function_indent and stripped and not stripped.startswith(("#", "@", ")")):
|
|
289
336
|
inside_function = False
|
|
290
337
|
|
|
291
|
-
|
|
292
|
-
|
|
338
|
+
is_inside_type_checking_block = type_checking_block_indent != NOT_INSIDE_TYPE_CHECKING_BLOCK
|
|
339
|
+
if inside_function and not is_inside_type_checking_block:
|
|
340
|
+
if stripped.startswith(IMPORT_STATEMENT_PREFIXES):
|
|
293
341
|
issues.append(f"Line {line_number}: Import inside function - move to top of file")
|
|
294
342
|
|
|
295
|
-
if len(issues) >=
|
|
343
|
+
if len(issues) >= MAX_ISSUES_PER_CHECK:
|
|
296
344
|
break
|
|
297
345
|
|
|
298
346
|
return issues
|
|
299
347
|
|
|
300
348
|
|
|
349
|
+
LOGGING_FSTRING_PATTERN = re.compile(
|
|
350
|
+
r'\b(?:log_(?:debug|info|warning|error|critical|exception)'
|
|
351
|
+
r'|(?:logger|logging|log)\.(?:debug|info|warning|error|critical|exception))'
|
|
352
|
+
r'\s*\(\s*(?:[rR][fF]|[fF][rR]?)["\']'
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
|
|
301
356
|
def check_logging_fstrings(content: str) -> list[str]:
|
|
302
357
|
"""Check for f-strings in logging calls."""
|
|
303
358
|
issues = []
|
|
304
|
-
pattern =
|
|
359
|
+
pattern = LOGGING_FSTRING_PATTERN
|
|
305
360
|
|
|
306
361
|
for line_number, line in enumerate(content.split("\n"), 1):
|
|
307
362
|
if pattern.search(line):
|
|
@@ -361,7 +416,7 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
|
361
416
|
inside_function = False
|
|
362
417
|
|
|
363
418
|
number_pattern = re.compile(r"(?<![.\w])(\d+\.?\d*)(?![.\w])")
|
|
364
|
-
allowed_numbers = {"0", "1", "-1", "0.0", "1.0"
|
|
419
|
+
allowed_numbers = {"0", "1", "-1", "0.0", "1.0"}
|
|
365
420
|
|
|
366
421
|
for line_number, line in enumerate(lines, 1):
|
|
367
422
|
stripped = line.strip()
|
|
@@ -400,6 +455,107 @@ def check_magic_values(content: str, file_path: str) -> list[str]:
|
|
|
400
455
|
return issues
|
|
401
456
|
|
|
402
457
|
|
|
458
|
+
def _extract_fstring_literal_parts(
|
|
459
|
+
joined_string_node: ast.JoinedStr,
|
|
460
|
+
) -> tuple[str, str]:
|
|
461
|
+
"""Return (display_body, shape_body) for an f-string node.
|
|
462
|
+
|
|
463
|
+
``display_body`` concatenates only the literal segments for use in the
|
|
464
|
+
human-readable flag message. ``shape_body`` substitutes each interpolation
|
|
465
|
+
slot with the placeholder word ``INTERP`` so regex patterns for path
|
|
466
|
+
shape (``\\w+/\\w+/\\w+``) still match across interpolation boundaries
|
|
467
|
+
(e.g. ``/api/v1/{id}/home`` keeps its three path segments instead of
|
|
468
|
+
collapsing to ``/api/v1//home``). Escaped braces (``{{`` / ``}}``) are
|
|
469
|
+
already decoded by :mod:`ast` into their literal forms.
|
|
470
|
+
"""
|
|
471
|
+
interpolation_placeholder = "INTERP"
|
|
472
|
+
display_segments: list[str] = []
|
|
473
|
+
shape_segments: list[str] = []
|
|
474
|
+
for each_part in joined_string_node.values:
|
|
475
|
+
if isinstance(each_part, ast.Constant) and isinstance(each_part.value, str):
|
|
476
|
+
display_segments.append(each_part.value)
|
|
477
|
+
shape_segments.append(each_part.value)
|
|
478
|
+
else:
|
|
479
|
+
shape_segments.append(interpolation_placeholder)
|
|
480
|
+
return "".join(display_segments), "".join(shape_segments)
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _has_structural_shape(literal_body: str) -> bool:
|
|
484
|
+
"""Return True when a literal body looks like a path, URL, or regex.
|
|
485
|
+
|
|
486
|
+
Natural English containing a single slash (e.g. ``online/offline``,
|
|
487
|
+
``CI/CD``, ``and/or``) must NOT match. Only multi-segment paths,
|
|
488
|
+
URL schemes, Windows drive prefixes, leading absolute paths, regex
|
|
489
|
+
escape sequences (``\\d``, ``\\w``, ``\\s`` and friends), or regex
|
|
490
|
+
anchors at the boundary are treated as structural.
|
|
491
|
+
"""
|
|
492
|
+
if re.search(r"\w+/\w+/\w+", literal_body):
|
|
493
|
+
return True
|
|
494
|
+
if re.search(r"\w+\\\w+\\\w+", literal_body):
|
|
495
|
+
return True
|
|
496
|
+
if re.search(r"[A-Za-z][A-Za-z0-9+.\-]*://", literal_body):
|
|
497
|
+
return True
|
|
498
|
+
if re.search(r"(^|\s)[A-Za-z]:[\\/]", literal_body):
|
|
499
|
+
return True
|
|
500
|
+
if re.search(r"^/\w+/\w+", literal_body):
|
|
501
|
+
return True
|
|
502
|
+
if re.search(r"\\[dwsDWSbBAZ]|\\\d", literal_body):
|
|
503
|
+
return True
|
|
504
|
+
if literal_body.startswith("^") or literal_body.endswith("$"):
|
|
505
|
+
return True
|
|
506
|
+
return False
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def check_fstring_structural_literals(content: str, file_path: str) -> list[str]:
|
|
510
|
+
"""Flag f-strings whose literal fragments look like paths, URLs, or regex.
|
|
511
|
+
|
|
512
|
+
Parses the file with :mod:`ast` so every f-string form is handled
|
|
513
|
+
uniformly: single, triple-quoted, raw (``rf`` / ``fr``), and strings
|
|
514
|
+
containing apostrophes or escaped braces. The literal portions of
|
|
515
|
+
each ``JoinedStr`` node are concatenated, and the result is treated
|
|
516
|
+
as a structural magic value only when :func:`_has_structural_shape`
|
|
517
|
+
matches a multi-segment path, a URL scheme, a Windows drive prefix,
|
|
518
|
+
a leading absolute path, a regex escape sequence, or a boundary
|
|
519
|
+
regex anchor.
|
|
520
|
+
|
|
521
|
+
The enforcer hook file, config files, and test files are all exempt.
|
|
522
|
+
Syntax errors in the input silently produce no issues, matching the
|
|
523
|
+
behaviour of the other lint-style checks in this module.
|
|
524
|
+
"""
|
|
525
|
+
if is_config_file(file_path) or is_test_file(file_path):
|
|
526
|
+
return []
|
|
527
|
+
if file_path.replace("\\", "/").endswith("hooks/blocking/code-rules-enforcer.py"):
|
|
528
|
+
return []
|
|
529
|
+
|
|
530
|
+
try:
|
|
531
|
+
syntax_tree = ast.parse(content)
|
|
532
|
+
except SyntaxError:
|
|
533
|
+
return []
|
|
534
|
+
|
|
535
|
+
minimum_literal_length = 2
|
|
536
|
+
maximum_issues_before_stop = 100
|
|
537
|
+
non_magic_stripped_values = {"", "True", "False"}
|
|
538
|
+
|
|
539
|
+
issues: list[str] = []
|
|
540
|
+
for each_node in ast.walk(syntax_tree):
|
|
541
|
+
if not isinstance(each_node, ast.JoinedStr):
|
|
542
|
+
continue
|
|
543
|
+
display_body, shape_body = _extract_fstring_literal_parts(each_node)
|
|
544
|
+
if display_body in non_magic_stripped_values:
|
|
545
|
+
continue
|
|
546
|
+
if len(display_body) < minimum_literal_length:
|
|
547
|
+
continue
|
|
548
|
+
if not _has_structural_shape(shape_body):
|
|
549
|
+
continue
|
|
550
|
+
issues.append(
|
|
551
|
+
f"Line {each_node.lineno}: Structural literal inside f-string {display_body!r} - extract to config"
|
|
552
|
+
)
|
|
553
|
+
if len(issues) >= maximum_issues_before_stop:
|
|
554
|
+
break
|
|
555
|
+
|
|
556
|
+
return issues
|
|
557
|
+
|
|
558
|
+
|
|
403
559
|
def check_e2e_test_naming(content: str, file_path: str) -> list[str]:
|
|
404
560
|
"""Check for online/offline in test names (spec files only)."""
|
|
405
561
|
if not is_spec_file(file_path):
|
|
@@ -418,6 +574,116 @@ def check_e2e_test_naming(content: str, file_path: str) -> list[str]:
|
|
|
418
574
|
return issues
|
|
419
575
|
|
|
420
576
|
|
|
577
|
+
def _render_annotation_source(annotation_node: ast.expr) -> str:
|
|
578
|
+
"""Return a textual representation of an annotation AST node."""
|
|
579
|
+
unparse_function = getattr(ast, "unparse", None)
|
|
580
|
+
if unparse_function is not None:
|
|
581
|
+
return unparse_function(annotation_node)
|
|
582
|
+
sys.stderr.write(
|
|
583
|
+
"code-rules-enforcer: ast.unparse unavailable on this interpreter; "
|
|
584
|
+
"falling back to ast.dump for Any detection.\n"
|
|
585
|
+
)
|
|
586
|
+
return ast.dump(annotation_node)
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _annotation_uses_any(annotation_node: Optional[ast.expr]) -> bool:
|
|
590
|
+
"""Return True when an annotation AST node textually references Any."""
|
|
591
|
+
if annotation_node is None:
|
|
592
|
+
return False
|
|
593
|
+
annotation_source = _render_annotation_source(annotation_node)
|
|
594
|
+
return bool(re.search(r"\bAny\b", annotation_source))
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _collect_annotated_arguments(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> list[ast.arg]:
|
|
598
|
+
"""Return every argument node on a function that may carry an annotation."""
|
|
599
|
+
arguments = function_node.args
|
|
600
|
+
all_annotated_arguments: list[ast.arg] = []
|
|
601
|
+
all_annotated_arguments.extend(arguments.posonlyargs)
|
|
602
|
+
all_annotated_arguments.extend(arguments.args)
|
|
603
|
+
all_annotated_arguments.extend(arguments.kwonlyargs)
|
|
604
|
+
if arguments.vararg is not None:
|
|
605
|
+
all_annotated_arguments.append(arguments.vararg)
|
|
606
|
+
if arguments.kwarg is not None:
|
|
607
|
+
all_annotated_arguments.append(arguments.kwarg)
|
|
608
|
+
return all_annotated_arguments
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _find_any_annotation_lines(source: str) -> list[int]:
|
|
612
|
+
"""Return line numbers of annotations that textually reference Any."""
|
|
613
|
+
try:
|
|
614
|
+
parsed_tree = ast.parse(source)
|
|
615
|
+
except SyntaxError:
|
|
616
|
+
return []
|
|
617
|
+
|
|
618
|
+
offending_line_numbers: list[int] = []
|
|
619
|
+
already_reported_lines: set[int] = set()
|
|
620
|
+
for each_node in ast.walk(parsed_tree):
|
|
621
|
+
if isinstance(each_node, ast.AnnAssign) and _annotation_uses_any(each_node.annotation):
|
|
622
|
+
if each_node.lineno not in already_reported_lines:
|
|
623
|
+
offending_line_numbers.append(each_node.lineno)
|
|
624
|
+
already_reported_lines.add(each_node.lineno)
|
|
625
|
+
continue
|
|
626
|
+
if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
|
|
627
|
+
if _annotation_uses_any(each_node.returns) and each_node.lineno not in already_reported_lines:
|
|
628
|
+
offending_line_numbers.append(each_node.lineno)
|
|
629
|
+
already_reported_lines.add(each_node.lineno)
|
|
630
|
+
for each_argument in _collect_annotated_arguments(each_node):
|
|
631
|
+
if _annotation_uses_any(each_argument.annotation) and each_argument.lineno not in already_reported_lines:
|
|
632
|
+
offending_line_numbers.append(each_argument.lineno)
|
|
633
|
+
already_reported_lines.add(each_argument.lineno)
|
|
634
|
+
return offending_line_numbers
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def _comment_tokens(source: str) -> list[tokenize.TokenInfo]:
|
|
638
|
+
"""Return COMMENT tokens from source, or an empty list when tokenization fails."""
|
|
639
|
+
try:
|
|
640
|
+
return [
|
|
641
|
+
each_token
|
|
642
|
+
for each_token in tokenize.generate_tokens(io.StringIO(source).readline)
|
|
643
|
+
if each_token.type == tokenize.COMMENT
|
|
644
|
+
]
|
|
645
|
+
except (tokenize.TokenError, IndentationError, SyntaxError):
|
|
646
|
+
return []
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def _find_unjustified_type_ignore_lines(source: str) -> list[int]:
|
|
650
|
+
"""Return line numbers of # type: ignore comments lacking a trailing reason."""
|
|
651
|
+
ignore_pattern = re.compile(r"#\s*type:\s*ignore(?:\[[^\]]*\])?(.*)$")
|
|
652
|
+
minimum_justification_characters = len("xxxxx")
|
|
653
|
+
offending_line_numbers: list[int] = []
|
|
654
|
+
for each_comment_token in _comment_tokens(source):
|
|
655
|
+
matched = ignore_pattern.search(each_comment_token.string)
|
|
656
|
+
if not matched:
|
|
657
|
+
continue
|
|
658
|
+
line_number = each_comment_token.start[0]
|
|
659
|
+
trailing_text = matched.group(1).strip()
|
|
660
|
+
if not trailing_text.startswith("#"):
|
|
661
|
+
offending_line_numbers.append(line_number)
|
|
662
|
+
continue
|
|
663
|
+
justification_text = trailing_text.lstrip("#").strip()
|
|
664
|
+
if len(justification_text) < minimum_justification_characters:
|
|
665
|
+
offending_line_numbers.append(line_number)
|
|
666
|
+
return offending_line_numbers
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def check_type_escape_hatches(content: str, file_path: str) -> list[str]:
|
|
670
|
+
"""Flag Any annotations and unjustified # type: ignore comments."""
|
|
671
|
+
if is_test_file(file_path):
|
|
672
|
+
return []
|
|
673
|
+
|
|
674
|
+
issues: list[str] = []
|
|
675
|
+
|
|
676
|
+
for each_any_line in _find_any_annotation_lines(content):
|
|
677
|
+
issues.append(f"Line {each_any_line}: Any annotation - replace with explicit type")
|
|
678
|
+
|
|
679
|
+
for each_ignore_line in _find_unjustified_type_ignore_lines(content):
|
|
680
|
+
issues.append(
|
|
681
|
+
f"Line {each_ignore_line}: Unjustified # type: ignore - add trailing '# reason' explaining why"
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
return issues
|
|
685
|
+
|
|
686
|
+
|
|
421
687
|
def is_migration_file(file_path: str) -> bool:
|
|
422
688
|
"""Check if file is a Django migration (must be self-contained)."""
|
|
423
689
|
path_lower = file_path.lower().replace("\\", "/")
|
|
@@ -478,6 +744,211 @@ def check_constants_outside_config(content: str, file_path: str) -> list[str]:
|
|
|
478
744
|
return issues
|
|
479
745
|
|
|
480
746
|
|
|
747
|
+
BANNED_IDENTIFIERS: frozenset[str] = frozenset({"result", "data", "output", "response", "value", "item", "temp"})
|
|
748
|
+
MAX_BANNED_IDENTIFIER_ISSUES: int = 3
|
|
749
|
+
BANNED_IDENTIFIER_MESSAGE_SUFFIX: str = "use descriptive name (see CODE_RULES Naming section)"
|
|
750
|
+
BANNED_IDENTIFIER_SKIP_ADVISORY: str = (
|
|
751
|
+
"banned-identifier check skipped: file did not parse as Python"
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _collect_banned_names_from_target(target: ast.expr) -> list[ast.Name]:
|
|
756
|
+
"""Return every banned ast.Name reachable through tuple/list unpacking or starred targets."""
|
|
757
|
+
if isinstance(target, ast.Name):
|
|
758
|
+
if target.id in BANNED_IDENTIFIERS:
|
|
759
|
+
return [target]
|
|
760
|
+
return []
|
|
761
|
+
if isinstance(target, (ast.Tuple, ast.List)):
|
|
762
|
+
banned_names: list[ast.Name] = []
|
|
763
|
+
for each_element in target.elts:
|
|
764
|
+
banned_names.extend(_collect_banned_names_from_target(each_element))
|
|
765
|
+
return banned_names
|
|
766
|
+
if isinstance(target, ast.Starred):
|
|
767
|
+
return _collect_banned_names_from_target(target.value)
|
|
768
|
+
return []
|
|
769
|
+
|
|
770
|
+
|
|
771
|
+
def _collect_banned_names_from_node(node: ast.AST) -> list[ast.Name]:
|
|
772
|
+
"""Return banned ast.Name nodes introduced by a single binding construct."""
|
|
773
|
+
if isinstance(node, ast.Assign):
|
|
774
|
+
banned_names: list[ast.Name] = []
|
|
775
|
+
for each_target in node.targets:
|
|
776
|
+
banned_names.extend(_collect_banned_names_from_target(each_target))
|
|
777
|
+
return banned_names
|
|
778
|
+
if isinstance(node, ast.AnnAssign):
|
|
779
|
+
return _collect_banned_names_from_target(node.target)
|
|
780
|
+
if isinstance(node, (ast.For, ast.AsyncFor)):
|
|
781
|
+
return _collect_banned_names_from_target(node.target)
|
|
782
|
+
if isinstance(node, ast.comprehension):
|
|
783
|
+
return _collect_banned_names_from_target(node.target)
|
|
784
|
+
if isinstance(node, ast.withitem):
|
|
785
|
+
if node.optional_vars is None:
|
|
786
|
+
return []
|
|
787
|
+
return _collect_banned_names_from_target(node.optional_vars)
|
|
788
|
+
if isinstance(node, ast.NamedExpr):
|
|
789
|
+
return _collect_banned_names_from_target(node.target)
|
|
790
|
+
return []
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
def check_banned_identifiers(content: str, file_path: str) -> list[str]:
|
|
794
|
+
"""Flag assignments to identifiers banned by the project Naming rules."""
|
|
795
|
+
if is_test_file(file_path) or is_hook_infrastructure(file_path):
|
|
796
|
+
return []
|
|
797
|
+
|
|
798
|
+
try:
|
|
799
|
+
parsed_tree = ast.parse(content)
|
|
800
|
+
except SyntaxError:
|
|
801
|
+
print(f"{file_path}: {BANNED_IDENTIFIER_SKIP_ADVISORY}", file=sys.stderr)
|
|
802
|
+
return []
|
|
803
|
+
|
|
804
|
+
banned_name_nodes: list[ast.Name] = []
|
|
805
|
+
for each_node in ast.walk(parsed_tree):
|
|
806
|
+
banned_name_nodes.extend(_collect_banned_names_from_node(each_node))
|
|
807
|
+
|
|
808
|
+
banned_name_nodes.sort(key=lambda each_name: (each_name.lineno, each_name.col_offset))
|
|
809
|
+
|
|
810
|
+
issues: list[str] = []
|
|
811
|
+
for each_name in banned_name_nodes:
|
|
812
|
+
issues.append(
|
|
813
|
+
f"Line {each_name.lineno}: Banned identifier '{each_name.id}' - {BANNED_IDENTIFIER_MESSAGE_SUFFIX}"
|
|
814
|
+
)
|
|
815
|
+
if len(issues) >= MAX_BANNED_IDENTIFIER_ISSUES:
|
|
816
|
+
break
|
|
817
|
+
|
|
818
|
+
return issues
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def _is_bool_constant(node: ast.AST) -> bool:
|
|
822
|
+
return isinstance(node, ast.Constant) and isinstance(node.value, bool)
|
|
823
|
+
|
|
824
|
+
|
|
825
|
+
def _rhs_names_if_all_bool(value_node: ast.AST, target_node: ast.AST) -> list[str]:
|
|
826
|
+
"""Return names from a tuple assignment target when every RHS element is a bool constant.
|
|
827
|
+
|
|
828
|
+
Handles cases like `valid, permitted = True, False` where target is a Tuple
|
|
829
|
+
and value is a Tuple of bool constants. Returns empty list otherwise.
|
|
830
|
+
"""
|
|
831
|
+
if not isinstance(target_node, ast.Tuple):
|
|
832
|
+
return []
|
|
833
|
+
if not isinstance(value_node, ast.Tuple):
|
|
834
|
+
return []
|
|
835
|
+
if len(target_node.elts) != len(value_node.elts):
|
|
836
|
+
return []
|
|
837
|
+
if not all(_is_bool_constant(element) for element in value_node.elts):
|
|
838
|
+
return []
|
|
839
|
+
names: list[str] = []
|
|
840
|
+
for element in target_node.elts:
|
|
841
|
+
if isinstance(element, ast.Name):
|
|
842
|
+
names.append(element.id)
|
|
843
|
+
return names
|
|
844
|
+
|
|
845
|
+
|
|
846
|
+
def _assign_target_names_for_bool(node: ast.Assign) -> list[str]:
|
|
847
|
+
if not node.targets:
|
|
848
|
+
return []
|
|
849
|
+
names: list[str] = []
|
|
850
|
+
for target in node.targets:
|
|
851
|
+
if isinstance(target, ast.Name) and _is_bool_constant(node.value):
|
|
852
|
+
names.append(target.id)
|
|
853
|
+
else:
|
|
854
|
+
names.extend(_rhs_names_if_all_bool(node.value, target))
|
|
855
|
+
return names
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
def _annassign_target_name_for_bool(node: ast.AnnAssign) -> list[str]:
|
|
859
|
+
if not isinstance(node.target, ast.Name):
|
|
860
|
+
return []
|
|
861
|
+
annotation_is_bool = isinstance(node.annotation, ast.Name) and node.annotation.id == "bool"
|
|
862
|
+
value_is_bool = node.value is not None and _is_bool_constant(node.value)
|
|
863
|
+
if annotation_is_bool and value_is_bool:
|
|
864
|
+
return [node.target.id]
|
|
865
|
+
return []
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _walrus_name_for_bool(node: ast.NamedExpr) -> list[str]:
|
|
869
|
+
if not isinstance(node.target, ast.Name):
|
|
870
|
+
return []
|
|
871
|
+
if not _is_bool_constant(node.value):
|
|
872
|
+
return []
|
|
873
|
+
return [node.target.id]
|
|
874
|
+
|
|
875
|
+
|
|
876
|
+
def _collect_boolean_assignments(tree: ast.Module) -> list[tuple[str, int, bool]]:
|
|
877
|
+
"""Collect boolean-constant assignments with (name, line_number, is_upper_snake_scope).
|
|
878
|
+
|
|
879
|
+
`is_upper_snake_scope` is True for module-level statements and direct class body
|
|
880
|
+
statements, where UPPER_SNAKE constants are acceptable (dataclass fields, class
|
|
881
|
+
constants). Function/method scope is False.
|
|
882
|
+
|
|
883
|
+
Invariant: relies on `ast.walk` returning the same node instances that were
|
|
884
|
+
stored in `upper_snake_scope_ids` via their `id()`. Do not call this helper
|
|
885
|
+
on a tree that has been rebuilt through an `ast.NodeTransformer` — the
|
|
886
|
+
transformer may replace nodes with fresh instances, and the identity-based
|
|
887
|
+
scope tagging will silently fail for the replaced nodes.
|
|
888
|
+
"""
|
|
889
|
+
upper_snake_scope_ids: set[int] = set()
|
|
890
|
+
for statement in tree.body:
|
|
891
|
+
upper_snake_scope_ids.add(id(statement))
|
|
892
|
+
for node in ast.walk(tree):
|
|
893
|
+
if isinstance(node, ast.ClassDef):
|
|
894
|
+
for class_statement in node.body:
|
|
895
|
+
upper_snake_scope_ids.add(id(class_statement))
|
|
896
|
+
collected: list[tuple[str, int, bool]] = []
|
|
897
|
+
for node in ast.walk(tree):
|
|
898
|
+
names: list[str] = []
|
|
899
|
+
line_number = 0
|
|
900
|
+
if isinstance(node, ast.Assign):
|
|
901
|
+
names = _assign_target_names_for_bool(node)
|
|
902
|
+
line_number = node.lineno
|
|
903
|
+
elif isinstance(node, ast.AnnAssign):
|
|
904
|
+
names = _annassign_target_name_for_bool(node)
|
|
905
|
+
line_number = node.lineno
|
|
906
|
+
elif isinstance(node, ast.NamedExpr):
|
|
907
|
+
names = _walrus_name_for_bool(node)
|
|
908
|
+
line_number = node.lineno
|
|
909
|
+
if not names:
|
|
910
|
+
continue
|
|
911
|
+
is_in_upper_snake_scope = id(node) in upper_snake_scope_ids
|
|
912
|
+
for name in names:
|
|
913
|
+
collected.append((name, line_number, is_in_upper_snake_scope))
|
|
914
|
+
return collected
|
|
915
|
+
|
|
916
|
+
|
|
917
|
+
def check_boolean_naming(content: str, file_path: str) -> list[str]:
|
|
918
|
+
"""Flag boolean assignments whose target name lacks a required prefix."""
|
|
919
|
+
if is_test_file(file_path):
|
|
920
|
+
return []
|
|
921
|
+
if is_hook_infrastructure(file_path):
|
|
922
|
+
return []
|
|
923
|
+
if is_config_file(file_path):
|
|
924
|
+
return []
|
|
925
|
+
if is_workflow_registry_file(file_path):
|
|
926
|
+
return []
|
|
927
|
+
try:
|
|
928
|
+
tree = ast.parse(content)
|
|
929
|
+
except SyntaxError as parse_error:
|
|
930
|
+
print(
|
|
931
|
+
f"[CODE_RULES advisory] {file_path}: boolean-naming check skipped - "
|
|
932
|
+
f"SyntaxError at line {parse_error.lineno}: {parse_error.msg}",
|
|
933
|
+
file=sys.stderr,
|
|
934
|
+
)
|
|
935
|
+
return []
|
|
936
|
+
issues: list[str] = []
|
|
937
|
+
for name, line_number, is_in_upper_snake_scope in _collect_boolean_assignments(tree):
|
|
938
|
+
if len(name) == 1:
|
|
939
|
+
continue
|
|
940
|
+
if is_in_upper_snake_scope and UPPER_SNAKE_CONSTANT_PATTERN.match(name):
|
|
941
|
+
continue
|
|
942
|
+
if name.startswith(BOOLEAN_NAME_PREFIXES):
|
|
943
|
+
continue
|
|
944
|
+
issues.append(
|
|
945
|
+
f"Line {line_number}: Boolean {name} - prefix with is_/has_/should_/can_"
|
|
946
|
+
)
|
|
947
|
+
if len(issues) >= BOOLEAN_NAMING_ISSUE_CAP:
|
|
948
|
+
break
|
|
949
|
+
return issues
|
|
950
|
+
|
|
951
|
+
|
|
481
952
|
def validate_content(content: str, file_path: str, old_content: str = "") -> list[str]:
|
|
482
953
|
"""Run all applicable validators on content.
|
|
483
954
|
|
|
@@ -497,7 +968,11 @@ def validate_content(content: str, file_path: str, old_content: str = "") -> lis
|
|
|
497
968
|
all_issues.extend(check_logging_fstrings(content))
|
|
498
969
|
all_issues.extend(check_windows_api_none(content))
|
|
499
970
|
all_issues.extend(check_magic_values(content, file_path))
|
|
971
|
+
all_issues.extend(check_fstring_structural_literals(content, file_path))
|
|
500
972
|
all_issues.extend(check_constants_outside_config(content, file_path))
|
|
973
|
+
all_issues.extend(check_type_escape_hatches(content, file_path))
|
|
974
|
+
all_issues.extend(check_banned_identifiers(content, file_path))
|
|
975
|
+
all_issues.extend(check_boolean_naming(content, file_path))
|
|
501
976
|
|
|
502
977
|
elif extension in JAVASCRIPT_EXTENSIONS:
|
|
503
978
|
if not is_test_file(file_path):
|