claude-dev-env 1.95.0 → 2.0.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 (240) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +20 -20
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +15 -12
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +11 -10
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +25 -19
  6. package/_shared/advisor/scripts/tests/test_tier_model_ids.py +17 -17
  7. package/_shared/advisor/scripts/tier_model_ids.py +18 -18
  8. package/_shared/pr-loop/CLAUDE.md +1 -0
  9. package/_shared/pr-loop/scripts/CLAUDE.md +2 -1
  10. package/_shared/pr-loop/scripts/README.md +1 -0
  11. package/_shared/pr-loop/scripts/code_rules_gate.py +253 -1980
  12. package/_shared/pr-loop/scripts/code_rules_gate_parts/CLAUDE.md +32 -0
  13. package/_shared/pr-loop/scripts/code_rules_gate_parts/__init__.py +7 -0
  14. package/_shared/pr-loop/scripts/code_rules_gate_parts/added_line_maps.py +268 -0
  15. package/_shared/pr-loop/scripts/code_rules_gate_parts/enforcer_loading.py +172 -0
  16. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_arguments.py +70 -0
  17. package/_shared/pr-loop/scripts/code_rules_gate_parts/gate_running.py +326 -0
  18. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_blob_readers.py +85 -0
  19. package/_shared/pr-loop/scripts/code_rules_gate_parts/git_file_sets.py +331 -0
  20. package/_shared/pr-loop/scripts/code_rules_gate_parts/staged_test_running.py +369 -0
  21. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/conftest.py +14 -0
  22. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_added_line_maps.py +118 -0
  23. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +17 -0
  24. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_arguments.py +29 -0
  25. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_gate_running.py +99 -0
  26. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_blob_readers.py +69 -0
  27. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_git_file_sets.py +137 -0
  28. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_staged_test_running.py +116 -0
  29. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_violation_scoping.py +75 -0
  30. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_wrapper_plumb_check.py +49 -0
  31. package/_shared/pr-loop/scripts/code_rules_gate_parts/violation_scoping.py +328 -0
  32. package/_shared/pr-loop/scripts/code_rules_gate_parts/wrapper_plumb_check.py +206 -0
  33. package/_shared/pr-loop/scripts/pr_loop_shared_constants/code_rules_gate_constants.py +24 -17
  34. package/_shared/pr-loop/scripts/pr_loop_shared_constants/reviews_disabled_constants.py +1 -0
  35. package/_shared/pr-loop/scripts/reviews_disabled.py +19 -2
  36. package/_shared/pr-loop/scripts/test_code_rules_gate.py +278 -0
  37. package/_shared/pr-loop/scripts/tests/test_code_rules_gate_constants.py +6 -39
  38. package/_shared/pr-loop/scripts/tests/test_reviews_disabled.py +43 -0
  39. package/_shared/pr-loop/worker-spawn.md +186 -0
  40. package/agents/code-verifier.md +1 -1
  41. package/bin/ever-shipped-skills.mjs +3 -0
  42. package/bin/expand_home_directory_tokens.mjs +1 -1
  43. package/bin/install.mjs +5 -2
  44. package/hooks/advisory/refactor_guard.py +3 -4
  45. package/hooks/blocking/CLAUDE.md +7 -1
  46. package/hooks/blocking/block_main_commit.py +2 -2
  47. package/hooks/blocking/claude_md_orphan_file_blocker.py +75 -699
  48. package/hooks/blocking/claude_md_orphan_file_blocker_parts/CLAUDE.md +28 -0
  49. package/hooks/blocking/claude_md_orphan_file_blocker_parts/__init__.py +1 -0
  50. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/__init__.py +1 -0
  51. package/hooks/blocking/claude_md_orphan_file_blocker_parts/config/orphan_blocker_constants.py +18 -0
  52. package/hooks/blocking/claude_md_orphan_file_blocker_parts/decision.py +81 -0
  53. package/hooks/blocking/claude_md_orphan_file_blocker_parts/references.py +307 -0
  54. package/hooks/blocking/claude_md_orphan_file_blocker_parts/scan_plan.py +124 -0
  55. package/hooks/blocking/claude_md_orphan_file_blocker_parts/subtree_scan.py +179 -0
  56. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/conftest.py +10 -0
  57. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_decision.py +34 -0
  58. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_references.py +42 -0
  59. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_scan_plan.py +27 -0
  60. package/hooks/blocking/claude_md_orphan_file_blocker_parts/tests/test_subtree_scan.py +30 -0
  61. package/hooks/blocking/code_rules_boolean_mustcheck.py +1 -1
  62. package/hooks/blocking/code_rules_mock_completeness.py +1 -1
  63. package/hooks/blocking/code_rules_optional_params.py +2 -2
  64. package/hooks/blocking/code_rules_shared.py +1 -1
  65. package/hooks/blocking/code_rules_test_assertions.py +1 -1
  66. package/hooks/blocking/code_rules_typeddict_stub.py +1 -1
  67. package/hooks/blocking/gh_pr_author_enforcer.py +1 -1
  68. package/hooks/blocking/inventory_intent_records/CLAUDE.md +26 -0
  69. package/hooks/blocking/inventory_intent_records/__init__.py +1 -0
  70. package/hooks/blocking/inventory_intent_records/config/__init__.py +1 -0
  71. package/hooks/blocking/inventory_intent_records/config/intent_records_constants.py +20 -0
  72. package/hooks/blocking/inventory_intent_records/records.py +271 -0
  73. package/hooks/blocking/inventory_intent_records/tests/conftest.py +10 -0
  74. package/hooks/blocking/inventory_intent_records/tests/test_records.py +80 -0
  75. package/hooks/blocking/package_inventory_stale_blocker.py +54 -384
  76. package/hooks/blocking/package_inventory_stale_blocker_parts/CLAUDE.md +26 -0
  77. package/hooks/blocking/package_inventory_stale_blocker_parts/__init__.py +1 -0
  78. package/hooks/blocking/package_inventory_stale_blocker_parts/config/__init__.py +1 -0
  79. package/hooks/blocking/package_inventory_stale_blocker_parts/config/inventory_blocker_constants.py +16 -0
  80. package/hooks/blocking/package_inventory_stale_blocker_parts/decision.py +84 -0
  81. package/hooks/blocking/package_inventory_stale_blocker_parts/inventory_detection.py +307 -0
  82. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/conftest.py +10 -0
  83. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_decision.py +38 -0
  84. package/hooks/blocking/package_inventory_stale_blocker_parts/tests/test_inventory_detection.py +61 -0
  85. package/hooks/blocking/pii_payload_scan.py +138 -42
  86. package/hooks/blocking/pii_prevention_blocker.py +185 -291
  87. package/hooks/blocking/pii_prevention_blocker_parts/CLAUDE.md +24 -0
  88. package/hooks/blocking/pii_prevention_blocker_parts/__init__.py +1 -0
  89. package/hooks/blocking/pii_prevention_blocker_parts/config/__init__.py +1 -0
  90. package/hooks/blocking/pii_prevention_blocker_parts/config/repository_resolution_constants.py +28 -0
  91. package/hooks/blocking/pii_prevention_blocker_parts/repository_exemption.py +214 -0
  92. package/hooks/blocking/pii_prevention_blocker_parts/repository_resolution.py +208 -0
  93. package/hooks/blocking/pr_description_command_parser.py +8 -4
  94. package/hooks/blocking/precommit_code_rules_gate.py +3 -3
  95. package/hooks/blocking/tdd_enforcer.py +97 -608
  96. package/hooks/blocking/tdd_enforcer_parts/CLAUDE.md +30 -0
  97. package/hooks/blocking/tdd_enforcer_parts/__init__.py +1 -0
  98. package/hooks/blocking/tdd_enforcer_parts/candidate_paths.py +142 -0
  99. package/hooks/blocking/tdd_enforcer_parts/config/__init__.py +1 -0
  100. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +32 -0
  101. package/hooks/blocking/tdd_enforcer_parts/content_analysis.py +268 -0
  102. package/hooks/blocking/tdd_enforcer_parts/decisions.py +92 -0
  103. package/hooks/blocking/tdd_enforcer_parts/freshness.py +80 -0
  104. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +63 -0
  105. package/hooks/blocking/tdd_enforcer_parts/path_classification.py +119 -0
  106. package/hooks/blocking/tdd_enforcer_parts/tests/conftest.py +10 -0
  107. package/hooks/blocking/tdd_enforcer_parts/tests/test_candidate_paths.py +31 -0
  108. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_analysis.py +30 -0
  109. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +34 -0
  110. package/hooks/blocking/tdd_enforcer_parts/tests/test_freshness.py +28 -0
  111. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +48 -0
  112. package/hooks/blocking/tdd_enforcer_parts/tests/test_path_classification.py +36 -0
  113. package/hooks/blocking/test_inventory_deadlock_resolution.py +154 -0
  114. package/hooks/blocking/test_pii_payload_scan.py +168 -0
  115. package/hooks/blocking/test_tdd_enforcer_restore.py +108 -0
  116. package/hooks/blocking/tests/conftest.py +10 -0
  117. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  118. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  119. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  120. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  121. package/hooks/git-hooks/post_commit.py +3 -4
  122. package/hooks/hooks_constants/CLAUDE.md +2 -2
  123. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  124. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  125. package/hooks/hooks_constants/local_identity.py +59 -8
  126. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  127. package/hooks/hooks_constants/test_local_identity.py +105 -3
  128. package/hooks/pyproject.toml +13 -36
  129. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  130. package/hooks/validation/mypy_validator.py +2 -2
  131. package/hooks/validators/health_check.py +1 -0
  132. package/hooks/validators/mypy_integration.py +2 -0
  133. package/hooks/validators/ruff_integration.py +3 -0
  134. package/hooks/workflow/auto_formatter.py +5 -4
  135. package/package.json +1 -1
  136. package/scripts/CLAUDE.md +4 -0
  137. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  138. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  139. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  140. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  141. package/scripts/grok_headless_runner.py +294 -0
  142. package/scripts/grok_worker_preflight.py +410 -0
  143. package/scripts/invoke_code_review.py +463 -0
  144. package/scripts/resolve_worker_spawn.py +619 -0
  145. package/scripts/spawn_grok_batch.py +672 -0
  146. package/scripts/test_grok_headless_runner.py +626 -0
  147. package/scripts/test_grok_worker_preflight.py +1054 -0
  148. package/scripts/test_invoke_code_review.py +672 -0
  149. package/scripts/test_resolve_worker_spawn.py +1014 -0
  150. package/scripts/test_spawn_grok_batch.py +1017 -0
  151. package/skills/CLAUDE.md +5 -3
  152. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  153. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  154. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  155. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  157. package/skills/autoconverge/CLAUDE.md +3 -3
  158. package/skills/autoconverge/SKILL.md +9 -3
  159. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  160. package/skills/autoconverge/reference/convergence.md +33 -11
  161. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  162. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  163. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  164. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  165. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  166. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  167. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  168. package/skills/autoconverge/workflow/converge.mjs +200 -16
  169. package/skills/bugteam/CLAUDE.md +2 -2
  170. package/skills/bugteam/CONSTRAINTS.md +3 -2
  171. package/skills/bugteam/PROMPTS.md +7 -6
  172. package/skills/bugteam/SKILL.md +18 -13
  173. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  174. package/skills/bugteam/reference/design-rationale.md +1 -1
  175. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  176. package/skills/bugteam/reference/team-setup.md +8 -2
  177. package/skills/codex-review/CLAUDE.md +46 -0
  178. package/skills/codex-review/SKILL.md +181 -0
  179. package/skills/codex-review/reference/CLAUDE.md +15 -0
  180. package/skills/codex-review/reference/cli-contract.md +253 -0
  181. package/skills/codex-review/reference/loop-integration.md +118 -0
  182. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  183. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  189. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  190. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  191. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  193. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  194. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  195. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  196. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  197. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  199. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  200. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  201. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  202. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  203. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  204. package/skills/codex-review/test_skill_scaffold.py +192 -0
  205. package/skills/grok-spawn/CLAUDE.md +28 -0
  206. package/skills/grok-spawn/SKILL.md +226 -0
  207. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  208. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  209. package/skills/grokify/SKILL.md +9 -1
  210. package/skills/grokify/capability-claims.test.mjs +28 -0
  211. package/skills/grokify/evals/README.md +72 -0
  212. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  213. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  214. package/skills/orchestrator/SKILL.md +15 -11
  215. package/skills/orchestrator-refresh/SKILL.md +5 -5
  216. package/skills/pr-converge/SKILL.md +34 -13
  217. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  218. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  219. package/skills/pr-converge/reference/ground-rules.md +1 -1
  220. package/skills/pr-converge/reference/per-tick.md +130 -42
  221. package/skills/pr-converge/reference/state-schema.md +10 -0
  222. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  223. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  224. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  225. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  226. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  227. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  228. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  229. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  230. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  231. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  232. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  234. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  235. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  236. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  237. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  238. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  239. package/skills/reviewer-gates/SKILL.md +7 -5
  240. package/skills/team-advisor/SKILL.md +7 -7
