claude-dev-env 2.30.1 → 2.31.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 (143) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/AGENTS.md +6 -0
  3. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  4. package/bin/install.prune.test.mjs +4 -2
  5. package/docs/CODE_RULES.md +12 -0
  6. package/hooks/advisory/migration_safety_advisor.py +25 -1
  7. package/hooks/advisory/refactor_guard.py +85 -18
  8. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  9. package/hooks/advisory/test_refactor_guard.py +87 -0
  10. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  11. package/hooks/blocking/code_rules_constants_config.py +7 -6
  12. package/hooks/blocking/code_rules_duplicate_body.py +272 -100
  13. package/hooks/blocking/code_rules_enforcer.py +89 -45
  14. package/hooks/blocking/code_rules_shared.py +64 -14
  15. package/hooks/blocking/codex_apply_patch.py +136 -21
  16. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  17. package/hooks/blocking/conftest.py +70 -0
  18. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  19. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  20. package/hooks/blocking/pii_payload_scan.py +61 -0
  21. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  22. package/hooks/blocking/pr_description_writer_gate.py +256 -0
  23. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  24. package/hooks/blocking/sensitive_file_protector.py +18 -3
  25. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  26. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  27. package/hooks/blocking/state_description_blocker.py +39 -26
  28. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  29. package/hooks/blocking/tdd_enforcer.py +63 -16
  30. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  31. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  32. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  33. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  34. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  37. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  38. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  39. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  40. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  41. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  42. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_ephemeral.py +12 -1
  44. package/hooks/blocking/test_code_rules_enforcer_ephemeral_repository_root.py +118 -0
  45. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  46. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +244 -0
  49. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  50. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +24 -0
  51. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  52. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  53. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  54. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  55. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  56. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  57. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  58. package/hooks/blocking/test_pr_description_writer_gate.py +234 -0
  59. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  60. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +18 -6
  61. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  62. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  63. package/hooks/blocking/test_state_description_blocker.py +163 -49
  64. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  65. package/hooks/blocking/test_tdd_enforcer.py +246 -2
  66. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  67. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  68. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  69. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  70. package/hooks/conftest.py +47 -0
  71. package/hooks/hooks.json +7 -12
  72. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  73. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  74. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  75. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  76. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  77. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  78. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  79. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +73 -0
  80. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  81. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  82. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  83. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  84. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  85. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +103 -0
  87. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  88. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +103 -0
  89. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  90. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  91. package/hooks/observability/pr_description_writer_spawn_tracker.py +113 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +150 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/validation/hook_format_validator.py +23 -9
  96. package/hooks/validation/test_hook_format_validator.py +58 -0
  97. package/hooks/validators/README.md +1 -1
  98. package/hooks/validators/abbreviation_checks.py +6 -8
  99. package/hooks/validators/code_quality_checks.py +4 -4
  100. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  101. package/hooks/validators/config/baseline_identity_constants.py +12 -0
  102. package/hooks/validators/config/directory_exemption_constants.py +44 -1
  103. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  104. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  105. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  106. package/hooks/validators/config/validator_base_constants.py +9 -0
  107. package/hooks/validators/fast_save_validators.py +275 -0
  108. package/hooks/validators/hook_timing_harness.py +8 -4
  109. package/hooks/validators/pr_reference_checks.py +2 -2
  110. package/hooks/validators/python_antipattern_checks.py +3 -3
  111. package/hooks/validators/python_style_checks.py +37 -62
  112. package/hooks/validators/python_style_helpers.py +16 -2
  113. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  114. package/hooks/validators/react_checks.py +2 -9
  115. package/hooks/validators/run_all_validators.py +201 -155
  116. package/hooks/validators/security_checks.py +3 -3
  117. package/hooks/validators/test_abbreviation_checks.py +20 -0
  118. package/hooks/validators/test_directory_exemption_constants.py +30 -24
  119. package/hooks/validators/test_fast_save_validators.py +172 -0
  120. package/hooks/validators/test_hook_timing_harness.py +10 -1
  121. package/hooks/validators/test_python_style_checks.py +9 -0
  122. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  123. package/hooks/validators/test_python_style_helpers.py +35 -0
  124. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  125. package/hooks/validators/test_run_all_validators.py +46 -0
  126. package/hooks/validators/test_run_all_validators_pretooluse.py +163 -0
  127. package/hooks/validators/test_run_all_validators_repository_root.py +202 -0
  128. package/hooks/validators/test_validator_base.py +51 -2
  129. package/hooks/validators/todo_checks.py +2 -2
  130. package/hooks/validators/type_safety_checks.py +3 -3
  131. package/hooks/validators/useless_test_checks.py +3 -3
  132. package/hooks/validators/validator_base.py +73 -1
  133. package/package.json +1 -1
  134. package/rules/docstring-prose-matches-implementation.md +1 -1
  135. package/rules/git-workflow.md +2 -0
  136. package/rules/testing.md +4 -0
  137. package/scripts/codex_compat_materializer.py +5 -0
  138. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +15 -0
  139. package/scripts/grok_headless_runner.py +15 -12
  140. package/scripts/shared_tree_paths.py +70 -0
  141. package/scripts/test_shared_tree_paths.py +143 -0
  142. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  143. package/hooks/blocking/tests/conftest.py +0 -10
