Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,8 @@ public final class StReportParser {
// id + timestamp from "━━━ ERROR #a1b2 ━━━ 2026-07-10 20:16:40.412 thread=… ━━━"
private static final Pattern HEADER = Pattern.compile("^━━━ ERROR #(\\S+) ━━━ (.+?) thread=");
// a frame carrying a source location: "…(PaymentService.java:44)"
private static final Pattern FRAME = Pattern.compile("\\(([\\w$]+\\.(?:java|kt|groovy|scala)):(\\d+)\\)");
private static final Pattern FRAME = Pattern.compile(
"\\(([^\\s:()]+\\.(?:java|kts|kt|groovy|scala)):(-?\\d+)\\)");

private StReportParser() {
}
Expand All @@ -37,24 +38,27 @@ public static List<StReport> parse(String content) {
}
List<String> block = new ArrayList<>();
block.add(stripCr(lines[i]));
boolean complete = false;
int j = i + 1;
while (j < lines.length) {
String bl = stripCr(lines[j]);
if (bl.startsWith(START)) break; // next block began — this one was truncated
block.add(bl);
if (bl.startsWith(END)) {
complete = true;
j++;
break;
}
j++;
}
StReport r = parseBlock(block);
if (r != null) reports.add(r);
if (complete) {
StReport report = parseBlock(block);
if (report != null) reports.add(report);
}
i = j;
}
return reports;
}

