claude-dev-env 2.30.0 → 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 (128) hide show
  1. package/.agents/agents/pr-description-writer.md +1 -1
  2. package/.agents/skills/e-simplify/SKILL.md +20 -8
  3. package/AGENTS.md +6 -0
  4. package/_shared/pr-loop/scripts/code_rules_gate_parts/tests/test_enforcer_loading.py +37 -0
  5. package/bin/install.prune.test.mjs +4 -2
  6. package/docs/CODE_RULES.md +11 -0
  7. package/hooks/advisory/migration_safety_advisor.py +25 -1
  8. package/hooks/advisory/refactor_guard.py +85 -18
  9. package/hooks/advisory/test_migration_safety_advisor.py +89 -5
  10. package/hooks/advisory/test_refactor_guard.py +87 -0
  11. package/hooks/advisory/test_refactor_guard_advisory.py +72 -1
  12. package/hooks/blocking/code_rules_constants_config.py +7 -6
  13. package/hooks/blocking/code_rules_enforcer.py +59 -36
  14. package/hooks/blocking/codex_apply_patch.py +136 -21
  15. package/hooks/blocking/config/codex_apply_patch_constants.py +13 -0
  16. package/hooks/blocking/conftest.py +70 -0
  17. package/hooks/blocking/duplicate_rmtree_helper_blocker.py +26 -16
  18. package/hooks/blocking/hook_prose_detector_consistency.py +30 -20
  19. package/hooks/blocking/pii_payload_scan.py +61 -0
  20. package/hooks/blocking/pii_prevention_blocker.py +5 -0
  21. package/hooks/blocking/pr_description_writer_gate.py +263 -0
  22. package/hooks/blocking/pre_tool_use_dispatcher.py +1 -1
  23. package/hooks/blocking/sensitive_file_protector.py +18 -3
  24. package/hooks/blocking/shell_substitution_blocker.py +62 -8
  25. package/hooks/blocking/stale_comment_reference_blocker.py +94 -24
  26. package/hooks/blocking/state_description_blocker.py +39 -26
  27. package/hooks/blocking/subprocess_budget_completeness.py +17 -43
  28. package/hooks/blocking/tdd_enforcer.py +57 -14
  29. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +11 -1
  30. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +180 -0
  31. package/hooks/blocking/tdd_enforcer_parts/decisions.py +6 -2
  32. package/hooks/blocking/tdd_enforcer_parts/freshness.py +18 -3
  33. package/hooks/blocking/tdd_enforcer_parts/git_tracking.py +66 -1
  34. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +143 -0
  35. package/hooks/blocking/tdd_enforcer_parts/tests/test_decisions.py +12 -0
  36. package/hooks/blocking/tdd_enforcer_parts/tests/test_git_tracking.py +63 -0
  37. package/hooks/blocking/test_bash_pre_tool_use_dispatcher.py +15 -0
  38. package/hooks/blocking/test_code_rules_enforcer_cap_meta.py +81 -2
  39. package/hooks/blocking/test_code_rules_enforcer_codex_apply_patch.py +35 -0
  40. package/hooks/blocking/test_code_rules_enforcer_config_path.py +29 -16
  41. package/hooks/blocking/test_code_rules_enforcer_duplicate_body_hook_routing.py +2 -0
  42. package/hooks/blocking/test_code_rules_enforcer_multi_edit.py +118 -0
  43. package/hooks/blocking/test_code_rules_enforcer_narrow_edit.py +2 -0
  44. package/hooks/blocking/test_code_rules_enforcer_precheck_forecast.py +2 -0
  45. package/hooks/blocking/test_code_rules_enforcer_split_constants_config.py +29 -28
  46. package/hooks/blocking/test_code_rules_enforcer_split_entry_2.py +2 -0
  47. package/hooks/blocking/test_code_rules_enforcer_stage.py +2 -0
  48. package/hooks/blocking/test_code_rules_enforcer_zero_payload_alias_hook_routing.py +2 -0
  49. package/hooks/blocking/test_code_rules_validation_phase.py +51 -0
  50. package/hooks/blocking/test_codex_apply_patch.py +43 -0
  51. package/hooks/blocking/test_duplicate_rmtree_helper_blocker.py +68 -0
  52. package/hooks/blocking/test_hook_prose_detector_consistency.py +41 -0
  53. package/hooks/blocking/test_pii_payload_scan.py +65 -2
  54. package/hooks/blocking/test_pii_prevention_blocker.py +80 -10
  55. package/hooks/blocking/test_pr_description_writer_gate.py +216 -0
  56. package/hooks/blocking/test_pre_tool_use_dispatcher.py +215 -25
  57. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +9 -4
  58. package/hooks/blocking/test_sensitive_file_protector.py +42 -2
  59. package/hooks/blocking/test_shell_substitution_blocker.py +69 -0
  60. package/hooks/blocking/test_stale_comment_reference_blocker.py +71 -0
  61. package/hooks/blocking/test_state_description_blocker.py +68 -0
  62. package/hooks/blocking/test_subprocess_budget_completeness.py +67 -0
  63. package/hooks/blocking/test_tdd_enforcer.py +182 -2
  64. package/hooks/blocking/test_windows_rmtree_blocker.py +63 -0
  65. package/hooks/blocking/test_write_existing_file_blocker.py +122 -0
  66. package/hooks/blocking/windows_rmtree_blocker.py +21 -7
  67. package/hooks/blocking/write_existing_file_blocker.py +75 -29
  68. package/hooks/conftest.py +47 -0
  69. package/hooks/hooks.json +8 -13
  70. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +3 -0
  71. package/hooks/hooks_constants/code_rules_enforcer_constants.py +4 -1
  72. package/hooks/hooks_constants/duplicate_rmtree_helper_blocker_constants.py +3 -0
  73. package/hooks/hooks_constants/hook_prose_detector_consistency_constants.py +7 -2
  74. package/hooks/hooks_constants/migration_safety_advisor_constants.py +7 -0
  75. package/hooks/hooks_constants/multi_edit_reconstruction.py +44 -3
  76. package/hooks/hooks_constants/pii_prevention_constants.py +9 -7
  77. package/hooks/hooks_constants/pr_description_writer_gate_constants.py +39 -0
  78. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +31 -14
  79. package/hooks/hooks_constants/session_edit_stage_gate_constants.py +15 -1
  80. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +4 -1
  81. package/hooks/hooks_constants/state_description_blocker_constants.py +1 -0
  82. package/hooks/hooks_constants/subprocess_budget_completeness_content.py +71 -0
  83. package/hooks/hooks_constants/task_tool_prompt_constants.py +13 -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 -9
  86. package/hooks/hooks_constants/test_multi_edit_reconstruction.py +102 -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 +70 -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 +121 -0
  92. package/hooks/observability/session_file_edit_tracker.py +25 -4
  93. package/hooks/observability/test_pr_description_writer_spawn_tracker.py +146 -0
  94. package/hooks/observability/test_session_file_edit_tracker.py +32 -0
  95. package/hooks/session/task_tool_prompt.py +37 -0
  96. package/hooks/session/test_task_tool_prompt.py +65 -0
  97. package/hooks/validation/hook_format_validator.py +23 -9
  98. package/hooks/validation/test_hook_format_validator.py +58 -0
  99. package/hooks/validators/README.md +1 -1
  100. package/hooks/validators/abbreviation_checks.py +3 -5
  101. package/hooks/validators/config/abbreviation_checks_constants.py +3 -0
  102. package/hooks/validators/config/fast_save_validator_constants.py +32 -0
  103. package/hooks/validators/config/hook_timing_harness_constants.py +3 -1
  104. package/hooks/validators/config/test_abbreviation_checks_constants.py +11 -0
  105. package/hooks/validators/fast_save_validators.py +332 -0
  106. package/hooks/validators/hook_timing_harness.py +8 -4
  107. package/hooks/validators/python_style_checks.py +34 -60
  108. package/hooks/validators/python_style_helpers.py +16 -2
  109. package/hooks/validators/python_style_import_bootstrap.py +83 -0
  110. package/hooks/validators/run_all_validators.py +42 -41
  111. package/hooks/validators/test_abbreviation_checks.py +20 -0
  112. package/hooks/validators/test_fast_save_validators.py +172 -0
  113. package/hooks/validators/test_python_style_checks.py +9 -0
  114. package/hooks/validators/test_python_style_checks_import_order.py +81 -0
  115. package/hooks/validators/test_python_style_helpers.py +35 -0
  116. package/hooks/validators/test_python_style_import_bootstrap.py +69 -0
  117. package/hooks/validators/test_run_all_validators.py +46 -0
  118. package/hooks/validators/test_run_all_validators_pretooluse.py +2 -0
  119. package/package.json +1 -1
  120. package/rules/docstring-prose-matches-implementation.md +1 -1
  121. package/rules/git-workflow.md +2 -0
  122. package/rules/testing.md +4 -0
  123. package/scripts/codex_compat_materializer.py +5 -0
  124. package/scripts/tests/test_codex_compat_materializer.py +37 -11
  125. package/hooks/blocking/tests/conftest.py +0 -10
  126. package/hooks/hooks_constants/task_list_loop_starter_constants.py +0 -19
  127. package/hooks/session/task_list_loop_starter.py +0 -45
  128. package/hooks/session/test_task_list_loop_starter.py +0 -63
