claude-dev-env 2.31.1 → 2.32.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 (46) hide show
  1. package/bin/install.test.mjs +11 -3
  2. package/docs/CODE_RULES.md +1 -1
  3. package/hooks/blocking/bash_post_call_dispatcher.py +70 -0
  4. package/hooks/blocking/bash_pre_tool_use_dispatcher.py +8 -21
  5. package/hooks/blocking/code_rules_duplicate_body.py +13 -7
  6. package/hooks/blocking/code_rules_enforcer_test_support.py +39 -0
  7. package/hooks/blocking/shell_substitution_blocker.py +175 -8
  8. package/hooks/blocking/stop_dispatcher.py +6 -9
  9. package/hooks/blocking/tdd_enforcer.py +3 -1
  10. package/hooks/blocking/tdd_enforcer_parts/config/tdd_enforcer_constants.py +7 -0
  11. package/hooks/blocking/tdd_enforcer_parts/content_hash_store.py +141 -23
  12. package/hooks/blocking/tdd_enforcer_parts/tests/test_content_hash_store.py +114 -1
  13. package/hooks/blocking/test_bash_post_call_dispatcher.py +112 -0
  14. package/hooks/blocking/test_code_rules_enforcer_same_file_inline_duplicate.py +8 -1
  15. package/hooks/blocking/test_pre_tool_use_dispatcher_native.py +7 -2
  16. package/hooks/blocking/test_shell_substitution_blocker.py +105 -0
  17. package/hooks/blocking/test_state_description_blocker.py +56 -71
  18. package/hooks/blocking/test_tdd_enforcer.py +67 -1
  19. package/hooks/hooks.json +2 -2
  20. package/hooks/hooks_constants/bash_post_call_dispatcher_constants.py +22 -0
  21. package/hooks/hooks_constants/hosted_hook_runner.py +53 -0
  22. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +9 -2
  23. package/hooks/hooks_constants/shell_substitution_blocker_constants.py +10 -0
  24. package/hooks/hooks_constants/state_file_lock.py +76 -0
  25. package/hooks/hooks_constants/test_bash_post_call_dispatcher_constants.py +27 -0
  26. package/hooks/hooks_constants/test_failure_recorder_constants.py +35 -0
  27. package/hooks/hooks_constants/test_hosted_hook_runner.py +55 -1
  28. package/hooks/observability/session_file_edit_tracker.py +2 -55
  29. package/hooks/observability/test_failure_recorder.py +219 -0
  30. package/hooks/observability/test_test_failure_recorder.py +173 -0
  31. package/hooks/validators/config/directory_exemption_constants.py +0 -9
  32. package/hooks/validators/config/system_temporary_roots_constants.py +15 -0
  33. package/hooks/validators/mypy_integration.py +10 -36
  34. package/hooks/validators/project_roots.py +58 -0
  35. package/hooks/validators/run_all_validators.py +87 -33
  36. package/hooks/validators/system_temporary_roots.py +1 -1
  37. package/hooks/validators/test_run_all_validators.py +22 -0
  38. package/hooks/validators/test_run_all_validators_mutation_tool_parity.py +249 -0
  39. package/hooks/validators/test_run_all_validators_reporting.py +66 -0
  40. package/package.json +1 -1
  41. package/rules/shell-invocation.md +1 -1
  42. package/rules/testing.md +4 -0
  43. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +9 -5
  44. package/scripts/dev_env_scripts_constants/shared_tree_constants.py +17 -0
  45. package/scripts/shared_tree_paths.py +59 -25
  46. package/scripts/test_shared_tree_paths.py +30 -0
@@ -925,9 +925,17 @@ function isPreToolUseDispatcherCommand(command) {
925
925
  return /(?:^|[/\\])pre_tool_use_dispatcher\.py(?![A-Za-z0-9_])/.test(command);
926
926
  }
927
927
 
