pi-squeeze 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +115 -2
  3. package/extensions/command/parse.ts +82 -0
  4. package/extensions/core/adb.ts +231 -0
  5. package/extensions/core/audit.ts +53 -0
  6. package/extensions/core/blame.ts +32 -0
  7. package/extensions/core/command.ts +86 -0
  8. package/extensions/core/compact.ts +332 -0
  9. package/extensions/core/config.ts +109 -0
  10. package/extensions/core/diff.ts +93 -0
  11. package/extensions/core/docker.ts +58 -0
  12. package/extensions/core/dust.ts +24 -0
  13. package/extensions/core/elide.ts +31 -0
  14. package/extensions/core/files.ts +29 -0
  15. package/extensions/core/generic.ts +40 -0
  16. package/extensions/core/gitlog.ts +75 -0
  17. package/extensions/core/gitstatus.ts +52 -0
  18. package/extensions/core/gradle.ts +269 -0
  19. package/extensions/core/gradlereports.ts +78 -0
  20. package/extensions/core/install.ts +122 -0
  21. package/extensions/core/javac.ts +44 -0
  22. package/extensions/core/jest.ts +38 -0
  23. package/extensions/core/jvm.ts +18 -0
  24. package/extensions/core/lint.ts +199 -0
  25. package/extensions/core/logs.ts +106 -0
  26. package/extensions/core/lsd.ts +64 -0
  27. package/extensions/core/maven.ts +122 -0
  28. package/extensions/core/paths.ts +50 -0
  29. package/extensions/core/pytest.ts +37 -0
  30. package/extensions/core/rg.ts +126 -0
  31. package/extensions/core/rtk.ts +50 -0
  32. package/extensions/core/runners.ts +50 -0
  33. package/extensions/core/rustc.ts +99 -0
  34. package/extensions/core/save.ts +11 -0
  35. package/extensions/core/stats.ts +31 -0
  36. package/extensions/core/tests.ts +46 -0
  37. package/extensions/core/tree.ts +51 -0
  38. package/extensions/core/tsc.ts +49 -0
  39. package/extensions/core/xcode.ts +123 -0
  40. package/extensions/squeeze.ts +122 -0
  41. package/package.json +32 -4