@@ -1,1500 +1,109 @@
1
- import argparse
2
- import ast
3
- import importlib.util
4
- import os
5
- import re
6
- import subprocess
7
- import sys
8
- from collections.abc import Callable, Iterator
9
- from pathlib import Path
10
-
11
- parent_directory = str(Path(__file__).resolve().parent)
12
- if parent_directory not in sys.path:
13
- sys.path.insert(0, parent_directory)
14
-
15
- from pr_loop_shared_constants.code_rules_gate_constants import ( # noqa: E402
16
- ALL_CODE_FILE_EXTENSIONS,
17
- ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND,
18
- ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX,
19
- ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
20
- ALL_PYTEST_CONFIG_FILE_SECTIONS,
21
- ALL_PYTEST_MODULE_INVOCATION,
22
- ALL_TEST_FILENAME_GLOB_SUFFIXES,
23
- ALL_TEST_FILENAME_SUFFIXES,
24
- ALL_VENV_DIRECTORY_NAMES,
25
- ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
26
- BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
27
- BANNED_NOUN_SPAN_GROUP_INDEX,
28
- BANNED_NOUN_VIOLATION_PATTERN,
29
- CODE_RULES_GATE_PYTHON_ENV_VAR,
30
- CODE_RULES_GATE_PYTHONPATH_ENV_VAR,
31
- COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH,
32
- DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX,
33
- DUPLICATE_BODY_SPAN_GROUP_INDEX,
34
- DUPLICATE_BODY_VIOLATION_PATTERN,
35
- EXPECTED_NON_RENAME_COLUMN_COUNT,
36
- EXPECTED_RENAME_COLUMN_COUNT,
37
- FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX,
38
- FUNCTION_LENGTH_SPAN_GROUP_INDEX,
39
- FUNCTION_LENGTH_VIOLATION_PATTERN,
40
- GIT_NAME_STATUS_ADDED_PREFIX,
41
- GIT_NAME_STATUS_RENAMED_PREFIX,
42
- ISOLATION_DEFINITION_LINE_GROUP_INDEX,
43
- ISOLATION_SPAN_GROUP_INDEX,
44
- ISOLATION_VIOLATION_PATTERN,
45
- MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
46
- MAX_VIOLATIONS_PER_CHECK,
47
- MINIMUM_STAGED_PYTEST_PYTHON_MAJOR,
48
- MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
49
- PYTHON_FILE_EXTENSION,
50
- PYTHONPATH_ENV_VAR,
51
- STAGED_PYTEST_TIMEOUT_SECONDS,
52
- STAGED_TEST_FAILURE_HEADER,
53
- STAGED_TEST_GROUP_FAILURE_MESSAGE,
54
- TEST_CONFTEST_FILENAME,
55
- TEST_FILENAME_PREFIX,
56
- TESTS_PATH_SEGMENT,
57
- )
58
- from pr_loop_shared_constants.preflight_constants import ( # noqa: E402
59
- PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
60
- )
61
- from pr_loop_shared_constants.inline_duplicate_body_span_constants import ( # noqa: E402
62
- INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX,
63
- INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX,
64
- INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX,
65
- INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX,
66
- INLINE_DUPLICATE_BODY_VIOLATION_PATTERN,
67
- )
68
- from pr_loop_shared_constants.terminology_sweep_constants import ( # noqa: E402
69
- TERMINOLOGY_SWEEP_GATE_HEADER,
70
- )
71
- from terminology_sweep import ( # noqa: E402
72
- repository_environment,
73
- staged_terminology_findings,
74
- )
75
-
76
- ValidateContentCallable = Callable[..., list[str]]
77
-
78
-
79
- def hunk_header_pattern() -> re.Pattern[str]:
80
- return re.compile(r"^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@")
81
-
82
-
83
- def violation_line_pattern() -> re.Pattern[str]:
84
- return re.compile(r"^Line (\d+):")
85
-
86
-
87
- def resolve_claude_dev_env_root(starting_path: Path) -> Path:
88
- """Walk up from *starting_path* to the claude-dev-env package root.
89
-
90
- Args:
91
- starting_path: A path inside the worktree; the function climbs to
92
- find the ancestor containing ``hooks/blocking/code_rules_enforcer.py``.
93
-
94
- Returns:
95
- The resolved package root that contains the enforcer file.
96
-
97
- Raises:
98
- SystemExit: When no ancestor contains the enforcer.
99
- """
100
- starting = Path(starting_path).resolve()
101
- enforcer_relative = Path("hooks") / "blocking" / "code_rules_enforcer.py"
102
- for each_candidate in [starting, *starting.parents]:
103
- if (each_candidate / enforcer_relative).is_file():
104
- return each_candidate
105
- print(
106
- f"code_rules_gate: could not locate {enforcer_relative} above {starting}",
107
- file=sys.stderr,
108
- )
109
- raise SystemExit(2)
110
-
111
-
112
- def _resolve_package_root_absolute(starting_path: Path) -> Path:
113
- enforcer_relative = Path("hooks") / "blocking" / "code_rules_enforcer.py"
114
- for each_starting_form in (
115
- Path(starting_path).absolute(),
116
- Path(starting_path).resolve(),
117
- ):
118
- for each_candidate in [each_starting_form, *each_starting_form.parents]:
119
- if (each_candidate / enforcer_relative).is_file():
120
- return each_candidate
121
- raise SystemExit(2)
122
-
123
-
124
- def load_validate_content() -> ValidateContentCallable:
125
- """Load ``code_rules_enforcer.validate_content`` for in-process use.
126
-
127
- Returns:
128
- The ``validate_content`` callable from the enforcer module.
129
-
130
- Raises:
131
- SystemExit: When the package root cannot be located or the
132
- enforcer module cannot be loaded from disk.
133
- """
134
- package_root = resolve_claude_dev_env_root(Path(__file__).resolve())
135
- enforcer_path = package_root / "hooks" / "blocking" / "code_rules_enforcer.py"
136
- if not enforcer_path.is_file():
137
- message = f"code_rules_gate: missing enforcer at {enforcer_path}"
138
- print(message, file=sys.stderr)
139
- raise SystemExit(2)
140
- specification = importlib.util.spec_from_file_location(
141
- "code_rules_enforcer",
142
- enforcer_path,
143
- )
144
- if specification is None or specification.loader is None:
145
- print("code_rules_gate: could not load code_rules_enforcer.", file=sys.stderr)
146
- raise SystemExit(2)
147
- module = importlib.util.module_from_spec(specification)
148
- package_root_for_imports = _resolve_package_root_absolute(Path(__file__).absolute())
149
- hooks_root_path = str(package_root_for_imports / "hooks")
150
- while hooks_root_path in sys.path:
151
- sys.path.remove(hooks_root_path)
152
- sys.path.insert(0, hooks_root_path)
153
- saved_hooks_constants_modules = {
154
- each_module_name: sys.modules.pop(each_module_name)
155
- for each_module_name in [
156
- each_key for each_key in list(sys.modules)
157
- if each_key == "hooks_constants" or each_key.startswith("hooks_constants.")
158
- ]
159
- }
160
- try:
161
- specification.loader.exec_module(module)
162
- finally:
163
- while hooks_root_path in sys.path:
164
- sys.path.remove(hooks_root_path)
165
- for each_module_name in [
166
- each_key for each_key in list(sys.modules)
167
- if each_key == "hooks_constants" or each_key.startswith("hooks_constants.")
168
- ]:
169
- sys.modules.pop(each_module_name, None)
170
- sys.modules.update(saved_hooks_constants_modules)
171
- return module.validate_content
172
-
173
-
174
- def resolve_merge_base(repository_root: Path, base_reference: str) -> str:
175
- """Return the merge-base SHA between HEAD and *base_reference*.
176
-
177
- Args:
178
- repository_root: Repository root used as the ``git -C`` target.
179
- base_reference: The git reference to merge-base against.
180
-
181
- Returns:
182
- The stripped merge-base SHA.
183
-
184
- Raises:
185
- SystemExit: When ``git merge-base`` returns non-zero.
186
- """
187
- merge_result = subprocess.run(
188
- ["git", "merge-base", "HEAD", base_reference],
189
- cwd=str(repository_root),
190
- capture_output=True,
191
- text=True,
192
- encoding="utf-8",
193
- errors="replace",
194
- check=False,
195
- env=repository_environment(),
196
- )
197
- if merge_result.returncode != 0:
198
- print(
199
- f"code_rules_gate: git merge-base HEAD {base_reference} failed:\n"
200
- f"{merge_result.stderr}",
201
- file=sys.stderr,
202
- )
203
- raise SystemExit(2)
204
- return merge_result.stdout.strip()
205
-
206
-
207
- def filter_paths_under_prefixes(
208
- all_file_paths: list[Path],
209
- repository_root: Path,
210
- all_prefixes: list[str],
211
- ) -> list[Path]:
212
- """Filter *all_file_paths* to entries falling under the supplied prefixes.
213
-
214
- Args:
215
- all_file_paths: Resolved file paths to filter.
216
- repository_root: Repository root used to compute relative paths.
217
- all_prefixes: Repository-relative POSIX prefixes; each path must
218
- equal one prefix or be nested beneath it to pass through.
219
-
220
- Returns:
221
- The subset of *all_file_paths* whose relative POSIX path matches one
222
- of the prefixes. When *all_prefixes* is empty, returns the input
223
- list unchanged.
224
- """
225
- if not all_prefixes:
226
- return all_file_paths
227
- normalized_prefixes = [
228
- each_prefix.strip().replace("\\", "/").rstrip("/")
229
- for each_prefix in all_prefixes
230
- if each_prefix.strip()
231
- ]
232
- if not normalized_prefixes:
233
- return all_file_paths
234
- resolved_root = repository_root.resolve()
235
- filtered: list[Path] = []
236
- for each_path in all_file_paths:
237
- try:
238
- relative_posix = each_path.resolve().relative_to(resolved_root).as_posix()
239
- except ValueError:
240
- continue
241
- if any(
242
- relative_posix == each_prefix
243
- or relative_posix.startswith(each_prefix + "/")
244
- for each_prefix in normalized_prefixes
245
- ):
246
- filtered.append(each_path)
247
- return filtered
248
-
249
-
250
- def paths_from_git_staged(repository_root: Path) -> list[Path]:
251
- """Return absolute paths for every file in the staged index.
252
-
253
- Args:
254
- repository_root: Repository root used as the ``git -C`` target.
255
-
256
- Returns:
257
- List of absolute paths for staged files. Names whose bytes cannot
258
- be decoded as Unicode are logged and skipped.
259
-
260
- Raises:
261
- SystemExit: When ``git diff --cached --name-only -z`` returns
262
- non-zero.
263
- """
264
- name_result = subprocess.run(
265
- list(ALL_GIT_DIFF_CACHED_NAME_ONLY_NULL_TERMINATED_COMMAND),
266
- cwd=str(repository_root),
267
- capture_output=True,
268
- check=False,
269
- env=repository_environment(),
270
- )
271
- if name_result.returncode != 0:
272
- stderr_text = name_result.stderr.decode("utf-8", errors="replace")
273
- print(
274
- f"code_rules_gate: git diff --cached --name-only -z failed:\n{stderr_text}",
275
- file=sys.stderr,
276
- )
277
- raise SystemExit(2)
278
- raw_paths = name_result.stdout.split(b"\x00")
279
- resolved_paths = []
280
- for each_raw_path in raw_paths:
281
- if not each_raw_path:
282
- continue
283
- try:
284
- relative_path = each_raw_path.decode("utf-8")
285
- except UnicodeDecodeError:
286
- print(
287
- f"code_rules_gate: skipping staged path with non-UTF-8 filename: {each_raw_path!r}",
288
- file=sys.stderr,
289
- )
290
- continue
291
- resolved_paths.append(repository_root / relative_path)
292
- return resolved_paths
293
-
294
-
295
- def staged_file_line_count(
296
- repository_root: Path,
297
- relative_path_posix: str,
298
- ) -> int:
299
- """Return the staged-blob line count for *relative_path_posix*.
300
-
301
- Args:
302
- repository_root: Repository root used as the ``git -C`` target.
303
- relative_path_posix: Repository-relative POSIX path of the staged
304
- file.
305
-
306
- Returns:
307
- The staged content line count, or zero when the blob is empty.
308
-
309
- Raises:
310
- SystemExit: When ``git show :<path>`` returns non-zero.
311
- """
312
- show_result = subprocess.run(
313
- ["git", "show", f":{relative_path_posix}"],
314
- cwd=str(repository_root),
315
- capture_output=True,
316
- text=True,
317
- encoding="utf-8",
318
- errors="replace",
319
- check=False,
320
- env=repository_environment(),
321
- )
322
- if show_result.returncode != 0:
323
- print(
324
- f"code_rules_gate: git show :{relative_path_posix} failed:\n"
325
- f"{show_result.stderr}",
326
- file=sys.stderr,
327
- )
328
- raise SystemExit(2)
329
- staged_content = show_result.stdout
330
- if not staged_content:
331
- return 0
332
- return len(staged_content.splitlines())
333
-
334
-
335
- def is_staged_file_newly_added(
336
- repository_root: Path,
337
- relative_path_posix: str,
338
- ) -> bool:
339
- """Check whether *relative_path_posix* is newly added in the staged diff.
340
-
341
- Args:
342
- repository_root: Repository root used as the ``git -C`` target.
343
- relative_path_posix: Repository-relative POSIX path to inspect.
344
-
345
- Returns:
346
- True when the first non-empty name-status line begins with the git
347
- added-prefix; False otherwise.
348
-
349
- Raises:
350
- SystemExit: When ``git diff --cached --name-status`` returns
351
- non-zero.
352
- """
353
- status_result = subprocess.run(
354
- ["git", "diff", "--cached", "--name-status", "--", relative_path_posix],
355
- cwd=str(repository_root),
356
- capture_output=True,
357
- text=True,
358
- encoding="utf-8",
359
- errors="replace",
360
- check=False,
361
- env=repository_environment(),
362
- )
363
- if status_result.returncode != 0:
364
- print(
365
- f"code_rules_gate: git diff --cached --name-status failed for "
366
- f"{relative_path_posix}:\n{status_result.stderr}",
367
- file=sys.stderr,
368
- )
369
- raise SystemExit(2)
370
- for each_line in status_result.stdout.splitlines():
371
- stripped_line = each_line.strip()
372
- if stripped_line:
373
- return stripped_line.startswith(GIT_NAME_STATUS_ADDED_PREFIX)
374
- return False
375
-
376
-
377
- def added_lines_for_staged_file(
378
- repository_root: Path,
379
- relative_path_posix: str,
380
- ) -> set[int]:
381
- """Return added line numbers within the staged diff for one file.
382
-
383
- Args:
384
- repository_root: Repository root used as the ``git -C`` target.
385
- relative_path_posix: Repository-relative POSIX path to inspect.
386
-
387
- Returns:
388
- Set of line numbers (1-indexed) added in the staged diff. When the
389
- file is newly added, returns every line in the staged blob.
390
-
391
- Raises:
392
- SystemExit: When the staged diff command returns non-zero.
393
- """
394
- diff_result = subprocess.run(
395
- ["git", "diff", "--cached", "--unified=0", "--", relative_path_posix],
396
- cwd=str(repository_root),
397
- capture_output=True,
398
- text=True,
399
- encoding="utf-8",
400
- errors="replace",
401
- check=False,
402
- env=repository_environment(),
403
- )
404
- if diff_result.returncode != 0:
405
- print(
406
- f"code_rules_gate: git diff --cached --unified=0 failed for {relative_path_posix}:\n"
407
- f"{diff_result.stderr}",
408
- file=sys.stderr,
409
- )
410
- raise SystemExit(2)
411
- if diff_result.stdout.strip():
412
- return parse_added_line_numbers(diff_result.stdout)
413
- if is_staged_file_newly_added(repository_root, relative_path_posix):
414
- total_lines = staged_file_line_count(repository_root, relative_path_posix)
415
- if total_lines > 0:
416
- return set(range(1, total_lines + 1))
417
- return set()
418
-
419
-
420
- def added_lines_by_file_staged(
421
- repository_root: Path,
422
- all_file_paths: list[Path],
423
- ) -> dict[Path, set[int]]:
424
- """Build a per-file map of staged-added line numbers.
425
-
426
- Args:
427
- repository_root: Repository root for diff invocations.
428
- all_file_paths: File paths whose added lines should be collected.
429
-
430
- Returns:
431
- Mapping from resolved file path to the set of staged-added line
432
- numbers.
433
- """
434
- resolved_root = repository_root.resolve()
435
- added_by_path: dict[Path, set[int]] = {}
436
- for each_path in all_file_paths:
437
- try:
438
- resolved = each_path.resolve()
439
- except OSError:
440
- continue
441
- try:
442
- relative = resolved.relative_to(resolved_root)
443
- except ValueError:
444
- continue
445
- relative_posix = str(relative).replace("\\", "/")
446
- added_numbers = added_lines_for_staged_file(resolved_root, relative_posix)
447
- added_by_path[resolved] = added_numbers
448
- return added_by_path
449
-
450
-
451
- def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
452
- """Return absolute paths for every file changed since *base_reference*.
453
-
454
- Args:
455
- repository_root: Repository root used as the ``git -C`` target.
456
- base_reference: The git reference to merge-base against.
457
-
458
- Returns:
459
- List of absolute paths changed since the merge-base of HEAD and
460
- *base_reference*.
461
-
462
- Raises:
463
- SystemExit: When the ``git diff --name-only`` command returns
464
- non-zero.
465
- """
466
- merge_base = resolve_merge_base(repository_root, base_reference)
467
- diff_command = list(ALL_GIT_DIFF_NAME_ONLY_NULL_TERMINATED_COMMAND_PREFIX) + [
468
- f"{merge_base}..HEAD"
469
- ]
470
- name_result = subprocess.run(
471
- diff_command,
472
- cwd=str(repository_root),
473
- capture_output=True,
474
- check=False,
475
- env=repository_environment(),
476
- )
477
- if name_result.returncode != 0:
478
- stderr_text = name_result.stderr.decode("utf-8", errors="replace")
479
- print(
480
- f"code_rules_gate: git diff --name-only -z failed:\n{stderr_text}",
481
- file=sys.stderr,
482
- )
483
- raise SystemExit(2)
484
- raw_paths = name_result.stdout.split(b"\x00")
485
- resolved_paths: list[Path] = []
486
- for each_raw_path in raw_paths:
487
- if not each_raw_path:
488
- continue
489
- try:
490
- relative_path = each_raw_path.decode("utf-8")
491
- except UnicodeDecodeError:
492
- print(
493
- f"code_rules_gate: skipping diff path with non-UTF-8 filename: {each_raw_path!r}",
494
- file=sys.stderr,
495
- )
496
- continue
497
- resolved_paths.append(repository_root / relative_path)
498
- return resolved_paths
499
-
500
-
501
- def is_code_path(file_path: Path) -> bool:
502
- suffix = file_path.suffix.lower()
503
- return suffix in ALL_CODE_FILE_EXTENSIONS
504
-
505
-
506
- def is_test_path(file_path: str) -> bool:
507
- """Return True when *file_path* matches CODE_RULES.md test-file detection patterns.
508
-
509
- Mirrors the test-file detection rule documented in CODE_RULES.md:
510
- filename matches test_*.py OR *_test.py OR *.test.* OR *.spec.* OR
511
- conftest.py, OR path contains the segment /tests/.
512
-
513
- Args:
514
- file_path: Path string to classify; backslashes are normalized to
515
- forward slashes before pattern matching.
516
-
517
- Returns:
518
- True when the path matches any test-file pattern; False otherwise.
519
- """
520
- normalized_posix = file_path.replace("\\", "/")
521
- filename_only = normalized_posix.rsplit("/", maxsplit=1)[-1]
522
- if TESTS_PATH_SEGMENT in normalized_posix:
523
- return True
524
- if filename_only == TEST_CONFTEST_FILENAME:
525
- return True
526
- if filename_only.startswith(TEST_FILENAME_PREFIX) and filename_only.endswith(
527
- PYTHON_FILE_EXTENSION
528
- ):
529
- return True
530
- if any(
531
- filename_only.endswith(each_suffix)
532
- for each_suffix in ALL_TEST_FILENAME_SUFFIXES
533
- ):
534
- return True
535
- if any(
536
- each_glob_suffix in filename_only
537
- for each_glob_suffix in ALL_TEST_FILENAME_GLOB_SUFFIXES
538
- ):
539
- return True
540
- return False
541
-
542
-
543
- def _iter_calls_excluding_nested_functions(node: ast.AST) -> Iterator[ast.Call]:
544
- for each_child in ast.iter_child_nodes(node):
545
- if isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef)):
546
- continue
547
- if isinstance(each_child, ast.Call):
548
- yield each_child
549
- continue
550
- yield from _iter_calls_excluding_nested_functions(each_child)
551
-
552
-
553
- def _module_level_optional_kwargs_by_name(tree: ast.Module) -> dict[str, set[str]]:
554
- function_signatures: dict[str, set[str]] = {}
555
- for each_node in ast.iter_child_nodes(tree):
556
- if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
557
- optional_kwargs: set[str] = set()
558
- for each_kwonly, each_default in zip(
559
- each_node.args.kwonlyargs, each_node.args.kw_defaults
560
- ):
561
- if each_default is not None:
562
- optional_kwargs.add(each_kwonly.arg)
563
- positional_defaults = each_node.args.defaults
564
- positional_args_with_defaults = (
565
- each_node.args.args[-len(positional_defaults):]
566
- if positional_defaults
567
- else []
568
- )
569
- for each_positional_arg in positional_args_with_defaults:
570
- optional_kwargs.add(each_positional_arg.arg)
571
- function_signatures[each_node.name] = optional_kwargs
572
- return function_signatures
573
-
574
-
575
- def _class_method_node_ids(tree: ast.Module) -> set[int]:
576
- class_method_node_ids: set[int] = set()
577
- for each_class_def in ast.walk(tree):
578
- if not isinstance(each_class_def, ast.ClassDef):
579
- continue
580
- for each_class_body_node in each_class_def.body:
581
- if isinstance(
582
- each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
583
- ):
584
- class_method_node_ids.add(id(each_class_body_node))
585
- return class_method_node_ids
586
-
587
-
588
- def _wrapper_dropped_kwarg_findings(
589
- wrapper_node: ast.FunctionDef | ast.AsyncFunctionDef,
590
- kwargs_by_function_name: dict[str, set[str]],
591
- ) -> Iterator[str]:
592
- wrapper_kwargs = kwargs_by_function_name.get(wrapper_node.name, set())
593
- for each_call in _iter_calls_excluding_nested_functions(wrapper_node):
594
- if isinstance(each_call.func, ast.Name):
595
- delegate_name = each_call.func.id
596
- elif isinstance(each_call.func, ast.Attribute):
597
- delegate_name = each_call.func.attr
598
- else:
599
- continue
600
- delegate_kwargs = kwargs_by_function_name.get(delegate_name)
601
- if delegate_kwargs is None:
602
- continue
603
- missing = delegate_kwargs - wrapper_kwargs
604
- if missing:
605
- yield (
606
- f"Line {wrapper_node.lineno}: Wrapper {wrapper_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
607
- )
608
-
609
-
610
- def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
611
- """Flag calls inside public functions that drop a same-file delegate's optional kwargs.
612
-
613
- Walks the AST. For every public function (name does not start with '_'),
614
- inspects every ast.Call inside its body and emits one finding per call
615
- whose target name matches a same-file function that exposes optional
616
- kwargs the enclosing public function does not also accept. Emission is
617
- capped at MAX_VIOLATIONS_PER_CHECK findings per call to run_gate.
618
-
619
- Limitations:
620
- - Only module-level FunctionDef nodes contribute signatures, and ClassDef
621
- methods are skipped both as signature sources and as wrapper candidates:
622
- a class method's signature is unrelated to a free-function delegate's
623
- keyword surface, so treating it as a wrapper produces false positives.
624
- - ast.Attribute calls match by attribute name only; the receiver type is
625
- not checked, so `self.fetch(...)` and `other.fetch(...)` both match a
626
- module-level `fetch` definition.
627
- - Nested call expressions inside another call's arguments are not treated as
628
- separate call sites; only the enclosing Call is inspected. This avoids
629
- false positives where a callee nested as an argument is confused with a
630
- top-level delegate invocation (for example `delegate(helper(x))`).
631
-
632
- Args:
633
- content: File content as a single string for AST parsing.
634
- file_path: Repository-relative POSIX path of the file (used to
635
- skip non-Python code extensions and test files early).
636
-
637
- Returns:
638
- List of violation strings, one per dropped optional kwarg. Empty for
639
- a non-Python file, a test file, or a file with a syntax error.
640
- """
641
- non_python_code_extensions = ALL_CODE_FILE_EXTENSIONS - {PYTHON_FILE_EXTENSION}
642
- lowercase_file_path = file_path.lower()
643
- if any(
644
- lowercase_file_path.endswith(each_extension)
645
- for each_extension in non_python_code_extensions
646
- ):
647
- return []
648
- if is_test_path(file_path):
649
- return []
650
- try:
651
- tree = ast.parse(content)
652
- except SyntaxError:
653
- return []
654
- function_signatures = _module_level_optional_kwargs_by_name(tree)
655
- class_method_node_ids = _class_method_node_ids(tree)
656
- issues: list[str] = []
657
- for each_node in ast.walk(tree):
658
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
659
- continue
660
- if id(each_node) in class_method_node_ids:
661
- continue
662
- if each_node.name.startswith("_"):
663
- continue
664
- for each_finding in _wrapper_dropped_kwarg_findings(each_node, function_signatures):
665
- issues.append(each_finding)
666
- if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
667
- return issues
668
- return issues
669
-
670
-
671
- def parse_added_line_numbers(unified_diff_text: str) -> set[int]:
672
- """Extract added line numbers from unified-diff text.
673
-
674
- Args:
675
- unified_diff_text: Output from ``git diff --unified=0``.
676
-
677
- Returns:
678
- Set of newly-added line numbers (1-indexed) extracted from the
679
- hunk headers.
680
- """
681
- header_regex = hunk_header_pattern()
682
- added_line_numbers: set[int] = set()
683
- for each_line in unified_diff_text.splitlines():
684
- header_match = header_regex.match(each_line)
685
- if header_match is None:
686
- continue
687
- new_start_text, new_count_text = header_match.groups()
688
- new_start = int(new_start_text)
689
- new_count = 1 if new_count_text is None else int(new_count_text)
690
- if new_count <= 0:
691
- continue
692
- for each_number in range(new_start, new_start + new_count):
693
- added_line_numbers.add(each_number)
694
- return added_line_numbers
695
-
696
-
697
- def is_file_new_at_base(
698
- repository_root: Path,
699
- merge_base: str,
700
- relative_path_posix: str,
701
- ) -> bool:
702
- """Check whether *relative_path_posix* did not exist at *merge_base*.
703
-
704
- Args:
705
- repository_root: Repository root used as the ``git -C`` target.
706
- merge_base: The merge-base SHA against which to check existence.
707
- relative_path_posix: Repository-relative POSIX path to inspect.
708
-
709
- Returns:
710
- True when ``git cat-file -e`` fails to find the blob at the merge
711
- base (i.e. the file was added on the HEAD side); False otherwise.
712
- """
713
- cat_result = subprocess.run(
714
- ["git", "cat-file", "-e", f"{merge_base}:{relative_path_posix}"],
715
- cwd=str(repository_root),
716
- capture_output=True,
717
- text=True,
718
- encoding="utf-8",
719
- errors="replace",
720
- check=False,
721
- env=repository_environment(),
722
- )
723
- return cat_result.returncode != 0
724
-
725
-
726
- def added_lines_for_file(
727
- repository_root: Path,
728
- merge_base: str,
729
- relative_path_posix: str,
730
- ) -> set[int]:
731
- """Return added line numbers for *relative_path_posix* since *merge_base*.
732
-
733
- Args:
734
- repository_root: Repository root used as the ``git -C`` target.
735
- merge_base: The merge-base SHA against which to diff.
736
- relative_path_posix: Repository-relative POSIX path to inspect.
737
-
738
- Returns:
739
- Set of line numbers (1-indexed) added on the HEAD side of the diff.
740
-
741
- Raises:
742
- SystemExit: When the diff command returns non-zero.
743
- """
744
- diff_result = subprocess.run(
745
- [
746
- "git",
747
- "diff",
748
- "--unified=0",
749
- f"{merge_base}..HEAD",
750
- "--",
751
- relative_path_posix,
752
- ],
753
- cwd=str(repository_root),
754
- capture_output=True,
755
- text=True,
756
- encoding="utf-8",
757
- errors="replace",
758
- check=False,
759
- env=repository_environment(),
760
- )
761
- if diff_result.returncode != 0:
762
- print(
763
- f"code_rules_gate: git diff --unified=0 failed for {relative_path_posix}:\n"
764
- f"{diff_result.stderr}",
765
- file=sys.stderr,
766
- )
767
- raise SystemExit(2)
768
- if not diff_result.stdout.strip():
769
- return set()
770
- return parse_added_line_numbers(diff_result.stdout)
771
-
772
-
773
- def whole_file_line_set(file_path: Path) -> set[int]:
774
- """Return the set of line numbers covering an entire file.
775
-
776
- Args:
777
- file_path: Path to the file whose line span should be summarized.
778
-
779
- Returns:
780
- Set of line numbers (1-indexed) covering every line in *file_path*,
781
- or an empty set when the file is unreadable or empty.
782
- """
783
- try:
784
- total_lines = len(file_path.read_text(encoding="utf-8").splitlines())
785
- except (OSError, UnicodeDecodeError) as read_error:
786
- print(
787
- f"code_rules_gate: skipping unreadable file {file_path}: {read_error}",
788
- file=sys.stderr,
789
- )
790
- return set()
791
- if total_lines <= 0:
792
- return set()
793
- return set(range(1, total_lines + 1))
794
-
795
-
796
- def renamed_file_source_map_since(
797
- repository_root: Path,
798
- merge_base: str,
799
- ) -> dict[str, str]:
800
- """Return a mapping from rename-destination path to rename-source path.
801
-
802
- Runs `git diff --name-status -M -z merge_base..HEAD` and collects both
803
- paths of every rename entry (status code starting with R, e.g. `R100`).
804
- Keys are destination posix paths; values are source posix paths.
805
-
806
- The -z flag asks git for null-terminated, unquoted output. A path
807
- holding a tab or newline byte then survives column and line splitting
808
- unmangled. Each rename record emits three null-terminated tokens
809
- (status, source, destination). Every other status record emits two
810
- (status, path).
811
-
812
- Args:
813
- repository_root: Repository root used as the ``git -C`` target.
814
- merge_base: The merge-base SHA against which to diff.
815
-
816
- Returns:
817
- Mapping from rename-destination POSIX path to rename-source POSIX
818
- path. Empty when no rename records are present.
819
-
820
- Raises:
821
- SystemExit: When ``git diff --name-status`` returns non-zero.
822
- """
823
- name_status_result = subprocess.run(
824
- ["git", "diff", "--name-status", "-M", "-z", f"{merge_base}..HEAD"],
825
- cwd=str(repository_root),
826
- capture_output=True,
827
- check=False,
828
- env=repository_environment(),
829
- )
830
- if name_status_result.returncode != 0:
831
- stderr_text = name_status_result.stderr.decode("utf-8", errors="replace")
832
- print(
833
- f"code_rules_gate: git diff --name-status -M -z failed:\n"
834
- f"{stderr_text}",
835
- file=sys.stderr,
836
- )
837
- raise SystemExit(2)
838
- null_separated_tokens = [
839
- each_token.decode("utf-8", errors="replace")
840
- for each_token in name_status_result.stdout.split(b"\x00")
841
- if each_token
842
- ]
843
- rename_source_by_destination: dict[str, str] = {}
844
- next_token_index = 0
845
- while next_token_index < len(null_separated_tokens):
846
- status_code = null_separated_tokens[next_token_index]
847
- if status_code.startswith(GIT_NAME_STATUS_RENAMED_PREFIX):
848
- if next_token_index + EXPECTED_RENAME_COLUMN_COUNT > len(
849
- null_separated_tokens
850
- ):
851
- break
852
- source_path = null_separated_tokens[next_token_index + 1].replace(
853
- "\\", "/"
854
- )
855
- destination_path = null_separated_tokens[next_token_index + 2].replace(
856
- "\\", "/"
857
- )
858
- rename_source_by_destination[destination_path] = source_path
859
- next_token_index += EXPECTED_RENAME_COLUMN_COUNT
860
- continue
861
- next_token_index += EXPECTED_NON_RENAME_COLUMN_COUNT
862
- return rename_source_by_destination
863
-
864
-
865
- def added_lines_for_renamed_file(
866
- repository_root: Path,
867
- merge_base: str,
868
- source_posix: str,
869
- destination_posix: str,
870
- ) -> set[int]:
871
- """Return added line numbers for a renamed file via blob comparison.
872
-
873
- Compares `merge_base:source_posix` against `HEAD:destination_posix`
874
- to surface only truly added lines, ignoring lines that already existed
875
- in the source file before the rename. Falls back to whole-file coverage
876
- when the source blob is absent at the merge base (i.e. the source was
877
- itself a new or renamed file that landed earlier in the branch).
878
-
879
- Args:
880
- repository_root: Repository root used as the ``git -C`` target.
881
- merge_base: The merge-base SHA against which to compare blobs.
882
- source_posix: Rename-source POSIX path at the merge base.
883
- destination_posix: Rename-destination POSIX path at HEAD.
884
-
885
- Returns:
886
- Set of line numbers (1-indexed) added on the HEAD side of the
887
- comparison; empty on diff failure.
888
- """
889
- diff_result = subprocess.run(
890
- [
891
- "git",
892
- "diff",
893
- "--unified=0",
894
- f"{merge_base}:{source_posix}",
895
- f"HEAD:{destination_posix}",
896
- ],
897
- cwd=str(repository_root),
898
- capture_output=True,
899
- text=True,
900
- encoding="utf-8",
901
- errors="replace",
902
- check=False,
903
- env=repository_environment(),
904
- )
905
- if diff_result.returncode != 0:
906
- print(
907
- f"code_rules_gate: git diff failed for renamed file {merge_base}:{source_posix} "
908
- f"vs HEAD:{destination_posix} (returncode={diff_result.returncode}); "
909
- f"stderr={diff_result.stderr.strip()!r}",
910
- file=sys.stderr,
911
- )
912
- return set()
913
- if not diff_result.stdout.strip():
914
- return set()
915
- return parse_added_line_numbers(diff_result.stdout)
916
-
917
-
918
- def added_lines_by_file(
919
- repository_root: Path,
920
- base_reference: str,
921
- all_file_paths: list[Path],
922
- ) -> dict[Path, set[int]]:
923
- """Build a per-file map of added line numbers across the branch.
924
-
925
- Args:
926
- repository_root: Repository root for diff invocations.
927
- base_reference: The git reference to merge-base against.
928
- all_file_paths: File paths whose added lines should be collected.
929
-
930
- Returns:
931
- Mapping from resolved file path to the set of line numbers added
932
- on the HEAD side, with renames resolved to compare against the
933
- original source path.
934
- """
935
- merge_base = resolve_merge_base(repository_root, base_reference)
936
- resolved_root = repository_root.resolve()
937
- rename_source_map = renamed_file_source_map_since(resolved_root, merge_base)
938
- added_by_path: dict[Path, set[int]] = {}
939
- for each_path in all_file_paths:
940
- try:
941
- resolved = each_path.resolve()
942
- except OSError:
943
- continue
944
- try:
945
- relative = resolved.relative_to(resolved_root)
946
- except ValueError:
947
- continue
948
- relative_posix = str(relative).replace("\\", "/")
949
- if relative_posix in rename_source_map:
950
- added_numbers = added_lines_for_renamed_file(
951
- resolved_root,
952
- merge_base,
953
- rename_source_map[relative_posix],
954
- relative_posix,
955
- )
956
- else:
957
- added_numbers = added_lines_for_file(
958
- resolved_root, merge_base, relative_posix
959
- )
960
- if not added_numbers and resolved.is_file():
961
- if is_file_new_at_base(resolved_root, merge_base, relative_posix):
962
- added_numbers = whole_file_line_set(resolved)
963
- added_by_path[resolved] = added_numbers
964
- return added_by_path
965
-
966
-
967
- def extract_violation_line_number(violation_text: str) -> int | None:
968
- """Return the line number captured by the gate's violation-line regex.
969
-
970
- Args:
971
- violation_text: A single violation string of the form ``Line N: ...``.
972
-
973
- Returns:
974
- The integer line number captured in the prefix, or None when the
975
- text does not match the violation-line pattern.
976
- """
977
- match_result = violation_line_pattern().match(violation_text)
978
- if match_result is None:
979
- return None
980
- return int(match_result.group(1))
981
-
982
-
983
- def function_length_span_range(violation_text: str) -> range | None:
984
- """Return the declared line range of a function-length violation, or None.
985
-
986
- The enforcer's function-length message carries the definition line and
987
- the function's line span: ``Function 'NAME' (defined at line X) is Y
988
- lines - ...``. The function occupies lines ``X`` through ``X + Y - 1``
989
- inclusive.
990
-
991
- Args:
992
- violation_text: A single violation string emitted by the enforcer.
993
-
994
- Returns:
995
- A ``range`` covering the function's declared line span, or None when
996
- the text is not a function-length violation.
997
- """
998
- span_match = FUNCTION_LENGTH_VIOLATION_PATTERN.search(violation_text)
999
- if span_match is None:
1000
- return None
1001
- definition_line = int(span_match.group(FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX))
1002
- line_span = int(span_match.group(FUNCTION_LENGTH_SPAN_GROUP_INDEX))
1003
- return range(definition_line, definition_line + line_span)
1004
-
1005
-
1006
- def isolation_span_range(violation_text: str) -> range | None:
1007
- """Return the enclosing test-function line range of an isolation violation.
1008
-
1009
- The enforcer's HOME/TMP isolation message carries the enclosing test
1010
- function's definition line and span: ``Line N: Test 'NAME' (defined at
1011
- line X, spanning Y lines) probes ...``. The function occupies lines ``X``
1012
- through ``X + Y - 1`` inclusive, so a signature-line change that
1013
- un-isolates an unchanged-body probe is scoped by the same span the
1014
- enforcer uses rather than by the ``Line N:`` probe line alone.
1015
-
1016
- Args:
1017
- violation_text: A single violation string emitted by the enforcer.
1018
-
1019
- Returns:
1020
- A ``range`` covering the enclosing test function's declared line span,
1021
- or None when the text is not an isolation violation.
1022
- """
1023
- span_match = ISOLATION_VIOLATION_PATTERN.search(violation_text)
1024
- if span_match is None:
1025
- return None
1026
- definition_line = int(span_match.group(ISOLATION_DEFINITION_LINE_GROUP_INDEX))
1027
- line_span = int(span_match.group(ISOLATION_SPAN_GROUP_INDEX))
1028
- return range(definition_line, definition_line + line_span)
1029
-
1030
-
1031
- def banned_noun_span_range(violation_text: str) -> range | None:
1032
- """Return the one-line binding span of a banned-noun violation, or None.
1033
-
1034
- The enforcer's banned-noun message carries the binding line and a one-line
1035
- span: ``Line N: Identifier 'NAME' ... (binding span at line X, spanning 1
1036
- lines)``. A banned-noun binding is a point fact about one identifier, so the
1037
- span is always the binding line alone (``X`` through ``X``) — never the
1038
- enclosing function span. Scoping to the binding line keeps a pre-existing
1039
- parameter or local-name binding out of scope when an unrelated line of its
1040
- enclosing function is edited.
1041
-
1042
- Args:
1043
- violation_text: A single violation string emitted by the enforcer.
1044
-
1045
- Returns:
1046
- A ``range`` covering the binding's one-line span, or None when the text
1047
- is not a banned-noun violation.
1048
- """
1049
- span_match = BANNED_NOUN_VIOLATION_PATTERN.search(violation_text)
1050
- if span_match is None:
1051
- return None
1052
- definition_line = int(span_match.group(BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX))
1053
- line_span = int(span_match.group(BANNED_NOUN_SPAN_GROUP_INDEX))
1054
- return range(definition_line, definition_line + line_span)
1055
-
1056
-
1057
- def duplicate_body_span_range(violation_text: str) -> range | None:
1058
- """Return the copied function's source line range of a duplicate-body issue.
1059
-
1060
- The duplicate-body message carries the copied function's definition line and
1061
- its full body span: ``Function 'NAME' duplicates location.py::name — ...
1062
- (duplicate body span at line X, spanning Y lines)``. The function occupies
1063
- lines ``X`` through ``X + Y - 1`` inclusive.
1064
-
1065
- So a duplicate of a sibling helper blocks only when the diff touches the
1066
- copied function. An unrelated edit that leaves a pre-existing copy
1067
- untouched keeps it advisory. This matches the span-scoped PreToolUse
1068
- Write/Edit behavior rather than blocking every duplicate-body message
1069
- unconditionally.
1070
-
1071
- Args:
1072
- violation_text: A single violation string emitted by the enforcer.
1073
-
1074
- Returns:
1075
- A ``range`` covering the copied function's declared line span, or None
1076
- when the text is not a duplicate-body violation.
1077
- """
1078
- span_match = DUPLICATE_BODY_VIOLATION_PATTERN.search(violation_text)
1079
- if span_match is None:
1080
- return None
1081
- definition_line = int(span_match.group(DUPLICATE_BODY_DEFINITION_LINE_GROUP_INDEX))
1082
- line_span = int(span_match.group(DUPLICATE_BODY_SPAN_GROUP_INDEX))
1083
- return range(definition_line, definition_line + line_span)
1084
-
1085
-
1086
- def inline_duplicate_body_span_lines(violation_text: str) -> frozenset[int] | None:
1087
- """Return the union of both spans of a same-file inline-duplicate issue, or None.
1088
-
1089
- The same-file inline-duplicate message names two functions that share a body:
1090
- the helper and the enclosing function carrying the inline copy. The live
1091
- Write/Edit hook scopes the violation by the union of both spans. It blocks
1092
- when an edit touches either function. So the message carries both spans:
1093
- ``(inline duplicate body spans: helper at line H spanning P lines,
1094
- enclosing at line E spanning Q lines)``.
1095
-
1096
- The two spans can be disjoint: an unrelated function may sit between the
1097
- helper and its inline copy. This returns the union as a line-number set
1098
- rather than a single contiguous range. A range covering the gap would
1099
- wrongly block an edit confined to that intervening function, which the
1100
- PreToolUse path leaves unflagged.
1101
-
1102
- Args:
1103
- violation_text: A single violation string emitted by the enforcer.
1104
-
1105
- Returns:
1106
- The frozenset of every line in the helper span and the enclosing span, or
1107
- None when the text is not a same-file inline-duplicate violation.
1108
- """
1109
- span_match = INLINE_DUPLICATE_BODY_VIOLATION_PATTERN.search(violation_text)
1110
- if span_match is None:
1111
- return None
1112
- helper_line = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX))
1113
- helper_span = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX))
1114
- enclosing_line = int(
1115
- span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX)
1116
- )
1117
- enclosing_span = int(
1118
- span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX)
1119
- )
1120
- helper_lines = range(helper_line, helper_line + helper_span)
1121
- enclosing_lines = range(enclosing_line, enclosing_line + enclosing_span)
1122
- return frozenset(helper_lines) | frozenset(enclosing_lines)
1123
-
1124
-
1125
- def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
1126
- return (
1127
- function_length_span_range,
1128
- isolation_span_range,
1129
- banned_noun_span_range,
1130
- duplicate_body_span_range,
1131
- )
1132
-
1133
-
1134
- def enclosing_span_range(violation_text: str) -> range | None:
1135
- """Return the enclosing-unit line range of a span-tagged violation, or None.
1136
-
1137
- Every diff-scoped enforcer check tags its message with an enclosing-unit
1138
- span fragment. This dispatcher tries each span extractor from
1139
- ``_all_span_range_extractors``, so the gate reconstructs every scoped
1140
- check's span through one shared mechanism. Adding a new scoped check
1141
- means adding one extractor to that registry rather than threading a new
1142
- branch through ``split_violations_by_scope``.
1143
-
1144
- Args:
1145
- violation_text: A single violation string emitted by the enforcer.
1146
-
1147
- Returns:
1148
- The first non-None span range any extractor recovers, or None when the
1149
- text carries no enclosing-unit span fragment.
1150
- """
1151
- for each_extractor in _all_span_range_extractors():
1152
- span_range = each_extractor(violation_text)
1153
- if span_range is not None:
1154
- return span_range
1155
- return None
1156
-
1157
-
1158
- def split_violations_by_scope(
1159
- all_issues: list[str],
1160
- all_added_line_numbers: set[int] | None,
1161
- ) -> tuple[list[str], list[str]]:
1162
- """Partition issues into blocking vs advisory based on touched lines.
1163
-
1164
- Args:
1165
- all_issues: Violation strings emitted by the enforcer.
1166
- all_added_line_numbers: Lines added in the current diff, or None
1167
- to treat every violation as blocking.
1168
-
1169
- Returns:
1170
- Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
1171
- None, every issue is blocking. A same-file inline-duplicate violation
1172
- carries both the helper span and the enclosing span;
1173
- ``inline_duplicate_body_span_lines`` reconstructs their union as a
1174
- line-number set, and the violation is blocking when an added line falls
1175
- in either span — matching the live Write/Edit hook's union scoping. Every
1176
- other diff-scoped violation (function-length, HOME/TMP isolation,
1177
- banned-noun, cross-file duplicate-body) carries one enclosing-unit span
1178
- fragment that ``enclosing_span_range`` reconstructs through one shared
1179
- extractor registry; such a violation is blocking
1180
- when its declared span intersects the added lines (the unit grew or its
1181
- signature changed in this diff) and advisory otherwise (a pre-existing
1182
- untouched unit). Every other issue is blocking when its ``Line N:``
1183
- prefix names an added line and advisory otherwise.
1184
- """
1185
- if all_added_line_numbers is None:
1186
- return list(all_issues), []
1187
- blocking: list[str] = []
1188
- advisory: list[str] = []
1189
- for each_issue in all_issues:
1190
- inline_duplicate_lines = inline_duplicate_body_span_lines(each_issue)
1191
- if inline_duplicate_lines is not None:
1192
- if inline_duplicate_lines & all_added_line_numbers:
1193
- blocking.append(each_issue)
1194
- else:
1195
- advisory.append(each_issue)
1196
- continue
1197
- span_range = enclosing_span_range(each_issue)
1198
- if span_range is not None:
1199
- if any(each_line in all_added_line_numbers for each_line in span_range):
1200
- blocking.append(each_issue)
1201
- else:
1202
- advisory.append(each_issue)
1203
- continue
1204
- violation_line = extract_violation_line_number(each_issue)
1205
- if violation_line is None:
1206
- blocking.append(each_issue)
1207
- continue
1208
- if violation_line in all_added_line_numbers:
1209
- blocking.append(each_issue)
1210
- else:
1211
- advisory.append(each_issue)
1212
- return blocking, advisory
1213
-
1214
-
1215
- def print_violation_section(
1216
- header_message: str,
1217
- violations_by_file: dict[Path, list[str]],
1218
- repository_root: Path,
1219
- ) -> None:
1220
- """Print a labeled block of violations grouped by relative path.
1221
-
1222
- Args:
1223
- header_message: Section header to write to stderr.
1224
- violations_by_file: Mapping from absolute file path to the list of
1225
- violation strings to render under that path.
1226
- repository_root: Repository root used to compute relative paths.
1227
- """
1228
- print(header_message, file=sys.stderr)
1229
- resolved_root = repository_root.resolve()
1230
- for each_path in sorted(violations_by_file.keys()):
1231
- relative = each_path.relative_to(resolved_root)
1232
- print(f"{relative}:", file=sys.stderr)
1233
- for each_issue in violations_by_file[each_path]:
1234
- print(f" {each_issue}", file=sys.stderr)
1235
-
1236
-
1237
- def read_prior_committed_content(
1238
- repository_root: Path, relative_path_posix: str
1239
- ) -> str:
1240
- """Return the HEAD-committed content for *relative_path_posix*.
1241
-
1242
- Args:
1243
- repository_root: Repository root used as the ``git -C`` target.
1244
- relative_path_posix: Repository-relative POSIX path to read.
1245
-
1246
- Returns:
1247
- The committed file content at HEAD, or an empty string when the
1248
- path is not tracked or ``git show`` returns non-zero.
1249
- """
1250
- show_result = subprocess.run(
1251
- ["git", "show", f"HEAD:{relative_path_posix}"],
1252
- cwd=str(repository_root),
1253
- capture_output=True,
1254
- text=True,
1255
- encoding="utf-8",
1256
- errors="replace",
1257
- check=False,
1258
- env=repository_environment(),
1259
- )
1260
- if show_result.returncode != 0:
1261
- return ""
1262
- return show_result.stdout
1263
-
1264
-
1265
- def read_staged_content(
1266
- repository_root: Path, relative_path_posix: str
1267
- ) -> str | None:
1268
- """Return the staged-blob content for *relative_path_posix*.
1269
-
1270
- Args:
1271
- repository_root: Repository root used as the ``git -C`` target.
1272
- relative_path_posix: Repository-relative POSIX path to read.
1
+ """Run the code-rules validators over a resolved git file set.
1273
2
 
