claude-dev-env 1.44.0 → 1.46.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 (44) 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/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  5. package/_shared/pr-loop/scripts/reviews_disabled.py +82 -9
  6. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +630 -21
  7. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +15 -0
  8. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +57 -0
  9. package/agents/clean-coder.md +7 -1
  10. package/agents/code-quality-agent.md +8 -5
  11. package/hooks/blocking/code_rules_enforcer.py +1562 -37
  12. package/hooks/blocking/content_search_zoekt_redirect_guidance.py +19 -0
  13. package/hooks/blocking/open_questions_in_plans_blocker.py +249 -0
  14. package/hooks/blocking/test_code_rules_enforcer.py +1389 -0
  15. package/hooks/blocking/test_code_rules_enforcer_banned_noun_word.py +292 -0
  16. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +46 -8
  17. package/hooks/blocking/test_code_rules_enforcer_exempt_marker_chained.py +189 -0
  18. package/hooks/blocking/test_code_rules_enforcer_function_length.py +210 -0
  19. package/hooks/blocking/test_code_rules_enforcer_tests_isolate_home_temp.py +1512 -0
  20. package/hooks/blocking/test_code_rules_enforcer_unused_imports.py +9 -5
  21. package/hooks/blocking/test_content_search_to_zoekt_redirector_unit.py +30 -0
  22. package/hooks/blocking/test_open_questions_in_plans_blocker.py +790 -0
  23. package/hooks/hooks.json +10 -0
  24. package/hooks/hooks_constants/banned_identifiers_constants.py +19 -0
  25. package/hooks/hooks_constants/code_rules_enforcer_constants.py +129 -2
  26. package/hooks/hooks_constants/open_questions_in_plans_blocker_constants.py +35 -0
  27. package/hooks/hooks_constants/test_open_questions_in_plans_blocker_constants.py +125 -0
  28. package/package.json +1 -1
  29. package/skills/_shared/pr-loop/scripts/_path_resolver.py +34 -13
  30. package/skills/_shared/pr-loop/scripts/init_loop_state.py +1 -2
  31. package/skills/_shared/pr-loop/scripts/teardown_worktrees.py +1 -4
  32. package/skills/_shared/pr-loop/scripts/test__path_resolver.py +57 -0
  33. package/skills/_shared/pr-loop/scripts/test_init_loop_state.py +48 -0
  34. package/skills/_shared/pr-loop/scripts/test_teardown_worktrees.py +59 -0
  35. package/skills/bugteam/PROMPTS.md +48 -12
  36. package/skills/bugteam/reference/team-setup.md +4 -2
  37. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +487 -76
  38. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +22 -1
  39. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +602 -12
  40. package/skills/pr-converge/SKILL.md +5 -0
  41. package/skills/pr-converge/reference/per-tick.md +14 -5
  42. package/skills/pr-converge/reference/state-schema.md +7 -3
  43. package/skills/pr-converge/scripts/check_convergence.py +27 -1
  44. package/skills/pr-converge/scripts/test_check_convergence.py +28 -0
package/CLAUDE.md CHANGED
@@ -7,6 +7,15 @@ The user delegates execution to you and expects zero manual steps unless strictl
7
7
 
8
8
  ALWAYS call the AskUserQuestion tool if you have a question for the user. Provide content-appropriate default options, with a flag for the recommended one.
9
9
 
10
+ ## Timeless Documentation (all `.md` files)
11
+
12
+ Every Markdown file I write or edit describes the system's **current** state only. The test: a reader a year out, with zero prior context, finds every sentence true and complete without knowing what came before. State what **is**, not what changed — git history records change; docs record the contract.
13
+
14
+ - No historical/transitional language (`previously`, `now uses`, `instead of`, `migrated from`, `used to`, `no longer`, `as of`, `originally`, version-transition narration).
15
+ - No references to the conversation that produced the doc (`as discussed`, `Option A`, `after Round 3`).
16
+
17
+ Full banned-pattern set + enforcement: `~/.claude/rules/no-historical-clutter.md` (hook `state-description-blocker`) and `~/.claude/rules/self-contained-docs.md`.
18
+
10
19
  ## GOTCHAS
11
20
  When making code changes, make sure you are working in the proper worktree path for the task at hand.
12
21
  When writing to an existing file, you must either EDIT the file, or remove it and THEN re-write it if it's truly a full re-write.
@@ -19,8 +19,17 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
19
19
  ALL_TEST_FILENAME_SUFFIXES,
