claude-dev-env 1.95.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (241) 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/test_verifier_verdict_minter.py +55 -158
  117. package/hooks/blocking/tests/conftest.py +10 -0
  118. package/hooks/blocking/tests/test_pii_prevention_blocker.py +260 -0
  119. package/hooks/blocking/tests/test_repository_exemption.py +105 -0
  120. package/hooks/blocking/tests/test_repository_resolution.py +108 -0
  121. package/hooks/diagnostic/hook_log_extractor.py +12 -10
  122. package/hooks/git-hooks/post_commit.py +3 -4
  123. package/hooks/hooks_constants/CLAUDE.md +2 -2
  124. package/hooks/hooks_constants/banned_identifiers_constants.py +0 -1
  125. package/hooks/hooks_constants/code_rules_path_utils_constants.py +1 -1
  126. package/hooks/hooks_constants/local_identity.py +59 -8
  127. package/hooks/hooks_constants/pii_prevention_constants.py +0 -6
  128. package/hooks/hooks_constants/test_local_identity.py +105 -3
  129. package/hooks/pyproject.toml +13 -36
  130. package/hooks/session/plugin_data_dir_cleanup.py +0 -1
  131. package/hooks/validation/mypy_validator.py +2 -2
  132. package/hooks/validators/health_check.py +1 -0
  133. package/hooks/validators/mypy_integration.py +2 -0
  134. package/hooks/validators/ruff_integration.py +3 -0
  135. package/hooks/workflow/auto_formatter.py +5 -4
  136. package/package.json +1 -1
  137. package/scripts/CLAUDE.md +4 -0
  138. package/scripts/dev_env_scripts_constants/CLAUDE.md +6 -4
  139. package/scripts/dev_env_scripts_constants/code_review_constants.py +71 -0
  140. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +435 -0
  141. package/scripts/dev_env_scripts_constants/timing.py +7 -1
  142. package/scripts/grok_headless_runner.py +294 -0
  143. package/scripts/grok_worker_preflight.py +410 -0
  144. package/scripts/invoke_code_review.py +463 -0
  145. package/scripts/resolve_worker_spawn.py +619 -0
  146. package/scripts/spawn_grok_batch.py +672 -0
  147. package/scripts/test_grok_headless_runner.py +626 -0
  148. package/scripts/test_grok_worker_preflight.py +1054 -0
  149. package/scripts/test_invoke_code_review.py +672 -0
  150. package/scripts/test_resolve_worker_spawn.py +1014 -0
  151. package/scripts/test_spawn_grok_batch.py +1017 -0
  152. package/skills/CLAUDE.md +5 -3
  153. package/skills/_shared/pr-loop/scripts/build_audit_prompt.py +72 -13
  154. package/skills/_shared/pr-loop/scripts/build_fix_prompt.py +121 -14
  155. package/skills/_shared/pr-loop/scripts/skills_pr_loop_constants/path_resolver_constants.py +78 -0
  156. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +121 -0
  157. package/skills/_shared/pr-loop/scripts/test_build_fix_prompt.py +196 -6
  158. package/skills/autoconverge/CLAUDE.md +3 -3
  159. package/skills/autoconverge/SKILL.md +9 -3
  160. package/skills/autoconverge/reference/CLAUDE.md +2 -2
  161. package/skills/autoconverge/reference/convergence.md +33 -11
  162. package/skills/autoconverge/reference/stop-conditions.md +16 -5
  163. package/skills/autoconverge/workflow/CLAUDE.md +2 -1
  164. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +7 -2
  165. package/skills/autoconverge/workflow/converge.codex-gate.test.mjs +300 -0
  166. package/skills/autoconverge/workflow/converge.contract.test.mjs +5 -5
  167. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +29 -29
  168. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  169. package/skills/autoconverge/workflow/converge.mjs +200 -16
  170. package/skills/bugteam/CLAUDE.md +2 -2
  171. package/skills/bugteam/CONSTRAINTS.md +3 -2
  172. package/skills/bugteam/PROMPTS.md +7 -6
  173. package/skills/bugteam/SKILL.md +18 -13
  174. package/skills/bugteam/reference/audit-and-teammates.md +215 -35
  175. package/skills/bugteam/reference/design-rationale.md +1 -1
  176. package/skills/bugteam/reference/obstacles/CLAUDE.md +1 -1
  177. package/skills/bugteam/reference/team-setup.md +8 -2
  178. package/skills/codex-review/CLAUDE.md +46 -0
  179. package/skills/codex-review/SKILL.md +181 -0
  180. package/skills/codex-review/reference/CLAUDE.md +15 -0
  181. package/skills/codex-review/reference/cli-contract.md +253 -0
  182. package/skills/codex-review/reference/loop-integration.md +118 -0
  183. package/skills/codex-review/scripts/codex_down_classifier.py +98 -0
  184. package/skills/codex-review/scripts/codex_review_scripts_constants/CLAUDE.md +18 -0
  185. package/skills/codex-review/scripts/codex_review_scripts_constants/__init__.py +1 -0
  186. package/skills/codex-review/scripts/codex_review_scripts_constants/classifier_constants.py +35 -0
  187. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +86 -0
  188. package/skills/codex-review/scripts/codex_review_scripts_constants/findings_constants.py +18 -0
  189. package/skills/codex-review/scripts/codex_review_scripts_constants/run_constants.py +45 -0
  190. package/skills/codex-review/scripts/codex_usage_probe.py +573 -0
  191. package/skills/codex-review/scripts/fixtures/auth_failure_synthetic.txt +1 -0
  192. package/skills/codex-review/scripts/fixtures/config_load_failure_v0.125.0.txt +1 -0
  193. package/skills/codex-review/scripts/fixtures/freeform_findings_v0.144.3.txt +6 -0
  194. package/skills/codex-review/scripts/fixtures/model_rejection_v0.125.0.jsonl +5 -0
  195. package/skills/codex-review/scripts/fixtures/structured_findings.txt +13 -0
  196. package/skills/codex-review/scripts/fixtures/success_stream_v0.144.3.jsonl +6 -0
  197. package/skills/codex-review/scripts/fixtures/unknown_failure_synthetic.txt +1 -0
  198. package/skills/codex-review/scripts/fixtures/usage_limit_synthetic.txt +1 -0
  199. package/skills/codex-review/scripts/parse_codex_findings.py +207 -0
  200. package/skills/codex-review/scripts/run_codex_review.py +415 -0
  201. package/skills/codex-review/scripts/test_codex_down_classifier.py +143 -0
  202. package/skills/codex-review/scripts/test_codex_usage_probe.py +678 -0
  203. package/skills/codex-review/scripts/test_parse_codex_findings.py +130 -0
  204. package/skills/codex-review/scripts/test_run_codex_review.py +812 -0
  205. package/skills/codex-review/test_skill_scaffold.py +192 -0
  206. package/skills/grok-spawn/CLAUDE.md +28 -0
  207. package/skills/grok-spawn/SKILL.md +226 -0
  208. package/skills/grok-spawn/reference/flag-profiles.md +132 -0
  209. package/skills/grok-spawn/reference/worker-briefs.md +152 -0
  210. package/skills/grokify/SKILL.md +9 -1
  211. package/skills/grokify/capability-claims.test.mjs +28 -0
  212. package/skills/grokify/evals/README.md +72 -0
  213. package/skills/grokify/evals/parse-payload.test.mjs +171 -0
  214. package/skills/grokify/evals/run-capability-evals.mjs +545 -0
  215. package/skills/orchestrator/SKILL.md +42 -29
  216. package/skills/orchestrator-refresh/SKILL.md +17 -9
  217. package/skills/pr-converge/SKILL.md +34 -13
  218. package/skills/pr-converge/reference/convergence-gates.md +42 -15
  219. package/skills/pr-converge/reference/fix-protocol.md +1 -1
  220. package/skills/pr-converge/reference/ground-rules.md +1 -1
  221. package/skills/pr-converge/reference/per-tick.md +130 -42
  222. package/skills/pr-converge/reference/state-schema.md +10 -0
  223. package/skills/pr-converge/scripts/CLAUDE.md +2 -0
  224. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +5 -1
  225. package/skills/pr-converge/scripts/check_convergence.py +605 -29
  226. package/skills/pr-converge/scripts/check_convergence_availability.py +232 -0
  227. package/skills/pr-converge/scripts/check_convergence_gates.py +279 -235
  228. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +1 -1
  229. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -2
  230. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +4 -0
  231. package/skills/pr-converge/scripts/test_check_convergence.py +71 -3
  232. package/skills/pr-converge/scripts/test_check_convergence_availability.py +326 -0
  233. package/skills/pr-converge/scripts/test_check_convergence_codex.py +507 -0
  234. package/skills/pr-converge/scripts/test_check_convergence_contract.py +89 -17
  235. package/skills/pr-converge/scripts/test_check_convergence_fixture.py +179 -0
  236. package/skills/pr-converge/scripts/test_check_convergence_gates.py +84 -68
  237. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +24 -0
  238. package/skills/pr-converge/test_step5_host_branch.py +106 -0
  239. package/skills/pr-loop-cloud-transport/SKILL.md +2 -0
  240. package/skills/reviewer-gates/SKILL.md +7 -5
  241. package/skills/team-advisor/SKILL.md +7 -7
