claude-dev-env 2.7.1 → 2.8.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 (85) hide show
  1. package/_shared/CLAUDE.md +1 -0
  2. package/_shared/advisor/advisor-protocol.md +19 -9
  3. package/_shared/pr-loop/audit-contract.md +4 -4
  4. package/_shared/pr-loop/precatch-rubric.md +2 -2
  5. package/_shared/process-tree/CLAUDE.md +41 -0
  6. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
  7. package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
  8. package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
  9. package/_shared/process-tree/scripts/pyproject.toml +16 -0
  10. package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
  11. package/agents/code-quality-agent.md +6 -5
  12. package/agents/deep-research.md +7 -24
  13. package/agents/docs-agent.md +1 -27
  14. package/agents/issue-tracker.md +1 -7
  15. package/agents/skill-writer-agent.md +1 -2
  16. package/agents/test_agent_frontmatter.py +309 -12
  17. package/hooks/blocking/CLAUDE.md +2 -0
  18. package/hooks/blocking/fable_spawn_gate.py +187 -0
  19. package/hooks/blocking/piped_pytest_blocker.py +1223 -0
  20. package/hooks/blocking/plain_language_blocker.py +287 -15
  21. package/hooks/blocking/test_fable_spawn_gate.py +374 -0
  22. package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
  23. package/hooks/blocking/test_plain_language_blocker.py +277 -2
  24. package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
  25. package/hooks/git-hooks/CLAUDE.md +2 -2
  26. package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
  27. package/hooks/git-hooks/pre_push.py +343 -54
  28. package/hooks/git-hooks/test_pre_push.py +852 -6
  29. package/hooks/hooks.json +9 -19
  30. package/hooks/hooks_constants/CLAUDE.md +2 -0
  31. package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
  32. package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
  33. package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
  34. package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
  35. package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
  36. package/hooks/hooks_constants/shell_command_segments.py +1 -1
  37. package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
  38. package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
  39. package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
  40. package/package.json +1 -1
  41. package/rules/CLAUDE.md +1 -0
  42. package/rules/ask-user-question-required.md +26 -0
  43. package/rules/claims-as-quotes.md +65 -0
  44. package/scripts/CLAUDE.md +1 -1
  45. package/scripts/_code_review_test_support.py +6 -0
  46. package/scripts/check.ps1 +18 -5
  47. package/scripts/claude_chain_runner.py +203 -31
  48. package/scripts/codec_forwarding_test_support.py +2 -0
  49. package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
  50. package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
  51. package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
  52. package/scripts/dev_env_scripts_constants/grok_worker_constants.py +28 -24
  53. package/scripts/grok_headless_runner.py +16 -83
  54. package/scripts/spawn_grok_batch.py +38 -1
  55. package/scripts/test_claude_chain_runner.py +358 -0
  56. package/scripts/test_grok_headless_runner.py +18 -63
  57. package/scripts/test_invoke_code_review.py +298 -0
  58. package/scripts/test_resolve_worker_spawn.py +6 -0
  59. package/scripts/test_spawn_grok_batch.py +101 -0
  60. package/scripts/tests/CLAUDE.md +1 -0
  61. package/scripts/tests/test_grok_worker_constants.py +59 -0
  62. package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
  63. package/skills/autoconverge/workflow/converge.contract.test.mjs +105 -2
  64. package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +34 -1
  65. package/skills/autoconverge/workflow/converge.mjs +66 -33
  66. package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
  67. package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
  68. package/skills/codex-review/scripts/run_codex_review.py +16 -64
  69. package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
  70. package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
  71. package/skills/e-code-review/SKILL.md +9 -8
  72. package/skills/e-code-review/reference/fix.md +29 -7
  73. package/skills/e-code-review/reference/loop.md +230 -14
  74. package/skills/e-code-review/reference/low.md +33 -15
  75. package/skills/e-code-review/reference/medium.md +55 -21
  76. package/skills/e-code-review/reference/xhigh.md +30 -12
  77. package/skills/fresh-branch/CLAUDE.md +5 -5
  78. package/skills/fresh-branch/SKILL.md +14 -6
  79. package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
  80. package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
  81. package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
  82. package/skills/grok-spawn/SKILL.md +5 -0
  83. package/skills/orchestrator/SKILL.md +4 -1
  84. package/skills/orchestrator-refresh/SKILL.md +5 -1
  85. package/skills/team-advisor/SKILL.md +4 -1