@@ -41,6 +41,64 @@ def test_simple_pattern_blocks_with_deny_payload(tmp_path: Path) -> None:
41
41
  assert hook_specific_output["permissionDecision"] == "deny"
42
42
 
43
43
 
44
+ def test_write_payload_reads_the_content_field(tmp_path: Path) -> None:
45
+ """_resolve_content's non-MultiEdit branch reads a Write payload's "content" key."""
46
+ settings_path = tmp_path / ".claude" / "settings.json"
47
+ payload = {
48
+ "tool_name": "Write",
49
+ "tool_input": {
50
+ "file_path": str(settings_path),
51
+ "content": "python3 ~/.claude/hooks/blocking/my-hook.py",
52
+ },
53
+ }
54
+
55
+ hook_run = _run_hook(payload)
56
+
57
+ assert hook_run.returncode == 0
58
+ deny_payload = json.loads(hook_run.stdout)
59
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
60
+
61
+
62
+ def test_second_multi_edit_new_string_is_caught(tmp_path: Path) -> None:
63
+ """A simple-pattern hook command in the second edit of a MultiEdit is denied."""
64
+ settings_path = tmp_path / ".claude" / "settings.json"
65
+ payload = {
66
+ "tool_name": "MultiEdit",
67
+ "tool_input": {
68
+ "file_path": str(settings_path),
69
+ "edits": [
70
+ {"old_string": "a", "new_string": "b"},
71
+ {"old_string": "c", "new_string": "python3 ~/.claude/hooks/blocking/my-hook.py"},
72
+ ],
73
+ },
74
+ }
75
+
76
+ hook_run = _run_hook(payload)
77
+
78
+ assert hook_run.returncode == 0
79
+ deny_payload = json.loads(hook_run.stdout)
80
+ assert deny_payload["hookSpecificOutput"]["permissionDecision"] == "deny"
81
+
82
+
83
+ def test_clean_multi_edit_passes(tmp_path: Path) -> None:
84
+ settings_path = tmp_path / ".claude" / "settings.json"
85
+ payload = {
86
+ "tool_name": "MultiEdit",
87
+ "tool_input": {
88
+ "file_path": str(settings_path),
89
+ "edits": [
90
+ {"old_string": "a", "new_string": "b"},
91
+ {"old_string": "c", "new_string": "node -e \"require('run-hook-wrapper')\""},
92
+ ],
93
+ },
94
+ }
95
+
96
+ hook_run = _run_hook(payload)
97
+
98
+ assert hook_run.returncode == 0
99
+ assert hook_run.stdout.strip() == ""
100
+
101
+
44
102
  def test_block_logs_pre_tool_use_event(tmp_path: Path) -> None:
45
103
  fake_home = tmp_path / "home"
46
104
  fake_home.mkdir()
@@ -2,7 +2,7 @@
2
2
 
3
3
  AST-based Python style checks for code quality enforcement.
4
4
 
5
- The checks live in `python_style_checks.py`; the shared source-line splitting and function-discovery helpers they build on live in `python_style_helpers.py`. `hook_timing_harness.py` measures real wall-clock time for the Write/Edit hooks `hooks.json` registers, reporting p50 and p95 over a configurable run count.
5
+ The checks live in `python_style_checks.py`; the shared source-line splitting, function-discovery, and statement-classification helpers they build on live in `python_style_helpers.py`. `python_style_import_bootstrap.py` recognizes the repo's sys.path bootstrap-guard idiom so the imports-at-top check does not penalize a file for following it. `hook_timing_harness.py` measures real wall-clock time for the Write/Edit hooks `hooks.json` registers, reporting p50 and p95 over a configurable run count. `fast_save_validators.py` runs the twelve non-Mypy, non-Ruff checks in-process for the Write/Edit save-path gate, with no per-check subprocess.
6
6
 
7
7
  ## Checks Implemented
8
8
 
@@ -11,20 +11,18 @@ Detects:
11
11
  import ast
12
12
  import sys
13
13
  from pathlib import Path
14
- from typing import List, Set
14
+ from typing import List
15
15
 
16
+ from .config.abbreviation_checks_constants import ALL_ALLOWED_SINGLE_LETTERS
16
17
  from .validator_base import Violation
17
18
 
18
19
 
