claude-dev-env 1.93.1 → 1.95.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 (262) hide show
  1. package/_shared/advisor/CLAUDE.md +2 -2
  2. package/_shared/advisor/advisor-protocol.md +35 -27
  3. package/_shared/advisor/scripts/config/advisor_scripts_constants/model_tier_run_validator_constants.py +3 -2
  4. package/_shared/advisor/scripts/model_tier_run_validator.py +23 -15
  5. package/_shared/advisor/scripts/tests/test_model_tier_run_validator.py +81 -17
  6. package/_shared/pr-loop/CLAUDE.md +1 -0
  7. package/_shared/pr-loop/audit-contract.md +1 -1
  8. package/_shared/pr-loop/gh-payloads.md +3 -3
  9. package/_shared/pr-loop/post-audit-thread-contract.md +51 -0
  10. package/_shared/pr-loop/scripts/README.md +2 -2
  11. package/_shared/pr-loop/scripts/fix_hookspath.py +47 -12
  12. package/_shared/pr-loop/scripts/tests/test_code_rules_gate.py +0 -69
  13. package/_shared/pr-loop/scripts/tests/test_fix_hookspath.py +497 -374
  14. package/_shared/pr-loop/state-schema.md +3 -22
  15. package/bin/CLAUDE.md +11 -2
  16. package/bin/ever-shipped-skills.mjs +70 -0
  17. package/bin/install.mjs +138 -13
  18. package/bin/install.prune.test.mjs +457 -0
  19. package/bin/install.test.mjs +10 -4
  20. package/docs/CODE_RULES.md +1 -1
  21. package/hooks/blocking/CLAUDE.md +2 -0
  22. package/hooks/blocking/code_rules_enforcer.py +4 -0
  23. package/hooks/blocking/code_rules_shared.py +82 -0
  24. package/hooks/blocking/code_rules_test_layout.py +9 -3
  25. package/hooks/blocking/pii_commit_command.py +398 -0
  26. package/hooks/blocking/pii_payload_scan.py +133 -0
  27. package/hooks/blocking/pii_prevention_blocker.py +151 -449
  28. package/hooks/blocking/plain_language_blocker.py +138 -4
  29. package/hooks/blocking/sensitive_file_protector.py +114 -48
  30. package/hooks/blocking/tdd_enforcer.py +9 -2
  31. package/hooks/blocking/test_code_rules_enforcer_scratchpad.py +105 -0
  32. package/hooks/blocking/test_code_rules_shared.py +181 -0
  33. package/hooks/blocking/test_pii_prevention_blocker.py +299 -0
  34. package/hooks/blocking/test_plain_language_blocker_allowlist.py +184 -0
  35. package/hooks/blocking/test_sensitive_file_protector.py +185 -0
  36. package/hooks/blocking/test_tdd_enforcer_scratchpad.py +105 -0
  37. package/hooks/blocking/test_verdict_directory_write_blocker.py +42 -7
  38. package/hooks/hooks_constants/CLAUDE.md +4 -2
  39. package/hooks/hooks_constants/code_rules_path_utils_constants.py +0 -2
  40. package/hooks/hooks_constants/harness_scratchpad_constants.py +17 -0
  41. package/hooks/hooks_constants/local_identity.py +65 -15
  42. package/hooks/hooks_constants/pii_prevention_constants.py +16 -0
  43. package/hooks/hooks_constants/plain_language_blocker_constants.py +5 -0
  44. package/hooks/hooks_constants/pr_description_enforcer_constants.py +1 -1
  45. package/hooks/hooks_constants/sensitive_file_protector_constants.py +42 -0
  46. package/hooks/hooks_constants/test_local_identity.py +68 -0
  47. package/hooks/pyproject.toml +75 -4
  48. package/hooks/validators/CLAUDE.md +1 -1
  49. package/hooks/validators/README.md +2 -0
  50. package/hooks/validators/python_style_checks.py +114 -136
  51. package/hooks/validators/python_style_helpers.py +95 -0
  52. package/hooks/validators/test_python_style_checks.py +0 -164
  53. package/hooks/validators/test_python_style_checks_decorator_gap.py +119 -0
  54. package/hooks/validators/test_python_style_fixes.py +251 -0
  55. package/hooks/validators/test_python_style_helpers.py +125 -0
  56. package/package.json +1 -1
  57. package/rules/CLAUDE.md +1 -0
  58. package/rules/anti-corollary-tests.md +69 -0
  59. package/rules/bdd.md +1 -3
  60. package/rules/code-reviews.md +1 -1
  61. package/rules/gh-paginate.md +1 -1
  62. package/rules/plain-language.md +2 -0
  63. package/scripts/CLAUDE.md +10 -1
  64. package/scripts/Get-SessionAccount.ps1 +210 -0
  65. package/scripts/tests/CLAUDE.md +15 -1
  66. package/scripts/tests/Get-SessionAccount.Tests.ps1 +350 -0
  67. package/skills/CLAUDE.md +6 -27
  68. package/skills/anthropic-plan/SKILL.md +1 -13
  69. package/skills/autoconverge/CLAUDE.md +5 -2
  70. package/skills/autoconverge/SKILL.md +112 -387
  71. package/skills/autoconverge/reference/CLAUDE.md +4 -1
  72. package/skills/autoconverge/reference/closing-report.md +9 -2
  73. package/skills/autoconverge/reference/convergence.md +26 -22
  74. package/skills/autoconverge/reference/headless-safety.md +44 -0
  75. package/skills/autoconverge/reference/multi-pr.md +88 -0
  76. package/skills/autoconverge/reference/self-closing-loop.md +84 -0
  77. package/skills/autoconverge/reference/stop-conditions.md +21 -13
  78. package/skills/autoconverge/workflow/converge.clean-audit.test.mjs +87 -27
  79. package/skills/autoconverge/workflow/converge.contract.test.mjs +40 -10
  80. package/skills/autoconverge/workflow/converge.copilot-gate.test.mjs +179 -47
  81. package/skills/autoconverge/workflow/converge.fix-progress.test.mjs +1 -1
  82. package/skills/autoconverge/workflow/converge.mjs +286 -96
  83. package/skills/bugteam/CLAUDE.md +1 -1
  84. package/skills/bugteam/CONSTRAINTS.md +3 -3
  85. package/skills/bugteam/PROMPTS.md +8 -19
  86. package/skills/bugteam/SKILL.md +13 -14
  87. package/skills/bugteam/reference/CLAUDE.md +1 -1
  88. package/skills/bugteam/reference/README.md +2 -3
  89. package/skills/bugteam/reference/audit-and-teammates.md +2 -2
  90. package/skills/bugteam/reference/copilot-gap-analysis.md +2 -0
  91. package/skills/bugteam/reference/github-pr-reviews.md +56 -38
  92. package/skills/bugteam/reference/team-setup.md +11 -11
  93. package/skills/bugteam/scripts/CLAUDE.md +6 -6
  94. package/skills/bugteam/scripts/README.md +20 -8
  95. package/skills/bugteam/scripts/bugteam_code_rules_gate.py +13 -1463
  96. package/skills/bugteam/scripts/bugteam_fix_hookspath.py +23 -358
  97. package/skills/bugteam/scripts/bugteam_preflight.py +13 -315
  98. package/skills/bugteam/scripts/bugteam_scripts_constants/CLAUDE.md +2 -3
  99. package/skills/bugteam/scripts/test_bugteam_code_rules_gate.py +34 -1053
  100. package/skills/bugteam/scripts/test_bugteam_fix_hookspath.py +30 -347
  101. package/skills/bugteam/scripts/test_bugteam_preflight.py +30 -305
  102. package/skills/closeout/SKILL.md +153 -0
  103. package/skills/closeout/reference/handoff-prompt-template.md +72 -0
  104. package/skills/closeout/reference/issue-body-templates.md +108 -0
  105. package/skills/closeout/reference/pii-redaction-checklist.md +36 -0
  106. package/skills/copilot-finding-triage/SKILL.md +80 -28
  107. package/skills/copilot-finding-triage/reference/tier-rubric.md +70 -11
  108. package/skills/copilot-finding-triage/templates/notification.md +8 -3
  109. package/skills/everything-search/SKILL.md +1 -2
  110. package/skills/grokify/SKILL.md +58 -0
  111. package/skills/grokify/templates/handoff-template.md +92 -0
  112. package/skills/orchestrator/SKILL.md +35 -25
  113. package/skills/orchestrator-refresh/SKILL.md +15 -11
  114. package/skills/pr-converge/CLAUDE.md +1 -1
  115. package/skills/pr-converge/SKILL.md +132 -233
  116. package/skills/pr-converge/reference/convergence-gates.md +102 -66
  117. package/skills/pr-converge/reference/examples.md +37 -25
  118. package/skills/pr-converge/reference/fix-protocol.md +7 -7
  119. package/skills/pr-converge/reference/multi-pr-orchestration.md +10 -4
  120. package/skills/pr-converge/reference/per-tick.md +64 -41
  121. package/skills/pr-converge/reference/state-schema.md +27 -2
  122. package/skills/pr-converge/scripts/CLAUDE.md +8 -0
  123. package/skills/pr-converge/scripts/README.md +4 -3
  124. package/skills/pr-converge/scripts/_pr_converge_path_setup.py +23 -0
  125. package/skills/pr-converge/scripts/check_convergence.py +189 -645
  126. package/skills/pr-converge/scripts/check_convergence_gates.py +235 -0
  127. package/skills/pr-converge/scripts/check_convergence_thread_gates.py +219 -0
  128. package/skills/pr-converge/scripts/conftest.py +8 -0
  129. package/skills/pr-converge/scripts/pr_converge_scripts_constants/CLAUDE.md +1 -0
  130. package/skills/pr-converge/scripts/pr_converge_scripts_constants/convergence_gate_constants.py +36 -0
  131. package/skills/pr-converge/scripts/test__pr_converge_path_setup.py +26 -0
  132. package/skills/pr-converge/scripts/test_check_convergence.py +73 -228
  133. package/skills/pr-converge/scripts/test_check_convergence_contract.py +321 -0
  134. package/skills/pr-converge/scripts/test_check_convergence_gates.py +68 -0
  135. package/skills/pr-converge/scripts/test_check_convergence_thread_gates.py +75 -0
  136. package/skills/pr-fix-protocol/SKILL.md +20 -20
  137. package/skills/privacy-hygiene/SKILL.md +4 -3
  138. package/skills/reviewer-gates/SKILL.md +26 -8
  139. package/skills/session-log/SKILL.md +11 -12
  140. package/skills/session-tidy/SKILL.md +40 -30
  141. package/skills/skill-builder/CLAUDE.md +13 -10
  142. package/skills/skill-builder/SKILL.md +31 -31
  143. package/skills/skill-builder/references/CLAUDE.md +10 -6
  144. package/skills/skill-builder/references/delegation-map.md +13 -4
  145. package/skills/skill-builder/references/description-field.md +113 -0
  146. package/skills/skill-builder/references/self-audit-checklist.md +21 -4
  147. package/skills/skill-builder/references/skill-modularity.md +113 -0
  148. package/skills/skill-builder/references/skill-types.md +4 -0
  149. package/skills/skill-builder/templates/CLAUDE.md +4 -4
  150. package/skills/skill-builder/templates/gap-analysis.md +25 -1
  151. package/skills/skill-builder/workflows/CLAUDE.md +6 -4
  152. package/skills/skill-builder/workflows/improve-skill.md +17 -16
  153. package/skills/skill-builder/workflows/new-skill.md +34 -10
  154. package/skills/skill-builder/workflows/polish-skill.md +59 -43
  155. package/skills/team-advisor/SKILL.md +15 -11
  156. package/skills/usage-pause/SKILL.md +10 -14
  157. package/system-prompts/software-engineer.xml +7 -6
  158. package/hooks/validators/test_verify_paths.py +0 -32
  159. package/hooks/validators/verify_paths.py +0 -57
  160. package/skills/bdd-protocol/CLAUDE.md +0 -26
  161. package/skills/bdd-protocol/SKILL.md +0 -30
  162. package/skills/bdd-protocol/references/CLAUDE.md +0 -10
  163. package/skills/bdd-protocol/references/anti-patterns.md +0 -26
  164. package/skills/bdd-protocol/references/example-mapping.md +0 -23
  165. package/skills/bg-agent/CLAUDE.md +0 -17
  166. package/skills/bg-agent/SKILL.md +0 -69
  167. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_code_rules_gate_constants.py +0 -55
  168. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_fix_hookspath_constants.py +0 -26
  169. package/skills/bugteam/scripts/bugteam_scripts_constants/bugteam_preflight_constants.py +0 -35
  170. package/skills/caveman/CLAUDE.md +0 -15
  171. package/skills/caveman/SKILL.md +0 -38
  172. package/skills/code/CLAUDE.md +0 -17
  173. package/skills/code/SKILL.md +0 -178
  174. package/skills/deep-research/CLAUDE.md +0 -17
  175. package/skills/deep-research/SKILL.md +0 -80
  176. package/skills/findbugs/CLAUDE.md +0 -20
  177. package/skills/findbugs/SKILL.md +0 -241
  178. package/skills/fixbugs/CLAUDE.md +0 -19
  179. package/skills/fixbugs/SKILL.md +0 -142
  180. package/skills/gh-paginate/CLAUDE.md +0 -18
  181. package/skills/gh-paginate/SKILL.md +0 -84
  182. package/skills/gotcha/CLAUDE.md +0 -33
  183. package/skills/gotcha/SKILL.md +0 -73
  184. package/skills/implement/CLAUDE.md +0 -27
  185. package/skills/implement/SKILL.md +0 -66
  186. package/skills/implement/scripts/CLAUDE.md +0 -22
  187. package/skills/implement/scripts/append_note.py +0 -133
  188. package/skills/implement/scripts/implement_scripts_constants/CLAUDE.md +0 -22
  189. package/skills/implement/scripts/implement_scripts_constants/__init__.py +0 -0
  190. package/skills/implement/scripts/implement_scripts_constants/notes_constants.py +0 -12
  191. package/skills/implement/scripts/test_append_note.py +0 -191
  192. package/skills/log-audit/CLAUDE.md +0 -20
  193. package/skills/log-audit/SKILL.md +0 -66
  194. package/skills/log-audit/reference/CLAUDE.md +0 -9
  195. package/skills/log-audit/reference/charter.md +0 -52
  196. package/skills/log-audit/scripts/CLAUDE.md +0 -27
  197. package/skills/log-audit/scripts/cluster_recurrences.py +0 -261
  198. package/skills/log-audit/scripts/collect_log_window.py +0 -199
  199. package/skills/log-audit/scripts/log_audit_constants/CLAUDE.md +0 -12
  200. package/skills/log-audit/scripts/log_audit_constants/__init__.py +0 -0
  201. package/skills/log-audit/scripts/log_audit_constants/cluster_recurrences_constants.py +0 -23
  202. package/skills/log-audit/scripts/log_audit_constants/collect_log_window_constants.py +0 -24
  203. package/skills/log-audit/scripts/log_audit_constants/mine_copilot_findings_constants.py +0 -49
  204. package/skills/log-audit/scripts/mine_copilot_findings.py +0 -302
  205. package/skills/log-audit/scripts/test_cluster_recurrences.py +0 -160
  206. package/skills/log-audit/scripts/test_collect_log_window.py +0 -111
  207. package/skills/log-audit/scripts/test_mine_copilot_findings.py +0 -126
  208. package/skills/logifix/CLAUDE.md +0 -36
  209. package/skills/logifix/SKILL.md +0 -69
  210. package/skills/logifix/scripts/CLAUDE.md +0 -16
  211. package/skills/logifix/scripts/logifix.ps1 +0 -205
  212. package/skills/monitor-open-prs/CLAUDE.md +0 -34
  213. package/skills/monitor-open-prs/SKILL.md +0 -88
  214. package/skills/monitor-open-prs/scripts/CLAUDE.md +0 -17
  215. package/skills/monitor-open-prs/scripts/discover_open_prs.py +0 -69
  216. package/skills/monitor-open-prs/scripts/test_discover_open_prs.py +0 -149
  217. package/skills/monitor-open-prs/test_skill_contract.py +0 -27
  218. package/skills/post-audit-findings/SKILL.md +0 -80
  219. package/skills/pr-consistency-audit/CLAUDE.md +0 -34
  220. package/skills/pr-consistency-audit/SKILL.md +0 -116
  221. package/skills/pr-consistency-audit/reference/CLAUDE.md +0 -16
  222. package/skills/pr-consistency-audit/reference/detection-rules.md +0 -96
  223. package/skills/pr-consistency-audit/reference/illustrations.md +0 -78
  224. package/skills/pr-review-responder/CLAUDE.md +0 -35
  225. package/skills/pr-review-responder/EXAMPLES.md +0 -590
  226. package/skills/pr-review-responder/PRINCIPLES.md +0 -533
  227. package/skills/pr-review-responder/README.md +0 -168
  228. package/skills/pr-review-responder/SKILL.md +0 -203
  229. package/skills/pr-review-responder/TESTING.md +0 -350
  230. package/skills/pr-scope-resolve/SKILL.md +0 -45
  231. package/skills/pre-compact/CLAUDE.md +0 -24
  232. package/skills/pre-compact/SKILL.md +0 -134
  233. package/skills/qbug/CLAUDE.md +0 -40
  234. package/skills/qbug/SKILL.md +0 -387
  235. package/skills/qbug/test_qbug_skill_audit_schema.py +0 -152
  236. package/skills/qbug/test_qbug_skill_post_fix_audit.py +0 -103
  237. package/skills/refine/CLAUDE.md +0 -44
  238. package/skills/refine/SKILL.md +0 -262
  239. package/skills/refine/templates/CLAUDE.md +0 -17
  240. package/skills/refine/templates/implementation-notes-template.html +0 -56
  241. package/skills/refine/templates/plan-template.md +0 -60
  242. package/skills/research-mode/CLAUDE.md +0 -35
  243. package/skills/research-mode/SKILL.md +0 -53
  244. package/skills/structure-prompt/CLAUDE.md +0 -42
  245. package/skills/structure-prompt/SKILL.md +0 -44
  246. package/skills/structure-prompt/reference/CLAUDE.md +0 -28
  247. package/skills/structure-prompt/reference/adversarial-tuning.md +0 -62
  248. package/skills/structure-prompt/reference/block-classification.md +0 -27
  249. package/skills/structure-prompt/reference/canonical-case.md +0 -48
  250. package/skills/structure-prompt/reference/citation-depth.md +0 -70
  251. package/skills/structure-prompt/reference/cleanup.md +0 -33
  252. package/skills/structure-prompt/reference/constraints.md +0 -33
  253. package/skills/structure-prompt/reference/directives.md +0 -37
  254. package/skills/structure-prompt/reference/examples.md +0 -72
  255. package/skills/structure-prompt/reference/instantiation.md +0 -51
  256. package/skills/structure-prompt/reference/output-contract.md +0 -72
  257. package/skills/structure-prompt/reference/per-category.md +0 -23
  258. package/skills/structure-prompt/reference/persona.md +0 -38
  259. package/skills/structure-prompt/reference/research.md +0 -33
  260. package/skills/structure-prompt/reference/structure.md +0 -28
  261. package/skills/verified-build/CLAUDE.md +0 -33
  262. package/skills/verified-build/SKILL.md +0 -33
