diff --git a/src/stParser.ts b/src/stParser.ts index eb7fcb1..2493010 100644 --- a/src/stParser.ts +++ b/src/stParser.ts @@ -172,7 +172,7 @@ function parseJsonReport(entry: unknown): StReport | undefined { timestamp, headline, culprit, - frames, + frames: framesWithoutDuplicateCulprit(culprit, frames), block: JSON.stringify(entry, null, 2) + "\n", }; } @@ -200,6 +200,23 @@ function parseFrame(text: string): StFrame | undefined { return line > 0 ? { file: match[1], line, text: text.trim() } : undefined; } +function framesWithoutDuplicateCulprit( + culprit: StFrame | undefined, + frames: StFrame[], +): StFrame[] { + if (!culprit) { + return frames; + } + + return [ + culprit, + ...frames.filter( + (frame) => + frame.file !== culprit.file || frame.line !== culprit.line, + ), + ]; +} + function isObject(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -245,7 +262,15 @@ function parseBlock(block: string[]): StReport | undefined { if (!culprit && !markedFrameSeen && frames.length > 0) { culprit = frames[0]; } - return { id, timestamp, headline, culprit, frames, block: block.join("\n") }; + + return { + id, + timestamp, + headline, + culprit, + frames: framesWithoutDuplicateCulprit(culprit, frames), + block: block.join("\n"), + }; } function stripCr(s: string): string { diff --git a/src/test/parser.test.ts b/src/test/parser.test.ts index 411edb0..118ca39 100644 --- a/src/test/parser.test.ts +++ b/src/test/parser.test.ts @@ -175,3 +175,73 @@ test("parses unicode filenames and .kts frames", () => { assert.equal(reports[1].culprit?.file, "構建.kts"); assert.equal(reports[1].culprit?.line, 9); }); + +test("deduplicates the culprit location in text and st-json/1 reports", () => { + const textContent = [ + "━━━ ERROR #dedupe-text ━━━ 2026-07-10 20:24:00.000 thread=main ━━━", + "IllegalStateException: payment gateway refused", + "at PaymentService.charge(PaymentService.java:118) ← YOUR CODE", + "stack (distilled, 1 of 1 frames):", + " PaymentService.charge(PaymentService.java:118) ← culprit", + "━━━ END #dedupe-text ━━━", + ].join("\n"); + + const jsonContent = JSON.stringify({ + type: "report", + id: "dedupe-json", + ts: "2026-07-10T20:24:00.000Z", + error: { + type: "IllegalStateException", + message: "payment gateway refused", + culprit: { + frame: "PaymentService.charge(PaymentService.java:118)", + appCode: true, + }, + }, + stack: { + shown: 1, + total: 1, + frames: ["PaymentService.charge(PaymentService.java:118)"], + }, + }); + + const textReport = parseReports(textContent)[0]; + const jsonReport = parseReports(jsonContent)[0]; + + const textLocations = textReport.frames.map( + (frame) => `${frame.file}:${frame.line}`, + ); + const jsonLocations = jsonReport.frames.map( + (frame) => `${frame.file}:${frame.line}`, + ); + + assert.deepEqual(textLocations, ["PaymentService.java:118"]); + assert.deepEqual(jsonLocations, textLocations); + assert.match(textReport.frames[0].text, /← YOUR CODE/); + assert.match(jsonReport.frames[0].text, /← YOUR CODE/); +}); + +test("keeps distinct frames when the culprit is not the top stack frame", () => { + const content = [ + "━━━ ERROR #non-top ━━━ 2026-07-10 20:25:00.000 thread=main ━━━", + "IllegalStateException: payment gateway refused", + "at PaymentService.charge(PaymentService.java:118) ← YOUR CODE", + "stack (distilled, 3 of 3 frames):", + " CheckoutController.submit(CheckoutController.java:31)", + " PaymentService.charge(PaymentService.java:118) ← culprit", + " Worker.run(Worker.java:9)", + "━━━ END #non-top ━━━", + ].join("\n"); + + const report = parseReports(content)[0]; + + assert.deepEqual( + report.frames.map((frame) => `${frame.file}:${frame.line}`), + [ + "PaymentService.java:118", + "CheckoutController.java:31", + "Worker.java:9", + ], + ); + assert.match(report.frames[0].text, /← YOUR CODE/); +});