1274
- Returns:
1275
- The staged blob content, or None when the path is not staged, when
1276
- ``git show`` returns non-zero, or when the staged bytes are not
1277
- decodable Unicode (the caller skips and fails closed).
1278
- """
1279
- git_show_process = subprocess.run(
1280
- ["git", "show", f":{relative_path_posix}"],
1281
- cwd=str(repository_root),
1282
- capture_output=True,
1283
- check=False,
1284
- env=repository_environment(),
1285
- )
1286
- if git_show_process.returncode != 0:
1287
- return None
1288
- try:
1289
- return git_show_process.stdout.decode(encoding="utf-8")
1290
- except UnicodeDecodeError:
1291
- return None
1292
-
1293
-
1294
- def staged_blob_exists(
1295
- repository_root: Path, relative_path_posix: str
1296
- ) -> bool:
1297
- """Report whether *relative_path_posix* is present in the staged index.
1298
-
1299
- Args:
1300
- repository_root: Repository root used as the ``git -C`` target.
1301
- relative_path_posix: Repository-relative POSIX path to probe.
1302
-
1303
- Returns:
1304
- True when the path is staged for add or modify (its blob exists in the
1305
- index); False when it is absent, such as a staged deletion.
1306
- """
1307
- git_cat_file_process = subprocess.run(
1308
- ["git", "cat-file", "-e", f":{relative_path_posix}"],
1309
- cwd=str(repository_root),
1310
- capture_output=True,
1311
- check=False,
1312
- env=repository_environment(),
1313
- )
1314
- return git_cat_file_process.returncode == 0
1315
-
1316
-
1317
- def _path_is_eligible_for_validation(
1318
- resolved_path: Path,
1319
- repository_root: Path,
1320
- read_staged_content_flag: bool,
1321
- ) -> bool:
1322
- """Decide whether *resolved_path* should be validated by the gate.
1323
-
1324
- Args:
1325
- resolved_path: A resolved candidate path already confirmed to live
1326
- under *repository_root*.
1327
- repository_root: Repository root used to compute the relative path.
1328
- read_staged_content_flag: When True, require staged-index presence so
1329
- files staged for add or modify are validated and staged deletions
1330
- are skipped; when False, require working-tree presence.
1331
-
1332
- Returns:
1333
- True when the path carries a code extension and exists in the source
1334
- the gate will read; False otherwise.
1335
- """
1336
- if not is_code_path(resolved_path):
1337
- return False
1338
- if read_staged_content_flag:
1339
- relative_posix = str(
1340
- resolved_path.relative_to(repository_root.resolve())
1341
- ).replace("\\", "/")
1342
- return staged_blob_exists(repository_root.resolve(), relative_posix)
1343
- return resolved_path.is_file()
1344
-
1345
-
1346
- def _scoped_violations_for_file(
1347
- validate_content: ValidateContentCallable,
1348
- resolved_path: Path,
1349
- repository_root: Path,
1350
- all_added_lines_for_file: set[int] | None,
1351
- read_staged_content_flag: bool = False,
1352
- ) -> tuple[list[str], list[str]] | None:
1353
- """Validate one resolved file and partition its violations by diff scope.
1354
-
1355
- Args:
1356
- validate_content: The enforcer ``validate_content`` callable.
1357
- resolved_path: The resolved code file to validate.
1358
- repository_root: Repository root used to resolve the relative path.
1359
- all_added_lines_for_file: Lines added in the current diff for this file,
1360
- or None to treat every violation as blocking.
1361
- read_staged_content_flag: When True, source the content from the staged
1362
- blob so it matches the staged diff that scoped the added lines.
3
+ ::
1363
4
 