20
20
  EXPECTED_NON_RENAME_COLUMN_COUNT,
21
21
  EXPECTED_RENAME_COLUMN_COUNT,
22
+ BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
23
+ BANNED_NOUN_SPAN_GROUP_INDEX,
24
+ BANNED_NOUN_VIOLATION_PATTERN,
25
+ FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX,
26
+ FUNCTION_LENGTH_SPAN_GROUP_INDEX,
27
+ FUNCTION_LENGTH_VIOLATION_PATTERN,
22
28
  GIT_NAME_STATUS_ADDED_PREFIX,
23
29
  GIT_NAME_STATUS_RENAMED_PREFIX,
30
+ ISOLATION_DEFINITION_LINE_GROUP_INDEX,
31
+ ISOLATION_SPAN_GROUP_INDEX,
32
+ ISOLATION_VIOLATION_PATTERN,
24
33
  MAX_VIOLATIONS_PER_CHECK,
25
34
  PYTHON_FILE_EXTENSION,
26
35
  TEST_CONFTEST_FILENAME,
@@ -29,7 +38,7 @@ from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
29
38
  )
30
39
 
31
40
 
32
- ValidateContentCallable = Callable[[str, str, str], list[str]]
41
+ ValidateContentCallable = Callable[..., list[str]]
33
42
 
34
43
 
35
44
  def hunk_header_pattern() -> re.Pattern[str]:
@@ -500,6 +509,63 @@ def _iter_calls_excluding_nested_functions(node: ast.AST) -> Iterator[ast.Call]:
500
509
  yield from _iter_calls_excluding_nested_functions(each_child)
501
510
 
502
511
 
512
+ def _module_level_optional_kwargs_by_name(tree: ast.Module) -> dict[str, set[str]]:
513
+ function_signatures: dict[str, set[str]] = {}
514
+ for each_node in ast.iter_child_nodes(tree):
515
+ if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
516
+ optional_kwargs: set[str] = set()
517
+ for each_kwonly, each_default in zip(
518
+ each_node.args.kwonlyargs, each_node.args.kw_defaults
519
+ ):
520
+ if each_default is not None:
521
+ optional_kwargs.add(each_kwonly.arg)
522
+ positional_defaults = each_node.args.defaults
523
+ positional_args_with_defaults = (
524
+ each_node.args.args[-len(positional_defaults):]
525
+ if positional_defaults
526
+ else []
527
+ )
528
+ for each_positional_arg in positional_args_with_defaults:
529
+ optional_kwargs.add(each_positional_arg.arg)
530
+ function_signatures[each_node.name] = optional_kwargs
531
+ return function_signatures
532
+
533
+
534
+ def _class_method_node_ids(tree: ast.Module) -> set[int]:
535
+ class_method_node_ids: set[int] = set()
536
+ for each_class_def in ast.walk(tree):
537
+ if not isinstance(each_class_def, ast.ClassDef):
538
+ continue
539
+ for each_class_body_node in each_class_def.body:
540
+ if isinstance(
541
+ each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
542
+ ):
543
+ class_method_node_ids.add(id(each_class_body_node))
544
+ return class_method_node_ids
545
+
546
+
547
+ def _wrapper_dropped_kwarg_findings(
548
+ wrapper_node: ast.FunctionDef | ast.AsyncFunctionDef,
549
+ kwargs_by_function_name: dict[str, set[str]],
550
+ ) -> Iterator[str]:
551
+ wrapper_kwargs = kwargs_by_function_name.get(wrapper_node.name, set())
552
+ for each_call in _iter_calls_excluding_nested_functions(wrapper_node):
553
+ if isinstance(each_call.func, ast.Name):
554
+ delegate_name = each_call.func.id
555
+ elif isinstance(each_call.func, ast.Attribute):
556
+ delegate_name = each_call.func.attr
557
+ else:
558
+ continue
559
+ delegate_kwargs = kwargs_by_function_name.get(delegate_name)
560
+ if delegate_kwargs is None:
561
+ continue
562
+ missing = delegate_kwargs - wrapper_kwargs
563
+ if missing:
564
+ yield (
565
+ f"Line {wrapper_node.lineno}: Wrapper {wrapper_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
566
+ )
567
+
568
+
503
569
  def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