@@ -0,0 +1,275 @@
1
+ """In-process, Mypy-free validator roster for the Write/Edit save-path gate.
2
+
3
+ ``run_all_validators.py``'s PreToolUse gate calls ``run_fast_save_validators``
4
+ in place of the CLI/full mode's per-check subprocess and its Mypy pass.
5
+
6
+ ::
7
+
8
+ save path (this module): 12 checks, in-process, no Mypy, no subprocess
9
+ CLI / full mode (run_all_validators.main): the same 12 checks, plus Mypy
10
+ and Ruff, each still its own subprocess -- unchanged by this module
11
+
12
+ Every check here calls the exact same ``validate_file`` (or check) function
13
+ the CLI mode calls. A save-path finding matches the CLI mode's finding for
14
+ the same validator; only Mypy and the process-per-check overhead are gone.
15
+ """
16
+
17
+ from collections.abc import Callable, Sequence
18
+ from dataclasses import dataclass
19
+ from pathlib import Path
20
+
21
+ from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
22
+
23
+ from .abbreviation_checks import validate_file as validate_abbreviation_file
24
+ from .code_quality_checks import validate_file as validate_code_quality_file
25
+ from .config.fast_save_validator_constants import (
26
+ ALL_CODE_REFERENCE_FILE_SUFFIXES,
27
+ ALL_REACT_COMPONENT_FILE_SUFFIXES,
28
+ NO_CODE_REFERENCE_FILES_MESSAGE,
29
+ NO_PYTHON_FILES_MESSAGE,
30
+ NO_REACT_FILES_MESSAGE,
31
+ NO_TEST_FILES_MESSAGE,
32
+ NO_VIOLATIONS_FOUND_MESSAGE,
33
+ VIOLATION_REPORT_LINE_SEPARATOR,
34
+ )
35
+ from .magic_value_checks import validate_file as validate_magic_literal_file
36
+ from .pr_reference_checks import validate_file as validate_pr_reference_file
37
+ from .python_antipattern_checks import validate_file as validate_python_antipattern_file
38
+ from .python_style_checks import validate_file as validate_python_style_file
39
+ from .react_checks import check_no_class_components
40
+ from .security_checks import validate_file as validate_security_file
41
+ from .test_safety_checks import (
42
+ check_debug_guard_in_dev_scripts,
43
+ check_no_skip_decorators,
44
+ )
45
+ from .todo_checks import validate_file as validate_todo_file
46
+ from .type_safety_checks import validate_file as validate_type_safety_file
47
+ from .useless_test_checks import validate_file as validate_useless_test_file
48
+ from .validator_base import ValidatorResult
49
+
50
+ FilePredicate = Callable[[Path], bool]
51
+ FileViolationLineCollector = Callable[[Path], "list[str]"]
52
+ _RawFileValidator = Callable[[Path], "Sequence[object]"]
53
+
54
+
55
+ def _violation_lines_from(raw_validate_file: _RawFileValidator) -> FileViolationLineCollector:
56
+ """Wrap a ``validate_file`` returning check-specific Violation objects.
57
+
58
+ ::
59
+
60
+ wrap(abbreviation_checks.validate_file) -> Callable[[Path], list[str]]
61
+
62
+ Each check module defines its own ``Violation``-shaped dataclass with a
63
+ matching ``__str__``, so formatting through ``str()`` here needs no shared
64
+ base type across the four differently-typed modules that define one.
65
+
66
+ Args:
67
+ raw_validate_file: A module's own ``validate_file(path) -> [Violation, ...]``.
68
+
69
+ Returns:
70
+ A collector returning each violation's ``str()`` line, not the
71
+ object itself.
72
+ """
73
+
74
+ def _collect(file_path: Path) -> list[str]:
75
+ return [str(each_violation) for each_violation in raw_validate_file(file_path)]
76
+
77
+ return _collect
78
+
79
+
80
+ def _is_python_file(file_path: Path) -> bool:
81
+ return file_path.suffix == PYTHON_SOURCE_SUFFIX
82
+
83
+
84
+ def _is_test_python_file(file_path: Path) -> bool:
85
+ return "test" in file_path.name.lower() and file_path.suffix == PYTHON_SOURCE_SUFFIX
86
+
87
+
88
+ def _is_react_file(file_path: Path) -> bool:
89
+ return file_path.suffix in ALL_REACT_COMPONENT_FILE_SUFFIXES
90
+
91
+
92
+ def _is_code_reference_file(file_path: Path) -> bool:
93
+ return file_path.suffix in ALL_CODE_REFERENCE_FILE_SUFFIXES
94
+
95
+
96
+ def _collect_test_safety_violation_lines(file_path: Path) -> "list[str]":
97
+ """Adapt test_safety_checks's whole-batch ``main`` into one file at a time."""
98
+ try:
99
+ code = file_path.read_text(encoding="utf-8")
100
+ except OSError:
101
+ return []
102
+ filepath = str(file_path)
103
+ all_violations = [
104
+ *check_no_skip_decorators(code, filepath),
105
+ *check_debug_guard_in_dev_scripts(code, filepath),
106
+ ]
107
+ return [str(each_violation) for each_violation in all_violations]
108
+
109
+
110
+ def _collect_react_violation_lines(file_path: Path) -> "list[str]":
111
+ """Adapt react_checks's whole-batch ``check_no_class_components`` to one file."""
112
+ return [
113
+ str(each_violation)
114
+ for each_violation in check_no_class_components([str(file_path)])
115
+ ]
116
+
117
+
118
+ @dataclass(frozen=True)
119
+ class _FastSaveValidatorSpecification:
120
+ """One save-path validator: display name, check numbers, and how to run it."""
121
+
122
+ display_name: str
123
+ checks: str
124
+ applies_to_file: FilePredicate
125
+ collect_violation_lines: FileViolationLineCollector
126
+ no_matching_files_message: str
127
+
128
+
129
+ def _all_fast_save_validator_specifications() -> (
130
+ "tuple[_FastSaveValidatorSpecification, ...]"
131
+ ):
132
+ """Return the save-path roster: every non-Mypy, non-Ruff file-scoped check."""
133
+ return (
134
+ # Python naming, style, and constants.
135
+ _FastSaveValidatorSpecification(
136
+ "Python Style",
137
+ "1,2,3,4",
138
+ _is_python_file,
139
+ _violation_lines_from(validate_python_style_file),
140
+ NO_PYTHON_FILES_MESSAGE,
141
+ ),
142
+ _FastSaveValidatorSpecification(
143
+ "Abbreviations",
144
+ "5",
145
+ _is_python_file,
146
+ _violation_lines_from(validate_abbreviation_file),
147
+ NO_PYTHON_FILES_MESSAGE,
148
+ ),
149
+ _FastSaveValidatorSpecification(
150
+ "Magic Values",
151
+ "7",
152
+ _is_python_file,
153
+ _violation_lines_from(validate_magic_literal_file),
154
+ NO_PYTHON_FILES_MESSAGE,
155
+ ),
156
+ # Python code quality and TODO tracking.
157
+ _FastSaveValidatorSpecification(
158
+ "TODO Tracking",
159
+ "36",
160
+ _is_python_file,
161
+ _violation_lines_from(validate_todo_file),
162
+ NO_PYTHON_FILES_MESSAGE,
163
+ ),
164
+ _FastSaveValidatorSpecification(
165
+ "Security",
166
+ "27,28,29",
167
+ _is_python_file,
168
+ _violation_lines_from(validate_security_file),
169
+ NO_PYTHON_FILES_MESSAGE,
170
+ ),
171
+ _FastSaveValidatorSpecification(
172
+ "Code Quality",
173
+ "30,31,32",
174
+ _is_python_file,
175
+ _violation_lines_from(validate_code_quality_file),
176
+ NO_PYTHON_FILES_MESSAGE,
177
+ ),
178
+ # Python anti-patterns and typing.
179
+ _FastSaveValidatorSpecification(
180
+ "Python Anti-patterns",
181
+ "33,34,35",
182
+ _is_python_file,
183
+ _violation_lines_from(validate_python_antipattern_file),
184
+ NO_PYTHON_FILES_MESSAGE,
185
+ ),
186
+ _FastSaveValidatorSpecification(
187
+ "Type Safety",
188
+ "39,40",
189
+ _is_python_file,
190
+ _violation_lines_from(validate_type_safety_file),
191
+ NO_PYTHON_FILES_MESSAGE,
192
+ ),
193
+ # Scoped to a test_*.py-shaped file.
194
+ _FastSaveValidatorSpecification(
195
+ "Test Safety",
196
+ "11,21",
197
+ _is_test_python_file,
198
+ _collect_test_safety_violation_lines,
199
+ NO_TEST_FILES_MESSAGE,
200
+ ),
201
+ _FastSaveValidatorSpecification(
202
+ "Useless Tests",
203
+ "12",
204
+ _is_test_python_file,
205
+ _violation_lines_from(validate_useless_test_file),
206
+ NO_TEST_FILES_MESSAGE,
207
+ ),
208
+ # Non-Python or multi-language files.
209
+ _FastSaveValidatorSpecification(
210
+ "React",
211
+ "17",
212
+ _is_react_file,
213
+ _collect_react_violation_lines,
214
+ NO_REACT_FILES_MESSAGE,
215
+ ),
216
+ _FastSaveValidatorSpecification(
217
+ "PR References",
218
+ "6",
219
+ _is_code_reference_file,
220
+ _violation_lines_from(validate_pr_reference_file),
221
+ NO_CODE_REFERENCE_FILES_MESSAGE,
222
+ ),
223
+ )
224
+
225
+
226
+ def _fast_save_outcome(
227
+ specification: _FastSaveValidatorSpecification,
228
+ is_clean: bool,
229
+ violation_report: str,
230
+ ) -> ValidatorResult:
231
+ """Build one result from a specification's display name and check numbers."""
232
+ return ValidatorResult(
233
+ name=specification.display_name,
234
+ checks=specification.checks,
235
+ passed=is_clean,
236
+ output=violation_report,
237
+ )
238
+
239
+
240
+ def _run_specification(
241
+ specification: _FastSaveValidatorSpecification, files: "list[Path]"
242
+ ) -> ValidatorResult:
243
+ """Run one validator's violation collector in-process over its matching files."""
244
+ matching_files = [
245
+ each_file for each_file in files if specification.applies_to_file(each_file)
246
+ ]
247
+ if not matching_files:
248
+ return _fast_save_outcome(
249
+ specification, True, specification.no_matching_files_message
250
+ )
251
+ all_violation_lines = [
252
+ each_line
253
+ for each_file in matching_files
254
+ for each_line in specification.collect_violation_lines(each_file)
255
+ ]
256
+ if not all_violation_lines:
257
+ return _fast_save_outcome(specification, True, NO_VIOLATIONS_FOUND_MESSAGE)
258
+ violation_report = VIOLATION_REPORT_LINE_SEPARATOR.join(all_violation_lines)
259
+ return _fast_save_outcome(specification, False, violation_report)
260
+
261
+
262
+ def run_fast_save_validators(files: "list[Path]") -> "list[ValidatorResult]":
263
+ """Run every save-path validator in-process, skipping Mypy and Ruff.
264
+
265
+ Args:
266
+ files: The files under validation -- one reconstructed file in gate mode.
267
+
268
+ Returns:
269
+ One result per in-process validator, in roster order. The caller adds
270
+ Ruff and never adds Mypy.
271
+ """
272
+ return [
273
+ _run_specification(each_specification, files)
274
+ for each_specification in _all_fast_save_validator_specifications()
275
+ ]
@@ -13,10 +13,14 @@ count, and reported as p50 and p95 milliseconds.
13
13
  post_tool_use_dispatcher p50=80.1ms p95=1200.5ms