@@ -0,0 +1,1223 @@
1
+ #!/usr/bin/env python3
2
+ """PreToolUse hook: deny a Bash command that pipes a pytest run into another command.
3
+
4
+ A pipeline reports the exit code of its last command, so a red pytest run piped
5
+ anywhere reads as green. Each pytest spelling denies when its output feeds a
6
+ pipe: bare ``pytest``, ``python -m pytest``, and an interpreter path such as
7
+ ``C:\\Python313\\python.exe -m pytest``.
8
+
9
+ ::
10
+
11
+ pytest | tee run.log flag: output feeds a pipe
12
+ python -m pytest tests | head -50 flag: module spelling
13
+ C:\\Python313\\python.exe -m pytest | cat flag: interpreter path
14
+ (python -m pytest tests) | tee run.log flag: subshell feeds a pipe
15
+ (python -m pytest tests)|tee run.log flag: no space before the pipe
16
+ (pytest tests)|(tee run.log) flag: one subshell into another
17
+ (<newline>pytest tests<newline>) | tee x flag: subshell across lines
18
+ bash -c 'pytest | tee run.log' flag: pipeline inside a wrapper
19
+ cmd /c "pytest tests | tee run.log" flag: Windows shell wrapper
20
+ cmd /c python -m pytest tests | tee x flag: unquoted wrapper argument
21
+ pytest tests#tag | tee run.log flag: a hash inside a word
22
+ # note <<EOF<newline>pytest | tee x flag: a heredoc named in a comment
23
+ bash -c 'pytest tests' | tee run.log flag: wrapper piped from outside
24
+ python -m coverage run -m pytest | cat flag: a later -m names pytest
25
+ time pytest tests | tee run.log flag: launcher wrapper
26
+ pytest tests \\<newline>| tee run.log flag: one continued line
27
+ { pytest tests; } | tee run.log flag: brace group feeds a pipe
28
+ if x; then pytest tests; fi | tee run.log flag: ``fi`` closes the compound
29
+ while x; do pytest tests; done | tee x flag: ``done`` closes it too
30
+ if x; then pytest; else echo s; fi | tee x flag: an earlier branch runs it
31
+ if x; then pytest; fi 2>&1 | tee run.log flag: the closer's own redirection
32
+ while x; do pytest; done < in | tee x flag: the same reading ``done``
33
+ pypy3 --jit off -m pytest | tee run.log flag: --jit takes the word after
34
+ pytest.bat tests | tee run.log flag: Windows shim
35
+ sudo pytest tests | tee run.log flag: sudo passes the run through
36
+ uv run pytest tests | tee run.log flag: a run subcommand wrapper
37
+ uv run --frozen pytest tests | tee x flag: a flag before the program
38
+ sudo -u ci pytest tests | tee run.log flag: -u takes the name after it
39
+ sudo -nu ci pytest tests | tee run.log flag: the cluster ends in -u
40
+ bash -euo pipefail -c 'pytest | tee x' flag: -o takes the name after it
41
+ bash -euc 'pytest tests | tee run.log' flag: a -c clustered with others
42
+ coverage run -m pytest tests | tee run.log flag: a run subcommand wrapper
43
+ uvx pytest tests | tee run.log flag: uvx runs its own operand
44
+ uv tool run pytest tests | tee run.log flag: the long spelling of uvx
45
+ pdm run pytest tests | tee run.log flag: a run subcommand wrapper
46
+ pypy3 -m pytest tests | tee run.log flag: another python interpreter
47
+ pythonw -m pytest tests | tee run.log flag: the windowed interpreter
48
+ pytest tests > run.log 2>&1 ok: redirection keeps the code
49
+ pytest tests # | tee run.log ok: the pipe sits in a comment
50
+ pytest tests ok: pytest alone
51
+ git status | head ok: segment carries no pytest
52
+ cat ids.txt | pytest --stdin ok: the pipe feeds into pytest
53
+ pytest tests -q<newline>git status | head ok: the pipe sits on a later line
54
+ cp file{a,b}.txt dst | tee log ok: a brace expansion, no group
55
+ sudo apt update | tee log ok: sudo runs another program
56
+ bash ci.sh -c 'pytest tests' | tee run.log ok: the -c belongs to the script
57
+ bash -- -c 'pytest tests' | tee run.log ok: -- makes -c a script name
58
+ uv run --with pytest mypy . | tee log ok: --with takes the name after
59
+ coverage run -m mypy . | tee types.log ok: the module is not pytest
60
+ pyright -m pytest . | tee out.log ok: not a python interpreter
61
+ bash -Cu script.sh | tee run.log ok: -C is noclobber, not -c
62
+ pwsh -NonInteractive -File a.ps1 | tee x ok: a word option, no cluster
63
+ uv tool install pytest | tee run.log ok: install runs no program
64
+ python myscript.py -m pytest | tee run.log ok: the script owns the -m
65
+ ls done | tee run.log ok: a path named like a closer
66
+ if pytest; then echo ok; fi | tee run.log ok: ``if`` consumes the code
67
+ if a; then x; elif pytest; then y; fi | z ok: an elif condition too
68
+ <<EOF<newline> EOF <newline>pytest | tee x ok: a spaced lookalike is body
69
+ cat > run.sh <<'EOF'<newline>pytest | tee x<newline>EOF ok: a heredoc body
70
+ cat > run.sh <<\\EOF<newline>pytest | tee x<newline>EOF ok: escaped delimiter
71
+
72
+ Tokenizing is local to this module. ``shell_command_segments.split_into_segments``
73
+ cuts a token on an operator character the token carries, so ``pytest -k "a|b"``
74
+ reads there as two segments; the lexer here keeps that quoted text whole.
75
+ """
76
+
77
+ from __future__ import annotations
78
+
79
+ import json
80
+ import shlex
81
+ import sys
82
+ from pathlib import Path
83
+ from typing import NamedTuple
84
+
85
+ _hooks_dir = str(Path(__file__).resolve().parent.parent)
86
+ if _hooks_dir not in sys.path:
87
+ sys.path.insert(0, _hooks_dir)
88
+
89
+ from hooks_constants.hook_block_logger import log_hook_block # noqa: E402
90
+ from hooks_constants.piped_pytest_blocker_constants import ( # noqa: E402
91
+ ALL_BRANCH_CONTINUATION_TOKENS,
92
+ ALL_CLUSTERED_STRING_EXEC_OPTION_LETTERS,
93
+ ALL_COMPOUND_BODY_INTRODUCER_TOKENS,
94
+ ALL_FLAG_TAKING_WRAPPER_COMMANDS,
95
+ ALL_GROUP_CLOSE_TOKENS,
96
+ ALL_OPERATOR_TOKENS_LONGEST_FIRST,
97
+ ALL_PIPE_OPERATOR_TOKENS,
98
+ ALL_PYTEST_PROGRAM_BASENAMES,
99
+ ALL_QUOTE_CHARACTERS,
100
+ ALL_REDIRECTION_SUFFIX_CHARACTERS,
101
+ ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS,
102
+ ALL_SEGMENT_RESET_OPERATOR_TOKENS,
103
+ ALL_SHORT_OPTION_CLUSTERING_SHELL_BASENAMES,
104
+ ALL_STRING_EXEC_COMMAND_FLAGS,
105
+ ALL_STRING_EXECUTING_SHELL_BASENAMES,
106
+ ALL_SUPPORTED_TOOL_NAMES,
107
+ ALL_VALUE_TAKING_INTERPRETER_OPTION_FLAGS,
108
+ ALL_VALUE_TAKING_SHELL_OPTION_FLAGS,
109
+ ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS,
110
+ CALLING_HOOK_NAME,
111
+ CLOSED_GROUP_DEPTH,
112
+ COMMAND_LINE_SPLIT_PATTERN,
113
+ COMMAND_OPTION_TOKEN_PATTERN,
114
+ COMMENT_START_CHARACTER,
115
+ COMMENT_START_GROUP,
116
+ COMMENT_START_SCAN_PATTERN,
117
+ CORRECTIVE_MESSAGE,
118
+ DENY_DECISION,
119
+ DISABLED_LEXER_COMMENTERS,
120
+ END_OF_OPTIONS_TOKEN,
121
+ FILE_DESCRIPTOR_TOKEN_PATTERN,
122
+ GROUP_CLOSE_CHARACTER,
123
+ GROUP_OPEN_CHARACTER,
124
+ HEREDOC_OPENER_OPERATOR,
125
+ HEREDOC_OPENER_PATTERN,
126
+ HEREDOC_STRIPPED_INDENT_CHARACTERS,
127
+ HEREDOC_TAB_STRIP_GROUP,
128
+ HEREDOC_TAB_STRIP_MARKER,
129
+ HEREDOC_TERMINATOR_GROUP,
130
+ HOOK_EVENT_NAME,
131
+ LINE_CONTINUATION_JOIN,
132
+ LINE_CONTINUATION_PATTERN,
133
+ MODULE_RUN_FLAG,
134
+ NO_FOLLOWING_OPERATOR,
135
+ PAREN_GROUP_LINE_JOIN,
136
+ PIPE_CHARACTER,
137
+ PUNCTUATION_ONLY_TOKEN_PATTERN,
138
+ PYTEST_MODULE_NAME,
139
+ PYTHON_INTERPRETER_BASENAME_PATTERN,
140
+ QUOTED_REGION_PATTERN,
141
+ QUOTED_REGION_REPLACEMENT,
142
+ RUN_SUBCOMMAND_NAME,
143
+ SHORT_OPTION_CLUSTER_PATTERN,
144
+ SHORT_OPTION_PREFIX,
145
+ TOOL_SUBCOMMAND_NAME,
146
+ WRAPPED_COMMAND_TOKEN_JOIN,
147
+ )
148
+ from hooks_constants.shell_command_segments import ( # noqa: E402
149
+ effective_leading_program,
150
+ token_basename,
151
+ )
152
+
153
+
154
+ def _unquoted(token: str) -> str:
155
+ """Return a token with its surrounding shell quotes removed."""
156
+ return token.strip(ALL_QUOTE_CHARACTERS)
157
+
158
+
159
+ def _all_operator_aware_tokenizations(command: str) -> list[list[str]]:
160
+ """Return quote-aware tokenizations that carry operators as their own tokens.
161
+
162
+ POSIX mode resolves quoting the way Git Bash does; raw mode keeps a Windows
163
+ interpreter path such as ``C:\\Python313\\python.exe`` whole, since POSIX
164
+ mode reads its backslashes as escapes. Both are returned so a violation in
165
+ either spelling is visible.
166
+
167
+ The lexer's own commenters are cleared, because it cuts at a ``#`` anywhere
168
+ in a word while a shell starts a comment only at a word's start. Leaving the
169
+ default in place drops the rest of ``pytest tests#tag | tee run.log`` and
170
+ hides the pipe. ``_command_line_without_comment`` owns comment removal and
171
+ applies the shell's rule.
172
+ """
173
+ all_tokenizations: list[list[str]] = []
174
+ for each_posix_mode in (True, False):
175
+ lexer = shlex.shlex(command, posix=each_posix_mode, punctuation_chars=True)
176
+ lexer.whitespace_split = True
177
+ lexer.commenters = DISABLED_LEXER_COMMENTERS
178
+ try:
179
+ all_tokens = list(lexer)
180
+ except ValueError:
181
+ continue
182
+ if all_tokens:
183
+ all_tokenizations.append(all_tokens)
184
+ return all_tokenizations
185
+
186
+
187
+ def _glued_module_name(token: str) -> str | None:
188
+ """Return the module name glued onto a ``-m`` flag, or None for any other token.
189
+
190
+ ::
191
+
192
+ -mpytest pytest
193
+ -mmypy mypy
194
+ -m None (the module name is the next token)
195
+ --maxfail None
196
+ """
197
+ unquoted_token = _unquoted(token)
198
+ if not unquoted_token.startswith(MODULE_RUN_FLAG):
199
+ return None
200
+ return unquoted_token[len(MODULE_RUN_FLAG) :] or None
201
+
202
+
203
+ def _some_module_run_flag_names_pytest(all_tokens: list[str]) -> bool:
204
+ """Return True when any ``-m`` among the tokens names pytest.
205
+
206
+ ::
207
+
208
+ ['-m', 'pytest'] flag
209
+ ['-mpytest'] flag
210
+ ['-m', 'coverage', 'run', '-m', 'pytest'] flag: a later -m
211
+ ['-m', 'mypy'] ok
212
+ ['-m'] ok
213
+
214
+ A runner module such as coverage or debugpy takes its own ``-m``, so the
215
+ scan reads every one rather than stopping at the first.
216
+
217
+ Args:
218
+ all_tokens: The interpreter's ``-m`` and every token after it.
219
+
220
+ Returns:
221
+ True when some ``-m pytest`` or ``-mpytest`` appears among them.
222
+ """
223
+ for each_index, each_token in enumerate(all_tokens):
224
+ if _glued_module_name(each_token) == PYTEST_MODULE_NAME:
225
+ return True
226
+ if each_token != MODULE_RUN_FLAG:
227
+ continue
228
+ module_index = each_index + 1
229
+ if module_index >= len(all_tokens):
230
+ continue
231
+ if _unquoted(all_tokens[module_index]) == PYTEST_MODULE_NAME:
232
+ return True
233
+ return False
234
+
235
+
236
+ def _all_tokens_from_the_interpreter_module_flag(
237
+ all_interpreter_argument_tokens: list[str],
238
+ ) -> list[str] | None:
239
+ """Return the tokens from the interpreter's own ``-m`` on, or None when it has none.
240
+
241
+ ::
242
+
243
+ ['-m', 'pytest'] ['-m', 'pytest']
244
+ ['-mpytest', 'tests'] ['-mpytest', 'tests']
245
+ ['-X', 'dev', '-m', 'pytest'] ['-m', 'pytest'] -X takes a value
246
+ ['-Xdev', '-m', 'pytest'] ['-m', 'pytest'] a glued value
247
+ ['myscript.py', '-m', 'pytest'] None: the script ends the options
248
+ ['tests'] None
249
+
250
+ A flag counts only while it is still an option. A script path is an operand,
251
+ and every token after it is that script's own argument — ``python
252
+ myscript.py -m pytest`` runs the script and may never reach pytest at all.
253
+ An interpreter option carrying a separate value is followed by that value
254
+ rather than by an operand, so the scan steps over it and keeps reading.
255
+
256
+ Args:
257
+ all_interpreter_argument_tokens: The tokens following the interpreter.
258
+
259
+ Returns:
260
+ The tokens from the interpreter's ``-m`` on, or None when an operand
261
+ ends the option list first.
262
+ """
263
+ scan_index = 0
264
+ while scan_index < len(all_interpreter_argument_tokens):
265
+ each_token = all_interpreter_argument_tokens[scan_index]
266
+ unquoted_token = _unquoted(each_token)
267
+ if unquoted_token == MODULE_RUN_FLAG or _glued_module_name(each_token) is not None:
268
+ return all_interpreter_argument_tokens[scan_index:]
269
+ if COMMAND_OPTION_TOKEN_PATTERN.match(unquoted_token) is None:
270
+ return None
271
+ scan_index += 1 + _option_value_token_count(
272
+ unquoted_token, ALL_VALUE_TAKING_INTERPRETER_OPTION_FLAGS
273
+ )
274
+ return None
275
+
276
+
277
+ def _runs_pytest_as_a_module(all_interpreter_argument_tokens: list[str]) -> bool:
278
+ """Return True when the interpreter's own ``-m`` reaches a pytest run.
279
+
280
+ ::
281
+
282
+ ['-m', 'pytest'] flag
283
+ ['-m', 'coverage', 'run', '-m', 'pytest'] flag: the module runs pytest
284
+ ['myscript.py', '-m', 'pytest'] ok: the script owns the -m
285
+ ['-m', 'mypy'] ok
286
+
287
+ Everything after the interpreter's own ``-m`` is the module's argument list,
288
+ and a module runner such as coverage names pytest with an ``-m`` of its own,
289
+ so the scan reads the whole tail from that flag on.
290
+
291
+ Args:
292
+ all_interpreter_argument_tokens: The tokens following the interpreter.
293
+
294
+ Returns:
295
+ True when the interpreter runs pytest as a module, directly or through
296
+ a module runner.
297
+ """
298
+ all_module_tokens = _all_tokens_from_the_interpreter_module_flag(
299
+ all_interpreter_argument_tokens
300
+ )
301
+ if all_module_tokens is None:
302
+ return False
303
+ return _some_module_run_flag_names_pytest(all_module_tokens)
304
+
305
+
306
+ def _clustered_option_value_token_count(
307
+ unquoted_token: str, all_value_taking_flags: frozenset[str]
308
+ ) -> int:
309
+ """Return how many tokens a short-option cluster takes as the value of its last flag.
310
+
311
+ ::
312
+
313
+ -nu with -u value-taking 1 the flag ends the cluster
314
+ -nuci with -u value-taking 0 ``ci`` is glued on as the value
315
+ -nx with neither value-taking 0
316
+
317
+ POSIX lets short options cluster behind one hyphen. A value-taking letter
318
+ inside the cluster carries its value glued to the end, so only a cluster
319
+ that ends in one reaches forward for the next token.
320
+
321
+ Args:
322
+ unquoted_token: One short-option cluster, its hyphen included.
323
+ all_value_taking_flags: The flags of the program the cluster belongs to.
324
+
325
+ Returns:
326
+ 1 when the cluster's value is the following token, else 0.
327
+ """
328
+ all_cluster_letters = unquoted_token[len(SHORT_OPTION_PREFIX) :]
329
+ last_letter_position = len(all_cluster_letters) - 1
330
+ for each_position, each_letter in enumerate(all_cluster_letters):
331
+ if SHORT_OPTION_PREFIX + each_letter not in all_value_taking_flags:
332
+ continue
333
+ return int(each_position == last_letter_position)
334
+ return 0
335
+
336
+
337
+ def _option_value_token_count(unquoted_token: str, all_value_taking_flags: frozenset[str]) -> int:
338
+ """Return how many tokens after an option token are that option's value.
339
+
340
+ ::
341
+
342
+ --with in the flag set 1
343
+ -o in the flag set 1
344
+ -euo with -o in the flag set 1 a cluster ending in the flag
345
+ --frozen 0
346
+ """
347
+ if unquoted_token in all_value_taking_flags:
348
+ return 1
349
+ if SHORT_OPTION_CLUSTER_PATTERN.fullmatch(unquoted_token) is None:
350
+ return 0
351
+ return _clustered_option_value_token_count(unquoted_token, all_value_taking_flags)
352
+
353
+
354
+ def _all_tokens_from_the_first_operand(all_tokens: list[str]) -> list[str]:
355
+ """Return the tokens from the first non-option one on, dropping the option flags.
356
+
357
+ ::
358
+
359
+ ['-n', 'pytest', 'tests'] ['pytest', 'tests']
360
+ ['-u', 'someone', 'pytest'] ['pytest'] -u takes the name
361
+ ['-nu', 'someone', 'pytest'] ['pytest'] the cluster ends in -u
362
+ ['--with', 'pytest', 'mypy', '.'] ['mypy', '.'] --with takes a name
363
+ ['--', '-c', 'run.sh'] ['-c', 'run.sh'] -- ends the flags
364
+ ['run', 'pytest'] ['run', 'pytest']
365
+ ['-n'] []
366
+
367
+ A flag that takes a separate value swallows the token after it, so the
368
+ operand the wrapper runs is the token past that value rather than the value
369
+ itself. A short-option cluster reaches forward the same way when its last
370
+ letter is the value-taking one. ``--`` ends the option list outright, and
371
+ every token after it is an operand however it is spelled.
372
+
373
+ Args:
374
+ all_tokens: The tokens following a wrapper command, in order.
375
+
376
+ Returns:
377
+ The tokens from the wrapper's first operand on, empty when it has none.
378
+ """
379
+ all_remaining_tokens = all_tokens
380
+ while all_remaining_tokens:
381
+ unquoted_token = _unquoted(all_remaining_tokens[0])
382
+ if unquoted_token == END_OF_OPTIONS_TOKEN:
383
+ return all_remaining_tokens[1:]
384
+ if COMMAND_OPTION_TOKEN_PATTERN.match(unquoted_token) is None:
385
+ return all_remaining_tokens
386
+ flag_value_token_count = _option_value_token_count(
387
+ unquoted_token, ALL_VALUE_TAKING_WRAPPER_OPTION_FLAGS
388
+ )
389
+ all_remaining_tokens = all_remaining_tokens[1 + flag_value_token_count :]
390
+ return []
391
+
392
+
393
+ def _all_tokens_after_one_wrapper(all_segment_tokens: list[str]) -> list[str] | None:
394
+ """Return the tokens a single leading pass-through wrapper runs, else None.
395
+
396
+ ::
397
+
398
+ ['sudo', 'pytest', 'tests'] ['pytest', 'tests']
399
+ ['uvx', 'pytest', 'tests'] ['pytest', 'tests']
400
+ ['uv', 'run', 'pytest', 'tests'] ['pytest', 'tests']
401
+ ['uv', 'run', '--frozen', 'pytest'] ['pytest']
402
+ ['uv', 'tool', 'run', 'pytest'] ['pytest']
403
+ ['sudo', 'apt', 'update'] ['apt', 'update']
404
+ ['uv', 'sync'] None no ``run`` subcommand
405
+ ['pytest', 'tests'] None no wrapper leads
406
+
407
+ ``sudo`` and ``uvx`` run whatever follows their own flags, so the step-over
408
+ drops the flags and keeps the rest. ``uv``, ``poetry``, ``pipenv``, and the
409
+ rest run a program only behind the literal ``run`` subcommand, so any other
410
+ subcommand leaves the wrapper as the program it already is. ``uv`` spells
411
+ the same pass-through as ``uv tool run``, so the step-over reads a leading
412
+ ``tool`` word before it looks for ``run``. Each subcommand takes flags of
413
+ its own, so the step-over drops those too before it reads the program.
414
+ """
415
+ leading_program = effective_leading_program(all_segment_tokens)
416
+ if leading_program is None:
417
+ return None
418
+ program_basename = token_basename(_unquoted(leading_program))
419
+ leading_index = all_segment_tokens.index(leading_program)
420
+ all_argument_tokens = _all_tokens_from_the_first_operand(
421
+ all_segment_tokens[leading_index + 1 :]
422
+ )
423
+ if program_basename in ALL_FLAG_TAKING_WRAPPER_COMMANDS:
424
+ return all_argument_tokens
425
+ if program_basename not in ALL_RUN_SUBCOMMAND_WRAPPER_COMMANDS:
426
+ return None
427
+ if all_argument_tokens and _unquoted(all_argument_tokens[0]) == TOOL_SUBCOMMAND_NAME:
428
+ all_argument_tokens = _all_tokens_from_the_first_operand(all_argument_tokens[1:])
429
+ if not all_argument_tokens or _unquoted(all_argument_tokens[0]) != RUN_SUBCOMMAND_NAME:
430
+ return None
431
+ return _all_tokens_from_the_first_operand(all_argument_tokens[1:])
432
+
433
+
434
+ def _all_tokens_after_wrappers(all_segment_tokens: list[str]) -> list[str]:
435
+ """Return the segment tokens with every leading pass-through wrapper stepped over.
436
+
437
+ ::
438
+
439
+ ['sudo', 'uv', 'run', 'pytest'] ['pytest']
440
+ ['git', 'status'] ['git', 'status']
441
+
442
+ Each step returns a strictly shorter token list, so the walk ends.
443
+ """
444
+ all_remaining_tokens = all_segment_tokens
445
+ while True:
446
+ all_stepped_tokens = _all_tokens_after_one_wrapper(all_remaining_tokens)
447
+ if all_stepped_tokens is None:
448
+ return all_remaining_tokens
449
+ all_remaining_tokens = all_stepped_tokens
450
+
451
+
452
+ def segment_runs_pytest(all_segment_tokens: list[str]) -> bool:
453
+ """Return True when a simple-command segment invokes pytest.
454
+
455
+ ::
456
+
457
+ ['pytest', 'tests'] flag
458
+ ['python', '-m', 'pytest'] flag
459
+ ['python', '-mpytest'] flag
460
+ ['time', 'pytest', 'tests'] flag
461
+ ['sudo', 'pytest', 'tests'] flag
462
+ ['uv', 'run', 'pytest', 'tests'] flag
463
+ ['C:\\\\Python313\\\\python.exe', '-m', 'pytest'] flag
464
+ ['python', '-m', 'mypy'] ok
465
+ ['git', 'status'] ok
466
+ [] ok
467
+
468
+ Args:
469
+ all_segment_tokens: The tokens of one simple command, operators removed.
470
+
471
+ Returns:
472
+ True when the segment's program is pytest or a python interpreter
473
+ running the pytest module.
474
+ """
475
+ all_unwrapped_tokens = _all_tokens_after_wrappers(all_segment_tokens)
476
+ leading_program = effective_leading_program(all_unwrapped_tokens)
477
+ if leading_program is None:
478
+ return False
479
+ program_basename = token_basename(_unquoted(leading_program))
480
+ if program_basename in ALL_PYTEST_PROGRAM_BASENAMES:
481
+ return True
482
+ if not PYTHON_INTERPRETER_BASENAME_PATTERN.fullmatch(program_basename):
483
+ return False
484
+ interpreter_index = all_unwrapped_tokens.index(leading_program)
485
+ return _runs_pytest_as_a_module(all_unwrapped_tokens[interpreter_index + 1 :])
486
+
487
+
488
+ def _clustered_string_exec_flag_offset(unquoted_token: str) -> int | None:
489
+ """Return how far past a cluster its command string sits, or None when it holds none.
490
+
491
+ ::
492
+
493
+ -euc 0: the command string is the very next token
494
+ -ceu 0: the letter's place in the cluster does not matter
495
+ -euoc 1: -o takes a word of its own, so the string is one further
496
+ -Cu None: -C is the noclobber switch, a different option
497
+ -eux None
498
+ --c None: a long option is one name rather than a letter run
499
+
500
+ A shell reads every letter behind one hyphen as its own option, so the
501
+ ``-c`` in ``bash -euc 'pytest'`` reads a command string exactly as a lone
502
+ ``-c`` does. A value-taking letter in that run takes the next word rather
503
+ than glued text, so each one pushes the command string one token further
504
+ along.
505
+
506
+ Args:
507
+ unquoted_token: One argument token, in the case its command line spells.
508
+
509
+ Returns:
510
+ The token count between the cluster and its command string, or None when
511
+ the cluster holds no string-exec letter.
512
+ """
513
+ if SHORT_OPTION_CLUSTER_PATTERN.fullmatch(unquoted_token) is None:
514
+ return None
515
+ all_cluster_letters = unquoted_token[len(SHORT_OPTION_PREFIX) :]
516
+ if not any(
517
+ each_letter in ALL_CLUSTERED_STRING_EXEC_OPTION_LETTERS
518
+ for each_letter in all_cluster_letters
519
+ ):
520
+ return None
521
+ return sum(
522
+ SHORT_OPTION_PREFIX + each_letter.lower() in ALL_VALUE_TAKING_SHELL_OPTION_FLAGS
523
+ for each_letter in all_cluster_letters
524
+ )
525
+
526
+
527
+ def _string_exec_flag_index(
528
+ all_argument_tokens: list[str], *, clusters_short_options: bool
529
+ ) -> int | None:
530
+ """Return the index of a wrapper's string-exec flag, or None when it takes none.
531
+
532
+ ::
533
+
534
+ ['-c', 'pytest tests'] 0
535
+ ['-x', '-c', 'pytest tests'] 1
536
+ ['-o', 'pipefail', '-c', 'pytest x'] 2: -o takes the option name
537
+ ['-euo', 'pipefail', '-c', 'x'] 2: the cluster ends in -o
538
+ ['-euc', 'pytest x'] 0: a clustered -c reads a string
539
+ ['-euoc', 'pipefail', 'pytest x'] 1: -o takes a word of its own
540
+ ['/c', 'python', '-m', 'pytest'] 0
541
+ ['scripts/ci.sh', '-c', 'pytest x'] None: the script is the operand
542
+ ['--', '-c', 'pytest tests'] None: -- makes -c the script
543
+ ['script.sh'] None
544
+
545
+ A string-exec flag counts only while it is still an option — that is, before
546
+ the first operand. Once a script path appears the shell is running that
547
+ script, and every later flag is the script's own argument rather than a
548
+ command string the shell reads. ``--`` ends the options outright, so the
549
+ ``-c`` behind one names a script file too.
550
+
551
+ A shell option that takes a value is followed by that value rather than by
552
+ an operand, so the scan steps over it and keeps reading options. ``-File``
553
+ is left out of that set on purpose: its value is a script path, which ends
554
+ the option list under the rule above.
555
+
556
+ Args:
557
+ all_argument_tokens: The tokens following the shell, in order.
558
+ clusters_short_options: True for a shell that reads ``-euc`` as a run of
559
+ single-letter options, False for one whose options are whole words.
560
+
561
+ Returns:
562
+ The index of the string-exec flag, or None when the shell takes none.
563
+ """
564
+ scan_index = 0
565
+ while scan_index < len(all_argument_tokens):
566
+ unquoted_token = _unquoted(all_argument_tokens[scan_index])
567
+ lowercased_token = unquoted_token.lower()
568
+ if lowercased_token == END_OF_OPTIONS_TOKEN:
569
+ return None
570
+ if lowercased_token in ALL_STRING_EXEC_COMMAND_FLAGS:
571
+ return scan_index
572
+ if clusters_short_options:
573
+ clustered_flag_offset = _clustered_string_exec_flag_offset(unquoted_token)
574
+ if clustered_flag_offset is not None:
575
+ return scan_index + clustered_flag_offset
576
+ if COMMAND_OPTION_TOKEN_PATTERN.match(lowercased_token) is None:
577
+ return None
578
+ scan_index += 1 + _option_value_token_count(
579
+ lowercased_token, ALL_VALUE_TAKING_SHELL_OPTION_FLAGS
580
+ )
581
+ return None
582
+
583
+
584
+ def _string_exec_inner_command(all_segment_tokens: list[str]) -> str | None:
585
+ """Return the command string a shell wrapper runs, or None for any other segment.
586
+
587
+ ::
588
+
589
+ bash -c 'pytest | tee run.log' pytest | tee run.log
590
+ bash -euc 'pytest | tee run.log' pytest | tee run.log
591
+ pwsh -Command 'pytest' pytest
592
+ cmd /c python -m pytest tests python -m pytest tests
593
+ bash scripts/ci.sh -c 'pytest tests' None: the script takes the -c
594
+ bash script.sh None
595
+ pytest tests None
596
+
597
+ Every token after the flag joins back into one string. A quoted inner
598
+ command is one token already, so the join returns it unchanged; an unquoted
599
+ one such as ``cmd /c python -m pytest tests`` spans several tokens, and
600
+ reading only the first would see ``python`` alone and miss the pytest the
601
+ ``-m`` names.
602
+ """
603
+ all_unwrapped_tokens = _all_tokens_after_wrappers(all_segment_tokens)
604
+ leading_program = effective_leading_program(all_unwrapped_tokens)
605
+ if leading_program is None:
606
+ return None
607
+ shell_basename = token_basename(_unquoted(leading_program))
608
+ if shell_basename not in ALL_STRING_EXECUTING_SHELL_BASENAMES:
609
+ return None
610
+ leading_index = all_unwrapped_tokens.index(leading_program)
611
+ all_argument_tokens = all_unwrapped_tokens[leading_index + 1 :]
612
+ flag_index = _string_exec_flag_index(
613
+ all_argument_tokens,
614
+ clusters_short_options=shell_basename in ALL_SHORT_OPTION_CLUSTERING_SHELL_BASENAMES,
615
+ )
616
+ if flag_index is None:
617
+ return None
618
+ inner_index = flag_index + 1
619
+ if inner_index >= len(all_argument_tokens):
620
+ return None
621
+ return WRAPPED_COMMAND_TOKEN_JOIN.join(all_argument_tokens[inner_index:])
622
+
623
+
624
+ def _trailing_operator_token(token: str) -> str | None:
625
+ """Return the longest control operator the token ends with, or None for any other token."""
626
+ for each_operator in ALL_OPERATOR_TOKENS_LONGEST_FIRST:
627
+ if token.endswith(each_operator):
628
+ return each_operator
629
+ return None
630
+
631
+
632
+ def _all_punctuation_token_parts(token: str) -> list[str]:
633
+ """Split a punctuation-only token into its leading text and every trailing operator.
634
+
635
+ ::
636
+
637
+ )| [')', '|'] a subshell closed right against a pipe
638
+ )|& [')', '|&'] the same against the stderr pipe
639
+ )|( [')', '|', '('] one subshell piped straight into the next
640
+ )|| [')', '||'] the longest operator wins, so this is no pipe
641
+ >| ['>|'] a clobber-override redirection, not a pipe
642
+ | ['|'] the whole token is already the operator
643
+ tests ['tests'] not punctuation only
644
+
645
+ ``shlex`` with ``punctuation_chars=True`` glues consecutive punctuation into
646
+ one token, so ``(pytest tests)|(tee x)`` arrives with ``)|(`` unsplit and the
647
+ pipe invisible to the segment pairing. Peeling repeats until the leading text
648
+ ends in no operator, so a token gluing several operators comes apart whole
649
+ rather than surrendering its last one only.
650
+
651
+ Args:
652
+ token: One token from an operator-aware tokenization.
653
+
654
+ Returns:
655
+ The token's parts in their original order — a one-item list holding the
656
+ token itself when it needs no splitting.
657
+ """
658
+ if PUNCTUATION_ONLY_TOKEN_PATTERN.fullmatch(token) is None:
659
+ return [token]
660
+ all_peeled_operators: list[str] = []
661
+ remaining_text = token
662
+ while True:
663
+ trailing_operator = _trailing_operator_token(remaining_text)
664
+ if trailing_operator is None or trailing_operator == remaining_text:
665
+ break
666
+ leading_text = remaining_text[: -len(trailing_operator)]
667
+ if leading_text.endswith(ALL_REDIRECTION_SUFFIX_CHARACTERS):
668
+ break
669
+ all_peeled_operators.append(trailing_operator)
670
+ remaining_text = leading_text
671
+ return [remaining_text, *reversed(all_peeled_operators)]
672
+
673
+
674
+ def _all_operator_split_tokens(all_command_tokens: list[str]) -> list[str]:
675
+ """Return the tokens with every glued punctuation-and-operator token split apart."""
676
+ all_split_tokens: list[str] = []
677
+ for each_token in all_command_tokens:
678
+ all_split_tokens.extend(_all_punctuation_token_parts(each_token))
679
+ return all_split_tokens
680
+
681
+
682
+ def _all_segments_with_following_operator(
683
+ all_command_tokens: list[str],
684
+ ) -> list[tuple[list[str], str]]:
685
+ """Pair each simple-command segment with the control operator that ends it.
686
+
687
+ A redirection token stays inside its segment, so ``pytest 2>&1 | tee x``
688
+ keeps the pytest evidence the pipe check reads. A close paren stays inside
689
+ too, so a subshell's pytest survives to the pipe that follows it — whether a
690
+ space separates the two (``) | tee x``) or not (``)|tee x``).
691
+ """
692
+ all_segments: list[tuple[list[str], str]] = []
693
+ current_segment: list[str] = []
694
+ for each_token in _all_operator_split_tokens(all_command_tokens):
695
+ if each_token in ALL_PIPE_OPERATOR_TOKENS or each_token in ALL_SEGMENT_RESET_OPERATOR_TOKENS:
696
+ all_segments.append((current_segment, each_token))
697
+ current_segment = []
698
+ continue
699
+ current_segment.append(each_token)
700
+ all_segments.append((current_segment, NO_FOLLOWING_OPERATOR))
701
+ return all_segments
702
+
703
+
704
+ def _wrapped_command_runs_pytest(inner_command: str) -> bool:
705
+ """Return True when the command string a shell wrapper runs invokes pytest.
706
+
707
+ ::
708
+
709
+ pytest tests flag
710
+ python -m pytest -q flag
711
+ git status ok
712
+ """
713
+ for each_tokenization in _all_operator_aware_tokenizations(inner_command):
714
+ for each_segment, _ in _all_segments_with_following_operator(each_tokenization):
715
+ if segment_runs_pytest(each_segment):
716
+ return True
717
+ return False
718
+
719
+
720
+ def _segment_reports_a_pytest_exit_code(all_segment_tokens: list[str]) -> bool:
721
+ """Return True when the segment's exit code is pytest's, directly or through a wrapper.
722
+
723
+ ::
724
+
725
+ ['pytest', 'tests'] flag
726
+ ['bash', '-c', 'pytest tests'] flag: bash exits with pytest's code
727
+ ['bash', 'script.sh'] ok
728
+ """
729
+ if segment_runs_pytest(all_segment_tokens):
730
+ return True
731
+ inner_command = _string_exec_inner_command(all_segment_tokens)
732
+ if inner_command is None:
733
+ return False
734
+ return _wrapped_command_runs_pytest(inner_command)
735
+
736
+
737
+ def _is_a_redirection_operator(token: str) -> bool:
738
+ """Return True when the token is a redirection operator rather than a word.
739
+
740
+ ::
741
+
742
+ > True
743
+ >> True
744
+ >& True
745
+ < True
746
+ >| True a clobber-override redirection
747
+ ) False punctuation, but not a redirection
748
+ out False
749
+ """
750
+ if PUNCTUATION_ONLY_TOKEN_PATTERN.fullmatch(token) is None:
751
+ return False
752
+ return any(
753
+ each_character in token for each_character in ALL_REDIRECTION_SUFFIX_CHARACTERS
754
+ )
755
+
756
+
757
+ def _all_tokens_before_the_first_redirection(all_segment_tokens: list[str]) -> list[str]:
758
+ """Return the segment tokens ahead of its first redirection, file descriptor included.
759
+
760
+ ::
761
+
762
+ ['fi', '>', 'out.log'] ['fi']
763
+ ['fi', '2', '>&', '1'] ['fi'] the ``2`` names the descriptor
764
+ ['done', '<', 'list'] ['done']
765
+ ['ls', 'done', '>', 'x'] ['ls', 'done']
766
+ ['pytest', 'tests'] ['pytest', 'tests']
767
+
768
+ A redirection binds to the command it follows rather than being part of it,
769
+ so the tokens ahead of the first one are the command itself. ``2>&1`` lexes
770
+ as three tokens, so a bare file-descriptor number written right before the
771
+ operator goes with it.
772
+
773
+ Args:
774
+ all_segment_tokens: The tokens of one simple command.
775
+
776
+ Returns:
777
+ The command's own tokens, the whole segment when it redirects nothing.
778
+ """
779
+ for each_index, each_token in enumerate(all_segment_tokens):
780
+ if not _is_a_redirection_operator(each_token):
781
+ continue
782
+ descriptor_index = each_index - 1
783
+ if descriptor_index >= 0 and FILE_DESCRIPTOR_TOKEN_PATTERN.fullmatch(
784
+ all_segment_tokens[descriptor_index]
785
+ ):
786
+ return all_segment_tokens[:descriptor_index]
787
+ return all_segment_tokens[:each_index]
788
+ return all_segment_tokens
789
+
790
+
791
+ def _holds_group_closers_only(all_segment_tokens: list[str]) -> bool:
792
+ """Return True when the segment holds nothing but group-closing reserved words.
793
+
794
+ ::
795
+
796
+ ['}'] True
797
+ [')'] True
798
+ ['fi'] True
799
+ ['done'] True
800
+ ['esac'] True
801
+ ['fi', '2', '>&', '1'] True the redirection is not part of it
802
+ ['done', '<', 'list'] True
803
+ ['pytest', 'tests'] False
804
+ ['ls', 'done'] False a path named like a keyword
805
+ [] False
806
+
807
+ A closing keyword counts as one only when it stands as its own whole token,
808
+ so ``ls done`` reads as a command operating on a path and ``echo fi`` as a
809
+ word being printed. A compound may carry a redirection of its own past the
810
+ closer, and that redirection belongs to the compound rather than making the
811
+ closer part of some other command.
812
+ """
813
+ all_command_tokens = _all_tokens_before_the_first_redirection(all_segment_tokens)
814
+ return bool(all_command_tokens) and all(
815
+ each_token in ALL_GROUP_CLOSE_TOKENS for each_token in all_command_tokens
816
+ )
817
+
818
+
819
+ def _all_tokens_after_body_introducers(all_segment_tokens: list[str]) -> list[str]:
820
+ """Return the segment tokens with each leading body-introducing reserved word dropped.
821
+
822
+ ::
823
+
824
+ ['then', 'pytest', 'tests'] ['pytest', 'tests']
825
+ ['do', 'pytest', 'tests'] ['pytest', 'tests']
826
+ ['else', 'pytest', 'tests'] ['pytest', 'tests']
827
+ ['ls', 'do'] ['ls', 'do'] a path, not a keyword
828
+ ['pytest', 'tests'] ['pytest', 'tests']
829
+
830
+ ``then``, ``do``, and ``else`` open the body of a compound command and stand
831
+ ahead of the first command in it, so the program whose exit status the
832
+ compound reports is the token past them. Only a leading run is dropped, so
833
+ the same word later in the segment stays the operand it is.
834
+ """
835
+ scan_index = 0
836
+ while (
837
+ scan_index < len(all_segment_tokens)
838
+ and all_segment_tokens[scan_index] in ALL_COMPOUND_BODY_INTRODUCER_TOKENS
839
+ ):
840
+ scan_index += 1
841
+ return all_segment_tokens[scan_index:]
842
+
843
+
844
+ def _opens_a_later_branch(all_segment_tokens: list[str]) -> bool:
845
+ """Return True when the segment opens the next branch of a compound command.
846
+
847
+ ::
848
+
849
+ ['else', 'echo', 'skip'] True
850
+ ['elif', 'false'] True
851
+ ['then', 'echo', 'skip'] False
852
+ ['fi'] False
853
+ [] False
854
+ """
855
+ return bool(all_segment_tokens) and all_segment_tokens[0] in ALL_BRANCH_CONTINUATION_TOKENS
856
+
857
+
858
+ def _all_status_reporting_token_lists(
859
+ all_segments: list[tuple[list[str], str]], segment_index: int
860
+ ) -> list[list[str]]:
861
+ """Return every command whose exit code the segment at the index can report.
862
+
863
+ ::
864
+
865
+ pytest tests | tee x [pytest tests] its own
866
+ { pytest tests; } | tee x [pytest tests] the group's last
867
+ if x; then pytest; fi | y [pytest] the body's last
868
+ while x; do pytest; done | y [pytest] the same via ``done``
869
+ if x; then pytest; else echo s; fi | y [echo s, pytest] either branch ran
870
+
871
+ A compound command ends with a closer of its own — ``}``, ``)``, ``fi``,
872
+ ``done``, or ``esac`` — which lands in a segment by itself, so a pipe after
873
+ it reads the status of the command before it. That command opens with the
874
+ body-introducing keyword of its compound, which the read drops off the front.
875
+ Any segment carrying real words of its own reports its own exit code, and no
876
+ branch scan runs for it.
877
+
878
+ A compound with several branches runs whichever one its condition picks, so
879
+ the status can come from any of them rather than from the last alone. The
880
+ scan walks back over the earlier branches and takes the command each one
881
+ ends on — the one written right before an ``else`` or ``elif``. It stops at
882
+ the first closer it meets going back, because that closer ends a compound of
883
+ its own and everything past it belongs to that earlier command.
884
+
885
+ Args:
886
+ all_segments: Every segment of one tokenization, paired with the
887
+ operator that ends it.
888
+ segment_index: The index of the segment the pipe follows.
889
+
890
+ Returns:
891
+ Every candidate command's tokens, empty when only group closers precede
892
+ the pipe.
893
+ """
894
+ close_index = segment_index
895
+ while close_index >= 0 and _holds_group_closers_only(all_segments[close_index][0]):
896
+ close_index -= 1
897
+ if close_index < 0:
898
+ return []
899
+ all_token_lists = [_all_tokens_after_body_introducers(all_segments[close_index][0])]
900
+ if close_index == segment_index:
901
+ return all_token_lists
902
+ for each_index in range(close_index - 1, -1, -1):
903
+ all_candidate_tokens = all_segments[each_index][0]
904
+ if _holds_group_closers_only(all_candidate_tokens):
905
+ break
906
+ if _opens_a_later_branch(all_segments[each_index + 1][0]):
907
+ all_token_lists.append(_all_tokens_after_body_introducers(all_candidate_tokens))
908
+ return all_token_lists
909
+
910
+
911
+ def _tokenization_pipes_pytest(all_command_tokens: list[str]) -> bool:
912
+ """Return True when a pytest segment feeds a pipe, at this level or inside a wrapper."""
913
+ all_segments = _all_segments_with_following_operator(all_command_tokens)
914
+ for each_index, (each_segment, each_operator) in enumerate(all_segments):
915
+ if each_operator in ALL_PIPE_OPERATOR_TOKENS and any(
916
+ _segment_reports_a_pytest_exit_code(each_token_list)
917
+ for each_token_list in _all_status_reporting_token_lists(all_segments, each_index)
918
+ ):
919
+ return True
920
+ inner_command = _string_exec_inner_command(each_segment)
921
+ if inner_command is None:
922
+ continue
923
+ if find_piped_pytest_violation(inner_command) is not None:
924
+ return True
925
+ return False
926
+
927
+
928
+ class _PendingHeredoc(NamedTuple):
929
+ """The word an open heredoc closes on, and how its opener lets that word be indented."""
930
+
931
+ terminator: str
932
+ allows_leading_tabs: bool
933
+
934
+
935
+ def _pending_heredoc_opened_by(command_line: str) -> _PendingHeredoc | None:
936
+ """Return the heredoc the line opens, or None when it opens none.
937
+
938
+ ::
939
+
940
+ cat > run.sh <<EOF _PendingHeredoc('EOF', allows_leading_tabs=False)
941
+ cat > run.sh <<-EOF _PendingHeredoc('EOF', allows_leading_tabs=True)
942
+ cat file <<<word None: a here-string opens no body
943
+ echo hi None
944
+ """
945
+ heredoc_opener = HEREDOC_OPENER_PATTERN.search(command_line)
946
+ if heredoc_opener is None:
947
+ return None
948
+ return _PendingHeredoc(
949
+ terminator=heredoc_opener.group(HEREDOC_TERMINATOR_GROUP),
950
+ allows_leading_tabs=(
951
+ heredoc_opener.group(HEREDOC_TAB_STRIP_GROUP) == HEREDOC_TAB_STRIP_MARKER
952
+ ),
953
+ )
954
+
955
+
956
+ def _closes_the_heredoc(command_line: str, pending_heredoc: _PendingHeredoc) -> bool:
957
+ """Return True when the line is the terminator its opener spelled.
958
+
959
+ ::
960
+
961
+ 'EOF' after <<EOF True
962
+ ' EOF ' after <<EOF False: bash wants the word alone
963
+ '\\tEOF' after <<-EOF True: a dash opener strips leading tabs
964
+ '\\tEOF' after <<EOF False
965
+
966
+ Bash closes a heredoc on a line holding the delimiter and nothing else, so a
967
+ body line spaced to look like one keeps the body open.
968
+ """
969
+ if pending_heredoc.allows_leading_tabs:
970
+ return command_line.lstrip(HEREDOC_STRIPPED_INDENT_CHARACTERS) == pending_heredoc.terminator
971
+ return command_line == pending_heredoc.terminator
972
+
973
+
974
+ def _all_live_command_lines(all_command_lines: list[str]) -> list[str]:
975
+ """Return the lines the call runs, dropping every heredoc body and its terminator.
976
+
977
+ ::
978
+
979
+ cat > run.sh <<'EOF' kept: the redirection line runs
980
+ pytest tests | tee out.log dropped: script text, nothing runs
981
+ EOF dropped: the terminator
982
+ pytest tests | tee out.log kept: a live line below the heredoc
983
+
984
+ A heredoc opener names the word that closes its body, so the lines between
985
+ the two are text this call writes rather than commands it runs.
986
+
987
+ Args:
988
+ all_command_lines: The physical lines of one Bash command, in order.
989
+
990
+ Returns:
991
+ The lines outside every heredoc body, in their original order.
992
+ """
993
+ all_live_lines: list[str] = []
994
+ pending_heredoc: _PendingHeredoc | None = None
995
+ for each_line in all_command_lines:
996
+ if pending_heredoc is not None:
997
+ if _closes_the_heredoc(each_line, pending_heredoc):
998
+ pending_heredoc = None
999
+ continue
1000
+ all_live_lines.append(each_line)
1001
+ pending_heredoc = _pending_heredoc_opened_by(each_line)
1002
+ return all_live_lines
1003
+
1004
+
1005
+ def _command_line_without_comment(command_line: str) -> str:
1006
+ """Return the line with a shell comment and everything after it removed.
1007
+
1008
+ ::
1009
+
1010
+ python -m pytest tests # fast python -m pytest tests
1011
+ # the fast run (nothing runs on this line)
1012
+ pytest -k "a#b" unchanged: the hash sits inside quotes
1013
+ tee run#1.log unchanged: the hash sits inside a word
1014
+
1015
+ A comment ends at its own newline. Removing it here keeps the lines under it
1016
+ live once a parenthesis group joins them into one logical line, where a
1017
+ surviving hash would comment the joined pipe out instead.
1018
+
1019
+ Args:
1020
+ command_line: One physical command line.
1021
+
1022
+ Returns:
1023
+ The line up to its comment, or the whole line when it carries none.
1024
+ """
1025
+ for each_match in COMMENT_START_SCAN_PATTERN.finditer(command_line):
1026
+ if each_match.group(COMMENT_START_GROUP) is not None:
1027
+ return command_line[: each_match.start(COMMENT_START_GROUP)]
1028
+ return command_line
1029
+
1030
+
1031
+ def _all_comment_free_lines(all_command_lines: list[str]) -> list[str]:
1032
+ """Return every line with its shell comment removed.
1033
+
1034
+ ::
1035
+
1036
+ # note the <<EOF form (nothing runs on this line)
1037
+ pytest tests # fast pytest tests
1038
+
1039
+ This runs before the heredoc scan, so a ``<<WORD`` written inside a comment
1040
+ opens no heredoc and cannot drop the live lines beneath it.
1041
+
1042
+ Args:
1043
+ all_command_lines: The physical lines of one Bash command, in order.
1044
+
1045
+ Returns:
1046
+ The comment-free lines, in their original order and count.
1047
+ """
1048
+ return [_command_line_without_comment(each_line) for each_line in all_command_lines]
1049
+
1050
+
1051
+ def _paren_depth_change(command_line: str) -> int:
1052
+ """Return how many parenthesis groups the line opens, minus the ones it closes.
1053
+
1054
+ Quoted text and backslash-escaped characters drop out first, so
1055
+ ``pytest -k "(a)"`` counts as no group at all.
1056
+ """
1057
+ unquoted_line = QUOTED_REGION_PATTERN.sub(QUOTED_REGION_REPLACEMENT, command_line)
1058
+ return unquoted_line.count(GROUP_OPEN_CHARACTER) - unquoted_line.count(GROUP_CLOSE_CHARACTER)
1059
+
1060
+
1061
+ def _all_paren_group_joined_lines(all_command_lines: list[str]) -> list[str]:
1062
+ """Return the lines with each open parenthesis group joined into one logical line.
1063
+
1064
+ ::
1065
+
1066
+ ( joined: the group is still open
1067
+ # the fast run joined: a comment ends at its own newline
1068
+ python -m pytest tests joined: the group is still open
1069
+ ) | tee run.log ( python -m pytest tests ) | tee run.log
1070
+ pytest tests pytest tests
1071
+
1072
+ A subshell opened on one line and closed on a later one is one command, so
1073
+ the pipe after the close paren belongs to the pytest run inside it. Comments
1074
+ are already gone by this point, so a parenthesis inside a comment opens no
1075
+ group and a comment never reaches across the newline that ends it.
1076
+
1077
+ Args:
1078
+ all_command_lines: The live command lines, comments removed and heredoc
1079
+ bodies already dropped.
1080
+
1081
+ Returns:
1082
+ One line per parenthesis group, and the unchanged line for every other.
1083
+ """
1084
+ all_joined_lines: list[str] = []
1085
+ all_pending_lines: list[str] = []
1086
+ open_group_depth = CLOSED_GROUP_DEPTH
1087
+ for each_line in all_command_lines:
1088
+ all_pending_lines.append(each_line)
1089
+ open_group_depth = max(
1090
+ open_group_depth + _paren_depth_change(each_line), CLOSED_GROUP_DEPTH
1091
+ )
1092
+ if open_group_depth > CLOSED_GROUP_DEPTH:
1093
+ continue
1094
+ all_joined_lines.append(PAREN_GROUP_LINE_JOIN.join(all_pending_lines))
1095
+ all_pending_lines = []
1096
+ if all_pending_lines:
1097
+ all_joined_lines.append(PAREN_GROUP_LINE_JOIN.join(all_pending_lines))
1098
+ return all_joined_lines
1099
+
1100
+
1101
+ def _all_scannable_command_lines(joined_command: str) -> list[str]:
1102
+ """Return the lines to tokenize, running only the passes the command's text calls for.
1103
+
1104
+ ::
1105
+
1106
+ pytest tests | tee x split only: no #, no <<, no (
1107
+ pytest tests # | tee x the comment pass runs
1108
+ (pytest tests) | tee x the parenthesis-group join runs
1109
+ cat <<EOF … EOF the heredoc pass runs
1110
+
1111
+ Each pass leaves the lines unchanged when its own character is absent, so
1112
+ testing for that character first drops the work without moving a verdict.
1113
+ Comments go first, so a ``<<WORD`` or a ``(`` written inside one is already
1114
+ gone; the tests read the whole command, which still carries both, so a
1115
+ needless pass is possible while a skipped one is not.
1116
+
1117
+ Args:
1118
+ joined_command: One Bash command, its line continuations already joined.
1119
+
1120
+ Returns:
1121
+ The command lines ready for tokenization.
1122
+ """
1123
+ all_command_lines = COMMAND_LINE_SPLIT_PATTERN.split(joined_command)
1124
+ if COMMENT_START_CHARACTER in joined_command:
1125
+ all_command_lines = _all_comment_free_lines(all_command_lines)
1126
+ if HEREDOC_OPENER_OPERATOR in joined_command:
1127
+ all_command_lines = _all_live_command_lines(all_command_lines)
1128
+ if GROUP_OPEN_CHARACTER not in joined_command:
1129
+ return all_command_lines
1130
+ return _all_paren_group_joined_lines(all_command_lines)
1131
+
1132
+
1133
+ def find_piped_pytest_violation(command: str) -> str | None:
1134
+ """Return the deny message for a piped pytest run, or None to allow.
1135
+
1136
+ ::
1137
+
1138
+ pytest | tee run.log flag
1139
+ python -m pytest tests | head -50 flag
1140
+ pytest tests 2>&1 | tee run.log flag
1141
+ cd repo && pytest | cat flag
1142
+ pytest tests > run.log 2>&1 ok
1143
+ pytest tests ok
1144
+ pytest && echo done | tee run.log ok
1145
+ git status | head ok
1146
+ cat ids.txt | pytest --stdin ok
1147
+ pytest -k "a|b" ok
1148
+
1149
+ A pipe operator carries a ``|`` in every spelling it has, and no step below
1150
+ inserts one, so a command holding no ``|`` at all can hold no violation and
1151
+ returns before any parsing. This runs on every Bash call in a session, and
1152
+ the check is one membership test.
1153
+
1154
+ Past that gate: joins each backslash-newline continuation into one logical
1155
+ line, splits the result on the newline and carriage-return terminators,
1156
+ drops each line's comment, drops every heredoc body, joins the lines of each
1157
+ still-open parenthesis group, then tokenizes each remaining line so shell
1158
+ operators stand alone and quoted text stays whole. Comments go first, so a
1159
+ ``<<WORD`` written inside one opens no heredoc; heredoc bodies go next, so a
1160
+ body inside a subshell is gone before the group join reads it and a ``(``
1161
+ written into a heredoc opens no group. A
1162
+ pipe operator tests the segment that feeds it; a command separator
1163
+ starts a fresh segment; a redirection and a close paren stay inside the
1164
+ segment they belong to. A shell wrapper running a quoted string re-enters
1165
+ this check on that string, and re-enters at this gate, so a short inner
1166
+ command carrying no pipe costs one membership test too.
1167
+
1168
+ Args:
1169
+ command: The raw Bash command string from the tool input.
1170
+
1171
+ Returns:
1172
+ The corrective deny message when a pytest segment feeds a pipe, else
1173
+ None.
1174
+ """
1175
+ if PIPE_CHARACTER not in command:
1176
+ return None
1177
+ joined_command = LINE_CONTINUATION_PATTERN.sub(LINE_CONTINUATION_JOIN, command)
1178
+ for each_command_line in _all_scannable_command_lines(joined_command):
1179
+ for each_tokenization in _all_operator_aware_tokenizations(each_command_line):
1180
+ if _tokenization_pipes_pytest(each_tokenization):
1181
+ return CORRECTIVE_MESSAGE
1182
+ return None
1183
+
1184
+
1185
+ def main() -> None:
1186
+ try:
1187
+ hook_input = json.load(sys.stdin)
1188
+ except json.JSONDecodeError:
1189
+ sys.exit(0)
1190
+
1191
+ tool_name = hook_input.get("tool_name", "")
1192
+ if tool_name not in ALL_SUPPORTED_TOOL_NAMES:
1193
+ sys.exit(0)
1194
+
1195
+ command = hook_input.get("tool_input", {}).get("command", "")
1196
+ if not command:
1197
+ sys.exit(0)
1198
+
1199
+ deny_reason = find_piped_pytest_violation(command)
1200
+ if deny_reason is None:
1201
+ sys.exit(0)
1202
+
1203
+ deny_payload = {
1204
+ "hookSpecificOutput": {
1205
+ "hookEventName": HOOK_EVENT_NAME,
1206
+ "permissionDecision": DENY_DECISION,
1207
+ "permissionDecisionReason": deny_reason,
1208
+ }
1209
+ }
1210
+ log_hook_block(
1211
+ calling_hook_name=CALLING_HOOK_NAME,
1212
+ hook_event=HOOK_EVENT_NAME,
1213
+ block_reason=deny_reason,
1214
+ tool_name=tool_name,
1215
+ offending_input_preview=command,
1216
+ )
1217
+ print(json.dumps(deny_payload))
1218
+ sys.stdout.flush()
1219
+ sys.exit(0)
1220
+
1221
+
1222
+ if __name__ == "__main__":
1223
+ main()