claude-dev-env 1.44.0 → 1.45.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 (34) hide show
  1. package/CLAUDE.md +9 -0
  2. package/_shared/pr-loop/scripts/code_rules_gate.py +426 -85
  3. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +20 -0
  4. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +625 -21
  5. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
  6. package/agents/clean-coder.md +7 -1
  7. package/agents/code-quality-agent.md +8 -5
  8. package/hooks/blocking/code_rules_enforcer.py +1562 -37
  9. package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
  10. package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
  11. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
  12. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
  13. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
  14. package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
  15. package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
  16. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
  17. package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
  18. package/hooks/hooks.json +10 -0
  19. package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
  20. package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
  21. package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
  22. package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
  23. package/package.json +1 -1
  24. package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
  25. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
  26. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
  27. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
  28. package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
  29. package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
  30. package/skills/bugteam/PROMPTS.md +48 -12
  31. package/skills/bugteam/reference/team-setup.md +4 -2
  32. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
  33. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
  34. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +597 -12
@@ -6,7 +6,7 @@ import importlib.util
6
6
  import re
7
7
  import subprocess
8
8
  import sys
9
- from collections.abc import Callable
9
+ from collections.abc import Callable, Iterator
10
10
  from pathlib import Path
11
11
 
12
12
  ValidateContentCallable = Callable[..., list[str]]
@@ -15,12 +15,22 @@ from bugteam_scripts_constants.bugteam_code_rules_gate_constants import (
15
15
  ALL_CODE_FILE_EXTENSIONS,
16
16
  ALL_COLUMN_MAGIC_FALSE_VALUES,
17
17
  ALL_GIT_DIFF_CACHED_ARGS,
18
- ALL_JS_FILE_EXTENSIONS,
19
18
  BUGTEAM_CODE_RULES_GATE_PREFIX,
20
19
  EXIT_CODE_ENFORCER_MISSING,
20
+ FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX,
21
+ FUNCTION_LENGTH_SPAN_GROUP_INDEX,
22
+ FUNCTION_LENGTH_VIOLATION_PATTERN,
23
+ BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
24
+ BANNED_NOUN_SPAN_GROUP_INDEX,
25
+ BANNED_NOUN_VIOLATION_PATTERN,
21
26
  HUNK_HEADER_RAW_PATTERN,
27
+ ISOLATION_DEFINITION_LINE_GROUP_INDEX,
28
+ ISOLATION_SPAN_GROUP_INDEX,
29
+ ISOLATION_VIOLATION_PATTERN,
30
+ MAX_VIOLATIONS_PER_CHECK,
22
31
  MAXIMUM_COLUMN_TUPLE_ELEMENT_COUNT,
23
32
  MAXIMUM_ISSUES_TO_REPORT,
33
+ PYTHON_FILE_EXTENSION,
24
34
  VIOLATION_LINE_RAW_PATTERN,
25
35
  )
26
36
 
@@ -416,6 +426,68 @@ def is_code_path(file_path: Path) -> bool:
416
426
  return suffix in ALL_CODE_FILE_EXTENSIONS
417
427
 
418
428
 