14
14
  session_file_edit_tracker p50=31.0ms p95=34.2ms
15
15
 
16
- A target under the OS temp root would return before any validator ran, so this
17
- harness refuses one. It always runs the whole command line ``hooks.json``
18
- registers, never a single hosted check module standing in for it, so neither
19
- shortcut can distort the numbers.
16
+ This harness refuses a target under the OS temp root, and runs the whole
17
+ command line ``hooks.json`` registers.
18
+
19
+ The payload names the Write tool and carries the target's own current content.
20
+ ``code_rules_enforcer`` takes its early return for a write over an existing
21
+ file, so the ``pre_tool_use_dispatcher`` figure covers interpreter start and
22
+ hook dispatch. Time an Edit payload against a real file to measure the
23
+ CODE_RULES checks themselves.
20
24
 
21
25
  The timed payload carries no ``session_id``, so ``session_file_edit_tracker``
22
26
  writes its edit record to the default-session file rather than to a
@@ -9,7 +9,7 @@ import sys
9
9
  from pathlib import Path
10
10
  from typing import List
11
11
 
12
- from .validator_base import Violation
12
+ from .validator_base import Violation, source_text
13
13
 
14
14
 
15
15
  PR_PATTERN = re.compile(r"#.*\bPR\s*#?\d+", re.IGNORECASE)
