claude-dev-env 2.30.1 → 2.31.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 (118) 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 +11 -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_enforcer.py +57 -31
  13. package/hooks/blocking/codex_apply_patch.py +136 -21
  14. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  15. package/hooks/blocking/conftest.py +70 -0
  16. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  17. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  18. package/hooks/blocking/pii_payload_scan.py +61 -0
  19. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  20. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  21. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  22. package/hooks/blocking/sensitive_file_protector.py +18 -3
  23. package/hooks/blocking/shell_substitution_blocker.py +25 -47
  24. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  25. package/hooks/blocking/state_description_blocker.py +39 -26
  26. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  27. package/hooks/blocking/tdd_enforcer.py +57 -14
  28. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  29. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  30. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  31. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  32. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  33. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  36. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +1 -0
  37. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  38. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  39. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  40. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  41. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  42. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  43. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  45. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  46. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  48. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  49. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  50. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  51. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  52. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  53. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  54. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  55. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  56. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  57. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  58. package/hooks/blocking/test_state_description_blocker.py +68 -0
  59. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  60. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  61. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  62. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  63. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  64. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  65. package/hooks/conftest.py +47 -0
  66. package/hooks/hooks.json +7 -12
  67. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  68. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -0
  69. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  70. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  71. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  72. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  73. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  74. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  75. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  76. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  77. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  78. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  79. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +2 -0
  80. package/hooks/hooks_constants/test_code_rules_enforcer_constants.py +2 -0
  81. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -0
  82. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +139 -25
  83. package/hooks/hooks_constants/test_subprocess_budget_completeness_content.py +70 -0
  84. package/hooks/hooks_constants/validation_phase_constants.py +2 -0
  85. package/hooks/hooks_constants/workflow_substitution_slot_blocker_constants.py +7 -3
  86. package/hooks/observability/pr_description_writer_spawn_tracker.py +121 -0
  87. package/hooks/observability/session_file_edit_tracker.py +25 -4
  88. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  89. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  90. package/hooks/validation/hook_format_validator.py +23 -9
  91. package/hooks/validation/test_hook_format_validator.py +58 -0
  92. package/hooks/validators/README.md +1 -1
  93. package/hooks/validators/abbreviation_checks.py +3 -5
  94. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  95. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  96. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  97. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  98. package/hooks/validators/fast_save_validators.py +332 -0
  99. package/hooks/validators/hook_timing_harness.py +8 -4
  100. package/hooks/validators/python_style_checks.py +34 -60
  101. package/hooks/validators/python_style_helpers.py +16 -2
  102. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  103. package/hooks/validators/run_all_validators.py +42 -41
  104. package/hooks/validators/test_abbreviation_checks.py +20 -0
  105. package/hooks/validators/test_fast_save_validators.py +172 -0
  106. package/hooks/validators/test_python_style_checks.py +9 -0
  107. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  108. package/hooks/validators/test_python_style_helpers.py +35 -0
  109. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  110. package/hooks/validators/test_run_all_validators.py +46 -0
  111. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  112. package/package.json +1 -1
  113. package/rules/docstring-prose-matches-implementation.md +1 -1
  114. package/rules/git-workflow.md +2 -0
  115. package/rules/testing.md +4 -0
  116. package/scripts/codex_compat_materializer.py +5 -0
  117. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  118. package/hooks/blocking/tests/conftest.py +0 -10