429
+ def _path_is_eligible_for_validation(
430
+ resolved_path: Path,
431
+ repository_root: Path,
432
+ read_staged_content_flag: bool,
433
+ ) -> bool:
434
+ """Decide whether *resolved_path* should be validated by the gate.
435
+
436
+ Args:
437
+ resolved_path: A resolved candidate path already confirmed to live
438
+ under *repository_root*.
439
+ repository_root: The repository root used to compute the relative path.
440
+ read_staged_content_flag: When True, require staged-index presence so
441
+ files staged for add or modify are validated and staged deletions
442
+ are skipped; when False, require working-tree presence.
443
+
444
+ Returns:
445
+ True when the path carries a code extension and exists in the source
446
+ the gate will read; False otherwise.
447
+ """
448
+ if not is_code_path(resolved_path):
449
+ return False
450
+ if read_staged_content_flag:
451
+ relative_posix = str(
452
+ resolved_path.relative_to(repository_root.resolve())
453
+ ).replace("\\", "/")
454
+ return staged_blob_exists(repository_root.resolve(), relative_posix)
455
+ return resolved_path.is_file()
456
+
457
+
458
+ def _resolve_eligible_code_path(
459
+ candidate_path: Path,
460
+ repository_root: Path,
461
+ read_staged_content_flag: bool = False,
462
+ ) -> Path | None:
463
+ """Resolve *candidate_path* and return it only when the gate should scan it.
464
+
465
+ Args:
466
+ candidate_path: One file path from the gate's candidate set.
467
+ repository_root: The repository root the resolved path must fall under.
468
+ read_staged_content_flag: When True, eligibility requires staged-index
469
+ presence; when False, it requires working-tree presence.
470
+
471
+ Returns:
472
+ The resolved path when it lives under *repository_root*, carries a code
473
+ extension, and is present in the source the gate will read; otherwise
474
+ None.
475
+ """
476
+ try:
477
+ resolved = candidate_path.resolve()
478
+ except OSError:
479
+ return None
480
+ try:
481
+ resolved.relative_to(repository_root.resolve())
482
+ except ValueError:
483
+ return None
484
+ if not _path_is_eligible_for_validation(
485
+ resolved, repository_root, read_staged_content_flag
486
+ ):
487
+ return None
488
+ return resolved
489
+
490
+
419
491
  def check_database_column_string_magic(content: str, file_path: str) -> list[str]:
