claude-dev-env 2.7.0 → 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.
- package/_shared/CLAUDE.md +1 -0
- package/_shared/advisor/advisor-protocol.md +19 -9
- package/_shared/pr-loop/audit-contract.md +4 -4
- package/_shared/pr-loop/precatch-rubric.md +2 -2
- package/_shared/pr-loop/worker-spawn.md +3 -1
- package/_shared/process-tree/CLAUDE.md +41 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/__init__.py +1 -0
- package/_shared/process-tree/scripts/config/process_tree_scripts_constants/process_tree_kill_constants.py +27 -0
- package/_shared/process-tree/scripts/process_tree_kill.py +141 -0
- package/_shared/process-tree/scripts/pyproject.toml +16 -0
- package/_shared/process-tree/scripts/test_process_tree_kill.py +278 -0
- package/agents/code-quality-agent.md +6 -5
- package/agents/deep-research.md +7 -24
- package/agents/docs-agent.md +1 -27
- package/agents/issue-tracker.md +1 -7
- package/agents/skill-writer-agent.md +1 -2
- package/agents/test_agent_frontmatter.py +309 -12
- package/hooks/blocking/CLAUDE.md +2 -0
- package/hooks/blocking/fable_spawn_gate.py +187 -0
- package/hooks/blocking/piped_pytest_blocker.py +1223 -0
- package/hooks/blocking/plain_language_blocker.py +287 -15
- package/hooks/blocking/test_fable_spawn_gate.py +374 -0
- package/hooks/blocking/test_piped_pytest_blocker.py +587 -0
- package/hooks/blocking/test_plain_language_blocker.py +277 -2
- package/hooks/blocking/test_pre_tool_use_dispatcher.py +34 -2
- package/hooks/git-hooks/CLAUDE.md +2 -2
- package/hooks/git-hooks/git_hooks_constants/__init__.py +28 -0
- package/hooks/git-hooks/pre_push.py +343 -54
- package/hooks/git-hooks/test_pre_push.py +852 -6
- package/hooks/hooks.json +9 -19
- package/hooks/hooks_constants/CLAUDE.md +2 -0
- package/hooks/hooks_constants/bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/fable_spawn_gate_constants.py +62 -0
- package/hooks/hooks_constants/piped_pytest_blocker_constants.py +360 -0
- package/hooks/hooks_constants/plain_language_blocker_constants.py +64 -1
- package/hooks/hooks_constants/pre_tool_use_dispatcher_constants.py +10 -0
- package/hooks/hooks_constants/shell_command_segments.py +1 -1
- package/hooks/hooks_constants/test_bash_pre_tool_use_dispatcher_constants.py +1 -0
- package/hooks/hooks_constants/test_pre_tool_use_dispatcher_constants.py +27 -0
- package/hooks/hooks_constants/test_prose_metrics_parity.py +124 -0
- package/package.json +1 -1
- package/rules/CLAUDE.md +1 -0
- package/rules/ask-user-question-required.md +26 -0
- package/rules/claims-as-quotes.md +65 -0
- package/scripts/CLAUDE.md +4 -4
- package/scripts/_code_review_test_support.py +6 -0
- package/scripts/check.ps1 +18 -5
- package/scripts/claude_chain_runner.py +203 -31
- package/scripts/codec_forwarding_test_support.py +2 -0
- package/scripts/dev_env_scripts_constants/CLAUDE.md +4 -4
- package/scripts/dev_env_scripts_constants/claude_chain_constants.py +38 -0
- package/scripts/dev_env_scripts_constants/code_review_constants.py +403 -2
- package/scripts/dev_env_scripts_constants/grok_worker_constants.py +83 -13
- package/scripts/grok_headless_runner.py +148 -18
- package/scripts/resolve_worker_spawn.py +56 -10
- package/scripts/spawn_grok_batch.py +81 -23
- package/scripts/test_claude_chain_runner.py +358 -0
- package/scripts/test_grok_headless_runner.py +547 -10
- package/scripts/test_invoke_code_review.py +298 -0
- package/scripts/test_resolve_worker_spawn.py +185 -15
- package/scripts/test_spawn_grok_batch.py +326 -22
- package/scripts/tests/CLAUDE.md +1 -0
- package/scripts/tests/test_grok_worker_constants.py +59 -0
- package/skills/_shared/pr-loop/scripts/test_build_audit_prompt.py +46 -0
- package/skills/autoconverge/workflow/converge.contract.test.mjs +133 -8
- package/skills/autoconverge/workflow/converge.fix-recovery.test.mjs +107 -1
- package/skills/autoconverge/workflow/converge.mjs +113 -31
- package/skills/codex-review/scripts/codex_review_scripts_constants/codex_usage_probe_constants.py +0 -4
- package/skills/codex-review/scripts/codex_usage_probe.py +20 -33
- package/skills/codex-review/scripts/run_codex_review.py +16 -64
- package/skills/codex-review/scripts/test_codex_usage_probe.py +46 -41
- package/skills/codex-review/scripts/test_run_codex_review.py +1 -33
- package/skills/e-code-review/SKILL.md +9 -8
- package/skills/e-code-review/reference/fix.md +29 -7
- package/skills/e-code-review/reference/loop.md +230 -14
- package/skills/e-code-review/reference/low.md +33 -15
- package/skills/e-code-review/reference/medium.md +55 -21
- package/skills/e-code-review/reference/xhigh.md +30 -12
- package/skills/fresh-branch/CLAUDE.md +5 -5
- package/skills/fresh-branch/SKILL.md +14 -6
- package/skills/fresh-branch/scripts/create_fresh_branch.py +122 -39
- package/skills/fresh-branch/scripts/fresh_branch_scripts_constants/fresh_branch_cli_constants.py +10 -3
- package/skills/fresh-branch/scripts/test_create_fresh_branch.py +251 -0
- package/skills/grok-spawn/SKILL.md +10 -3
- package/skills/grok-spawn/reference/flag-profiles.md +3 -1
- package/skills/orchestrator/SKILL.md +4 -1
- package/skills/orchestrator-refresh/SKILL.md +5 -1
- 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()
|