@@ -0,0 +1,332 @@
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
+
49
+ FilePredicate = Callable[[Path], bool]
50
+ FileViolationLineCollector = Callable[[Path], "list[str]"]
51
+ _RawFileValidator = Callable[[Path], "Sequence[object]"]
52
+
53
+
54
+ def _violation_lines_from(raw_validate_file: _RawFileValidator) -> FileViolationLineCollector:
55
+ """Wrap a ``validate_file`` returning check-specific Violation objects.
56
+
57
+ ::
58
+
59
+ wrap(abbreviation_checks.validate_file) -> Callable[[Path], list[str]]
60
+
61
+ Each check module defines its own ``Violation``-shaped dataclass with a
62
+ matching ``__str__``, so formatting through ``str()`` here needs no shared
63
+ base type across the four differently-typed modules that define one.
64
+
65
+ Args:
66
+ raw_validate_file: A module's own ``validate_file(path) -> [Violation, ...]``.
67
+
68
+ Returns:
69
+ A collector returning each violation's ``str()`` line, not the
70
+ object itself.
71
+ """
72
+
73
+ def _collect(file_path: Path) -> list[str]:
74
+ return [str(each_violation) for each_violation in raw_validate_file(file_path)]
75
+
76
+ return _collect
77
+
78
+
79
+ @dataclass(frozen=True)
80
+ class FastSaveCheckOutcome:
81
+ """One save-path check's finding.
82
+
83
+ The caller wraps this into its own reporting type. This module never
84
+ imports that type back, so no circular import exists between the two
85
+ modules.
86
+ """
87
+
88
+ display_name: str
89
+ checks: str
90
+ is_clean: bool
91
+ violation_report: str
92
+
93
+
94
+ def _is_python_file(file_path: Path) -> bool:
95
+ return file_path.suffix == PYTHON_SOURCE_SUFFIX
96
+
97
+
98
+ def _is_test_python_file(file_path: Path) -> bool:
99
+ return "test" in file_path.name.lower() and file_path.suffix == PYTHON_SOURCE_SUFFIX
100
+
101
+
102
+ def _is_react_file(file_path: Path) -> bool:
103
+ return file_path.suffix in ALL_REACT_COMPONENT_FILE_SUFFIXES
104
+
105
+
106
+ def _is_code_reference_file(file_path: Path) -> bool:
107
+ return file_path.suffix in ALL_CODE_REFERENCE_FILE_SUFFIXES
108
+
109
+
110
+ def _collect_test_safety_violation_lines(file_path: Path) -> "list[str]":
111
+ """Adapt test_safety_checks's whole-batch ``main`` into one file at a time."""
112
+ try:
113
+ code = file_path.read_text(encoding="utf-8")
114
+ except OSError:
115
+ return []
116
+ filepath = str(file_path)
117
+ all_violations = [
118
+ *check_no_skip_decorators(code, filepath),
119
+ *check_debug_guard_in_dev_scripts(code, filepath),
120
+ ]
121
+ return [str(each_violation) for each_violation in all_violations]
122
+
123
+
124
+ def _collect_react_violation_lines(file_path: Path) -> "list[str]":
125
+ """Adapt react_checks's whole-batch ``check_no_class_components`` to one file.
126
+
127
+ react_checks.Violation carries no ``__str__`` override, so this formats
128
+ each violation the same way react_checks's own ``main`` prints one.
129
+ """
130
+ all_violations = check_no_class_components([str(file_path)])
131
+ return [
132
+ f"{each_violation.file}:{each_violation.line}: {each_violation.message}"
133
+ for each_violation in all_violations
134
+ ]
135
+
136
+
137
+ @dataclass(frozen=True)
138
+ class _FastSaveValidatorSpecification:
139
+ """One save-path validator: display name, check numbers, and how to run it."""
140
+
141
+ display_name: str
142
+ checks: str
143
+ applies_to_file: FilePredicate
144
+ collect_violation_lines: FileViolationLineCollector
145
+ no_matching_files_message: str
146
+
147
+
148
+ def _python_naming_and_syntax_specifications() -> (
149
+ "list[_FastSaveValidatorSpecification]"
150
+ ):
151
+ """Return the save-path checks for Python naming, style, and constants."""
152
+ return [
153
+ _FastSaveValidatorSpecification(
154
+ "Python Style",
155
+ "1,2,3,4",
156
+ _is_python_file,
157
+ _violation_lines_from(validate_python_style_file),
158
+ NO_PYTHON_FILES_MESSAGE,
159
+ ),
160
+ _FastSaveValidatorSpecification(
161
+ "Abbreviations",
162
+ "5",
163
+ _is_python_file,
164
+ _violation_lines_from(validate_abbreviation_file),
165
+ NO_PYTHON_FILES_MESSAGE,
166
+ ),
167
+ _FastSaveValidatorSpecification(
168
+ "Magic Values",
169
+ "7",
170
+ _is_python_file,
171
+ _violation_lines_from(validate_magic_literal_file),
172
+ NO_PYTHON_FILES_MESSAGE,
173
+ ),
174
+ ]
175
+
176
+
177
+ def _python_quality_and_tracking_specifications() -> (
178
+ "list[_FastSaveValidatorSpecification]"
179
+ ):
180
+ """Return the save-path checks for Python code quality and TODO tracking."""
181
+ return [
182
+ _FastSaveValidatorSpecification(
183
+ "TODO Tracking",
184
+ "36",
185
+ _is_python_file,
186
+ _violation_lines_from(validate_todo_file),
187
+ NO_PYTHON_FILES_MESSAGE,
188
+ ),
189
+ _FastSaveValidatorSpecification(
190
+ "Security",
191
+ "27,28,29",
192
+ _is_python_file,
193
+ _violation_lines_from(validate_security_file),
194
+ NO_PYTHON_FILES_MESSAGE,
195
+ ),
196
+ _FastSaveValidatorSpecification(
197
+ "Code Quality",
198
+ "30,31,32",
199
+ _is_python_file,
200
+ _violation_lines_from(validate_code_quality_file),
201
+ NO_PYTHON_FILES_MESSAGE,
202
+ ),
203
+ ]
204
+
205
+
206
+ def _python_safety_and_typing_specifications() -> (
207
+ "list[_FastSaveValidatorSpecification]"
208
+ ):
209
+ """Return the save-path checks for Python anti-patterns and typing."""
210
+ return [
211
+ _FastSaveValidatorSpecification(
212
+ "Python Anti-patterns",
213
+ "33,34,35",
214
+ _is_python_file,
215
+ _violation_lines_from(validate_python_antipattern_file),
216
+ NO_PYTHON_FILES_MESSAGE,
217
+ ),
218
+ _FastSaveValidatorSpecification(
219
+ "Type Safety",
220
+ "39,40",
221
+ _is_python_file,
222
+ _violation_lines_from(validate_type_safety_file),
223
+ NO_PYTHON_FILES_MESSAGE,
224
+ ),
225
+ ]
226
+
227
+
228
+ def _test_scoped_fast_save_specifications() -> "list[_FastSaveValidatorSpecification]":
229
+ """Return the save-path checks scoped to a ``test_*.py``-shaped file."""
230
+ return [
231
+ _FastSaveValidatorSpecification(
232
+ "Test Safety",
233
+ "11,21",
234
+ _is_test_python_file,
235
+ _collect_test_safety_violation_lines,
236
+ NO_TEST_FILES_MESSAGE,
237
+ ),
238
+ _FastSaveValidatorSpecification(
239
+ "Useless Tests",
240
+ "12",
241
+ _is_test_python_file,
242
+ _violation_lines_from(validate_useless_test_file),
243
+ NO_TEST_FILES_MESSAGE,
244
+ ),
245
+ ]
246
+
247
+
248
+ def _cross_language_fast_save_specifications() -> (
249
+ "list[_FastSaveValidatorSpecification]"
250
+ ):
251
+ """Return the save-path checks that read a non-Python or multi-language file."""
252
+ return [
253
+ _FastSaveValidatorSpecification(
254
+ "React",
255
+ "17",
256
+ _is_react_file,
257
+ _collect_react_violation_lines,
258
+ NO_REACT_FILES_MESSAGE,
259
+ ),
260
+ _FastSaveValidatorSpecification(
261
+ "PR References",
262
+ "6",
263
+ _is_code_reference_file,
264
+ _violation_lines_from(validate_pr_reference_file),
265
+ NO_CODE_REFERENCE_FILES_MESSAGE,
266
+ ),
267
+ ]
268
+
269
+
270
+ def _all_fast_save_validator_specifications() -> (
271
+ "list[_FastSaveValidatorSpecification]"
272
+ ):
273
+ """Return the save-path roster: every non-Mypy, non-Ruff file-scoped check."""
274
+ return [
275
+ *_python_naming_and_syntax_specifications(),
276
+ *_python_quality_and_tracking_specifications(),
277
+ *_python_safety_and_typing_specifications(),
278
+ *_test_scoped_fast_save_specifications(),
279
+ *_cross_language_fast_save_specifications(),
280
+ ]
281
+
282
+
283
+ def _fast_save_outcome(
284
+ specification: _FastSaveValidatorSpecification,
285
+ is_clean: bool,
286
+ violation_report: str,
287
+ ) -> FastSaveCheckOutcome:
288
+ """Build one outcome from a specification's display name and check numbers."""
289
+ return FastSaveCheckOutcome(
290
+ display_name=specification.display_name,
291
+ checks=specification.checks,
292
+ is_clean=is_clean,
293
+ violation_report=violation_report,
294
+ )
295
+
296
+
297
+ def _run_specification(
298
+ specification: _FastSaveValidatorSpecification, files: "list[Path]"
299
+ ) -> FastSaveCheckOutcome:
300
+ """Run one validator's violation collector in-process over its matching files."""
301
+ matching_files = [
302
+ each_file for each_file in files if specification.applies_to_file(each_file)
303
+ ]
304
+ if not matching_files:
305
+ return _fast_save_outcome(
306
+ specification, True, specification.no_matching_files_message
307
+ )
308
+ all_violation_lines = [
309
+ each_line
310
+ for each_file in matching_files
311
+ for each_line in specification.collect_violation_lines(each_file)
312
+ ]
313
+ if not all_violation_lines:
314
+ return _fast_save_outcome(specification, True, NO_VIOLATIONS_FOUND_MESSAGE)
315
+ violation_report = VIOLATION_REPORT_LINE_SEPARATOR.join(all_violation_lines)
316
+ return _fast_save_outcome(specification, False, violation_report)
317
+
318
+
319
+ def run_fast_save_validators(files: "list[Path]") -> "list[FastSaveCheckOutcome]":
320
+ """Run every save-path validator in-process, skipping Mypy and Ruff.
321
+
322
+ Args:
323
+ files: The files under validation -- one reconstructed file in gate mode.
324
+
325
+ Returns:
326
+ One outcome per in-process validator, in roster order. The caller adds
327
+ Ruff and never adds Mypy.
328
+ """
329
+ return [
330
+ _run_specification(each_specification, files)
331
+ for each_specification in _all_fast_save_validator_specifications()
332
+ ]
@@ -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
@@ -14,42 +14,27 @@ 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.python_style_helpers import ( # noqa: E402
25
+ FunctionNode,
26
+ blank_line_for_source,
27
+ function_start_line,
28
+ gap_is_blank_only,
29
+ is_docstring_statement,
30
+ is_import_statement,
31
+ iter_function_definitions,
32
+ real_newline_lines,
33
+ top_level_functions,
34
+ )
35
+ from validators.python_style_import_bootstrap import ( # noqa: E402
36
+ is_sys_path_bootstrap_prelude,
37
+ )
53
38
 