private static StReport parseBlock(List<String> block) {
Matcher h = HEADER.matcher(block.get(0));
if (!h.find()) return null;
Expand All @@ -63,21 +67,38 @@ private static StReport parseBlock(List<String> block) {
String headline = block.size() > 1 ? block.get(1).trim() : "";

StFrame culprit = null;
boolean markedFrameFound = false;
List<StFrame> frames = new ArrayList<>();
for (String bl : block) {
Matcher fm = FRAME.matcher(bl);
if (fm.find()) {
StFrame f = new StFrame(fm.group(1), Integer.parseInt(fm.group(2)), bl.trim());
frames.add(f);
if (culprit == null && (bl.contains("← YOUR CODE") || bl.contains("← culprit"))) {
culprit = f;
}
boolean marked = bl.contains("← YOUR CODE") || bl.contains("← culprit");
if (marked) markedFrameFound = true;

int line = parseLineNumber(fm.group(2));
if (line <= 0) continue;

StFrame frame = new StFrame(fm.group(1), line, bl.trim());
frames.add(frame);
if (culprit == null && marked) culprit = frame;
}
}
if (culprit == null && !frames.isEmpty()) culprit = frames.get(0);
if (culprit == null && !markedFrameFound && !frames.isEmpty()) {
culprit = frames.get(0);
}
return new StReport(id, timestamp, headline, culprit, frames, String.join("\n", block));
}

private static int parseLineNumber(String value) {
try {
long line = Long.parseLong(value);
if (line > Integer.MAX_VALUE) return Integer.MAX_VALUE;
if (line < Integer.MIN_VALUE) return Integer.MIN_VALUE;
return (int) line;
} catch (NumberFormatException ignored) {
// The regex guarantees digits, so overflow is the only expected failure.
return value.startsWith("-") ? Integer.MIN_VALUE : Integer.MAX_VALUE;
}
}
private static String stripCr(String s) {
return s.endsWith("\r") ? s.substring(0, s.length() - 1) : s;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ class StReportParserTest {
env: app=shop-api | java 21 | linux
━━━ END #a1b2c3d4 ━━━
""";

@Test
void parsesReportIdHeadlineAndCulpritLocation() {
List<StReport> reports = StReportParser.parse(SAMPLE);
Expand All @@ -37,7 +36,6 @@ void parsesReportIdHeadlineAndCulpritLocation() {
assertThat(r.culprit().line()).isEqualTo(44);
assertThat(r.block()).startsWith("━━━ ERROR #a1b2c3d4").contains("━━━ END #a1b2c3d4");
}

@Test
void ignoresTheSelfDescribingHeaderAndParsesEveryReport() {
String two = SAMPLE
Expand All @@ -51,20 +49,73 @@ void ignoresTheSelfDescribingHeaderAndParsesEveryReport() {
assertThat(reports).extracting(StReport::id).containsExactly("a1b2c3d4", "beef");
assertThat(reports.get(1).culprit().line()).isEqualTo(87);
}

@Test
void aTruncatedTrailingBlockIsStillSurfaced() {
// a file killed mid-write: the last block has no END line
void discardsTruncatedBlocksAndStillParsesTheNextCompleteReport() {
String truncated = "━━━ ERROR #dead ━━━ 2026-07-10 20:18:00.000 thread=main ━━━\n"
+ "RuntimeException: boom\n"
+ "at Svc.run(Svc.java:12) ← YOUR CODE\n";

List<StReport> reports = StReportParser.parse(truncated);

assertThat(StReportParser.parse(truncated)).isEmpty();
String completeAfterTruncated = truncated
+ "━━━ ERROR #beef ━━━ 2026-07-10 20:19:00.000 thread=main ━━━\n"
+ "RuntimeException: complete report\n"
+ "at GoodService.run(GoodService.java:7) ← YOUR CODE\n"
+ "━━━ END #beef ━━━\n";
assertThat(StReportParser.parse(completeAfterTruncated))
.extracting(StReport::id)
.containsExactly("beef");
}
@Test
void markedFrameWithNegativeLineDoesNotFallBackToAnotherFile() {
String content = """
━━━ ERROR #nodebug ━━━ 2026-07-10 20:20:00.000 thread=main ━━━
IllegalStateException: wrapped failure
at CheckoutService.wrap(CheckoutService.java:88)
at OrderService.confirm(OrderService.java:-1) ← YOUR CODE
━━━ END #nodebug ━━━
""";

StReport report = StReportParser.parse(content).get(0);

assertThat(report.culprit()).isNull();
assertThat(report.frames())
.extracting(StFrame::fileName)
.containsExactly("CheckoutService.java");
}
@Test
void clampsAbsurdLineNumbersInsteadOfThrowing() {
String content = """
━━━ ERROR #huge ━━━ 2026-07-10 20:21:00.000 thread=main ━━━
RuntimeException: huge line number
at HugeService.run(HugeService.java:999999999999999999999999) ← YOUR CODE
━━━ END #huge ━━━
""";

List<StReport> reports = StReportParser.parse(content);
assertThat(reports).hasSize(1);
assertThat(reports.get(0).culprit().fileName()).isEqualTo("Svc.java");
assertThat(reports.get(0).culprit()).isNotNull();
assertThat(reports.get(0).culprit().line()).isEqualTo(Integer.MAX_VALUE);
}
@Test
void parsesUnicodeAndKotlinScriptFileNames() {
String content = """
━━━ ERROR #unicode ━━━ 2026-07-10 20:22:00.000 thread=main ━━━
RuntimeException: unicode filename
at Ação.run(Ação.java:23) ← YOUR CODE
━━━ END #unicode ━━━
━━━ ERROR #script ━━━ 2026-07-10 20:23:00.000 thread=main ━━━
RuntimeException: Kotlin script filename
at 構建.run(構建.kts:9) ← YOUR CODE
━━━ END #script ━━━
""";

List<StReport> reports = StReportParser.parse(content);

assertThat(reports).hasSize(2);
assertThat(reports.get(0).culprit().fileName()).isEqualTo("Ação.java");
assertThat(reports.get(0).culprit().line()).isEqualTo(23);
assertThat(reports.get(1).culprit().fileName()).isEqualTo("構建.kts");
assertThat(reports.get(1).culprit().line()).isEqualTo(9);
}

@Test
void toleratesEmptyAndHeaderOnlyFiles() {
assertThat(StReportParser.parse("")).isEmpty();
Expand Down