@@ -42,7 +42,7 @@ def check_pr_references(source: str, filename: str) -> List[Violation]:
42
42
  def validate_file(file_path: Path) -> List[Violation]:
43
43
  filename = str(file_path)
44
44
  try:
45
- source = file_path.read_text(encoding="utf-8")
45
+ source = source_text(file_path)
46
46
  except Exception as error:
47
47
  return [Violation(filename, 0, f"Error reading file: {error}")]
48
48
 
@@ -11,7 +11,7 @@ import sys
11
11
  from pathlib import Path
12
12
  from typing import List
13
13
 
14
- from .validator_base import Violation
14
+ from .validator_base import Violation, source_text, syntax_tree
15
15
 
16
16
  _hooks_directory = str(Path(__file__).resolve().parent.parent)
17
17
 
@@ -90,8 +90,8 @@ def validate_file(file_path: Path) -> List[Violation]:
90
90
  filename = str(file_path)
91
91
 
92
92
  try:
93
- source = file_path.read_text(encoding="utf-8")
94
- tree = ast.parse(source)
93
+ source = source_text(file_path)
94
+ tree = syntax_tree(source)
95
95
  except Exception as error:
96
96
  return [Violation(filename, 0, f"Error: {error}")]
97
97
 
@@ -14,42 +14,28 @@ from dataclasses import dataclass
14
14
  from pathlib import Path