54
39
  logger = logging.getLogger(__name__)
55
40
 
@@ -83,37 +68,26 @@ def check_imports_at_top(tree: ast.AST, filename: str) -> list[Violation]:
83
68
  return violations
84
69
 
85
70
 
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
71
  def _check_module_level_import_order(tree: ast.AST, filename: str) -> list[Violation]:
100
- """Flag module-level imports that appear after other statements."""
72
+ """Flag module-level imports after other statements, exempting a sys.path bootstrap run."""
101
73
  if not isinstance(tree, ast.Module):
102
74
  return []
103
75
  violations: list[Violation] = []
104
76
  has_seen_non_import = False
77
+ pending_prelude_statements: list[ast.stmt] = []
105
78
  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):
79
+ if not is_import_statement(each_statement):
80
+ if not is_docstring_statement(each_statement):
81
+ pending_prelude_statements.append(each_statement)
82
+ continue
83
+ if pending_prelude_statements and not is_sys_path_bootstrap_prelude(
84
+ pending_prelude_statements
85
+ ):
116
86
  has_seen_non_import = True
87
+ pending_prelude_statements = []
88
+ if has_seen_non_import:
89
+ message = "Import statement must be at top of file"
90
+ violations.append(Violation(filename, each_statement.lineno, message))
117
91
  return violations
118
92
 
119
93
 
@@ -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
+ )