420
492
  """Flag string literals that look like database/HTTP column or key names inside function bodies.
421
493
 
@@ -472,64 +544,129 @@ def check_database_column_string_magic(content: str, file_path: str) -> list[str
472
544
  return issues
473
545
 
474
546
 
547
+ def _iter_calls_excluding_nested_functions(node: ast.AST) -> Iterator[ast.Call]:
548
+ for each_child in ast.iter_child_nodes(node):
549
+ if isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef)):
550
+ continue
551
+ if isinstance(each_child, ast.Call):
552
+ yield each_child
553
+ continue
554
+ yield from _iter_calls_excluding_nested_functions(each_child)
555
+
556
+
557
+ def _module_level_optional_kwargs_by_name(tree: ast.Module) -> dict[str, set[str]]:
558
+ function_signatures: dict[str, set[str]] = {}
559
+ for each_node in ast.iter_child_nodes(tree):
560
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
561
+ optional_kwargs: set[str] = set()
562
+ for each_kwonly, each_default in zip(
563
+ each_node.args.kwonlyargs, each_node.args.kw_defaults
564
+ ):
565
+ if each_default is not None:
566
+ optional_kwargs.add(each_kwonly.arg)
567
+ positional_defaults = each_node.args.defaults
568
+ positional_args_with_defaults = (
569
+ each_node.args.args[-len(positional_defaults):]
570
+ if positional_defaults
571
+ else []
572
+ )
573
+ for each_positional_arg in positional_args_with_defaults:
574
+ optional_kwargs.add(each_positional_arg.arg)
575
+ function_signatures[each_node.name] = optional_kwargs
576
+ return function_signatures
577
+
578
+
579
+ def _class_method_node_ids(tree: ast.Module) -> set[int]:
580
+ class_method_node_ids: set[int] = set()
581
+ for each_class_def in ast.walk(tree):
582
+ if not isinstance(each_class_def, ast.ClassDef):
583
+ continue
584
+ for each_class_body_node in each_class_def.body:
585
+ if isinstance(
586
+ each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
587
+ ):
588
+ class_method_node_ids.add(id(each_class_body_node))
589
+ return class_method_node_ids
590
+
591
+
592
+ def _wrapper_dropped_kwarg_findings(
593
+ wrapper_node: ast.FunctionDef | ast.AsyncFunctionDef,
594
+ kwargs_by_function_name: dict[str, set[str]],
595
+ ) -> Iterator[str]:
596
+ wrapper_kwargs = kwargs_by_function_name.get(wrapper_node.name, set())
597
+ for each_call in _iter_calls_excluding_nested_functions(wrapper_node):
598
+ if isinstance(each_call.func, ast.Name):
599
+ delegate_name = each_call.func.id
600
+ elif isinstance(each_call.func, ast.Attribute):
601
+ delegate_name = each_call.func.attr
602
+ else:
603
+ continue
604
+ delegate_kwargs = kwargs_by_function_name.get(delegate_name)
605
+ if delegate_kwargs is None:
606
+ continue
607
+ missing = delegate_kwargs - wrapper_kwargs
608
+ if missing:
609
+ yield (
610
+ f"Line {wrapper_node.lineno}: Wrapper {wrapper_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
611
+ )
612
+
613
+
475
614
  def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
476
- """Flag public wrappers that drop optional kwargs of a same-file delegate.
615
+ """Flag calls inside public functions that drop a same-file delegate's optional kwargs.
477
616
 
478
617
  Walks the AST. For every public function (name does not start with '_'),
479
- if its body contains exactly one direct call to another same-file
480
- function and that delegate's signature accepts optional kwargs that the
481
- wrapper does not also accept, emit a finding with both line numbers.
618
+ inspects every ast.Call inside its body and emits one finding per call
619
+ whose target name matches a same-file function that exposes optional
620
+ kwargs the enclosing public function does not also accept. Emission is
621
+ capped at MAX_VIOLATIONS_PER_CHECK findings per call to run_gate.
622
+
623
+ Limitations:
624
+ - Only module-level FunctionDef nodes contribute signatures, and ClassDef
625
+ methods are skipped both as signature sources and as wrapper candidates:
626
+ a class method's signature is unrelated to a free-function delegate's
627
+ keyword surface, so treating it as a wrapper produces false positives.
628
+ - ast.Attribute calls match by attribute name only; the receiver type is
629
+ not checked, so `self.fetch(...)` and `other.fetch(...)` both match a
630
+ module-level `fetch` definition.
631
+ - Nested call expressions inside another call's arguments are not treated as
632
+ separate call sites; only the enclosing Call is inspected. This avoids
633
+ false positives where a callee nested as an argument is confused with a
634
+ top-level delegate invocation (for example `delegate(helper(x))`).
482
635
 
483
636
  Args:
484
- content: The source code content to inspect.
485
- file_path: The file path for JS/TS extension exemption.
637
+ content: File content as a single string for AST parsing.
638
+ file_path: Repository-relative POSIX path of the file (used to
639
+ skip non-Python code extensions early).
486
640
 
487
641
  Returns:
488
- List of violation messages, or an empty list when no violations are found.
642
+ List of violation strings, one per dropped optional kwarg. Returns
643
+ an empty list when the file is not Python or has a syntax error.
489
644
  """
490
- if file_path.endswith(ALL_JS_FILE_EXTENSIONS):
645
+ non_python_code_extensions = ALL_CODE_FILE_EXTENSIONS - {PYTHON_FILE_EXTENSION}
646
+ lowercase_file_path = file_path.lower()
647
+ if any(
648
+ lowercase_file_path.endswith(each_extension)
649
+ for each_extension in non_python_code_extensions
650
+ ):
491
651
  return []
492
652
  try:
493
653
  tree = ast.parse(content)
494
654
  except SyntaxError:
495
655
  return []
496
- function_signatures: dict[str, set[str]] = {}
497
- for each_node in ast.walk(tree):
498
- if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
499
- optional_kwargs: set[str] = set()
500
- for each_kwonly, each_default in zip(each_node.args.kwonlyargs, each_node.args.kw_defaults):
501
- if each_default is not None:
502
- optional_kwargs.add(each_kwonly.arg)
503
- function_signatures[each_node.name] = optional_kwargs
656
+ function_signatures = _module_level_optional_kwargs_by_name(tree)
657
+ class_method_node_ids = _class_method_node_ids(tree)
504
658
  issues: list[str] = []
505
659
  for each_node in ast.walk(tree):
506
660
  if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
507
661
  continue
