claude-dev-env 1.23.1 → 1.25.0

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.
Files changed (25) hide show
  1. package/docs/CODE_RULES.md +14 -1
  2. package/hooks/blocking/_gh_body_arg_utils.py +171 -13
  3. package/hooks/blocking/code-rules-enforcer.py +490 -15
  4. package/hooks/blocking/gh-body-arg-blocker.py +27 -21
  5. package/hooks/blocking/pr-description-enforcer.py +247 -11
  6. package/hooks/blocking/tdd-enforcer.py +208 -13
  7. package/hooks/blocking/test_code_rules_enforcer_any_type_ignore.py +116 -0
  8. package/hooks/blocking/test_code_rules_enforcer_banned_identifier.py +231 -0
  9. package/hooks/blocking/test_code_rules_enforcer_conftest_anchor.py +51 -0
  10. package/hooks/blocking/test_code_rules_enforcer_dot_test_pattern.py +55 -0
  11. package/hooks/blocking/test_code_rules_enforcer_fstring_scan.py +144 -0
  12. package/hooks/blocking/test_code_rules_enforcer_logger_fstring.py +102 -0
  13. package/hooks/blocking/test_code_rules_enforcer_magic_allowlist.py +76 -0
  14. package/hooks/blocking/test_code_rules_enforcer_naming_pattern.py +176 -0
  15. package/hooks/blocking/test_code_rules_enforcer_type_checking_scope.py +112 -0
  16. package/hooks/blocking/test_gh_body_arg_blocker.py +229 -2
  17. package/hooks/blocking/test_pr_description_enforcer.py +193 -3
  18. package/hooks/blocking/test_tdd_enforcer.py +249 -0
  19. package/hooks/validators/exempt_paths.py +99 -0
  20. package/hooks/validators/magic_value_checks.py +126 -26
  21. package/hooks/validators/test_magic_value_checks.py +356 -2
  22. package/package.json +1 -1
  23. package/rules/gh-body-file.md +11 -2
  24. package/skills/bugteam/SKILL.md +111 -59
  25. 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.", ".spec.", "conftest", "/tests/", "\\tests\\", "/tests.py", "\\tests.py"}
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
- issues = []
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, line in enumerate(lines, 1):
275
- stripped = line.strip()
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
- func_match = re.match(r"^(\s*)(async\s+)?def\s+\w+", line)
281
- if func_match:
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(func_match.group(1)) if func_match.group(1) else 0
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
- if inside_function:
292
- if stripped.startswith(("import ", "from ")) and "TYPE_CHECKING" not in content[:500]:
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) >= 3:
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 = re.compile(r'\blog_(debug|info|warning|error|critical)\s*\(\s*f["\']')
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", "2", "100"}
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):