19
- ALLOWED_SINGLE_LETTERS: Set[str] = frozenset({"i", "j", "k", "_"})
20
-
21
-
22
20
  def check_single_letter_variables(tree: ast.AST, filename: str) -> List[Violation]:
23
21
  violations: List[Violation] = []
24
22
 
25
23
  for node in ast.walk(tree):
26
24
  if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Store):
27
- if len(node.id) == 1 and node.id not in ALLOWED_SINGLE_LETTERS:
25
+ if len(node.id) == 1 and node.id not in ALL_ALLOWED_SINGLE_LETTERS:
28
26
  violations.append(
29
27
  Violation(
30
28
  filename,
@@ -0,0 +1,3 @@
1
+ """Shared constants for abbreviation_checks.py's single-letter-name check."""
2
+
3
+ ALL_ALLOWED_SINGLE_LETTERS: frozenset[str] = frozenset({"i", "j", "k", "_"})
@@ -0,0 +1,32 @@
1
+ """File suffixes and report text the save-path validator roster reuses.
2
+
3
+ Config files are exempt from the string-literal magic-value checks, so the
4
+ per-language file suffixes and shared report strings live here rather than
5
+ inline in ``fast_save_validators.py``.
6
+ """
7
+
8
+ from hooks_constants.mypy_integration_constants import PYTHON_SOURCE_SUFFIX
9
+
10
+ TYPESCRIPT_SOURCE_SUFFIX = ".ts"
11
+ REACT_TYPESCRIPT_SOURCE_SUFFIX = ".tsx"
12
+ JAVASCRIPT_SOURCE_SUFFIX = ".js"
13
+ REACT_JAVASCRIPT_SOURCE_SUFFIX = ".jsx"
14
+
15
+ ALL_REACT_COMPONENT_FILE_SUFFIXES: tuple[str, ...] = (
16
+ REACT_TYPESCRIPT_SOURCE_SUFFIX,
17
+ REACT_JAVASCRIPT_SOURCE_SUFFIX,
18
+ )
19
+ ALL_CODE_REFERENCE_FILE_SUFFIXES: tuple[str, ...] = (
20
+ PYTHON_SOURCE_SUFFIX,
21
+ TYPESCRIPT_SOURCE_SUFFIX,
22
+ REACT_TYPESCRIPT_SOURCE_SUFFIX,
23
+ JAVASCRIPT_SOURCE_SUFFIX,
24
+ REACT_JAVASCRIPT_SOURCE_SUFFIX,
25
+ )
26
+
27
+ NO_PYTHON_FILES_MESSAGE = "No Python files to check"
28
+ NO_TEST_FILES_MESSAGE = "No test files to check"
29
+ NO_REACT_FILES_MESSAGE = "No React files to check"
30
+ NO_CODE_REFERENCE_FILES_MESSAGE = "No code files to check"
31
+ NO_VIOLATIONS_FOUND_MESSAGE = "All checks passed"
32
+ VIOLATION_REPORT_LINE_SEPARATOR = "\n"
@@ -9,7 +9,9 @@ P50_FRACTION = 0.50
9
9
  P95_FRACTION = 0.95
10
10
  DEFAULT_TARGET_RELATIVE_PATH = "hooks/blocking/code_rules_shared.py"
11
11
  SUBPROCESS_TIMEOUT_SECONDS = 60.0
12
- ALL_WRITE_EDIT_MATCHER_PATTERNS = frozenset({"Write|Edit", "Write|Edit|MultiEdit"})
12
+ ALL_WRITE_EDIT_MATCHER_PATTERNS = frozenset(
13
+ {"Write|Edit", "Write|Edit|MultiEdit", "Write|Edit|MultiEdit|apply_patch"}
14
+ )
13
15
  ALL_TIMED_EVENT_NAMES = ("PreToolUse", "PostToolUse")
14
16
  RUN_ALL_VALIDATORS_LABEL = "run_all_validators"
15
17
  CLAUDE_PLUGIN_ROOT_PLACEHOLDER = "${CLAUDE_PLUGIN_ROOT}"
@@ -0,0 +1,11 @@
1
+ """Tests for the shared abbreviation_checks allow-list constant."""
2
+
3
+ from .abbreviation_checks_constants import ALL_ALLOWED_SINGLE_LETTERS
4
+
5
+
6
+ def test_allowed_single_letters_covers_loop_counters_and_underscore() -> None:
7
+ assert ALL_ALLOWED_SINGLE_LETTERS == frozenset({"i", "j", "k", "_"})
8
+
9
+
10
+ def test_allowed_single_letters_is_immutable() -> None:
11
+ assert isinstance(ALL_ALLOWED_SINGLE_LETTERS, frozenset)
@@ -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)