662
+ if id(each_node) in class_method_node_ids:
663
+ continue
508
664
  if each_node.name.startswith("_"):
509
665
  continue
510
- wrapper_kwargs = function_signatures.get(each_node.name, set())
511
- for each_call in ast.walk(each_node):
512
- if not isinstance(each_call, ast.Call):
513
- continue
514
- if not isinstance(each_call.func, ast.Attribute):
515
- continue
516
- delegate_name = each_call.func.attr
517
- delegate_kwargs = function_signatures.get(delegate_name)
518
- if delegate_kwargs is None:
519
- continue
520
- missing = delegate_kwargs - wrapper_kwargs
521
- if missing:
522
- issues.append(
523
- f"Line {each_node.lineno}: Wrapper {each_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
524
- )
525
- if len(issues) >= MAXIMUM_ISSUES_TO_REPORT:
526
- print(
527
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}check_wrapper_plumb_through "
528
- f"cap reached at {MAXIMUM_ISSUES_TO_REPORT} issues for {file_path}; "
529
- "additional matches were dropped.",
530
- file=sys.stderr,
531
- )
532
- return issues
666
+ for each_finding in _wrapper_dropped_kwarg_findings(each_node, function_signatures):
667
+ issues.append(each_finding)
668
+ if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
669
+ return issues
533
670
  return issues
534
671
 
535
672
 
@@ -639,8 +776,8 @@ def whole_file_line_set(file_path: Path) -> set[int]:
639
776
  "no lines changed" and silently downgrades blocking violations.