15
15
 
16
16
  _hooks_directory = str(Path(__file__).resolve().parent.parent)
17
-
18
- try:
19
- from hooks_constants.python_style_checks_constants import (
20
- EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS,
21
- MINIMUM_ARGUMENT_COUNT,
22
- )
23
- except ModuleNotFoundError:
24
- if _hooks_directory not in sys.path:
25
- sys.path.insert(0, _hooks_directory)
26
- from hooks_constants.python_style_checks_constants import (
27
- EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS,
28
- MINIMUM_ARGUMENT_COUNT,
29
- )
30
-
31
- try:
32
- from validators.python_style_helpers import (
33
- FunctionNode,
34
- blank_line_for_source,
35
- function_start_line,
36
- gap_is_blank_only,
37
- iter_function_definitions,
38
- real_newline_lines,
39
- top_level_functions,
40
- )
41
- except ModuleNotFoundError:
42
- if _hooks_directory not in sys.path:
43
- sys.path.insert(0, _hooks_directory)
44
- from validators.python_style_helpers import (
45
- FunctionNode,
46
- blank_line_for_source,
47
- function_start_line,
48
- gap_is_blank_only,
49
- iter_function_definitions,
50
- real_newline_lines,
51
- top_level_functions,
52
- )
17
+ if _hooks_directory not in sys.path:
18
+ sys.path.insert(0, _hooks_directory)
19
+
20
+ from hooks_constants.python_style_checks_constants import ( # noqa: E402
21
+ EXPECTED_BLANK_LINES_BETWEEN_FUNCTIONS,
22
+ MINIMUM_ARGUMENT_COUNT,
23
+ )
24
+ from validators.validator_base import source_text, syntax_tree # noqa: E402
25
+ from validators.python_style_helpers import ( # noqa: E402
26
+ FunctionNode,
27
+ blank_line_for_source,
28
+ function_start_line,
29
+ gap_is_blank_only,
30
+ is_docstring_statement,
31
+ is_import_statement,
32
+ iter_function_definitions,
33
+ real_newline_lines,
34
+ top_level_functions,
35
+ )
36
+ from validators.python_style_import_bootstrap import ( # noqa: E402
37
+ is_sys_path_bootstrap_prelude,
38
+ )
53
39
 