1364
- Returns:
1365
- ``(blocking, advisory)`` for the file, or None when the file is
1366
- unreadable (the caller logs and skips it).
1367
- """
1368
- relative_posix = str(
1369
- resolved_path.relative_to(repository_root.resolve())
1370
- ).replace("\\", "/")
1371
- if read_staged_content_flag:
1372
- staged_content = read_staged_content(repository_root.resolve(), relative_posix)
1373
- if staged_content is None:
1374
- print(f"code_rules_gate: skip unreadable {resolved_path}", file=sys.stderr)
1375
- return None
1376
- content = staged_content
1377
- else:
1378
- try:
1379
- content = resolved_path.read_text(encoding="utf-8")
1380
- except (OSError, UnicodeDecodeError):
1381
- print(f"code_rules_gate: skip unreadable {resolved_path}", file=sys.stderr)
1382
- return None
1383
- prior_content = read_prior_committed_content(
1384
- repository_root.resolve(), relative_posix
1385
- )
1386
- issues = validate_content(
1387
- content,
1388
- relative_posix,
1389
- prior_content,
1390
- defer_scope_to_caller=True,
1391
- sibling_directory=resolved_path.parent,
1392
- )
1393
- issues.extend(check_wrapper_plumb_through(content, relative_posix))
1394
- if not issues:
1395
- return [], []
1396
- return split_violations_by_scope(issues, all_added_lines_for_file)
5
+ default mode: git diff since merge-base, joined with untracked files
6
+ --staged: validate the staged index; --paths: validate explicit files
7
+ every mode ends by naming how many files it inspected
1397
8
 
9
+ This entry module wires the ``code_rules_gate_parts`` submodules into one CLI
10
+ and re-exports their surface for the test suite.
11
+ """
1398
12
 
