kompactor 0.5.0__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.
Potentially problematic release.
This version of kompactor might be problematic. Click here for more details.
- kompactor/__init__.py +3 -0
- kompactor/__main__.py +4 -0
- kompactor/_launch.py +22 -0
- kompactor/cli.py +446 -0
- kompactor/config.py +231 -0
- kompactor/estimate.py +145 -0
- kompactor/filters.py +617 -0
- kompactor/graph.py +1398 -0
- kompactor/hooks.py +353 -0
- kompactor/installer.py +344 -0
- kompactor/langs.py +102 -0
- kompactor/outline.py +274 -0
- kompactor/prove.py +317 -0
- kompactor/rules.py +54 -0
- kompactor/samples.py +217 -0
- kompactor/server.py +301 -0
- kompactor/shellwrap.py +149 -0
- kompactor/static/fonts/OFL.txt +93 -0
- kompactor/static/fonts/plex-mono-400.woff2 +0 -0
- kompactor/static/fonts/plex-mono-500.woff2 +0 -0
- kompactor/static/fonts/plex-sans-400.woff2 +0 -0
- kompactor/static/fonts/plex-sans-500.woff2 +0 -0
- kompactor/static/fonts/plex-sans-600.woff2 +0 -0
- kompactor/static/logo-mark.svg +5 -0
- kompactor/static/usage.html +342 -0
- kompactor/store.py +213 -0
- kompactor/tokens.py +29 -0
- kompactor/ui.html +749 -0
- kompactor-0.5.0.dist-info/METADATA +274 -0
- kompactor-0.5.0.dist-info/RECORD +34 -0
- kompactor-0.5.0.dist-info/WHEEL +5 -0
- kompactor-0.5.0.dist-info/entry_points.txt +2 -0
- kompactor-0.5.0.dist-info/licenses/LICENSE +21 -0
- kompactor-0.5.0.dist-info/top_level.txt +1 -0
kompactor/filters.py
ADDED
|
@@ -0,0 +1,617 @@
|
|
|
1
|
+
"""Command-output compressors.
|
|
2
|
+
|
|
3
|
+
Each filter takes the (ANSI-stripped) output lines and returns fewer lines that
|
|
4
|
+
carry the same information for an agent. Errors, failures and summaries are
|
|
5
|
+
always preserved; progress bars, passing tests, download chatter, library
|
|
6
|
+
stack frames and repeated lines are not. How aggressive each step is comes
|
|
7
|
+
from the quality-vs-tokens preset (see config.PRESETS).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
import shlex
|
|
13
|
+
from collections import OrderedDict
|
|
14
|
+
|
|
15
|
+
from . import langs
|
|
16
|
+
|
|
17
|
+
ANSI = re.compile(r"\x1b\[[0-9;?]*[ -/]*[@-~]|\x1b\][^\x07]*(\x07|\x1b\\)|\x1b[=>]")
|
|
18
|
+
IMPORTANT = re.compile(r"error|fail|fatal|exception|traceback|panic|denied|not found|cannot|unable|warning: |assert", re.I)
|
|
19
|
+
MAX_LINE = 300
|
|
20
|
+
|
|
21
|
+
DEFAULT_OPTS = {"bash_max_lines": 200, "max_line_chars": 500, "grep_per_file": 12, "test_hide_passing": True, "test_boilerplate": False,
|
|
22
|
+
"trim_stack_frames": True,
|
|
23
|
+
"diff_context": -1, "git_log_compact": True, "languages": []}
|
|
24
|
+
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
# command classification
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
WRAPPERS = {"sudo", "time", "nice", "env", "npx", "bunx", "pnpx", "command"}
|
|
30
|
+
RUN_PREFIXES = (("uv", "run"), ("poetry", "run"), ("pipenv", "run"), ("python", "-m"), ("python3", "-m"),
|
|
31
|
+
("bundle", "exec"), ("npm", "exec"), ("pnpm", "exec"), ("yarn", "exec"), ("pdm", "run"),
|
|
32
|
+
("hatch", "run"), ("rye", "run"))
|
|
33
|
+
BUILD_TOOLS = {"npm", "pnpm", "yarn", "pip", "pip3", "uv", "poetry", "pdm", "cargo", "go", "mvn", "gradle", "gradlew",
|
|
34
|
+
"docker", "apt", "apt-get", "brew", "bundle", "composer", "make", "cmake", "tsc", "bun", "conda",
|
|
35
|
+
"vite", "next", "webpack", "turbo", "nx", "rollup", "esbuild", "parcel", "expo", "docker-compose"}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
SETUP_WORDS = {"cd", "pushd", "popd", "export", "source", ".", "set", "unset", "alias", "ulimit", "umask", "exit", "true", ":"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def argv0(cmd: str):
|
|
42
|
+
"""Return the meaningful words of the first real command in a shell string,
|
|
43
|
+
skipping setup steps like `cd repo &&` or `export X=1;`."""
|
|
44
|
+
words = []
|
|
45
|
+
for seg in re.split(r"&&|;|\|\|", cmd.split("|", 1)[0] if "||" not in cmd else cmd):
|
|
46
|
+
try:
|
|
47
|
+
words = shlex.split(seg)
|
|
48
|
+
except ValueError:
|
|
49
|
+
words = seg.split()
|
|
50
|
+
if words and words[0] not in SETUP_WORDS:
|
|
51
|
+
break
|
|
52
|
+
words = []
|
|
53
|
+
while words and (re.match(r"^\w+=", words[0]) or words[0] in WRAPPERS):
|
|
54
|
+
words = words[1:]
|
|
55
|
+
while len(words) >= 2 and tuple(words[:2]) in RUN_PREFIXES:
|
|
56
|
+
words = words[2:]
|
|
57
|
+
if words:
|
|
58
|
+
words[0] = os.path.basename(words[0])
|
|
59
|
+
return words
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def classify(cmd: str) -> str:
|
|
63
|
+
w = argv0(cmd)
|
|
64
|
+
if not w:
|
|
65
|
+
return "generic"
|
|
66
|
+
p, sub = w[0], (w[1] if len(w) > 1 else "")
|
|
67
|
+
if p == "git":
|
|
68
|
+
if sub == "status":
|
|
69
|
+
return "git_status"
|
|
70
|
+
if sub == "log":
|
|
71
|
+
return "git_log"
|
|
72
|
+
if sub in ("diff", "show"):
|
|
73
|
+
return "git_diff"
|
|
74
|
+
if sub in ("push", "pull", "fetch", "clone", "checkout", "switch", "merge", "rebase", "submodule"):
|
|
75
|
+
return "git_net"
|
|
76
|
+
if sub == "grep":
|
|
77
|
+
return "grep"
|
|
78
|
+
if sub == "ls-files":
|
|
79
|
+
return "listing"
|
|
80
|
+
if p in ("pytest", "py.test", "jest", "vitest", "mocha", "ava", "tap", "rspec", "phpunit", "ctest", "tox", "nox",
|
|
81
|
+
"playwright", "cypress", "gotestsum", "unittest"):
|
|
82
|
+
return "test"
|
|
83
|
+
if p in ("go", "cargo", "dotnet", "mix", "deno", "bun", "swift") and sub == "test":
|
|
84
|
+
return "test"
|
|
85
|
+
if p in ("npm", "pnpm", "yarn") and ("test" in sub or (sub == "run" and len(w) > 2 and "test" in w[2])):
|
|
86
|
+
return "test"
|
|
87
|
+
if p in ("make", "gradle", "gradlew", "mvn", "mvnw", "turbo", "nx") and any("test" in x for x in w[1:]):
|
|
88
|
+
return "test"
|
|
89
|
+
if p in BUILD_TOOLS:
|
|
90
|
+
return "build"
|
|
91
|
+
if p in ("find", "fd", "tree") or (p == "ls" and any(a.startswith("-") and "R" in a for a in w[1:])):
|
|
92
|
+
return "listing"
|
|
93
|
+
if p in ("grep", "egrep", "rg", "ag", "ack"):
|
|
94
|
+
return "grep"
|
|
95
|
+
if p in ("cat", "head", "tail", "nl", "bat", "less", "more", "type") or (p == "sed" and "-n" in w[1:]):
|
|
96
|
+
return "view"
|
|
97
|
+
return "generic"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ---------------------------------------------------------------------------
|
|
101
|
+
# generic passes
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def clean(text: str):
|
|
105
|
+
text = ANSI.sub("", text)
|
|
106
|
+
lines = []
|
|
107
|
+
for ln in text.split("\n"):
|
|
108
|
+
if "\r" in ln: # progress redraws: keep only the final frame
|
|
109
|
+
parts = [p for p in ln.split("\r") if p.strip()]
|
|
110
|
+
ln = parts[-1] if parts else ""
|
|
111
|
+
lines.append(ln.rstrip())
|
|
112
|
+
while lines and not lines[-1]:
|
|
113
|
+
lines.pop()
|
|
114
|
+
return lines
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# A progress line needs a real progress signal: a bar, a spinner, curl's meter, or a
|
|
118
|
+
# transfer speed next to a percentage/count. A bare "50%" is data, not progress.
|
|
119
|
+
BAR = re.compile(r"[█▓▒░━■▇▆▅▄▃▂▁]{3,}|\[[#=>\-.\s]{6,}\]|[⠁-⣿]|--:--:--")
|
|
120
|
+
SPEED = re.compile(r"\d+(\.\d+)?\s?[kKMG]i?B/s|\beta\b|\bETA\b|\d+(\.\d+)?\s?it/s")
|
|
121
|
+
PCT_OR_COUNT = re.compile(r"\b\d{1,3}(\.\d+)?%|\b\d+/\d+\b")
|
|
122
|
+
LEADING_PCT = re.compile(r"^\s*\d{1,3}%\s") # webpack/vite "45% building …"
|
|
123
|
+
TEST_RESULT = re.compile(r"\b(PASS|PASSED|FAIL|FAILED|SKIP|SKIPPED|XFAIL|XPASS|ERROR|ok)\b")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def is_progress(ln: str) -> bool:
|
|
127
|
+
if len(ln) >= 200 or IMPORTANT.search(ln) or TEST_RESULT.search(ln) or ln.startswith(("+", "-", "@@")):
|
|
128
|
+
return False
|
|
129
|
+
return bool(BAR.search(ln) or LEADING_PCT.match(ln) or (SPEED.search(ln) and PCT_OR_COUNT.search(ln)))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def drop_progress(lines):
|
|
133
|
+
"""Drop progress bars and download meters (never data, test results, diff lines or errors)."""
|
|
134
|
+
return [ln for ln in lines if not is_progress(ln)]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _shape(ln: str) -> str:
|
|
138
|
+
return re.sub(r"0x[0-9a-f]+|\d+(\.\d+)?", "#", ln)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
LOG_LINE = re.compile(r"^\s*[\[(]?(\d{2,4}[-/.]\d{2}[-/.]\d{2}|\d{2}:\d{2}:\d{2}|#\d+ \d)")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def collapse_repeats(lines, min_run: int = 4, shapes: str = "none"):
|
|
145
|
+
"""Collapse runs of identical lines ("[x50]"). With shapes="all" (build output) or
|
|
146
|
+
"logs" (timestamped log lines only), also collapse runs of lines that differ just
|
|
147
|
+
in numbers ("… 120 similar lines …"); never for tables or data, where the
|
|
148
|
+
numbers are the information."""
|
|
149
|
+
out, i = [], 0
|
|
150
|
+
while i < len(lines):
|
|
151
|
+
j = i + 1
|
|
152
|
+
exact = True
|
|
153
|
+
shape_ok = shapes == "all" or (shapes == "logs" and bool(LOG_LINE.match(lines[i])))
|
|
154
|
+
while j < len(lines) and (lines[j] == lines[i] or (shape_ok and _shape(lines[j]) == _shape(lines[i]))):
|
|
155
|
+
exact &= lines[j] == lines[i]
|
|
156
|
+
j += 1
|
|
157
|
+
run = j - i
|
|
158
|
+
if run >= (min_run if exact else max(min_run, 6)) and lines[i].strip():
|
|
159
|
+
if exact:
|
|
160
|
+
out.append(f"{lines[i]} [x{run}]")
|
|
161
|
+
else:
|
|
162
|
+
out += [lines[i], f" … {run - 2} similar lines …", lines[j - 1]]
|
|
163
|
+
elif run >= 2 and not lines[i].strip():
|
|
164
|
+
out.append("") # squeeze blank lines
|
|
165
|
+
else:
|
|
166
|
+
out += lines[i:j]
|
|
167
|
+
i = j
|
|
168
|
+
return out
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def truncate_long(lines, limit: int = MAX_LINE):
|
|
172
|
+
return [ln if len(ln) <= limit else f"{ln[:limit]}…[+{len(ln) - limit} chars]" for ln in lines]
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def cap(lines, max_lines: int, head: int, tail: int, stash_id=None):
|
|
176
|
+
"""Keep head and tail; from the middle keep only lines that look important."""
|
|
177
|
+
if len(lines) <= max_lines:
|
|
178
|
+
return lines
|
|
179
|
+
middle = lines[head:len(lines) - tail]
|
|
180
|
+
keep = [ln for ln in middle if IMPORTANT.search(ln)][:30]
|
|
181
|
+
ref = f"; full output: kompactor show {stash_id}" if stash_id else ""
|
|
182
|
+
note = f"… {len(middle) - len(keep)} lines omitted{ref} …"
|
|
183
|
+
return lines[:head] + [note] + keep + (["… (end of omitted section) …"] if keep else []) + lines[-tail:]
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
# ---------------------------------------------------------------------------
|
|
187
|
+
# stack traces: collapse frames that live in libraries / the runtime
|
|
188
|
+
|
|
189
|
+
PY_FRAME = re.compile(r'^\s*File "([^"]+)", line \d+')
|
|
190
|
+
PYTEST_FRAME = re.compile(r"^(\S+\.py):\d+: in \S+")
|
|
191
|
+
NODE_FRAME = re.compile(r"^\s+at \S")
|
|
192
|
+
GO_FUNC = re.compile(r"^([\w./*()\-\[\]{},]+\(.*\)|created by .+)$")
|
|
193
|
+
GO_FILE = re.compile(r"^\t\S+\.go:\d+")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _frames(lines):
|
|
197
|
+
"""Yield (start, end, header_text, innermost_last) for each stack frame found in lines."""
|
|
198
|
+
i, n = 0, len(lines)
|
|
199
|
+
while i < n:
|
|
200
|
+
ln = lines[i]
|
|
201
|
+
if PY_FRAME.match(ln):
|
|
202
|
+
ind = len(ln) - len(ln.lstrip())
|
|
203
|
+
j = i + 1
|
|
204
|
+
while j < n and lines[j].strip() and len(lines[j]) - len(lines[j].lstrip()) > ind \
|
|
205
|
+
and not PY_FRAME.match(lines[j]):
|
|
206
|
+
j += 1
|
|
207
|
+
yield i, j, ln, True
|
|
208
|
+
i = j
|
|
209
|
+
elif PYTEST_FRAME.match(ln):
|
|
210
|
+
j = i + 1
|
|
211
|
+
while j < n and not PYTEST_FRAME.match(lines[j]) and not lines[j].startswith(("E ", "_ _", "=", "___")):
|
|
212
|
+
j += 1
|
|
213
|
+
yield i, j, ln, True
|
|
214
|
+
i = j
|
|
215
|
+
elif NODE_FRAME.match(ln):
|
|
216
|
+
yield i, i + 1, ln, False
|
|
217
|
+
i += 1
|
|
218
|
+
elif GO_FUNC.match(ln) and i + 1 < n and GO_FILE.match(lines[i + 1]):
|
|
219
|
+
yield i, i + 2, lines[i + 1], False
|
|
220
|
+
i += 2
|
|
221
|
+
else:
|
|
222
|
+
i += 1
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def trim_frames(lines, lib_re):
|
|
226
|
+
if lib_re is None:
|
|
227
|
+
return lines
|
|
228
|
+
frames = list(_frames(lines))
|
|
229
|
+
if len(frames) < 3:
|
|
230
|
+
return lines
|
|
231
|
+
out, pos, k = [], 0, 0
|
|
232
|
+
while k < len(frames):
|
|
233
|
+
s, e, head, innermost_last = frames[k]
|
|
234
|
+
if not lib_re.search(head):
|
|
235
|
+
k += 1
|
|
236
|
+
continue
|
|
237
|
+
# gather a run of consecutive library frames
|
|
238
|
+
run = [frames[k]]
|
|
239
|
+
while k + len(run) < len(frames) and frames[k + len(run)][0] == run[-1][1] \
|
|
240
|
+
and lib_re.search(frames[k + len(run)][2]):
|
|
241
|
+
run.append(frames[k + len(run)])
|
|
242
|
+
nxt = k + len(run)
|
|
243
|
+
ends_trace = nxt >= len(frames) or frames[nxt][0] != run[-1][1]
|
|
244
|
+
if len(run) >= 2:
|
|
245
|
+
# Python prints the raising frame last; keep it. Go/Node print it first.
|
|
246
|
+
hide = run[:-1] if ends_trace and innermost_last else run
|
|
247
|
+
out += lines[pos:hide[0][0]]
|
|
248
|
+
out.append(f"{' ' * (len(head) - len(head.lstrip()))}[… {len(hide)} library frames hidden …]")
|
|
249
|
+
pos = hide[-1][1]
|
|
250
|
+
k = nxt
|
|
251
|
+
out += lines[pos:]
|
|
252
|
+
return out
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
# ---------------------------------------------------------------------------
|
|
256
|
+
# specific filters
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def f_git_status(lines, opts):
|
|
260
|
+
if not any(ln.startswith(("On branch", "HEAD detached", "Changes", "Untracked", "nothing to commit")) for ln in lines):
|
|
261
|
+
return lines # already short format
|
|
262
|
+
out, section, groups = [], None, OrderedDict()
|
|
263
|
+
labels = {"Changes to be committed": "staged", "Changes not staged for commit": "unstaged",
|
|
264
|
+
"Untracked files": "untracked", "Unmerged paths": "conflicts"}
|
|
265
|
+
for ln in lines:
|
|
266
|
+
s = ln.strip()
|
|
267
|
+
if not s or s.startswith("(use ") or s.startswith("(commit or discard") or s.startswith("no changes added"):
|
|
268
|
+
continue
|
|
269
|
+
if s.rstrip(":") in labels:
|
|
270
|
+
section = labels[s.rstrip(":")]
|
|
271
|
+
continue
|
|
272
|
+
if ln.startswith(("\t", " ")) and section:
|
|
273
|
+
m = re.match(r"(new file|modified|deleted|renamed|typechange|both modified|both added|deleted by \w+):\s+(.*)", s)
|
|
274
|
+
item = f"{m.group(1).split()[0][0].upper() if m.group(1) != 'renamed' else 'R'} {m.group(2)}" if m else s
|
|
275
|
+
groups.setdefault(section, []).append(item)
|
|
276
|
+
continue
|
|
277
|
+
out.append(s)
|
|
278
|
+
for sec, items in groups.items():
|
|
279
|
+
shown = items[:40] + ([f"… +{len(items) - 40} more"] if len(items) > 40 else [])
|
|
280
|
+
out.append(f"{sec} ({len(items)}): " + ", ".join(shown))
|
|
281
|
+
return out
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def f_git_log(lines, opts):
|
|
285
|
+
"""One line per commit (sha, date, author, subject). Whatever follows the message (--stat,
|
|
286
|
+
--name-only, --name-status and --numstat file lines, or the patch for -p) is kept as is."""
|
|
287
|
+
if not opts["git_log_compact"] or not lines or not lines[0].startswith("commit "):
|
|
288
|
+
return lines
|
|
289
|
+
out, cur = [], None
|
|
290
|
+
for ln in lines:
|
|
291
|
+
if ln.startswith("commit "):
|
|
292
|
+
if cur:
|
|
293
|
+
out.append(cur)
|
|
294
|
+
cur = {"sha": ln.split()[1][:9], "author": "", "date": "", "msg": None, "extra": "", "files": [],
|
|
295
|
+
"patch": False}
|
|
296
|
+
elif cur is None:
|
|
297
|
+
continue
|
|
298
|
+
elif cur["patch"] or ln.startswith("diff --git "):
|
|
299
|
+
cur["patch"] = True
|
|
300
|
+
cur["files"].append(ln)
|
|
301
|
+
elif ln.startswith("Author:") and cur["msg"] is None:
|
|
302
|
+
cur["author"] = re.sub(r"\s*<.*>", "", ln[7:].strip())
|
|
303
|
+
elif ln.startswith("Date:") and cur["msg"] is None:
|
|
304
|
+
cur["date"] = ln[5:].strip()
|
|
305
|
+
elif ln.startswith("Merge:") and cur["msg"] is None:
|
|
306
|
+
cur["extra"] = " (merge)"
|
|
307
|
+
elif ln.startswith(" "):
|
|
308
|
+
if cur["msg"] is None and ln.strip():
|
|
309
|
+
cur["msg"] = ln.strip()
|
|
310
|
+
elif re.match(r"^\s*\d+ files? changed", ln):
|
|
311
|
+
cur["extra"] += " | " + ln.strip()
|
|
312
|
+
elif ln.strip():
|
|
313
|
+
cur["files"].append(ln)
|
|
314
|
+
if cur:
|
|
315
|
+
out.append(cur)
|
|
316
|
+
res = []
|
|
317
|
+
for c in out:
|
|
318
|
+
res.append(f"{c['sha']} {_short_date(c['date'])} {c['author']}: {c['msg'] or ''}{c['extra']}")
|
|
319
|
+
res.extend(c["files"])
|
|
320
|
+
return res
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _short_date(d: str) -> str:
|
|
324
|
+
m = re.match(r"\w{3} (\w{3}) (\d+) [\d:]+ (\d{4})", d)
|
|
325
|
+
return f"{m.group(3)}-{m.group(1)}-{int(m.group(2)):02d}" if m else d
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def f_git_diff(lines, opts):
|
|
329
|
+
lock_re = langs.lockfile_re(opts["languages"])
|
|
330
|
+
gen_re = langs.generated_re(opts["languages"])
|
|
331
|
+
out, skipping, added, removed, name, why = [], False, 0, 0, "", ""
|
|
332
|
+
|
|
333
|
+
def flush():
|
|
334
|
+
if skipping:
|
|
335
|
+
out.append(f"[{name}: {why} diff, +{added} -{removed} lines omitted]")
|
|
336
|
+
|
|
337
|
+
for ln in lines:
|
|
338
|
+
if ln.startswith("diff --git"):
|
|
339
|
+
flush()
|
|
340
|
+
name = ln.split(" b/")[-1]
|
|
341
|
+
why = "lock file" if lock_re.search(name) else "generated file" if gen_re and gen_re.search(name) else ""
|
|
342
|
+
skipping = bool(why)
|
|
343
|
+
added = removed = 0
|
|
344
|
+
out.append(ln)
|
|
345
|
+
continue
|
|
346
|
+
if skipping:
|
|
347
|
+
added += ln.startswith("+") and not ln.startswith("+++")
|
|
348
|
+
removed += ln.startswith("-") and not ln.startswith("---")
|
|
349
|
+
continue
|
|
350
|
+
if ln.startswith("index ") or ln.startswith("similarity index"):
|
|
351
|
+
continue
|
|
352
|
+
out.append(ln)
|
|
353
|
+
flush()
|
|
354
|
+
ctx = opts["diff_context"]
|
|
355
|
+
return trim_diff_context(out, ctx) if ctx is not None and ctx >= 0 else out
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def trim_diff_context(lines, ctx: int):
|
|
359
|
+
"""Keep only `ctx` unchanged lines around each change inside hunks."""
|
|
360
|
+
out, hunk = [], []
|
|
361
|
+
|
|
362
|
+
def flush():
|
|
363
|
+
if not hunk:
|
|
364
|
+
return
|
|
365
|
+
changed = [i for i, ln in enumerate(hunk) if ln[:1] in "+-"]
|
|
366
|
+
keep = set()
|
|
367
|
+
for i in changed:
|
|
368
|
+
keep.update(range(i - ctx, i + ctx + 1))
|
|
369
|
+
gap = 0
|
|
370
|
+
for i, ln in enumerate(hunk):
|
|
371
|
+
if i in keep or ln[:1] not in " " or ln.startswith("\\"):
|
|
372
|
+
if gap:
|
|
373
|
+
out.append(f" ⋯ {gap} unchanged")
|
|
374
|
+
gap = 0
|
|
375
|
+
out.append(ln)
|
|
376
|
+
else:
|
|
377
|
+
gap += 1
|
|
378
|
+
if gap:
|
|
379
|
+
out.append(f" ⋯ {gap} unchanged")
|
|
380
|
+
hunk.clear()
|
|
381
|
+
|
|
382
|
+
in_hunk = False
|
|
383
|
+
for ln in lines:
|
|
384
|
+
if ln.startswith("@@"):
|
|
385
|
+
flush()
|
|
386
|
+
out.append(ln)
|
|
387
|
+
in_hunk = True
|
|
388
|
+
elif in_hunk and (ln[:1] in " +-\\" or ln == "") and not ln.startswith(("diff --git", "--- ", "+++ ")):
|
|
389
|
+
hunk.append(ln if ln else " ")
|
|
390
|
+
else:
|
|
391
|
+
flush()
|
|
392
|
+
in_hunk = ln.startswith("@@")
|
|
393
|
+
out.append(ln)
|
|
394
|
+
flush()
|
|
395
|
+
return out
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
GIT_NOISE = re.compile(r"^(remote: )?(Enumerating|Counting|Compressing|Writing|Receiving|Resolving|Total \d|"
|
|
399
|
+
r"Delta compression|Unpacking|Updating files|Filtering content)")
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def f_git_net(lines, opts):
|
|
403
|
+
return [ln for ln in lines if not GIT_NOISE.match(ln)]
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
PASSING = re.compile(
|
|
407
|
+
r"(\bPASS(ED)?\b(?!.*\bFAIL)|^\s*[✓✔√]|^\s*ok\s|^--- PASS|^=== (RUN|PAUSE|CONT|NAME)|\.\.\. ok$|^\s*test \S+ \.\.\. ok"
|
|
408
|
+
r"|^\?\s+\S+\s+\[no test files\]|^PASS\s|\bpassed\b.*\d+ms$|^\s*\.+\s*\[\s*\d+%\]$)")
|
|
409
|
+
FAILED = re.compile(r"\b(FAILED|FAIL|failed|failures?|ERROR|panicked|panic:)\b")
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
TEST_BOILER = re.compile(
|
|
413
|
+
r"^(platform \w+ -- Python|rootdir:|configfile:|plugins:|cachedir:|collected \d+ items?|"
|
|
414
|
+
r"=+ (test session starts|short test summary info) =+$|(_ )+_?$|\s*⎯{5,}|\s*RUN\s+v\d|\s+Start at\s|\s+Duration\s)")
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def f_test(lines, opts):
|
|
418
|
+
if not opts["test_hide_passing"]:
|
|
419
|
+
return lines
|
|
420
|
+
kept = [ln for ln in lines if not PASSING.search(ln) and not (BUILD_NOISE.match(ln) and not IMPORTANT.search(ln))]
|
|
421
|
+
if opts["test_boilerplate"]:
|
|
422
|
+
kept = [ln for ln in kept if not TEST_BOILER.match(ln)]
|
|
423
|
+
dropped = len(lines) - len(kept)
|
|
424
|
+
failed = any(FAILED.search(ln) for ln in kept)
|
|
425
|
+
if not failed and len(kept) > 40:
|
|
426
|
+
# green run: the summary at the bottom is what matters
|
|
427
|
+
kept = [f"… {len(kept) - 25} lines of passing-run output omitted …"] + kept[-25:]
|
|
428
|
+
if dropped:
|
|
429
|
+
kept.append(f"[kompactor: {dropped} passing-test lines hidden]")
|
|
430
|
+
return kept
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
BUILD_NOISE = re.compile(
|
|
434
|
+
r"^\s*(Downloading|Downloaded|Collecting|Requirement already satisfied|Using cached|Obtaining|Fetching|Fetched|"
|
|
435
|
+
r"Compiling|Checking|Fresh|Installing collected|Preparing metadata|Building wheel|Created wheel|Stored in directory|"
|
|
436
|
+
r"Resolving|Resolved|Prepared|Audited|Get:\d|Hit:\d|Ign:\d|Reading (package|state)|Building dependency tree|"
|
|
437
|
+
r"Unpacking|Selecting previously|Setting up|Processing triggers|\[INFO\] Download|Progress \(\d|"
|
|
438
|
+
r"go: (downloading|finding|extracting)|"
|
|
439
|
+
r"#\d+ (sha256|extracting|resolve|transferring|DONE|CACHED|\[internal\])|npm (http|timing|sill|verb))", re.I)
|
|
440
|
+
PKG_LINE = re.compile(r"^\s*([+\-~]) ([\w.\-\[\]]+)[=@ ]=?\s*v?[\w.+\-]+$") # uv / pnpm "+ pkg==1.2" lines
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def f_build(lines, opts):
|
|
444
|
+
out, warned, pkgs = [], OrderedDict(), {"+": [], "-": [], "~": []}
|
|
445
|
+
for ln in lines:
|
|
446
|
+
if BUILD_NOISE.match(ln) and not IMPORTANT.search(ln):
|
|
447
|
+
continue
|
|
448
|
+
m = re.match(r"(npm WARN deprecated|warning:? deprecated|DEPRECATION:)\s*(\S+)", ln, re.I)
|
|
449
|
+
if m:
|
|
450
|
+
warned[m.group(2)] = 1
|
|
451
|
+
continue
|
|
452
|
+
m = PKG_LINE.match(ln)
|
|
453
|
+
if m:
|
|
454
|
+
pkgs[m.group(1)].append(m.group(2))
|
|
455
|
+
continue
|
|
456
|
+
out.append(ln)
|
|
457
|
+
for sign, label in (("+", "added"), ("-", "removed"), ("~", "changed")):
|
|
458
|
+
if pkgs[sign]:
|
|
459
|
+
names = pkgs[sign]
|
|
460
|
+
out.append(f"[{len(names)} packages {label}: {', '.join(names[:10])}{' …' if len(names) > 10 else ''}]")
|
|
461
|
+
if warned:
|
|
462
|
+
out.append(f"[{len(warned)} deprecation warnings: {', '.join(list(warned)[:8])}{' …' if len(warned) > 8 else ''}]")
|
|
463
|
+
return out
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def f_listing(lines, opts):
|
|
467
|
+
if len(lines) <= 60:
|
|
468
|
+
return lines
|
|
469
|
+
by_dir = OrderedDict()
|
|
470
|
+
if sum(ln.endswith(":") for ln in lines) >= 2: # `ls -R`: "dir:" headers followed by names
|
|
471
|
+
cur = "."
|
|
472
|
+
for ln in lines:
|
|
473
|
+
if ln.endswith(":"):
|
|
474
|
+
cur = ln[:-1]
|
|
475
|
+
by_dir.setdefault(cur, [])
|
|
476
|
+
elif ln.strip():
|
|
477
|
+
by_dir.setdefault(cur, []).extend(ln.split())
|
|
478
|
+
lines = [f for fs in by_dir.values() for f in fs]
|
|
479
|
+
else:
|
|
480
|
+
for ln in lines:
|
|
481
|
+
d, _, f = ln.strip().rpartition("/")
|
|
482
|
+
by_dir.setdefault(d or ".", []).append(f)
|
|
483
|
+
out = [f"[{len(lines)} paths in {len(by_dir)} directories]"]
|
|
484
|
+
for d, files in by_dir.items():
|
|
485
|
+
shown = ", ".join(files[:8]) + (f", … +{len(files) - 8}" if len(files) > 8 else "")
|
|
486
|
+
out.append(f"{d}/ ({len(files)}): {shown}")
|
|
487
|
+
return out
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def f_grep(lines, opts):
|
|
491
|
+
limit = opts["grep_per_file"]
|
|
492
|
+
per_file, out, hidden = {}, [], {}
|
|
493
|
+
for ln in lines:
|
|
494
|
+
m = re.match(r"^([^:\s][^:]*):(\d+[:-])?", ln)
|
|
495
|
+
f = m.group(1) if m else None
|
|
496
|
+
if f:
|
|
497
|
+
per_file[f] = per_file.get(f, 0) + 1
|
|
498
|
+
if per_file[f] > limit:
|
|
499
|
+
hidden[f] = hidden.get(f, 0) + 1
|
|
500
|
+
continue
|
|
501
|
+
out.append(ln)
|
|
502
|
+
for f, n in hidden.items():
|
|
503
|
+
out.append(f"[{f}: +{n} more matches]")
|
|
504
|
+
return truncate_long(out, 220)
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
FILTERS = {"git_status": f_git_status, "git_log": f_git_log, "git_diff": f_git_diff, "git_net": f_git_net,
|
|
508
|
+
"test": f_test, "build": f_build, "listing": f_listing, "grep": f_grep}
|
|
509
|
+
|
|
510
|
+
# ---------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def compress(cmd: str, output: str, exit_code: int = 0, stash_id=None, opts=None, max_lines=None) -> str:
|
|
514
|
+
"""Compress a command's combined output. Pure function; safe on any input."""
|
|
515
|
+
o = dict(DEFAULT_OPTS)
|
|
516
|
+
o.update({k: v for k, v in (opts or {}).items() if k in DEFAULT_OPTS})
|
|
517
|
+
if max_lines:
|
|
518
|
+
o["bash_max_lines"] = max_lines
|
|
519
|
+
kind = classify(cmd)
|
|
520
|
+
lines = clean(output)
|
|
521
|
+
m = o["bash_max_lines"]
|
|
522
|
+
if kind == "view" or _piped(cmd):
|
|
523
|
+
# Claude asked for exactly this: a file, or output it shaped itself with a pipe or a format flag
|
|
524
|
+
# (counts, lists, JSON). Keep it verbatim; only cap huge ones, never below 400 lines.
|
|
525
|
+
m = max(m * 2, 400)
|
|
526
|
+
return "\n".join(cap(lines, m, head=m // 2, tail=m // 3, stash_id=stash_id))
|
|
527
|
+
if kind in FILTERS:
|
|
528
|
+
lines = FILTERS[kind](lines, o)
|
|
529
|
+
if o["trim_stack_frames"] and kind != "git_diff":
|
|
530
|
+
lines = trim_frames(lines, langs.library_frame_re(o["languages"]))
|
|
531
|
+
lines = drop_progress(lines)
|
|
532
|
+
lines = collapse_repeats(lines, shapes={"build": "all", "generic": "logs", "test": "logs"}.get(kind, "none"))
|
|
533
|
+
lines = truncate_long(lines, o["max_line_chars"])
|
|
534
|
+
lines = cap(lines, m, head=m // 2, tail=m // 3, stash_id=stash_id)
|
|
535
|
+
return "\n".join(lines)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def _piped(cmd: str) -> bool:
|
|
539
|
+
"""Output already shaped by the user's own pipeline/format flags."""
|
|
540
|
+
return bool(re.search(r"(?<!\|)\|(?!\|)", cmd)) or "--format" in cmd or "--pretty" in cmd or "--porcelain" in cmd \
|
|
541
|
+
or "--json" in cmd
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# ---------------------------------------------------------------------------
|
|
545
|
+
# commands the shell prefix may buffer: finite and noisy (tests, builds, linters,
|
|
546
|
+
# installs). Never servers, watchers or anything interactive: buffering those would
|
|
547
|
+
# hide their output until they exit.
|
|
548
|
+
|
|
549
|
+
LINTERS = {"tsc", "vue-tsc", "svelte-check", "eslint", "oxlint", "biome", "prettier", "stylelint", "ruff", "mypy",
|
|
550
|
+
"pyright", "basedpyright", "flake8", "pylint", "black", "isort", "bandit", "golangci-lint", "staticcheck",
|
|
551
|
+
"revive", "shellcheck", "hadolint", "rubocop", "markdownlint"}
|
|
552
|
+
FINITE_SCRIPT = re.compile(r"^(build|lint|typecheck|type-check|types|check|test|tsc|compile|ci|validate|verify|e2e|"
|
|
553
|
+
r"format:check|fmt:check)(:[\w:-]+)?$")
|
|
554
|
+
INSTALL_WORDS = {"install", "i", "ci", "add", "sync", "update", "upgrade", "lock", "remove", "uninstall", "audit",
|
|
555
|
+
"outdated", "dedupe", "rebuild", "prune"}
|
|
556
|
+
LONG_RUNNING = re.compile(r"(^|\s)(--watch\w*|-w|--ui|--follow|--serve|--open)(\s|=|$)")
|
|
557
|
+
SAFE_STEPS = {"echo", "printf", "ls", "pwd", "mkdir", "rm", "cp", "mv", "touch", "cat", "which", "true", "false", "git",
|
|
558
|
+
"rmdir", "ln", "chmod", "date", "wc"}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _wrappable_words(w) -> bool:
|
|
562
|
+
if not w:
|
|
563
|
+
return True
|
|
564
|
+
p, rest = w[0], w[1:]
|
|
565
|
+
sub = rest[0] if rest else ""
|
|
566
|
+
if any(a in ("watch", "dev", "serve", "start", "open", "run-server", "runserver", "up") for a in rest[:2]):
|
|
567
|
+
return False
|
|
568
|
+
if p in ("pytest", "py.test", "jest", "vitest", "mocha", "ava", "tap", "rspec", "phpunit", "ctest", "tox", "nox",
|
|
569
|
+
"playwright", "cypress", "gotestsum", "unittest") or p in LINTERS:
|
|
570
|
+
return True
|
|
571
|
+
if p == "go":
|
|
572
|
+
return sub in ("test", "build", "vet", "generate", "install", "mod")
|
|
573
|
+
if p == "cargo":
|
|
574
|
+
return sub in ("test", "build", "check", "clippy", "fetch", "doc", "fmt", "nextest")
|
|
575
|
+
if p in ("npm", "pnpm", "yarn", "bun"):
|
|
576
|
+
if sub in INSTALL_WORDS or (p == "yarn" and not sub) or sub == "test":
|
|
577
|
+
return True
|
|
578
|
+
if sub in ("run", "run-script"):
|
|
579
|
+
return len(rest) > 1 and bool(FINITE_SCRIPT.match(rest[1]))
|
|
580
|
+
return p != "npm" and bool(FINITE_SCRIPT.match(sub))
|
|
581
|
+
if p in ("pip", "pip3", "poetry", "pdm", "uv", "conda", "bundle", "composer"):
|
|
582
|
+
return sub in INSTALL_WORDS or (sub == "pip" and len(rest) > 1 and rest[1] in INSTALL_WORDS)
|
|
583
|
+
if p in ("make", "ninja", "cmake"):
|
|
584
|
+
return not any(a in ("run", "serve", "dev", "watch", "start") for a in rest)
|
|
585
|
+
if p in ("mvn", "mvnw", "gradle", "gradlew", "./mvnw", "./gradlew"):
|
|
586
|
+
return not any(a in ("run", "bootRun", "spring-boot:run", "jetty:run", "exec:java", "--continuous", "-t")
|
|
587
|
+
for a in rest)
|
|
588
|
+
if p == "docker":
|
|
589
|
+
return sub == "build" or (sub in ("compose", "buildx") and "build" in rest[:2])
|
|
590
|
+
if p in ("next", "vite", "nuxt", "astro", "remix", "expo", "turbo", "nx"):
|
|
591
|
+
return "build" in rest[:3] or any(FINITE_SCRIPT.match(a.split(":")[-1]) for a in rest[1:3] if sub == "run")
|
|
592
|
+
if p in ("webpack", "rollup", "esbuild", "tsup", "parcel"):
|
|
593
|
+
return sub != "serve"
|
|
594
|
+
return False
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def wrappable(cmd: str) -> bool:
|
|
598
|
+
"""True when every step of `cmd` is a finite, noisy command (or a trivial setup
|
|
599
|
+
step), so running it to completion before Claude sees the output is safe."""
|
|
600
|
+
if not cmd.strip() or "\n" in cmd.strip() or _piped(cmd) or LONG_RUNNING.search(cmd) \
|
|
601
|
+
or re.search(r"(?<![&>|])&(?![&>])", cmd) or re.search(r"(^|\s)(nohup|setsid|disown|exec)\s", cmd):
|
|
602
|
+
return False
|
|
603
|
+
noisy = False
|
|
604
|
+
for seg in re.split(r"&&|\|\||;", cmd):
|
|
605
|
+
try:
|
|
606
|
+
words = shlex.split(seg)
|
|
607
|
+
except ValueError:
|
|
608
|
+
return False
|
|
609
|
+
if not words or words[0] in SETUP_WORDS:
|
|
610
|
+
continue
|
|
611
|
+
w = argv0(seg)
|
|
612
|
+
if w and os.path.basename(w[0]) in SAFE_STEPS:
|
|
613
|
+
continue
|
|
614
|
+
if not _wrappable_words(w):
|
|
615
|
+
return False
|
|
616
|
+
noisy = True
|
|
617
|
+
return noisy
|