@@ -1,1473 +1,23 @@
1
+ """Skill-path entry for bugteam code_rules_gate.
2
+
3
+ Delegates to the package shared home:
4
+ ``_shared/pr-loop/scripts/code_rules_gate.py``.
5
+ """
6
+
1
7
  from __future__ import annotations
2
8
 
3
- import argparse
4
- import ast
5
- import importlib.util
6
- import re
7
- import subprocess
8
9
  import sys
9
- from collections.abc import Callable, Iterator
10
10
  from pathlib import Path
11
11
 
12
- ValidateContentCallable = Callable[..., list[str]]
13
-
14
- from bugteam_scripts_constants.bugteam_code_rules_gate_constants import (
15
- ALL_CODE_FILE_EXTENSIONS,
16
- ALL_COLUMN_MAGIC_FALSE_VALUES,
17
- ALL_GIT_DIFF_CACHED_ARGS,
18
- BUGTEAM_CODE_RULES_GATE_PREFIX,
19
- EXIT_CODE_ENFORCER_MISSING,
20
- FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX,
21
- FUNCTION_LENGTH_SPAN_GROUP_INDEX,
22
- FUNCTION_LENGTH_VIOLATION_PATTERN,
23
- BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX,
24
- BANNED_NOUN_SPAN_GROUP_INDEX,
25
- BANNED_NOUN_VIOLATION_PATTERN,
26
- INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX,
27
- INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX,
28
- INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX,
29
- INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX,
30
- INLINE_DUPLICATE_BODY_VIOLATION_PATTERN,
31
- HUNK_HEADER_RAW_PATTERN,
32
- ISOLATION_DEFINITION_LINE_GROUP_INDEX,
33
- ISOLATION_SPAN_GROUP_INDEX,
34
- ISOLATION_VIOLATION_PATTERN,
35
- MAX_VIOLATIONS_PER_CHECK,
36
- MAXIMUM_COLUMN_TUPLE_ELEMENT_COUNT,
37
- MAXIMUM_ISSUES_TO_REPORT,
38
- PYTHON_FILE_EXTENSION,
39
- VIOLATION_LINE_RAW_PATTERN,
12
+ _shared_pr_loop_scripts_directory = (
13
+ Path(__file__).resolve().parents[3] / "_shared" / "pr-loop" / "scripts"
40
14
  )
15
+ if str(_shared_pr_loop_scripts_directory) not in sys.path:
16
+ sys.path.insert(0, str(_shared_pr_loop_scripts_directory))
41
17
 
42
-
43
- def hunk_header_pattern() -> re.Pattern[str]:
44
- return re.compile(HUNK_HEADER_RAW_PATTERN)
45
-
46
-
47
- def violation_line_pattern() -> re.Pattern[str]:
48
- return re.compile(VIOLATION_LINE_RAW_PATTERN)
49
-
50
-
51
- def resolve_claude_dev_env_root() -> Path:
52
- environment_value = (Path(__file__).resolve().parents[3]).resolve()
53
- return environment_value
54
-
55
-
56
- def load_validate_content() -> ValidateContentCallable:
57
- """Load and return the validate_content function from the CODE_RULES enforcer.
58
-
59
- Dynamically imports the code_rules_enforcer module by resolving its path
60
- relative to the current file's location. Temporarily removes the gate
61
- script's ``config`` from ``sys.modules`` to avoid a namespace clash with
62
- the enforcer's ``hooks/config/`` package.
63
-
64
- Not thread-safe: mutates the process-global ``sys.modules`` mapping. Call
65
- only from single-threaded contexts (the CLI entry point at ``main`` is
66
- safe; concurrent invocations from multiple threads must wrap calls in an
67
- external lock).
68
-
69
- Returns:
70
- The validate_content callable from the loaded enforcer module.
71
-
72
- Raises:
73
- SystemExit: When the enforcer file is missing or cannot be loaded.
74
- """
75
- package_root = resolve_claude_dev_env_root()
76
- enforcer_path = package_root / "hooks" / "blocking" / "code_rules_enforcer.py"
77
- if not enforcer_path.is_file():
78
- print(
79
- f"missing enforcer at {enforcer_path}",
80
- file=sys.stderr,
81
- )
82
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
83
- previously_cached_config = {}
84
- for each_cached_module_name in [
85
- each_module_key
86
- for each_module_key in list(sys.modules)
87
- if each_module_key == "config" or each_module_key.startswith("config.")
88
- ]:
89
- previously_cached_config[each_cached_module_name] = sys.modules.pop(
90
- each_cached_module_name
91
- )
92
- hooks_config_init = package_root / "hooks" / "config" / "__init__.py"
93
- if hooks_config_init.is_file():
94
- hooks_config_spec = importlib.util.spec_from_file_location(
95
- "config",
96
- hooks_config_init,
97
- )
98
- if hooks_config_spec is not None and hooks_config_spec.loader is not None:
99
- hooks_config_module = importlib.util.module_from_spec(hooks_config_spec)
100
- sys.modules["config"] = hooks_config_module
101
- hooks_config_spec.loader.exec_module(hooks_config_module)
102
- try:
103
- specification = importlib.util.spec_from_file_location(
104
- "code_rules_enforcer",
105
- enforcer_path,
106
- )
107
- if specification is None or specification.loader is None:
108
- print("could not load code_rules_enforcer.", file=sys.stderr)
109
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
110
- module = importlib.util.module_from_spec(specification)
111
- specification.loader.exec_module(module)
112
- return module.validate_content
113
- finally:
114
- sys.modules.update(previously_cached_config)
115
-
116
-
117
- def resolve_merge_base(repository_root: Path, base_reference: str) -> str:
118
- """Resolve the merge-base commit between HEAD and a base reference.
119
-
120
- Args:
121
- repository_root: The root directory of the git repository.
122
- base_reference: The git reference to compare against (e.g., origin/main).
123
-
124
- Returns:
125
- The merge-base commit hash as a string.
126
-
127
- Raises:
128
- SystemExit: When git merge-base fails.
129
- """
130
- merge_result = subprocess.run(
131
- ["git", "merge-base", "HEAD", base_reference],
132
- cwd=str(repository_root),
133
- capture_output=True,
134
- text=True,
135
- encoding="utf-8",
136
- errors="replace",
137
- check=False,
138
- )
139
- if merge_result.returncode != 0:
140
- print(
141
- f"git merge-base HEAD {base_reference} failed:\n"
142
- f"{merge_result.stderr}",
143
- file=sys.stderr,
144
- )
145
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
146
- return merge_result.stdout.strip()
147
-
148
-
149
- def filter_paths_under_prefixes(
150
- all_file_paths: list[Path],
151
- repository_root: Path,
152
- all_prefixes: list[str],
153
- ) -> list[Path]:
154
- """Filter a list of file paths to keep only those under the given prefixes.
155
-
156
- Args:
157
- all_file_paths: File paths to filter.
158
- repository_root: The repository root for resolving relative paths.
159
- all_prefixes: Prefixes to match against (POSIX-style, relative to root).
160
-
161
- Returns:
162
- Filtered list of file paths whose repo-relative path starts with a prefix.
163
- """
164
- if not all_prefixes:
165
- return all_file_paths
166
- normalized_prefixes = [
167
- each_prefix.strip().replace("\\", "/").rstrip("/")
168
- for each_prefix in all_prefixes
169
- if each_prefix.strip()
170
- ]
171
- if not normalized_prefixes:
172
- return all_file_paths
173
- resolved_root = repository_root.resolve()
174
- filtered: list[Path] = []
175
- for each_path in all_file_paths:
176
- try:
177
- relative_posix = each_path.resolve().relative_to(resolved_root).as_posix()
178
- except ValueError:
179
- continue
180
- if any(
181
- relative_posix == each_prefix or relative_posix.startswith(each_prefix + "/")
182
- for each_prefix in normalized_prefixes
183
- ):
184
- filtered.append(each_path)
185
- return filtered
186
-
187
-
188
- def paths_from_git_staged(repository_root: Path) -> list[Path]:
189
- """Retrieve file paths that are staged for commit.
190
-
191
- Uses ``git diff --cached --name-only -z`` to get the list of staged files.
192
-
193
- Args:
194
- repository_root: The repository root for running git commands.
195
-
196
- Returns:
197
- List of absolute Path objects for each staged file.
198
-
199
- Raises:
200
- SystemExit: When the git command fails.
201
- """
202
- name_result = subprocess.run(
203
- list(ALL_GIT_DIFF_CACHED_ARGS),
204
- cwd=str(repository_root),
205
- capture_output=True,
206
- check=False,
207
- )
208
- if name_result.returncode != 0:
209
- stderr_text = name_result.stderr.decode("utf-8", errors="replace")
210
- print(
211
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --cached --name-only -z failed:\n{stderr_text}",
212
- file=sys.stderr,
213
- )
214
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
215
- raw_paths = name_result.stdout.split(b"\x00")
216
- resolved_paths = []
217
- for each_raw_path in raw_paths:
218
- if not each_raw_path:
219
- continue
220
- try:
221
- relative_path = each_raw_path.decode("utf-8")
222
- except UnicodeDecodeError:
223
- print(
224
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skipping staged path with non-UTF-8 filename: {each_raw_path!r}",
225
- file=sys.stderr,
226
- )
227
- continue
228
- resolved_paths.append(repository_root / relative_path)
229
- return resolved_paths
230
-
231
-
232
- def staged_file_line_count(
233
- repository_root: Path,
234
- relative_path_posix: str,
235
- ) -> int:
236
- """Count lines in a staged file.
237
-
238
- Args:
239
- repository_root: The repository root.
240
- relative_path_posix: POSIX-style relative path to the staged file.
241
-
242
- Returns:
243
- Number of lines in the staged file (zero only when the file is genuinely empty).
244
-
245
- Raises:
246
- SystemExit: When ``git show`` fails. Returning zero on git errors
247
- would be indistinguishable from an empty file and would silently
248
- cause the gate to skip validating a newly added file.
249
- """
250
- show_result = subprocess.run(
251
- ["git", "show", f":{relative_path_posix}"],
252
- cwd=str(repository_root),
253
- capture_output=True,
254
- text=True,
255
- encoding="utf-8",
256
- errors="replace",
257
- check=False,
258
- )
259
- if show_result.returncode != 0:
260
- print(
261
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git show :{relative_path_posix} failed:\n"
262
- f"{show_result.stderr}",
263
- file=sys.stderr,
264
- )
265
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
266
- staged_content = show_result.stdout
267
- if not staged_content:
268
- return 0
269
- return len(staged_content.splitlines())
270
-
271
-
272
- def is_staged_file_newly_added(
273
- repository_root: Path,
274
- relative_path_posix: str,
275
- ) -> bool:
276
- """Check whether a staged file is newly added (not previously tracked).
277
-
278
- Args:
279
- repository_root: The repository root.
280
- relative_path_posix: POSIX-style relative path to the staged file.
281
-
282
- Returns:
283
- True when the file status starts with 'A' (added).
284
-
285
- Raises:
286
- SystemExit: When ``git diff --cached --name-status`` fails. Returning
287
- False on git errors would be indistinguishable from "modified, not
288
- added" and would cause the gate to silently skip validating a
289
- newly added file.
290
- """
291
- status_result = subprocess.run(
292
- ["git", "diff", "--cached", "--name-status", "--", relative_path_posix],
293
- cwd=str(repository_root),
294
- capture_output=True,
295
- text=True,
296
- encoding="utf-8",
297
- errors="replace",
298
- check=False,
299
- )
300
- if status_result.returncode != 0:
301
- print(
302
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --cached --name-status failed for "
303
- f"{relative_path_posix}:\n{status_result.stderr}",
304
- file=sys.stderr,
305
- )
306
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
307
- for each_line in status_result.stdout.splitlines():
308
- stripped_line = each_line.strip()
309
- if stripped_line:
310
- return stripped_line.startswith("A")
311
- return False
312
-
313
-
314
- def added_lines_for_staged_file(
315
- repository_root: Path,
316
- relative_path_posix: str,
317
- ) -> set[int]:
318
- """Determine which lines were added in a staged file.
319
-
320
- Uses ``git diff --cached --unified=0``. For newly added files, returns
321
- the full range of line numbers.
322
-
323
- Args:
324
- repository_root: The repository root.
325
- relative_path_posix: POSIX-style relative path to the staged file.
326
-
327
- Returns:
328
- Set of added line numbers (1-based).
329
-
330
- Raises:
331
- SystemExit: When the git diff command fails.
332
- """
333
- diff_result = subprocess.run(
334
- ["git", "diff", "--cached", "--unified=0", "--", relative_path_posix],
335
- cwd=str(repository_root),
336
- capture_output=True,
337
- text=True,
338
- encoding="utf-8",
339
- errors="replace",
340
- check=False,
341
- )
342
- if diff_result.returncode != 0:
343
- print(
344
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --cached --unified=0 failed for {relative_path_posix}:\n"
345
- f"{diff_result.stderr}",
346
- file=sys.stderr,
347
- )
348
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
349
- if diff_result.stdout.strip():
350
- return parse_added_line_numbers(diff_result.stdout)
351
- if is_staged_file_newly_added(repository_root, relative_path_posix):
352
- total_lines = staged_file_line_count(repository_root, relative_path_posix)
353
- if total_lines > 0:
354
- return set(range(1, total_lines + 1))
355
- return set()
356
-
357
-
358
- def added_lines_by_file_staged(
359
- repository_root: Path,
360
- all_file_paths: list[Path],
361
- ) -> dict[Path, set[int]]:
362
- """Map each staged file path to the set of added line numbers.
363
-
364
- Args:
365
- repository_root: The repository root.
366
- all_file_paths: Staged file paths to check.
367
-
368
- Returns:
369
- Dictionary mapping resolved file paths to their added line numbers.
370
- """
371
- resolved_root = repository_root.resolve()
372
- added_by_path: dict[Path, set[int]] = {}
373
- for each_path in all_file_paths:
374
- try:
375
- resolved = each_path.resolve()
376
- except OSError:
377
- continue
378
- try:
379
- relative = resolved.relative_to(resolved_root)
380
- except ValueError:
381
- continue
382
- relative_posix = str(relative).replace("\\", "/")
383
- added_numbers = added_lines_for_staged_file(resolved_root, relative_posix)
384
- added_by_path[resolved] = added_numbers
385
- return added_by_path
386
-
387
-
388
- def paths_from_git_diff(repository_root: Path, base_reference: str) -> list[Path]:
389
- """Retrieve file paths changed between merge-base and HEAD.
390
-
391
- Args:
392
- repository_root: The repository root.
393
- base_reference: The git reference for the merge-base comparison.
394
-
395
- Returns:
396
- List of absolute Path objects for changed files.
397
-
398
- Raises:
399
- SystemExit: When the git diff command fails.
400
- """
401
- merge_base = resolve_merge_base(repository_root, base_reference)
402
- name_result = subprocess.run(
403
- ["git", "diff", "--name-only", f"{merge_base}..HEAD"],
404
- cwd=str(repository_root),
405
- capture_output=True,
406
- text=True,
407
- encoding="utf-8",
408
- errors="replace",
409
- check=False,
410
- )
411
- if name_result.returncode != 0:
412
- print(
413
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --name-only failed:\n{name_result.stderr}",
414
- file=sys.stderr,
415
- )
416
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
417
- relative_paths = [line.strip() for line in name_result.stdout.splitlines() if line.strip()]
418
- return [repository_root / each_relative_path for each_relative_path in relative_paths]
419
-
420
-
421
- def is_code_path(file_path: Path) -> bool:
422
- """Check whether a file path has a recognized code file extension.
423
-
424
- Args:
425
- file_path: The file path to check.
426
-
427
- Returns:
428
- True when the file extension is in the set of code extensions.
429
- """
430
- suffix = file_path.suffix.lower()
431
- return suffix in ALL_CODE_FILE_EXTENSIONS
432
-
433
-
434
- def _path_is_eligible_for_validation(
435
- resolved_path: Path,
436
- repository_root: Path,
437
- read_staged_content_flag: bool,
438
- ) -> bool:
439
- """Decide whether *resolved_path* should be validated by the gate.
440
-
441
- Args:
442
- resolved_path: A resolved candidate path already confirmed to live
443
- under *repository_root*.
444
- repository_root: The repository root used to compute the relative path.
445
- read_staged_content_flag: When True, require staged-index presence so
446
- files staged for add or modify are validated and staged deletions
447
- are skipped; when False, require working-tree presence.
448
-
449
- Returns:
450
- True when the path carries a code extension and exists in the source
451
- the gate will read; False otherwise.
452
- """
453
- if not is_code_path(resolved_path):
454
- return False
455
- if read_staged_content_flag:
456
- relative_posix = str(
457
- resolved_path.relative_to(repository_root.resolve())
458
- ).replace("\\", "/")
459
- return staged_blob_exists(repository_root.resolve(), relative_posix)
460
- return resolved_path.is_file()
461
-
462
-
463
- def _resolve_eligible_code_path(
464
- candidate_path: Path,
465
- repository_root: Path,
466
- read_staged_content_flag: bool = False,
467
- ) -> Path | None:
468
- """Resolve *candidate_path* and return it only when the gate should scan it.
469
-
470
- Args:
471
- candidate_path: One file path from the gate's candidate set.
472
- repository_root: The repository root the resolved path must fall under.
473
- read_staged_content_flag: When True, eligibility requires staged-index
474
- presence; when False, it requires working-tree presence.
475
-
476
- Returns:
477
- The resolved path when it lives under *repository_root*, carries a code
478
- extension, and is present in the source the gate will read; otherwise
479
- None.
480
- """
481
- try:
482
- resolved = candidate_path.resolve()
483
- except OSError:
484
- return None
485
- try:
486
- resolved.relative_to(repository_root.resolve())
487
- except ValueError:
488
- return None
489
- if not _path_is_eligible_for_validation(
490
- resolved, repository_root, read_staged_content_flag
491
- ):
492
- return None
493
- return resolved
494
-
495
-
496
- def check_database_column_string_magic(content: str, file_path: str) -> list[str]:
497
- """Flag string literals that look like database/HTTP column or key names inside function bodies.
498
-
499
- Triggers when a snake_case string literal appears as the first element of a
500
- two-element tuple inside a function body (the characteristic column-name/value
501
- pair pattern). Files under ``config/`` and test files are exempt.
502
-
503
- Args:
504
- content: The source code content to inspect.
505
- file_path: The file path for exemption checks.
506
-
507
- Returns:
508
- List of violation messages, or an empty list when no violations are found.
509
- """
510
- if "/config/" in file_path.replace("\\", "/") or "\\config\\" in file_path:
511
- return []
512
- if "/tests/" in file_path.replace("\\", "/") or file_path.endswith(("_test.py", ".spec.py")):
513
- return []
514
- try:
515
- tree = ast.parse(content)
516
- except SyntaxError:
517
- return []
518
- issues: list[str] = []
519
- column_key_pattern = re.compile(r"^[a-z][a-z0-9_]{2,}$")
520
- for each_node in ast.walk(tree):
521
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
522
- continue
523
- for each_child in ast.walk(each_node):
524
- if not isinstance(each_child, ast.Tuple):
525
- continue
526
- if len(each_child.elts) != MAXIMUM_COLUMN_TUPLE_ELEMENT_COUNT:
527
- continue
528
- first_element = each_child.elts[0]
529
- if not isinstance(first_element, ast.Constant):
530
- continue
531
- if not isinstance(first_element.value, str):
532
- continue
533
- literal_text = first_element.value
534
- if not column_key_pattern.match(literal_text):
535
- continue
536
- if literal_text in ALL_COLUMN_MAGIC_FALSE_VALUES:
537
- continue
538
- issues.append(
539
- f"Line {first_element.lineno}: Column-name string magic {literal_text!r} - extract to config"
540
- )
541
- if len(issues) >= MAXIMUM_ISSUES_TO_REPORT:
542
- print(
543
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}check_database_column_string_magic "
544
- f"cap reached at {MAXIMUM_ISSUES_TO_REPORT} issues for {file_path}; "
545
- "additional matches were dropped.",
546
- file=sys.stderr,
547
- )
548
- return issues
549
- return issues
550
-
551
-
552
- def is_test_path(file_path: str) -> bool:
553
- """Return True when *file_path* matches CODE_RULES.md test-file detection patterns.
554
-
555
- Mirrors the test-file detection rule documented in CODE_RULES.md:
556
- filename matches test_*.py OR *_test.py OR *.test.* OR *.spec.* OR
557
- conftest.py, OR path contains the segment /tests/.
558
-
559
- Args:
560
- file_path: Path string to classify; backslashes are normalized to
561
- forward slashes before pattern matching.
562
-
563
- Returns:
564
- True when the path matches any test-file pattern; False otherwise.
565
- """
566
- tests_path_segment = "/tests/"
567
- conftest_filename = "conftest.py"
568
- test_filename_prefix = "test_"
569
- all_test_filename_suffixes = ("_test.py",)
570
- all_test_filename_glob_suffixes = (".test.", ".spec.")
571
- normalized_posix = file_path.replace("\\", "/")
572
- filename_only = normalized_posix.rsplit("/", maxsplit=1)[-1]
573
- if tests_path_segment in normalized_posix:
574
- return True
575
- if filename_only == conftest_filename:
576
- return True
577
- if filename_only.startswith(test_filename_prefix) and filename_only.endswith(
578
- PYTHON_FILE_EXTENSION
579
- ):
580
- return True
581
- if any(
582
- filename_only.endswith(each_suffix)
583
- for each_suffix in all_test_filename_suffixes
584
- ):
585
- return True
586
- if any(
587
- each_glob_suffix in filename_only
588
- for each_glob_suffix in all_test_filename_glob_suffixes
589
- ):
590
- return True
591
- return False
592
-
593
-
594
- def _iter_calls_excluding_nested_functions(node: ast.AST) -> Iterator[ast.Call]:
595
- for each_child in ast.iter_child_nodes(node):
596
- if isinstance(each_child, (ast.FunctionDef, ast.AsyncFunctionDef)):
597
- continue
598
- if isinstance(each_child, ast.Call):
599
- yield each_child
600
- continue
601
- yield from _iter_calls_excluding_nested_functions(each_child)
602
-
603
-
604
- def _module_level_optional_kwargs_by_name(tree: ast.Module) -> dict[str, set[str]]:
605
- function_signatures: dict[str, set[str]] = {}
606
- for each_node in ast.iter_child_nodes(tree):
607
- if isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
608
- optional_kwargs: set[str] = set()
609
- for each_kwonly, each_default in zip(
610
- each_node.args.kwonlyargs, each_node.args.kw_defaults
611
- ):
612
- if each_default is not None:
613
- optional_kwargs.add(each_kwonly.arg)
614
- positional_defaults = each_node.args.defaults
615
- positional_args_with_defaults = (
616
- each_node.args.args[-len(positional_defaults):]
617
- if positional_defaults
618
- else []
619
- )
620
- for each_positional_arg in positional_args_with_defaults:
621
- optional_kwargs.add(each_positional_arg.arg)
622
- function_signatures[each_node.name] = optional_kwargs
623
- return function_signatures
624
-
625
-
626
- def _class_method_node_ids(tree: ast.Module) -> set[int]:
627
- class_method_node_ids: set[int] = set()
628
- for each_class_def in ast.walk(tree):
629
- if not isinstance(each_class_def, ast.ClassDef):
630
- continue
631
- for each_class_body_node in each_class_def.body:
632
- if isinstance(
633
- each_class_body_node, (ast.FunctionDef, ast.AsyncFunctionDef)
634
- ):
635
- class_method_node_ids.add(id(each_class_body_node))
636
- return class_method_node_ids
637
-
638
-
639
- def _wrapper_dropped_kwarg_findings(
640
- wrapper_node: ast.FunctionDef | ast.AsyncFunctionDef,
641
- kwargs_by_function_name: dict[str, set[str]],
642
- ) -> Iterator[str]:
643
- wrapper_kwargs = kwargs_by_function_name.get(wrapper_node.name, set())
644
- for each_call in _iter_calls_excluding_nested_functions(wrapper_node):
645
- if isinstance(each_call.func, ast.Name):
646
- delegate_name = each_call.func.id
647
- elif isinstance(each_call.func, ast.Attribute):
648
- delegate_name = each_call.func.attr
649
- else:
650
- continue
651
- delegate_kwargs = kwargs_by_function_name.get(delegate_name)
652
- if delegate_kwargs is None:
653
- continue
654
- missing = delegate_kwargs - wrapper_kwargs
655
- if missing:
656
- yield (
657
- f"Line {wrapper_node.lineno}: Wrapper {wrapper_node.name!r} drops optional kwargs {sorted(missing)!r} of delegate {delegate_name!r}"
658
- )
659
-
660
-
661
- def check_wrapper_plumb_through(content: str, file_path: str) -> list[str]:
662
- """Flag calls inside public functions that drop a same-file delegate's optional kwargs.
663
-
664
- Walks the AST. For every public function (name does not start with '_'),
665
- inspects every ast.Call inside its body and emits one finding per call
666
- whose target name matches a same-file function that exposes optional
667
- kwargs the enclosing public function does not also accept. Emission is
668
- capped at MAX_VIOLATIONS_PER_CHECK findings per call to run_gate.
669
-
670
- Limitations:
671
- - Only module-level FunctionDef nodes contribute signatures, and ClassDef
672
- methods are skipped both as signature sources and as wrapper candidates:
673
- a class method's signature is unrelated to a free-function delegate's
674
- keyword surface, so treating it as a wrapper produces false positives.
675
- - ast.Attribute calls match by attribute name only; the receiver type is
676
- not checked, so `self.fetch(...)` and `other.fetch(...)` both match a
677
- module-level `fetch` definition.
678
- - Nested call expressions inside another call's arguments are not treated as
679
- separate call sites; only the enclosing Call is inspected. This avoids
680
- false positives where a callee nested as an argument is confused with a
681
- top-level delegate invocation (for example `delegate(helper(x))`).
682
-
683
- Args:
684
- content: File content as a single string for AST parsing.
685
- file_path: Repository-relative POSIX path of the file (used to
686
- skip non-Python code extensions and test files early).
687
-
688
- Returns:
689
- List of violation strings, one per dropped optional kwarg. Empty for
690
- a non-Python file, a test file, or a file with a syntax error.
691
- """
692
- non_python_code_extensions = ALL_CODE_FILE_EXTENSIONS - {PYTHON_FILE_EXTENSION}
693
- lowercase_file_path = file_path.lower()
694
- if any(
695
- lowercase_file_path.endswith(each_extension)
696
- for each_extension in non_python_code_extensions
697
- ):
698
- return []
699
- if is_test_path(file_path):
700
- return []
701
- try:
702
- tree = ast.parse(content)
703
- except SyntaxError:
704
- return []
705
- function_signatures = _module_level_optional_kwargs_by_name(tree)
706
- class_method_node_ids = _class_method_node_ids(tree)
707
- issues: list[str] = []
708
- for each_node in ast.walk(tree):
709
- if not isinstance(each_node, (ast.FunctionDef, ast.AsyncFunctionDef)):
710
- continue
711
- if id(each_node) in class_method_node_ids:
712
- continue
713
- if each_node.name.startswith("_"):
714
- continue
715
- for each_finding in _wrapper_dropped_kwarg_findings(each_node, function_signatures):
716
- issues.append(each_finding)
717
- if len(issues) >= MAX_VIOLATIONS_PER_CHECK:
718
- return issues
719
- return issues
720
-
721
-
722
- def parse_added_line_numbers(unified_diff_text: str) -> set[int]:
723
- """Parse unified diff text and return the set of added line numbers.
724
-
725
- Args:
726
- unified_diff_text: The unified diff output to parse.
727
-
728
- Returns:
729
- Set of line numbers (1-based) that were added in the diff.
730
- """
731
- header_regex = hunk_header_pattern()
732
- added_line_numbers: set[int] = set()
733
- for each_line in unified_diff_text.splitlines():
734
- header_match = header_regex.match(each_line)
735
- if header_match is None:
736
- continue
737
- new_start_text, new_count_text = header_match.groups()
738
- new_start = int(new_start_text)
739
- new_count = 1 if new_count_text is None else int(new_count_text)
740
- if new_count <= 0:
741
- continue
742
- for each_number in range(new_start, new_start + new_count):
743
- added_line_numbers.add(each_number)
744
- return added_line_numbers
745
-
746
-
747
- def is_file_new_at_base(
748
- repository_root: Path,
749
- merge_base: str,
750
- relative_path_posix: str,
751
- ) -> bool:
752
- """Check whether a file did not exist at the merge-base commit.
753
-
754
- Args:
755
- repository_root: The repository root.
756
- merge_base: The merge-base commit reference.
757
- relative_path_posix: POSIX-style relative path to check.
758
-
759
- Returns:
760
- True when the file does not exist in the base commit.
761
- """
762
- cat_result = subprocess.run(
763
- ["git", "cat-file", "-e", f"{merge_base}:{relative_path_posix}"],
764
- cwd=str(repository_root),
765
- capture_output=True,
766
- text=True,
767
- encoding="utf-8",
768
- errors="replace",
769
- check=False,
770
- )
771
- return cat_result.returncode != 0
772
-
773
-
774
- def added_lines_for_file(
775
- repository_root: Path,
776
- merge_base: str,
777
- relative_path_posix: str,
778
- ) -> set[int]:
779
- """Determine which lines were added in a file between merge-base and HEAD.
780
-
781
- Args:
782
- repository_root: The repository root.
783
- merge_base: The merge-base commit reference.
784
- relative_path_posix: POSIX-style relative path to the file.
785
-
786
- Returns:
787
- Set of added line numbers (1-based).
788
-
789
- Raises:
790
- SystemExit: When the git diff command fails.
791
- """
792
- diff_result = subprocess.run(
793
- ["git", "diff", "--unified=0", f"{merge_base}..HEAD", "--", relative_path_posix],
794
- cwd=str(repository_root),
795
- capture_output=True,
796
- text=True,
797
- encoding="utf-8",
798
- errors="replace",
799
- check=False,
800
- )
801
- if diff_result.returncode != 0:
802
- print(
803
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}git diff --unified=0 failed for {relative_path_posix}:\n"
804
- f"{diff_result.stderr}",
805
- file=sys.stderr,
806
- )
807
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING)
808
- if not diff_result.stdout.strip():
809
- return set()
810
- return parse_added_line_numbers(diff_result.stdout)
811
-
812
-
813
- def whole_file_line_set(file_path: Path) -> set[int]:
814
- """Return a set of all line numbers in a file.
815
-
816
- Args:
817
- file_path: Path to the file.
818
-
819
- Returns:
820
- Set of line numbers (1-based), or an empty set when the file is empty.
821
-
822
- Raises:
823
- SystemExit: When the file cannot be read; an empty set must not be
824
- returned on read failure because the caller treats it as
825
- "no lines changed" and silently downgrades blocking violations.
826
- """
827
- try:
828
- total_lines = len(file_path.read_text(encoding="utf-8").splitlines())
829
- except (OSError, UnicodeDecodeError) as read_error:
830
- print(
831
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}whole_file_line_set could not read "
832
- f"{file_path}: {type(read_error).__name__}: {read_error}",
833
- file=sys.stderr,
834
- )
835
- raise SystemExit(EXIT_CODE_ENFORCER_MISSING) from read_error
836
- if total_lines <= 0:
837
- return set()
838
- return set(range(1, total_lines + 1))
839
-
840
-
841
- def added_lines_by_file(
842
- repository_root: Path,
843
- base_reference: str,
844
- all_file_paths: list[Path],
845
- ) -> dict[Path, set[int]]:
846
- """Map each changed file path to the set of added line numbers vs merge-base.
847
-
848
- Args:
849
- repository_root: The repository root.
850
- base_reference: The base reference for merge-base comparison.
851
- all_file_paths: File paths to check.
852
-
853
- Returns:
854
- Dictionary mapping resolved file paths to their added line numbers.
855
- """
856
- merge_base = resolve_merge_base(repository_root, base_reference)
857
- resolved_root = repository_root.resolve()
858
- added_by_path: dict[Path, set[int]] = {}
859
- for each_path in all_file_paths:
860
- try:
861
- resolved = each_path.resolve()
862
- except OSError:
863
- continue
864
- try:
865
- relative = resolved.relative_to(resolved_root)
866
- except ValueError:
867
- continue
868
- relative_posix = str(relative).replace("\\", "/")
869
- added_numbers = added_lines_for_file(resolved_root, merge_base, relative_posix)
870
- if not added_numbers and resolved.is_file():
871
- if is_file_new_at_base(resolved_root, merge_base, relative_posix):
872
- added_numbers = whole_file_line_set(resolved)
873
- added_by_path[resolved] = added_numbers
874
- return added_by_path
875
-
876
-
877
- def extract_violation_line_number(violation_text: str) -> int | None:
878
- """Extract the line number from a violation message.
879
-
880
- Args:
881
- violation_text: The violation message text.
882
-
883
- Returns:
884
- The extracted line number, or None when no line number is present.
885
- """
886
- match_result = violation_line_pattern().match(violation_text)
887
- if match_result is None:
888
- return None
889
- return int(match_result.group(1))
890
-
891
-
892
- def function_length_span_range(violation_text: str) -> range | None:
893
- """Return the declared line range of a function-length violation, or None.
894
-
895
- The enforcer's function-length message carries the definition line and
896
- the function's line span: ``Function 'NAME' (defined at line X) is Y
897
- lines - ...``. The function occupies lines ``X`` through ``X + Y - 1``
898
- inclusive.
899
-
900
- Args:
901
- violation_text: A single violation string emitted by the enforcer.
902
-
903
- Returns:
904
- A ``range`` covering the function's declared line span, or None when
905
- the text is not a function-length violation.
906
- """
907
- span_match = FUNCTION_LENGTH_VIOLATION_PATTERN.search(violation_text)
908
- if span_match is None:
909
- return None
910
- definition_line = int(span_match.group(FUNCTION_LENGTH_DEFINITION_LINE_GROUP_INDEX))
911
- line_span = int(span_match.group(FUNCTION_LENGTH_SPAN_GROUP_INDEX))
912
- return range(definition_line, definition_line + line_span)
913
-
914
-
915
- def isolation_span_range(violation_text: str) -> range | None:
916
- """Return the enclosing test-function line range of an isolation violation.
917
-
918
- The enforcer's HOME/TMP isolation message carries the enclosing test
919
- function's definition line and span: ``Line N: Test 'NAME' (defined at
920
- line X, spanning Y lines) probes ...``. The function occupies lines ``X``
921
- through ``X + Y - 1`` inclusive, so a signature-line change that
922
- un-isolates an unchanged-body probe is scoped by the same span the
923
- enforcer uses rather than by the ``Line N:`` probe line alone.
924
-
925
- Args:
926
- violation_text: A single violation string emitted by the enforcer.
927
-
928
- Returns:
929
- A ``range`` covering the enclosing test function's declared line span,
930
- or None when the text is not an isolation violation.
931
- """
932
- span_match = ISOLATION_VIOLATION_PATTERN.search(violation_text)
933
- if span_match is None:
934
- return None
935
- definition_line = int(span_match.group(ISOLATION_DEFINITION_LINE_GROUP_INDEX))
936
- line_span = int(span_match.group(ISOLATION_SPAN_GROUP_INDEX))
937
- return range(definition_line, definition_line + line_span)
938
-
939
-
940
- def banned_noun_span_range(violation_text: str) -> range | None:
941
- """Return the one-line binding span of a banned-noun violation, or None.
942
-
943
- The enforcer's banned-noun message carries the binding line and a one-line
944
- span: ``Line N: Identifier 'NAME' ... (binding span at line X, spanning 1
945
- lines)``. A banned-noun binding is a point fact about one identifier, so the
946
- span is always the binding line alone (``X`` through ``X``) — never the
947
- enclosing function span. Scoping to the binding line keeps a pre-existing
948
- parameter or local-name binding out of scope when an unrelated line of its
949
- enclosing function is edited.
950
-
951
- Args:
952
- violation_text: A single violation string emitted by the enforcer.
953
-
954
- Returns:
955
- A ``range`` covering the binding's one-line span, or None when the text
956
- is not a banned-noun violation.
957
- """
958
- span_match = BANNED_NOUN_VIOLATION_PATTERN.search(violation_text)
959
- if span_match is None:
960
- return None
961
- definition_line = int(span_match.group(BANNED_NOUN_DEFINITION_LINE_GROUP_INDEX))
962
- line_span = int(span_match.group(BANNED_NOUN_SPAN_GROUP_INDEX))
963
- return range(definition_line, definition_line + line_span)
964
-
965
-
966
- def inline_duplicate_body_span_lines(violation_text: str) -> frozenset[int] | None:
967
- """Return the union of both spans of a same-file inline-duplicate issue, or None.
968
-
969
- The same-file inline-duplicate message names two functions that share a body —
970
- the helper and the enclosing function carrying the inline copy — and the live
971
- Write/Edit hook scopes the violation by the UNION of both spans, blocking when
972
- an edit touches either function. So the message carries both spans: ``(inline
973
- duplicate body spans: helper at line H spanning P lines, enclosing at line E
974
- spanning Q lines)``. The two spans can be disjoint (an unrelated function may
975
- sit between the helper and its inline copy), so this returns the union as a
976
- line-number set rather than a single contiguous range — a range covering the
977
- gap would wrongly block an edit confined to that intervening function, which
978
- the PreToolUse path leaves unflagged.
979
-
980
- Args:
981
- violation_text: A single violation string emitted by the enforcer.
982
-
983
- Returns:
984
- The frozenset of every line in the helper span and the enclosing span, or
985
- None when the text is not a same-file inline-duplicate violation.
986
- """
987
- span_match = INLINE_DUPLICATE_BODY_VIOLATION_PATTERN.search(violation_text)
988
- if span_match is None:
989
- return None
990
- helper_line = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_LINE_GROUP_INDEX))
991
- helper_span = int(span_match.group(INLINE_DUPLICATE_BODY_HELPER_SPAN_GROUP_INDEX))
992
- enclosing_line = int(
993
- span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_LINE_GROUP_INDEX)
994
- )
995
- enclosing_span = int(
996
- span_match.group(INLINE_DUPLICATE_BODY_ENCLOSING_SPAN_GROUP_INDEX)
997
- )
998
- helper_lines = range(helper_line, helper_line + helper_span)
999
- enclosing_lines = range(enclosing_line, enclosing_line + enclosing_span)
1000
- return frozenset(helper_lines) | frozenset(enclosing_lines)
1001
-
1002
-
1003
- def _all_span_range_extractors() -> tuple[Callable[[str], range | None], ...]:
1004
- return (
1005
- function_length_span_range,
1006
- isolation_span_range,
1007
- banned_noun_span_range,
1008
- )
1009
-
1010
-
1011
- def enclosing_span_range(violation_text: str) -> range | None:
1012
- """Return the enclosing-unit line range of a span-tagged violation, or None.
1013
-
1014
- Every diff-scoped enforcer check tags its message with an enclosing-unit
1015
- span fragment. This dispatcher tries each span extractor from
1016
- ``_all_span_range_extractors`` so the gate reconstructs every scoped
1017
- check's span through one shared mechanism — adding a new scoped check means
1018
- adding one extractor to that registry rather than threading a new branch
1019
- through ``split_violations_by_scope``.
1020
-
1021
- Args:
1022
- violation_text: A single violation string emitted by the enforcer.
1023
-
1024
- Returns:
1025
- The first non-None span range any extractor recovers, or None when the
1026
- text carries no enclosing-unit span fragment.
1027
- """
1028
- for each_extractor in _all_span_range_extractors():
1029
- span_range = each_extractor(violation_text)
1030
- if span_range is not None:
1031
- return span_range
1032
- return None
1033
-
1034
-
1035
- def split_violations_by_scope(
1036
- all_issues: list[str],
1037
- all_added_line_numbers: set[int] | None,
1038
- ) -> tuple[list[str], list[str]]:
1039
- """Partition issues into blocking vs advisory based on touched lines.
1040
-
1041
- Args:
1042
- all_issues: Violation strings emitted by the enforcer.
1043
- all_added_line_numbers: Lines added in the current diff, or None
1044
- to treat every violation as blocking.
1045
-
1046
- Returns:
1047
- Tuple ``(blocking, advisory)``. When *all_added_line_numbers* is
1048
- None, every issue is blocking. A same-file inline-duplicate violation
1049
- carries both the helper span and the enclosing span;
1050
- ``inline_duplicate_body_span_lines`` reconstructs their union as a
1051
- line-number set, and the violation is blocking when an added line falls
1052
- in either span — matching the live Write/Edit hook's union scoping. Every
1053
- other diff-scoped violation (function-length, HOME/TMP isolation,
1054
- banned-noun) carries one enclosing-unit span fragment that
1055
- ``enclosing_span_range`` reconstructs through one shared extractor
1056
- registry; such a violation is blocking
1057
- when its declared span intersects the added lines (the unit grew or its
1058
- signature changed in this diff) and advisory otherwise (a pre-existing
1059
- untouched unit). Every other issue is blocking when its ``Line N:``
1060
- prefix names an added line and advisory otherwise.
1061
- """
1062
- if all_added_line_numbers is None:
1063
- return list(all_issues), []
1064
- blocking: list[str] = []
1065
- advisory: list[str] = []
1066
- for each_issue in all_issues:
1067
- inline_duplicate_lines = inline_duplicate_body_span_lines(each_issue)
1068
- if inline_duplicate_lines is not None:
1069
- if inline_duplicate_lines & all_added_line_numbers:
1070
- blocking.append(each_issue)
1071
- else:
1072
- advisory.append(each_issue)
1073
- continue
1074
- span_range = enclosing_span_range(each_issue)
1075
- if span_range is not None:
1076
- if any(each_line in all_added_line_numbers for each_line in span_range):
1077
- blocking.append(each_issue)
1078
- else:
1079
- advisory.append(each_issue)
1080
- continue
1081
- violation_line = extract_violation_line_number(each_issue)
1082
- if violation_line is None:
1083
- blocking.append(each_issue)
1084
- continue
1085
- if violation_line in all_added_line_numbers:
1086
- blocking.append(each_issue)
1087
- else:
1088
- advisory.append(each_issue)
1089
- return blocking, advisory
1090
-
1091
-
1092
- def print_violation_section(
1093
- header_message: str,
1094
- violations_by_file: dict[Path, list[str]],
1095
- repository_root: Path,
1096
- ) -> None:
1097
- """Print a section of grouped violation messages grouped by file.
1098
-
1099
- Args:
1100
- header_message: The section header to print first.
1101
- violations_by_file: Violations grouped by file path.
1102
- repository_root: Root for computing relative file paths.
1103
- """
1104
- print(header_message, file=sys.stderr)
1105
- resolved_root = repository_root.resolve()
1106
- for each_path in sorted(violations_by_file.keys()):
1107
- relative = each_path.relative_to(resolved_root)
1108
- print(f"{relative}:", file=sys.stderr)
1109
- for each_issue in violations_by_file[each_path]:
1110
- print(f" {each_issue}", file=sys.stderr)
1111
-
1112
-
1113
- def read_prior_committed_content(
1114
- repository_root: Path, relative_path_posix: str
1115
- ) -> str:
1116
- """Return the HEAD-committed content for *relative_path_posix*.
1117
-
1118
- Args:
1119
- repository_root: The repository root for running git commands.
1120
- relative_path_posix: POSIX-style relative path to read.
1121
-
1122
- Returns:
1123
- The committed file content at HEAD, or an empty string when the
1124
- path is not tracked or ``git show`` returns non-zero.
1125
- """
1126
- git_show_process = subprocess.run(
1127
- ["git", "show", f"HEAD:{relative_path_posix}"],
1128
- cwd=str(repository_root),
1129
- capture_output=True,
1130
- text=True,
1131
- encoding="utf-8",
1132
- errors="replace",
1133
- check=False,
1134
- )
1135
- if git_show_process.returncode != 0:
1136
- return ""
1137
- return git_show_process.stdout
1138
-
1139
-
1140
- def read_staged_content(
1141
- repository_root: Path, relative_path_posix: str
1142
- ) -> str | None:
1143
- """Return the staged-blob content for *relative_path_posix*.
1144
-
1145
- Args:
1146
- repository_root: The repository root for running git commands.
1147
- relative_path_posix: POSIX-style relative path to read.
1148
-
1149
- Returns:
1150
- The staged blob content, or None when the path is not staged, when
1151
- ``git show`` returns non-zero, or when the staged bytes are not
1152
- decodable Unicode (the caller skips and fails closed).
1153
- """
1154
- git_show_process = subprocess.run(
1155
- ["git", "show", f":{relative_path_posix}"],
1156
- cwd=str(repository_root),
1157
- capture_output=True,
1158
- check=False,
1159
- )
1160
- if git_show_process.returncode != 0:
1161
- return None
1162
- try:
1163
- return git_show_process.stdout.decode(encoding="utf-8")
1164
- except UnicodeDecodeError:
1165
- return None
1166
-
1167
-
1168
- def staged_blob_exists(
1169
- repository_root: Path, relative_path_posix: str
1170
- ) -> bool:
1171
- """Report whether *relative_path_posix* is present in the staged index.
1172
-
1173
- Args:
1174
- repository_root: The repository root for running git commands.
1175
- relative_path_posix: POSIX-style relative path to probe.
1176
-
1177
- Returns:
1178
- True when the path is staged for add or modify (its blob exists in the
1179
- index); False when it is absent, such as a staged deletion.
1180
- """
1181
- git_cat_file_process = subprocess.run(
1182
- ["git", "cat-file", "-e", f":{relative_path_posix}"],
1183
- cwd=str(repository_root),
1184
- capture_output=True,
1185
- check=False,
1186
- )
1187
- return git_cat_file_process.returncode == 0
1188
-
1189
-
1190
- def _scoped_violations_for_file(
1191
- validate_content: ValidateContentCallable,
1192
- resolved_path: Path,
1193
- repository_root: Path,
1194
- all_added_lines_for_file: set[int] | None,
1195
- read_staged_content_flag: bool = False,
1196
- ) -> tuple[list[str], list[str]] | None:
1197
- """Validate one resolved file and partition its violations by diff scope.
1198
-
1199
- Args:
1200
- validate_content: The validator function from code_rules_enforcer.
1201
- resolved_path: The resolved code file to validate.
1202
- repository_root: The repository root for relative path resolution.
1203
- all_added_lines_for_file: Lines added in the current diff for this file,
1204
- or None to treat every violation as blocking.
1205
- read_staged_content_flag: When True, source the content from the staged
1206
- blob so it matches the staged diff that scoped the added lines.
1207
-
1208
- Returns:
1209
- ``(blocking, advisory)`` for the file, or None when the file could not
1210
- be read (the caller logs the skip and counts it).
1211
- """
1212
- relative_posix = str(
1213
- resolved_path.relative_to(repository_root.resolve())
1214
- ).replace("\\", "/")
1215
- if read_staged_content_flag:
1216
- staged_content = read_staged_content(repository_root.resolve(), relative_posix)
1217
- if staged_content is None:
1218
- print(f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skip unreadable {resolved_path}", file=sys.stderr)
1219
- return None
1220
- content = staged_content
1221
- else:
1222
- try:
1223
- content = resolved_path.read_text(encoding="utf-8")
1224
- except (OSError, UnicodeDecodeError):
1225
- print(f"{BUGTEAM_CODE_RULES_GATE_PREFIX}skip unreadable {resolved_path}", file=sys.stderr)
1226
- return None
1227
- prior_content = read_prior_committed_content(
1228
- repository_root.resolve(), relative_posix
1229
- )
1230
- issues = validate_content(
1231
- content,
1232
- relative_posix,
1233
- old_content=prior_content,
1234
- defer_scope_to_caller=True,
1235
- )
1236
- issues.extend(check_database_column_string_magic(content, relative_posix))
1237
- issues.extend(check_wrapper_plumb_through(content, relative_posix))
1238
- if not issues:
1239
- return [], []
1240
- return split_violations_by_scope(issues, all_added_lines_for_file)
1241
-
1242
-
1243
- def run_gate(
1244
- validate_content: ValidateContentCallable,
1245
- all_file_paths: list[Path],
1246
- repository_root: Path,
1247
- all_added_lines_map: dict[Path, set[int]] | None,
1248
- read_staged_content_flag: bool = False,
1249
- ) -> int:
1250
- """Run the CODE_RULES gate on a set of file paths.
1251
-
1252
- Applies validate_content, column-string-magic, and wrapper-plumb-through
1253
- checks to each file, then reports violations grouped by file.
1254
-
1255
- Args:
1256
- validate_content: The validator function from code_rules_enforcer.
1257
- all_file_paths: File paths to validate.
1258
- repository_root: The repository root for relative path resolution.
1259
- all_added_lines_map: Optional map of resolved path to added line numbers.
1260
- When provided, violations on added lines are blocking; others are advisory.
1261
- read_staged_content_flag: When True, validate each file's staged blob
1262
- so the content source matches the staged diff.
1263
-
1264
- Returns:
1265
- Zero when every targeted file was validated and no blocking
1266
- violations were found. Non-zero when any blocking violations were
1267
- found OR when one or more files could not be read (a skipped file
1268
- means the gate could not vouch for it).
1269
- """
1270
- blocking_by_file: dict[Path, list[str]] = {}
1271
- advisory_by_file: dict[Path, list[str]] = {}
1272
- skipped_unreadable_count = 0
1273
- for each_file_path in sorted(set(all_file_paths)):
1274
- resolved = _resolve_eligible_code_path(
1275
- each_file_path, repository_root, read_staged_content_flag
1276
- )
1277
- if resolved is None:
1278
- continue
1279
- all_added_lines_for_file = (
1280
- None if all_added_lines_map is None else all_added_lines_map.get(resolved)
1281
- )
1282
- scoped_violations = _scoped_violations_for_file(
1283
- validate_content, resolved, repository_root,
1284
- all_added_lines_for_file, read_staged_content_flag,
1285
- )
1286
- if scoped_violations is None:
1287
- skipped_unreadable_count += 1
1288
- continue
1289
- blocking, advisory = scoped_violations
1290
- if blocking:
1291
- blocking_by_file[resolved] = blocking
1292
- if advisory:
1293
- advisory_by_file[resolved] = advisory
1294
- return _report_partitioned_violations(
1295
- blocking_by_file,
1296
- advisory_by_file,
1297
- repository_root,
1298
- all_added_lines_map is None,
1299
- skipped_unreadable_count,
1300
- )
1301
-
1302
-
1303
- def _report_partitioned_violations(
1304
- blocking_by_file: dict[Path, list[str]],
1305
- advisory_by_file: dict[Path, list[str]],
1306
- repository_root: Path,
1307
- is_whole_file_scope: bool,
1308
- skipped_unreadable_count: int,
1309
- ) -> int:
1310
- """Print the blocking and advisory sections and return the gate exit code.
1311
-
1312
- Args:
1313
- blocking_by_file: Blocking violations grouped by resolved file path.
1314
- advisory_by_file: Advisory violations grouped by resolved file path.
1315
- repository_root: Repository root used to compute relative paths.
1316
- is_whole_file_scope: True when no per-file added-line map was supplied,
1317
- which selects the whole-file header wording.
1318
- skipped_unreadable_count: Count of files that could not be read; a
1319
- non-zero count forces a non-zero exit because the gate cannot
1320
- vouch for those files.
1321
-
1322
- Returns:
1323
- Zero when no blocking violations were found and no file was skipped;
1324
- non-zero otherwise.
1325
- """
1326
- blocking_count = sum(len(each_list) for each_list in blocking_by_file.values())
1327
- advisory_count = sum(len(each_list) for each_list in advisory_by_file.values())
1328
- if blocking_count:
1329
- if is_whole_file_scope:
1330
- header = f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{blocking_count} violation(s) reported."
1331
- else:
1332
- header = (
1333
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{blocking_count} violation(s) "
1334
- "introduced on changed lines:"
1335
- )
1336
- print_violation_section(header, blocking_by_file, repository_root)
1337
- if advisory_count:
1338
- if blocking_count:
1339
- print("", file=sys.stderr)
1340
- print_violation_section(
1341
- (
1342
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{advisory_count} pre-existing violation(s) "
1343
- "in touched files (advisory, not blocking):"
1344
- ),
1345
- advisory_by_file,
1346
- repository_root,
1347
- )
1348
- if skipped_unreadable_count:
1349
- print(
1350
- f"{BUGTEAM_CODE_RULES_GATE_PREFIX}{skipped_unreadable_count} file(s) "
1351
- "skipped due to read errors; gate cannot vouch for those files.",
1352
- file=sys.stderr,
1353
- )
1354
- if blocking_count or skipped_unreadable_count:
1355
- return 1
1356
- return 0
1357
-
1358
-
1359
- def parse_arguments(all_argv: list[str]) -> argparse.Namespace:
1360
- """Parse command-line arguments for the bugteam CODE_RULES gate.
1361
-
1362
- Args:
1363
- all_argv: Command-line argument list.
1364
-
1365
- Returns:
1366
- Parsed namespace with repo_root, base, staged, only_under, and paths.
1367
- """
1368
- parser = argparse.ArgumentParser(
1369
- description=(
1370
- "Run CODE_RULES validators (validate_content) on files in the working tree. "
1371
- "Default file set: git diff --name-only merge-base(base)..HEAD."
1372
- ),
1373
- )
1374
- parser.add_argument(
1375
- "--repo-root",
1376
- type=Path,
1377
- default=None,
1378
- help="Repository root (default: cwd).",
1379
- )
1380
- parser.add_argument(
1381
- "--base",
1382
- default="origin/main",
1383
- help="Merge-base ref for git diff (default: origin/main).",
1384
- )
1385
- parser.add_argument(
1386
- "--staged",
1387
- action="store_true",
1388
- default=False,
1389
- help=(
1390
- "Scope to staged changes only (git diff --cached). "
1391
- "Blocks on violations introduced on staged-added lines; "
1392
- "reports pre-existing violations in touched files as advisory."
1393
- ),
1394
- )
1395
- parser.add_argument(
1396
- "--only-under",
1397
- action="append",
1398
- default=[],
1399
- dest="only_under",
1400
- metavar="PREFIX",
1401
- help=(
1402
- "After resolving the merge-base diff, keep only files whose repo-relative path "
1403
- "uses POSIX slashes and starts with PREFIX or equals PREFIX (repeatable)."
1404
- ),
1405
- )
1406
- parser.add_argument(
1407
- "paths",
1408
- nargs="*",
1409
- type=Path,
1410
- help="Optional explicit files; if set, git diff is not used.",
1411
- )
1412
- return parser.parse_args(all_argv)
1413
-
1414
-
1415
- def main(all_arguments: list[str]) -> int:
1416
- """Entry point for the bugteam CODE_RULES gate.
1417
-
1418
- Parses arguments, loads the validate_content function, determines the
1419
- file scope (staged, diff against base, or explicit paths), and runs
1420
- the gate.
1421
-
1422
- Args:
1423
- all_arguments: Command-line arguments to parse.
1424
-
1425
- Returns:
1426
- Zero when all checks pass, non-zero on violations or errors.
1427
- """
1428
- arguments = parse_arguments(all_arguments)
1429
- repository_root = (
1430
- arguments.repo_root.resolve()
1431
- if arguments.repo_root is not None
1432
- else Path.cwd().resolve()
1433
- )
1434
- validate_content = load_validate_content()
1435
- if arguments.paths:
1436
- all_parsed_paths = [repository_root / each_path for each_path in arguments.paths]
1437
- return run_gate(validate_content, all_parsed_paths, repository_root, all_added_lines_map=None)
1438
- if arguments.staged:
1439
- staged_file_paths = paths_from_git_staged(repository_root)
1440
- staged_file_paths = filter_paths_under_prefixes(
1441
- staged_file_paths,
1442
- repository_root,
1443
- arguments.only_under,
1444
- )
1445
- if not staged_file_paths:
1446
- return 0
1447
- staged_added_lines = added_lines_by_file_staged(repository_root, staged_file_paths)
1448
- return run_gate(
1449
- validate_content,
1450
- staged_file_paths,
1451
- repository_root,
1452
- all_added_lines_map=staged_added_lines,
1453
- read_staged_content_flag=True,
1454
- )
1455
- all_diff_paths = paths_from_git_diff(repository_root, arguments.base)
1456
- all_diff_paths = filter_paths_under_prefixes(
1457
- all_diff_paths,
1458
- repository_root,
1459
- arguments.only_under,
1460
- )
1461
- if not all_diff_paths:
1462
- return 0
1463
- scoped_added_lines = added_lines_by_file(repository_root, arguments.base, all_diff_paths)
1464
- return run_gate(
1465
- validate_content,
1466
- all_diff_paths,
1467
- repository_root,
1468
- all_added_lines_map=scoped_added_lines,
1469
- )
1470
-
18
+ from code_rules_gate import ( # noqa: E402
19
+ main,
20
+ )
1471
21
 
1472
22
  if __name__ == "__main__":
1473
23
  raise SystemExit(main(sys.argv[1:]))