1399
- def run_gate(
1400
- validate_content: ValidateContentCallable,
1401
- all_file_paths: list[Path],
1402
- repository_root: Path,
1403
- all_added_lines_by_path: dict[Path, set[int]] | None = None,
1404
- read_staged_content_flag: bool = False,
1405
- ) -> int:
1406
- """Run the gate over *all_file_paths* and emit a partitioned report.
13
+ import argparse
14
+ import sys
15
+ from pathlib import Path
1407
16
 
1408
- Args:
1409
- validate_content: The enforcer ``validate_content`` callable.
1410
- all_file_paths: File paths to inspect.
1411
- repository_root: Repository root used to resolve relative paths.
1412
- all_added_lines_by_path: Optional per-file added-line maps used to
1413
- partition issues into blocking vs advisory.
1414
- read_staged_content_flag: When True, validate each file's staged blob
1415
- so the content source matches the staged diff.
1416
17
 
1417
- Returns:
1418
- Zero when every targeted file was validated and no blocking violation
1419
- was found. Non-zero when any blocking violation was reported OR when
1420
- one or more files could not be read (a skipped file means the gate
1421
- could not vouch for it).
1422
- """
1423
- blocking_by_file, advisory_by_file, skipped_unreadable_count = (
1424
- _collect_partitioned_violations(
1425
- validate_content,
1426
- all_file_paths,
1427
- repository_root,
1428
- all_added_lines_by_path,
1429
- read_staged_content_flag,
1430
- )
1431
- )
1432
- return _report_partitioned_violations(
1433
- blocking_by_file,
1434
- advisory_by_file,
1435
- repository_root,
1436
- all_added_lines_by_path is None,
1437
- skipped_unreadable_count,
18
+ def _ensure_scripts_directory_on_path() -> None:
19
+ """Add this file's directory to sys.path so the parts package resolves."""
20
+ scripts_directory = str(Path(__file__).resolve().parent)
21
+ if scripts_directory not in sys.path:
22
+ sys.path.insert(0, scripts_directory)
23
+
24
+
25
+ _ensure_scripts_directory_on_path()
26
+
27
+ try:
28
+ from code_rules_gate_parts import (
29
+ added_line_maps,
30
+ enforcer_loading,
31
+ gate_arguments,
32
+ gate_running,
33
+ git_blob_readers,
34
+ git_file_sets,
35
+ staged_test_running,
36
+ violation_scoping,
37
+ wrapper_plumb_check,
38
+ )
39
+ from pr_loop_shared_constants.code_rules_gate_constants import (
40
+ ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
41
+ ALL_PYTEST_MODULE_INVOCATION,
42
+ ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS,
43
+ EMPTY_FILE_SET_EXIT_CODE,
44
+ EMPTY_FILE_SET_MESSAGE,
45
+ INSPECTED_COUNT_MESSAGE,
46
+ MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS,
47
+ MINIMUM_STAGED_PYTEST_PYTHON_MAJOR,
48
+ MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
1438
49
  )
1439
-
1440
-
1441
- def _collect_partitioned_violations(
1442
- validate_content: ValidateContentCallable,
1443
- all_file_paths: list[Path],
1444
- repository_root: Path,
1445
- all_added_lines_by_path: dict[Path, set[int]] | None,
1446
- read_staged_content_flag: bool = False,
1447
- ) -> tuple[dict[Path, list[str]], dict[Path, list[str]], int]:
1448
- """Validate every targeted file and partition results by diff scope.
1449
-
1450
- Args:
1451
- validate_content: The enforcer ``validate_content`` callable.
1452
- all_file_paths: File paths to inspect.
1453
- repository_root: Repository root used to resolve relative paths.
1454
- all_added_lines_by_path: Optional per-file added-line maps used to
1455
- partition issues into blocking vs advisory.
1456
- read_staged_content_flag: When True, validate each file's staged blob
1457
- so the content source matches the staged diff.
1458
-
1459
- Returns:
1460
- ``(blocking_by_file, advisory_by_file, skipped_unreadable_count)`` where
1461
- the skipped count increments for every changed file that could not be
1462
- read, so the caller can fail closed on unvalidated files.
1463
- """
1464
- blocking_by_file: dict[Path, list[str]] = {}
1465
- advisory_by_file: dict[Path, list[str]] = {}
1466
- skipped_unreadable_count = 0
1467
- for each_path in sorted(set(all_file_paths)):
1468
- try:
1469
- resolved = each_path.resolve()
1470
- except OSError:
1471
- continue
1472
- try:
1473
- resolved.relative_to(repository_root.resolve())
1474
- except ValueError:
1475
- continue
1476
- if not _path_is_eligible_for_validation(
1477
- resolved, repository_root, read_staged_content_flag
1478
- ):
1479
- continue
1480
- all_added_lines_for_file = (
1481
- None
1482
- if all_added_lines_by_path is None
1483
- else all_added_lines_by_path.get(resolved)
1484
- )
1485
- scoped_violations = _scoped_violations_for_file(
1486
- validate_content, resolved, repository_root,
1487
- all_added_lines_for_file, read_staged_content_flag,
1488
- )
1489
- if scoped_violations is None:
1490
- skipped_unreadable_count += 1
1491
- continue
1492
- blocking, advisory = scoped_violations
1493
- if blocking:
1494
- blocking_by_file[resolved] = blocking
1495
- if advisory:
1496
- advisory_by_file[resolved] = advisory
1497
- return blocking_by_file, advisory_by_file, skipped_unreadable_count
50
+ from pr_loop_shared_constants.preflight_constants import (
51
+ PYTEST_NO_TESTS_COLLECTED_EXIT_CODE,
52
+ )
53
+ from terminology_sweep import repository_environment, staged_terminology_findings
54
+ except ImportError as import_error:
55
+ raise ImportError(
56
+ "code_rules_gate: could not import its code_rules_gate_parts submodules; "
57
+ "ensure the pr-loop scripts directory is importable."
58
+ ) from import_error
59
+
60
+ subprocess = git_file_sets.subprocess
61
+
62
+ resolve_claude_dev_env_root = enforcer_loading.resolve_claude_dev_env_root
63
+ load_validate_content = enforcer_loading.load_validate_content
64
+ ValidateContentCallable = enforcer_loading.ValidateContentCallable
65
+
66
+ resolve_merge_base = git_file_sets.resolve_merge_base
67
+ paths_from_git_staged = git_file_sets.paths_from_git_staged
68
+ paths_from_git_diff = git_file_sets.paths_from_git_diff
69
+ paths_from_git_untracked = git_file_sets.paths_from_git_untracked
70
+ filter_paths_under_prefixes = git_file_sets.filter_paths_under_prefixes
71
+ is_staged_file_newly_added = git_file_sets.is_staged_file_newly_added
72
+ staged_file_line_count = git_file_sets.staged_file_line_count
73
+ staged_unified_diff_text = git_file_sets.staged_unified_diff_text
74
+
75
+ read_prior_committed_content = git_blob_readers.read_prior_committed_content
76
+ read_staged_content = git_blob_readers.read_staged_content
77
+ staged_blob_exists = git_blob_readers.staged_blob_exists
78
+
79
+ parse_added_line_numbers = violation_scoping.parse_added_line_numbers
80
+ hunk_header_pattern = violation_scoping.hunk_header_pattern
81
+ violation_line_pattern = violation_scoping.violation_line_pattern
82
+ extract_violation_line_number = violation_scoping.extract_violation_line_number
83
+ function_length_span_range = violation_scoping.function_length_span_range
84
+ isolation_span_range = violation_scoping.isolation_span_range
85
+ banned_noun_span_range = violation_scoping.banned_noun_span_range
86
+ duplicate_body_span_range = violation_scoping.duplicate_body_span_range
87
+ inline_duplicate_body_span_lines = violation_scoping.inline_duplicate_body_span_lines
88
+ enclosing_span_range = violation_scoping.enclosing_span_range
89
+ split_violations_by_scope = violation_scoping.split_violations_by_scope
90
+
91
+ added_lines_for_file = added_line_maps.added_lines_for_file
92
+ added_lines_for_renamed_file = added_line_maps.added_lines_for_renamed_file
93
+ renamed_file_source_map_since = added_line_maps.renamed_file_source_map_since
94
+ added_lines_by_file = added_line_maps.added_lines_by_file
95
+ is_file_new_at_base = added_line_maps.is_file_new_at_base
96
+ whole_file_line_set = added_line_maps.whole_file_line_set
97
+
98
+ check_wrapper_plumb_through = wrapper_plumb_check.check_wrapper_plumb_through
99
+ is_code_path = wrapper_plumb_check.is_code_path
100
+ is_test_path = wrapper_plumb_check.is_test_path
101
+
102
+ run_gate = gate_running.run_gate
103
+ print_violation_section = gate_running.print_violation_section
104
+ _collect_partitioned_violations = gate_running._collect_partitioned_violations
105
+ _scoped_violations_for_file = gate_running._scoped_violations_for_file
106
+ _report_terminology_findings = gate_running._report_terminology_findings
1498
107
 
1499
108
 
1500
109
  def _report_partitioned_violations(
@@ -1504,407 +113,101 @@ def _report_partitioned_violations(
1504
113
  is_whole_file_scope: bool,
1505
114
  skipped_unreadable_count: int,
1506
115
  ) -> int:
1507
- """Print the blocking and advisory sections and return the gate exit code.
116
+ """Print the violation sections and return the gate exit code.
117
+
118
+ Importers outside this directory (the code-verifier spawn-preflight hook)
119
+ call this surface with the partition spread across positional arguments,
120
+ so this wrapper keeps that calling shape and folds the pieces into the
121
+ ``PartitionedViolations`` tuple the gate-running module consumes.
1508
122
 
1509
123
  Args:
1510
124
  blocking_by_file: Blocking violations grouped by resolved file path.
1511
125
  advisory_by_file: Advisory violations grouped by resolved file path.
1512
- repository_root: Repository root used to compute relative paths.
1513
- is_whole_file_scope: True when no per-file added-line map was supplied,
1514
- which selects the whole-file header wording.
1515
- skipped_unreadable_count: Count of changed files that could not be read;
1516
- a non-zero count forces a non-zero exit because the gate cannot
1517
- vouch for those files.
1518
-
1519
- Returns:
1520
- Zero when no blocking violation was found and no file was skipped;
1521
- non-zero otherwise.
1522
- """
1523
- blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
1524
- advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
1525
- if blocking_count:
1526
- if is_whole_file_scope:
1527
- header = f"code_rules_gate: {blocking_count} violation(s) reported."
1528
- else:
1529
- header = (
1530
- f"code_rules_gate: {blocking_count} violation(s) "
1531
- "introduced on changed lines:"
1532
- )
1533
- print_violation_section(header, blocking_by_file, repository_root)
1534
- if advisory_count:
1535
- if blocking_count:
1536
- print("", file=sys.stderr)
1537
- print_violation_section(
1538
- (
1539
- f"code_rules_gate: {advisory_count} pre-existing violation(s) "
1540
- "in touched files (advisory, not blocking):"
1541
- ),
1542
- advisory_by_file,
1543
- repository_root,
1544
- )
1545
- if skipped_unreadable_count:
1546
- print(
1547
- f"code_rules_gate: {skipped_unreadable_count} file(s) "
1548
- "skipped due to read errors; gate cannot vouch for those files.",
1549
- file=sys.stderr,
1550
- )
1551
- if blocking_count or skipped_unreadable_count:
1552
- return 1
1553
- return 0
1554
-
1555
-
1556
- def _staged_test_file_paths(repository_root: Path) -> list[Path]:
1557
- """Return the staged Python test files that exist under a repository.
1558
-
1559
- Args:
1560
- repository_root: The repository root whose staged index is read.
1561
-
1562
- Returns:
1563
- The staged paths whose extension is Python, whose name matches a
1564
- test-file pattern, and which exist on disk.
1565
- """
1566
- all_test_paths: list[Path] = []
1567
- for each_path in paths_from_git_staged(repository_root):
1568
- if each_path.suffix != PYTHON_FILE_EXTENSION:
1569
- continue
1570
- if is_test_path(str(each_path)) and each_path.is_file():
1571
- all_test_paths.append(each_path)
1572
- return all_test_paths
1573
-
1574
-
1575
- def _directory_holds_pytest_config(directory: Path) -> bool:
1576
- """Return True when *directory* holds a recognized pytest configuration file."""
1577
- for each_filename, each_required_section in ALL_PYTEST_CONFIG_FILE_SECTIONS:
1578
- config_path = directory / each_filename
1579
- if not config_path.is_file():
1580
- continue
1581
- if each_required_section is None:
1582
- return True
1583
- try:
1584
- config_text = config_path.read_text(encoding="utf-8", errors="replace")
1585
- except OSError:
1586
- continue
1587
- if each_required_section in config_text:
1588
- return True
1589
- return False
1590
-
1591
-
1592
- def _resolve_owning_test_root(test_file_path: Path, repository_root: Path) -> Path:
1593
- """Return the nearest ancestor of *test_file_path* that owns a pytest config.
1594
-
1595
- Walks up from the test file to the first directory holding a pytest config,
1596
- stopping at *repository_root*, which is the fallback when none is found.
1597
-
1598
- Args:
1599
- test_file_path: The staged test file to resolve a root for.
1600
- repository_root: The repository root that bounds the upward walk.
1601
-
1602
- Returns:
1603
- The resolved owning test root directory.
1604
- """
1605
- resolved_repository_root = repository_root.resolve()
1606
- for each_ancestor in test_file_path.resolve().parents:
1607
- if _directory_holds_pytest_config(each_ancestor):
1608
- return each_ancestor
1609
- if each_ancestor == resolved_repository_root:
1610
- return resolved_repository_root
1611
- return resolved_repository_root
1612
-
1613
-
1614
- def _group_staged_tests_by_root(
1615
- all_test_paths: list[Path],
1616
- repository_root: Path,
1617
- ) -> dict[Path, list[Path]]:
1618
- """Group *all_test_paths* by their owning pytest-config root.
1619
-
1620
- Args:
1621
- all_test_paths: The staged test files to partition.
1622
- repository_root: The repository root that bounds each upward walk.
1623
-
1624
- Returns:
1625
- A mapping from owning test root to the staged test files under it.
1626
- """
1627
- tests_by_root: dict[Path, list[Path]] = {}
1628
- for each_test_path in all_test_paths:
1629
- owning_root = _resolve_owning_test_root(each_test_path, repository_root)
1630
- tests_by_root.setdefault(owning_root, []).append(each_test_path)
1631
- return tests_by_root
1632
-
1633
-
1634
- def _venv_python_relative_path() -> tuple[str, ...]:
1635
- """Return the venv-relative python executable path segments for this platform.
1636
-
1637
- Returns:
1638
- The Windows ``Scripts/python.exe`` segments on Windows, the POSIX
1639
- ``bin/python`` segments on every other platform.
1640
- """
1641
- if os.name == "nt":
1642
- return ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
1643
- return ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS
1644
-
1645
-
1646
- def _venv_python_executable(repository_root: Path) -> Path | None:
1647
- """Return the first existing venv interpreter under *repository_root*.
1648
-
1649
- Checks each directory name in ``ALL_VENV_DIRECTORY_NAMES`` in order and
1650
- returns the platform-specific interpreter path the first time it exists
1651
- on disk.
1652
-
1653
- Args:
1654
- repository_root: The repository root to search for a venv directory.
1655
-
1656
- Returns:
1657
- The resolved interpreter path, or None when no candidate exists.
1658
- """
1659
- relative_python_path = _venv_python_relative_path()
1660
- for each_venv_directory_name in ALL_VENV_DIRECTORY_NAMES:
1661
- candidate_path = repository_root.joinpath(
1662
- each_venv_directory_name, *relative_python_path
1663
- )
1664
- if candidate_path.is_file():
1665
- return candidate_path
1666
- return None
1667
-
1668
-
1669
- def _resolve_gate_python_executable(repository_root: Path) -> str:
1670
- """Return the interpreter the staged-test subprocess runs under.
1671
-
1672
- Resolves in order: the ``CODE_RULES_GATE_PYTHON`` environment variable
1673
- when set, then the first existing project venv interpreter under
1674
- *repository_root*, then the interpreter running the gate itself.
1675
-
1676
- Args:
1677
- repository_root: The repository root used to locate a project venv.
1678
-
1679
- Returns:
1680
- The absolute path (or command name) of the interpreter to invoke.
1681
- """
1682
- configured_python = os.environ.get(CODE_RULES_GATE_PYTHON_ENV_VAR)
1683
- if configured_python:
1684
- return configured_python
1685
- venv_python = _venv_python_executable(repository_root)
1686
- if venv_python is not None:
1687
- return str(venv_python)
1688
- return sys.executable
1689
-
1690
-
1691
- def _staged_pytest_environment() -> dict[str, str]:
1692
- """Return the subprocess environment for the staged-test pytest run.
1693
-
1694
- Starts from ``repository_environment()`` (the GIT_-scrubbed process
1695
- environment) and, when ``CODE_RULES_GATE_PYTHONPATH`` is set, prepends
1696
- its value to ``PYTHONPATH`` so a resolved venv interpreter that lacks the
1697
- project on its default path can still import it.
1698
-
1699
- Returns:
1700
- The environment mapping to pass to the staged-test subprocess.
1701
- """
1702
- environment = repository_environment()
1703
- configured_pythonpath = os.environ.get(CODE_RULES_GATE_PYTHONPATH_ENV_VAR)
1704
- if not configured_pythonpath:
1705
- return environment
1706
- existing_pythonpath = environment.get(PYTHONPATH_ENV_VAR, "")
1707
- environment[PYTHONPATH_ENV_VAR] = (
1708
- configured_pythonpath
1709
- if not existing_pythonpath
1710
- else os.pathsep.join([configured_pythonpath, existing_pythonpath])
1711
- )
1712
- return environment
1713
-
1714
-
1715
- def _relative_pytest_argument(test_path: Path, group_root: Path) -> str:
1716
- """Express *test_path* as a pytest argument relative to *group_root*.
1717
-
1718
- *group_root* is already the pytest working directory, so the relative form
1719
- names the same file in far fewer characters.
1720
-
1721
- Example::
1722
-
1723
- group_root = C:/repo/package
1724
- ok: C:/repo/package/tests/test_a.py -> "tests/test_a.py"
1725
- flag: C:/elsewhere/test_b.py -> no relative form; passed absolute
1726
-
1727
- Args:
1728
- test_path: The staged test file to express as a pytest argument.
1729
- group_root: The pytest working directory for the group.
1730
-
1731
- Returns:
1732
- The path relative to *group_root* when *test_path* sits under it,
1733
- otherwise the absolute path unchanged.
1734
- """
1735
- try:
1736
- return str(test_path.relative_to(group_root))
1737
- except ValueError:
1738
- return str(test_path)
1739
-
1740
-
1741
- def _batched_pytest_arguments(
1742
- all_pytest_arguments: list[str], character_budget: int
1743
- ) -> list[list[str]]:
1744
- """Split pytest path arguments into command-line-length-safe batches.
1745
-
1746
- Windows rejects a command line past 32767 characters with WinError 206, so a
1747
- large staged set passed in one invocation crashes the gate and denies the
1748
- commit. Each batch takes arguments until the next one would push its joined
1749
- length past *character_budget*.
1750
-
1751
- Example::
1752
-
1753
- character_budget = 10
1754
- arguments = ["aaaa", "bbbb", "cccc"]
1755
- ok: [["aaaa", "bbbb"], ["cccc"]] - every batch within budget
1756
- flag: [["aaaa", "bbbb", "cccc"]] - 14 characters, over budget
1757
-
1758
- Args:
1759
- all_pytest_arguments: The path arguments to distribute, in order.
1760
- character_budget: The joined length each batch must stay within. An
1761
- argument wider than the budget on its own still lands in a batch
1762
- by itself, so no argument is ever dropped.
126
+ repository_root: Repository root for computing relative paths.
127
+ is_whole_file_scope: True when findings cover whole files, not a diff.
128
+ skipped_unreadable_count: Count of files skipped due to read errors.
1763
129
 
1764
130
  Returns:
1765
- One argument list per pytest invocation, preserving input order.
131
+ The gate exit code: 1 when anything blocks, 0 otherwise.
1766
132
  """
1767
- all_batches: list[list[str]] = []
1768
- current_batch: list[str] = []
1769
- current_length = 0
1770
- for each_argument in all_pytest_arguments:
1771
- argument_length = len(each_argument) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
1772
- if current_batch and current_length + argument_length > character_budget:
1773
- all_batches.append(current_batch)
1774
- current_batch = []
1775
- current_length = 0
1776
- current_batch.append(each_argument)
1777
- current_length += argument_length
1778
- if current_batch:
1779
- all_batches.append(current_batch)
1780
- return all_batches
1781
-
1782
-
1783
- def _pytest_batch_exit_code(all_batch_command: list[str], group_root: Path) -> int:
1784
- """Run one pytest invocation and normalize its exit code.
1785
-
1786
- Args:
1787
- all_batch_command: The full argv for this pytest invocation.
1788
- group_root: The owning test root used as the working directory.
1789
-
1790
- Returns:
1791
- 0 when the batch passes or collects no tests; pytest's non-zero exit
1792
- code otherwise.
1793
- """
1794
- pytest_process = subprocess.run(
1795
- all_batch_command,
1796
- cwd=str(group_root),
1797
- timeout=STAGED_PYTEST_TIMEOUT_SECONDS,
1798
- check=False,
1799
- env=_staged_pytest_environment(),
1800
- )
1801
- if pytest_process.returncode == PYTEST_NO_TESTS_COLLECTED_EXIT_CODE:
1802
- return 0
1803
- return pytest_process.returncode
1804
-
1805
-
1806
- def _run_pytest_for_group(
1807
- group_root: Path, all_group_test_paths: list[Path], repository_root: Path
1808
- ) -> int:
1809
- """Run pytest over one group's test files with the working directory at its root.
1810
-
1811
- Paths are passed relative to *group_root* (the subprocess working directory)
1812
- and split into batches whose joined command line stays within
1813
- ``MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS``, so a large staged set never
1814
- trips the Windows 32767-character limit. A staged set that fits the budget runs
1815
- as one invocation, exactly as an unbatched run did.
1816
-
1817
- Example::
1818
-
1819
- ok: 3 staged tests -> 1 pytest invocation, argv well under the budget
1820
- flag: 800 staged tests -> 1 pytest invocation, argv over the Windows limit
1821
- (WinError 206); batching splits it across several
133
+ return gate_running._report_partitioned_violations(
134
+ (blocking_by_file, advisory_by_file, skipped_unreadable_count),
135
+ repository_root,
136
+ is_whole_file_scope,
137
+ )
138
+
139
+ run_staged_test_files = staged_test_running.run_staged_test_files
140
+ _staged_test_file_paths = staged_test_running._staged_test_file_paths
141
+ _resolve_owning_test_root = staged_test_running._resolve_owning_test_root
142
+ _group_staged_tests_by_root = staged_test_running._group_staged_tests_by_root
143
+ _batched_pytest_arguments = staged_test_running._batched_pytest_arguments
144
+ _resolve_gate_python_executable = staged_test_running._resolve_gate_python_executable
145
+ _staged_pytest_environment = staged_test_running._staged_pytest_environment
146
+ _relative_pytest_argument = staged_test_running._relative_pytest_argument
147
+ _run_pytest_for_group = staged_test_running._run_pytest_for_group
148
+
149
+ parse_arguments = gate_arguments.parse_arguments
150
+
151
+ __all__ = [
152
+ "ALL_POSIX_VENV_PYTHON_RELATIVE_PATH_SEGMENTS",
153
+ "ALL_PYTEST_MODULE_INVOCATION",
154
+ "ALL_WINDOWS_VENV_PYTHON_RELATIVE_PATH_SEGMENTS",
155
+ "MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS",
156
+ "MINIMUM_STAGED_PYTEST_PYTHON_MAJOR",
157
+ "MINIMUM_STAGED_PYTEST_PYTHON_MINOR",
158
+ "PYTEST_NO_TESTS_COLLECTED_EXIT_CODE",
159
+ "repository_environment",
160
+ "subprocess",
161
+ ]
162
+
163
+
164
+ def added_lines_for_staged_file(repository_root: Path, relative_path_posix: str) -> set[int]:
165
+ """Return added line numbers within the staged diff for one file.
1822
166
 
1823
167
  Args:
1824
- group_root: The owning test root used as the pytest working directory.
1825
- all_group_test_paths: The staged test files that share *group_root*.
1826
- repository_root: The repository root used to resolve a project venv
1827
- interpreter when ``CODE_RULES_GATE_PYTHON`` is not set.
168
+ repository_root: Repository root used as the ``git -C`` target.
169
+ relative_path_posix: Repository-relative POSIX path to inspect.
1828
170
 
1829
171
  Returns:
1830
- 0 when every batch passes or collects nothing; the first failing batch's
1831
- non-zero pytest exit code otherwise.
172
+ Line numbers added in the staged diff, or the whole staged blob when
173
+ the file is newly added.
1832
174
  """
1833
- all_fixed_command = [
1834
- _resolve_gate_python_executable(repository_root),
1835
- *ALL_PYTEST_MODULE_INVOCATION,
1836
- ]
1837
- fixed_command_length = sum(
1838
- len(each_part) + COMMAND_LINE_ARGUMENT_SEPARATOR_LENGTH
1839
- for each_part in all_fixed_command
1840
- )
1841
- all_batches = _batched_pytest_arguments(
1842
- [
1843
- _relative_pytest_argument(each_path, group_root)
1844
- for each_path in all_group_test_paths
1845
- ],
1846
- MAXIMUM_STAGED_PYTEST_COMMAND_LINE_CHARACTERS - fixed_command_length,
1847
- )
1848
- first_failing_exit_code = 0
1849
- for each_batch in all_batches:
1850
- batch_exit_code = _pytest_batch_exit_code(
1851
- [*all_fixed_command, *each_batch], group_root
1852
- )
1853
- if batch_exit_code != 0 and first_failing_exit_code == 0:
1854
- first_failing_exit_code = batch_exit_code
1855
- return first_failing_exit_code
1856
-
175
+ diff_text = staged_unified_diff_text(repository_root, relative_path_posix)
176
+ if diff_text.strip():
177
+ return parse_added_line_numbers(diff_text)
178
+ if is_staged_file_newly_added(repository_root, relative_path_posix):
179
+ total_lines = staged_file_line_count(repository_root, relative_path_posix)
180
+ if total_lines > 0:
181
+ return set(range(1, total_lines + 1))
182
+ return set()
1857
183
 
1858
- def run_staged_test_files(repository_root: Path) -> int:
1859
- """Run pytest over the staged test files and return the gate exit code.
1860
184
 
1861
- Groups the staged test files by their owning pytest-config root and runs one
1862
- pytest session per group with the working directory at that root, so two
1863
- packages that expose a same-named top-level package never share one session
1864
- and shadow each other's imports.
185
+ def added_lines_by_file_staged(
186
+ repository_root: Path, all_file_paths: list[Path]
187
+ ) -> dict[Path, set[int]]:
188
+ """Build a per-file map of staged-added line numbers.
1865
189
 
1866
190
  Args:
1867
- repository_root: The repository root the staged test files belong to.
191
+ repository_root: Repository root for diff invocations.
192
+ all_file_paths: File paths whose staged-added lines are collected.
1868
193
 
1869
194
  Returns:
1870
- 0 when no test file is staged, when every group collects no tests, or
1871
- when every group passes. The first failing group's non-zero pytest exit
1872
- code otherwise, which blocks the commit.
195
+ Mapping from resolved file path to its staged-added line numbers.
1873
196
  """
1874
- all_test_paths = _staged_test_file_paths(repository_root)
1875
- if not all_test_paths:
1876
- return 0
1877
- tests_by_root = _group_staged_tests_by_root(all_test_paths, repository_root)
1878
- first_failing_exit_code = 0
1879
- for each_group_root in sorted(tests_by_root):
1880
- group_exit_code = _run_pytest_for_group(
1881
- each_group_root, tests_by_root[each_group_root], repository_root
1882
- )
1883
- if group_exit_code != 0:
1884
- print(
1885
- STAGED_TEST_GROUP_FAILURE_MESSAGE.format(group_root=each_group_root),
1886
- file=sys.stderr,
1887
- )
1888
- if first_failing_exit_code == 0:
1889
- first_failing_exit_code = group_exit_code
1890
- if first_failing_exit_code != 0:
1891
- print(STAGED_TEST_FAILURE_HEADER, file=sys.stderr)
1892
- return first_failing_exit_code
197
+ resolved_root = repository_root.resolve()
198
+ added_by_path: dict[Path, set[int]] = {}
199
+ for each_path in all_file_paths:
200
+ resolved = added_line_maps._resolved_under_root(each_path, resolved_root)
201
+ if resolved is None:
202
+ continue
203
+ relative_posix = str(resolved.relative_to(resolved_root)).replace("\\", "/")
204
+ added_by_path[resolved] = added_lines_for_staged_file(resolved_root, relative_posix)
205
+ return added_by_path
1893
206
 
1894
207
 
1895
208
  def _staged_pytest_exit_code_for_current_python(repository_root: Path) -> int:
1896
209
  """Run the staged test files, or skip them on a Python below the minimum.
1897
210
 
1898
- ::
1899
-
1900
- running (3, 11), minimum (3, 12) -> log a notice and return 0 (skipped)
1901
- running (3, 12), minimum (3, 12) -> run pytest over the staged tests
1902
-
1903
- The staged tests call newer-Python APIs, so an older interpreter fails them
1904
- for the environment rather than for a code defect. On such a runtime this
1905
- step is skipped so the gap does not block the commit, while the terminology
1906
- and code-rules checks still run.
1907
-
1908
211
  Args:
1909
212
  repository_root: The repository root whose staged test files run.
1910
213
 
@@ -1918,87 +221,102 @@ def _staged_pytest_exit_code_for_current_python(repository_root: Path) -> int:
1918
221
  MINIMUM_STAGED_PYTEST_PYTHON_MINOR,
1919
222
  )
1920
223
  if running_version < minimum_version:
1921
- print(
224
+ sys.stderr.write(
1922
225
  f"code_rules_gate: Python {running_version} is below the staged-test "
1923
- f"minimum {minimum_version}; skipping the staged "
1924
- "pytest step (environment gap, not a code failure).",
1925
- file=sys.stderr,
226
+ f"minimum {minimum_version}; skipping the staged pytest step.\n"
1926
227
  )
1927
228
  return 0
1928
229
  return run_staged_test_files(repository_root)
1929
230
 
1930
231
 
1931
- def _report_terminology_findings(all_findings: list[str]) -> None:
1932
- """Print the terminology-sweep findings, when any, to standard error.
232
+ def _deduplicate_paths(all_paths: list[Path]) -> list[Path]:
233
+ """Return *all_paths* with duplicates removed, preserving first-seen order."""
234
+ return list(dict.fromkeys(all_paths))
1933
235
 
1934
- Args:
1935
- all_findings: The near-miss findings from the staged terminology sweep.
236
+
237
+ def _report_empty_file_set() -> int:
238
+ """Report an empty resolved file set loudly and exit non-zero.
239
+
240
+ Zero candidate files means the gate inspected nothing, and a bad merge
241
+ base or a wrong directory produces exactly this state. A quiet pass here
242
+ would be trusted like a real pass (issue #62), so the run refuses with
243
+ its own exit code, distinct from the violation and error codes. A set
244
+ emptied only by the ``--only-under`` scope never reaches this reporter.
1936
245
  """
1937
- if not all_findings:
1938
- return
1939
- print(
1940
- TERMINOLOGY_SWEEP_GATE_HEADER.format(finding_count=len(all_findings)),
1941
- file=sys.stderr,
246
+ sys.stderr.write(EMPTY_FILE_SET_MESSAGE + "\n")
247
+ sys.stderr.write(INSPECTED_COUNT_MESSAGE.format(inspected_count=0) + "\n")
248
+ return EMPTY_FILE_SET_EXIT_CODE
249
+
250
+
251
+ def _run_explicit_paths_mode(
252
+ validate_content: enforcer_loading.ValidateContentCallable,
253
+ arguments: argparse.Namespace,
254
+ repository_root: Path,
255
+ ) -> int:
256
+ """Validate the explicit paths named on the command line."""
257
+ all_explicit_paths = [repository_root / each_path for each_path in arguments.paths]
258
+ return run_gate(
259
+ validate_content,
260
+ all_explicit_paths,
261
+ repository_root,
262
+ all_added_lines_by_path=None,
1942
263
  )
1943
- for each_finding in all_findings:
1944
- print(f" {each_finding}", file=sys.stderr)
1945
264
 
1946
265
 
1947
- def parse_arguments(all_arguments: list[str]) -> argparse.Namespace:
1948
- """Parse the command-line arguments for the code-rules gate.
266
+ def _run_staged_mode(
267
+ validate_content: enforcer_loading.ValidateContentCallable,
268
+ arguments: argparse.Namespace,
269
+ repository_root: Path,
270
+ ) -> int:
271
+ """Validate the staged changes, run staged tests, and sweep terminology."""
272
+ _report_terminology_findings(staged_terminology_findings(repository_root))
273
+ staged_test_exit_code = _staged_pytest_exit_code_for_current_python(repository_root)
274
+ staged_file_paths = filter_paths_under_prefixes(
275
+ paths_from_git_staged(repository_root), repository_root, arguments.only_under
276
+ )
277
+ if not staged_file_paths:
278
+ sys.stderr.write(INSPECTED_COUNT_MESSAGE.format(inspected_count=0) + "\n")
279
+ return staged_test_exit_code
280
+ staged_added_lines = added_lines_by_file_staged(repository_root, staged_file_paths)
281
+ gate_exit_code = run_gate(
282
+ validate_content,
283
+ staged_file_paths,
284
+ repository_root,
285
+ all_added_lines_by_path=staged_added_lines,
286
+ should_read_staged_content=True,
287
+ )
288
+ return gate_exit_code or staged_test_exit_code
289
+
1949
290
 
1950
- Args:
1951
- all_arguments: Command-line argument list forwarded to argparse.
291
+ def _run_diff_mode(
292
+ validate_content: enforcer_loading.ValidateContentCallable,
293
+ arguments: argparse.Namespace,
294
+ repository_root: Path,
295
+ ) -> int:
296
+ """Validate the merge-base diff joined with the untracked files.
1952
297
 
1953
- Returns:
1954
- The parsed argparse namespace with ``repo_root``, ``base``,
1955
- ``staged``, ``only_under``, and ``paths`` attributes.
298
+ Zero candidates means nothing was inspected (bad wiring looks the same),
299
+ so that run refuses loudly. A set emptied only by the ``--only-under``
300
+ scope flows through ``run_gate`` over zero files and exits clean.
1956
301
  """
1957
- parser = argparse.ArgumentParser(
1958
- description=(
1959
- "Run CODE_RULES validators (validate_content) on files in the working tree. "
1960
- "Default file set: git diff --name-only merge-base(base)..HEAD."
1961
- ),
1962
- )
1963
- parser.add_argument(
1964
- "--repo-root",
1965
- type=Path,
1966
- default=None,
1967
- help="Repository root (default: cwd).",
302
+ all_candidate_paths = _deduplicate_paths(
303
+ paths_from_git_diff(repository_root, arguments.base)
304
+ + paths_from_git_untracked(repository_root)
1968
305
  )
1969
- parser.add_argument(
1970
- "--base",
1971
- default="origin/main",
1972
- help="Merge-base ref for git diff (default: origin/main).",
1973
- )
1974
- parser.add_argument(
1975
- "--staged",
1976
- action="store_true",
1977
- default=False,
1978
- help=(
1979
- "Scope to staged changes only (git diff --cached). "
1980
- "Blocks on violations introduced on staged-added lines; "
1981
- "reports pre-existing violations in touched files as advisory."
1982
- ),
306
+ if not all_candidate_paths:
307
+ return _report_empty_file_set()
308
+ file_paths = filter_paths_under_prefixes(
309
+ all_candidate_paths, repository_root, arguments.only_under
1983
310
  )
1984
- parser.add_argument(
1985
- "--only-under",
1986
- action="append",
1987
- default=[],
1988
- dest="only_under",
1989
- metavar="PREFIX",
1990
- help=(
1991
- "After resolving the merge-base diff, keep only files whose repo-relative path "
1992
- "uses POSIX slashes and starts with PREFIX or equals PREFIX (repeatable)."
1993
- ),
311
+ scoped_added_lines = (
312
+ added_lines_by_file(repository_root, arguments.base, file_paths) if file_paths else {}
1994
313
  )
1995
- parser.add_argument(
1996
- "paths",
1997
- nargs="*",
1998
- type=Path,
1999
- help="Optional explicit files; if set, git diff is not used.",
314
+ return run_gate(
315
+ validate_content,
316
+ file_paths,
317
+ repository_root,
318
+ all_added_lines_by_path=scoped_added_lines,
2000
319
  )
2001
- return parser.parse_args(all_arguments)
2002
320
 
2003
321
 
2004
322
  def main(all_arguments: list[str]) -> int:
@@ -2008,63 +326,18 @@ def main(all_arguments: list[str]) -> int:
2008
326
  all_arguments: Command-line argument list forwarded to argparse.
2009
327
 
2010
328
  Returns:
2011
- The exit code from ``run_gate`` (``0`` clean, ``1`` blocking
2012
- violations).
329
+ The gate exit code for the selected mode.
2013
330
  """
2014
331
  arguments = parse_arguments(all_arguments)
2015
332
  repository_root = (
2016
- arguments.repo_root.resolve()
2017
- if arguments.repo_root is not None
2018
- else Path.cwd().resolve()
333
+ arguments.repo_root.resolve() if arguments.repo_root is not None else Path.cwd().resolve()
2019
334
  )
2020
335
  validate_content = load_validate_content()
2021
336
  if arguments.paths:
2022
- file_paths = [repository_root / each_path for each_path in arguments.paths]
2023
- return run_gate(
2024
- validate_content, file_paths, repository_root, all_added_lines_by_path=None
2025
- )
337
+ return _run_explicit_paths_mode(validate_content, arguments, repository_root)
2026
338
  if arguments.staged:
2027
- all_terminology_findings = staged_terminology_findings(repository_root)
2028
- _report_terminology_findings(all_terminology_findings)
2029
- staged_test_exit_code = _staged_pytest_exit_code_for_current_python(
2030
- repository_root
2031
- )
2032
- staged_file_paths = paths_from_git_staged(repository_root)
2033
- staged_file_paths = filter_paths_under_prefixes(
2034
- staged_file_paths,
2035
- repository_root,
2036
- arguments.only_under,
2037
- )
2038
- if not staged_file_paths:
2039
- return staged_test_exit_code
2040
- staged_added_lines = added_lines_by_file_staged(
2041
- repository_root, staged_file_paths
2042
- )
2043
- gate_exit_code = run_gate(
2044
- validate_content,
2045
- staged_file_paths,
2046
- repository_root,
2047
- all_added_lines_by_path=staged_added_lines,
2048
- read_staged_content_flag=True,
2049
- )
2050
- return gate_exit_code or staged_test_exit_code
2051
- file_paths = paths_from_git_diff(repository_root, arguments.base)
2052
- file_paths = filter_paths_under_prefixes(
2053
- file_paths,
2054
- repository_root,
2055
- arguments.only_under,
2056
- )
2057
- if not file_paths:
2058
- return 0
2059
- scoped_added_lines = added_lines_by_file(
2060
- repository_root, arguments.base, file_paths
2061
- )
2062
- return run_gate(
2063
- validate_content,
2064
- file_paths,
2065
- repository_root,
2066
- all_added_lines_by_path=scoped_added_lines,
2067
- )
339
+ return _run_staged_mode(validate_content, arguments, repository_root)
340
+ return _run_diff_mode(validate_content, arguments, repository_root)
2068
341
 
2069
342
 
2070
343
  if __name__ == "__main__":