Skip to content

Commit e1a7f6c

Browse files
authored
feat: parse st-json/1 reports (#13)
1 parent fefb6cb commit e1a7f6c

2 files changed

Lines changed: 186 additions & 6 deletions

File tree

‎src/stParser.ts‎

Lines changed: 133 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
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.
56

67
export interface StFrame {
78
/** The source file name as written in the frame, e.g. "PaymentService.java". */
@@ -39,10 +40,21 @@ const HEADER = /^━━━ ERROR #(\S+) ━━━ (.+?) thread=/;
3940
const FRAME = /\(([^\s:()]+\.(?:java|kts|kt|groovy|scala)):(-?\d+)\)/;
4041

4142
export function parseReports(content: string): StReport[] {
42-
const reports: StReport[] = [];
4343
if (!content) {
44-
return reports;
44+
return [];
4545
}
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[] = [];
4658
const lines = content.split("\n").map(stripCr);
4759
let i = 0;
4860
while (i < lines.length) {
@@ -81,6 +93,121 @@ export function parseReports(content: string): StReport[] {
8193
return reports;
8294
}
8395

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+
84211
function parseBlock(block: string[]): StReport | undefined {
85212
const header = HEADER.exec(block[0]);
86213
if (!header) {

‎src/test/parser.test.ts‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,59 @@ test("keeps reports that do not contain a source frame", () => {
6565
assert.equal(reports[0].culprit, undefined);
6666
});
6767

68+
test("parses st-json/1 reports and ignores non-report and torn entries", () => {
69+
const content = [
70+
' {"type":"header","format":"st-json/1"}',
71+
JSON.stringify({
72+
type: "report",
73+
id: "json0001",
74+
ts: "2026-07-10T20:16:40.412Z",
75+
thread: "main",
76+
error: {
77+
type: "IllegalStateException",
78+
message: "payment gateway refused",
79+
culprit: {
80+
frame: "PaymentService.charge(PaymentService.java:44)",
81+
appCode: true,
82+
},
83+
},
84+
stack: {
85+
shown: 1,
86+
total: 1,
87+
frames: ["PaymentService.charge(PaymentService.java:44)"],
88+
},
89+
}),
90+
'{"type":"repeat","id":"json0001","count":3,"last":"2026-07-10T20:17:30.000Z"}',
91+
'{"type":"report","id":"half-written"',
92+
].join("\n");
93+
94+
const reports = parseReports(content);
95+
assert.equal(reports.length, 1);
96+
const r = reports[0];
97+
assert.equal(r.id, "json0001");
98+
assert.equal(r.timestamp, "2026-07-10 20:16:40.412");
99+
assert.equal(r.headline, "IllegalStateException: payment gateway refused");
100+
assert.equal(r.culprit?.file, "PaymentService.java");
101+
assert.equal(r.culprit?.line, 44);
102+
assert.equal(JSON.parse(r.block).type, "report");
103+
});
104+
105+
test("maps an st-json/1 no-exception report", () => {
106+
const content = JSON.stringify({
107+
type: "report",
108+
id: "json0002",
109+
ts: "2026-07-10T20:17:01.000Z",
110+
thread: "worker-1",
111+
error: { noException: true, message: "checkout timed out" },
112+
});
113+
114+
const reports = parseReports(content);
115+
assert.equal(reports.length, 1);
116+
assert.equal(reports[0].headline, "ERROR (no exception): checkout timed out");
117+
assert.equal(reports[0].frames.length, 0);
118+
assert.equal(reports[0].culprit, undefined);
119+
});
120+
68121
// The three bugs from #5, mirroring the fix landed in the JetBrains plugin (its PR #11).
69122
// The two parsers are deliberate twins; these cases must behave identically in both.
70123

0 commit comments

Comments
 (0)