|
1 | | -// Parses the st/1 text in errors-ai.log into reports. Pure — no vscode dependency — so it |
2 | | -// is unit-testable headless. Mirrors the IntelliJ plugin's parser: tolerant of unknown |
3 | | -// lines (forward compatibility per the st/1 spec), of the self-describing file header |
4 | | -// (which quotes the delimiter mid-line inside a '#' comment), and of truncated/rotated files. |
| 1 | +// Parses st/1 text or st-json/1 NDJSON in errors-ai.log into reports. Pure — no vscode |
| 2 | +// dependency — so it is unit-testable headless. Mirrors the IntelliJ plugin's text parser: |
| 3 | +// tolerant of unknown lines (forward compatibility per the st/1 spec), of the |
| 4 | +// self-describing file header (which quotes the delimiter mid-line inside a '#' comment), |
| 5 | +// and of truncated/rotated files. |
5 | 6 |
|
6 | 7 | export interface StFrame { |
7 | 8 | /** The source file name as written in the frame, e.g. "PaymentService.java". */ |
@@ -39,10 +40,21 @@ const HEADER = /^━━━ ERROR #(\S+) ━━━ (.+?) thread=/; |
39 | 40 | const FRAME = /\(([^\s:()]+\.(?:java|kts|kt|groovy|scala)):(-?\d+)\)/; |
40 | 41 |
|
41 | 42 | export function parseReports(content: string): StReport[] { |
42 | | - const reports: StReport[] = []; |
43 | 43 | if (!content) { |
44 | | - return reports; |
| 44 | + return []; |
45 | 45 | } |
| 46 | + |
| 47 | + const firstEntry = content |
| 48 | + .split("\n") |
| 49 | + .map(stripCr) |
| 50 | + .map((line) => line.trim()) |
| 51 | + .find((line) => line.length > 0 && !line.startsWith("#")); |
| 52 | + |
| 53 | + return firstEntry?.startsWith("{") ? parseJsonReports(content) : parseTextReports(content); |
| 54 | +} |
| 55 | + |
| 56 | +function parseTextReports(content: string): StReport[] { |
| 57 | + const reports: StReport[] = []; |
46 | 58 | const lines = content.split("\n").map(stripCr); |
47 | 59 | let i = 0; |
48 | 60 | while (i < lines.length) { |
@@ -81,6 +93,121 @@ export function parseReports(content: string): StReport[] { |
81 | 93 | return reports; |
82 | 94 | } |
83 | 95 |
|
| 96 | +function parseJsonReports(content: string): StReport[] { |
| 97 | + const reports: StReport[] = []; |
| 98 | + for (const rawLine of content.split("\n")) { |
| 99 | + const line = stripCr(rawLine).trim(); |
| 100 | + if (!line) { |
| 101 | + continue; |
| 102 | + } |
| 103 | + |
| 104 | + let entry: unknown; |
| 105 | + try { |
| 106 | + entry = JSON.parse(line); |
| 107 | + } catch { |
| 108 | + continue; // torn/half-written lines must not abort the rest of the file |
| 109 | + } |
| 110 | + |
| 111 | + const report = parseJsonReport(entry); |
| 112 | + if (report) { |
| 113 | + reports.push(report); |
| 114 | + } |
| 115 | + } |
| 116 | + return reports; |
| 117 | +} |
| 118 | + |
| 119 | +function parseJsonReport(entry: unknown): StReport | undefined { |
| 120 | + if (!isObject(entry) || entry.type !== "report") { |
| 121 | + return undefined; |
| 122 | + } |
| 123 | + |
| 124 | + const id = stringValue(entry.id); |
| 125 | + if (!id) { |
| 126 | + return undefined; |
| 127 | + } |
| 128 | + |
| 129 | + const timestamp = jsonTimestamp(stringValue(entry.ts)); |
| 130 | + const error = isObject(entry.error) ? entry.error : {}; |
| 131 | + const headline = jsonHeadline(error); |
| 132 | + const frames: StFrame[] = []; |
| 133 | + |
| 134 | + let culprit: StFrame | undefined; |
| 135 | + const culpritValue = isObject(error.culprit) ? error.culprit : undefined; |
| 136 | + if (culpritValue) { |
| 137 | + const frameText = stringValue(culpritValue.frame); |
| 138 | + const text = culpritValue.appCode === true ? `${frameText} ← YOUR CODE` : frameText; |
| 139 | + culprit = parseFrame(text); |
| 140 | + if (culprit) { |
| 141 | + frames.push(culprit); |
| 142 | + } |
| 143 | + } |
| 144 | + |
| 145 | + if (Array.isArray(error.wrappedBy)) { |
| 146 | + for (const value of error.wrappedBy) { |
| 147 | + if (typeof value !== "string") { |
| 148 | + continue; |
| 149 | + } |
| 150 | + const frame = parseFrame(value); |
| 151 | + if (frame) { |
| 152 | + frames.push(frame); |
| 153 | + } |
| 154 | + } |
| 155 | + } |
| 156 | + |
| 157 | + const stack = isObject(entry.stack) ? entry.stack : undefined; |
| 158 | + if (stack && Array.isArray(stack.frames)) { |
| 159 | + for (const value of stack.frames) { |
| 160 | + if (typeof value !== "string") { |
| 161 | + continue; |
| 162 | + } |
| 163 | + const frame = parseFrame(value); |
| 164 | + if (frame) { |
| 165 | + frames.push(frame); |
| 166 | + } |
| 167 | + } |
| 168 | + } |
| 169 | + |
| 170 | + return { |
| 171 | + id, |
| 172 | + timestamp, |
| 173 | + headline, |
| 174 | + culprit, |
| 175 | + frames, |
| 176 | + block: JSON.stringify(entry, null, 2) + "\n", |
| 177 | + }; |
| 178 | +} |
| 179 | + |
| 180 | +function jsonHeadline(error: Record<string, unknown>): string { |
| 181 | + const message = stringValue(error.message); |
| 182 | + if (error.noException === true) { |
| 183 | + return `ERROR (no exception): ${message}`; |
| 184 | + } |
| 185 | + const type = stringValue(error.type); |
| 186 | + return message ? `${type}: ${message}` : type; |
| 187 | +} |
| 188 | + |
| 189 | +function jsonTimestamp(timestamp: string): string { |
| 190 | + const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2}\.\d{3})(?:Z|[+-]\d{2}:\d{2})$/.exec(timestamp); |
| 191 | + return match ? `${match[1]} ${match[2]}` : timestamp; |
| 192 | +} |
| 193 | + |
| 194 | +function parseFrame(text: string): StFrame | undefined { |
| 195 | + const match = FRAME.exec(text); |
| 196 | + if (!match) { |
| 197 | + return undefined; |
| 198 | + } |
| 199 | + const line = parseInt(match[2], 10); |
| 200 | + return line > 0 ? { file: match[1], line, text: text.trim() } : undefined; |
| 201 | +} |
| 202 | + |
| 203 | +function isObject(value: unknown): value is Record<string, unknown> { |
| 204 | + return typeof value === "object" && value !== null && !Array.isArray(value); |
| 205 | +} |
| 206 | + |
| 207 | +function stringValue(value: unknown): string { |
| 208 | + return typeof value === "string" ? value : ""; |
| 209 | +} |
| 210 | + |
84 | 211 | function parseBlock(block: string[]): StReport | undefined { |
85 | 212 | const header = HEADER.exec(block[0]); |
86 | 213 | if (!header) { |
|
0 commit comments