54
40
  logger = logging.getLogger(__name__)
55
41
 
@@ -83,37 +69,26 @@ def check_imports_at_top(tree: ast.AST, filename: str) -> list[Violation]:
83
69
  return violations
84
70
 
85
71
 
86
- def _is_import_statement(statement: ast.stmt) -> bool:
87
- """Return True when the statement is an import or from-import."""
88
- return isinstance(statement, (ast.Import, ast.ImportFrom))
89
-
90
-
91
- def _is_docstring_statement(statement: ast.stmt) -> bool:
92
- """Return True when the statement is a string-literal docstring."""
93
- if not isinstance(statement, ast.Expr):
94
- return False
95
- literal = statement.value
96
- return isinstance(literal, ast.Constant) and isinstance(literal.value, str)
97
-
98
-
99
72
  def _check_module_level_import_order(tree: ast.AST, filename: str) -> list[Violation]:
100
- """Flag module-level imports that appear after other statements."""
73
+ """Flag module-level imports after other statements, exempting a sys.path bootstrap run."""
101
74
  if not isinstance(tree, ast.Module):
102
75
  return []
103
76
  violations: list[Violation] = []
104
77
  has_seen_non_import = False
78
+ pending_prelude_statements: list[ast.stmt] = []
105
79
  for each_statement in tree.body:
106
- is_import = _is_import_statement(each_statement)
107
- if is_import and has_seen_non_import:
108
- violations.append(
109
- Violation(
110
- filename,
111
- each_statement.lineno,
112
- "Import statement must be at top of file",
113
- )
114
- )
115
- if not is_import and not _is_docstring_statement(each_statement):
80
+ if not is_import_statement(each_statement):
81
+ if not is_docstring_statement(each_statement):
82
+ pending_prelude_statements.append(each_statement)
83
+ continue
84
+ if pending_prelude_statements and not is_sys_path_bootstrap_prelude(
85
+ pending_prelude_statements
86
+ ):
116
87
  has_seen_non_import = True
88
+ pending_prelude_statements = []
89
+ if has_seen_non_import:
90
+ message = "Import statement must be at top of file"
91
+ violations.append(Violation(filename, each_statement.lineno, message))
117
92
  return violations
118
93
 
119
94
 
@@ -357,12 +332,12 @@ def validate_file(file_path: Path) -> list[Violation]:
357
332
  violations: list[Violation] = []
358
333
  filename = str(file_path)
359
334
  try:
360
- source = file_path.read_text(encoding="utf-8")
335
+ source = source_text(file_path)
361
336
  except Exception as error:
362
337
  violations.append(Violation(filename, 0, f"Error reading file: {error}"))
363
338
  return violations
364
339
  try:
365
- tree = ast.parse(source)
340
+ tree = syntax_tree(source)
366
341
  except SyntaxError as error:
367
342
  violations.append(
368
343
  Violation(filename, error.lineno or 0, f"Syntax error: {error.msg}")
@@ -1,8 +1,9 @@
1
1
  """Shared source-line and function-discovery helpers for the style checks.
2
2
 
3
3
  These pure helpers underlie the style checks and the blank-line fixers:
4
- splitting source into ast-aligned lines, locating function definitions, and
5
- matching the source newline convention.
4
+ splitting source into ast-aligned lines, locating function definitions,
5
+ classifying a module-level statement, and matching the source newline
6
+ convention.
6
7
  """
7
8
 
8
9
  import ast
@@ -93,3 +94,16 @@ def real_newline_lines(source: str) -> list[str]:
93
94
  if line_start < total_length:
94
95
  lines.append(source[line_start:])
95
96
  return lines
97
+
98
+
99
+ def is_import_statement(statement: ast.stmt) -> bool:
100
+ """Return True when the statement is an import or from-import."""
101
+ return isinstance(statement, (ast.Import, ast.ImportFrom))
102
+
103
+
104
+ def is_docstring_statement(statement: ast.stmt) -> bool:
105
+ """Return True when the statement is a string-literal docstring."""
106
+ if not isinstance(statement, ast.Expr):
107
+ return False
108
+ literal = statement.value
109
+ return isinstance(literal, ast.Constant) and isinstance(literal.value, str)
@@ -0,0 +1,83 @@
1
+ """Recognizer for the repo's sys.path bootstrap-guard idiom.
2
+
3
+ Split out of python_style_checks.py to keep that file under its line cap.
4
+ Tells the import-order check whether a run of module-level statements is that
5
+ idiom rather than ordinary code: `check_sys_path_insert_deduplication_guard`
6
+ (code_rules_paths_syspath.py) requires every `sys.path.insert` call to sit
7
+ behind `if <path> not in sys.path:`, and the import-order check must not
8
+ penalize a file for following that guard.
9
+ """
10
+
11
+ import ast
12
+
13
+
14
+ def _is_sys_path_insert_expression(expression: ast.expr) -> bool:
15
+ """Return True for a `sys.path.insert(...)` call expression."""
16
+ if not isinstance(expression, ast.Call):
17
+ return False
18
+ function_reference = expression.func
19
+ if not isinstance(function_reference, ast.Attribute) or function_reference.attr != "insert":
20
+ return False
21
+ receiver = function_reference.value
22
+ if not isinstance(receiver, ast.Attribute) or receiver.attr != "path":
23
+ return False
24
+ receiver_value = receiver.value
25
+ return isinstance(receiver_value, ast.Name) and receiver_value.id == "sys"
26
+
27
+
28
+ def _is_sys_path_bootstrap_guard(statement: ast.stmt) -> bool:
29
+ """Return True for `if <expr> not in sys.path: sys.path.insert(...)`.
30
+
31
+ Only the then-branch is inspected, matching the scope
32
+ `check_sys_path_insert_deduplication_guard` itself checks.
33
+ """
34
+ if not isinstance(statement, ast.If):
35
+ return False
36
+ membership_test = statement.test
37
+ if not isinstance(membership_test, ast.Compare) or len(membership_test.ops) != 1:
38
+ return False
39
+ if not isinstance(membership_test.ops[0], ast.NotIn):
40
+ return False
41
+ membership_target = membership_test.comparators[0]
42
+ if not isinstance(membership_target, ast.Attribute) or membership_target.attr != "path":
43
+ return False
44
+ if not isinstance(membership_target.value, ast.Name) or membership_target.value.id != "sys":
45
+ return False
46
+ return any(
47
+ isinstance(each_body_statement, ast.Expr)
48
+ and _is_sys_path_insert_expression(each_body_statement.value)
49
+ for each_body_statement in statement.body
50
+ )
51
+
52
+
53
+ def _is_plain_assignment_statement(statement: ast.stmt) -> bool:
54
+ """Return True for a single-target module-level `name = value` assignment."""
55
+ return (
56
+ isinstance(statement, ast.Assign)
57
+ and len(statement.targets) == 1
58
+ and isinstance(statement.targets[0], ast.Name)
59
+ )
60
+
61
+
62
+ def is_sys_path_bootstrap_prelude(all_pending_statements: list[ast.stmt]) -> bool:
63
+ """Return True when *all_pending_statements* form the repo's sys.path bootstrap idiom.
64
+
65
+ Writing the mandated dedup guard takes a preceding `<path> = ...`
66
+ assignment and the guarded `if` itself — both ordinary, non-import
67
+ statements that would otherwise read as ordinary code to an import-order
68
+ check and flag every import that follows.
69
+
70
+ A run of statements between two import blocks is accepted as this
71
+ bootstrap prelude, rather than as ordinary code, when it contains at
72
+ least one real guard and every other statement in the same run is a
73
+ plain assignment — the shape a `path = ...` line or an unrelated
74
+ same-block constant both take. A bare `sys.path.insert` with no guard,
75
+ or any def/class/call statement, is not part of this shape and still
76
+ counts as ordinary code.
77
+ """
78
+ if not any(_is_sys_path_bootstrap_guard(each) for each in all_pending_statements):
79
+ return False
80
+ return all(
81
+ _is_sys_path_bootstrap_guard(each) or _is_plain_assignment_statement(each)
82
+ for each in all_pending_statements
83
+ )
@@ -7,16 +7,9 @@ Validates React-specific code standards:
7
7
 
8
8
  import re
9
9
  import sys
10
- from dataclasses import dataclass
11
10
  from pathlib import Path
12
11
 
13
-
14
- @dataclass(frozen=True)
15
- class Violation:
16
- """Represents a validation violation."""
17
- file: str
18
- line: int
19
- message: str
12
+ from .validator_base import Violation
20
13
 
21
14
 
22
15
  CLASS_COMPONENT_PATTERN = re.compile(
@@ -81,7 +74,7 @@ def main() -> int:
81
74
  violations = check_no_class_components(file_paths)
82
75
 
83
76
  for violation in violations:
84
- print(f"{violation.file}:{violation.line}: {violation.message}")
77
+ print(violation)
85
78
 
86
79
  return 1 if violations else 0
87
80