928
+ function matcherCoversWriteAndEdit(matcher) {
929
+ const allToolNames = String(matcher || '').split('|');
930
+ return allToolNames.includes('Write') && allToolNames.includes('Edit');
931
+ }
932
+
928
933
  function countManagedRunAllValidatorsHooks(settings) {
929
- const writeEditGroups = (settings.hooks.PreToolUse || []).filter(
930
- group => group.matcher === 'Write|Edit'
934
+ // The runner is registered inline on the write path. Which mutation tools
935
+ // that path lists is the gate's business, so this counts any matcher
936
+ // carrying Write and Edit rather than one exact tool list.
937
+ const writeEditGroups = (settings.hooks.PreToolUse || []).filter(group =>
938
+ matcherCoversWriteAndEdit(group.matcher)
931
939
  );
932
940
  let runAllValidatorsCount = 0;
933
941
  for (const group of writeEditGroups) {
@@ -1382,7 +1390,7 @@ test('shipped hooks.json matches the dispatcher design: dispatchers registered,
1382
1390
  assert.equal(
1383
1391
  countManagedRunAllValidatorsHooks(shippedHooksConfig),
1384
1392
  1,
1385
- 'shipped hooks.json must retain the inline run_all_validators runner in Write|Edit',
1393
+ 'shipped hooks.json must retain the inline run_all_validators runner on the write path',
1386
1394
  );
1387
1395
 
1388
1396
  const allPostToolUseGroups = shippedHooksConfig.hooks.PostToolUse || [];
@@ -130,4 +130,4 @@ Three surfaces report on the roster, and each reports a specific thing:
130
130
 
131
131
  - `hooks/validators/hook_timing_harness.py` builds a `Write` payload against a target that already holds content. `_contents_for_validation` returns `None` for that payload, so the harness times interpreter start and hook dispatch. Time an `Edit` payload against a real file to measure the checks.
132
132
  - `~/.claude/logs/hook-blocks.log` records the denials raised by fixtures in `test_code_rules_enforcer_*.py` and by the timing harness's default target.
133
- - `hooks/validators/run_all_validators.py` suppresses exemption-directory substring matching for a target under a system temporary root, so pytest's `test_<name>0` parents stay scaffolding. A pytest `--basetemp` outside those roots restores the matching, and the staged copy takes a test-file exemption. Run this suite under the default temporary root.
133
+ - `hooks/validators/run_all_validators.py` stages the target under a temporary root and rebuilds the shortest path tail that carries every exemption signal. The walk starts at the target's own project root and skips a directory pytest generated for its own scratch tree, so the staged path reads the same wherever `--basetemp` places that tree.
@@ -0,0 +1,70 @@
1
+ #!/usr/bin/env python3
2
+ """PostToolUse dispatcher that hosts the Bash hook chain running after a call.
3
+
4
+ Named ``bash_post_call_dispatcher`` rather than the more obvious
5
+ ``bash_post_tool_use_dispatcher``: ``bin/install.test.mjs`` counts every
6
+ PostToolUse command whose text contains the literal substring
7
+ ``post_tool_use_dispatcher.py`` and asserts exactly one match, against the
8
+ unrelated Write/Edit dispatcher in ``validation/post_tool_use_dispatcher.py``.
9
+ That check carries no basename anchor (its PreToolUse sibling does), so a
10
+ correctly-named Bash-side sibling here would read as a second match of an
11
+ unrelated dispatcher. This name carries the same meaning without the
12
+ collision, and needs no change to that shared, out-of-scope test file.
13
+
14
+ Reads the tool payload from stdin once, selects the hosted hooks applicable to
15
+ the payload's tool name, and runs each one in-process via the shared
16
+ hosted-hook runner. A hosted PostToolUse hook here carries no permission
17
+ decision -- it is a side-effecting observer, not a gate -- so this dispatcher
18
+ runs the whole roster unconditionally and emits nothing itself, matching every
19
+ hosted hook's own empty-stdout contract.
20
+
21
+ A single hosted hook crash fails open: ``run_hook_capturing_output`` isolates
22
+ it, so it contributes nothing and does not stop the remaining hosted hooks.
23
+
24
+ One interpreter start here serves the whole roster: every hosted hook's
25
+ imports load once into this process rather than once per hook.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+
31
+ import _path_setup # noqa: F401
32
+
33
+ from hooks_constants.bash_post_call_dispatcher_constants import (
34
+ ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES,
35
+ )
36
+ from hooks_constants.bash_pre_tool_use_dispatcher_constants import BashHostedHookEntry
37
+ from hooks_constants.hosted_hook_runner import (
38
+ run_hook_capturing_output,
39
+ resolved_hook_script_path,
40
+ run_dispatcher_main,
41
+ )
42
+
43
+
44
+ def select_applicable_entries(tool_name: str) -> list[BashHostedHookEntry]:
45
+ """Return the ordered hosted-hook entries that apply to tool_name."""
46
+ return [
47
+ each_entry
48
+ for each_entry in ALL_BASH_POST_TOOL_USE_HOSTED_HOOK_ENTRIES
49
+ if tool_name in each_entry.applicable_tool_names
50
+ ]
51
+
52
+
53
+ def dispatch(payload_text: str, tool_name: str) -> None:
54
+ """Run every hosted hook applicable to tool_name, in registration order.
55
+
56
+ Each hosted hook's crash is isolated by ``run_hook_capturing_output``, so
57
+ one hook failing never stops a later one in the same roster.
58
+ """
59
+ for each_entry in select_applicable_entries(tool_name):
60
+ script_path = resolved_hook_script_path(each_entry.script_relative_path)
61
+ run_hook_capturing_output(script_path, payload_text)
62
+
63
+
64
+ def main() -> None:
65
+ """Read stdin once and dispatch the Bash PostToolUse hosted-hook roster."""
66
+ run_dispatcher_main(dispatch)
67
+
68
+
69
+ if __name__ == "__main__":
70
+ main()
@@ -22,7 +22,6 @@ from __future__ import annotations
22
22
  import json
23
23
  import sys
24
24
  from dataclasses import dataclass, field
25
- from pathlib import Path
26
25
 
27
26
  import _path_setup # noqa: F401
28
27
 
@@ -36,8 +35,12 @@ from hooks_constants.bash_pre_tool_use_dispatcher_constants import (
36
35
  REASON_JOIN_SEPARATOR,
37
36
  BashHostedHookEntry,
38
37
  )
39
- from hooks_constants.hosted_hook_runner import HostedHookRun, run_hook_capturing_output
40
- from hooks_constants.pre_tool_use_stdin import read_hook_input_dictionary_from_stdin
38
+ from hooks_constants.hosted_hook_runner import (
39
+ run_dispatcher_main,
40
+ run_hook_capturing_output,
41
+ HostedHookRun,
42
+ resolved_hook_script_path,
43
+ )
41
44
 
42
45
  _PERMISSION_DECISION_KEY = "permissionDecision"
43
46
  _PERMISSION_REASON_KEY = "permissionDecisionReason"
@@ -203,12 +206,6 @@ def aggregate_bash_hook_results(
203
206
  )
204
207
 
205
208
 
206
- def _resolve_hook_script_path(relative_path: str) -> str:
207
- """Resolve a hooks/-relative path to an absolute script path."""
208
- hooks_root = Path(__file__).resolve().parent.parent
209
- return str(hooks_root / relative_path)
210
-
211
-
212
209
  def _emit_decision(decision: BashDispatcherDecision) -> None:
213
210
  """Write one PreToolUse permission payload to stdout when an outcome exists."""
214
211
  if not decision.decision:
@@ -251,7 +248,7 @@ def dispatch(payload_text: str, tool_name: str) -> None:
251
248
  applicable_entries = select_applicable_entries(tool_name)
252
249
  all_runs: list[HostedHookRun] = []
253
250
  for each_entry in applicable_entries:
254
- script_path = _resolve_hook_script_path(each_entry.script_relative_path)
251
+ script_path = resolved_hook_script_path(each_entry.script_relative_path)
255
252
  hook_run = run_hook_capturing_output(script_path, payload_text)
256
253
  all_runs.append(hook_run)
257
254
  if _run_is_deny(hook_run):
@@ -264,17 +261,7 @@ def dispatch(payload_text: str, tool_name: str) -> None:
264
261
 
265
262
  def main() -> None:
266
263
  """Read stdin once and dispatch the Bash/PowerShell hosted-hook chain."""
267
- payload_dictionary = read_hook_input_dictionary_from_stdin()
268
- if payload_dictionary is None:
269
- sys.exit(0)
270
-
271
- payload_text = json.dumps(payload_dictionary)
272
- tool_name = payload_dictionary.get("tool_name", "")
273
- if not isinstance(tool_name, str) or not tool_name:
274
- sys.exit(0)
275
-
276
- dispatch(payload_text, tool_name)
277
- sys.exit(0)
264
+ run_dispatcher_main(dispatch)
278
265
 
279
266
 
280
267
  if __name__ == "__main__":
@@ -492,6 +492,7 @@ def _total_reachable_statement_count(
492
492
 
493
493
  def _helper_match_window_dumps(
494
494
  function_node: ast.FunctionDef | ast.AsyncFunctionDef,
495
+ dump_by_statement: dict[ast.stmt, str],
495
496
  ) -> list[str] | None:
496
497
  """Return the helper's substantive block as per-statement dumps, or None.
497
498
 
@@ -541,7 +542,10 @@ def _helper_match_window_dumps(
541
542
  )
542
543
  if not has_substantial_compound:
543
544
  return None
544
- return [_normalized_statement_dump(each) for each in match_window]
545
+ return [
546
+ _memoized_statement_dump(each_statement, dump_by_statement)
547
+ for each_statement in match_window
548
+ ]
545
549
 
546
550
 
547
551
  def check_same_file_inline_duplicate_body(
@@ -763,14 +767,14 @@ def _inline_duplicate_violation(
763
767
 
764
768
 
765
769
  def _violations_for_qualifying_helpers(
766
- window_by_function: dict[_ModuleScopeFunction, list[str] | None],
770
+ window_by_function: dict[_ModuleScopeFunction, list[str]],
767
771
  profile_by_function: dict[_ModuleScopeFunction, _FunctionScanProfile],
768
772
  maximum_issue_count: int,
769
773
  ) -> list[tuple[frozenset[int], str]]:
770
774
  """Walk the helpers in source order and report the first inliner of each.
771
775
 
772
776
  Args:
773
- window_by_function: Each function's helper match window, or None.
777
+ window_by_function: Each qualifying function's helper match window.
774
778
  profile_by_function: Each function's readings, keyed in source order.
775
779
  maximum_issue_count: The report ceiling the walk stops at.
776
780
 
@@ -779,8 +783,6 @@ def _violations_for_qualifying_helpers(
779
783
  """
780
784
  all_violations_in_walk_order: list[tuple[frozenset[int], str]] = []
781
785
  for each_helper, each_window_dumps in window_by_function.items():
782
- if each_window_dumps is None:
783
- continue
784
786
  each_enclosing = _first_enclosing_inliner(
785
787
  each_helper, each_window_dumps, profile_by_function
786
788
  )
@@ -811,10 +813,14 @@ def _inline_duplicate_violations(
811
813
  """
812
814
  dump_by_statement: dict[ast.stmt, str] = {}
813
815
  window_by_function = {
814
- each_function: _helper_match_window_dumps(each_function)
816
+ each_function: each_window
815
817
  for each_function in all_top_level_functions
818
+ if (
819
+ each_window := _helper_match_window_dumps(each_function, dump_by_statement)
820
+ )
821
+ is not None
816
822
  }
817
- if all(each_window is None for each_window in window_by_function.values()):
823
+ if not window_by_function:
818
824
  return []
819
825
  profile_by_function = {
820
826
  each_function: _function_scan_profile(each_function, dump_by_statement)
@@ -14,6 +14,45 @@ from pathlib import Path
14
14
  _ENFORCER_SCRIPT_PATH = Path(__file__).with_name("code_rules_enforcer.py")
15
15
 
16
16
 
17
+ def repository_root() -> Path:
18
+ """Return the root of the checkout these tests run inside.
19
+
20
+ ::
21
+
22
+ <checkout>/packages/claude-dev-env/hooks/blocking/test_x.py -> <checkout>
23
+
24
+ Found by walking up for a ``.git`` entry rather than by counting
25
+ directories, so moving a test file does not silently anchor it somewhere
26
+ else. Falls back to the four-levels-up ancestor when no marker is found,
27
+ which is where the checkout root sits for this directory.
28
+
29
+ Returns:
30
+ The checkout root directory.
31
+ """
32
+ all_ancestors = Path(__file__).resolve().parents
33
+ for each_ancestor in all_ancestors:
34
+ if (each_ancestor / ".git").exists():
35
+ return each_ancestor
36
+ return all_ancestors[3]
37
+
38
+
39
+ def repository_file_path(relative_path: str) -> str:
40
+ """Return the absolute path of a repository-relative file in this checkout.
41
+
42
+ A hook resolves a bare relative path against the process working
43
+ directory, so a payload carrying one answers to wherever pytest was
44
+ invoked from. An absolute path under the checkout root keeps the
45
+ decision stable however the suite is launched.
46
+
47
+ Args:
48
+ relative_path: The target file's path relative to the checkout root.
49
+
50
+ Returns:
51
+ That file's absolute path inside this checkout.
52
+ """
53
+ return str(repository_root() / relative_path)
54
+
55
+
17
56
  def build_write_payload(file_path: str, content: str) -> str:
18
57
  """Build the JSON payload sent by a Write tool call."""
19
58
  return json.dumps(
@@ -23,6 +23,12 @@ ignored because bash performs no substitution inside them (for example
23
23
  are treated as literal only when the count of preceding backslashes is odd;
24
24
  an even count (including zero) means the backtick is a live substitution.
25
25
 
26
+ A quoted heredoc body is stripped for the same reason. `<<'EOF'`, `<<"EOF"`
27
+ and `<<\\EOF` each tell bash to expand nothing between the opener and the
28
+ terminator, so a backtick there is text the file receives rather than a
29
+ command the shell runs. A bare `<<EOF` expands its body and keeps its scan,
30
+ and text outside any heredoc is scanned either way.
31
+
26
32
  Bash arithmetic expansion ``$((...))`` is explicitly NOT blocked: it does not
27
33
  spawn a subshell and does not defeat the allowlist matcher. Telling it apart
28
34
  from a disguised command substitution takes more than counting two open
@@ -41,11 +47,6 @@ another ``)`` (an ``))`` pair), the expansion is arithmetic and passes. When
41
47
  the closer that brings depth to zero is a lone ``)``, bash has fallen back to
42
48
  running a parenthesized command inside the substitution, so it is blocked.
43
49
  This hook applies that walk, tracking paren depth from the opening ``$((``.
44
-
45
- False positives are still possible when the user is intentionally writing
46
- shell-script content (for example, authoring a .sh file via heredoc) and the
47
- substitution is literal payload rather than something to execute. In that
48
- case, author the file with the Write tool, not a Bash heredoc.
49
50
  """
50
51
 
51
52
  from __future__ import annotations
@@ -53,6 +54,7 @@ from __future__ import annotations
53
54
  import json
54
55
  import sys
55
56
  from pathlib import Path
57
+ from re import Match
56
58
 
57
59
  _hooks_dir = str(Path(__file__).resolve().parent.parent)
58
60
  if _hooks_dir not in sys.path:
@@ -74,6 +76,11 @@ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
74
76
  PERMISSION_DECISION_KEY,
75
77
  PERMISSION_DECISION_REASON_KEY,
76
78
  PROCESS_SUBSTITUTION_PATTERN,
79
+ BACKSLASH_CHARACTER,
80
+ COMMAND_LINE_SEPARATOR,
81
+ DOUBLE_QUOTE_CHARACTER,
82
+ NO_TERMINATOR_INDEX,
83
+ SINGLE_QUOTE_CHARACTER,
77
84
  SINGLE_QUOTED_RUN_PATTERN,
78
85
  STRIPPED_RUN_REPLACEMENT,
79
86
  TOOL_INPUT_KEY,
@@ -81,10 +88,169 @@ from hooks_constants.shell_substitution_blocker_constants import ( # noqa: E402
81
88
  )
82
89
 
83
90
 
91
+ from hooks_constants.piped_pytest_blocker_constants import ( # noqa: E402
92
+ HEREDOC_ESCAPE_GROUP,
93
+ HEREDOC_OPENER_PATTERN,
94
+ HEREDOC_QUOTE_GROUP,
95
+ HEREDOC_TAB_STRIP_GROUP,
96
+ HEREDOC_TAB_STRIP_MARKER,
97
+ HEREDOC_TERMINATOR_GROUP,
98
+ HEREDOC_UNQUOTED_MARKER,
99
+ )
100
+ from hooks_constants.shell_command_pipeline import ( # noqa: E402
101
+ PendingHeredoc,
102
+ closes_the_heredoc,
103
+ )
104
+
105
+
84
106
  def _strip_single_quoted_runs(command: str) -> str:
85
107
  return SINGLE_QUOTED_RUN_PATTERN.sub(STRIPPED_RUN_REPLACEMENT, command)
86
108
 
87
109
 
110
+ def _opener_suppresses_expansion(opener_match: Match[str]) -> bool:
111
+ """Return whether an opener's delimiter is quoted or backslash-escaped.
112
+
113
+ ::
114
+
115
+ <<'EOF' -> True quoted, the body is literal
116
+ <<EOF -> False bare, bash expands the body
117
+
118
+ Args:
119
+ opener_match: One HEREDOC_OPENER_PATTERN match from a command line.
120
+
121
+ Returns:
122
+ True when bash expands nothing down to this opener's terminator.
123
+ """
124
+ is_quoted = opener_match.group(HEREDOC_QUOTE_GROUP) != HEREDOC_UNQUOTED_MARKER
125
+ is_escaped = opener_match.group(HEREDOC_ESCAPE_GROUP) != HEREDOC_UNQUOTED_MARKER
126
+ return is_quoted or is_escaped
127
+
128
+
129
+ def _sits_outside_quotes(command_line: str, character_index: int) -> bool:
130
+ """Return whether a position on a line sits outside every quoted run.
131
+
132
+ ::
133
+
134
+ cat <<'EOF' index of << -> True bash reads an opener
135
+ echo "<<'EOF'" index of << -> False bash reads a string
136
+
137
+ Counting unescaped quotes before the position tells the two apart: an even
138
+ count of each quote character means every run opened before it also closed.
139
+
140
+ Args:
141
+ command_line: One line of the command.
142
+ character_index: Position of the candidate opener on that line.
143
+
144
+ Returns:
145
+ True when bash reads this position as shell syntax.
146
+ """
147
+ single_quote_count = 0
148
+ double_quote_count = 0
149
+ is_escaped = False
150
+ for each_character in command_line[:character_index]:
151
+ if is_escaped:
152
+ is_escaped = False
153
+ continue
154
+ if each_character == BACKSLASH_CHARACTER:
155
+ is_escaped = True
156
+ continue
157
+ if each_character == SINGLE_QUOTE_CHARACTER:
158
+ single_quote_count += 1
159
+ elif each_character == DOUBLE_QUOTE_CHARACTER:
160
+ double_quote_count += 1
161
+ return single_quote_count % 2 == 0 and double_quote_count % 2 == 0
162
+
163
+
164
+ def _first_literal_opener(command_line: str) -> Match[str] | None:
165
+ """Return the line's first heredoc opener whose body bash leaves literal.
166
+
167
+ An opener spelled inside a quoted string is text bash passes on, so it
168
+ opens nothing and the lines below it stay in the scan.
169
+
170
+ Args:
171
+ command_line: One line of the command.
172
+
173
+ Returns:
174
+ The matching opener, or None when the line opens no literal heredoc.
175
+ """
176
+ for each_opener in HEREDOC_OPENER_PATTERN.finditer(command_line):
177
+ if not _sits_outside_quotes(command_line, each_opener.start()):
178
+ continue
179
+ if _opener_suppresses_expansion(each_opener):
180
+ return each_opener
181
+ return None
182
+
183
+
184
+ def _index_past_literal_body(
185
+ all_lines: list[str], body_start_index: int, opener_match: Match[str]
186
+ ) -> int:
187
+ """Return the index of the line that closes a literal heredoc body.
188
+
189
+ The terminator match, including the ``<<-`` tab-strip rule, comes from
190
+ ``closes_the_heredoc`` so this hook and the shared pipeline agree on what
191
+ ends a body.
192
+
193
+ Args:
194
+ all_lines: Every line of the command.
195
+ body_start_index: Index of the body's first line.
196
+ opener_match: The opener whose terminator closes this body.
197
+
198
+ Returns:
199
+ The index of the terminator line, or NO_TERMINATOR_INDEX when the
200
+ command holds none.
201
+ """
202
+ pending_heredoc = PendingHeredoc(
203
+ terminator=opener_match.group(HEREDOC_TERMINATOR_GROUP),
204
+ allows_leading_tabs=(
205
+ opener_match.group(HEREDOC_TAB_STRIP_GROUP) == HEREDOC_TAB_STRIP_MARKER
206
+ ),
207
+ )
208
+ for each_index in range(body_start_index, len(all_lines)):
209
+ if closes_the_heredoc(all_lines[each_index], pending_heredoc):
210
+ return each_index
211
+ return NO_TERMINATOR_INDEX
212
+
213
+
214
+ def _strip_quoted_heredoc_bodies(command: str) -> str:
215
+ """Return the command holding its openers, terminators, and shell text.
216
+
217
+ ::
218
+
219
+ cat <<'EOF' -> cat <<'EOF'
220
+ inert text EOF
221
+ EOF
222
+
223
+ A quoted or escaped delimiter makes the lines below it text the shell hands
224
+ on, so a scan of those lines reports a substitution bash never runs. A bare
225
+ delimiter keeps its body, and text outside any heredoc keeps its scan.
226
+
227
+ An opener with no terminator returns the command whole. Bash consumes the
228
+ rest of the input in that case, and dropping those lines would hide a live
229
+ substitution among them.
230
+
231
+ Args:
232
+ command: The raw Bash command string from the tool input.
233
+
234
+ Returns:
235
+ The command with each literal heredoc body dropped.
236
+ """
237
+ all_lines = command.split(COMMAND_LINE_SEPARATOR)
238
+ all_kept_lines: list[str] = []
239
+ each_index = 0
240
+ while each_index < len(all_lines):
241
+ current_line = all_lines[each_index]
242
+ all_kept_lines.append(current_line)
243
+ each_index += 1
244
+ opener_match = _first_literal_opener(current_line)
245
+ if opener_match is None:
246
+ continue
247
+ terminator_index = _index_past_literal_body(all_lines, each_index, opener_match)
248
+ if terminator_index == NO_TERMINATOR_INDEX:
249
+ return command
250
+ each_index = terminator_index
251
+ return COMMAND_LINE_SEPARATOR.join(all_kept_lines)
252
+
253
+
88
254
  def _closes_as_arithmetic_expansion(command: str, scan_start_index: int) -> bool:
89
255
  """Return True when a ``$((`` expansion closes on an ``))`` pair.
90
256
 
@@ -132,8 +298,9 @@ def has_shell_substitution(command: str) -> bool:
132
298
  echo '$(not-executed)' ok: single-quoted, inert
133
299
  echo $((2 + 2)) ok: arithmetic expansion
134
300
 
135
- Single-quoted runs are stripped first because bash substitutes nothing
136
- inside them. A backtick preceded by an odd backslash count is escaped.
301
+ Literal heredoc bodies are dropped first, then single-quoted runs are
302
+ stripped, because bash substitutes nothing inside either. A backtick
303
+ preceded by an odd backslash count is escaped.
137
304
  A `$((` pair is walked to its terminator (module docstring) to tell
138
305
  real arithmetic from a disguised subshell.
139
306
 
@@ -143,7 +310,7 @@ def has_shell_substitution(command: str) -> bool:
143
310
  Returns:
144
311
  True when a substitution the matcher cannot descend into is present.
145
312
  """
146
- scannable_command = _strip_single_quoted_runs(command)
313
+ scannable_command = _strip_single_quoted_runs(_strip_quoted_heredoc_bodies(command))
147
314
  if _has_unsafe_dollar_paren_expansion(scannable_command):
148
315
  return True
149
316
  if EVEN_BACKSLASH_BACKTICK_PATTERN.search(scannable_command):
@@ -12,11 +12,14 @@ from __future__ import annotations
12
12
 
13
13
  import json
14
14
  import sys
15
- from pathlib import Path
16
15
 
17
16
  import _path_setup # noqa: F401
18
17
 
19
- from hooks_constants.hosted_hook_runner import HostedHookRun, run_hook_capturing_output
18
+ from hooks_constants.hosted_hook_runner import (
19
+ run_hook_capturing_output,
20
+ HostedHookRun,
21
+ resolved_hook_script_path,
22
+ )
20
23
  from hooks_constants.stop_dispatcher_constants import (
21
24
  ALL_STOP_HOSTED_HOOK_PATHS,
22
25
  BLOCK_DECISION,
@@ -24,12 +27,6 @@ from hooks_constants.stop_dispatcher_constants import (
24
27
  )
25
28
 
26
29
 
27
- def _resolve_hook_script_path(relative_path: str) -> str:
28
- """Resolve a hooks/-relative path to an absolute script path."""
29
- hooks_root = Path(__file__).resolve().parent.parent
30
- return str(hooks_root / relative_path)
31
-
32
-
33
30
  def _is_block_decision(stdout_text: str) -> bool:
34
31
  """Return True when stdout carries a Stop decision of block."""
35
32
  stripped_text = stdout_text.strip()
@@ -58,7 +55,7 @@ def dispatch(payload_text: str) -> None:
58
55
  """Run every Stop hosted hook and emit the first block decision if any."""
59
56
  all_runs: list[HostedHookRun] = []
60
57
  for each_relative_path in ALL_STOP_HOSTED_HOOK_PATHS:
61
- script_path = _resolve_hook_script_path(each_relative_path)
58
+ script_path = resolved_hook_script_path(each_relative_path)
62
59
  hook_run = run_hook_capturing_output(script_path, payload_text)
63
60
  all_runs.append(hook_run)
64
61
  block_stdout = select_first_block_stdout(all_runs)
@@ -4,7 +4,9 @@ Blocks a write to a production source file when no matching test was modified
4
4
  within the freshness window, enforcing "TDD IS NON-NEGOTIABLE" from CLAUDE.md.
5
5
  The gate records each candidate test's content hash at every sighting and
6
6
  compares that hash on the next write. A first sighting requires content that
7
- differs from HEAD.
7
+ differs from HEAD. A recorded failing pytest run, written by
8
+ ``hooks/observability/test_failure_recorder.py``, is read before that fallback
9
+ and applies while the candidate's content still matches what failed.
8
10
  Each concern lives in a ``tdd_enforcer_parts`` submodule; this entry wires them
9
11
  into one PreToolUse gate and re-exports their surface for the test suite.
10
12
  """
@@ -3,6 +3,9 @@
3
3
  Centralizes the freshness window, the ancestor-walk limit, the git-tracking
4
4
  and git-diff command tokens, the source-file extension sets, the join
5
5
  separator, and the last-observed-hash store's filename shape and JSON keys.
6
+ The store's JSON keys also cover a recorded failing-test-run entry: the test
7
+ file's content hash at failure time, the failure timestamp, the failing
8
+ command, and its exit status.
6
9
  """
7
10
 
8
11
  from __future__ import annotations
@@ -22,6 +25,10 @@ HASH_STATE_FILE_SUFFIX: str = ".json"
22
25
  REPOSITORY_ROOT_DIGEST_LENGTH: int = 16
23
26
  STORED_CONTENT_HASH_KEY: str = "hash"
24
27
  STORED_CHANGED_AT_KEY: str = "changed_at"
28
+ STORED_FAILURE_HASH_KEY: str = "failed_hash"
29
+ STORED_FAILED_AT_KEY: str = "failed_at"
30
+ STORED_FAILURE_COMMAND_KEY: str = "failed_command"
31
+ STORED_FAILURE_EXIT_STATUS_KEY: str = "failed_exit_status"
25
32
  PYTHON_SOURCE_EXTENSION: str = ".py"
26
33
  ENTRY_HOOK_FILE_NAME: str = "tdd_enforcer.py"
27
34
  NEWLINE_JOIN_SEPARATOR: str = "\n"