504
570
  """Flag calls inside public functions that drop a same-file delegate's optional kwargs.
505
571
 
@@ -510,14 +576,10 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
510
576
  capped at MAX_VIOLATIONS_PER_CHECK findings per call to run_gate.
511
577
 
512
578
  Limitations:
513
- - Only module-level FunctionDef nodes contribute signatures. Methods
514
- defined inside ClassDef bodies are ignored so cross-class same-name
515
- methods cannot overwrite a module-level delegate's signature index.
516
- - Methods defined inside ClassDef bodies are also skipped as wrapper
517
- candidates. A class method that calls a module-level delegate has a
518
- signature unrelated to that delegate's keyword-argument surface, so
519
- treating it as a wrapper produces false positives that flag every
520
- class method calling a free-function delegate with optional kwargs.
579
+ - Only module-level FunctionDef nodes contribute signatures, and ClassDef
580
+ methods are skipped both as signature sources and as wrapper candidates:
581
+ a class method's signature is unrelated to a free-function delegate's
582
+ keyword surface, so treating it as a wrapper produces false positives.
521
583
  - ast.Attribute calls match by attribute name only; the receiver type is
522
584
  not checked, so `self.fetch(...)` and `other.fetch(...)` both match a
523
585
  module-level `fetch` definition.
@@ -546,33 +608,8 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
546
608
  tree = ast.parse(content)
547
609
  except SyntaxError:
548
610
  return []
549
- function_signatures: dict[str, set[str]] = {}
550
- for each_node in ast.iter_child_nodes(tree):
551
- if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
552
- optional_kwargs: set[str] = set()
553
- for each_kwonly, each_default in zip(
554
- each_node.args.kwonlyargs, each_node.args.kw_defaults
555
- ):
556
- if each_default is not None:
557
- optional_kwargs.add(each_kwonly.arg)
558
- positional_defaults = each_node.args.defaults
559
- positional_args_with_defaults = (
560
- each_node.args.args[-len(positional_defaults):]
561
- if positional_defaults
562
- else []
563
- )
564
- for each_positional_arg in positional_args_with_defaults:
565
- optional_kwargs.add(each_positional_arg.arg)
566
- function_signatures[each_node.name] = optional_kwargs
567
- class_method_node_ids: set[int] = set()
568
- for each_class_def in ast.walk(tree):
569
- if not isinstance(each_class_def, ast.ClassDef):
570
- continue
571
- for each_class_body_node in each_class_def.body:
572
- if isinstance(
573
- each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
574
- ):
575
- class_method_node_ids.add(id(each_class_body_node))
611
+ function_signatures = _module_level_optional_kwargs_by_name(tree)
612
+ class_method_node_ids = _class_method_node_ids(tree)
576
613
  issues: list[str] = []
577
614
  for each_node in ast.walk(tree):
578
615
  if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
@@ -581,24 +618,10 @@ def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
581
618
  continue
582
619
  if each_node.name.startswith("_"):
583
620
  continue
584
- wrapper_kwargs = function_signatures.get(each_node.name, set())
585
- for each_call in _iter_calls_excluding_nested_functions(each_node):
586
- if isinstance(each_call.func, ast.Name):
587
- delegate_name = each_call.func.id
588
- elif isinstance(each_call.func, ast.Attribute):
589
- delegate_name = each_call.func.attr
590
- else:
591
- continue
592
- delegate_kwargs = function_signatures.get(delegate_name)
593
- if delegate_kwargs is None:
594
- continue
595
- missing = delegate_kwargs - wrapper_kwargs
596
- if missing:
597
- issues.append(
598
- f"Line {each_node.lineno}: Wrapper {each_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
599
- )
600
- if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
601
- return issues
621
+ for each_finding in _wrapper_dropped_kwarg_findings(each_node, function_signatures):
622
+ issues.append(each_finding)
623
+ if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
624
+ return issues
602
625
  return issues
603
626
 
604
627
 
@@ -909,6 +932,112 @@ def extract_violation_line_number(violation_text: str) -> int | None:
909
932
  return int(match_result.group(1))
910
933
 
911
934
 
935
+ def function_length_span_range(violation_text: str) -> range | None:
936
+ """Return the declared line range of a function-length violation, or None.
937
+
938
+ The enforcer's function-length message carries the definition line and
939
+ the function's line span: ``Function 'NAME' (defined at line X) is Y
940
+ lines - ...``. The function occupies lines ``X`` through ``X + Y - 1``
941
+ inclusive.
942
+
943
+ Args:
944
+ violation_text: A single violation string emitted by the enforcer.
945
+
946
+ Returns:
947
+ A ``range`` covering the function's declared line span, or None when
948
+ the text is not a function-length violation.
949
+ """
950
+ span_match = FUNCTION_LENGTH_VIOLATION_PATTERN.search(violation_text)
951
+ if span_match is None:
952
+ return None
953
+ definition_line = int(span_match.group(FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX))
954
+ line_span = int(span_match.group(FUNCTION_LENGTH_SPAN_GROUP_INDEX))
955
+ return range(definition_line, definition_line + line_span)
956
+
957
+
958
+ def isolation_span_range(violation_text: str) -> range | None:
959
+ """Return the enclosing test-function line range of an isolation violation.
960
+
961
+ The enforcer's HOME/TMP isolation message carries the enclosing test
962
+ function's definition line and span: ``Line N: Test 'NAME' (defined at
963
+ line X, spanning Y lines) probes ...``. The function occupies lines ``X``
964
+ through ``X + Y - 1`` inclusive, so a signature-line change that
965
+ un-isolates an unchanged-body probe is scoped by the same span the
966
+ enforcer uses rather than by the ``Line N:`` probe line alone.
967
+
968
+ Args:
969
+ violation_text: A single violation string emitted by the enforcer.
970
+
971
+ Returns:
972
+ A ``range`` covering the enclosing test function's declared line span,
973
+ or None when the text is not an isolation violation.
974
+ """
975
+ span_match = ISOLATION_VIOLATION_PATTERN.search(violation_text)
976
+ if span_match is None:
977
+ return None
978
+ definition_line = int(span_match.group(ISOLATION_DEFINITION_LINE_GROUP_INDEX))
979
+ line_span = int(span_match.group(ISOLATION_SPAN_GROUP_INDEX))
980
+ return range(definition_line, definition_line + line_span)
981
+
982
+
983
+ def banned_noun_span_range(violation_text: str) -> range | None:
984
+ """Return the one-line binding span of a banned-noun violation, or None.
985
+
986
+ The enforcer's banned-noun message carries the binding line and a one-line
987
+ span: ``Line N: Identifier 'NAME' ... (binding span at line X, spanning 1
988
+ lines)``. A banned-noun binding is a point fact about one identifier, so the
989
+ span is always the binding line alone (``X`` through ``X``) — never the
990
+ enclosing function span. Scoping to the binding line keeps a pre-existing
991
+ parameter or local-name binding out of scope when an unrelated line of its
992
+ enclosing function is edited.
993
+
994
+ Args:
995
+ violation_text: A single violation string emitted by the enforcer.
996
+
997
+ Returns:
998
+ A ``range`` covering the binding's one-line span, or None when the text
999
+ is not a banned-noun violation.
1000
+ """
1001
+ span_match = BANNED_NOUN_VIOLATION_PATTERN.search(violation_text)
1002
+ if span_match is None:
1003
+ return None
1004
+ definition_line = int(span_match.group(BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX))
1005
+ line_span = int(span_match.group(BANNED_NOUN_SPAN_GROUP_INDEX))
1006
+ return range(definition_line, definition_line + line_span)
1007
+
1008
+
1009
+ def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
1010
+ return (
1011
+ function_length_span_range,
1012
+ isolation_span_range,
1013
+ banned_noun_span_range,
1014
+ )
1015
+
1016
+
1017
+ def enclosing_span_range(violation_text: str) -> range | None:
1018
+ """Return the enclosing-unit line range of a span-tagged violation, or None.
1019
+
1020
+ Every diff-scoped enforcer check tags its message with an enclosing-unit
1021
+ span fragment. This dispatcher tries each span extractor from
1022
+ ``_all_span_range_extractors`` so the gate reconstructs every scoped
1023
+ check's span through one shared mechanism — adding a new scoped check means
1024
+ adding one extractor to that registry rather than threading a new branch
1025
+ through ``split_violations_by_scope``.
1026
+
1027
+ Args:
1028
+ violation_text: A single violation string emitted by the enforcer.
1029
+
1030
+ Returns:
1031
+ The first non-None span range any extractor recovers, or None when the
1032
+ text carries no enclosing-unit span fragment.
1033
+ """
1034
+ for each_extractor in _all_span_range_extractors():
1035
+ span_range = each_extractor(violation_text)
1036
+ if span_range is not None:
1037
+ return span_range
1038
+ return None
1039
+
1040
+
912
1041
  def split_violations_by_scope(
913
1042
  all_issues: list[str],
914
1043
  all_added_line_numbers: set[int] | None,
@@ -922,15 +1051,27 @@ def split_violations_by_scope(
922
1051
 
923
1052
  Returns:
924
1053
  Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
925
- None, every issue is blocking; otherwise issues whose ``Line N:``
926
- prefix matches an added line are blocking and the rest are
927
- advisory.
1054
+ None, every issue is blocking. Every diff-scoped violation
1055
+ (function-length, HOME/TMP isolation, banned-noun) carries an
1056
+ enclosing-unit span fragment that ``enclosing_span_range`` reconstructs
1057
+ through one shared extractor registry; such a violation is blocking
1058
+ when its declared span intersects the added lines (the unit grew or its
1059
+ signature changed in this diff) and advisory otherwise (a pre-existing
1060
+ untouched unit). Every other issue is blocking when its ``Line N:``
1061
+ prefix names an added line and advisory otherwise.
928
1062
  """