@@ -0,0 +1,14 @@
1
+ """Put the pr-loop scripts directory on sys.path for the parts test suite.
2
+
3
+ Importing this conftest inserts the ``_shared/pr-loop/scripts`` directory (this
4
+ file's parent's parent's parent) at the front of sys.path so the parts modules
5
+ resolve ``pr_loop_shared_constants`` and ``terminology_sweep`` with every import
6
+ kept at module top.
7
+ """
8
+
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ _scripts_directory = str(Path(__file__).resolve().parents[2])
13
+ if _scripts_directory not in sys.path:
14
+ sys.path.insert(0, _scripts_directory)
@@ -0,0 +1,118 @@
1
+ """Behavioral tests for the added_line_maps parts module."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from code_rules_gate_parts import added_line_maps, git_file_sets
7
+
8
+
9
+ def _run(repository_root: Path, *arguments: str) -> None:
10
+ subprocess.run(
11
+ ["git", *arguments],
12
+ cwd=str(repository_root),
13
+ check=True,
14
+ capture_output=True,
15
+ env=git_file_sets.repository_environment(),
16
+ )
17
+
18
+
19
+ def _base_repository(repository_root: Path) -> None:
20
+ _run(repository_root, "init", "--initial-branch=main")
21
+ _run(repository_root, "config", "user.email", "test@example.com")
22
+ _run(repository_root, "config", "user.name", "Test")
23
+ _run(repository_root, "config", "commit.gpgsign", "false")
24
+ disabled_hooks = repository_root / "disabled-git-hooks"
25
+ disabled_hooks.mkdir()
26
+ _run(repository_root, "config", "core.hooksPath", str(disabled_hooks))
27
+ (repository_root / "base.py").write_text("a = 1\nb = 2\n", encoding="utf-8")
28
+ _run(repository_root, "add", "-A")
29
+ _run(repository_root, "commit", "-m", "base")
30
+
31
+
32
+ def _head_sha(repository_root: Path) -> str:
33
+ return subprocess.run(
34
+ ["git", "rev-parse", "HEAD"],
35
+ cwd=str(repository_root),
36
+ check=True,
37
+ capture_output=True,
38
+ text=True,
39
+ env=git_file_sets.repository_environment(),
40
+ ).stdout.strip()
41
+
42
+
43
+ def test_whole_file_line_set_covers_every_line(tmp_path: Path) -> None:
44
+ module_path = tmp_path / "three.py"
45
+ module_path.write_text("a\nb\nc\n", encoding="utf-8")
46
+ assert added_line_maps.whole_file_line_set(module_path) == {1, 2, 3}
47
+
48
+
49
+ def test_is_file_new_at_base_distinguishes_added_and_existing(tmp_path: Path) -> None:
50
+ repository_root = tmp_path / "repo"
51
+ repository_root.mkdir()
52
+ _base_repository(repository_root)
53
+ base_sha = _head_sha(repository_root)
54
+ (repository_root / "fresh.py").write_text("x = 1\n", encoding="utf-8")
55
+ _run(repository_root, "add", "-A")
56
+ _run(repository_root, "commit", "-m", "add fresh")
57
+
58
+ assert added_line_maps.is_file_new_at_base(repository_root, base_sha, "fresh.py")
59
+ assert not added_line_maps.is_file_new_at_base(repository_root, base_sha, "base.py")
60
+
61
+
62
+ def test_added_lines_for_file_reports_new_lines(tmp_path: Path) -> None:
63
+ repository_root = tmp_path / "repo"
64
+ repository_root.mkdir()
65
+ _base_repository(repository_root)
66
+ base_sha = _head_sha(repository_root)
67
+ (repository_root / "base.py").write_text("a = 1\nb = 2\nc = 3\n", encoding="utf-8")
68
+ _run(repository_root, "add", "-A")
69
+ _run(repository_root, "commit", "-m", "extend")
70
+
71
+ added = added_line_maps.added_lines_for_file(repository_root, base_sha, "base.py")
72
+
73
+ assert added == {3}
74
+
75
+
76
+ def test_renamed_file_source_map_since_maps_destination_to_source(tmp_path: Path) -> None:
77
+ repository_root = tmp_path / "repo"
78
+ repository_root.mkdir()
79
+ _base_repository(repository_root)
80
+ base_sha = _head_sha(repository_root)
81
+ _run(repository_root, "mv", "base.py", "moved.py")
82
+ _run(repository_root, "commit", "-m", "rename")
83
+
84
+ rename_map = added_line_maps.renamed_file_source_map_since(repository_root, base_sha)
85
+
86
+ assert rename_map == {"moved.py": "base.py"}
87
+
88
+
89
+ def test_added_lines_by_file_marks_new_file_whole(tmp_path: Path) -> None:
90
+ repository_root = tmp_path / "repo"
91
+ repository_root.mkdir()
92
+ _base_repository(repository_root)
93
+ base_sha = _head_sha(repository_root)
94
+ fresh = repository_root / "fresh.py"
95
+ fresh.write_text("x = 1\ny = 2\n", encoding="utf-8")
96
+ _run(repository_root, "add", "-A")
97
+ _run(repository_root, "commit", "-m", "add fresh")
98
+
99
+ added_by_path = added_line_maps.added_lines_by_file(repository_root, base_sha, [fresh])
100
+
101
+ assert added_by_path[fresh.resolve()] == {1, 2}
102
+
103
+
104
+ def test_added_lines_for_renamed_file_reports_only_new_lines(tmp_path: Path) -> None:
105
+ repository_root = tmp_path / "repo"
106
+ repository_root.mkdir()
107
+ _base_repository(repository_root)
108
+ base_sha = _head_sha(repository_root)
109
+ _run(repository_root, "mv", "base.py", "moved.py")
110
+ (repository_root / "moved.py").write_text("a = 1\nb = 2\nc = 3\n", encoding="utf-8")
111
+ _run(repository_root, "add", "-A")
112
+ _run(repository_root, "commit", "-m", "rename and extend")
113
+
114
+ added = added_line_maps.added_lines_for_renamed_file(
115
+ repository_root, base_sha, "base.py", "moved.py"
116
+ )
117
+
118
+ assert added == {3}
@@ -0,0 +1,17 @@
1
+ """Behavioral tests for the enforcer_loading parts module."""
2
+
3
+ from pathlib import Path
4
+
5
+ from code_rules_gate_parts import enforcer_loading
6
+
7
+
8
+ def test_resolve_claude_dev_env_root_finds_enforcer_bearing_root() -> None:
9
+ resolved_root = enforcer_loading.resolve_claude_dev_env_root(Path(__file__))
10
+ assert (resolved_root / "hooks" / "blocking" / "code_rules_enforcer.py").is_file()
11
+
12
+
13
+ def test_load_validate_content_returns_callable_passing_a_clean_module() -> None:
14
+ validate_content = enforcer_loading.load_validate_content()
15
+ clean_module = '"""Clean module."""\n\n\ndef ping() -> str:\n return "pong"\n'
16
+ issues = validate_content(clean_module, "sample.py", "")
17
+ assert issues == []
@@ -0,0 +1,29 @@
1
+ """Behavioral tests for the gate_arguments parts module."""
2
+
3
+ from pathlib import Path
4
+
5
+ from code_rules_gate_parts import gate_arguments
6
+
7
+
8
+ def test_parse_arguments_reads_staged_base_and_repo_root() -> None:
9
+ arguments = gate_arguments.parse_arguments(
10
+ ["--staged", "--base", "main", "--repo-root", "/tmp/repo"]
11
+ )
12
+ assert arguments.staged is True
13
+ assert arguments.base == "main"
14
+ assert arguments.repo_root == Path("/tmp/repo")
15
+
16
+
17
+ def test_parse_arguments_collects_only_under_and_paths() -> None:
18
+ arguments = gate_arguments.parse_arguments(
19
+ ["--only-under", "pkg", "--only-under", "lib", "a.py", "b.py"]
20
+ )
21
+ assert arguments.only_under == ["pkg", "lib"]
22
+ assert arguments.paths == [Path("a.py"), Path("b.py")]
23
+
24
+
25
+ def test_parse_arguments_defaults_base_to_origin_main() -> None:
26
+ arguments = gate_arguments.parse_arguments([])
27
+ assert arguments.base == "origin/main"
28
+ assert arguments.staged is False
29
+ assert arguments.only_under == []
@@ -0,0 +1,99 @@
1
+ """Behavioral tests for the gate_running parts module."""
2
+
3
+ from pathlib import Path
4
+
5
+ import pytest
6
+ from code_rules_gate_parts import gate_running
7
+
8
+
9
+ def _clean_validate(_content: str, _path: str, _prior: str = "", **_kwargs: object) -> list[str]:
10
+ return []
11
+
12
+
13
+ def _dirty_validate(_content: str, _path: str, _prior: str = "", **_kwargs: object) -> list[str]:
14
+ return ["Line 1: bad"]
15
+
16
+
17
+ def _install_eligibility_walk_counter(monkeypatch: pytest.MonkeyPatch) -> list[int]:
18
+ """Wrap ``_eligible_resolved_paths`` to tally how many walks a run makes."""
19
+ walk_count_holder = [0]
20
+ real_eligible_resolved_paths = gate_running._eligible_resolved_paths
21
+
22
+ def counting_eligible_resolved_paths(
23
+ all_file_paths: list[Path],
24
+ repository_root: Path,
25
+ should_read_staged_content: bool,
26
+ ) -> list[Path]:
27
+ walk_count_holder[0] += 1
28
+ return real_eligible_resolved_paths(
29
+ all_file_paths, repository_root, should_read_staged_content
30
+ )
31
+
32
+ monkeypatch.setattr(
33
+ gate_running, "_eligible_resolved_paths", counting_eligible_resolved_paths
34
+ )
35
+ return walk_count_holder
36
+
37
+
38
+ def test_run_gate_resolves_eligible_paths_once_per_run(
39
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
40
+ ) -> None:
41
+ """One ``run_gate`` call walks the eligible file set exactly once.
42
+
43
+ Deriving the inspected-file count from a second walk of the file set would
44
+ re-run the eligibility probe on every candidate — in staged mode a wasted
45
+ ``git cat-file -e`` subprocess per file. Counting the walks proves the set
46
+ is resolved a single time.
47
+ """
48
+ first_module = tmp_path / "first_module.py"
49
+ second_module = tmp_path / "second_module.py"
50
+ first_module.write_text("first_count = 1\n", encoding="utf-8")
51
+ second_module.write_text("second_count = 2\n", encoding="utf-8")
52
+
53
+ walk_count_holder = _install_eligibility_walk_counter(monkeypatch)
54
+ gate_running.run_gate(
55
+ _clean_validate,
56
+ [first_module, second_module],
57
+ tmp_path,
58
+ all_added_lines_by_path=None,
59
+ )
60
+
61
+ assert walk_count_holder[0] == 1
62
+
63
+
64
+ def test_run_gate_reports_inspected_count_for_clean_file(
65
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
66
+ ) -> None:
67
+ module_path = tmp_path / "clean.py"
68
+ module_path.write_text("value = 1\n", encoding="utf-8")
69
+
70
+ exit_code = gate_running.run_gate(
71
+ _clean_validate, [module_path], tmp_path, all_added_lines_by_path=None
72
+ )
73
+
74
+ captured = capsys.readouterr()
75
+ assert exit_code == 0
76
+ assert "inspected 1 file(s)" in captured.err
77
+
78
+
79
+ def test_run_gate_blocks_when_validate_reports_issue(tmp_path: Path) -> None:
80
+ module_path = tmp_path / "dirty.py"
81
+ module_path.write_text("value = 1\n", encoding="utf-8")
82
+
83
+ exit_code = gate_running.run_gate(
84
+ _dirty_validate, [module_path], tmp_path, all_added_lines_by_path=None
85
+ )
86
+
87
+ assert exit_code == 1
88
+
89
+
90
+ def test_print_violation_section_groups_by_relative_path(
91
+ tmp_path: Path, capsys: pytest.CaptureFixture[str]
92
+ ) -> None:
93
+ module_path = (tmp_path / "module.py").resolve()
94
+
95
+ gate_running.print_violation_section("HEADER", {module_path: ["Line 1: issue"]}, tmp_path)
96
+
97
+ captured = capsys.readouterr()
98
+ assert "HEADER" in captured.err
99
+ assert "Line 1: issue" in captured.err
@@ -0,0 +1,69 @@
1
+ """Behavioral tests for the git_blob_readers parts module."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from code_rules_gate_parts import git_blob_readers, git_file_sets
7
+
8
+
9
+ def _run(repository_root: Path, *arguments: str) -> None:
10
+ subprocess.run(
11
+ ["git", *arguments],
12
+ cwd=str(repository_root),
13
+ check=True,
14
+ capture_output=True,
15
+ env=git_file_sets.repository_environment(),
16
+ )
17
+
18
+
19
+ def _init_repository(repository_root: Path) -> None:
20
+ _run(repository_root, "init", "--initial-branch=main")
21
+ _run(repository_root, "config", "user.email", "test@example.com")
22
+ _run(repository_root, "config", "user.name", "Test")
23
+ _run(repository_root, "config", "commit.gpgsign", "false")
24
+ disabled_hooks = repository_root / "disabled-git-hooks"
25
+ disabled_hooks.mkdir()
26
+ _run(repository_root, "config", "core.hooksPath", str(disabled_hooks))
27
+ (repository_root / "seed.txt").write_text("seed\n", encoding="utf-8")
28
+ _run(repository_root, "add", "-A")
29
+ _run(repository_root, "commit", "-m", "seed")
30
+
31
+
32
+ def test_read_prior_committed_content_returns_head_blob(tmp_path: Path) -> None:
33
+ repository_root = tmp_path / "repo"
34
+ repository_root.mkdir()
35
+ _init_repository(repository_root)
36
+ (repository_root / "tracked.py").write_text("value = 1\n", encoding="utf-8")
37
+ _run(repository_root, "add", "tracked.py")
38
+ _run(repository_root, "commit", "-m", "add tracked")
39
+
40
+ content = git_blob_readers.read_prior_committed_content(repository_root, "tracked.py")
41
+
42
+ assert content == "value = 1\n"
43
+
44
+
45
+ def test_read_prior_committed_content_returns_empty_for_untracked(tmp_path: Path) -> None:
46
+ repository_root = tmp_path / "repo"
47
+ repository_root.mkdir()
48
+ _init_repository(repository_root)
49
+
50
+ assert git_blob_readers.read_prior_committed_content(repository_root, "absent.py") == ""
51
+
52
+
53
+ def test_read_staged_content_returns_none_when_not_staged(tmp_path: Path) -> None:
54
+ repository_root = tmp_path / "repo"
55
+ repository_root.mkdir()
56
+ _init_repository(repository_root)
57
+
58
+ assert git_blob_readers.read_staged_content(repository_root, "absent.py") is None
59
+
60
+
61
+ def test_staged_blob_exists_reflects_index_presence(tmp_path: Path) -> None:
62
+ repository_root = tmp_path / "repo"
63
+ repository_root.mkdir()
64
+ _init_repository(repository_root)
65
+ (repository_root / "staged.py").write_text("value = 2\n", encoding="utf-8")
66
+ _run(repository_root, "add", "staged.py")
67
+
68
+ assert git_blob_readers.staged_blob_exists(repository_root, "staged.py")
69
+ assert not git_blob_readers.staged_blob_exists(repository_root, "absent.py")
@@ -0,0 +1,137 @@
1
+ """Behavioral tests for the git_file_sets parts module."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from code_rules_gate_parts import git_file_sets
7
+
8
+
9
+ def _init_repository(repository_root: Path) -> None:
10
+ def run(*arguments: str) -> None:
11
+ subprocess.run(
12
+ ["git", *arguments],
13
+ cwd=str(repository_root),
14
+ check=True,
15
+ capture_output=True,
16
+ env=git_file_sets.repository_environment(),
17
+ )
18
+
19
+ run("init", "--initial-branch=main")
20
+ run("config", "user.email", "test@example.com")
21
+ run("config", "user.name", "Test")
22
+ run("config", "commit.gpgsign", "false")
23
+ (repository_root / "seed.txt").write_text("seed\n", encoding="utf-8")
24
+ run("add", "-A")
25
+ run("commit", "-m", "seed")
26
+
27
+
28
+ def test_paths_from_git_untracked_lists_only_untracked(tmp_path: Path) -> None:
29
+ repository_root = tmp_path / "repo"
30
+ repository_root.mkdir()
31
+ _init_repository(repository_root)
32
+ (repository_root / "fresh.py").write_text("value = 1\n", encoding="utf-8")
33
+
34
+ resolved_paths = git_file_sets.paths_from_git_untracked(repository_root)
35
+
36
+ resolved_names = {each_path.name for each_path in resolved_paths}
37
+ assert "fresh.py" in resolved_names
38
+ assert "seed.txt" not in resolved_names
39
+
40
+
41
+ def test_paths_from_git_staged_lists_the_staged_file(tmp_path: Path) -> None:
42
+ repository_root = tmp_path / "repo"
43
+ repository_root.mkdir()
44
+ _init_repository(repository_root)
45
+ (repository_root / "staged_module.py").write_text("value = 2\n", encoding="utf-8")
46
+ subprocess.run(
47
+ ["git", "add", "staged_module.py"],
48
+ cwd=str(repository_root),
49
+ check=True,
50
+ capture_output=True,
51
+ env=git_file_sets.repository_environment(),
52
+ )
53
+
54
+ resolved_names = {
55
+ each_path.name for each_path in git_file_sets.paths_from_git_staged(repository_root)
56
+ }
57
+
58
+ assert "staged_module.py" in resolved_names
59
+
60
+
61
+ def test_filter_paths_under_prefixes_keeps_only_matching_prefix(tmp_path: Path) -> None:
62
+ kept = tmp_path / "keep" / "module.py"
63
+ dropped = tmp_path / "other" / "module.py"
64
+ kept.parent.mkdir(parents=True)
65
+ dropped.parent.mkdir(parents=True)
66
+ kept.write_text("value = 3\n", encoding="utf-8")
67
+ dropped.write_text("value = 4\n", encoding="utf-8")
68
+
69
+ filtered = git_file_sets.filter_paths_under_prefixes([kept, dropped], tmp_path, ["keep"])
70
+
71
+ assert filtered == [kept]
72
+
73
+
74
+ def _staged_repository_with(repository_root: Path, filename: str, content: str) -> None:
75
+ _init_repository(repository_root)
76
+ (repository_root / filename).write_text(content, encoding="utf-8")
77
+ subprocess.run(
78
+ ["git", "add", filename],
79
+ cwd=str(repository_root),
80
+ check=True,
81
+ capture_output=True,
82
+ env=git_file_sets.repository_environment(),
83
+ )
84
+
85
+
86
+ def test_resolve_merge_base_of_head_with_itself_is_head_sha(tmp_path: Path) -> None:
87
+ repository_root = tmp_path / "repo"
88
+ repository_root.mkdir()
89
+ _init_repository(repository_root)
90
+
91
+ merge_base = git_file_sets.resolve_merge_base(repository_root, "HEAD")
92
+
93
+ head_sha = subprocess.run(
94
+ ["git", "rev-parse", "HEAD"],
95
+ cwd=str(repository_root),
96
+ check=True,
97
+ capture_output=True,
98
+ text=True,
99
+ env=git_file_sets.repository_environment(),
100
+ ).stdout.strip()
101
+ assert merge_base == head_sha
102
+
103
+
104
+ def test_paths_from_git_diff_is_empty_when_head_matches_base(tmp_path: Path) -> None:
105
+ repository_root = tmp_path / "repo"
106
+ repository_root.mkdir()
107
+ _init_repository(repository_root)
108
+
109
+ changed_paths = git_file_sets.paths_from_git_diff(repository_root, "HEAD")
110
+
111
+ assert changed_paths == []
112
+
113
+
114
+ def test_is_staged_file_newly_added_true_for_new_staged_file(tmp_path: Path) -> None:
115
+ repository_root = tmp_path / "repo"
116
+ repository_root.mkdir()
117
+ _staged_repository_with(repository_root, "added.py", "value = 5\n")
118
+
119
+ assert git_file_sets.is_staged_file_newly_added(repository_root, "added.py")
120
+
121
+
122
+ def test_staged_file_line_count_reports_blob_line_count(tmp_path: Path) -> None:
123
+ repository_root = tmp_path / "repo"
124
+ repository_root.mkdir()
125
+ _staged_repository_with(repository_root, "three.py", "a = 1\nb = 2\nc = 3\n")
126
+
127
+ assert git_file_sets.staged_file_line_count(repository_root, "three.py") == 3
128
+
129
+
130
+ def test_staged_unified_diff_text_carries_hunk_header(tmp_path: Path) -> None:
131
+ repository_root = tmp_path / "repo"
132
+ repository_root.mkdir()
133
+ _staged_repository_with(repository_root, "hunk.py", "a = 1\n")
134
+
135
+ diff_text = git_file_sets.staged_unified_diff_text(repository_root, "hunk.py")
136
+
137
+ assert "@@" in diff_text
@@ -0,0 +1,116 @@
1
+ """Behavioral tests for the staged_test_running parts module."""
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+ from code_rules_gate_parts import git_file_sets, staged_test_running
7
+
8
+
9
+ def _git(repository_root: Path, *arguments: str) -> None:
10
+ subprocess.run(
11
+ ["git", *arguments],
12
+ cwd=str(repository_root),
13
+ check=True,
14
+ capture_output=True,
15
+ env=git_file_sets.repository_environment(),
16
+ )
17
+
18
+
19
+ def _init_repository(repository_root: Path) -> None:
20
+ _git(repository_root, "init", "--initial-branch=main")
21
+ _git(repository_root, "config", "user.email", "test@example.com")
22
+ _git(repository_root, "config", "user.name", "Test")
23
+ _git(repository_root, "config", "commit.gpgsign", "false")
24
+ (repository_root / "seed.txt").write_text("seed\n", encoding="utf-8")
25
+ _git(repository_root, "add", "-A")
26
+ _git(repository_root, "commit", "-m", "seed")
27
+
28
+
29
+ def _write_and_stage(repository_root: Path, relative_path: str, file_text: str) -> Path:
30
+ file_path = repository_root / relative_path
31
+ file_path.parent.mkdir(parents=True, exist_ok=True)
32
+ file_path.write_text(file_text, encoding="utf-8")
33
+ _git(repository_root, "add", "--", relative_path)
34
+ return file_path
35
+
36
+
37
+ def _repository_with_root_pytest_config(tmp_path: Path) -> Path:
38
+ repository_root = tmp_path / "repo"
39
+ repository_root.mkdir()
40
+ _init_repository(repository_root)
41
+ (repository_root / "pytest.ini").write_text("[pytest]\n", encoding="utf-8")
42
+ return repository_root
43
+
44
+
45
+ def test_run_staged_test_files_returns_zero_when_nothing_staged(tmp_path: Path) -> None:
46
+ repository_root = tmp_path / "repo"
47
+ repository_root.mkdir()
48
+ _init_repository(repository_root)
49
+
50
+ assert staged_test_running.run_staged_test_files(repository_root) == 0
51
+
52
+
53
+ def test_batched_pytest_arguments_splits_over_the_budget() -> None:
54
+ all_batches = staged_test_running._batched_pytest_arguments(["aaaa", "bbbb", "cccc"], 10)
55
+ assert all_batches == [["aaaa", "bbbb"], ["cccc"]]
56
+
57
+
58
+ def test_batched_pytest_arguments_keeps_oversized_argument_in_its_own_batch() -> None:
59
+ all_batches = staged_test_running._batched_pytest_arguments(["wide_argument"], 4)
60
+ assert all_batches == [["wide_argument"]]
61
+
62
+
63
+ def test_pytest_target_paths_drops_conftest_and_keeps_real_tests() -> None:
64
+ all_staged_paths = [
65
+ Path("pkg_a/conftest.py"),
66
+ Path("pkg_b/conftest.py"),
67
+ Path("pkg_a/test_alpha.py"),
68
+ Path("pkg_b/tests/conftest.py"),
69
+ ]
70
+
71
+ all_targets = staged_test_running._pytest_target_paths(all_staged_paths)
72
+
73
+ assert all_targets == [Path("pkg_a/test_alpha.py")]
74
+
75
+
76
+ def test_run_staged_test_files_returns_zero_when_only_multiple_confests_staged(
77
+ tmp_path: Path,
78
+ ) -> None:
79
+ repository_root = _repository_with_root_pytest_config(tmp_path)
80
+ _write_and_stage(repository_root, "pkg_a/conftest.py", "import pytest\n")
81
+ _write_and_stage(repository_root, "pkg_b/conftest.py", "import pytest\n")
82
+ _write_and_stage(
83
+ repository_root, "pkg_c/tests/conftest.py", "import pytest\n"
84
+ )
85
+
86
+ assert staged_test_running.run_staged_test_files(repository_root) == 0
87
+
88
+
89
+ def test_run_staged_test_files_passes_when_confests_stage_with_passing_test(
90
+ tmp_path: Path,
91
+ ) -> None:
92
+ repository_root = _repository_with_root_pytest_config(tmp_path)
93
+ _write_and_stage(repository_root, "pkg_a/conftest.py", "import pytest\n")
94
+ _write_and_stage(repository_root, "pkg_b/conftest.py", "import pytest\n")
95
+ _write_and_stage(
96
+ repository_root,
97
+ "pkg_a/test_alpha.py",
98
+ "def test_alpha_passes() -> None:\n assert True\n",
99
+ )
100
+
101
+ assert staged_test_running.run_staged_test_files(repository_root) == 0
102
+
103
+
104
+ def test_run_staged_test_files_fails_when_real_test_fails_alongside_confests(
105
+ tmp_path: Path,
106
+ ) -> None:
107
+ repository_root = _repository_with_root_pytest_config(tmp_path)
108
+ _write_and_stage(repository_root, "pkg_a/conftest.py", "import pytest\n")
109
+ _write_and_stage(repository_root, "pkg_b/conftest.py", "import pytest\n")
110
+ _write_and_stage(
111
+ repository_root,
112
+ "pkg_a/test_alpha.py",
113
+ "def test_alpha_fails() -> None:\n assert False\n",
114
+ )
115
+
116
+ assert staged_test_running.run_staged_test_files(repository_root) != 0
@@ -0,0 +1,75 @@
1
+ """Behavioral tests for the violation_scoping parts module."""
2
+
3
+ from code_rules_gate_parts import violation_scoping
4
+
5
+
6
+ def test_hunk_header_pattern_matches_a_unified_zero_header() -> None:
7
+ match_result = violation_scoping.hunk_header_pattern().match("@@ -1,0 +5,3 @@")
8
+ assert match_result is not None
9
+ assert match_result.group(1) == "5"
10
+
11
+
12
+ def test_violation_line_pattern_extracts_the_line_prefix() -> None:
13
+ match_result = violation_scoping.violation_line_pattern().match("Line 7: issue")
14
+ assert match_result is not None
15
+ assert match_result.group(1) == "7"
16
+
17
+
18
+ def test_parse_added_line_numbers_reads_hunk_headers() -> None:
19
+ diff_text = "@@ -0,0 +3,2 @@\n+alpha\n+beta\n"
20
+ assert violation_scoping.parse_added_line_numbers(diff_text) == {3, 4}
21
+
22
+
23
+ def test_extract_violation_line_number_reads_the_prefix() -> None:
24
+ assert violation_scoping.extract_violation_line_number("Line 9: issue") == 9
25
+ assert violation_scoping.extract_violation_line_number("no prefix") is None
26
+
27
+
28
+ def test_function_length_span_range_covers_the_declared_span() -> None:
29
+ message = "Function 'f' (defined at line 4) is 3 lines - too long"
30
+ assert violation_scoping.function_length_span_range(message) == range(4, 7)
31
+
32
+
33
+ def test_isolation_span_range_covers_enclosing_test() -> None:
34
+ message = "Line 5: Test 'f' (defined at line 4, spanning 3 lines) probes HOME"
35
+ assert violation_scoping.isolation_span_range(message) == range(4, 7)
36
+
37
+
38
+ def test_banned_noun_span_range_is_one_line() -> None:
39
+ message = "Line 8: Identifier 'result' (binding span at line 8, spanning 1 lines)"
40
+ assert violation_scoping.banned_noun_span_range(message) == range(8, 9)
41
+
42
+
43
+ def test_duplicate_body_span_range_covers_copy() -> None:
44
+ message = "Function 'f' duplicates a.py::f — (duplicate body span at line 2, spanning 4 lines)"
45
+ assert violation_scoping.duplicate_body_span_range(message) == range(2, 6)
46
+
47
+
48
+ def test_inline_duplicate_body_span_lines_unions_both_spans() -> None:
49
+ message = (
50
+ "same body (inline duplicate body spans: helper at line 2 spanning 2 lines, "
51
+ "enclosing at line 10 spanning 2 lines)"
52
+ )
53
+ span_lines = violation_scoping.inline_duplicate_body_span_lines(message)
54
+ assert span_lines == frozenset({2, 3, 10, 11})
55
+
56
+
57
+ def test_enclosing_span_range_dispatches_to_first_extractor() -> None:
58
+ message = "Function 'f' (defined at line 4) is 3 lines - too long"
59
+ assert violation_scoping.enclosing_span_range(message) == range(4, 7)
60
+
61
+
62
+ def test_split_violations_by_scope_partitions_by_added_line() -> None:
63
+ blocking, advisory = violation_scoping.split_violations_by_scope(
64
+ ["Line 5: touched", "Line 9: untouched"], {5}
65
+ )
66
+ assert blocking == ["Line 5: touched"]
67
+ assert advisory == ["Line 9: untouched"]
68
+
69
+
70
+ def test_split_violations_by_scope_blocks_everything_when_scope_is_none() -> None:
71
+ blocking, advisory = violation_scoping.split_violations_by_scope(
72
+ ["Line 5: a", "Line 9: b"], None
73
+ )
74
+ assert blocking == ["Line 5: a", "Line 9: b"]
75
+ assert advisory == []