agent-postmortem 0.3.4__py3-none-any.whl
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.
- agent_postmortem/__init__.py +9 -0
- agent_postmortem/argerrors.py +160 -0
- agent_postmortem/claims.py +43 -0
- agent_postmortem/cli.py +2278 -0
- agent_postmortem/commands.py +1543 -0
- agent_postmortem/config.py +435 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000001.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000002.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.jsonl +5 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000003.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.jsonl +2 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/agent-ac200000000000004.meta.json +8 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000/subagents/workflows/wf_00000000-d30/journal.jsonl +7 -0
- agent_postmortem/demo/-home-user-project/00000000-0000-4000-8000-000000000000.jsonl +4 -0
- agent_postmortem/detectors/__init__.py +98 -0
- agent_postmortem/detectors/attempt_failed.py +261 -0
- agent_postmortem/detectors/base.py +319 -0
- agent_postmortem/detectors/bash_backgrounded.py +72 -0
- agent_postmortem/detectors/bash_timeout.py +94 -0
- agent_postmortem/detectors/claim_contradiction.py +251 -0
- agent_postmortem/detectors/classifier_unavailable.py +73 -0
- agent_postmortem/detectors/codex_unavailable.py +102 -0
- agent_postmortem/detectors/codex_write_denied.py +100 -0
- agent_postmortem/detectors/git_cwd_mismatch.py +74 -0
- agent_postmortem/detectors/mislabeled_kill.py +164 -0
- agent_postmortem/detectors/missing_artifact.py +159 -0
- agent_postmortem/detectors/permission_denied.py +99 -0
- agent_postmortem/detectors/silent_retry.py +163 -0
- agent_postmortem/detectors/structured_output_failure.py +91 -0
- agent_postmortem/detectors/tool_json_error.py +77 -0
- agent_postmortem/detectors/wasted_time.py +125 -0
- agent_postmortem/detectors/workflow_failed.py +62 -0
- agent_postmortem/disclosure.py +931 -0
- agent_postmortem/hooks/model_allocation_guard.mjs +248 -0
- agent_postmortem/hooks_cli.py +1269 -0
- agent_postmortem/ledger.py +624 -0
- agent_postmortem/markers.py +505 -0
- agent_postmortem/models.py +815 -0
- agent_postmortem/observe.py +792 -0
- agent_postmortem/reader.py +4611 -0
- agent_postmortem/redact.py +1981 -0
- agent_postmortem/report.py +3010 -0
- agent_postmortem/rescue.py +2096 -0
- agent_postmortem/returns.py +499 -0
- agent_postmortem/rules.py +627 -0
- agent_postmortem/severity.py +275 -0
- agent_postmortem/state.py +1261 -0
- agent_postmortem/trend.py +391 -0
- agent_postmortem/watch.py +973 -0
- agent_postmortem-0.3.4.dist-info/METADATA +264 -0
- agent_postmortem-0.3.4.dist-info/RECORD +56 -0
- agent_postmortem-0.3.4.dist-info/WHEEL +4 -0
- agent_postmortem-0.3.4.dist-info/entry_points.txt +3 -0
- agent_postmortem-0.3.4.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,1543 @@
|
|
|
1
|
+
"""Pure command-text classifier for Bash and Monitor calls (docs/DESIGN.md
|
|
2
|
+
4.13.1, N4a). Standard library only: no filesystem access, no subprocess.
|
|
3
|
+
|
|
4
|
+
The transcript records a command's text, never the shell it ran in, so
|
|
5
|
+
nothing here can prove a command did not write. The grammars decide one
|
|
6
|
+
syntactic claim only: "no write is recognized in the recorded command".
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import functools
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class Word:
|
|
19
|
+
text: str
|
|
20
|
+
raw: str
|
|
21
|
+
quoted: bool
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Redirect:
|
|
26
|
+
fd: str | None
|
|
27
|
+
op: str
|
|
28
|
+
target: str | None
|
|
29
|
+
target_quoted: bool
|
|
30
|
+
glued_before: bool
|
|
31
|
+
after_words: int
|
|
32
|
+
body: str | None = None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass(frozen=True)
|
|
36
|
+
class Segment:
|
|
37
|
+
words: tuple[Word, ...]
|
|
38
|
+
redirects: tuple[Redirect, ...]
|
|
39
|
+
sep_after: str | None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Lexed:
|
|
44
|
+
segments: tuple[Segment, ...]
|
|
45
|
+
reasons: tuple[str, ...]
|
|
46
|
+
trailing_op: str | None
|
|
47
|
+
# FA6: every (reason, segment index) the lexer raised, the index being the
|
|
48
|
+
# segment under construction when it fired (a reason about the end of the
|
|
49
|
+
# command names the last segment). `reasons` stays the call-global answer;
|
|
50
|
+
# this lets one candidate segment be judged on its own (commit_command_ran).
|
|
51
|
+
reason_at: tuple[tuple[str, int], ...] = field(default=(), compare=False, repr=False)
|
|
52
|
+
|
|
53
|
+
@property
|
|
54
|
+
def placed(self) -> bool:
|
|
55
|
+
return not self.reasons
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
_FIRST_WORD_OPAQUE = frozenset(
|
|
59
|
+
{"{", "!", "function", "alias", "eval", "exec", "source", ".", "command",
|
|
60
|
+
"builtin", "time", "xargs", "env", "nohup"}
|
|
61
|
+
)
|
|
62
|
+
_ASSIGN_RE = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(\[[^\]]*\])?\+?=")
|
|
63
|
+
_BRACE_RE = re.compile(r"\{[^{}]*(?:,|\.\.)[^{}]*\}")
|
|
64
|
+
_MESSAGE_RE = re.compile(
|
|
65
|
+
r"\$\([ \t]*cat[ \t]+<<(-?)(?:'([A-Za-z_][A-Za-z0-9_]*)'|\"([A-Za-z_][A-Za-z0-9_]*)\")[ \t]*\n"
|
|
66
|
+
)
|
|
67
|
+
_CLOSE_PAREN_RE = re.compile(r"[ \t\n]*\)")
|
|
68
|
+
_EXTGLOB_PREV = "+@!?*"
|
|
69
|
+
# Characters scan() treats specially in any word state; everything else is a
|
|
70
|
+
# plain word character once a word is active.
|
|
71
|
+
_PLAIN_RUN_RE = re.compile(r"[^ \t\n\\'\"$`*?\[()&;|<>]+")
|
|
72
|
+
_SPECIAL_CHARS = frozenset(" \t\n\\'\"$`*?[()&;|<>")
|
|
73
|
+
# Inside double quotes: a run with no quote, backslash, or substitution start.
|
|
74
|
+
_DQUOTE_RUN_RE = re.compile(r'[^"\\$`]+')
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class _Lexer:
|
|
78
|
+
"""One pass over the command. Opaque triggers are recorded as reasons and
|
|
79
|
+
scanning continues, so the words of an opaque command are still available
|
|
80
|
+
to the background and delegation rows."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, command: str, commit_message_form: bool) -> None:
|
|
83
|
+
self.s = command
|
|
84
|
+
self.n = len(command)
|
|
85
|
+
self.i = 0
|
|
86
|
+
self.message_form = commit_message_form
|
|
87
|
+
self.reasons: list[str] = []
|
|
88
|
+
self.reason_at: list[tuple[str, int]] = []
|
|
89
|
+
self.segs: list[dict] = []
|
|
90
|
+
self.words: list[Word] = []
|
|
91
|
+
self.redirs: list[list] = []
|
|
92
|
+
self.heredocs: list[tuple[list, bool, bool]] = []
|
|
93
|
+
self.pending: list | None = None
|
|
94
|
+
self.trailing_op: str | None = None
|
|
95
|
+
self._reset_word()
|
|
96
|
+
|
|
97
|
+
# -- helpers ------------------------------------------------------------
|
|
98
|
+
def reason(self, name: str, index: int | None = None) -> None:
|
|
99
|
+
if name not in self.reasons:
|
|
100
|
+
self.reasons.append(name)
|
|
101
|
+
self.reason_at.append((name, len(self.segs) if index is None else index))
|
|
102
|
+
|
|
103
|
+
def _reset_word(self) -> None:
|
|
104
|
+
self.w_active = False
|
|
105
|
+
self.w_start = 0
|
|
106
|
+
# Pieces of the word: the text, and the same with every quoted or
|
|
107
|
+
# escaped character replaced by "Q" (what brace and extglob detection
|
|
108
|
+
# look at); w_last is the final character of the second.
|
|
109
|
+
self.w_text: list[str] = []
|
|
110
|
+
self.w_unq: list[str] = []
|
|
111
|
+
self.w_last = ""
|
|
112
|
+
self.w_quoted = False
|
|
113
|
+
|
|
114
|
+
def _begin_word(self) -> None:
|
|
115
|
+
if not self.w_active:
|
|
116
|
+
self.w_active = True
|
|
117
|
+
self.w_start = self.i
|
|
118
|
+
|
|
119
|
+
def _add(self, ch: str, quoted: bool) -> None:
|
|
120
|
+
self._begin_word()
|
|
121
|
+
self.w_text.append(ch)
|
|
122
|
+
last = "Q" if quoted else ch
|
|
123
|
+
self.w_unq.append(last)
|
|
124
|
+
self.w_last = last
|
|
125
|
+
if quoted:
|
|
126
|
+
self.w_quoted = True
|
|
127
|
+
|
|
128
|
+
def _add_quoted(self, text: str) -> None:
|
|
129
|
+
"""Every character of text, quoted (the word is already active)."""
|
|
130
|
+
if text:
|
|
131
|
+
self.w_text.append(text)
|
|
132
|
+
self.w_unq.append("Q" * len(text))
|
|
133
|
+
self.w_last = "Q"
|
|
134
|
+
|
|
135
|
+
def flush_word(self) -> None:
|
|
136
|
+
if not self.w_active:
|
|
137
|
+
return
|
|
138
|
+
pieces = self.w_text
|
|
139
|
+
text = pieces[0] if len(pieces) == 1 else "".join(pieces)
|
|
140
|
+
word = Word(text, self.s[self.w_start:self.i], self.w_quoted)
|
|
141
|
+
if "{" in text and _BRACE_RE.search("".join(self.w_unq)):
|
|
142
|
+
self.reason("brace")
|
|
143
|
+
if self.pending is not None:
|
|
144
|
+
pending = self.pending
|
|
145
|
+
self.pending = None
|
|
146
|
+
pending[2] = word.text
|
|
147
|
+
pending[3] = word.quoted
|
|
148
|
+
op = pending[1]
|
|
149
|
+
if op in (">&", "<&"):
|
|
150
|
+
if not (op == ">&" and pending[0] == "2" and word.text == "1"):
|
|
151
|
+
self.reason("fd_redirect")
|
|
152
|
+
if op in ("<<", "<<-"):
|
|
153
|
+
self.heredocs.append((pending, op == "<<-", word.quoted))
|
|
154
|
+
else:
|
|
155
|
+
self.words.append(word)
|
|
156
|
+
self.w_active = False
|
|
157
|
+
self.w_text = []
|
|
158
|
+
self.w_unq = []
|
|
159
|
+
self.w_last = ""
|
|
160
|
+
self.w_quoted = False
|
|
161
|
+
|
|
162
|
+
def end_segment(self, op: str) -> None:
|
|
163
|
+
self.flush_word()
|
|
164
|
+
if self.pending is not None:
|
|
165
|
+
self.reason("missing_redirect_target")
|
|
166
|
+
self.pending = None
|
|
167
|
+
if self.words or self.redirs:
|
|
168
|
+
self.segs.append({"words": self.words, "redirs": self.redirs, "sep": op})
|
|
169
|
+
self.words = []
|
|
170
|
+
self.redirs = []
|
|
171
|
+
elif op != "\n":
|
|
172
|
+
self.reason("empty_segment")
|
|
173
|
+
|
|
174
|
+
# -- scanning -------------------------------------------------------------
|
|
175
|
+
def scan(self) -> None:
|
|
176
|
+
s, n = self.s, self.n
|
|
177
|
+
plain_run = _PLAIN_RUN_RE.match
|
|
178
|
+
special = _SPECIAL_CHARS
|
|
179
|
+
while self.i < n:
|
|
180
|
+
c = s[self.i]
|
|
181
|
+
if c in " \t":
|
|
182
|
+
self.flush_word()
|
|
183
|
+
self.i += 1
|
|
184
|
+
continue
|
|
185
|
+
if c not in special and (self.w_active or not (c == "~" or c == "#" or c.isdigit())):
|
|
186
|
+
# The common case first: a run of plain characters (every
|
|
187
|
+
# character not in _SPECIAL_CHARS; ~, # and a digit are plain
|
|
188
|
+
# inside a word and special only where a word starts).
|
|
189
|
+
chunk = plain_run(s, self.i).group()
|
|
190
|
+
if not self.w_active:
|
|
191
|
+
self.w_active = True
|
|
192
|
+
self.w_start = self.i
|
|
193
|
+
self.w_text.append(chunk)
|
|
194
|
+
self.w_unq.append(chunk)
|
|
195
|
+
self.w_last = chunk[-1]
|
|
196
|
+
self.i += len(chunk)
|
|
197
|
+
if self.i < n and s[self.i] in " \t":
|
|
198
|
+
# The blank after a word ends it: what the next pass does.
|
|
199
|
+
self.flush_word()
|
|
200
|
+
self.i += 1
|
|
201
|
+
continue
|
|
202
|
+
if c == "\n":
|
|
203
|
+
self.flush_word()
|
|
204
|
+
self.i += 1
|
|
205
|
+
if self.heredocs:
|
|
206
|
+
self._read_heredocs()
|
|
207
|
+
self.end_segment("\n")
|
|
208
|
+
elif c == "\\":
|
|
209
|
+
self._backslash()
|
|
210
|
+
elif c == "'":
|
|
211
|
+
self._squote()
|
|
212
|
+
elif c == '"':
|
|
213
|
+
self._dquote()
|
|
214
|
+
elif c == "$":
|
|
215
|
+
self.reason("expansion")
|
|
216
|
+
self._add(c, False)
|
|
217
|
+
self.i += 1
|
|
218
|
+
elif c == "`":
|
|
219
|
+
self.reason("command_substitution")
|
|
220
|
+
self._add(c, False)
|
|
221
|
+
self.i += 1
|
|
222
|
+
elif c in "*?[":
|
|
223
|
+
self.reason("glob")
|
|
224
|
+
self._add(c, False)
|
|
225
|
+
self.i += 1
|
|
226
|
+
elif c == "~" and not self.w_active:
|
|
227
|
+
self.reason("tilde")
|
|
228
|
+
self._add(c, False)
|
|
229
|
+
self.i += 1
|
|
230
|
+
elif c in "()":
|
|
231
|
+
if c == "(" and self.w_last and self.w_last in _EXTGLOB_PREV:
|
|
232
|
+
self.reason("extglob")
|
|
233
|
+
else:
|
|
234
|
+
self.reason("paren")
|
|
235
|
+
self._add(c, False)
|
|
236
|
+
self.i += 1
|
|
237
|
+
elif c == ";":
|
|
238
|
+
if s.startswith(";;", self.i):
|
|
239
|
+
self.reason("double_semicolon")
|
|
240
|
+
self.i += 1
|
|
241
|
+
self.i += 1
|
|
242
|
+
self.end_segment(";")
|
|
243
|
+
elif c == "&":
|
|
244
|
+
if s.startswith("&&", self.i):
|
|
245
|
+
self.i += 2
|
|
246
|
+
self.end_segment("&&")
|
|
247
|
+
elif s.startswith("&>", self.i):
|
|
248
|
+
self._redirect(None, self.w_active)
|
|
249
|
+
else:
|
|
250
|
+
self.i += 1
|
|
251
|
+
self.end_segment("&")
|
|
252
|
+
elif c == "|":
|
|
253
|
+
if s.startswith("||", self.i):
|
|
254
|
+
self.i += 2
|
|
255
|
+
self.end_segment("||")
|
|
256
|
+
elif s.startswith("|&", self.i):
|
|
257
|
+
self.i += 2
|
|
258
|
+
self.end_segment("|&")
|
|
259
|
+
else:
|
|
260
|
+
self.i += 1
|
|
261
|
+
self.end_segment("|")
|
|
262
|
+
elif c in "<>":
|
|
263
|
+
self._redirect(None, self.w_active)
|
|
264
|
+
elif c == "#" and not self.w_active:
|
|
265
|
+
nl = s.find("\n", self.i)
|
|
266
|
+
self.i = n if nl < 0 else nl
|
|
267
|
+
elif c.isdigit() and not self.w_active and self._fd_prefix():
|
|
268
|
+
pass
|
|
269
|
+
else:
|
|
270
|
+
# ~, # or a digit that did not start a special form: plain.
|
|
271
|
+
chunk = plain_run(s, self.i).group()
|
|
272
|
+
self._begin_word()
|
|
273
|
+
self.w_text.append(chunk)
|
|
274
|
+
self.w_unq.append(chunk)
|
|
275
|
+
self.w_last = chunk[-1]
|
|
276
|
+
self.i += len(chunk)
|
|
277
|
+
self._finish()
|
|
278
|
+
|
|
279
|
+
def _fd_prefix(self) -> bool:
|
|
280
|
+
j = self.i
|
|
281
|
+
while j < self.n and self.s[j].isdigit():
|
|
282
|
+
j += 1
|
|
283
|
+
if j < self.n and self.s[j] in "<>":
|
|
284
|
+
fd = self.s[self.i:j]
|
|
285
|
+
self.i = j
|
|
286
|
+
self._redirect(fd, False)
|
|
287
|
+
return True
|
|
288
|
+
return False
|
|
289
|
+
|
|
290
|
+
def _backslash(self) -> None:
|
|
291
|
+
s = self.s
|
|
292
|
+
if self.i + 1 >= self.n:
|
|
293
|
+
self._add("\\", False)
|
|
294
|
+
self.i += 1
|
|
295
|
+
return
|
|
296
|
+
nxt = s[self.i + 1]
|
|
297
|
+
if nxt == "\n":
|
|
298
|
+
self.i += 2
|
|
299
|
+
return
|
|
300
|
+
self._begin_word()
|
|
301
|
+
self._add(nxt, True)
|
|
302
|
+
if nxt in "*?[":
|
|
303
|
+
self.reason("glob")
|
|
304
|
+
elif nxt == "$":
|
|
305
|
+
self.reason("expansion")
|
|
306
|
+
elif nxt == "`":
|
|
307
|
+
self.reason("command_substitution")
|
|
308
|
+
self.i += 2
|
|
309
|
+
|
|
310
|
+
def _squote(self) -> None:
|
|
311
|
+
end = self.s.find("'", self.i + 1)
|
|
312
|
+
self._begin_word()
|
|
313
|
+
self.w_quoted = True
|
|
314
|
+
if end < 0:
|
|
315
|
+
self.reason("unbalanced_quote")
|
|
316
|
+
self.i = self.n
|
|
317
|
+
return
|
|
318
|
+
self._add_quoted(self.s[self.i + 1:end])
|
|
319
|
+
self.i = end + 1
|
|
320
|
+
|
|
321
|
+
def _dquote(self) -> None:
|
|
322
|
+
s, n = self.s, self.n
|
|
323
|
+
self._begin_word()
|
|
324
|
+
self.w_quoted = True
|
|
325
|
+
self.i += 1
|
|
326
|
+
run = _DQUOTE_RUN_RE.match
|
|
327
|
+
while self.i < n:
|
|
328
|
+
found = run(s, self.i)
|
|
329
|
+
if found is not None:
|
|
330
|
+
self._add_quoted(found.group())
|
|
331
|
+
self.i = found.end()
|
|
332
|
+
if self.i >= n:
|
|
333
|
+
break
|
|
334
|
+
c = s[self.i]
|
|
335
|
+
if c == '"':
|
|
336
|
+
self.i += 1
|
|
337
|
+
return
|
|
338
|
+
if c == "\\" and self.i + 1 < n and s[self.i + 1] in '$`"\\\n':
|
|
339
|
+
nxt = s[self.i + 1]
|
|
340
|
+
if nxt == "\n":
|
|
341
|
+
self.i += 2
|
|
342
|
+
continue
|
|
343
|
+
if nxt == "$":
|
|
344
|
+
self.reason("expansion")
|
|
345
|
+
elif nxt == "`":
|
|
346
|
+
self.reason("command_substitution")
|
|
347
|
+
self._add(nxt, True)
|
|
348
|
+
self.i += 2
|
|
349
|
+
elif c == "$":
|
|
350
|
+
if self.message_form and self._message_literal():
|
|
351
|
+
continue
|
|
352
|
+
self.reason("expansion")
|
|
353
|
+
self._add(c, True)
|
|
354
|
+
self.i += 1
|
|
355
|
+
elif c == "`":
|
|
356
|
+
self.reason("command_substitution")
|
|
357
|
+
self._add(c, True)
|
|
358
|
+
self.i += 1
|
|
359
|
+
else:
|
|
360
|
+
self._add(c, True)
|
|
361
|
+
self.i += 1
|
|
362
|
+
self.reason("unbalanced_quote")
|
|
363
|
+
|
|
364
|
+
def _message_literal(self) -> bool:
|
|
365
|
+
"""The one literal substitution: $(cat <<'DELIM' ... DELIM )."""
|
|
366
|
+
s = self.s
|
|
367
|
+
m = _MESSAGE_RE.match(s, self.i)
|
|
368
|
+
if m is None:
|
|
369
|
+
return False
|
|
370
|
+
strip = m.group(1) == "-"
|
|
371
|
+
delim = m.group(2) or m.group(3)
|
|
372
|
+
pos = m.end()
|
|
373
|
+
body: list[str] = []
|
|
374
|
+
while pos <= self.n:
|
|
375
|
+
eol = s.find("\n", pos)
|
|
376
|
+
if eol < 0:
|
|
377
|
+
return False
|
|
378
|
+
line = s[pos:eol]
|
|
379
|
+
if (line.lstrip("\t") if strip else line) == delim:
|
|
380
|
+
close = _CLOSE_PAREN_RE.match(s, eol + 1)
|
|
381
|
+
if close is None:
|
|
382
|
+
return False
|
|
383
|
+
self._add_quoted("".join(body))
|
|
384
|
+
self.i = close.end()
|
|
385
|
+
return True
|
|
386
|
+
body.append(line + "\n")
|
|
387
|
+
pos = eol + 1
|
|
388
|
+
return False
|
|
389
|
+
|
|
390
|
+
def _redirect(self, fd: str | None, glued: bool) -> None:
|
|
391
|
+
s = self.s
|
|
392
|
+
if glued:
|
|
393
|
+
self.flush_word()
|
|
394
|
+
c = s[self.i]
|
|
395
|
+
nxt = s[self.i + 1:self.i + 2]
|
|
396
|
+
if c == "&":
|
|
397
|
+
op = "&>>" if s.startswith("&>>", self.i) else "&>"
|
|
398
|
+
elif c == ">":
|
|
399
|
+
if nxt == "(":
|
|
400
|
+
self.reason("process_substitution")
|
|
401
|
+
self.i += 2
|
|
402
|
+
return
|
|
403
|
+
op = {">": ">>", "&": ">&", "|": ">|"}.get(nxt, ">")
|
|
404
|
+
else:
|
|
405
|
+
if nxt == "(":
|
|
406
|
+
self.reason("process_substitution")
|
|
407
|
+
self.i += 2
|
|
408
|
+
return
|
|
409
|
+
if s.startswith("<<<", self.i):
|
|
410
|
+
self.reason("here_string")
|
|
411
|
+
op = "<<<"
|
|
412
|
+
elif s.startswith("<<-", self.i):
|
|
413
|
+
op = "<<-"
|
|
414
|
+
elif s.startswith("<<", self.i):
|
|
415
|
+
op = "<<"
|
|
416
|
+
else:
|
|
417
|
+
op = {"&": "<&", ">": "<>"}.get(nxt, "<")
|
|
418
|
+
self.i += len(op)
|
|
419
|
+
if self.pending is not None:
|
|
420
|
+
self.reason("missing_redirect_target")
|
|
421
|
+
# [fd, op, target, target_quoted, glued_before, after_words, body]
|
|
422
|
+
self.pending = [fd, op, None, False, glued, len(self.words), None]
|
|
423
|
+
self.redirs.append(self.pending)
|
|
424
|
+
|
|
425
|
+
def _read_heredocs(self) -> None:
|
|
426
|
+
s = self.s
|
|
427
|
+
for builder, strip, quoted in self.heredocs:
|
|
428
|
+
delim = builder[2]
|
|
429
|
+
body: list[str] = []
|
|
430
|
+
found = False
|
|
431
|
+
pos = self.i
|
|
432
|
+
while pos <= self.n:
|
|
433
|
+
eol = s.find("\n", pos)
|
|
434
|
+
end = self.n if eol < 0 else eol
|
|
435
|
+
line = s[pos:end]
|
|
436
|
+
if (line.lstrip("\t") if strip else line) == delim:
|
|
437
|
+
found = True
|
|
438
|
+
pos = self.n if eol < 0 else eol + 1
|
|
439
|
+
break
|
|
440
|
+
if eol < 0:
|
|
441
|
+
break
|
|
442
|
+
body.append(line + "\n")
|
|
443
|
+
pos = eol + 1
|
|
444
|
+
if not found:
|
|
445
|
+
self.reason("unclosed_heredoc")
|
|
446
|
+
self.i = self.n
|
|
447
|
+
self.heredocs = []
|
|
448
|
+
return
|
|
449
|
+
text = "".join(body)
|
|
450
|
+
builder[6] = text
|
|
451
|
+
if not quoted and ("$" in text or "`" in text):
|
|
452
|
+
self.reason("heredoc_expansion")
|
|
453
|
+
self.i = pos
|
|
454
|
+
self.heredocs = []
|
|
455
|
+
|
|
456
|
+
def _finish(self) -> None:
|
|
457
|
+
self.flush_word()
|
|
458
|
+
if self.pending is not None:
|
|
459
|
+
self.reason("missing_redirect_target")
|
|
460
|
+
self.pending = None
|
|
461
|
+
if self.heredocs:
|
|
462
|
+
self.reason("unclosed_heredoc")
|
|
463
|
+
if self.words or self.redirs:
|
|
464
|
+
self.segs.append({"words": self.words, "redirs": self.redirs, "sep": None})
|
|
465
|
+
elif self.segs:
|
|
466
|
+
last = self.segs[-1]["sep"]
|
|
467
|
+
if last in ("&&", "||", "|", "|&"):
|
|
468
|
+
self.reason("dangling_operator", len(self.segs) - 1)
|
|
469
|
+
elif last in (";", "&"):
|
|
470
|
+
self.trailing_op = last
|
|
471
|
+
self.segs[-1]["sep"] = None
|
|
472
|
+
|
|
473
|
+
def result(self) -> Lexed:
|
|
474
|
+
segments = []
|
|
475
|
+
for index, seg in enumerate(self.segs):
|
|
476
|
+
words = tuple(seg["words"])
|
|
477
|
+
if words:
|
|
478
|
+
first = words[0]
|
|
479
|
+
if first.text in _FIRST_WORD_OPAQUE or (
|
|
480
|
+
"=" in first.raw and _ASSIGN_RE.match(first.raw)
|
|
481
|
+
):
|
|
482
|
+
self.reason("first_word", index)
|
|
483
|
+
segments.append(Segment(
|
|
484
|
+
words,
|
|
485
|
+
tuple(Redirect(*r) for r in seg["redirs"]),
|
|
486
|
+
seg["sep"],
|
|
487
|
+
))
|
|
488
|
+
return Lexed(
|
|
489
|
+
tuple(segments), tuple(self.reasons), self.trailing_op, tuple(self.reason_at),
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
|
|
493
|
+
def lex(command: str, *, commit_message_form: bool = False) -> Lexed:
|
|
494
|
+
"""Lex the FULL command text (docs/DESIGN.md 4.13.1 Tokenization, C-7).
|
|
495
|
+
commit_message_form places the one literal `$(cat <<'DELIM' ... )`
|
|
496
|
+
commit-message substitution as a single word (commit grammar only)."""
|
|
497
|
+
lexer = _Lexer(command, commit_message_form)
|
|
498
|
+
lexer.scan()
|
|
499
|
+
return lexer.result()
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
# --- the closed read-only grammar (docs/DESIGN.md 4.13.1, C-8) -----------------
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
@dataclass(frozen=True)
|
|
506
|
+
class _Spec:
|
|
507
|
+
"""One program's option table: short options by character, long options
|
|
508
|
+
by name, each with its arity (0, 1, or 2 arguments; an argument may be
|
|
509
|
+
attached or separate). Anything outside the table is write-capable."""
|
|
510
|
+
|
|
511
|
+
short: dict
|
|
512
|
+
long: dict
|
|
513
|
+
exact: frozenset = frozenset()
|
|
514
|
+
long_optional: frozenset = frozenset()
|
|
515
|
+
numeric: bool = False
|
|
516
|
+
lenient: bool = False
|
|
517
|
+
max_operands: int | None = None
|
|
518
|
+
required: str | None = None
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def _flags(chars: str, **extra: int) -> dict:
|
|
522
|
+
table = {c: 0 for c in chars}
|
|
523
|
+
table.update(extra)
|
|
524
|
+
return table
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
_SPECS: dict[str, _Spec] = {
|
|
528
|
+
"cat": _Spec(_flags("nbsvetA"), {}),
|
|
529
|
+
"head": _Spec(_flags("", n=1, c=1), {}, numeric=True),
|
|
530
|
+
"tail": _Spec(_flags("fF", n=1, c=1), {}, numeric=True),
|
|
531
|
+
"nl": _Spec(_flags("p", b=1, n=1, w=1, s=1, v=1, i=1, l=1), {}),
|
|
532
|
+
"wc": _Spec(_flags("lwcmL"), {}),
|
|
533
|
+
"ls": _Spec(_flags("1aAlhRtrSdFGinops"), {}, exact=frozenset({"--color=never"})),
|
|
534
|
+
"pwd": _Spec(_flags("LP"), {}, max_operands=0),
|
|
535
|
+
"echo": _Spec(_flags("neE"), {}, lenient=True),
|
|
536
|
+
"printf": _Spec({}, {}),
|
|
537
|
+
"which": _Spec(_flags("as"), {}),
|
|
538
|
+
"stat": _Spec(_flags("L", f=1, c=1), {"--format": 1}),
|
|
539
|
+
"file": _Spec(_flags("biL"), {"--mime": 0}),
|
|
540
|
+
"du": _Spec(_flags("shkmac", d=1), {}),
|
|
541
|
+
"df": _Spec(_flags("hk"), {}),
|
|
542
|
+
"cut": _Spec(_flags("", d=1, f=1, c=1, b=1), {}),
|
|
543
|
+
"tr": _Spec(_flags("dsc"), {}),
|
|
544
|
+
"basename": _Spec(_flags("a", s=1), {}),
|
|
545
|
+
"dirname": _Spec({}, {}),
|
|
546
|
+
"realpath": _Spec(_flags("qems"), {}),
|
|
547
|
+
"grep": _Spec(
|
|
548
|
+
_flags("rRnilLcvwxEFohHqs", A=1, B=1, C=1, e=1, m=1),
|
|
549
|
+
{"--include": 1, "--exclude": 1, "--exclude-dir": 1},
|
|
550
|
+
exact=frozenset({"--color=never"}),
|
|
551
|
+
),
|
|
552
|
+
"rg": _Spec(
|
|
553
|
+
_flags("FisSwxvclLnNouU", e=1, f=1, A=1, B=1, C=1, g=1, t=1, T=1, m=1),
|
|
554
|
+
{
|
|
555
|
+
"--type": 1, "--max-depth": 1, "--hidden": 0, "--no-ignore": 0, "--files": 0,
|
|
556
|
+
"--files-with-matches": 0, "--count": 0, "--json": 0, "--no-config": 0,
|
|
557
|
+
},
|
|
558
|
+
required="--no-config",
|
|
559
|
+
),
|
|
560
|
+
"diff": _Spec(
|
|
561
|
+
_flags("ucqrNwbBis", U=1), {"--brief": 0}, long_optional=frozenset({"--unified"})
|
|
562
|
+
),
|
|
563
|
+
"cmp": _Spec(_flags("ls"), {}),
|
|
564
|
+
"jq": _Spec(_flags("rceSnsM"), {"--arg": 2, "--argjson": 2}),
|
|
565
|
+
"sed": _Spec(_flags("nEr", e=1), {}),
|
|
566
|
+
}
|
|
567
|
+
_FIND_TESTS_1 = frozenset(
|
|
568
|
+
{"-name", "-iname", "-path", "-ipath", "-regex", "-type", "-maxdepth", "-mindepth",
|
|
569
|
+
"-newer", "-mtime", "-mmin", "-size", "-perm"}
|
|
570
|
+
)
|
|
571
|
+
_FIND_FLAGS_0 = frozenset(
|
|
572
|
+
{"-empty", "-prune", "-print", "-print0", "-not", "!", "-o", "-a", "(", ")"}
|
|
573
|
+
)
|
|
574
|
+
|
|
575
|
+
READ_ONLY_PROGRAMS: tuple[str, ...] = tuple(sorted([*_SPECS, "find"]))
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _parse_args(spec: _Spec, args: list[str], *, posix: bool = False):
|
|
579
|
+
"""(options, operands) or None when an option is outside the table or
|
|
580
|
+
lacks its argument. options is a list of (name, values). posix stops the
|
|
581
|
+
options at the first operand (getopt, what macOS runs): every later word
|
|
582
|
+
is an operand as written, and a `--` before it is recorded as an option
|
|
583
|
+
named `--`."""
|
|
584
|
+
opts: list[tuple[str, tuple[str, ...]]] = []
|
|
585
|
+
operands: list[str] = []
|
|
586
|
+
i = 0
|
|
587
|
+
n = len(args)
|
|
588
|
+
while i < n:
|
|
589
|
+
a = args[i]
|
|
590
|
+
if a == "--":
|
|
591
|
+
if posix:
|
|
592
|
+
opts.append(("--", ()))
|
|
593
|
+
operands.extend(args[i + 1:])
|
|
594
|
+
break
|
|
595
|
+
if a.startswith("--"):
|
|
596
|
+
if a in spec.exact:
|
|
597
|
+
opts.append((a, ()))
|
|
598
|
+
i += 1
|
|
599
|
+
continue
|
|
600
|
+
name, eq, val = a.partition("=")
|
|
601
|
+
if name in spec.long_optional:
|
|
602
|
+
opts.append((name, (val,) if eq else ()))
|
|
603
|
+
i += 1
|
|
604
|
+
continue
|
|
605
|
+
arity = spec.long.get(name)
|
|
606
|
+
if arity is None:
|
|
607
|
+
if spec.lenient:
|
|
608
|
+
operands.append(a)
|
|
609
|
+
i += 1
|
|
610
|
+
continue
|
|
611
|
+
return None
|
|
612
|
+
if eq:
|
|
613
|
+
if arity != 1:
|
|
614
|
+
return None
|
|
615
|
+
opts.append((name, (val,)))
|
|
616
|
+
i += 1
|
|
617
|
+
continue
|
|
618
|
+
values = args[i + 1:i + 1 + arity]
|
|
619
|
+
if len(values) < arity:
|
|
620
|
+
return None
|
|
621
|
+
opts.append((name, tuple(values)))
|
|
622
|
+
i += 1 + arity
|
|
623
|
+
continue
|
|
624
|
+
if a.startswith("-") and len(a) > 1:
|
|
625
|
+
if spec.numeric and a[1:].isdigit():
|
|
626
|
+
opts.append(("-NUM", (a[1:],)))
|
|
627
|
+
i += 1
|
|
628
|
+
continue
|
|
629
|
+
consumed = 0
|
|
630
|
+
bad = False
|
|
631
|
+
j = 1
|
|
632
|
+
while j < len(a):
|
|
633
|
+
arity = spec.short.get(a[j])
|
|
634
|
+
if arity is None:
|
|
635
|
+
bad = True
|
|
636
|
+
break
|
|
637
|
+
if arity == 0:
|
|
638
|
+
j += 1
|
|
639
|
+
continue
|
|
640
|
+
rest = a[j + 1:]
|
|
641
|
+
need = arity - 1 if rest else arity
|
|
642
|
+
values = args[i + 1:i + 1 + need]
|
|
643
|
+
if len(values) < need:
|
|
644
|
+
return None
|
|
645
|
+
opts.append(("-" + a[j], ((rest,) if rest else ()) + tuple(values)))
|
|
646
|
+
consumed = need
|
|
647
|
+
break
|
|
648
|
+
if bad:
|
|
649
|
+
if spec.lenient:
|
|
650
|
+
operands.append(a)
|
|
651
|
+
i += 1
|
|
652
|
+
continue
|
|
653
|
+
return None
|
|
654
|
+
i += 1 + consumed
|
|
655
|
+
continue
|
|
656
|
+
if posix:
|
|
657
|
+
operands.extend(args[i:])
|
|
658
|
+
break
|
|
659
|
+
operands.append(a)
|
|
660
|
+
i += 1
|
|
661
|
+
return opts, operands
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def _find_ok(args: list[str]) -> bool:
|
|
665
|
+
i = 0
|
|
666
|
+
n = len(args)
|
|
667
|
+
while i < n and not (args[i].startswith("-") or args[i] in ("!", "(", ")")):
|
|
668
|
+
i += 1
|
|
669
|
+
while i < n:
|
|
670
|
+
word = args[i]
|
|
671
|
+
if word in _FIND_FLAGS_0:
|
|
672
|
+
i += 1
|
|
673
|
+
elif word in _FIND_TESTS_1:
|
|
674
|
+
if i + 1 >= n:
|
|
675
|
+
return False
|
|
676
|
+
i += 2
|
|
677
|
+
else:
|
|
678
|
+
return False
|
|
679
|
+
return True
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _sed_ok(args: list[str]) -> bool:
|
|
683
|
+
parsed = _parse_args(_SPECS["sed"], args)
|
|
684
|
+
if parsed is None:
|
|
685
|
+
return False
|
|
686
|
+
opts, operands = parsed
|
|
687
|
+
scripts = [values[-1] if values else "" for name, values in opts if name == "-e"]
|
|
688
|
+
if not scripts:
|
|
689
|
+
if not operands:
|
|
690
|
+
return False
|
|
691
|
+
scripts = [operands[0]]
|
|
692
|
+
return all(sed_script_ok(script) for script in scripts)
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _sed_regex_end(script: str, i: int, delim: str, mode: int) -> int:
|
|
696
|
+
"""Index just past the closing delimiter of a regex or replacement that
|
|
697
|
+
starts at i, or -1. mode 0: backslash escapes everywhere, no bracket
|
|
698
|
+
expressions; mode 1 and 2 honor bracket expressions, with a backslash
|
|
699
|
+
inside one escaping (1) or literal (2)."""
|
|
700
|
+
n = len(script)
|
|
701
|
+
while i < n:
|
|
702
|
+
c = script[i]
|
|
703
|
+
if c == "\\":
|
|
704
|
+
i += 2
|
|
705
|
+
continue
|
|
706
|
+
if c == delim:
|
|
707
|
+
return i + 1
|
|
708
|
+
if mode and c == "[":
|
|
709
|
+
i += 1
|
|
710
|
+
if i < n and script[i] == "^":
|
|
711
|
+
i += 1
|
|
712
|
+
if i < n and script[i] == "]":
|
|
713
|
+
i += 1
|
|
714
|
+
while i < n and script[i] != "]":
|
|
715
|
+
if script[i] == "[" and i + 1 < n and script[i + 1] in ":.=":
|
|
716
|
+
close = script.find(script[i + 1] + "]", i + 2)
|
|
717
|
+
if close < 0:
|
|
718
|
+
return -1
|
|
719
|
+
i = close + 2
|
|
720
|
+
continue
|
|
721
|
+
if mode == 1 and script[i] == "\\":
|
|
722
|
+
i += 1
|
|
723
|
+
i += 1
|
|
724
|
+
if i >= n:
|
|
725
|
+
return -1
|
|
726
|
+
i += 1
|
|
727
|
+
continue
|
|
728
|
+
i += 1
|
|
729
|
+
return -1
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _sed_address(script: str, i: int, mode: int, second: bool) -> int:
|
|
733
|
+
"""Index after one address starting at i (i itself when there is none),
|
|
734
|
+
or -1 when it is malformed."""
|
|
735
|
+
n = len(script)
|
|
736
|
+
if i >= n:
|
|
737
|
+
return i
|
|
738
|
+
c = script[i]
|
|
739
|
+
if c.isdigit():
|
|
740
|
+
while i < n and script[i].isdigit():
|
|
741
|
+
i += 1
|
|
742
|
+
if not second and i < n and script[i] == "~":
|
|
743
|
+
i += 1
|
|
744
|
+
if i >= n or not script[i].isdigit():
|
|
745
|
+
return -1
|
|
746
|
+
while i < n and script[i].isdigit():
|
|
747
|
+
i += 1
|
|
748
|
+
return i
|
|
749
|
+
if c == "$":
|
|
750
|
+
return i + 1
|
|
751
|
+
if second and c in "+~":
|
|
752
|
+
i += 1
|
|
753
|
+
if i >= n or not script[i].isdigit():
|
|
754
|
+
return -1
|
|
755
|
+
while i < n and script[i].isdigit():
|
|
756
|
+
i += 1
|
|
757
|
+
return i
|
|
758
|
+
if c == "/" or c == "\\":
|
|
759
|
+
if c == "\\":
|
|
760
|
+
if i + 1 >= n or script[i + 1] in "\\\n":
|
|
761
|
+
return -1
|
|
762
|
+
delim = script[i + 1]
|
|
763
|
+
start = i + 2
|
|
764
|
+
else:
|
|
765
|
+
delim = "/"
|
|
766
|
+
start = i + 1
|
|
767
|
+
end = _sed_regex_end(script, start, delim, mode)
|
|
768
|
+
if end < 0:
|
|
769
|
+
return -1
|
|
770
|
+
if end < n and script[end] == "I":
|
|
771
|
+
end += 1
|
|
772
|
+
return end
|
|
773
|
+
return i
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def _sed_parse(script: str, mode: int):
|
|
777
|
+
"""The (offset, command, flags) list of a script, or None when it is not
|
|
778
|
+
inside the closed sed grammar."""
|
|
779
|
+
n = len(script)
|
|
780
|
+
i = 0
|
|
781
|
+
out: list[tuple[int, str, str]] = []
|
|
782
|
+
while True:
|
|
783
|
+
while i < n and script[i] in " \t\n;":
|
|
784
|
+
i += 1
|
|
785
|
+
if i >= n:
|
|
786
|
+
return out
|
|
787
|
+
start = i
|
|
788
|
+
j = _sed_address(script, i, mode, False)
|
|
789
|
+
if j < 0:
|
|
790
|
+
return None
|
|
791
|
+
if j > i:
|
|
792
|
+
i = j
|
|
793
|
+
if i < n and script[i] == ",":
|
|
794
|
+
k = _sed_address(script, i + 1, mode, True)
|
|
795
|
+
if k < 0 or k == i + 1:
|
|
796
|
+
return None
|
|
797
|
+
i = k
|
|
798
|
+
while i < n and script[i] in " \t":
|
|
799
|
+
i += 1
|
|
800
|
+
if i < n and script[i] == "!":
|
|
801
|
+
i += 1
|
|
802
|
+
while i < n and script[i] in " \t":
|
|
803
|
+
i += 1
|
|
804
|
+
if i >= n:
|
|
805
|
+
return None
|
|
806
|
+
cmd = script[i]
|
|
807
|
+
i += 1
|
|
808
|
+
flags = ""
|
|
809
|
+
if cmd in "pdn=N":
|
|
810
|
+
pass
|
|
811
|
+
elif cmd == "q":
|
|
812
|
+
while i < n and script[i].isdigit():
|
|
813
|
+
flags += script[i]
|
|
814
|
+
i += 1
|
|
815
|
+
elif cmd == "s":
|
|
816
|
+
if i >= n or script[i] in "\\\n":
|
|
817
|
+
return None
|
|
818
|
+
delim = script[i]
|
|
819
|
+
end = _sed_regex_end(script, i + 1, delim, mode)
|
|
820
|
+
if end < 0:
|
|
821
|
+
return None
|
|
822
|
+
end = _sed_regex_end(script, end, delim, 0)
|
|
823
|
+
if end < 0:
|
|
824
|
+
return None
|
|
825
|
+
i = end
|
|
826
|
+
while i < n and script[i] in "gpIi0123456789":
|
|
827
|
+
flags += script[i]
|
|
828
|
+
i += 1
|
|
829
|
+
else:
|
|
830
|
+
return None
|
|
831
|
+
while i < n and script[i] in " \t":
|
|
832
|
+
i += 1
|
|
833
|
+
if i < n and script[i] not in ";\n":
|
|
834
|
+
return None
|
|
835
|
+
out.append((start, cmd, flags))
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def sed_script_ok(script: str) -> bool:
|
|
839
|
+
"""A script of addresses plus p d q = n N and s (flags g p I i and
|
|
840
|
+
digits). A script whose structure depends on how a sed treats a
|
|
841
|
+
delimiter inside a bracket expression is rejected: it must parse the
|
|
842
|
+
same under every reading."""
|
|
843
|
+
readings = [_sed_parse(script, mode) for mode in (0, 1, 2)]
|
|
844
|
+
return readings[0] is not None and readings[0] == readings[1] == readings[2]
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _redirects_read_only(segment: Segment) -> bool:
|
|
848
|
+
for r in segment.redirects:
|
|
849
|
+
if r.glued_before:
|
|
850
|
+
return False
|
|
851
|
+
if r.op == ">&" and r.fd == "2" and r.target == "1":
|
|
852
|
+
continue
|
|
853
|
+
if r.target == "/dev/null" and (
|
|
854
|
+
(r.op in (">", ">>") and r.fd in (None, "1", "2")) or (r.op == "&>" and r.fd is None)
|
|
855
|
+
):
|
|
856
|
+
continue
|
|
857
|
+
return False
|
|
858
|
+
return True
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
def _segment_read_only(segment: Segment) -> bool:
|
|
862
|
+
if not segment.words or not _redirects_read_only(segment):
|
|
863
|
+
return False
|
|
864
|
+
program = segment.words[0].text
|
|
865
|
+
args = [w.text for w in segment.words[1:]]
|
|
866
|
+
if program == "find":
|
|
867
|
+
return _find_ok(args)
|
|
868
|
+
spec = _SPECS.get(program)
|
|
869
|
+
if spec is None:
|
|
870
|
+
return False
|
|
871
|
+
if program == "sed":
|
|
872
|
+
return _sed_ok(args)
|
|
873
|
+
parsed = _parse_args(spec, args)
|
|
874
|
+
if parsed is None:
|
|
875
|
+
return False
|
|
876
|
+
opts, operands = parsed
|
|
877
|
+
if spec.max_operands is not None and len(operands) > spec.max_operands:
|
|
878
|
+
return False
|
|
879
|
+
if spec.required is not None and not any(name == spec.required for name, _ in opts):
|
|
880
|
+
return False
|
|
881
|
+
return True
|
|
882
|
+
|
|
883
|
+
|
|
884
|
+
def cd_operand(segment: Segment) -> str | None:
|
|
885
|
+
"""DIR of a segment that is exactly `cd DIR` with one plain word that
|
|
886
|
+
does not start with `-`; None for every other form (L3)."""
|
|
887
|
+
if (
|
|
888
|
+
len(segment.words) == 2
|
|
889
|
+
and not segment.redirects
|
|
890
|
+
and segment.words[0].text == "cd"
|
|
891
|
+
and segment.words[1].text
|
|
892
|
+
and not segment.words[1].text.startswith("-")
|
|
893
|
+
):
|
|
894
|
+
return segment.words[1].text
|
|
895
|
+
return None
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def is_read_only(lexed: Lexed) -> bool:
|
|
899
|
+
"""The read-only grammar recognizes no write in the recorded command: the
|
|
900
|
+
command is placed and every segment is read-only, after at most one
|
|
901
|
+
leading `cd DIR &&`."""
|
|
902
|
+
if not lexed.placed or not lexed.segments:
|
|
903
|
+
return False
|
|
904
|
+
segments = list(lexed.segments)
|
|
905
|
+
if cd_operand(segments[0]) is not None:
|
|
906
|
+
if segments[0].sep_after != "&&" or len(segments) == 1:
|
|
907
|
+
return False
|
|
908
|
+
segments = segments[1:]
|
|
909
|
+
return all(_segment_read_only(seg) for seg in segments)
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
@dataclass(frozen=True)
|
|
913
|
+
class WriteTarget:
|
|
914
|
+
path: str | None
|
|
915
|
+
repo: str | None
|
|
916
|
+
kind: str
|
|
917
|
+
truncated: bool = False
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
@dataclass(frozen=True)
|
|
921
|
+
class WriteMatch:
|
|
922
|
+
form: str
|
|
923
|
+
targets: tuple[WriteTarget, ...]
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
_PATH_LIMIT = 1024
|
|
927
|
+
_SUFFIX_RE = re.compile(r"[A-Za-z0-9._~-]+\Z")
|
|
928
|
+
_TOUCH_SPEC = _Spec(_flags("cam", t=1, d=1, r=1), {})
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def normalize_path(path: str) -> str:
|
|
932
|
+
"""The lexical spelling of a path (D7): `os.path.normpath`, with the
|
|
933
|
+
exactly-two leading slashes it keeps collapsed to one."""
|
|
934
|
+
path = os.path.normpath(path)
|
|
935
|
+
if path.startswith("//"):
|
|
936
|
+
return "/" + path.lstrip("/")
|
|
937
|
+
return path
|
|
938
|
+
|
|
939
|
+
|
|
940
|
+
_normalize_base = functools.lru_cache(maxsize=512)(normalize_path)
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
def _base(cwd: str | None) -> str | None:
|
|
944
|
+
"""A call cwd is a candidate base only when it is absolute (L9). The few
|
|
945
|
+
distinct cwds of a run are normalized once each."""
|
|
946
|
+
if isinstance(cwd, str) and cwd.startswith("/"):
|
|
947
|
+
return _normalize_base(cwd)
|
|
948
|
+
return None
|
|
949
|
+
|
|
950
|
+
|
|
951
|
+
def resolve_target(base: str | None, target: str) -> tuple[str | None, bool] | None:
|
|
952
|
+
"""(path, truncated) for a target, or None when the text cannot place it:
|
|
953
|
+
an empty target, or a relative one with no absolute base (D7)."""
|
|
954
|
+
if not target:
|
|
955
|
+
return None
|
|
956
|
+
if target.startswith("/"):
|
|
957
|
+
path = normalize_path(target)
|
|
958
|
+
elif base is not None:
|
|
959
|
+
path = normalize_path(os.path.join(base, target))
|
|
960
|
+
else:
|
|
961
|
+
return None
|
|
962
|
+
if len(path) > _PATH_LIMIT:
|
|
963
|
+
return None, True
|
|
964
|
+
return path, False
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
def _targets(base: str | None, entries: list[tuple[str, str]]) -> tuple[WriteTarget, ...] | None:
|
|
968
|
+
out: list[WriteTarget] = []
|
|
969
|
+
for kind, target in entries:
|
|
970
|
+
resolved = resolve_target(base, target)
|
|
971
|
+
if resolved is None:
|
|
972
|
+
return None
|
|
973
|
+
out.append(WriteTarget(resolved[0], base, kind, resolved[1]))
|
|
974
|
+
return tuple(out)
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _plain_segment(segment: Segment) -> Segment:
|
|
978
|
+
return Segment(segment.words, (), segment.sep_after)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
def _write_redirect(seg: Segment, base: str | None) -> WriteMatch | None:
|
|
982
|
+
if len(seg.redirects) != 1 or not seg.words:
|
|
983
|
+
return None
|
|
984
|
+
r = seg.redirects[0]
|
|
985
|
+
if (
|
|
986
|
+
r.op not in (">", ">>")
|
|
987
|
+
or r.fd not in (None, "1")
|
|
988
|
+
or r.glued_before
|
|
989
|
+
or r.after_words != len(seg.words)
|
|
990
|
+
or not r.target
|
|
991
|
+
):
|
|
992
|
+
return None
|
|
993
|
+
if not _segment_read_only(_plain_segment(seg)):
|
|
994
|
+
return None
|
|
995
|
+
targets = _targets(base, [("redirect", r.target)])
|
|
996
|
+
return WriteMatch("redirect", targets) if targets else None
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _write_tee(segments: tuple[Segment, ...], base: str | None) -> WriteMatch | None:
|
|
1000
|
+
if len(segments) < 2 or any(seg.sep_after != "|" for seg in segments[:-1]):
|
|
1001
|
+
return None
|
|
1002
|
+
last = segments[-1]
|
|
1003
|
+
if last.redirects:
|
|
1004
|
+
return None
|
|
1005
|
+
words = [w.text for w in last.words]
|
|
1006
|
+
if words[:1] != ["tee"]:
|
|
1007
|
+
return None
|
|
1008
|
+
rest = words[1:]
|
|
1009
|
+
if rest[:1] == ["-a"]:
|
|
1010
|
+
rest = rest[1:]
|
|
1011
|
+
if len(rest) != 1 or rest[0].startswith("-"):
|
|
1012
|
+
return None
|
|
1013
|
+
if not all(_segment_read_only(seg) for seg in segments[:-1]):
|
|
1014
|
+
return None
|
|
1015
|
+
targets = _targets(base, [("tee", rest[0])])
|
|
1016
|
+
return WriteMatch("tee", targets) if targets else None
|
|
1017
|
+
|
|
1018
|
+
|
|
1019
|
+
def _write_sed_i(seg: Segment, base: str | None) -> WriteMatch | None:
|
|
1020
|
+
if seg.redirects:
|
|
1021
|
+
return None
|
|
1022
|
+
args = [w.text for w in seg.words[1:]]
|
|
1023
|
+
if not args:
|
|
1024
|
+
return None
|
|
1025
|
+
first = args[0]
|
|
1026
|
+
suffix = ""
|
|
1027
|
+
if first == "-i":
|
|
1028
|
+
rest = args[1:]
|
|
1029
|
+
if len(rest) == 3:
|
|
1030
|
+
suffix = rest[0]
|
|
1031
|
+
if suffix and not (suffix.startswith(".") and _SUFFIX_RE.match(suffix)):
|
|
1032
|
+
return None
|
|
1033
|
+
rest = rest[1:]
|
|
1034
|
+
elif len(rest) != 2:
|
|
1035
|
+
return None
|
|
1036
|
+
elif first.startswith("-i") and not first.startswith("--"):
|
|
1037
|
+
suffix = first[2:]
|
|
1038
|
+
if not _SUFFIX_RE.match(suffix):
|
|
1039
|
+
return None
|
|
1040
|
+
rest = args[1:]
|
|
1041
|
+
if len(rest) != 2:
|
|
1042
|
+
return None
|
|
1043
|
+
else:
|
|
1044
|
+
return None
|
|
1045
|
+
script, target = rest
|
|
1046
|
+
if script.startswith("-") or target.startswith("-") or not sed_script_ok(script):
|
|
1047
|
+
return None
|
|
1048
|
+
entries = [("sed_i", target)]
|
|
1049
|
+
resolved = resolve_target(base, target)
|
|
1050
|
+
if resolved is None:
|
|
1051
|
+
return None
|
|
1052
|
+
targets = list(_targets(base, entries) or ())
|
|
1053
|
+
if not targets:
|
|
1054
|
+
return None
|
|
1055
|
+
if suffix:
|
|
1056
|
+
path, truncated = resolved
|
|
1057
|
+
backup = None if path is None else path + suffix
|
|
1058
|
+
if backup is not None and len(backup) > _PATH_LIMIT:
|
|
1059
|
+
backup, truncated = None, True
|
|
1060
|
+
targets.append(WriteTarget(backup, base, "sed_backup", truncated))
|
|
1061
|
+
return WriteMatch("sed_i", tuple(targets))
|
|
1062
|
+
|
|
1063
|
+
|
|
1064
|
+
def _write_touch(seg: Segment, base: str | None) -> WriteMatch | None:
|
|
1065
|
+
if seg.redirects:
|
|
1066
|
+
return None
|
|
1067
|
+
parsed = _parse_args(_TOUCH_SPEC, [w.text for w in seg.words[1:]], posix=True)
|
|
1068
|
+
if parsed is None:
|
|
1069
|
+
return None
|
|
1070
|
+
opts, operands = parsed
|
|
1071
|
+
# S4: after the first operand every word is a file name on macOS, so an
|
|
1072
|
+
# option-looking one would make the claimed target set incomplete.
|
|
1073
|
+
if not any(name == "--" for name, _ in opts) and any(
|
|
1074
|
+
word.startswith("-") and len(word) > 1 for word in operands[1:]
|
|
1075
|
+
):
|
|
1076
|
+
return None
|
|
1077
|
+
if sum(1 for name, _ in opts if name in ("-t", "-d", "-r")) > 1 or not operands:
|
|
1078
|
+
return None
|
|
1079
|
+
targets = _targets(base, [("touch", operand) for operand in operands])
|
|
1080
|
+
return WriteMatch("touch", targets) if targets else None
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def _write_cat_heredoc(seg: Segment, base: str | None) -> WriteMatch | None:
|
|
1084
|
+
if [w.text for w in seg.words] != ["cat"] or len(seg.redirects) != 2:
|
|
1085
|
+
return None
|
|
1086
|
+
out, doc = seg.redirects
|
|
1087
|
+
if (
|
|
1088
|
+
out.op != ">" or out.fd is not None or out.glued_before or out.after_words != 1
|
|
1089
|
+
or not out.target
|
|
1090
|
+
or doc.op not in ("<<", "<<-") or doc.fd is not None or doc.body is None
|
|
1091
|
+
):
|
|
1092
|
+
return None
|
|
1093
|
+
targets = _targets(base, [("heredoc", out.target)])
|
|
1094
|
+
return WriteMatch("heredoc", targets) if targets else None
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def match_write_grammar(lexed: Lexed, cwd: str | None) -> WriteMatch | None:
|
|
1098
|
+
"""The tiny write grammar (C-12): one of five exact forms, the producer
|
|
1099
|
+
proven read-only, every path named resolved lexically. cp and mv are
|
|
1100
|
+
never matched. None means the command is not in the grammar."""
|
|
1101
|
+
if not lexed.placed or not lexed.segments or lexed.trailing_op is not None:
|
|
1102
|
+
return None
|
|
1103
|
+
base = _base(cwd)
|
|
1104
|
+
segments = lexed.segments
|
|
1105
|
+
if len(segments) >= 2:
|
|
1106
|
+
return _write_tee(segments, base)
|
|
1107
|
+
seg = segments[0]
|
|
1108
|
+
if not seg.words:
|
|
1109
|
+
return None
|
|
1110
|
+
program = seg.words[0].text
|
|
1111
|
+
if program == "sed":
|
|
1112
|
+
return _write_sed_i(seg, base) or _write_redirect(seg, base)
|
|
1113
|
+
if program == "touch":
|
|
1114
|
+
return _write_touch(seg, base)
|
|
1115
|
+
if program == "cat" and len(seg.redirects) == 2:
|
|
1116
|
+
return _write_cat_heredoc(seg, base)
|
|
1117
|
+
return _write_redirect(seg, base)
|
|
1118
|
+
|
|
1119
|
+
|
|
1120
|
+
@dataclass(frozen=True)
|
|
1121
|
+
class GitPrefix:
|
|
1122
|
+
subcommand: str
|
|
1123
|
+
args: tuple[str, ...]
|
|
1124
|
+
c_dirs: tuple[str, ...]
|
|
1125
|
+
pins_location: bool
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
def segment_bases(lexed: Lexed, cwd: str | None) -> tuple[str | None, ...]:
|
|
1129
|
+
"""The candidate base directory in force when each segment starts (L9).
|
|
1130
|
+
The base is the call cwd when absolute; a leading `cd DIR &&` with an
|
|
1131
|
+
absolute plain DIR replaces it for the later segments; every other cd
|
|
1132
|
+
shape (relative, listed forms, joined by something other than `&&`, a
|
|
1133
|
+
later cd, pushd, popd) makes it unknown from then on. An opaque command
|
|
1134
|
+
has no base at all."""
|
|
1135
|
+
if not lexed.placed:
|
|
1136
|
+
return (None,) * len(lexed.segments)
|
|
1137
|
+
base = _base(cwd)
|
|
1138
|
+
out: list[str | None] = []
|
|
1139
|
+
for index, seg in enumerate(lexed.segments):
|
|
1140
|
+
out.append(base)
|
|
1141
|
+
if not seg.words or seg.words[0].text not in ("cd", "pushd", "popd"):
|
|
1142
|
+
continue
|
|
1143
|
+
operand = cd_operand(seg)
|
|
1144
|
+
if (
|
|
1145
|
+
index == 0
|
|
1146
|
+
and operand is not None
|
|
1147
|
+
and seg.sep_after == "&&"
|
|
1148
|
+
and operand.startswith("/")
|
|
1149
|
+
):
|
|
1150
|
+
base = normalize_path(operand)
|
|
1151
|
+
else:
|
|
1152
|
+
base = None
|
|
1153
|
+
return tuple(out)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def git_prefix(words: list[str]) -> GitPrefix | None:
|
|
1157
|
+
"""The global options before a git subcommand, when they are only `-C
|
|
1158
|
+
DIR` (any number, in order), `-c K=V`, `--git-dir=...`, and
|
|
1159
|
+
`--work-tree=...`; None for any other option or a missing subcommand."""
|
|
1160
|
+
if not words or words[0] != "git":
|
|
1161
|
+
return None
|
|
1162
|
+
c_dirs: list[str] = []
|
|
1163
|
+
pins = False
|
|
1164
|
+
i = 1
|
|
1165
|
+
while i < len(words):
|
|
1166
|
+
word = words[i]
|
|
1167
|
+
if word == "-C":
|
|
1168
|
+
if i + 1 >= len(words):
|
|
1169
|
+
return None
|
|
1170
|
+
c_dirs.append(words[i + 1])
|
|
1171
|
+
i += 2
|
|
1172
|
+
elif word == "-c":
|
|
1173
|
+
if i + 1 >= len(words):
|
|
1174
|
+
return None
|
|
1175
|
+
i += 2
|
|
1176
|
+
elif word.startswith(("--git-dir=", "--work-tree=")):
|
|
1177
|
+
pins = True
|
|
1178
|
+
i += 1
|
|
1179
|
+
elif word.startswith("-"):
|
|
1180
|
+
return None
|
|
1181
|
+
else:
|
|
1182
|
+
return GitPrefix(word, tuple(words[i + 1:]), tuple(c_dirs), pins)
|
|
1183
|
+
return None
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
def git_repo(base: str | None, prefix: GitPrefix) -> str | None:
|
|
1187
|
+
"""The candidate repo of a git segment: the base with each -C applied in
|
|
1188
|
+
order; None when a relative -C meets an unknown base, or when
|
|
1189
|
+
--git-dir or --work-tree is present (L9)."""
|
|
1190
|
+
if prefix.pins_location:
|
|
1191
|
+
return None
|
|
1192
|
+
repo = base
|
|
1193
|
+
for directory in prefix.c_dirs:
|
|
1194
|
+
if not directory:
|
|
1195
|
+
continue
|
|
1196
|
+
if directory.startswith("/"):
|
|
1197
|
+
repo = normalize_path(directory)
|
|
1198
|
+
elif repo is not None:
|
|
1199
|
+
repo = normalize_path(os.path.join(repo, directory))
|
|
1200
|
+
return repo
|
|
1201
|
+
|
|
1202
|
+
|
|
1203
|
+
@dataclass(frozen=True)
|
|
1204
|
+
class CommitSegment:
|
|
1205
|
+
repo: str | None
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@dataclass(frozen=True)
|
|
1209
|
+
class CommitFacts:
|
|
1210
|
+
segments: tuple[CommitSegment, ...]
|
|
1211
|
+
|
|
1212
|
+
@property
|
|
1213
|
+
def is_commit(self) -> bool:
|
|
1214
|
+
return bool(self.segments)
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
# A1: the only call completions whose result can give a commit id.
|
|
1218
|
+
COMMIT_COMPLETIONS = ("completed_ok", "completed_error")
|
|
1219
|
+
_COMMIT_ID_RE = re.compile(r"(?m)^\[[^\]\s]+ ([0-9a-f]{7,40})\]")
|
|
1220
|
+
_MAX_COMMIT_IDS_PER_CALL = 20
|
|
1221
|
+
_REJECTED_COMMIT_LONG = ("--dry-run", "--quiet", "--porcelain", "--short", "--long")
|
|
1222
|
+
# FA6: the same set without the quiet form, for commit_command_ran, which still
|
|
1223
|
+
# excludes a dry run but recognizes a quiet commit.
|
|
1224
|
+
_DRY_RUN_COMMIT_LONG = ("--dry-run", "--porcelain", "--short", "--long")
|
|
1225
|
+
_OPTION_CLUSTER_RE = re.compile(r"-[A-Za-z]+\Z")
|
|
1226
|
+
_SHELL_WRAPPERS = ("bash", "sh")
|
|
1227
|
+
|
|
1228
|
+
|
|
1229
|
+
def _commit_flags_ok(args: tuple[str, ...], *, allow_quiet: bool = False) -> bool:
|
|
1230
|
+
"""False for a commit segment with a dry-run or quiet form: --dry-run,
|
|
1231
|
+
-q, --quiet, --porcelain, --short, --long, -z, an unambiguous
|
|
1232
|
+
abbreviation of a long one, or a short cluster containing q or z. Words
|
|
1233
|
+
after `--` are pathspecs. With allow_quiet (FA6's commit_command_ran) the
|
|
1234
|
+
quiet forms (-q, --quiet, a cluster with q) are accepted and every dry-run
|
|
1235
|
+
form is still rejected."""
|
|
1236
|
+
long_forms = _DRY_RUN_COMMIT_LONG if allow_quiet else _REJECTED_COMMIT_LONG
|
|
1237
|
+
rejected_short = "z" if allow_quiet else "qz"
|
|
1238
|
+
for word in args:
|
|
1239
|
+
if word == "--":
|
|
1240
|
+
break
|
|
1241
|
+
if word.startswith("--"):
|
|
1242
|
+
name = word.split("=", 1)[0]
|
|
1243
|
+
if len(name) >= 3 and any(r.startswith(name) for r in long_forms):
|
|
1244
|
+
return False
|
|
1245
|
+
elif _OPTION_CLUSTER_RE.match(word) and any(c in word for c in rejected_short):
|
|
1246
|
+
return False
|
|
1247
|
+
return True
|
|
1248
|
+
|
|
1249
|
+
|
|
1250
|
+
def _backgrounded_lists(lexed: Lexed) -> list[bool]:
|
|
1251
|
+
"""For each segment, whether the and-or list that contains it is ended by
|
|
1252
|
+
`&` (shell semantics: `&` applies to the whole `&&`/`||` list, pipelines
|
|
1253
|
+
included). A list ends at `;`, a newline, `&`, or the end of the command
|
|
1254
|
+
(A1)."""
|
|
1255
|
+
segments = lexed.segments
|
|
1256
|
+
flags = [False] * len(segments)
|
|
1257
|
+
start = 0
|
|
1258
|
+
for index, seg in enumerate(segments):
|
|
1259
|
+
last = index == len(segments) - 1
|
|
1260
|
+
if not (last or seg.sep_after in (";", "\n", "&")):
|
|
1261
|
+
continue
|
|
1262
|
+
background = seg.sep_after == "&" or (last and lexed.trailing_op == "&")
|
|
1263
|
+
for j in range(start, index + 1):
|
|
1264
|
+
flags[j] = background
|
|
1265
|
+
start = index + 1
|
|
1266
|
+
return flags
|
|
1267
|
+
|
|
1268
|
+
|
|
1269
|
+
def _commit_segments(
|
|
1270
|
+
lexed: Lexed, cwd: str | None, depth: int
|
|
1271
|
+
) -> list[tuple[CommitSegment, bool]] | None:
|
|
1272
|
+
"""The commit segments of one placed command, each with whether it is
|
|
1273
|
+
backgrounded in the text (A1), or None when the call can count no id:
|
|
1274
|
+
the text is not placed, a wrapper is deeper than one level or holds text
|
|
1275
|
+
that is not placed, or a commit segment carries a dry-run or quiet flag
|
|
1276
|
+
(poisoning: such a call counts no id at all)."""
|
|
1277
|
+
if not lexed.placed:
|
|
1278
|
+
return None
|
|
1279
|
+
found: list[tuple[CommitSegment, bool]] = []
|
|
1280
|
+
background = _backgrounded_lists(lexed)
|
|
1281
|
+
for seg, base, bg in zip(lexed.segments, segment_bases(lexed, cwd), background):
|
|
1282
|
+
words = [w.text for w in seg.words]
|
|
1283
|
+
if (
|
|
1284
|
+
len(words) == 3
|
|
1285
|
+
and not seg.redirects
|
|
1286
|
+
and words[0] in _SHELL_WRAPPERS
|
|
1287
|
+
and words[1] in ("-c", "-lc")
|
|
1288
|
+
):
|
|
1289
|
+
if depth >= 1:
|
|
1290
|
+
return None
|
|
1291
|
+
inner = _commit_segments(lex(words[2], commit_message_form=True), base, depth + 1)
|
|
1292
|
+
if inner is None:
|
|
1293
|
+
return None
|
|
1294
|
+
found.extend((commit, bg or inner_bg) for commit, inner_bg in inner)
|
|
1295
|
+
continue
|
|
1296
|
+
if words[:1] != ["git"]:
|
|
1297
|
+
continue
|
|
1298
|
+
prefix = git_prefix(words)
|
|
1299
|
+
if prefix is None or prefix.subcommand != "commit":
|
|
1300
|
+
continue
|
|
1301
|
+
if not _commit_flags_ok(prefix.args):
|
|
1302
|
+
return None
|
|
1303
|
+
found.append((CommitSegment(git_repo(base, prefix)), bg))
|
|
1304
|
+
return found
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
def commit_command_ran(words: list[str] | tuple[str, ...]) -> bool:
|
|
1308
|
+
"""FA6, the grammar fact (DESIGN 4.13.1): whether one segment's words are a
|
|
1309
|
+
`git commit` command, quiet forms included (-q, --quiet, a short cluster with
|
|
1310
|
+
q) and every dry-run form (--dry-run, --porcelain, --short, --long, -z, their
|
|
1311
|
+
abbreviations) excluded. A1's id grammar rejects the quiet forms because a
|
|
1312
|
+
quiet segment poisons the call's id count; this fact says only that the
|
|
1313
|
+
command is present, never that the shell reached it or that it printed an id.
|
|
1314
|
+
Segment-level: placement and backgrounding are judged by commit_ran_in_call."""
|
|
1315
|
+
prefix = git_prefix(list(words))
|
|
1316
|
+
return (
|
|
1317
|
+
prefix is not None
|
|
1318
|
+
and prefix.subcommand == "commit"
|
|
1319
|
+
and _commit_flags_ok(prefix.args, allow_quiet=True)
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def _commit_ran_in_lexed(lexed: Lexed, depth: int) -> bool:
|
|
1324
|
+
"""Whether some foreground commit segment of one lexed command is a
|
|
1325
|
+
commit_command_ran segment whose OWN reasons are empty. A reason raised in
|
|
1326
|
+
another segment (an expansion in an earlier `cd $WT`) does not hide it;
|
|
1327
|
+
any reason in the candidate itself (opaque or malformed syntax, quoting,
|
|
1328
|
+
heredoc data) fails closed, as does an `&` ending its and-or list."""
|
|
1329
|
+
flagged = {index for _, index in lexed.reason_at}
|
|
1330
|
+
background = _backgrounded_lists(lexed)
|
|
1331
|
+
for index, (seg, bg) in enumerate(zip(lexed.segments, background)):
|
|
1332
|
+
if bg or index in flagged:
|
|
1333
|
+
continue
|
|
1334
|
+
words = [w.text for w in seg.words]
|
|
1335
|
+
if (
|
|
1336
|
+
len(words) == 3
|
|
1337
|
+
and not seg.redirects
|
|
1338
|
+
and words[0] in _SHELL_WRAPPERS
|
|
1339
|
+
and words[1] in ("-c", "-lc")
|
|
1340
|
+
):
|
|
1341
|
+
inner = lex(words[2], commit_message_form=True) if depth < 1 else None
|
|
1342
|
+
if inner is not None and _commit_ran_in_lexed(inner, depth + 1):
|
|
1343
|
+
return True
|
|
1344
|
+
continue
|
|
1345
|
+
if commit_command_ran(words):
|
|
1346
|
+
return True
|
|
1347
|
+
return False
|
|
1348
|
+
|
|
1349
|
+
|
|
1350
|
+
def commit_ran_in_call(command: str) -> bool:
|
|
1351
|
+
"""FA6: whether a Bash command text contains a foreground commit_command_ran
|
|
1352
|
+
segment (the call-level presence fact; the caller adds the completion and no-id
|
|
1353
|
+
conditions). Reachability is not judged: control flow may skip the segment."""
|
|
1354
|
+
if not isinstance(command, str) or _COMMIT_SPELLING_RE.search(command) is None:
|
|
1355
|
+
return False
|
|
1356
|
+
return _commit_ran_in_lexed(lex(command, commit_message_form=True), 0)
|
|
1357
|
+
|
|
1358
|
+
|
|
1359
|
+
# A placed word is its pieces with quotes and backslashes removed, so a word
|
|
1360
|
+
# `commit` always shows up as the substring `commit` once those characters
|
|
1361
|
+
# (and a line continuation's newline) are stripped: a sound prefilter that
|
|
1362
|
+
# only skips commands that cannot hold a commit segment.
|
|
1363
|
+
_COMMIT_SPELLING_RE = re.compile(
|
|
1364
|
+
"c['\"\\\\\n]*o['\"\\\\\n]*m['\"\\\\\n]*m['\"\\\\\n]*i['\"\\\\\n]*t"
|
|
1365
|
+
)
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def commit_facts(command: str, cwd: str | None = None) -> CommitFacts:
|
|
1369
|
+
"""The commit segments of a command (L8, amended by A1). Empty unless the
|
|
1370
|
+
command is placed by the lexer, no commit segment carries a dry-run or
|
|
1371
|
+
quiet flag, and no commit segment is backgrounded in the text."""
|
|
1372
|
+
if not isinstance(command, str) or _COMMIT_SPELLING_RE.search(command) is None:
|
|
1373
|
+
return CommitFacts(())
|
|
1374
|
+
found = _commit_segments(lex(command, commit_message_form=True), cwd, 0)
|
|
1375
|
+
if found is None or any(background for _, background in found):
|
|
1376
|
+
return CommitFacts(())
|
|
1377
|
+
return CommitFacts(tuple(commit for commit, _ in found))
|
|
1378
|
+
|
|
1379
|
+
|
|
1380
|
+
def commit_ids(facts: CommitFacts, result_text: str) -> tuple[str, ...]:
|
|
1381
|
+
"""The last k line-start summary matches of a result, for k commit
|
|
1382
|
+
segments: an unverified, summary-shaped output line, not attributed to
|
|
1383
|
+
git (DESIGN 4.13.1). Empty when the result has fewer matches than k.
|
|
1384
|
+
The caller guarantees the call's completion is in COMMIT_COMPLETIONS."""
|
|
1385
|
+
k = len(facts.segments)
|
|
1386
|
+
if k == 0:
|
|
1387
|
+
return ()
|
|
1388
|
+
matches = _COMMIT_ID_RE.findall(result_text)
|
|
1389
|
+
if len(matches) < k:
|
|
1390
|
+
return ()
|
|
1391
|
+
return tuple(matches[-k:][:_MAX_COMMIT_IDS_PER_CALL])
|
|
1392
|
+
|
|
1393
|
+
|
|
1394
|
+
DELEGATION_TOOLS = ("Agent", "Workflow", "SendMessage", "Task")
|
|
1395
|
+
_DELEGATION_PROGRAMS = ("codex", "aider")
|
|
1396
|
+
_NO_COMMIT = CommitFacts(())
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
@dataclass(frozen=True)
|
|
1400
|
+
class CallClass:
|
|
1401
|
+
"""One Bash, Monitor, or delegation-tool call classified into its row of
|
|
1402
|
+
docs/DESIGN.md 4.13.1's truth table (L6). effect is None (no entry),
|
|
1403
|
+
'possible', or 'claimed_target' (row 5: N4b maps a call that did not
|
|
1404
|
+
succeed to 'attempted'). basis is None, 'background_process',
|
|
1405
|
+
'delegation', 'command_grammar', or 'opaque_command'. targets are the
|
|
1406
|
+
tiny grammar's paths (row 5 only). commit holds the commit segments of
|
|
1407
|
+
the call. repo is the call's candidate repository: the base in force at
|
|
1408
|
+
its last segment, or the call cwd for a command the lexer does not place.
|
|
1409
|
+
Completion is never decided here."""
|
|
1410
|
+
|
|
1411
|
+
row: int
|
|
1412
|
+
effect: str | None
|
|
1413
|
+
basis: str | None
|
|
1414
|
+
targets: tuple[WriteTarget, ...]
|
|
1415
|
+
commit: CommitFacts
|
|
1416
|
+
repo: str | None
|
|
1417
|
+
# FA6 (C1): the call CONTAINS a foreground commit command (a quiet commit
|
|
1418
|
+
# included); False for a background call. Presence only: the shell may never have
|
|
1419
|
+
# reached it (`git diff --quiet || git commit -q`). Reader-private like ToolCall._path_state:
|
|
1420
|
+
# out of equality, repr, and to_dict (the classification's own shape is unchanged).
|
|
1421
|
+
commit_command_present: bool = field(default=False, compare=False, repr=False)
|
|
1422
|
+
|
|
1423
|
+
def to_dict(self) -> dict:
|
|
1424
|
+
"""The private, complete form (ToolCall.to_dict, N4b-1)."""
|
|
1425
|
+
return {
|
|
1426
|
+
"row": self.row,
|
|
1427
|
+
"effect": self.effect,
|
|
1428
|
+
"basis": self.basis,
|
|
1429
|
+
"targets": [
|
|
1430
|
+
{"path": t.path, "repo": t.repo, "kind": t.kind, "truncated": t.truncated}
|
|
1431
|
+
for t in self.targets
|
|
1432
|
+
],
|
|
1433
|
+
"commit": [{"repo": seg.repo} for seg in self.commit.segments],
|
|
1434
|
+
"repo": self.repo,
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
|
|
1438
|
+
def _has_nohup(command: str, lexed: Lexed) -> bool:
|
|
1439
|
+
if any(w.text == "nohup" for seg in lexed.segments for w in seg.words):
|
|
1440
|
+
return True
|
|
1441
|
+
structural = "unbalanced_quote" in lexed.reasons or "unclosed_heredoc" in lexed.reasons
|
|
1442
|
+
return structural and "nohup" in command.split()
|
|
1443
|
+
|
|
1444
|
+
|
|
1445
|
+
def _is_delegation(lexed: Lexed) -> bool:
|
|
1446
|
+
for seg in lexed.segments:
|
|
1447
|
+
words = [w.text for w in seg.words]
|
|
1448
|
+
if not words:
|
|
1449
|
+
continue
|
|
1450
|
+
program = words[0].rpartition("/")[2]
|
|
1451
|
+
if program in _DELEGATION_PROGRAMS:
|
|
1452
|
+
return True
|
|
1453
|
+
if program == "claude" and any(w in ("-p", "--print") for w in words[1:]):
|
|
1454
|
+
return True
|
|
1455
|
+
if "codex-companion.mjs" in "\0".join(words) and any(
|
|
1456
|
+
w.endswith("codex-companion.mjs") for w in words
|
|
1457
|
+
):
|
|
1458
|
+
return True
|
|
1459
|
+
return False
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
# A transcript repeats the same few commands many times, and the lexer is a
|
|
1463
|
+
# per-character loop, so the pure classification of a (tool, command, flags,
|
|
1464
|
+
# cwd) tuple is memoized. CallClass is frozen, so sharing one is safe. Very
|
|
1465
|
+
# long commands are not cached (they would pin large strings).
|
|
1466
|
+
_CACHE_COMMAND_LIMIT = 2048
|
|
1467
|
+
|
|
1468
|
+
|
|
1469
|
+
def classify_call(
|
|
1470
|
+
tool_name: str | None,
|
|
1471
|
+
command: object,
|
|
1472
|
+
*,
|
|
1473
|
+
run_in_background: object = False,
|
|
1474
|
+
backgrounded_hit: bool = False,
|
|
1475
|
+
cwd: str | None = None,
|
|
1476
|
+
) -> CallClass | None:
|
|
1477
|
+
"""classify_call's contract is _classify_call's below; this wrapper only
|
|
1478
|
+
memoizes it."""
|
|
1479
|
+
if isinstance(command, str) and len(command) <= _CACHE_COMMAND_LIMIT:
|
|
1480
|
+
return _classify_cached(
|
|
1481
|
+
tool_name, command, run_in_background is True, backgrounded_hit, cwd
|
|
1482
|
+
)
|
|
1483
|
+
return _classify_call(
|
|
1484
|
+
tool_name, command, run_in_background=run_in_background,
|
|
1485
|
+
backgrounded_hit=backgrounded_hit, cwd=cwd,
|
|
1486
|
+
)
|
|
1487
|
+
|
|
1488
|
+
|
|
1489
|
+
@functools.lru_cache(maxsize=4096)
|
|
1490
|
+
def _classify_cached(
|
|
1491
|
+
tool_name: str | None, command: str, background: bool, backgrounded_hit: bool,
|
|
1492
|
+
cwd: str | None,
|
|
1493
|
+
) -> CallClass | None:
|
|
1494
|
+
return _classify_call(
|
|
1495
|
+
tool_name, command, run_in_background=background, backgrounded_hit=backgrounded_hit,
|
|
1496
|
+
cwd=cwd,
|
|
1497
|
+
)
|
|
1498
|
+
|
|
1499
|
+
|
|
1500
|
+
def _classify_call(
|
|
1501
|
+
tool_name: str | None,
|
|
1502
|
+
command: object,
|
|
1503
|
+
*,
|
|
1504
|
+
run_in_background: object = False,
|
|
1505
|
+
backgrounded_hit: bool = False,
|
|
1506
|
+
cwd: str | None = None,
|
|
1507
|
+
) -> CallClass | None:
|
|
1508
|
+
"""Rows 2 to 6 of the truth table for a Bash or Monitor call, and row 3
|
|
1509
|
+
for the delegation tools; None for every other tool name (the file
|
|
1510
|
+
tools, the read-only tools, and unknown tools belong to the ledger).
|
|
1511
|
+
backgrounded_hit says whether an anchored bash_backgrounded marker hit
|
|
1512
|
+
belongs to this call's result."""
|
|
1513
|
+
if tool_name in DELEGATION_TOOLS:
|
|
1514
|
+
return CallClass(3, "possible", "delegation", (), _NO_COMMIT, None)
|
|
1515
|
+
if tool_name not in ("Bash", "Monitor"):
|
|
1516
|
+
return None
|
|
1517
|
+
is_bash = tool_name == "Bash"
|
|
1518
|
+
base = _base(cwd)
|
|
1519
|
+
background = run_in_background is True or backgrounded_hit
|
|
1520
|
+
if not isinstance(command, str):
|
|
1521
|
+
if background:
|
|
1522
|
+
return CallClass(2, "possible", "background_process", (), _NO_COMMIT, base)
|
|
1523
|
+
return CallClass(6, "possible", "opaque_command", (), _NO_COMMIT, base)
|
|
1524
|
+
lexed = lex(command)
|
|
1525
|
+
commit = commit_facts(command, cwd) if is_bash else _NO_COMMIT
|
|
1526
|
+
if lexed.placed and lexed.segments:
|
|
1527
|
+
repo = segment_bases(lexed, cwd)[-1]
|
|
1528
|
+
else:
|
|
1529
|
+
repo = base
|
|
1530
|
+
if background or lexed.trailing_op == "&" or _has_nohup(command, lexed):
|
|
1531
|
+
return CallClass(2, "possible", "background_process", (), commit, repo)
|
|
1532
|
+
present = is_bash and commit_ran_in_call(command)
|
|
1533
|
+
if is_bash and _is_delegation(lexed):
|
|
1534
|
+
return CallClass(3, "possible", "delegation", (), commit, repo, present)
|
|
1535
|
+
if is_read_only(lexed):
|
|
1536
|
+
return CallClass(4, None, None, (), commit, repo, present)
|
|
1537
|
+
if is_bash:
|
|
1538
|
+
match = match_write_grammar(lexed, cwd)
|
|
1539
|
+
if match is not None:
|
|
1540
|
+
return CallClass(
|
|
1541
|
+
5, "claimed_target", "command_grammar", match.targets, commit, repo, present,
|
|
1542
|
+
)
|
|
1543
|
+
return CallClass(6, "possible", "opaque_command", (), commit, repo, present)
|