929
1063
  if all_added_line_numbers is None:
930
1064
  return list(all_issues), []
931
1065
  blocking: list[str] = []
932
1066
  advisory: list[str] = []
933
1067
  for each_issue in all_issues:
1068
+ span_range = enclosing_span_range(each_issue)
1069
+ if span_range is not None:
1070
+ if any(each_line in all_added_line_numbers for each_line in span_range):
1071
+ blocking.append(each_issue)
1072
+ else:
1073
+ advisory.append(each_issue)
1074
+ continue
934
1075
  violation_line = extract_violation_line_number(each_issue)
935
1076
  if violation_line is None:
936
1077
  blocking.append(each_issue)
@@ -991,11 +1132,143 @@ def read_prior_committed_content(
991
1132
  return show_result.stdout
992
1133
 
993
1134
 
1135
+ def read_staged_content(
1136
+ repository_root: Path, relative_path_posix: str
1137
+ ) -> str | None:
1138
+ """Return the staged-blob content for *relative_path_posix*.
1139
+
1140
+ Args:
1141
+ repository_root: Repository root used as the ``git -C`` target.
1142
+ relative_path_posix: Repository-relative POSIX path to read.
1143
+
1144
+ Returns:
1145
+ The staged blob content, or None when the path is not staged, when
1146
+ ``git show`` returns non-zero, or when the staged bytes are not
1147
+ decodable Unicode (the caller skips and fails closed).
1148
+ """
1149
+ git_show_process = subprocess.run(
1150
+ ["git", "show", f":{relative_path_posix}"],
1151
+ cwd=str(repository_root),
1152
+ capture_output=True,
1153
+ check=False,
1154
+ )
1155
+ if git_show_process.returncode != 0:
1156
+ return None
1157
+ try:
1158
+ return git_show_process.stdout.decode(encoding="utf-8")
1159
+ except UnicodeDecodeError:
1160
+ return None
1161
+
1162
+
1163
+ def staged_blob_exists(
1164
+ repository_root: Path, relative_path_posix: str
1165
+ ) -> bool:
1166
+ """Report whether *relative_path_posix* is present in the staged index.
1167
+
1168
+ Args:
1169
+ repository_root: Repository root used as the ``git -C`` target.
1170
+ relative_path_posix: Repository-relative POSIX path to probe.
1171
+
1172
+ Returns:
1173
+ True when the path is staged for add or modify (its blob exists in the
1174
+ index); False when it is absent, such as a staged deletion.
1175
+ """
1176
+ git_cat_file_process = subprocess.run(
1177
+ ["git", "cat-file", "-e", f":{relative_path_posix}"],
1178
+ cwd=str(repository_root),
1179
+ capture_output=True,
1180
+ check=False,
1181
+ )
1182
+ return git_cat_file_process.returncode == 0
1183
+
1184
+
1185
+ def _path_is_eligible_for_validation(
1186
+ resolved_path: Path,
1187
+ repository_root: Path,
1188
+ read_staged_content_flag: bool,
1189
+ ) -> bool:
1190
+ """Decide whether *resolved_path* should be validated by the gate.
1191
+
1192
+ Args:
1193
+ resolved_path: A resolved candidate path already confirmed to live
1194
+ under *repository_root*.
1195
+ repository_root: Repository root used to compute the relative path.
1196
+ read_staged_content_flag: When True, require staged-index presence so
1197
+ files staged for add or modify are validated and staged deletions
1198
+ are skipped; when False, require working-tree presence.
1199
+
1200
+ Returns:
1201
+ True when the path carries a code extension and exists in the source
1202
+ the gate will read; False otherwise.
1203
+ """
1204
+ if not is_code_path(resolved_path):
1205
+ return False
1206
+ if read_staged_content_flag:
1207
+ relative_posix = str(
1208
+ resolved_path.relative_to(repository_root.resolve())
1209
+ ).replace("\\", "/")
1210
+ return staged_blob_exists(repository_root.resolve(), relative_posix)
1211
+ return resolved_path.is_file()
1212
+
1213
+
1214
+ def _scoped_violations_for_file(
1215
+ validate_content: ValidateContentCallable,
1216
+ resolved_path: Path,
1217
+ repository_root: Path,
1218
+ all_added_lines_for_file: set[int] | None,
1219
+ read_staged_content_flag: bool = False,
1220
+ ) -> tuple[list[str], list[str]] | None:
1221
+ """Validate one resolved file and partition its violations by diff scope.
1222
+
1223
+ Args:
1224
+ validate_content: The enforcer ``validate_content`` callable.
1225
+ resolved_path: The resolved code file to validate.
1226
+ repository_root: Repository root used to resolve the relative path.
1227
+ all_added_lines_for_file: Lines added in the current diff for this file,
1228
+ or None to treat every violation as blocking.
1229
+ read_staged_content_flag: When True, source the content from the staged
1230
+ blob so it matches the staged diff that scoped the added lines.
1231
+
1232
+ Returns:
1233
+ ``(blocking, advisory)`` for the file, or None when the file is
1234
+ unreadable (the caller logs and skips it).
1235
+ """
1236
+ relative_posix = str(
1237
+ resolved_path.relative_to(repository_root.resolve())
1238
+ ).replace("\\", "/")
1239
+ if read_staged_content_flag:
1240
+ staged_content = read_staged_content(repository_root.resolve(), relative_posix)
1241
+ if staged_content is None:
1242
+ print(f"code_rules_gate: skip unreadable {resolved_path}", file=sys.stderr)
1243
+ return None
1244
+ content = staged_content
1245
+ else:
1246
+ try:
1247
+ content = resolved_path.read_text(encoding="utf-8")
1248
+ except (OSError, UnicodeDecodeError):
1249
+ print(f"code_rules_gate: skip unreadable {resolved_path}", file=sys.stderr)
1250
+ return None
1251
+ prior_content = read_prior_committed_content(
1252
+ repository_root.resolve(), relative_posix
1253
+ )
1254
+ issues = validate_content(
1255
+ content,
1256
+ relative_posix,
1257
+ prior_content,
1258
+ defer_scope_to_caller=True,
1259
+ )
1260
+ issues.extend(check_wrapper_plumb_through(content, relative_posix))
1261
+ if not issues:
1262
+ return [], []
1263
+ return split_violations_by_scope(issues, all_added_lines_for_file)
1264
+
1265
+
994
1266
  def run_gate(
995
1267
  validate_content: ValidateContentCallable,
996
1268
  all_file_paths: list[Path],
997
1269
  repository_root: Path,
998
1270
  all_added_lines_by_path: dict[Path, set[int]] | None = None,
1271
+ read_staged_content_flag: bool = False,
999
1272
  ) -> int:
1000
1273
  """Run the gate over *all_file_paths* and emit a partitioned report.
1001
1274
 
@@ -1005,13 +1278,59 @@ def run_gate(
1005
1278
  repository_root: Repository root used to resolve relative paths.
1006
1279
  all_added_lines_by_path: Optional per-file added-line maps used to
1007
1280
  partition issues into blocking vs advisory.
1281
+ read_staged_content_flag: When True, validate each file's staged blob
1282
+ so the content source matches the staged diff.
1283
+
1284
+ Returns:
1285
+ Zero when every targeted file was validated and no blocking violation
1286
+ was found. Non-zero when any blocking violation was reported OR when
1287
+ one or more files could not be read (a skipped file means the gate
1288
+ could not vouch for it).
1289
+ """
1290
+ blocking_by_file, advisory_by_file, skipped_unreadable_count = (
1291
+ _collect_partitioned_violations(
1292
+ validate_content,
1293
+ all_file_paths,
1294
+ repository_root,
1295
+ all_added_lines_by_path,
1296
+ read_staged_content_flag,
1297
+ )
1298
+ )
1299
+ return _report_partitioned_violations(
1300
+ blocking_by_file,
1301
+ advisory_by_file,
1302
+ repository_root,
1303
+ all_added_lines_by_path is None,
1304
+ skipped_unreadable_count,
1305
+ )
1306
+
1307
+
1308
+ def _collect_partitioned_violations(
1309
+ validate_content: ValidateContentCallable,
1310
+ all_file_paths: list[Path],
1311
+ repository_root: Path,
1312
+ all_added_lines_by_path: dict[Path, set[int]] | None,
1313
+ read_staged_content_flag: bool = False,
1314
+ ) -> tuple[dict[Path, list[str]], dict[Path, list[str]], int]:
1315
+ """Validate every targeted file and partition results by diff scope.
1316
+
1317
+ Args:
1318
+ validate_content: The enforcer ``validate_content`` callable.
1319
+ all_file_paths: File paths to inspect.
1320
+ repository_root: Repository root used to resolve relative paths.
1321
+ all_added_lines_by_path: Optional per-file added-line maps used to
1322
+ partition issues into blocking vs advisory.
1323
+ read_staged_content_flag: When True, validate each file's staged blob
1324
+ so the content source matches the staged diff.
1008
1325
 
1009
1326
  Returns:
1010
- ``1`` when at least one blocking violation is reported, ``0``
1011
- otherwise.
1327
+ ``(blocking_by_file, advisory_by_file, skipped_unreadable_count)`` where
1328
+ the skipped count increments for every changed file that could not be
1329
+ read, so the caller can fail closed on unvalidated files.
1012
1330
  """
1013
1331
  blocking_by_file: dict[Path, list[str]] = {}
1014
1332
  advisory_by_file: dict[Path, list[str]] = {}
1333
+ skipped_unreadable_count = 0
1015
1334
  for each_path in sorted(set(all_file_paths)):
1016
1335
  try:
1017
1336
  resolved = each_path.resolve()
@@ -1021,49 +1340,64 @@ def run_gate(
1021
1340
  resolved.relative_to(repository_root.resolve())
1022
1341
  except ValueError:
1023
1342
  continue
1024
- if not is_code_path(resolved):
1025
- continue
1026
- if not resolved.is_file():
1027
- continue
1028
- try:
1029
- content = resolved.read_text(encoding="utf-8")
1030
- except (OSError, UnicodeDecodeError):
1031
- print(f"code_rules_gate: skip unreadable {resolved}", file=sys.stderr)
1032
- continue
1033
- relative = resolved.relative_to(repository_root.resolve())
1034
- relative_posix = str(relative).replace("\\", "/")
1035
- prior_content = read_prior_committed_content(
1036
- repository_root.resolve(), relative_posix
1037
- )
1038
- issues = validate_content(content, relative_posix, prior_content)
1039
- issues.extend(check_wrapper_plumb_through(content, relative_posix))
1040
- if not issues:
1343
+ if not _path_is_eligible_for_validation(
1344
+ resolved, repository_root, read_staged_content_flag
1345
+ ):
1041
1346
  continue
1042
- added_for_file = (
1347
+ all_added_lines_for_file = (
1043
1348
  None
1044
1349
  if all_added_lines_by_path is None
1045
1350
  else all_added_lines_by_path.get(resolved)
1046
1351
  )
1047
- blocking, advisory = split_violations_by_scope(issues, added_for_file)
1352
+ scoped_violations = _scoped_violations_for_file(
1353
+ validate_content, resolved, repository_root,
1354
+ all_added_lines_for_file, read_staged_content_flag,
1355
+ )
1356
+ if scoped_violations is None:
1357
+ skipped_unreadable_count += 1
1358
+ continue
1359
+ blocking, advisory = scoped_violations
1048
1360
  if blocking:
1049
1361
  blocking_by_file[resolved] = blocking
1050
1362
  if advisory:
1051
1363
  advisory_by_file[resolved] = advisory
1364
+ return blocking_by_file, advisory_by_file, skipped_unreadable_count
1365
+
1366
+
1367
+ def _report_partitioned_violations(
1368
+ blocking_by_file: dict[Path, list[str]],
1369
+ advisory_by_file: dict[Path, list[str]],
1370
+ repository_root: Path,
1371
+ is_whole_file_scope: bool,
1372
+ skipped_unreadable_count: int,
1373
+ ) -> int:
1374
+ """Print the blocking and advisory sections and return the gate exit code.
1375
+
1376
+ Args:
1377
+ blocking_by_file: Blocking violations grouped by resolved file path.
1378
+ advisory_by_file: Advisory violations grouped by resolved file path.
1379
+ repository_root: Repository root used to compute relative paths.
1380
+ is_whole_file_scope: True when no per-file added-line map was supplied,
1381
+ which selects the whole-file header wording.
1382
+ skipped_unreadable_count: Count of changed files that could not be read;
1383
+ a non-zero count forces a non-zero exit because the gate cannot
1384
+ vouch for those files.
1385
+
1386
+ Returns:
1387
+ Zero when no blocking violation was found and no file was skipped;
1388
+ non-zero otherwise.
1389
+ """
1052
1390
  blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
1053
1391
  advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
1054
1392
  if blocking_count:
1055
- if all_added_lines_by_path is None:
1393
+ if is_whole_file_scope:
1056
1394
  header = f"code_rules_gate: {blocking_count} violation(s) reported."
1057
1395
  else:
1058
1396
  header = (
1059
1397
  f"code_rules_gate: {blocking_count} violation(s) "
1060
1398
  "introduced on changed lines:"
1061
1399
  )
1062
- print_violation_section(
1063
- header,
1064
- blocking_by_file,
1065
- repository_root,
1066
- )
1400
+ print_violation_section(header, blocking_by_file, repository_root)
1067
1401
  if advisory_count:
1068
1402
  if blocking_count:
1069
1403
  print("", file=sys.stderr)
@@ -1075,7 +1409,13 @@ def run_gate(
1075
1409
  advisory_by_file,
1076
1410
  repository_root,
1077
1411
  )
1078
- if blocking_count:
1412
+ if skipped_unreadable_count:
1413
+ print(
1414
+ f"code_rules_gate: {skipped_unreadable_count} file(s) "
1415
+ "skipped due to read errors; gate cannot vouch for those files.",
1416
+ file=sys.stderr,
1417
+ )
1418
+ if blocking_count or skipped_unreadable_count:
1079
1419
  return 1
1080
1420
  return 0
1081
1421
 
@@ -1176,6 +1516,7 @@ def main(all_arguments: list[str]) -> int:
1176
1516
  staged_file_paths,
1177
1517
  repository_root,
1178
1518
  all_added_lines_by_path=staged_added_lines,
1519
+ read_staged_content_flag=True,
1179
1520
  )
1180
1521
  file_paths = paths_from_git_diff(repository_root, arguments.base)
1181
1522
  file_paths = filter_paths_under_prefixes(
@@ -1,8 +1,28 @@
1
1
  """Constants for code_rules_gate.py per CODE_RULES centralized-config rule."""
2
2
 
3
+ import re
4
+
3
5
  MAX_VIOLATIONS_PER_CHECK: int = 3
4
6
  EXPECTED_TUPLE_PAIR_LENGTH: int = 2
5
7
 
8
+ FUNCTION_LENGTH_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
9
+ r"\(defined at line (\d+)\) is (\d+) lines"
10
+ )
11
+ FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX: int = 1
12
+ FUNCTION_LENGTH_SPAN_GROUP_INDEX: int = 2
13
+
14
+ ISOLATION_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
15
+ r"\(defined at line (\d+), spanning (\d+) lines\)"
16
+ )
17
+ ISOLATION_DEFINITION_LINE_GROUP_INDEX: int = 1
18
+ ISOLATION_SPAN_GROUP_INDEX: int = 2
19
+
20
+ BANNED_NOUN_VIOLATION_PATTERN: re.Pattern[str] = re.compile(
21
+ r"\(binding span at line (\d+), spanning (\d+) lines\)"
22
+ )
23
+ BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX: int = 1
24
+ BANNED_NOUN_SPAN_GROUP_INDEX: int = 2
25
+
6
26
  ALL_CODE_FILE_EXTENSIONS: frozenset[str] = frozenset(
7
27
  {".py", ".js", ".ts", ".tsx", ".jsx"}
8
28
  )
@@ -5,4 +5,5 @@ from __future__ import annotations
5
5
  CLAUDE_REVIEWS_DISABLED_ENV_VAR_NAME: str = "CLAUDE_REVIEWS_DISABLED"
6
6
  CLAUDE_REVIEWS_DISABLED_TOKEN_SEPARATOR: str = ","
7
7
  CLAUDE_REVIEWS_DISABLED_BUGTEAM_TOKEN: str = "bugteam"
8
+ CLAUDE_REVIEWS_DISABLED_BUGBOT_TOKEN: str = "bugbot"
8
9
  EXIT_CODE_BUGTEAM_DISABLED_VIA_ENV: int = 7