pi-squeeze 0.1.0 → 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.
@@ -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,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,37 @@
1
+ /**
2
+ * pytest: the session header, progress dots and PASSED lines go; failures, errors, the
3
+ * warnings summary, the short test summary and the result line stay.
4
+ */
5
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
6
+ import { stripAnsi } from "./generic.ts";
7
+
8
+ const RESULT = /^=+ .*\b(passed|failed|errors?|skipped|deselected|no tests ran)\b.* in [\d.]+s.* =+$/;
9
+ const DROP = [
10
+ /^=+ test session starts =+$/,
11
+ /^platform \S+ -- Python /,
12
+ /^(rootdir|cachedir|plugins|configfile|testpaths|cov|hypothesis profile|benchmark|asyncio|django|metadata): /,
13
+ /^collect(ing|ed) \d+ items?/,
14
+ /^collecting \.\.\./,
15
+ /^-- Docs: https:\/\/docs\.pytest\.org/,
16
+ // `test_a.py ..F.s [ 50%]`: the failures are listed again below.
17
+ /^\S+\.py [.sSxXfFE]+\s*(\[\s*\d+%\])?$/,
18
+ / PASSED\s*(\[\s*\d+%\])?$/,
19
+ ];
20
+
21
+ export function compactPytest(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
22
+ const lines = stripAnsi(output).split("\n");
23
+ while (lines.length && lines.at(-1) === "") lines.pop();
24
+ const result = lines.findLast((l) => RESULT.test(l));
25
+ if (!result) return undefined;
26
+ const kept: string[] = [];
27
+ for (const l of lines) {
28
+ if (RESULT.test(l) || DROP.some((d) => d.test(l))) continue;
29
+ if (l === "" && (kept.length === 0 || kept.at(-1) === "")) continue;
30
+ // `-v`: `test_a.py::test_x FAILED [ 50%]` keeps its verdict, loses the percentage;
31
+ // a full-width `==== FAILURES ====` / `____ test_x ____` banner keeps two of each.
32
+ const line = l.replace(/\s+\[\s*\d+%\]$/, "").replace(/^([=_])\1{2,} (.*?) \1{3,}$/, "$1$1 $2 $1$1");
33
+ kept.push(cutLine(line, limits.lineChars));
34
+ }
35
+ while (kept.at(-1) === "") kept.pop();
36
+ return capLines([`pytest: ${result.replace(/^=+ | =+$/g, "")}`, ...kept], limits.total);
37
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Tree output (`cargo tree`, `tree`, `eza --tree`) cut below a depth, with a marker that says
2
+ * Tree output (`cargo tree`, `tree`, `eza --tree`, `gradle dependencies`) cut below a depth, with a marker that says
3
3
  * how many lines were left out under each cut:
4
4
  *
5
5
  * ├── ratatui v0.30.2
@@ -11,8 +11,13 @@
11
11
  */
12
12
  import { cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
13
13
 
14
- const PREFIX = /^((?:│ | )*)/;
15
- const BRANCH = /^(?:│ | )*(?:├── |└── )/;
14
+ /** Box-drawing trees indent by 4; gradle's ASCII one (`+--- `, `| `) by 5, Maven's by 3. */
15
+ const STYLES = [
16
+ { prefix: /^((?:│ | )*)/, branch: /^(?:│ | )*(?:├── |└── )/, unit: 4 },
17
+ { prefix: /^((?:\| | {5})*)/, branch: /^(?:\| | {5})*(?:\+--- |\\--- )/, unit: 5 },
18
+ // Maven's `dependency:tree`: `+- `, `\- `, `| `.
19
+ { prefix: /^((?:\| | {3})*)/, branch: /^(?:\| | {3})*(?:\+- |\\- )/, unit: 3 },
20
+ ];
16
21
 
17
22
  /** Levels kept below the root. */
18
23
  const DEPTH = 3;
@@ -20,17 +25,19 @@ const DEPTH = 3;
20
25
  export function compactTree(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
21
26
  const lines = output.split("\n");
22
27
  while (lines.length && lines.at(-1) === "") lines.pop();
23
- if (lines.length < 5 || !lines.some((l) => BRANCH.test(l))) return undefined;
28
+ const style = STYLES.find((s) => lines.some((l) => s.branch.test(l)));
29
+ if (lines.length < 5 || !style) return undefined;
30
+ const { prefix, branch, unit } = style;
24
31
  const out: string[] = [];
25
32
  let hidden: string[] = [];
26
33
  const flush = () => {
27
34
  if (hidden.length === 1) out.push(cutLine(hidden[0]!, limits.lineChars));
28
- else if (hidden.length) out.push(`${" ".repeat(DEPTH)}… [+${hidden.length} lines deeper]`);
35
+ else if (hidden.length) out.push(`${" ".repeat(DEPTH * unit)}… [+${hidden.length} lines deeper]`);
29
36
  hidden = [];
30
37
  };
31
38
  for (const l of lines) {
32
- const units = PREFIX.exec(l)![1]!.length / 4;
33
- const depth = BRANCH.test(l) || units > 0 ? units + 1 : 0;
39
+ const units = prefix.exec(l)![1]!.length / unit;
40
+ const depth = branch.test(l) || units > 0 ? units + 1 : 0;
34
41
  if (depth > DEPTH) {
35
42
  hidden.push(l);
36
43
  continue;
@@ -0,0 +1,123 @@
1
+ /**
2
+ * `xcodebuild` and `swift build` / `swift test`:
3
+ *
4
+ * * build steps (`CompileSwift …`, `Ld …`, `CodeSign …` with their indented command lines)
5
+ * are counted, not listed; so are progress lines (`[12/40] Compiling …`);
6
+ * * warnings are grouped by message, one location each, without their source frame;
7
+ * * errors keep their frame and their notes;
8
+ * * passing tests (XCTest and Swift Testing) are counted; failures, issues and totals stay;
9
+ * * the `** BUILD SUCCEEDED **` / `** TEST FAILED **` result stays.
10
+ */
11
+ import { capLines, cutLine, DEFAULT_LIMITS, type Limits, type Squeezed } from "./elide.ts";
12
+ import { stripAnsi } from "./generic.ts";
13
+ import { renderDiags, type Diag } from "./lint.ts";
14
+
15
+ const DIAG = /^(.+?):(\d+)(?::(\d+))?: (error|warning|note): (.*)$/;
16
+ /** `CompileSwift normal arm64 …`, `WriteAuxiliaryFile …`: a CamelCase verb, or a one-word one. */
17
+ const ACTION = /^(?:[A-Z][a-z]+[A-Z]\w*|Ld|Copy|Touch|Libtool|Strip|Lipo|Ditto|Validate)(?=[ \\]|$)/;
18
+ const DROP = [
19
+ // `2026-11-02 01:38:45.358 xcodebuild[40118:44955631] …`: tool chatter.
20
+ /^\d{4}-\d\d-\d\d [\d:.]+ [\w.-]+\[\d+:\d+\] /,
21
+ /^(Details|Object|Method|Thread): {1,3}\S/,
22
+ /^Please file a bug at https:\/\/feedbackassistant/,
23
+ /^\{ platform:/,
24
+ /^(Command line invocation|Resolved source packages):$/,
25
+ /^(Resolve Package Graph|Prepare packages|Testing started)$/,
26
+ /^Build description (signature|path): /,
27
+ /^Building for (debugging|production)\.\.\.$/,
28
+ /^\[Planning deferred tasks\]$/,
29
+ // SwiftPM progress; the spaces are narrow no-break ones.
30
+ /^\[\d+\s?\/\s?\d+\]/,
31
+ /^(Fetching|Fetched|Computing version for|Computed|Creating working copy for|Working copy of|Updating|Updated) /,
32
+ /^note: (Building targets|Target dependency graph|Run script build phase|Using codesigning|Removed stale)/,
33
+ /^Test Case '.*' started\.$/,
34
+ /^Test Suite '.*' (started|passed|failed) at /,
35
+ /^◇ /,
36
+ /^↳ (Testing Library Version|Target Platform): /,
37
+ /^\[#[\w-]+\]: <https?:/,
38
+ ];
39
+ /** A source frame line under a diagnostic. */
40
+ const FRAME = /^\s*(\d+ )?\|/;
41
+
42
+ export function compactXcode(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
43
+ const lines = stripAnsi(output).replace(/\u200b|XCTestOutputBarrier/g, "").split("\n");
44
+ while (lines.length && lines.at(-1) === "") lines.pop();
45
+ if (!lines.some((l) => /^\*\* \w+ (SUCCEEDED|FAILED) \*\*$|^Build complete!|^error: (Build failed|fatalError)|^Test Suite '|^✘ Test run|^✔ Test run/.test(l))) return undefined;
46
+
47
+ const actions = new Map<string, number>();
48
+ const warnings: Diag[] = [];
49
+ const seen = new Set<string>();
50
+ const out: string[] = [];
51
+ let executed: string | undefined;
52
+ let passed = 0;
53
+ let indented = false;
54
+ let last: "error" | "warning" | "other" = "other";
55
+ let skipNext = false;
56
+ for (const l of lines) {
57
+ if (skipNext) {
58
+ skipNext = false;
59
+ continue;
60
+ }
61
+ // Each suite repeats the totals; the last one (all tests) is the one that counts.
62
+ if (/^\s+Executed \d+ tests?/.test(l)) {
63
+ executed = l.trim();
64
+ continue;
65
+ }
66
+ if (indented && /^\s+\S/.test(l)) continue;
67
+ indented = false;
68
+ const action = ACTION.exec(l);
69
+ if (action) {
70
+ actions.set(action[0], (actions.get(action[0]) ?? 0) + 1);
71
+ indented = true;
72
+ continue;
73
+ }
74
+ if (/^(Command line invocation|Resolved source packages):$|^error: .* Command line:/.test(l)) indented = true;
75
+ if (DROP.some((d) => d.test(l))) {
76
+ indented = true;
77
+ continue;
78
+ }
79
+ if (/^Test Case '.*' passed \(/.test(l) || /^✔ Test /.test(l)) {
80
+ passed++;
81
+ continue;
82
+ }
83
+ const d = DIAG.exec(l);
84
+ if (d) {
85
+ const severity = d[4]!;
86
+ if (severity === "warning") {
87
+ const key = `${d[1]}:${d[2]}:${d[3]}:${d[5]}`;
88
+ if (!seen.has(key)) warnings.push({ file: d[1]!, line: d[2]!, col: d[3], severity, message: d[5]! });
89
+ seen.add(key);
90
+ last = "warning";
91
+ continue;
92
+ }
93
+ if (severity === "note" && last !== "error") continue;
94
+ if (severity === "error") last = "error";
95
+ out.push(cutLine(l, limits.lineChars));
96
+ continue;
97
+ }
98
+ if (FRAME.test(l) || /^\s+`- /.test(l)) {
99
+ if (last === "error") out.push(cutLine(l, limits.lineChars));
100
+ continue;
101
+ }
102
+ last = "other";
103
+ if (l === "Failed frontend command:") skipNext = true;
104
+ if (l.trim() === "" && (out.length === 0 || out.at(-1) === "")) continue;
105
+ out.push(cutLine(l.trim() === "" ? "" : l, limits.lineChars));
106
+ }
107
+ while (out.at(-1) === "") out.pop();
108
+ if (executed) out.push(executed);
109
+
110
+ const steps = [...actions].sort((a, b) => b[1] - a[1]);
111
+ const total = steps.reduce((n, [, c]) => n + c, 0);
112
+ const head: string[] = [];
113
+ if (total) head.push(`build: ${total} steps (${steps.slice(0, 4).map(([k, v]) => `${v} ${k}`).join(", ")}${steps.length > 4 ? ", …" : ""})`);
114
+ if (passed) head.push(`tests: ${passed} passed (not listed)`);
115
+ const warned = warnings.length ? ["", ...renderDiags({ tool: "swift", diags: warnings, notes: [] }, limits).text.split("\n")] : [];
116
+ const capped = capLines([...head, ...out, ...warned], limits.total);
117
+ return { text: capped.text, truncated: capped.truncated || warnings.length > 0 };
118
+ }
119
+
120
+ /** For sniffing: only with xcodebuild's own `** BUILD SUCCEEDED **`-style result line. */
121
+ export function compactXcodeSniffed(output: string, limits: Limits = DEFAULT_LIMITS): Squeezed | undefined {
122
+ return /^\*\* [A-Z ]+ (SUCCEEDED|FAILED) \*\*$/m.test(output) ? compactXcode(output, limits) : undefined;
123
+ }