640
777
  """
641
778
  try:
642
- total_lines = len(file_path.read_text().splitlines())
643
- except OSError as read_error:
779
+ total_lines = len(file_path.read_text(encoding="utf-8").splitlines())
780
+ except (OSError, UnicodeDecodeError) as read_error:
644
781
  print(
645
782
  f"{BUGTEAM_CODE_RULES_GATE_PREFIX}whole_file_line_set could not read "
646
783
  f"{file_path}: {type(read_error).__name__}: {read_error}",
@@ -703,24 +840,146 @@ def extract_violation_line_number(violation_text: str) -> int | None:
703
840
  return int(match_result.group(1))
704
841
 
705
842
 
843
+ def function_length_span_range(violation_text: str) -> range | None:
844
+ """Return the declared line range of a function-length violation, or None.
845
+
846
+ The enforcer's function-length message carries the definition line and
847
+ the function's line span: ``Function 'NAME' (defined at line X) is Y
848
+ lines - ...``. The function occupies lines ``X`` through ``X + Y - 1``
849
+ inclusive.
850
+
851
+ Args:
852
+ violation_text: A single violation string emitted by the enforcer.
853
+
854
+ Returns:
855
+ A ``range`` covering the function's declared line span, or None when
856
+ the text is not a function-length violation.
857
+ """
858
+ span_match = FUNCTION_LENGTH_VIOLATION_PATTERN.search(violation_text)
859
+ if span_match is None:
860
+ return None
861
+ definition_line = int(span_match.group(FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX))
862
+ line_span = int(span_match.group(FUNCTION_LENGTH_SPAN_GROUP_INDEX))
863
+ return range(definition_line, definition_line + line_span)
864
+
865
+
866
+ def isolation_span_range(violation_text: str) -> range | None:
867
+ """Return the enclosing test-function line range of an isolation violation.
868
+
869
+ The enforcer's HOME/TMP isolation message carries the enclosing test
870
+ function's definition line and span: ``Line N: Test 'NAME' (defined at
871
+ line X, spanning Y lines) probes ...``. The function occupies lines ``X``
872
+ through ``X + Y - 1`` inclusive, so a signature-line change that
873
+ un-isolates an unchanged-body probe is scoped by the same span the
874
+ enforcer uses rather than by the ``Line N:`` probe line alone.
875
+
876
+ Args:
877
+ violation_text: A single violation string emitted by the enforcer.
878
+
879
+ Returns:
880
+ A ``range`` covering the enclosing test function's declared line span,
881
+ or None when the text is not an isolation violation.
882
+ """
883
+ span_match = ISOLATION_VIOLATION_PATTERN.search(violation_text)
884
+ if span_match is None:
885
+ return None
886
+ definition_line = int(span_match.group(ISOLATION_DEFINITION_LINE_GROUP_INDEX))
887
+ line_span = int(span_match.group(ISOLATION_SPAN_GROUP_INDEX))
888
+ return range(definition_line, definition_line + line_span)
889
+
890
+
891
+ def banned_noun_span_range(violation_text: str) -> range | None:
892
+ """Return the one-line binding span of a banned-noun violation, or None.
893
+
894
+ The enforcer's banned-noun message carries the binding line and a one-line
895
+ span: ``Line N: Identifier 'NAME' ... (binding span at line X, spanning 1
896
+ lines)``. A banned-noun binding is a point fact about one identifier, so the
897
+ span is always the binding line alone (``X`` through ``X``) — never the
898
+ enclosing function span. Scoping to the binding line keeps a pre-existing
899
+ parameter or local-name binding out of scope when an unrelated line of its
900
+ enclosing function is edited.
901
+
902
+ Args:
903
+ violation_text: A single violation string emitted by the enforcer.
904
+
905
+ Returns:
906
+ A ``range`` covering the binding's one-line span, or None when the text
907
+ is not a banned-noun violation.
908
+ """
909
+ span_match = BANNED_NOUN_VIOLATION_PATTERN.search(violation_text)
910
+ if span_match is None:
911
+ return None
912
+ definition_line = int(span_match.group(BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX))
913
+ line_span = int(span_match.group(BANNED_NOUN_SPAN_GROUP_INDEX))
914
+ return range(definition_line, definition_line + line_span)
915
+
916
+
917
+ def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
918
+ return (
919
+ function_length_span_range,
920
+ isolation_span_range,
921
+ banned_noun_span_range,
922
+ )
923
+
924
+
925
+ def enclosing_span_range(violation_text: str) -> range | None:
926
+ """Return the enclosing-unit line range of a span-tagged violation, or None.
927
+
928
+ Every diff-scoped enforcer check tags its message with an enclosing-unit
929
+ span fragment. This dispatcher tries each span extractor from
930
+ ``_all_span_range_extractors`` so the gate reconstructs every scoped
931
+ check's span through one shared mechanism — adding a new scoped check means
932
+ adding one extractor to that registry rather than threading a new branch
933
+ through ``split_violations_by_scope``.
934
+
935
+ Args:
936
+ violation_text: A single violation string emitted by the enforcer.
937
+
938
+ Returns:
939
+ The first non-None span range any extractor recovers, or None when the
940
+ text carries no enclosing-unit span fragment.
941
+ """
942
+ for each_extractor in _all_span_range_extractors():
943
+ span_range = each_extractor(violation_text)
944
+ if span_range is not None:
945
+ return span_range
946
+ return None
947
+
948
+
706
949
  def split_violations_by_scope(
707
950
  all_issues: list[str],
708
951
  all_added_line_numbers: set[int] | None,
709
952
  ) -> tuple[list[str], list[str]]:
710
- """Split violations into blocking and advisory groups by line number.
953
+ """Partition issues into blocking vs advisory based on touched lines.
711
954
 
712
955
  Args:
713
- all_issues: All violation messages to split.
714
- all_added_line_numbers: Set of added line numbers, or None for full-file scope.
956
+ all_issues: Violation strings emitted by the enforcer.
957
+ all_added_line_numbers: Lines added in the current diff, or None
958
+ to treat every violation as blocking.
715
959
 
716
960
  Returns:
717
- Tuple of (blocking_issues, advisory_issues).
961
+ Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
962
+ None, every issue is blocking. Every diff-scoped violation
963
+ (function-length, HOME/TMP isolation, banned-noun) carries an
964
+ enclosing-unit span fragment that ``enclosing_span_range`` reconstructs
965
+ through one shared extractor registry; such a violation is blocking
966
+ when its declared span intersects the added lines (the unit grew or its
967
+ signature changed in this diff) and advisory otherwise (a pre-existing
968
+ untouched unit). Every other issue is blocking when its ``Line N:``
969
+ prefix names an added line and advisory otherwise.
718
970
  """
