align-agent 0.4.3__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.
- align_agent/__init__.py +11 -0
- align_agent/_audit/common.py +58 -0
- align_agent/_audit/deepswe.py +260 -0
- align_agent/_audit/excerpts.py +203 -0
- align_agent/_audit/extract.py +798 -0
- align_agent/_audit/materialize.py +624 -0
- align_agent/_audit/score.py +325 -0
- align_agent/_audit/shellparse.py +1141 -0
- align_agent/_audit/tarsur.py +153 -0
- align_agent/_vendor/src/__init__.py +3 -0
- align_agent/_vendor/src/harness/__init__.py +13 -0
- align_agent/_vendor/src/harness/arms.py +524 -0
- align_agent/_vendor/src/harness/completion.py +104 -0
- align_agent/_vendor/src/harness/dependency_notice.py +616 -0
- align_agent/_vendor/src/harness/freshness_hook.py +131 -0
- align_agent/_vendor/src/harness/lean.py +41 -0
- align_agent/_vendor/src/harness/mini_real.py +1040 -0
- align_agent/_vendor/src/harness/miniswe_scaffold.py +661 -0
- align_agent/_vendor/src/harness/tail_diff.py +292 -0
- align_agent/_vendor/src/harness/viewport.py +220 -0
- align_agent/_vendor/src/metrics/__init__.py +38 -0
- align_agent/_vendor/src/metrics/coherence.py +354 -0
- align_agent/_vendor/src/metrics/corruption_oracles.py +139 -0
- align_agent/_vendor/src/metrics/replay.py +399 -0
- align_agent/_vendor/src/metrics/statistics.py +185 -0
- align_agent/_vendor/src/metrics/verification.py +104 -0
- align_agent/_vendor/src/parsers/__init__.py +33 -0
- align_agent/_vendor/src/parsers/base_parser.py +349 -0
- align_agent/_vendor/src/parsers/file_events.py +806 -0
- align_agent/_vendor/src/parsers/miniswe_parser.py +96 -0
- align_agent/_vendor/src/parsers/openhands_parser.py +414 -0
- align_agent/_vendor/src/parsers/sweagent_parser.py +180 -0
- align_agent/_vendor/src/parsers/terminus_parser.py +106 -0
- align_agent/cli.py +101 -0
- align_agent/core.py +161 -0
- align_agent/run.py +84 -0
- align_agent/sessions.py +985 -0
- align_agent/writethrough/__init__.py +4 -0
- align_agent/writethrough/_lib/__init__.py +1 -0
- align_agent/writethrough/_lib/wt_src/__init__.py +3 -0
- align_agent/writethrough/_lib/wt_src/harness/__init__.py +1 -0
- align_agent/writethrough/_lib/wt_src/harness/dependency_notice.py +616 -0
- align_agent/writethrough/_lib/wt_src/harness/lean.py +41 -0
- align_agent/writethrough/_lib/wt_src/harness/tail_diff.py +292 -0
- align_agent/writethrough/_lib/wt_src/harness/viewport.py +220 -0
- align_agent/writethrough/_lib/wt_src/metrics/__init__.py +38 -0
- align_agent/writethrough/_lib/wt_src/metrics/replay.py +399 -0
- align_agent/writethrough/_lib/wt_src/parsers/__init__.py +1 -0
- align_agent/writethrough/_lib/wt_src/parsers/file_events.py +806 -0
- align_agent/writethrough/claude_code.py +81 -0
- align_agent/writethrough/engine.py +647 -0
- align_agent/writethrough/hooks.py +227 -0
- align_agent/writethrough/mcp_server.py +125 -0
- align_agent/writethrough/protocol.py +68 -0
- align_agent-0.4.3.dist-info/METADATA +224 -0
- align_agent-0.4.3.dist-info/RECORD +60 -0
- align_agent-0.4.3.dist-info/WHEEL +5 -0
- align_agent-0.4.3.dist-info/entry_points.txt +6 -0
- align_agent-0.4.3.dist-info/licenses/LICENSE +21 -0
- align_agent-0.4.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
"""Ordered file events in a shell command, for the stale-act replay.
|
|
2
|
+
|
|
3
|
+
The stale-act rate asks one question of every edit: when the agent issued it,
|
|
4
|
+
had the file changed since the agent last saw it? Answering that needs three
|
|
5
|
+
things the older extractors did not give. Order: `cat f; sed -i ... f` and
|
|
6
|
+
`sed -i ... f; cat f` differ in exactly the way that matters. What was *shown*:
|
|
7
|
+
`cp a b` names a without showing it, `grep -l` prints file names and no lines,
|
|
8
|
+
and neither is a view. What *kind* of write: an edit located by existing text
|
|
9
|
+
or line numbers depends on the view it was composed against, while a heredoc
|
|
10
|
+
that rewrites the whole file does not, and a `git checkout` restores content
|
|
11
|
+
the agent never typed.
|
|
12
|
+
|
|
13
|
+
An event is (op, path, kind):
|
|
14
|
+
op "R" content of `path` was printed to the agent
|
|
15
|
+
"W" `path` was changed
|
|
16
|
+
kind for R: "view", or "echo" for the lines an editor prints back
|
|
17
|
+
after its own edit
|
|
18
|
+
for W: "anchored" located by existing text or lines (sed -i,
|
|
19
|
+
perl -i, awk inplace, patch, a script that
|
|
20
|
+
opens the file for writing)
|
|
21
|
+
"overwrite" whole content replaced by text the command
|
|
22
|
+
itself carries (heredoc, echo, printf)
|
|
23
|
+
"replace" whole content replaced by text the agent does
|
|
24
|
+
not see (`cmd > f`, cp of several files)
|
|
25
|
+
"copy:SRC" whole content replaced by SRC's (cp/mv/install
|
|
26
|
+
of one file onto another)
|
|
27
|
+
"append", "delete"
|
|
28
|
+
"restore" git checkout/restore/stash/reset; path "*"
|
|
29
|
+
when the command names no path
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import re
|
|
34
|
+
import shlex
|
|
35
|
+
from typing import Dict, List, Optional, Tuple
|
|
36
|
+
|
|
37
|
+
FileEvent = Tuple[str, str, str]
|
|
38
|
+
|
|
39
|
+
ALL_PATHS = "*"
|
|
40
|
+
# Workspace mount points agents write absolute paths under; stripping one of
|
|
41
|
+
# these gives the workspace-relative path. Shared by the live harness and the
|
|
42
|
+
# replay so the two canonicalize identically.
|
|
43
|
+
CONTAINER_ROOTS = ("/testbed/", "/app/", "/workspace/", "/root/repo/", "/home/")
|
|
44
|
+
|
|
45
|
+
_HEREDOC_OP_RE = re.compile(r"""(?<!<)<<-?(?!<)\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1""")
|
|
46
|
+
_SEPARATORS = {";", ";;", "&&", "||", "&", "(", ")"}
|
|
47
|
+
_SEPARATOR_CHARS = set("();|&\n")
|
|
48
|
+
_REDIRECT_CHARS = set("<>&")
|
|
49
|
+
|
|
50
|
+
_VIEW_VERBS = {"cat", "head", "tail", "nl", "less", "more", "bat", "view"}
|
|
51
|
+
_GREP_VERBS = {"grep", "rg", "egrep", "fgrep", "ack", "ag"}
|
|
52
|
+
# Flags under which grep prints names or counts and never a line of content.
|
|
53
|
+
_GREP_NO_CONTENT = {"-l", "-L", "-c", "-q", "--files-with-matches",
|
|
54
|
+
"--files-without-match", "--count", "--quiet", "--silent"}
|
|
55
|
+
_INTERPRETERS = ("python", "perl", "ruby", "node")
|
|
56
|
+
_LITERAL_SOURCES = {"echo", "printf"}
|
|
57
|
+
_WRAPPERS = {"sudo", "command", "exec", "nohup", "time"}
|
|
58
|
+
|
|
59
|
+
_SED_SCRIPT_RE = re.compile(r"^[0-9,~$+]*\s*[a-zA-Z]([/,;|!].*)?$|^[/$^].*[/]|^[0-9,$]+[a-zA-Z]")
|
|
60
|
+
_DIFF_TARGET_RE = re.compile(r"^\+\+\+\s+(?:b/)?(\S+)", re.M)
|
|
61
|
+
_PY_OPEN_LIT = re.compile(r"""open\(\s*[rbfu]?(['"])(?P<p>[^'"\n]+)\1\s*,\s*(?:mode\s*=\s*)?[rbfu]?(['"])(?P<m>[^'"\n]*)\3""")
|
|
62
|
+
_PY_ASSIGN = re.compile(r"""(?P<v>\b[A-Za-z_]\w*)\s*=\s*(?:(?:pathlib\.)?Path\(\s*)?[rbfu]?(['"])(?P<p>[^'"\n]+)\2""")
|
|
63
|
+
_PY_OPEN_VAR = re.compile(r"""open\(\s*(?P<v>[A-Za-z_]\w*)\s*,\s*(?:mode\s*=\s*)?[rbfu]?(['"])(?P<m>[^'"\n]*)\2""")
|
|
64
|
+
_PY_PATH_LIT_WRITE = re.compile(r"""Path\(\s*[rbfu]?(['"])(?P<p>[^'"\n]+)\1\s*\)\s*\.write_(?:text|bytes)\(""")
|
|
65
|
+
_PY_VAR_WRITE = re.compile(r"""(?P<v>\b[A-Za-z_]\w*)\s*\.write_(?:text|bytes)\(""")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# Files conventionally named without an extension; any other bare word is
|
|
69
|
+
# taken not to be a path, so these must be listed.
|
|
70
|
+
COPY_PREFIX = "copy:"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def copy_source(kind: str) -> Optional[str]:
|
|
74
|
+
"""The source path a "copy:SRC" write carries, or None."""
|
|
75
|
+
return kind[len(COPY_PREFIX):] if kind.startswith(COPY_PREFIX) else None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def map_kind_path(kind: str, f) -> str:
|
|
79
|
+
"""`kind` with the source path of a copy rewritten by `f`, as the
|
|
80
|
+
target's path is, so the two stay in the same spelling."""
|
|
81
|
+
src = copy_source(kind)
|
|
82
|
+
return kind if src is None else COPY_PREFIX + f(src)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
_KNOWN_NAMES = {
|
|
86
|
+
"Makefile", "makefile", "GNUmakefile", "Dockerfile", "Containerfile", "configure", "Rakefile",
|
|
87
|
+
"Gemfile", "Procfile", "Vagrantfile", "Jenkinsfile", "Pipfile", "Justfile", "Brewfile",
|
|
88
|
+
"SConstruct", "SConscript", "Snakefile", "BUILD", "WORKSPACE", "Kconfig", "Kbuild",
|
|
89
|
+
"LICENSE", "COPYING", "README", "CHANGELOG", "NOTICE", "AUTHORS", "INSTALL",
|
|
90
|
+
}
|
|
91
|
+
_DOTFILE = re.compile(r"\.[A-Za-z][\w.-]*")
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def plausible_path(tok: str) -> bool:
|
|
95
|
+
"""Whether a shell word can be a file path rather than an expression."""
|
|
96
|
+
if not tok or tok.startswith("-"):
|
|
97
|
+
return False
|
|
98
|
+
if tok in ("/dev/null", "/dev/stdout", "/dev/stderr", "null", ".", ".."):
|
|
99
|
+
return False
|
|
100
|
+
if tok.startswith("/dev/") or tok.startswith("/proc/"):
|
|
101
|
+
return False
|
|
102
|
+
if any(ch in tok for ch in "{}$*?!()<>\"'`=\n"):
|
|
103
|
+
return False
|
|
104
|
+
if tok.isdigit():
|
|
105
|
+
return False
|
|
106
|
+
base = tok.rsplit("/", 1)[-1]
|
|
107
|
+
return ("/" in tok or bool(re.search(r"\.[A-Za-z0-9_]{1,6}$", tok)) or base in _KNOWN_NAMES
|
|
108
|
+
or base.split(".", 1)[0] in _KNOWN_NAMES or bool(_DOTFILE.fullmatch(base)))
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def clean(tok: str) -> str:
|
|
112
|
+
tok = tok.strip("'\"` ,;")
|
|
113
|
+
# /proc/self/cwd/ is the process's working directory.
|
|
114
|
+
if tok.startswith("/proc/self/cwd/"):
|
|
115
|
+
tok = tok[len("/proc/self/cwd/"):]
|
|
116
|
+
return tok[2:] if tok.startswith("./") else tok
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def split_heredocs(cmd: str) -> Tuple[str, List[str]]:
|
|
120
|
+
"""Shell text with heredoc bodies removed, and the bodies in the order
|
|
121
|
+
their `<<` operators appear. Every heredoc is removed, not only the first:
|
|
122
|
+
lines of a second body otherwise read as commands."""
|
|
123
|
+
lines = (cmd or "").split("\n")
|
|
124
|
+
out, bodies, i = [], [], 0
|
|
125
|
+
while i < len(lines):
|
|
126
|
+
line = lines[i]
|
|
127
|
+
out.append(line)
|
|
128
|
+
i += 1
|
|
129
|
+
for m in _HEREDOC_OP_RE.finditer(line):
|
|
130
|
+
delim, body = m.group(2), []
|
|
131
|
+
while i < len(lines) and lines[i].strip() != delim:
|
|
132
|
+
body.append(lines[i])
|
|
133
|
+
i += 1
|
|
134
|
+
i += 1
|
|
135
|
+
bodies.append("\n".join(body))
|
|
136
|
+
return "\n".join(out), bodies
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def command_units(cmd: str) -> List[Dict]:
|
|
140
|
+
"""Simple commands in order, each with its argv, its redirections, its
|
|
141
|
+
heredoc body, and the pipeline it belongs to.
|
|
142
|
+
|
|
143
|
+
Tokenising respects quoting, so a `>` inside a grep pattern is part of
|
|
144
|
+
the pattern and not a redirection; newlines separate commands as `;`
|
|
145
|
+
does."""
|
|
146
|
+
shell, bodies = split_heredocs(cmd)
|
|
147
|
+
# A backslash-newline continues a line; join before tokenising.
|
|
148
|
+
shell = shell.replace("\\\n", " ")
|
|
149
|
+
try:
|
|
150
|
+
lexer = shlex.shlex(shell, posix=True, punctuation_chars="();<>|&\n")
|
|
151
|
+
lexer.whitespace = " \t\r"
|
|
152
|
+
lexer.whitespace_split = True
|
|
153
|
+
tokens = list(lexer)
|
|
154
|
+
except ValueError:
|
|
155
|
+
return []
|
|
156
|
+
|
|
157
|
+
units: List[Dict] = []
|
|
158
|
+
pipe = 0
|
|
159
|
+
depth = 0
|
|
160
|
+
body_i = 0
|
|
161
|
+
pending: Optional[str] = None
|
|
162
|
+
|
|
163
|
+
def new() -> Dict:
|
|
164
|
+
return {"argv": [], "redirs": [], "heredoc": None, "pipe": pipe, "depth": depth}
|
|
165
|
+
|
|
166
|
+
cur = new()
|
|
167
|
+
for t in tokens:
|
|
168
|
+
if pending is not None:
|
|
169
|
+
if pending.startswith("<<") and pending != "<<<":
|
|
170
|
+
cur["heredoc"] = bodies[body_i] if body_i < len(bodies) else ""
|
|
171
|
+
body_i += 1
|
|
172
|
+
else:
|
|
173
|
+
cur["redirs"].append((pending, t))
|
|
174
|
+
pending = None
|
|
175
|
+
continue
|
|
176
|
+
if t == "|":
|
|
177
|
+
if cur["argv"] or cur["redirs"]:
|
|
178
|
+
units.append(cur)
|
|
179
|
+
cur = new()
|
|
180
|
+
elif t in _SEPARATORS or (t and set(t) <= _SEPARATOR_CHARS):
|
|
181
|
+
# Punctuation arrives grouped (`);`, `))`), so any all-separator
|
|
182
|
+
# token separates; its parentheses open and close subshells.
|
|
183
|
+
if cur["argv"] or cur["redirs"]:
|
|
184
|
+
units.append(cur)
|
|
185
|
+
pipe += 1
|
|
186
|
+
depth = max(0, depth + t.count("(") - t.count(")"))
|
|
187
|
+
cur = new()
|
|
188
|
+
elif set(t) <= _REDIRECT_CHARS and ("<" in t or ">" in t):
|
|
189
|
+
# `2>` and `2>&1` arrive as the word "2" and then the operator; a
|
|
190
|
+
# lone digit right before a redirect is its file descriptor.
|
|
191
|
+
if cur["argv"] and len(cur["argv"][-1]) == 1 and cur["argv"][-1].isdigit():
|
|
192
|
+
t = cur["argv"].pop() + t
|
|
193
|
+
pending = t
|
|
194
|
+
else:
|
|
195
|
+
cur["argv"].append(t)
|
|
196
|
+
if cur["argv"] or cur["redirs"]:
|
|
197
|
+
units.append(cur)
|
|
198
|
+
return units
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def script_writes(script: str) -> List[str]:
|
|
202
|
+
"""Files a Python-style script opens for writing, by literal or by a
|
|
203
|
+
variable bound to a literal."""
|
|
204
|
+
if not script:
|
|
205
|
+
return []
|
|
206
|
+
names: Dict[str, str] = {}
|
|
207
|
+
for m in _PY_ASSIGN.finditer(script):
|
|
208
|
+
names[m.group("v")] = m.group("p")
|
|
209
|
+
out: List[str] = []
|
|
210
|
+
for m in _PY_OPEN_LIT.finditer(script):
|
|
211
|
+
if any(c in m.group("m") for c in "wa+"):
|
|
212
|
+
out.append(m.group("p"))
|
|
213
|
+
for m in _PY_OPEN_VAR.finditer(script):
|
|
214
|
+
if any(c in m.group("m") for c in "wa+") and m.group("v") in names:
|
|
215
|
+
out.append(names[m.group("v")])
|
|
216
|
+
for m in _PY_PATH_LIT_WRITE.finditer(script):
|
|
217
|
+
out.append(m.group("p"))
|
|
218
|
+
for m in _PY_VAR_WRITE.finditer(script):
|
|
219
|
+
if m.group("v") in names:
|
|
220
|
+
out.append(names[m.group("v")])
|
|
221
|
+
return [clean(p) for p in dict.fromkeys(out) if plausible_path(clean(p))]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
_PY_READ_LIT = re.compile(r"""open\(\s*[rbfu]?(['"])(?P<p>[^'"\n]+)\1\s*(?:\)|,\s*(?:mode\s*=\s*)?[rbfu]?(['"])(?P<m>[^'"\n]*)\3)""")
|
|
225
|
+
_PY_READ_VAR = re.compile(r"""open\(\s*(?P<v>[A-Za-z_]\w*)\s*(?:\)|,\s*(?:mode\s*=\s*)?[rbfu]?(['"])(?P<m>[^'"\n]*)\2)""")
|
|
226
|
+
_PY_PATH_LIT_READ = re.compile(r"""Path\(\s*[rbfu]?(['"])(?P<p>[^'"\n]+)\1\s*\)\s*\.read_(?:text|bytes)\(""")
|
|
227
|
+
_PY_VAR_READ = re.compile(r"""(?P<v>\b[A-Za-z_]\w*)\s*\.read_(?:text|bytes)\(""")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def script_reads(script: str) -> List[str]:
|
|
231
|
+
"""Files a Python-style script opens for reading, by literal or by a
|
|
232
|
+
variable bound to a literal."""
|
|
233
|
+
if not script:
|
|
234
|
+
return []
|
|
235
|
+
names = {m.group("v"): m.group("p") for m in _PY_ASSIGN.finditer(script)}
|
|
236
|
+
out: List[str] = []
|
|
237
|
+
for m in _PY_READ_LIT.finditer(script):
|
|
238
|
+
if not m.group("m") or ("r" in m.group("m") or "+" in m.group("m")):
|
|
239
|
+
out.append(m.group("p"))
|
|
240
|
+
for m in _PY_READ_VAR.finditer(script):
|
|
241
|
+
if (not m.group("m") or "r" in m.group("m") or "+" in m.group("m")) and m.group("v") in names:
|
|
242
|
+
out.append(names[m.group("v")])
|
|
243
|
+
out += [m.group("p") for m in _PY_PATH_LIT_READ.finditer(script)]
|
|
244
|
+
out += [names[m.group("v")] for m in _PY_VAR_READ.finditer(script) if m.group("v") in names]
|
|
245
|
+
return [clean(p) for p in dict.fromkeys(out) if plausible_path(clean(p))]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def script_write_events(script: str) -> List[Tuple[str, str]]:
|
|
249
|
+
"""(path, kind) for each file a script writes: an anchored edit when the
|
|
250
|
+
script also reads that file, a replacement when it only writes it."""
|
|
251
|
+
read = set(script_reads(script))
|
|
252
|
+
return [(p, "anchored" if p in read else "replace") for p in script_writes(script)]
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def patch_targets(body: Optional[str]) -> List[str]:
|
|
256
|
+
if not body:
|
|
257
|
+
return []
|
|
258
|
+
return [p for p in dict.fromkeys(_DIFF_TARGET_RE.findall(body))
|
|
259
|
+
if p != "/dev/null" and plausible_path(p)]
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _operands_after_scripts(argv: List[str], script_flags: Tuple[str, ...]) -> Tuple[List[str], bool]:
|
|
263
|
+
"""Operands of argv, dropping the argument of each script flag (-e X).
|
|
264
|
+
The second value says whether a script flag was present."""
|
|
265
|
+
ops, skip, seen = [], False, False
|
|
266
|
+
for a in argv[1:]:
|
|
267
|
+
if skip:
|
|
268
|
+
skip = False
|
|
269
|
+
continue
|
|
270
|
+
if a in script_flags:
|
|
271
|
+
skip, seen = True, True
|
|
272
|
+
continue
|
|
273
|
+
if a.startswith("-"):
|
|
274
|
+
continue
|
|
275
|
+
ops.append(a)
|
|
276
|
+
return ops, seen
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
Rw = Tuple[List[str], List[Tuple[str, str]]]
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _is_short_flag_with(f: str, letters: str) -> bool:
|
|
283
|
+
return len(f) > 1 and f[0] == "-" and f[1] != "-" and bool(set(f[1:]) & set(letters))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _view(argv, flags, ops, u, literal) -> Rw:
|
|
287
|
+
return ops + [t for op, t in u["redirs"] if op == "<"], []
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
# One `s` command, optionally line-addressed: `s/a/b/g`, `12s|x|y|`, `$s/a/b/`.
|
|
291
|
+
# A backslash is matched only as the start of an escape pair: with `.` also
|
|
292
|
+
# allowed to match it, a script full of `\t\n` that fails to parse backtracked
|
|
293
|
+
# exponentially (minutes on one vuls-8659668 Qwen step).
|
|
294
|
+
_SUBST_CMD = re.compile(r"""^\s*(?:\d+(?:,\d+)?|\$)?\s*s(?P<d>[^\w\s\\])(?:\\.|(?!(?P=d))[^\\])*(?P=d)"""
|
|
295
|
+
r"""(?:\\.|(?!(?P=d))[^\\])*(?P=d)[gIip0-9]*\s*$""", re.S)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def is_substitution(script: str) -> bool:
|
|
299
|
+
"""Whether a sed/perl script only substitutes: every command is `s///`."""
|
|
300
|
+
parts = [x for x in re.split(r"[;\n]", script or "") if x.strip()]
|
|
301
|
+
return bool(parts) and all(_SUBST_CMD.match(x) for x in parts)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _sed_scripts(argv: List[str], files: List[str], had_e: bool) -> List[str]:
|
|
305
|
+
out, i = [], 1
|
|
306
|
+
while i < len(argv):
|
|
307
|
+
if argv[i] in ("-e", "--expression") and i + 1 < len(argv):
|
|
308
|
+
out.append(argv[i + 1]); i += 2
|
|
309
|
+
elif argv[i] in ("-f",):
|
|
310
|
+
return [] # a script file: contents unknown
|
|
311
|
+
else:
|
|
312
|
+
i += 1
|
|
313
|
+
return out if had_e else files[:1]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _sed(argv, flags, ops, u, literal) -> Rw:
|
|
317
|
+
inplace = any(f.startswith("--in-place") or _is_short_flag_with(f, "i") for f in flags)
|
|
318
|
+
files, had_e = _operands_after_scripts(argv, ("-e", "-f", "--expression"))
|
|
319
|
+
scripts = _sed_scripts(argv, files, had_e)
|
|
320
|
+
if not had_e and files and (_SED_SCRIPT_RE.match(files[0]) or not plausible_path(files[0])):
|
|
321
|
+
files = files[1:]
|
|
322
|
+
# "subst" is an anchored edit whose effect the agent chose exactly; it
|
|
323
|
+
# scores as "anchored" unless replay(known_substitutions=True).
|
|
324
|
+
kind = "subst" if scripts and all(is_substitution(x) for x in scripts) else "anchored"
|
|
325
|
+
return ([], [(f, kind) for f in files]) if inplace else (files, [])
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
_GREP_ARG_FLAGS = ("-e", "-f", "--regexp", "-A", "-B", "-C", "-m", "-D", "-d",
|
|
329
|
+
"--after-context", "--before-context", "--context",
|
|
330
|
+
"--max-count", "--devices", "--directories")
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _grep(argv, flags, ops, u, literal) -> Rw:
|
|
334
|
+
if any(f in _GREP_NO_CONTENT or _is_short_flag_with(f, "lLcq") for f in flags):
|
|
335
|
+
return [], []
|
|
336
|
+
files, _ = _operands_after_scripts(argv, _GREP_ARG_FLAGS)
|
|
337
|
+
had_e = any(f in ("-e", "-f", "--regexp") for f in flags)
|
|
338
|
+
return (files if had_e else files[1:]), []
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _awk(argv, flags, ops, u, literal) -> Rw:
|
|
342
|
+
inplace = "inplace" in argv
|
|
343
|
+
files, _ = _operands_after_scripts(argv, ("-f", "-v", "-i"))
|
|
344
|
+
had_f = "-f" in argv
|
|
345
|
+
if not had_f and files:
|
|
346
|
+
files = files[1:]
|
|
347
|
+
return ([], [(f, "anchored") for f in files]) if inplace else (files, [])
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def _perl_files(argv: List[str]) -> List[str]:
|
|
351
|
+
"""Operands of a perl command line, skipping the -e script. The script
|
|
352
|
+
follows any short-flag cluster ending in e or E (-e, -pe, -0777pe)."""
|
|
353
|
+
files, skip = [], False
|
|
354
|
+
for a in argv[1:]:
|
|
355
|
+
if skip:
|
|
356
|
+
skip = False
|
|
357
|
+
elif a.startswith("-") and not a.startswith("--"):
|
|
358
|
+
skip = a[-1] in "eE"
|
|
359
|
+
else:
|
|
360
|
+
files.append(a)
|
|
361
|
+
return files
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _perl(argv, flags, ops, u, literal) -> Rw:
|
|
365
|
+
files = _perl_files(argv)
|
|
366
|
+
if any(f.startswith("-i") or _is_short_flag_with(f, "i") for f in flags):
|
|
367
|
+
exprs = [argv[i + 1] for i, a in enumerate(argv[:-1]) if a == "-e" or (a.startswith("-") and a.endswith("e") and "e" in a[1:])]
|
|
368
|
+
kind = "subst" if exprs and all(is_substitution(x) for x in exprs) else "anchored"
|
|
369
|
+
return [], [(f, kind) for f in files]
|
|
370
|
+
if any(_is_short_flag_with(f, "pn") for f in flags):
|
|
371
|
+
# `perl -pe` / `-ne` prints the files it reads: a view, not a write.
|
|
372
|
+
return files, []
|
|
373
|
+
return _interpreter(argv, flags, ops, u, literal)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _copy(argv, flags, ops, u, literal) -> Rw:
|
|
377
|
+
targets = [o for o in ops if plausible_path(clean(o))]
|
|
378
|
+
if len(targets) < 2:
|
|
379
|
+
return [], []
|
|
380
|
+
# One source onto one target carries the source's content; several go
|
|
381
|
+
# into a directory, and no target has a single source.
|
|
382
|
+
kind = f"{COPY_PREFIX}{clean(targets[0])}" if len(targets) == 2 else "replace"
|
|
383
|
+
writes = [(targets[-1], kind)]
|
|
384
|
+
if argv[0].split("/")[-1] == "mv":
|
|
385
|
+
writes += [(t, "delete") for t in targets[:-1]]
|
|
386
|
+
return [], writes
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _rm(argv, flags, ops, u, literal) -> Rw:
|
|
390
|
+
return [], [(o, "delete") for o in ops]
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def _tee(argv, flags, ops, u, literal) -> Rw:
|
|
394
|
+
if "-a" in flags or "--append" in flags:
|
|
395
|
+
kind = "append"
|
|
396
|
+
else:
|
|
397
|
+
kind = "overwrite" if literal else "replace"
|
|
398
|
+
return [], [(o, kind) for o in ops]
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _truncate(argv, flags, ops, u, literal) -> Rw:
|
|
402
|
+
return [], [(o, "replace") for o in ops]
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def _patch(argv, flags, ops, u, literal) -> Rw:
|
|
406
|
+
named = [o for o in ops if plausible_path(clean(o)) and not o.endswith((".patch", ".diff"))]
|
|
407
|
+
return [], [(p, "anchored") for p in patch_targets(u["heredoc"]) + named]
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _git(argv, flags, ops, u, literal) -> Rw:
|
|
411
|
+
if not ops:
|
|
412
|
+
return [], []
|
|
413
|
+
sub, rest = ops[0], ops[1:]
|
|
414
|
+
after_dd = argv[argv.index("--") + 1:] if "--" in argv else None
|
|
415
|
+
if sub == "apply":
|
|
416
|
+
return [], [(p, "anchored") for p in patch_targets(u["heredoc"])]
|
|
417
|
+
if sub in ("checkout", "restore"):
|
|
418
|
+
named = after_dd if after_dd is not None else [r for r in rest if plausible_path(clean(r)) or r == "."]
|
|
419
|
+
if "." in named or not named:
|
|
420
|
+
return [], [(ALL_PATHS, "restore")]
|
|
421
|
+
return [], [(p, "restore") for p in named]
|
|
422
|
+
if sub == "stash" and (not rest or rest[0] in ("push", "save")):
|
|
423
|
+
return [], [(ALL_PATHS, "restore")]
|
|
424
|
+
if sub == "stash" and rest and rest[0] in ("pop", "apply"):
|
|
425
|
+
# Re-applies stashed changes: new content, not the committed one.
|
|
426
|
+
return [], [(ALL_PATHS, "replace")]
|
|
427
|
+
if sub == "reset" and "--hard" in flags:
|
|
428
|
+
return [], [(ALL_PATHS, "restore")]
|
|
429
|
+
if sub in ("diff", "show"):
|
|
430
|
+
# Named paths are views; the files a bare diff prints are read from
|
|
431
|
+
# its output (see output_views).
|
|
432
|
+
named = after_dd if after_dd is not None else [r for r in rest if plausible_path(clean(r))]
|
|
433
|
+
return named, []
|
|
434
|
+
return [], []
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
def _interpreter(argv, flags, ops, u, literal) -> Rw:
|
|
438
|
+
script = u["heredoc"]
|
|
439
|
+
if script is None:
|
|
440
|
+
for flag in ("-c", "-e"):
|
|
441
|
+
if flag in argv and argv.index(flag) + 1 < len(argv):
|
|
442
|
+
script = argv[argv.index(flag) + 1]
|
|
443
|
+
break
|
|
444
|
+
return [], script_write_events(script or "")
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
_HANDLERS = {verb: _view for verb in _VIEW_VERBS}
|
|
448
|
+
_HANDLERS.update({verb: _grep for verb in _GREP_VERBS})
|
|
449
|
+
_HANDLERS.update({"sed": _sed, "awk": _awk, "gawk": _awk, "perl": _perl,
|
|
450
|
+
"cp": _copy, "mv": _copy, "install": _copy, "rm": _rm,
|
|
451
|
+
"tee": _tee, "truncate": _truncate, "patch": _patch, "git": _git})
|
|
452
|
+
|
|
453
|
+
|
|
454
|
+
def _strip_prefixes(argv: List[str]) -> List[str]:
|
|
455
|
+
"""Drop VAR=value assignments and wrappers such as sudo and timeout N."""
|
|
456
|
+
argv = [a for a in argv if a != ""]
|
|
457
|
+
while argv and (re.match(r"^[A-Za-z_]\w*=", argv[0]) or argv[0] in _WRAPPERS):
|
|
458
|
+
argv = argv[1:]
|
|
459
|
+
if argv and argv[0] == "timeout" and len(argv) > 2:
|
|
460
|
+
argv = argv[2:]
|
|
461
|
+
return argv
|
|
462
|
+
|
|
463
|
+
|
|
464
|
+
def _unit_events(u: Dict, literal_pipe: bool) -> List[FileEvent]:
|
|
465
|
+
# An empty word is BSD sed's backup suffix (`sed -i '' ...`); left in, it
|
|
466
|
+
# shifts the script into the file position.
|
|
467
|
+
argv = _strip_prefixes(u["argv"])
|
|
468
|
+
verb = argv[0].split("/")[-1] if argv else ""
|
|
469
|
+
flags = [a for a in argv[1:] if a.startswith("-")]
|
|
470
|
+
ops = [a for a in argv[1:] if not a.startswith("-")]
|
|
471
|
+
literal = literal_pipe or (verb == "cat" and u["heredoc"] is not None)
|
|
472
|
+
handler = _HANDLERS.get(verb) or (_interpreter if verb.startswith(_INTERPRETERS) else None)
|
|
473
|
+
reads, writes = handler(argv, flags, ops, u, literal) if handler else ([], [])
|
|
474
|
+
|
|
475
|
+
for op, target in u["redirs"]:
|
|
476
|
+
op = op.lstrip("0123456789")
|
|
477
|
+
if target.startswith("/dev/"):
|
|
478
|
+
continue
|
|
479
|
+
if op in (">", "&>", ">|"):
|
|
480
|
+
writes.append((target, "overwrite" if literal else "replace"))
|
|
481
|
+
elif op in (">>", "&>>"):
|
|
482
|
+
writes.append((target, "append"))
|
|
483
|
+
|
|
484
|
+
events: List[FileEvent] = []
|
|
485
|
+
for r in reads:
|
|
486
|
+
r = clean(r)
|
|
487
|
+
if plausible_path(r):
|
|
488
|
+
events.append(("R", r, "view"))
|
|
489
|
+
for w, kind in writes:
|
|
490
|
+
w = w if w == ALL_PATHS else clean(w)
|
|
491
|
+
if w == ALL_PATHS or plausible_path(w):
|
|
492
|
+
events.append(("W", w, kind))
|
|
493
|
+
return events
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
# A pipeline ending in one of these prints a count or a digest, never the
|
|
497
|
+
# content its earlier stages read.
|
|
498
|
+
_SINKS = {"wc", "md5sum", "sha1sum", "sha256sum", "cksum", "xxd", "od"}
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
def _stdout_captured(u: Dict) -> bool:
|
|
502
|
+
"""Whether a unit's standard output goes to a file or /dev/null rather
|
|
503
|
+
than to the terminal the agent reads."""
|
|
504
|
+
for op, target in u["redirs"]:
|
|
505
|
+
base = op.lstrip("0123456789")
|
|
506
|
+
fd = op[:len(op) - len(base)] or "1"
|
|
507
|
+
if base in ("&>", "&>>"):
|
|
508
|
+
return True
|
|
509
|
+
if fd == "1" and base in (">", ">>", ">|") and target not in ("/dev/stdout", "/dev/stderr", "/dev/tty"):
|
|
510
|
+
return True
|
|
511
|
+
return False
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
def _swallowed_pipes(units: List[Dict]) -> set:
|
|
515
|
+
last: Dict[int, Dict] = {}
|
|
516
|
+
for u in units:
|
|
517
|
+
last[u["pipe"]] = u
|
|
518
|
+
out = set()
|
|
519
|
+
for pipe, u in last.items():
|
|
520
|
+
argv = _strip_prefixes(u["argv"])
|
|
521
|
+
verb = argv[0].split("/")[-1] if argv else ""
|
|
522
|
+
flags = [a for a in argv[1:] if a.startswith("-")]
|
|
523
|
+
if verb in _SINKS or (verb in _GREP_VERBS and any(
|
|
524
|
+
f in _GREP_NO_CONTENT or _is_short_flag_with(f, "lLcq") for f in flags)):
|
|
525
|
+
out.add(pipe)
|
|
526
|
+
return out
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
def resolve_path(cwd: Optional[str], p: str) -> str:
|
|
530
|
+
"""`p` as named from working directory `cwd`. An unknown (None) or
|
|
531
|
+
repository-root ('') working directory leaves it as written."""
|
|
532
|
+
if not cwd or p == ALL_PATHS or p.startswith(("/", "~", "$")):
|
|
533
|
+
return p
|
|
534
|
+
return os.path.normpath(os.path.join(cwd, p))
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def _cd_target(cwd: Optional[str], argv: List[str]) -> Optional[str]:
|
|
538
|
+
"""The working directory after `cd`/`pushd` from `cwd`; None when it
|
|
539
|
+
cannot be known (home, a variable, `-`, above the repository root)."""
|
|
540
|
+
if argv[0] == "popd":
|
|
541
|
+
return None
|
|
542
|
+
args = [a for a in argv[1:] if a == "-" or not a.startswith("-")]
|
|
543
|
+
if not args:
|
|
544
|
+
return None
|
|
545
|
+
t = clean(args[0])
|
|
546
|
+
if t.startswith(("~", "$")) or t == "-":
|
|
547
|
+
return None
|
|
548
|
+
if t.startswith("/"):
|
|
549
|
+
return os.path.normpath(t)
|
|
550
|
+
if cwd is None:
|
|
551
|
+
return None
|
|
552
|
+
new = os.path.normpath(os.path.join(cwd, t)) if cwd else os.path.normpath(t)
|
|
553
|
+
if new == ".":
|
|
554
|
+
return ""
|
|
555
|
+
return None if new == ".." or new.startswith("../") else new
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def _units_with_cwd(units: List[Dict], cwd: Optional[str]):
|
|
559
|
+
"""Each unit with the working directory it runs in, and the working
|
|
560
|
+
directory the command leaves at top level. A `cd` applies to the units
|
|
561
|
+
after it at its own subshell depth and deeper, until that subshell ends."""
|
|
562
|
+
at = {0: cwd}
|
|
563
|
+
placed = []
|
|
564
|
+
for u in units:
|
|
565
|
+
d = u.get("depth", 0)
|
|
566
|
+
for k in [k for k in at if k > d]:
|
|
567
|
+
del at[k]
|
|
568
|
+
if d not in at:
|
|
569
|
+
at[d] = at[max(k for k in at if k < d)] if any(k < d for k in at) else cwd
|
|
570
|
+
argv = _strip_prefixes(u["argv"])
|
|
571
|
+
placed.append((u, at[d]))
|
|
572
|
+
if argv and argv[0].split("/")[-1] in ("cd", "pushd", "popd"):
|
|
573
|
+
at[d] = _cd_target(at[d], [argv[0].split("/")[-1]] + argv[1:])
|
|
574
|
+
return placed, at.get(0, cwd)
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def command_cwd(cmd: str, cwd: Optional[str]) -> Optional[str]:
|
|
578
|
+
"""The working directory a command leaves behind, starting from `cwd`."""
|
|
579
|
+
return _units_with_cwd(command_units(cmd), cwd)[1]
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
_SCRIPT_SUFFIXES = (".py", ".pl", ".rb", ".js")
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _run_script(u: Dict) -> Optional[str]:
|
|
586
|
+
"""The script file an interpreter unit runs (`python fix.py ...`)."""
|
|
587
|
+
argv = _strip_prefixes(u["argv"])
|
|
588
|
+
if not argv or not argv[0].split("/")[-1].startswith(_INTERPRETERS) or u["heredoc"] is not None:
|
|
589
|
+
return None
|
|
590
|
+
if any(a in ("-c", "-e", "-m") for a in argv[1:]):
|
|
591
|
+
return None
|
|
592
|
+
arg = next((a for a in argv[1:] if not a.startswith("-")), None)
|
|
593
|
+
return clean(arg) if arg and clean(arg).endswith(_SCRIPT_SUFFIXES) else None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _events_with_verbs(cmd: str, cwd: Optional[str] = None,
|
|
597
|
+
scripts: Optional[Dict[str, str]] = None) -> List[Tuple[str, str, str, str]]:
|
|
598
|
+
"""`scripts`, when given, maps script files the agent created (by a
|
|
599
|
+
heredoc) to their text, and is updated in place: running one of them
|
|
600
|
+
writes the files its text writes."""
|
|
601
|
+
units = command_units(cmd)
|
|
602
|
+
literal_pipes = set()
|
|
603
|
+
for u in units:
|
|
604
|
+
first = u["argv"][0].split("/")[-1] if u["argv"] else ""
|
|
605
|
+
if first in _LITERAL_SOURCES or (first == "cat" and u["heredoc"] is not None):
|
|
606
|
+
literal_pipes.add(u["pipe"])
|
|
607
|
+
swallowed = _swallowed_pipes(units)
|
|
608
|
+
# The last unit of each pipeline whose output goes to a file: what it and
|
|
609
|
+
# every unit before it in the pipeline read never reaches the terminal.
|
|
610
|
+
captured = {u["pipe"]: i for i, u in enumerate(units) if _stdout_captured(u)}
|
|
611
|
+
placed, _ = _units_with_cwd(units, cwd)
|
|
612
|
+
composed: set = set()
|
|
613
|
+
out = []
|
|
614
|
+
for i, (u, here) in enumerate(placed):
|
|
615
|
+
argv = _strip_prefixes(u["argv"])
|
|
616
|
+
verb = argv[0].split("/")[-1] if argv else ""
|
|
617
|
+
if scripts is not None:
|
|
618
|
+
run = _run_script(u)
|
|
619
|
+
if run is not None and resolve_path(here, run) in scripts:
|
|
620
|
+
path = resolve_path(here, run)
|
|
621
|
+
if path in composed:
|
|
622
|
+
# Written and run in this one action: an edit composed now.
|
|
623
|
+
for p, kind in script_write_events(scripts[path]):
|
|
624
|
+
out.append(("W", resolve_path(here, clean(p)), kind, verb))
|
|
625
|
+
else:
|
|
626
|
+
# A saved script is a program: its writes change the files,
|
|
627
|
+
# but running it is not an edit composed in this action
|
|
628
|
+
# against the agent's view of them.
|
|
629
|
+
for p in script_writes(scripts[path]):
|
|
630
|
+
out.append(("W", resolve_path(here, clean(p)), "replace", verb))
|
|
631
|
+
if u["heredoc"] is not None:
|
|
632
|
+
for op, target in u["redirs"]:
|
|
633
|
+
if op.lstrip("0123456789") in (">", ">|") and clean(target).endswith(_SCRIPT_SUFFIXES):
|
|
634
|
+
scripts[resolve_path(here, clean(target))] = u["heredoc"]
|
|
635
|
+
composed.add(resolve_path(here, clean(target)))
|
|
636
|
+
for op, p, kind in _unit_events(u, u["pipe"] in literal_pipes):
|
|
637
|
+
p = resolve_path(here, p)
|
|
638
|
+
kind = map_kind_path(kind, lambda x: resolve_path(here, x))
|
|
639
|
+
# Content piped into wc or a digest never reaches the agent.
|
|
640
|
+
if op == "R" and u["pipe"] in swallowed and not _last_in_pipe(units, u):
|
|
641
|
+
continue
|
|
642
|
+
if op == "R" and captured.get(u["pipe"], -1) >= i:
|
|
643
|
+
continue
|
|
644
|
+
out.append((op, p, kind, verb))
|
|
645
|
+
return out
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _last_in_pipe(units: List[Dict], u: Dict) -> bool:
|
|
649
|
+
same = [x for x in units if x["pipe"] == u["pipe"]]
|
|
650
|
+
return same[-1] is u
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def shell_file_events(cmd: str, cwd: Optional[str] = None,
|
|
654
|
+
scripts: Optional[Dict[str, str]] = None) -> List[FileEvent]:
|
|
655
|
+
"""(op, path, kind) for every file a shell command shows or changes, in
|
|
656
|
+
the order it does so. Relative paths are resolved against `cwd` and any
|
|
657
|
+
`cd` the command makes; with no `cwd` they stay as written."""
|
|
658
|
+
return [(op, p, kind) for op, p, kind, _ in _events_with_verbs(cmd, cwd, scripts)]
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
# bash names the offending line and quotes it; dash gives only its number.
|
|
662
|
+
# Both run every complete command before that line, so the cut is at the
|
|
663
|
+
# line, not at the start of the command.
|
|
664
|
+
_BASH_SYNTAX = re.compile(r"syntax error near unexpected token[^\n]*\n[^\n]*?line (\d+): `(.*)'")
|
|
665
|
+
_DASH_SYNTAX = re.compile(r"(?m)^\S*sh: (\d+): Syntax error:")
|
|
666
|
+
_EOF_ERROR = re.compile(r"unexpected EOF while looking for matching|Unterminated quoted string|end of file unexpected")
|
|
667
|
+
_FORMAT_ERROR = re.compile(r"Format error:|Please always provide EXACTLY ONE action")
|
|
668
|
+
_SED_FAILED = re.compile(r"^sed: ", re.M)
|
|
669
|
+
_TRACEBACK = re.compile(r"Traceback \(most recent call last\)")
|
|
670
|
+
_SCRIPT_SYNTAX = re.compile(r"^(?:SyntaxError|IndentationError|TabError): ", re.M)
|
|
671
|
+
_NOT_FOUND = re.compile(r"(?:python\d?(?:\.\d+)?|perl|ruby|node): (?:command )?not found")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def _line_start(cmd: str, line_no: int) -> int:
|
|
675
|
+
"""Character offset where 1-based line `line_no` begins."""
|
|
676
|
+
pos = 0
|
|
677
|
+
for _ in range(max(0, line_no - 1)):
|
|
678
|
+
nxt = cmd.find("\n", pos)
|
|
679
|
+
if nxt < 0:
|
|
680
|
+
return len(cmd)
|
|
681
|
+
pos = nxt + 1
|
|
682
|
+
return pos
|
|
683
|
+
|
|
684
|
+
|
|
685
|
+
def executed_prefix(cmd: str, observation: str) -> str:
|
|
686
|
+
"""The part of a command the shell actually ran.
|
|
687
|
+
|
|
688
|
+
A syntax error stops the shell at the offending line, after it has run
|
|
689
|
+
every complete command before it; an unterminated quote swallows the rest
|
|
690
|
+
of the text, and we then take nothing to have run."""
|
|
691
|
+
obs = observation or ""
|
|
692
|
+
if _FORMAT_ERROR.search(obs):
|
|
693
|
+
return ""
|
|
694
|
+
m = _BASH_SYNTAX.search(obs)
|
|
695
|
+
if m:
|
|
696
|
+
text = m.group(2).strip()
|
|
697
|
+
idx = cmd.find(text) if text else -1
|
|
698
|
+
if idx >= 0:
|
|
699
|
+
return cmd[:cmd.rfind("\n", 0, idx) + 1] if "\n" in cmd[:idx] else ""
|
|
700
|
+
return cmd[:_line_start(cmd, int(m.group(1)))]
|
|
701
|
+
m = _DASH_SYNTAX.search(obs)
|
|
702
|
+
if m:
|
|
703
|
+
return cmd[:_line_start(cmd, int(m.group(1)))]
|
|
704
|
+
if _EOF_ERROR.search(obs):
|
|
705
|
+
return ""
|
|
706
|
+
return cmd
|
|
707
|
+
|
|
708
|
+
|
|
709
|
+
def shell_events_with_outcome(cmd: str, observation: str = "", returncode: Optional[int] = None,
|
|
710
|
+
cwd: Optional[str] = None,
|
|
711
|
+
scripts: Optional[Dict[str, str]] = None) -> List[Tuple[str, str, str, bool]]:
|
|
712
|
+
"""Events with a `landed` flag read from the command's own output.
|
|
713
|
+
|
|
714
|
+
Only the part of the command the shell ran yields events. A sed that
|
|
715
|
+
printed an error changed nothing, and neither did a script that raised
|
|
716
|
+
and exited nonzero. Other writes are taken to have landed; a sed whose
|
|
717
|
+
pattern matched nothing exits cleanly and prints nothing, and is
|
|
718
|
+
indistinguishable here."""
|
|
719
|
+
obs = observation or ""
|
|
720
|
+
cmd = executed_prefix(cmd, obs)
|
|
721
|
+
sed_failed = bool(_SED_FAILED.search(obs))
|
|
722
|
+
# A script that raised, did not parse, or whose interpreter was missing
|
|
723
|
+
# wrote nothing. A heredoc cut short gives a SyntaxError with no
|
|
724
|
+
# Traceback header.
|
|
725
|
+
never_ran = (bool(_SCRIPT_SYNTAX.search(obs)) and not _TRACEBACK.search(obs) and returncode not in (0, None)) \
|
|
726
|
+
or returncode in (126, 127) or bool(_NOT_FOUND.search(obs))
|
|
727
|
+
script_failed = bool(_TRACEBACK.search(obs)) and returncode not in (0, None)
|
|
728
|
+
out = []
|
|
729
|
+
for op, p, kind, verb in _events_with_verbs(cmd, cwd, scripts):
|
|
730
|
+
landed = True
|
|
731
|
+
if op == "W" and verb == "sed" and sed_failed:
|
|
732
|
+
landed = False
|
|
733
|
+
if verb.startswith(_INTERPRETERS) and never_ran:
|
|
734
|
+
continue # the script never executed: no edit was attempted
|
|
735
|
+
if op == "W" and verb.startswith(_INTERPRETERS) and script_failed:
|
|
736
|
+
landed = False
|
|
737
|
+
out.append((op, p, kind, landed))
|
|
738
|
+
return out
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
# Error lines that name the file an operation failed on.
|
|
742
|
+
_NAMED_FAILURE = re.compile(
|
|
743
|
+
r"sed: can't read (?P<a>[^:\n]+):"
|
|
744
|
+
r"|cannot create (?P<b>[^:\n]+):"
|
|
745
|
+
r"|(?:^|[\s'\"`])(?P<c>[^\s'\"`:]+)['\"`]?: (?:No such file or directory|Permission denied|"
|
|
746
|
+
r"Is a directory|Read-only file system)", re.M)
|
|
747
|
+
_SED_EXPRESSION_ERROR = re.compile(r"^sed: -e expression #\d+, char \d+: ", re.M)
|
|
748
|
+
|
|
749
|
+
|
|
750
|
+
def _same_file(a: str, b: str) -> bool:
|
|
751
|
+
a, b = clean(a), clean(b)
|
|
752
|
+
return a == b or a.endswith("/" + b) or b.endswith("/" + a)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def terminal_events_with_outcome(cmd: str, output: str = "", cwd: Optional[str] = None,
|
|
756
|
+
scripts: Optional[Dict[str, str]] = None) -> List[Tuple[str, str, str, bool]]:
|
|
757
|
+
"""Events for keystrokes typed into a terminal, `landed` read from what
|
|
758
|
+
the terminal then showed.
|
|
759
|
+
|
|
760
|
+
Unlike a subprocess's output, a pane capture carries no exit status, may
|
|
761
|
+
have lost its top lines, and can follow several commands. So a write is
|
|
762
|
+
taken not to have landed only when an error line names its file, or when
|
|
763
|
+
a sed expression error follows the only sed write typed."""
|
|
764
|
+
output = output or ""
|
|
765
|
+
failed = {m.group("a") or m.group("b") or m.group("c") for m in _NAMED_FAILURE.finditer(output)}
|
|
766
|
+
events = _events_with_verbs(cmd, cwd, scripts)
|
|
767
|
+
sed_writes = [i for i, (op, _, _, verb) in enumerate(events) if op == "W" and verb == "sed"]
|
|
768
|
+
lone_sed_failed = len(sed_writes) == 1 and bool(_SED_EXPRESSION_ERROR.search(output))
|
|
769
|
+
out = []
|
|
770
|
+
for i, (op, p, kind, verb) in enumerate(events):
|
|
771
|
+
landed = True
|
|
772
|
+
if op == "W" and (any(_same_file(p, f) for f in failed) or (lone_sed_failed and i == sed_writes[0])):
|
|
773
|
+
landed = False
|
|
774
|
+
out.append((op, p, kind, landed))
|
|
775
|
+
return out
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
# Output that quotes lines of a file: a traceback frame, a compiler or test
|
|
779
|
+
# diagnostic, a grep hit, a diff header. Each is a view of that file, seen
|
|
780
|
+
# after the command ran.
|
|
781
|
+
_OUT_TRACEBACK = re.compile(r'File "([^"<>\n]+)", line \d+')
|
|
782
|
+
_OUT_LOCATED = re.compile(r"(?m)^\s*(?:-->\s+)?(/?(?:[\w.\-]+/)*[\w.\-]+\.[A-Za-z]{1,6}):(\d+)[:\s]")
|
|
783
|
+
_OUT_DIFF = re.compile(r"(?m)^(?:diff --git a/(\S+) b/|\+\+\+ b/(\S+))")
|
|
784
|
+
_OUT_VIEWPORT = re.compile(r"\[VIEWPORT[^:]*:\s*([^\]\n]+)\]")
|
|
785
|
+
_OUT_TAIL_DIFF = re.compile(r"\[TAIL DIFF: File '([^'\n]+)'")
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def output_views(observation: str, cwd: Optional[str] = None) -> List[str]:
|
|
789
|
+
"""Files whose content the command's output quoted.
|
|
790
|
+
|
|
791
|
+
A traceback frame or a `file:line` diagnostic names its file relative to
|
|
792
|
+
where the process ran, `cwd`; a git diff header and a harness viewport
|
|
793
|
+
name it relative to the repository root."""
|
|
794
|
+
obs = observation or ""
|
|
795
|
+
here: List[str] = _OUT_TRACEBACK.findall(obs) + [m[0] for m in _OUT_LOCATED.findall(obs)]
|
|
796
|
+
rooted: List[str] = ([a or b for a, b in _OUT_DIFF.findall(obs)] + [m.strip() for m in _OUT_VIEWPORT.findall(obs)]
|
|
797
|
+
+ _OUT_TAIL_DIFF.findall(obs))
|
|
798
|
+
found = [resolve_path(cwd, clean(p)) for p in here if plausible_path(clean(p))]
|
|
799
|
+
found += [clean(p) for p in rooted if plausible_path(clean(p))]
|
|
800
|
+
return list(dict.fromkeys(found))
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def runs_interpreter(cmd: str) -> bool:
|
|
804
|
+
"""Whether the command runs a script interpreter at all."""
|
|
805
|
+
return any((u["argv"][0].split("/")[-1] if u["argv"] else "").startswith(_INTERPRETERS)
|
|
806
|
+
for u in command_units(cmd))
|