@@ -0,0 +1,44 @@
1
+ /**
2
+ * `javac` output: each error keeps its source line, caret and `symbol:` / `location:` lines
3
+ * (what fixing it needs); warnings are grouped by kind and message, each location once
4
+ * (`warning [rawtypes]: found raw type: List (3)`), their source lines and "where E is a
5
+ * type-variable" explanations go, as does the advice to recompile with -Xlint. Anything
6
+ * that is not javac output passes the whole of it through.
7
+ */
8
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
9
+ import { shortenPaths } from "./gradle.ts";
10
+ import { type Diag, renderDiags } from "./lint.ts";
11
+
12
+ const HEAD = /^(\S.*?\.java):(\d+): (error|warning): (?:\[([\w-]+)\] )?(.*)$/;
13
+ const TOTAL = /^\d+ (?:errors?|warnings?)$/;
14
+ const NOTE = /^Note: /;
15
+
16
+ export function compactJavac(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
17
+ const lines = output.split("\n");
18
+ while (lines.length && lines.at(-1) === "") lines.pop();
19
+ const errors: string[] = [];
20
+ const warnings: Diag[] = [];
21
+ const totals: string[] = [];
22
+ let current: "error" | "warning" | undefined;
23
+ for (const l of lines) {
24
+ const m = HEAD.exec(l);
25
+ if (m) {
26
+ current = m[3] as "error" | "warning";
27
+ if (current === "error") errors.push(l);
28
+ else warnings.push({ file: m[1]!, line: m[2]!, severity: "warning", rule: m[4], message: m[5]! });
29
+ } else if (TOTAL.test(l)) totals.push(l);
30
+ else if (NOTE.test(l)) {
31
+ // "uses or overrides a deprecated API" says something; "Recompile with -Xlint" does not.
32
+ if (!/Recompile with -Xlint/.test(l)) totals.push(l);
33
+ } else if (!current) return undefined;
34
+ // Under an error: the source line, the caret and symbol/location stay; type-variable notes go.
35
+ else if (current === "error" && !/^\s+(?:where \w+ is a type-variable|\w+ extends \w+ declared in)/.test(l)) errors.push(l);
36
+ }
37
+ if (!errors.length && !warnings.length) return undefined;
38
+ shortenPaths(errors);
39
+ const out = errors.map((l) => cutLine(l, limits.lineChars));
40
+ if (warnings.length) out.push(...renderDiags({ tool: "javac", diags: warnings, notes: [] }, { ...limits, total: Number.MAX_SAFE_INTEGER }).text.split("\n"));
41
+ out.push(...totals);
42
+ const capped = capLines(out, limits.total);
43
+ return { text: capped.text, truncated: warnings.length > 0 || capped.truncated };
44
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * jest and vitest: passing tests (`✓`) and passing files go, skipped tests are counted;
3
+ * failures with their diff and source frame, and the totals, stay.
4
+ */
5
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
6
+ import { stripAnsi } from "./generic.ts";
7
+
8
+ const TOTALS = /^\s*(Tests|Test Files|Test Suites):?\s+\d+ (passed|failed|skipped|todo|total)/;
9
+ const PASS = [
10
+ /^\s*[✓√] /,
11
+ /^PASS /,
12
+ /^ RUN {2}v[\d.]+/,
13
+ /^\s+Start at {2}/,
14
+ /^Snapshots: {3}0 total$/,
15
+ /^Ran all test suites\.$/,
16
+ ];
17
+ const SKIP = /^\s*[↓○] (skipped )?/;
18
+
19
+ export function compactJest(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
20
+ const lines = stripAnsi(output).split("\n");
21
+ while (lines.length && lines.at(-1) === "") lines.pop();
22
+ if (!lines.some((l) => TOTALS.test(l))) return undefined;
23
+ const kept: string[] = [];
24
+ let skipped = 0;
25
+ for (const l of lines) {
26
+ if (PASS.some((p) => p.test(l))) continue;
27
+ if (SKIP.test(l)) {
28
+ skipped++;
29
+ continue;
30
+ }
31
+ if (l.trim() === "" && (kept.length === 0 || kept.at(-1) === "")) continue;
32
+ // vitest's full-width `⎯⎯⎯ Failed Tests 2 ⎯⎯⎯` rules keep two of their dashes.
33
+ kept.push(cutLine(l.trim() === "" ? "" : l.replace(/⎯{3,}/g, "⎯⎯"), limits.lineChars));
34
+ }
35
+ while (kept.at(-1) === "") kept.pop();
36
+ if (skipped) kept.push(`(${skipped} skipped not listed)`);
37
+ return capLines(kept, limits.total);
38
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * What the JVM and its usual libraries print on every run of Gradle, Maven or a test JVM,
3
+ * and that says nothing about the build: class-data sharing, dynamic agents (Mockito,
4
+ * ByteBuddy), SLF4J without a binding.
5
+ */
6
+ export const JVM_NOISE = [
7
+ /^OpenJDK 64-Bit Server VM warning: Sharing is only supported/,
8
+ /^WARNING: A Java agent has been loaded dynamically/,
9
+ /^WARNING: If a serviceability tool is (?:not )?in use/,
10
+ /^WARNING: Dynamic loading of agents will be disallowed/,
11
+ /^WARNING: A terminally deprecated method in sun\.misc\.Unsafe/,
12
+ /^WARNING: sun\.misc\.Unsafe::\w+ (?:has been called|will be removed)/,
13
+ /^WARNING: Please consider reporting this to the maintainers/,
14
+ /^Mockito is currently self-attaching to enable the inline-mock-maker/,
15
+ /^SLF4J(?:\(\w\))?: (?:No SLF4J providers|Defaulting to no-operation|See https?:\/\/www\.slf4j\.org|Failed to load class|Class path contains multiple)/,
16
+ ];
17
+
18
+ export const isJvmNoise = (line: string): boolean => JVM_NOISE.some((n) => n.test(line));
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Linter findings grouped by rule and message, each location listed once:
3
+ *
4
+ * eslint: 20 errors, 5 warnings in 5 files
5
+ * error eqeqeq: Expected '===' and instead saw '==' (5)
6
+ * src/m.js:2:46 src/m1.js:2:47 …
7
+ *
8
+ * Source frames, help text and fix suggestions go (the location leads back to them). Formats:
9
+ * ESLint stylish, Biome, Ruff (full and concise), `flutter analyze`, and the one-line
10
+ * `path:line:col: message` shape that flake8, mypy, pylint, ktlint, detekt, swiftlint and
11
+ * shellcheck share, and Android Lint's text report (its source lines and explanations go). Anything else in the output passes the whole of it through.
12
+ */
13
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
14
+ import { stripAnsi } from "./generic.ts";
15
+
16
+ export type Diag = { file: string; line: string; col?: string; severity: string; rule?: string; message: string };
17
+ /** `lossy`: the format had more than the findings (frames, help, fixes), which the result leaves out. */
18
+ export type Parsed = { tool: string; diags: Diag[]; notes: string[]; lossy?: boolean };
19
+
20
+ /** ESLint's default `stylish` formatter. */
21
+ export function parseEslint(lines: string[]): Parsed | undefined {
22
+ const diags: Diag[] = [];
23
+ const notes: string[] = [];
24
+ let file: string | undefined;
25
+ for (const l of lines) {
26
+ const m = /^\s+(\d+):(\d+)\s+(error|warning)\s+(.*?)(?:\s{2,}(\S+))?$/.exec(l);
27
+ if (m && file) diags.push({ file, line: m[1]!, col: m[2], severity: m[3]!, message: m[4]!, rule: m[5] });
28
+ else if (/^✖ \d+ problems?/.test(l) || /^\s+\d+ errors? and \d+ warnings? potentially fixable/.test(l)) notes.push(l.trim());
29
+ else if (/^\S/.test(l) && !/:\d+/.test(l)) file = l;
30
+ else if (l.trim() !== "") return undefined;
31
+ }
32
+ return diags.length && notes.length ? { tool: "eslint", diags, notes } : undefined;
33
+ }
34
+
35
+ /** Biome: `path:line:col rule FIXABLE ━━━` headers, the message on the first `×`/`!`/`i` line. */
36
+ export function parseBiome(lines: string[]): Parsed | undefined {
37
+ const diags: Diag[] = [];
38
+ const notes: string[] = [];
39
+ let open: Diag | undefined;
40
+ let footer = false;
41
+ for (const l of lines) {
42
+ const h = /^(\S+?):(\d+):(\d+) (\S+)(?:\s+FIXABLE)?\s+━+$/.exec(l);
43
+ if (h && !footer) {
44
+ open = { file: h[1]!, line: h[2]!, col: h[3], rule: h[4], severity: "", message: "" };
45
+ diags.push(open);
46
+ } else if (/^(Checked \d+ files?|Found \d+ (errors?|warnings?|infos?)|The number of diagnostics exceeds|Skipped \d+ suggested)/.test(l)) {
47
+ footer = true;
48
+ notes.push(l);
49
+ } else if (footer && /^\s+× /.test(l)) notes.push(l.trim());
50
+ else if (open && !open.message) {
51
+ const m = /^\s+([×!i]) (.*)$/.exec(l);
52
+ if (m) {
53
+ open.severity = m[1] === "×" ? "error" : m[1] === "!" ? "warning" : "info";
54
+ open.message = m[2]!;
55
+ }
56
+ }
57
+ }
58
+ return diags.length && notes.length && diags.every((d) => d.message) ? { tool: "biome", diags, notes, lossy: true } : undefined;
59
+ }
60
+
61
+ /** Ruff's full format: `CODE [*] message` then ` --> path:line:col`, then a frame and help. */
62
+ export function parseRuff(lines: string[]): Parsed | undefined {
63
+ const diags: Diag[] = [];
64
+ const notes: string[] = [];
65
+ for (let i = 0; i < lines.length; i++) {
66
+ const l = lines[i]!;
67
+ const h = /^([A-Z]+\d+) (?:\[\*\] )?(.*)$/.exec(l);
68
+ const at = /^\s*--> (.+?):(\d+):(\d+)$/.exec(lines[i + 1] ?? "");
69
+ if (h && at) {
70
+ diags.push({ file: at[1]!, line: at[2]!, col: at[3], rule: h[1], severity: "", message: h[2]! });
71
+ i++;
72
+ } else if (/^(Found \d+ errors?|\[\*\] \d+ fixable|No fixes available|warning: )/.test(l)) notes.push(l);
73
+ }
74
+ return diags.length ? { tool: "ruff", diags, notes, lossy: true } : undefined;
75
+ }
76
+
77
+ /** `flutter analyze` / `dart analyze`: ` info • message • lib/a.dart:3:5 • rule`. */
78
+ export function parseDart(lines: string[]): Parsed | undefined {
79
+ const diags: Diag[] = [];
80
+ const notes: string[] = [];
81
+ for (const l of lines) {
82
+ const m = /^\s*(error|warning|info) • (.*) • (\S+?):(\d+):(\d+) • (\S+)$/.exec(l);
83
+ if (m) diags.push({ severity: m[1]!, message: m[2]!, file: m[3]!, line: m[4]!, col: m[5], rule: m[6] });
84
+ else if (l.trim() !== "") notes.push(l.trim());
85
+ }
86
+ return diags.length && notes.length <= 4 ? { tool: "analyze", diags, notes } : undefined;
87
+ }
88
+
89
+ const LOCATED = /^(\S.*?):(\d+)(?::(\d+))?:? (?:(error|warning|note|info|fatal|convention|refactor|style)\s*:?\s+)?(.+)$/;
90
+ /** Lines a one-line linter prints around its findings. */
91
+ const BANNER = /^(Found \d+|\[\*\] \d+ fixable|No fixes available|warning: |Success: |All checks passed|Your code has been rated|-{10,}|\*{5,} Module |Summary: |Done linting|Summary error count|\s+[\w:./-]+: \d+$|\d+ (problems?|errors?|warnings?|violations?|issues?)\b)/;
92
+
93
+ /** One finding per line: `path:line[:col]: [severity:] message [rule]`. */
94
+ export function parseLocated(lines: string[]): Parsed | undefined {
95
+ const diags: Diag[] = [];
96
+ const notes: string[] = [];
97
+ for (const l of lines) {
98
+ const m = LOCATED.exec(l);
99
+ if (m && !/^\s/.test(l) && !BANNER.test(l)) {
100
+ let message = m[5]!;
101
+ let rule: string | undefined;
102
+ const code = /^([A-Z]+\d+):? (?:\[\*\] )?(.*)$/.exec(message);
103
+ const tail = /^(.*?)\s+[[(]([\w:./@-]+)[\])]$/.exec(message);
104
+ if (code) [rule, message] = [code[1], code[2]!];
105
+ else if (tail) [message, rule] = [tail[1]!, tail[2]];
106
+ diags.push({ file: m[1]!, line: m[2]!, col: m[3], severity: m[4] ?? "", rule, message });
107
+ } else if (BANNER.test(l)) notes.push(l);
108
+ else if (l.trim() !== "") return undefined;
109
+ }
110
+ return diags.length ? { tool: "lint", diags, notes } : undefined;
111
+ }
112
+
113
+ /** Android Lint's text report: `path:12: Warning: message [IssueId]`, the source line, `~~~~`, explanations. */
114
+ const ANDROID_LINT = /^(\S.*?):(\d+): (Error|Warning|Information|Hint|Fatal): (.*) \[(\w+)\]$/;
115
+
116
+ export function parseAndroidLint(lines: string[]): Parsed | undefined {
117
+ const diags: Diag[] = [];
118
+ const notes: string[] = [];
119
+ for (const l of lines) {
120
+ const m = ANDROID_LINT.exec(l);
121
+ if (m) diags.push({ file: m[1]!, line: m[2]!, severity: m[3]!.toLowerCase(), rule: m[5], message: m[4]! });
122
+ // Source lines, underlines and explanations are indented; the summary is `3 errors, 1 warning`.
123
+ else if (/^\d+ errors?(, \d+ warnings?)?$|^\d+ warnings?$|^No issues found/.test(l)) notes.push(l);
124
+ else if (l.trim() !== "" && !/^\s/.test(l)) return undefined;
125
+ }
126
+ return diags.length ? { tool: "android lint", diags, notes, lossy: true } : undefined;
127
+ }
128
+
129
+ /** The directory every absolute path shares, to be written once. */
130
+ export function sharedDir(files: string[]): string {
131
+ if (!files.length || !files.every((f) => f.startsWith("/"))) return "";
132
+ let prefix = files[0]!.slice(0, files[0]!.lastIndexOf("/") + 1);
133
+ for (const f of files) while (!f.startsWith(prefix)) prefix = prefix.slice(0, prefix.lastIndexOf("/", prefix.length - 2) + 1);
134
+ return prefix.length > 1 ? prefix : "";
135
+ }
136
+
137
+ const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
138
+
139
+ /** Renders findings grouped by severity, rule and message. */
140
+ export function renderDiags({ tool, diags, notes, lossy = false }: Parsed, limits: Limits): Squeezed {
141
+ const dir = sharedDir([...new Set(diags.map((d) => d.file))]);
142
+ const groups = new Map<string, string[]>();
143
+ for (const d of diags) {
144
+ // A message that names its own file (detekt's license rule) groups with the others once the name goes.
145
+ const message = d.message.replaceAll(d.file, "<file>");
146
+ const key = `${d.severity ? `${d.severity} ` : ""}${d.rule ? `${d.rule}: ` : ""}${message}`;
147
+ const at = `${d.file.slice(dir.length)}:${d.line}${d.col ? `:${d.col}` : ""}`;
148
+ groups.set(key, [...(groups.get(key) ?? []), at]);
149
+ }
150
+ const by = (s: string) => diags.filter((d) => d.severity === s).length;
151
+ const parts = [by("error") && count(by("error"), "error"), by("warning") && count(by("warning"), "warning")].filter(Boolean);
152
+ const files = new Set(diags.map((d) => d.file)).size;
153
+ const out = [`${tool}: ${parts.length ? parts.join(", ") : count(diags.length, "finding")} in ${count(files, "file")}${dir ? ` (under ${dir})` : ""}`];
154
+ let cut = false;
155
+ const sorted = [...groups].sort((a, b) => b[1].length - a[1].length);
156
+ for (const [key, at] of sorted) {
157
+ // A finding of its own is one line: message, then where.
158
+ if (at.length === 1) {
159
+ out.push(cutLine(`${key} ${at[0]}`, limits.lineChars));
160
+ continue;
161
+ }
162
+ out.push(cutLine(`${key} (${at.length})`, limits.lineChars));
163
+ const shown = at.slice(0, limits.perGroup * 2);
164
+ // Locations share lines, as many as fit.
165
+ let line = " ";
166
+ for (const a of shown) {
167
+ if (line.length + a.length + 2 > limits.lineChars && line.trim()) (out.push(line), (line = " "));
168
+ line += ` ${a}`;
169
+ }
170
+ if (at.length > shown.length) {
171
+ line += ` … +${at.length - shown.length}`;
172
+ cut = true;
173
+ }
174
+ out.push(line);
175
+ }
176
+ if (notes.length) out.push("", ...notes.map((n) => cutLine(n, limits.lineChars)));
177
+ const capped = capLines(out, limits.total);
178
+ return { text: capped.text, truncated: lossy || cut || capped.truncated };
179
+ }
180
+
181
+ const PARSERS = [parseEslint, parseBiome, parseRuff, parseDart, parseAndroidLint, parseLocated];
182
+
183
+ export function compactLint(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
184
+ const lines = stripAnsi(output).split("\n");
185
+ while (lines.length && lines.at(-1) === "") lines.pop();
186
+ for (const parse of PARSERS) {
187
+ const p = parse(lines);
188
+ if (p) return renderDiags(p, limits);
189
+ }
190
+ return undefined;
191
+ }
192
+
193
+ /** For sniffing: only formats with an unmistakable shape (a `path:line` line alone could be anything). */
194
+ export function compactLintSniffed(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
195
+ const lines = stripAnsi(output).split("\n");
196
+ const android = lines.some((l) => /^\s+~+$/.test(l)) ? parseAndroidLint(lines) : undefined;
197
+ const p = parseEslint(lines) ?? parseBiome(lines) ?? parseRuff(lines) ?? android;
198
+ return p ? renderDiags(p, limits) : undefined;
199
+ }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * Logs (`docker logs`, `docker compose logs`, `kubectl logs`, `journalctl`, `adb logcat`):
3
+ *
4
+ * * a run of 3+ lines that differ only in timestamps and numbers keeps its first and last
5
+ * line, with `… [N similar]` between;
6
+ * * stack frames of the runtime itself (java., android., kotlinx.coroutines., …) fold into
7
+ * `… N framework frames`;
8
+ * * logcat's `MM-DD` date (when it is the same throughout), thread id and padding go
9
+ * (`threadtime`, `time` and `brief` formats);
10
+ * * the end of a log is what matters: past the line limit the start goes, not the end.
11
+ */
12
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
13
+ import { stripAnsi } from "./generic.ts";
14
+
15
+ /** Leading timestamps in the common log formats (ISO 8601, syslog, logcat, bracketed clock). */
16
+ const STAMP = /^(?:\S+\s+\|\s+)?(?:\[?\d{4}-\d\d-\d\dT?\s?[\d:.,]+(?:Z|[+-]\d\d:?\d\d)?\]?|[A-Z][a-z]{2} [ \d]\d [\d:]{8}|\d\d-\d\d [\d:.]{12}|\[?[\d:]{8}(?:[.,]\d+)?\]?)\s*/;
17
+
18
+ /** Frames of runtimes and frameworks: never the code being debugged. */
19
+ const FRAMEWORK = /(?:^|:\s)\s*at (?:java\.|javax\.|jdk\.|sun\.|kotlin\.|kotlinx\.coroutines\.|android\.|androidx\.|com\.android\.|dalvik\.|libcore\.|org\.junit\.|junit\.|org\.gradle\.|worker\.org\.gradle\.|org\.robolectric\.|com\.intellij\.|io\.mockk\.|org\.mockito\.|org\.assertj\.|org\.hamcrest\.|org\.springframework\.|org\.apache\.(?:maven|catalina|tomcat|coyote)\.|io\.netty\.|reactor\.core\.|okhttp3\.internal\.|retrofit2\.|io\.ktor\.|org\.koin\.|node:internal|process\.processTicksAndRejections|new Promise \(<anonymous>\))/;
20
+
21
+ /** The shape of a line once what changes from one occurrence to the next is gone. */
22
+ export const shape = (line: string): string => line.replace(STAMP, "").replace(/0x[\da-f]+|\d+/gi, "#");
23
+
24
+ /** Folds runs of framework stack frames. */
25
+ export function foldFrames(lines: string[]): { lines: string[]; folded: boolean } {
26
+ const out: string[] = [];
27
+ let folded = false;
28
+ for (let i = 0; i < lines.length; ) {
29
+ let j = i;
30
+ while (j < lines.length && FRAMEWORK.test(lines[j]!)) j++;
31
+ if (j - i >= 2) {
32
+ // Whatever stood before `at ` (indent, a logcat prefix) stays.
33
+ const m = FRAMEWORK.exec(lines[i]!)!;
34
+ out.push(`${lines[i]!.slice(0, m.index + m[0].indexOf("at "))}… ${j - i} framework frames`);
35
+ folded = true;
36
+ i = j;
37
+ } else out.push(lines[i++]!);
38
+ }
39
+ return { lines: out, folded };
40
+ }
41
+
42
+ /** logcat `threadtime` (the default): `11-02 01:36:28.503 1947 2690 D Tag: message`. */
43
+ const LOGCAT = /^(\d\d-\d\d) ([\d:.]{12}) +(\d+) +\d+ ([VDIWEFA]) (.*)$/;
44
+ /** logcat `-v time` and `-v brief`: `[11-02 01:36:28.503 ]D/Tag( 1947): message`. */
45
+ const LOGCAT_TIME = /^(?:(\d\d-\d\d) ([\d:.]{12}) )?([VDIWEFA])\/(.*?)\(\s*(\d+)\): (.*)$/;
46
+
47
+ /** The same logcat line in `threadtime`'s order, without the thread id and padding. */
48
+ function logcatParts(l: string): [string | undefined, string | undefined, string, string, string] | undefined {
49
+ const t = LOGCAT.exec(l);
50
+ if (t) return [t[1], t[2], t[3]!, t[4]!, t[5]!];
51
+ const b = LOGCAT_TIME.exec(l);
52
+ return b ? [b[1], b[2], b[5]!, b[3]!, `${b[4]!.trimEnd()}: ${b[6]}`] : undefined;
53
+ }
54
+
55
+ function shortenLogcat(lines: string[]): string[] {
56
+ const dates = new Set(lines.map((l) => logcatParts(l)?.[0]).filter(Boolean));
57
+ const keepDate = dates.size > 1;
58
+ return lines.map((l) => {
59
+ const m = logcatParts(l);
60
+ return m ? [keepDate && m[0], m[1], m[2], m[3], m[4]].filter(Boolean).join(" ") : l;
61
+ });
62
+ }
63
+
64
+ export function compactLogs(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
65
+ const raw = stripAnsi(output).split("\n");
66
+ while (raw.length && raw.at(-1) === "") raw.pop();
67
+ if (raw.length < 4) return undefined;
68
+ const logcat = raw.some((l) => logcatParts(l) !== undefined);
69
+ const lines = logcat ? shortenLogcat(raw) : raw;
70
+
71
+ const runs: string[] = [];
72
+ let similar = false;
73
+ for (let i = 0; i < lines.length; ) {
74
+ const s = shape(lines[i]!);
75
+ let j = i + 1;
76
+ while (j < lines.length && shape(lines[j]!) === s) j++;
77
+ if (j - i >= 3) {
78
+ runs.push(lines[i]!, ` … [${j - i - 2} similar]`, lines[j - 1]!);
79
+ similar = true;
80
+ } else runs.push(...lines.slice(i, j));
81
+ i = j;
82
+ }
83
+ const { lines: folded, folded: didFold } = foldFrames(runs);
84
+ const cut = folded.map((l) => cutLine(l, limits.lineChars));
85
+ const dropped = Math.max(0, cut.length - limits.total);
86
+ const text = [...(dropped ? [`… [first ${dropped} lines left out]`] : []), ...cut.slice(dropped)].join("\n");
87
+ return { text, truncated: logcat || similar || didFold || dropped > 0 || cut.some((l, i) => l !== folded[i]) };
88
+ }
89
+
90
+ /** A JVM stack trace: `\tat pkg.Class.method(File.java:12)` frames under an exception. */
91
+ const FRAME = /^\s*at [\w$.<>/-]+\(.*\)$/;
92
+
93
+ /**
94
+ * Stack traces in any output (`java -jar`, a crashed test JVM, `adb logcat`, a pasted log):
95
+ * runs of framework frames fold to `… N framework frames`; the exception, each `Caused by:`,
96
+ * the code's own frames and `... N more` stay. Nothing else is touched.
97
+ */
98
+ export function compactTrace(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
99
+ const lines = output.split("\n");
100
+ if (lines.filter((l) => FRAME.test(l)).length < 3 || !lines.some((l) => /(?:Exception|Error|Throwable)\b/.test(l))) return undefined;
101
+ const { lines: folded, folded: didFold } = foldFrames(lines);
102
+ if (!didFold) return undefined;
103
+ const cut = folded.map((l) => cutLine(l, limits.lineChars));
104
+ const capped = capLines(cut, limits.total);
105
+ return { text: capped.text, truncated: true };
106
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `lsd -l`, `eza -l` and `ls -l` listings reduced to what a model uses: type, size, modified
3
+ * time (no year or seconds) and name. Owner, group and permission bits are dropped.
4
+ *
5
+ * d Oct 24 16:40 command
6
+ * - 6.0KB Oct 24 16:40 tokenburn.ts
7
+ *
8
+ * Strict: a single line that does not parse makes it return undefined.
9
+ */
10
+ import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
11
+
12
+ type Row = { type: string; size: string; when: string; name: string };
13
+ type Parser = (line: string) => Row | undefined;
14
+
15
+ const kindOf = (k: string) => (k === "d" ? "d" : k === "l" ? "l" : "-");
16
+
17
+ /** lsd: `.rw-r--r-- user group 1.9 KB Sun Nov 1 23:14:32 2026 name` */
18
+ const LSD = /^([dl.\-bcps])[rwxsStT\-.+@]{9}\S*\s+\S+\s+\S+\s+([\d.,]+ ?[A-Za-z]*)\s+\w{3} (\w{3})\s+(\d+) (\d\d:\d\d):\d\d \d{4}\s+(.+)$/;
19
+ /** eza: `.rw-r--r--@ 1.9k user [group] 1 Nov 23:14 name` (a year instead of the time when old) */
20
+ const EZA = /^([dl.\-bcps])[rwxsStT\-.+@]{9}\S*\s+(-|[\d.,]+[kMGTPE]?)\s+\S+(?:\s+\S+)?\s+(\d{1,2}) (\w{3})\s+(\d\d:\d\d|\d{4})\s+(.+)$/;
21
+ /** ls: `-rw-r--r--@ 1 user group 1934 Nov 1 23:14 name` */
22
+ const LS = /^([dl\-bcps])[rwxsStT\-]{9}[@+.]?\s+\d+\s+\S+\s+\S+\s+(\d+)\s+(\w{3})\s+(\d{1,2})\s+(\d\d:\d\d|\d{4})\s+(.+)$/;
23
+
24
+ /** Bytes as ls -lh would say them. */
25
+ export function human(bytes: number): string {
26
+ const units = ["B", "K", "M", "G", "T"];
27
+ let n = bytes;
28
+ let u = 0;
29
+ while (n >= 1024 && u < units.length - 1) (n /= 1024), u++;
30
+ return u === 0 ? `${n}B` : `${n < 10 ? n.toFixed(1) : Math.round(n)}${units[u]}`;
31
+ }
32
+
33
+ const PARSERS: Parser[] = [
34
+ (l: string) => {
35
+ const m = LSD.exec(l);
36
+ return m ? { type: kindOf(m[1]!), size: m[2]!.replace(/\s+/g, ""), when: `${m[3]} ${m[4]} ${m[5]}`, name: m[6]! } : undefined;
37
+ },
38
+ (l: string) => {
39
+ const m = EZA.exec(l);
40
+ return m ? { type: kindOf(m[1]!), size: m[2]!, when: `${m[4]} ${m[3]} ${m[5]}`, name: m[6]! } : undefined;
41
+ },
42
+ (l: string) => {
43
+ const m = LS.exec(l);
44
+ return m ? { type: kindOf(m[1]!), size: human(Number(m[2])), when: `${m[3]} ${m[4]} ${m[5]}`, name: m[6]! } : undefined;
45
+ },
46
+ ];
47
+
48
+ export function compactLsd(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
49
+ const lines = output.split("\n");
50
+ while (lines.length && lines.at(-1) === "") lines.pop();
51
+ if (/^total \d+/.test(lines[0] ?? "")) lines.shift(); // ls -l
52
+ if (lines.length < 3) return undefined;
53
+ const parse = PARSERS.find((p) => p(lines[0]!));
54
+ if (!parse) return undefined;
55
+ const out: string[] = [];
56
+ for (const l of lines) {
57
+ const row = parse(l);
58
+ if (!row) return undefined;
59
+ if (row.name === "." || row.name === "..") continue;
60
+ const meta = row.type === "d" ? "" : `${row.size} `;
61
+ out.push(`${row.type} ${meta}${row.when} ${row.name}`);
62
+ }
63
+ return capLines(out, limits.total);
64
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Maven (`mvn`, `./mvnw`, `mvnd`) output:
3
+ *
4
+ * * `Downloading from` / `Downloaded from` lines and their `Progress (1): …` frames become a
5
+ * count in the header;
6
+ * * `[INFO]` lines go (banners, plugin headers, "Copying 1 resource", "Compiling 8 source
7
+ * files", "Running x.FooTest" …) except the result: `BUILD SUCCESS|FAILURE`, `Total time`,
8
+ * test totals, and the reactor summary lines of modules that did not succeed;
9
+ * * a test class that passes takes what it printed with it (Spring banners, logs); a class
10
+ * that fails keeps it, with its failures;
11
+ * * `[ERROR]` lines that repeat an earlier line go (Maven prints compiler errors twice), and
12
+ * so does the help footer (`-> [Help 1]`, "Re-run Maven using the -X switch" …);
13
+ * * `[WARNING]` lines are said once, with a count; JVM chatter goes; framework stack frames
14
+ * fold; reported files lose the directory they share.
15
+ *
16
+ * `mvn dependency:tree` is the tree cut by depth (see tree.ts), once `[INFO] ` is gone.
17
+ */
18
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
19
+ import { shortenPaths } from "./gradle.ts";
20
+ import { isJvmNoise } from "./jvm.ts";
21
+ import { foldFrames } from "./logs.ts";
22
+ import { compactTree } from "./tree.ts";
23
+
24
+ const RESULT = /^\[INFO\] BUILD (SUCCESS|FAILURE)$|^\[ERROR\] BUILD FAILURE$/;
25
+ /** Without `-B` the lines stand alone; with it (batch mode) they are `[INFO]` lines. */
26
+ const DOWNLOAD = /^(?:\[INFO\] )?(Downloading|Downloaded) from [\w.-]+: /;
27
+ /** `[INFO]` lines that stay. */
28
+ const INFO_KEPT = [
29
+ /^\[INFO\] BUILD /,
30
+ /^\[INFO\] Total time:/,
31
+ // The totals, not a class's own line (`-- in x.FooTest`).
32
+ /^\[INFO\] Tests run: (?!.* -- in ).*$/,
33
+ /^\[INFO\] Reactor Summary/,
34
+ /^\[INFO\] \S.* (?:FAILURE|SKIPPED)(?: \[|$)/,
35
+ /^\[INFO\] \d+ (?:errors?|warnings?) ?$/,
36
+ ];
37
+ const HELP = /^\[ERROR\] (?:-> \[Help \d+\]|To see the full stack trace|Re-run Maven using the -X|For more information about the errors|\[Help \d+\] https?:|See dump files|After correcting the problems, you can resume|\s*mvn <args> -rf|See .*surefire-reports for the individual test results)/;
38
+ /** The Spring Boot banner each `@SpringBootTest` prints. */
39
+ const BANNER = /^ {0,2}(?:\. ____|\/\\\\ \/ ___'|\( \( \)\\___|\\\\\/ ___\)|' \|____\||=+\|_\|=+)| :: Spring Boot :: /;
40
+ const CLASS_START = /^\[INFO\] Running (\S+)$/;
41
+ const CLASS_END = /^\[(INFO|ERROR|WARNING)\] Tests run: \d+, Failures: (\d+), Errors: (\d+), .* -- in (\S+)$/;
42
+
43
+ export function compactMaven(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
44
+ // Progress frames end in `\r`; what follows the last one is the line.
45
+ const lines = output.split("\n").map((l) => l.slice(l.lastIndexOf("\r") + 1).trimEnd());
46
+ if (!lines.some((l) => RESULT.test(l))) return undefined;
47
+
48
+ const kept: string[] = [];
49
+ const said = new Map<string, { at: number; n: number }>();
50
+ const before = new Set<string>();
51
+ let downloads = 0;
52
+ let passed = 0;
53
+ let dropped = false;
54
+ /** What a test class printed, kept only if it fails. */
55
+ let running: string[] | undefined;
56
+ const keep = (l: string) => (running ? running.push(l) : kept.push(l));
57
+
58
+ for (const l of lines) {
59
+ const content = l.replace(/^\[(?:ERROR|WARNING|INFO)\] ?/, "").trim();
60
+ const repeat = content.length >= 4 && before.has(content);
61
+ before.add(content);
62
+ const dl = DOWNLOAD.exec(l);
63
+ if (dl || /^Progress \(\d+\):/.test(l)) {
64
+ downloads += dl?.[1] === "Downloaded" ? 1 : 0;
65
+ continue;
66
+ }
67
+ if (CLASS_START.test(l)) {
68
+ running = [];
69
+ continue;
70
+ }
71
+ const end = CLASS_END.exec(l);
72
+ if (end) {
73
+ if (end[2] === "0" && end[3] === "0") passed++;
74
+ else kept.push(...running!, l);
75
+ running = undefined;
76
+ continue;
77
+ }
78
+ if (l.startsWith("[INFO]") && !INFO_KEPT.some((k) => k.test(l))) {
79
+ dropped = true;
80
+ continue;
81
+ }
82
+ // The indented paragraph of a dropped `[INFO]` line (annotation processing advice).
83
+ if (/^ {2}\S/.test(l) && dropped) continue;
84
+ dropped = false;
85
+ if (BANNER.test(l) || isJvmNoise(l) || HELP.test(l) || /^\[(?:ERROR|WARNING)\]\s*$/.test(l)) continue;
86
+ if (l.startsWith("[ERROR]") && repeat) continue;
87
+ if (l.startsWith("[WARNING]")) {
88
+ const seen = said.get(l);
89
+ if (seen) {
90
+ seen.n++;
91
+ continue;
92
+ }
93
+ said.set(l, { at: kept.length, n: 1 });
94
+ }
95
+ keep(l);
96
+ }
97
+ if (running) kept.push(...running);
98
+ for (const { at, n } of said.values()) if (n > 1) kept[at] += ` (×${n})`;
99
+ shortenPaths(kept);
100
+ const { lines: folded } = foldFrames(kept);
101
+
102
+ const body: string[] = [];
103
+ for (const l of folded) {
104
+ if (l === "" && (body.length === 0 || body.at(-1) === "")) continue;
105
+ body.push(cutLine(l, limits.lineChars));
106
+ }
107
+ while (body.at(-1) === "") body.pop();
108
+ const parts = [downloads && `${downloads} downloads`, passed && `${passed} test classes passed`].filter(Boolean);
109
+ const capped = capLines([...(parts.length ? [`maven: ${parts.join(", ")}`] : []), ...body], limits.total);
110
+ // `[INFO]` lines and passing classes' output always go: the full output keeps them.
111
+ return { text: capped.text, truncated: true };
112
+ }
113
+
114
+ /** `mvn dependency:tree`: the `[INFO] ` prefix goes, then the tree is cut by depth. */
115
+ export function compactMavenTree(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
116
+ const lines = output.split("\n").map((l) => l.slice(l.lastIndexOf("\r") + 1));
117
+ if (!lines.some((l) => RESULT.test(l))) return undefined;
118
+ const tree = lines.filter((l) => /^\[INFO\] (?:[|+\\ ]*[+\\]- |[\w.-]+:[\w.-]+:)/.test(l)).map((l) => l.slice(7));
119
+ const cut = compactTree(tree.join("\n"), limits);
120
+ const result = lines.filter((l) => RESULT.test(l) || /^\[ERROR\] (?!\s*$)/.test(l) && !HELP.test(l));
121
+ return { text: [...(cut?.text ?? tree.join("\n")).split("\n"), ...result].join("\n"), truncated: true };
122
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * `fd` / `find` output (one path per line) grouped by directory:
3
+ *
4
+ * src/core/
5
+ * a.ts b.ts c.ts
6
+ *
7
+ * Names with spaces are quoted. A directory with a single entry keeps its full path.
8
+ */
9
+ import { capLines, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
10
+
11
+ function split(path: string): [string, string] {
12
+ const dir = path.endsWith("/");
13
+ const body = dir ? path.slice(0, -1) : path;
14
+ const i = body.lastIndexOf("/");
15
+ if (i < 0) return ["", path];
16
+ return [body.slice(0, i + 1), body.slice(i + 1) + (dir ? "/" : "")];
17
+ }
18
+
19
+ export function compactPaths(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
20
+ const lines = output.split("\n");
21
+ while (lines.length && lines.at(-1) === "") lines.pop();
22
+ if (lines.length < 2 || lines.some((l) => l === "" || l.includes("\t"))) return undefined;
23
+
24
+ const groups = new Map<string, string[]>();
25
+ for (const l of lines) {
26
+ const [dir, name] = split(l);
27
+ const g = groups.get(dir);
28
+ if (g) g.push(name);
29
+ else groups.set(dir, [name]);
30
+ }
31
+ const out: string[] = [];
32
+ for (const [dir, names] of groups) {
33
+ if (names.length === 1) {
34
+ out.push(`${dir}${names[0]}`);
35
+ continue;
36
+ }
37
+ out.push(dir || "./");
38
+ const quoted = names.map((n) => (/\s/.test(n) ? JSON.stringify(n) : n));
39
+ let row = " ";
40
+ for (const n of quoted) {
41
+ if (row.length + n.length > 100 && row.trim()) {
42
+ out.push(row.trimEnd());
43
+ row = " ";
44
+ }
45
+ row += `${n} `;
46
+ }
47
+ if (row.trim()) out.push(row.trimEnd());
48
+ }
49
+ return capLines(out, limits.total);
50
+ }