719
971
  if all_added_line_numbers is None:
720
972
  return list(all_issues), []
721
973
  blocking: list[str] = []
722
974
  advisory: list[str] = []
723
975
  for each_issue in all_issues:
976
+ span_range = enclosing_span_range(each_issue)
977
+ if span_range is not None:
978
+ if any(each_line in all_added_line_numbers for each_line in span_range):
979
+ blocking.append(each_issue)
980
+ else:
981
+ advisory.append(each_issue)
982
+ continue
724
983
  violation_line = extract_violation_line_number(each_issue)
725
984
  if violation_line is None:
726
985
  blocking.append(each_issue)
@@ -753,11 +1012,142 @@ def print_violation_section(
753
1012
  print(f" {each_issue}", file=sys.stderr)
754
1013
 
755
1014
 
1015
+ def read_prior_committed_content(
1016
+ repository_root: Path, relative_path_posix: str
1017
+ ) -> str:
1018
+ """Return the HEAD-committed content for *relative_path_posix*.
1019
+
1020
+ Args:
1021
+ repository_root: The repository root for running git commands.
1022
+ relative_path_posix: POSIX-style relative path to read.
1023
+
1024
+ Returns:
1025
+ The committed file content at HEAD, or an empty string when the
1026
+ path is not tracked or ``git show`` returns non-zero.
1027
+ """
1028
+ git_show_process = subprocess.run(
1029
+ ["git", "show", f"HEAD:{relative_path_posix}"],
1030
+ cwd=str(repository_root),
1031
+ capture_output=True,
1032
+ text=True,
1033
+ encoding="utf-8",
1034
+ errors="replace",
1035
+ check=False,
1036
+ )
1037
+ if git_show_process.returncode != 0:
1038
+ return ""
1039
+ return git_show_process.stdout
1040
+
1041
+
1042
+ def read_staged_content(
1043
+ repository_root: Path, relative_path_posix: str
1044
+ ) -> str | None:
1045
+ """Return the staged-blob content for *relative_path_posix*.
1046
+
1047
+ Args:
1048
+ repository_root: The repository root for running git commands.
1049
+ relative_path_posix: POSIX-style relative path to read.
1050
+
1051
+ Returns:
1052
+ The staged blob content, or None when the path is not staged, when
1053
+ ``git show`` returns non-zero, or when the staged bytes are not
1054
+ decodable Unicode (the caller skips and fails closed).
1055
+ """
1056
+ git_show_process = subprocess.run(
1057
+ ["git", "show", f":{relative_path_posix}"],
1058
+ cwd=str(repository_root),
1059
+ capture_output=True,
1060
+ check=False,
1061
+ )
1062
+ if git_show_process.returncode != 0:
1063
+ return None
1064
+ try:
1065
+ return git_show_process.stdout.decode(encoding="utf-8")
1066
+ except UnicodeDecodeError:
1067
+ return None
1068
+
1069
+
1070
+ def staged_blob_exists(
1071
+ repository_root: Path, relative_path_posix: str
1072
+ ) -> bool:
1073
+ """Report whether *relative_path_posix* is present in the staged index.
1074
+
1075
+ Args:
1076
+ repository_root: The repository root for running git commands.
1077
+ relative_path_posix: POSIX-style relative path to probe.
1078
+
1079
+ Returns:
1080
+ True when the path is staged for add or modify (its blob exists in the
1081
+ index); False when it is absent, such as a staged deletion.
1082
+ """
1083
+ git_cat_file_process = subprocess.run(
1084
+ ["git", "cat-file", "-e", f":{relative_path_posix}"],
1085
+ cwd=str(repository_root),
1086
+ capture_output=True,
1087
+ check=False,
1088
+ )
1089
+ return git_cat_file_process.returncode == 0
1090
+
1091
+
1092
+ def _scoped_violations_for_file(
1093
+ validate_content: ValidateContentCallable,
1094
+ resolved_path: Path,
1095
+ repository_root: Path,
1096
+ all_added_lines_for_file: set[int] | None,
1097
+ read_staged_content_flag: bool = False,
1098
+ ) -> tuple[list[str], list[str]] | None:
1099
+ """Validate one resolved file and partition its violations by diff scope.
1100
+
1101
+ Args:
1102
+ validate_content: The validator function from code_rules_enforcer.
1103
+ resolved_path: The resolved code file to validate.
1104
+ repository_root: The repository root for relative path resolution.
1105
+ all_added_lines_for_file: Lines added in the current diff for this file,
1106
+ or None to treat every violation as blocking.
1107
+ read_staged_content_flag: When True, source the content from the staged
1108
+ blob so it matches the staged diff that scoped the added lines.
1109
+
1110
+ Returns:
1111
+ ``(blocking, advisory)`` for the file, or None when the file could not
1112
+ be read (the caller logs the skip and counts it).
1113
+ """
1114
+ relative_posix = str(
1115
+ resolved_path.relative_to(repository_root.resolve())
1116
+ ).replace("\\", "/")
1117
+ if read_staged_content_flag:
1118
+ staged_content = read_staged_content(repository_root.resolve(), relative_posix)
1119
+ if staged_content is None:
1120
+ print(f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skip unreadable {resolved_path}", file=sys.stderr)
1121
+ return None
1122
+ content = staged_content
1123
+ else:
1124
+ try:
1125
+ content = resolved_path.read_text(encoding="utf-8")
1126
+ except (OSError, UnicodeDecodeError):
1127
+ print(f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skip unreadable {resolved_path}", file=sys.stderr)
1128
+ return None
1129
+ prior_content = read_prior_committed_content(
1130
+ repository_root.resolve(), relative_posix
1131
+ )
1132
+ issues = validate_content(
1133
+ content,
1134
+ relative_posix,
1135
+ old_content=prior_content,
1136
+ defer_scope_to_caller=True,
1137
+ )
1138
+ issues.extend(check_database_column_string_magic(content, relative_posix))
1139
+ issues.extend(check_wrapper_plumb_through(content, relative_posix))
1140
+ if not issues:
1141
+ return [], []
1142
+ return split_violations_by_scope(issues, all_added_lines_for_file)
1143
+
1144
+
756
1145
  def run_gate(
757
1146
  validate_content: ValidateContentCallable,
758
1147
  all_file_paths: list[Path],
759
1148
  repository_root: Path,
760
1149
  all_added_lines_map: dict[Path, set[int]] | None,
1150
+ read_staged_content_flag: bool = False,
761
1151
  ) -> int:
762
1152
  """Run the CODE_RULES gate on a set of file paths.
763
1153
 
@@ -770,6 +1160,8 @@ def run_gate(
770
1160
  repository_root: The repository root for relative path resolution.
771
1161
  all_added_lines_map: Optional map of resolved path to added line numbers.
772
1162
  When provided, violations on added lines are blocking; others are advisory.
1163
+ read_staged_content_flag: When True, validate each file's staged blob
1164
+ so the content source matches the staged diff.
773
1165
 
774
1166
  Returns:
775
1167
  Zero when every targeted file was validated and no blocking
@@ -781,51 +1173,69 @@ def run_gate(
781
1173
  advisory_by_file: dict[Path, list[str]] = {}
782
1174
  skipped_unreadable_count = 0
783
1175
  for each_file_path in sorted(set(all_file_paths)):
784
- try:
785
- resolved = each_file_path.resolve()
786
- except OSError:
787
- continue
788
- try:
789
- resolved.relative_to(repository_root.resolve())
790
- except ValueError:
791
- continue
792
- if not is_code_path(resolved):
793
- continue
794
- if not resolved.is_file():
1176
+ resolved = _resolve_eligible_code_path(
1177
+ each_file_path, repository_root, read_staged_content_flag
1178
+ )
1179
+ if resolved is None:
795
1180
  continue
796
- try:
797
- content = resolved.read_text(encoding="utf-8")
798
- except OSError:
799
- print(f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skip unreadable {resolved}", file=sys.stderr)
1181
+ all_added_lines_for_file = (
1182
+ None if all_added_lines_map is None else all_added_lines_map.get(resolved)
1183
+ )
1184
+ scoped_violations = _scoped_violations_for_file(
1185
+ validate_content, resolved, repository_root,
1186
+ all_added_lines_for_file, read_staged_content_flag,
1187
+ )
1188
+ if scoped_violations is None:
800
1189
  skipped_unreadable_count += 1
801
1190
  continue
802
- relative = resolved.relative_to(repository_root.resolve())
803
- issues = validate_content(content, str(relative).replace("\\", "/"), old_content=content)
804
- issues.extend(check_database_column_string_magic(content, str(relative).replace("\\", "/")))
805
- issues.extend(check_wrapper_plumb_through(content, str(relative).replace("\\", "/")))
806
- if not issues:
807
- continue
808
- added_for_file = None if all_added_lines_map is None else all_added_lines_map.get(resolved)
809
- blocking, advisory = split_violations_by_scope(issues, added_for_file)
1191
+ blocking, advisory = scoped_violations
810
1192
  if blocking:
811
1193
  blocking_by_file[resolved] = blocking
812
1194
  if advisory:
813
1195
  advisory_by_file[resolved] = advisory
1196
+ return _report_partitioned_violations(
1197
+ blocking_by_file,
1198
+ advisory_by_file,
1199
+ repository_root,
1200
+ all_added_lines_map is None,
1201
+ skipped_unreadable_count,
1202
+ )
1203
+
1204
+
1205
+ def _report_partitioned_violations(
1206
+ blocking_by_file: dict[Path, list[str]],
1207
+ advisory_by_file: dict[Path, list[str]],
1208
+ repository_root: Path,
1209
+ is_whole_file_scope: bool,
1210
+ skipped_unreadable_count: int,
1211
+ ) -> int:
1212
+ """Print the blocking and advisory sections and return the gate exit code.
1213
+
1214
+ Args:
1215
+ blocking_by_file: Blocking violations grouped by resolved file path.
1216
+ advisory_by_file: Advisory violations grouped by resolved file path.
1217
+ repository_root: Repository root used to compute relative paths.
1218
+ is_whole_file_scope: True when no per-file added-line map was supplied,
1219
+ which selects the whole-file header wording.
1220
+ skipped_unreadable_count: Count of files that could not be read; a
1221
+ non-zero count forces a non-zero exit because the gate cannot
1222
+ vouch for those files.
1223
+
1224
+ Returns:
1225
+ Zero when no blocking violations were found and no file was skipped;
1226
+ non-zero otherwise.
1227
+ """
814
1228
  blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
815
1229
  advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
816
1230
  if blocking_count:
817
- if all_added_lines_map is None:
1231
+ if is_whole_file_scope:
818
1232
  header = f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{blocking_count} violation(s) reported."
819
1233
  else:
820
1234
  header = (
821
1235
  f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{blocking_count} violation(s) "
822
1236
  "introduced on changed lines:"
823
1237
  )
824
- print_violation_section(
825
- header,
826
- blocking_by_file,
827
- repository_root,
828
- )
1238
+ print_violation_section(header, blocking_by_file, repository_root)
829
1239
  if advisory_count:
830
1240
  if blocking_count:
831
1241
  print("", file=sys.stderr)
@@ -942,6 +1352,7 @@ def main(all_arguments: list[str]) -> int:
942
1352
  staged_file_paths,
943
1353
  repository_root,
944
1354
  all_added_lines_map=staged_added_lines,
1355
+ read_staged_content_flag=True,
945
1356
  )
946
1357
  all_diff_paths = paths_from_git_diff(repository_root, arguments.base)
947
1358
  all_diff_paths = filter_paths_under_prefixes(