@robhowley/pi-structured-return 1.0.3 → 1.1.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.
package/README.md CHANGED
@@ -15,10 +15,11 @@ Linters: 1 unused variable warning in a single file.
15
15
 
16
16
  | Parser | Raw (tokens) | Structured (tokens) | Reduction | Notes |
17
17
  |---|---|---|---|---|
18
- | `pytest-json-report` | 446 | 59 | **87%** | verbose output with source snippets and summary footer |
18
+ | `junit-xml` (go) | 446 | 58 | **87%** | verbose output with full stack trace per failure |
19
+ | `junit-xml` (pytest) | 446 | 71 | **84%** | verbose output with source snippets and summary footer |
19
20
  | `vitest-json` | 348 | 75 | **78%** | source diff with inline arrows and ANSI color codes per failure |
20
21
  | `rspec-json` | 212 | 55 | **74%** | default output with backtrace |
21
- | `junit-xml` | 263 | 81 | **69%** | gradle console output with build lifecycle noise |
22
+ | `junit-xml` (gradle) | 263 | 81 | **69%** | gradle console output with build lifecycle noise |
22
23
  | `minitest-text` | 168 | 59 | **65%** | default output with backtrace |
23
24
  | `ruff-json` | 107 | 52 | **51%** | source context + help text per error |
24
25
  | `eslint-json` | 64 | 59 | **8%** | already compact formatter |
@@ -26,13 +27,13 @@ Linters: 1 unused variable warning in a single file.
26
27
  Tokens counted with `cl100k_base` (tiktoken). Linter output is more compact than test runner output to begin with, so the baseline reduction is lower. The numbers above are measured against a single file with a single error — a conservative lower bound. Both ruff and eslint repeat absolute file paths per error in their raw output, so reduction grows as violations spread across more files.
27
28
 
28
29
  ## Built-in parsers
29
- - `pytest-json-report`
30
- - `ruff-json` (`ruff check` only — `ruff format` has no json support)
31
- - `eslint-json`
30
+ - `junit-xml` (JUnit XML — covers pytest `--junitxml`, Gradle, Maven, Jest with `jest-junit`, Go with `go-junit-report`, and any other tool that emits the JUnit XML schema)
32
31
  - `vitest-json`
33
32
  - `rspec-json`
34
33
  - `minitest-text` (parses default minitest output — no flags or reporters needed)
35
- - `junit-xml` (JUnit XMLcovers pytest `--junitxml`, Gradle, Maven, Jest with `jest-junit`, Go with `go-junit-report`, and any other tool that emits the JUnit XML schema)
34
+ - `ruff-json` (`ruff check` only — `ruff format` has no json support)
35
+ - `eslint-json`
36
+
36
37
 
37
38
  ## Before / after
38
39
 
@@ -1,6 +1,5 @@
1
1
  import path from "node:path";
2
2
  import type { ParserModule, ParserRegistration } from "../types";
3
- import pytestJsonReport from "../parsers/pytest-json-report";
4
3
  import ruffJson from "../parsers/ruff-json";
5
4
  import eslintJson from "../parsers/eslint-json";
6
5
  import vitestJson from "../parsers/vitest-json";
@@ -10,7 +9,6 @@ import junitXml from "../parsers/junit-xml";
10
9
  import tailFallback from "../parsers/tail-fallback";
11
10
 
12
11
  const builtIns: Record<string, ParserModule> = {
13
- "pytest-json-report": pytestJsonReport,
14
12
  "ruff-json": ruffJson,
15
13
  "eslint-json": eslintJson,
16
14
  "vitest-json": vitestJson,
@@ -33,10 +31,6 @@ const AUTO_DETECT: Array<{ parserId: string; detect: (argv: string[]) => boolean
33
31
  argv.some((a) => a === "--output-format=json" || a === "json") &&
34
32
  (argv.includes("--output-format=json") || argv.includes("--output-format")),
35
33
  },
36
- {
37
- parserId: "pytest-json-report",
38
- detect: (argv) => argv.includes("pytest") && argv.includes("--json-report"),
39
- },
40
34
  {
41
35
  parserId: "vitest-json",
42
36
  detect: (argv) => argv.includes("vitest") && argv.includes("--reporter=json"),
@@ -54,6 +48,12 @@ const AUTO_DETECT: Array<{ parserId: string; detect: (argv: string[]) => boolean
54
48
  },
55
49
  ];
56
50
 
51
+ export function listParsers(): { id: string; autoDetect: boolean }[] {
52
+ const builtInIds = Object.keys(builtIns).filter((id) => id !== "tail-fallback");
53
+ const autoDetectIds = new Set(AUTO_DETECT.map((d) => d.parserId));
54
+ return builtInIds.map((id) => ({ id, autoDetect: autoDetectIds.has(id) }));
55
+ }
56
+
57
57
  export async function resolveParser(opts: {
58
58
  cwd: string;
59
59
  parseAs?: string;
@@ -7,9 +7,7 @@ import { Text } from "@mariozechner/pi-tui";
7
7
  import type { ObservedRunArgs, ParsedResult, RunContext } from "./types";
8
8
  import { ensureRunDir, writeRunArtifacts } from "./storage/log-store";
9
9
  import { loadProjectConfig } from "./config/project-config";
10
- import { resolveParser } from "./config/registry";
11
-
12
- const BUILT_IN_PARSER_IDS = ["pytest-json-report", "ruff-json", "eslint-json", "vitest-json", "tail-fallback"];
10
+ import { resolveParser, listParsers } from "./config/registry";
13
11
 
14
12
  export default function structuredReturn(pi: ExtensionAPI) {
15
13
  pi.registerCommand("sr-parsers", {
@@ -18,8 +16,8 @@ export default function structuredReturn(pi: ExtensionAPI) {
18
16
  const lines: string[] = ["structured-return parsers", ""];
19
17
 
20
18
  lines.push("built-in:");
21
- for (const id of BUILT_IN_PARSER_IDS) {
22
- lines.push(` ${id}`);
19
+ for (const { id, autoDetect } of listParsers()) {
20
+ lines.push(` ${id}${autoDetect ? " (auto-detect)" : ""}`);
23
21
  }
24
22
 
25
23
  const projectRegistrations = loadProjectConfig(ctx.cwd);
@@ -148,6 +148,64 @@ describe("junit-xml parser", () => {
148
148
  });
149
149
  });
150
150
 
151
+ describe("go-junit-report output", () => {
152
+ it("extracts panic message and file:line from system-out when failure body is empty", async () => {
153
+ const xml = `<testsuites tests="2" failures="1">
154
+ <testsuite name="math-benchmark" tests="2" failures="1" errors="0">
155
+ <testcase name="TestAddsTwoNumbersCorrectly" classname="math-benchmark" time="0.000"/>
156
+ <testcase name="TestDoesNotPanic" classname="math-benchmark" time="0.000">
157
+ <failure message="Failed"></failure>
158
+ </testcase>
159
+ <system-out><![CDATA[panic: runtime error: invalid memory address or nil pointer dereference [recovered, repanicked]
160
+ goroutine 23 [running]:
161
+ math-benchmark.TestDoesNotPanic(0x123)
162
+ /project/math_test.go:22 +0x4
163
+ ]]></system-out>
164
+ </testsuite>
165
+ </testsuites>`;
166
+ const result = await parser.parse(makeCtx(xml, "/project"));
167
+ expect(result.failures![0].message).toBe("runtime error: invalid memory address or nil pointer dereference");
168
+ expect(result.failures![0].file).toBe("math_test.go");
169
+ expect(result.failures![0].line).toBe(22);
170
+ });
171
+
172
+ it("extracts file, line, and message from Go failure body", async () => {
173
+ const xml = `<testsuites tests="2" failures="1">
174
+ <testsuite name="math-benchmark" tests="2" failures="1" errors="0">
175
+ <testcase name="TestAddsTwoNumbersCorrectly" classname="math-benchmark" time="0.000"/>
176
+ <testcase name="TestMultipliesTwoNumbersCorrectly" classname="math-benchmark" time="0.000">
177
+ <failure message="Failed"><![CDATA[ math_test.go:16: expected 99, got 12]]></failure>
178
+ </testcase>
179
+ </testsuite>
180
+ </testsuites>`;
181
+ const result = await parser.parse(makeCtx(xml, "/project"));
182
+ expect(result.failures![0].file).toBe("math_test.go");
183
+ expect(result.failures![0].line).toBe(16);
184
+ expect(result.failures![0].message).toBe("expected 99, got 12");
185
+ });
186
+ });
187
+
188
+ describe("pytest --junitxml output", () => {
189
+ it("extracts file and line from failure body when no file attr present", async () => {
190
+ const xml = `<?xml version="1.0" encoding="utf-8"?>
191
+ <testsuites name="pytest tests">
192
+ <testsuite name="pytest" errors="0" failures="1" skipped="0" tests="2">
193
+ <testcase classname="tests.test_math" name="test_adds" time="0.000"/>
194
+ <testcase classname="tests.test_math" name="test_multiplies" time="0.000">
195
+ <failure message="assert (3 * 4) == 99">def test_multiplies():
196
+ &gt; assert 3 * 4 == 99
197
+ E assert (3 * 4) == 99
198
+
199
+ tests/test_math.py:5: AssertionError</failure>
200
+ </testcase>
201
+ </testsuite>
202
+ </testsuites>`;
203
+ const result = await parser.parse(makeCtx(xml, "/project"));
204
+ expect(result.failures![0].file).toBe("tests/test_math.py");
205
+ expect(result.failures![0].line).toBe(5);
206
+ });
207
+ });
208
+
151
209
  describe("multi-suite totals", () => {
152
210
  it("failures and errors summed across suites", async () => {
153
211
  const xml = `<testsuites>
@@ -25,6 +25,7 @@ interface JUnitTestSuite {
25
25
  failures?: string | number;
26
26
  errors?: string | number;
27
27
  testcase?: JUnitTestCase[];
28
+ "system-out"?: string;
28
29
  }
29
30
 
30
31
  interface JUnitDocument {
@@ -38,6 +39,45 @@ const xmlParser = new XMLParser({
38
39
  isArray: (name) => ["testsuite", "testcase"].includes(name),
39
40
  });
40
41
 
42
+ /** Extract file, line, and message from failure body text.
43
+ * Handles pytest-style (last line: "file.py:line: ExceptionType")
44
+ * and Go-style (any line: " file.go:line: message"). */
45
+ function parseBodyLocation(text: string): { file: string; line: number; message?: string } | undefined {
46
+ const lines = text
47
+ .split("\n")
48
+ .map((l) => l.trim())
49
+ .filter(Boolean);
50
+ for (const line of lines) {
51
+ const m = line.match(/^([^:\s]+\.\w+):(\d+):\s*(.*)/);
52
+ if (m) return { file: m[1], line: Number(m[2]), message: m[3] || undefined };
53
+ }
54
+ return undefined;
55
+ }
56
+
57
+ /** Extract panic message and file:line for a specific test from Go's system-out. */
58
+ function parsePanicInfo(
59
+ systemOut: string,
60
+ testName: string
61
+ ): { message?: string; file?: string; line?: number } | undefined {
62
+ const lines = systemOut.split("\n");
63
+ const panicLine = lines.find((l) => l.trim().startsWith("panic:"));
64
+ if (!panicLine) return undefined;
65
+ const message = panicLine
66
+ .trim()
67
+ .replace(/^panic:\s*/, "")
68
+ .replace(/\s*\[.*\]$/, "")
69
+ .trim();
70
+
71
+ // Find the stack frame for this test function, then grab the file:line on the next line
72
+ for (let i = 0; i < lines.length - 1; i++) {
73
+ if (lines[i].includes(`.${testName}(`)) {
74
+ const m = lines[i + 1].trim().match(/^([^:]+\.go):(\d+)/);
75
+ if (m) return { message, file: m[1].split("/").pop(), line: Number(m[2]) };
76
+ }
77
+ }
78
+ return { message };
79
+ }
80
+
41
81
  function resolveFile(tc: JUnitTestCase, suite: JUnitTestSuite, cwd: string): string | undefined {
42
82
  const raw = tc.file ?? suite.file;
43
83
  if (raw) return path.relative(cwd, path.resolve(cwd, raw));
@@ -67,15 +107,22 @@ const parser: ParserModule = {
67
107
  const problem = tc.failure ?? tc.error;
68
108
  if (!problem) continue;
69
109
 
70
- const file = resolveFile(tc, suite, ctx.cwd);
71
- const line = tc.line !== undefined ? Number(tc.line) : undefined;
110
+ const bodyLocation = problem["#text"] ? parseBodyLocation(problem["#text"]) : undefined;
111
+ const panicInfo =
112
+ !bodyLocation && suite["system-out"] && tc.name ? parsePanicInfo(suite["system-out"], tc.name) : undefined;
113
+ const file =
114
+ (tc.file ?? suite.file)
115
+ ? resolveFile(tc, suite, ctx.cwd)
116
+ : (bodyLocation?.file ?? panicInfo?.file ?? resolveFile(tc, suite, ctx.cwd));
117
+ const line = tc.line !== undefined ? Number(tc.line) : (bodyLocation?.line ?? panicInfo?.line);
72
118
  const id = [file, line, tc.name].filter(Boolean).join(":");
73
119
 
74
120
  failures.push({
75
121
  id: id || String(failures.length),
76
122
  file,
77
123
  line: Number.isNaN(line) ? undefined : line,
78
- message: problem.message ?? problem["#text"]?.trim().split("\n")[0],
124
+ message:
125
+ bodyLocation?.message ?? panicInfo?.message ?? problem.message ?? problem["#text"]?.trim().split("\n")[0],
79
126
  rule: problem.type,
80
127
  });
81
128
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@robhowley/pi-structured-return",
3
- "version": "1.0.3",
3
+ "version": "1.1.0",
4
4
  "description": "Structured command execution for Pi agents: compact results for the model, full logs for humans.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,8 +25,7 @@ Prefer better output at the source.
25
25
  ## Examples
26
26
 
27
27
  ### pytest
28
- - `structured_return({ command: "pytest [any pytest args] --json-report --json-report-file=.tmp/pytest-report.json", parseAs: "pytest-json-report", artifactPaths: [".tmp/pytest-report.json"] })` — append the json-report flags to whatever pytest invocation is needed; scope, filters, markers, etc. go in `[any pytest args]`
29
- - `structured_return({ command: "pytest -q" })` — fallback when json-report is unavailable
28
+ - `structured_return({ command: "pytest [any pytest args] --junitxml=.tmp/report.xml", parseAs: "junit-xml", artifactPaths: [".tmp/report.xml"] })` — `--junitxml` is built into pytest, no extra dependencies needed; scope, filters, markers, etc. go in `[any pytest args]`
30
29
 
31
30
  ### ruff check
32
31
  - `structured_return({ command: "ruff check [any ruff args] --output-format=json", parseAs: "ruff-json" })` — scope, selects, ignores, etc. go in `[any ruff args]`
@@ -49,7 +48,8 @@ Prefer better output at the source.
49
48
  ### junit-xml
50
49
  JUnit XML is the de facto standard output format across the JVM ecosystem and many others — Maven, Gradle, pytest (`--junitxml`), Go (`go-junit-report`), .NET (`--logger trx` with conversion), Jest (`jest-junit`), and more. If a tool can emit JUnit XML, `junit-xml` covers it without a custom parser.
51
50
 
52
- - `structured_return({ command: "[tool] [args] --junitxml=.tmp/report.xml", parseAs: "junit-xml", artifactPaths: [".tmp/report.xml"] })` — pytest, nose2, and others that write to a file
51
+ - `structured_return({ command: "[tool] [args] --junitxml=.tmp/report.xml", parseAs: "junit-xml", artifactPaths: [".tmp/report.xml"] })` — pytest, nose2, and any other tool that writes to a file via `--junitxml`
52
+ - `structured_return({ command: "go test [any go test args] 2>&1 | go-junit-report > .tmp/report.xml", parseAs: "junit-xml", artifactPaths: [".tmp/report.xml"] })` — Go requires `go-junit-report` (`go install github.com/jstemmer/go-junit-report/v2@latest`); pipe `go test -v` output through it
53
53
  - `structured_return({ command: "gradle test", parseAs: "junit-xml", artifactPaths: ["build/test-results/test/TEST-*.xml"] })` — Gradle writes one XML per test class; pass all matching paths
54
54
  - `structured_return({ command: "mvn test", parseAs: "junit-xml", artifactPaths: ["target/surefire-reports/TEST-*.xml"] })` — Maven surefire same pattern
55
55
 
@@ -1,76 +0,0 @@
1
- import fs from "node:fs";
2
- import os from "node:os";
3
- import path from "node:path";
4
- import { describe, it, expect } from "vitest";
5
- import parser from "./pytest-json-report";
6
- import type { RunContext } from "../types";
7
-
8
- function makeCtx(report: object | null): RunContext {
9
- const dir = fs.mkdtempSync(path.join(os.tmpdir(), "pytest-test-"));
10
- const artifactPath = path.join(dir, "report.json");
11
- if (report !== null) {
12
- fs.writeFileSync(artifactPath, JSON.stringify(report));
13
- }
14
- return {
15
- command: "pytest --json-report",
16
- argv: ["pytest", "--json-report"],
17
- cwd: "/project",
18
- artifactPaths: [artifactPath],
19
- stdoutPath: path.join(dir, "stdout"),
20
- stderrPath: path.join(dir, "stderr"),
21
- logPath: path.join(dir, "log"),
22
- };
23
- }
24
-
25
- describe("pytest-json-report parser", () => {
26
- it("mix of passed and failed tests → correct counts, status fail", async () => {
27
- const report = {
28
- summary: { passed: 3 },
29
- tests: [
30
- { nodeid: "test_foo.py::test_a", outcome: "passed" },
31
- { nodeid: "test_foo.py::test_b", outcome: "failed", longrepr: "AssertionError: assert False" },
32
- { nodeid: "test_foo.py::test_c", outcome: "failed", longrepr: "AssertionError: assert 1 == 2" },
33
- ],
34
- };
35
- const result = await parser.parse(makeCtx(report));
36
- expect(result.status).toBe("fail");
37
- expect(result.failures).toHaveLength(2);
38
- expect(result.summary).toBe("2 failed, 3 passed");
39
- });
40
-
41
- it("all passing → status pass, summary reflects passed count", async () => {
42
- const report = {
43
- summary: { passed: 5 },
44
- tests: [
45
- { nodeid: "test_foo.py::test_a", outcome: "passed" },
46
- { nodeid: "test_foo.py::test_b", outcome: "passed" },
47
- ],
48
- };
49
- const result = await parser.parse(makeCtx(report));
50
- expect(result.status).toBe("pass");
51
- expect(result.failures).toHaveLength(0);
52
- expect(result.summary).toBe("5 passed");
53
- });
54
-
55
- it("failed test with longrepr → first line surfaced as message", async () => {
56
- const report = {
57
- summary: { passed: 0 },
58
- tests: [
59
- {
60
- nodeid: "test_foo.py::test_a",
61
- outcome: "failed",
62
- longrepr: "AssertionError: assert False\nfull traceback line 2\nfull traceback line 3",
63
- },
64
- ],
65
- };
66
- const result = await parser.parse(makeCtx(report));
67
- expect(result.failures![0].message).toBe("AssertionError: assert False");
68
- });
69
-
70
- it("missing artifact file → throws", async () => {
71
- const ctx = makeCtx(null);
72
- // point artifact at a file that doesn't exist
73
- ctx.artifactPaths = ["/nonexistent/path/report.json"];
74
- await expect(parser.parse(ctx)).rejects.toThrow();
75
- });
76
- });
@@ -1,41 +0,0 @@
1
- import fs from "node:fs";
2
- import type { ParserModule } from "../types";
3
-
4
- interface PytestTest {
5
- nodeid: string;
6
- outcome: string;
7
- longrepr?: string;
8
- }
9
-
10
- interface PytestReport {
11
- tests?: PytestTest[];
12
- summary?: { passed?: number };
13
- }
14
-
15
- const parser: ParserModule = {
16
- id: "pytest-json-report",
17
- async parse(ctx) {
18
- const artifact = ctx.artifactPaths[0];
19
- const data = JSON.parse(fs.readFileSync(artifact, "utf8")) as PytestReport;
20
- const tests = Array.isArray(data.tests) ? data.tests : [];
21
- const failures = tests
22
- .filter((t) => t.outcome === "failed")
23
- .map((t) => ({
24
- id: t.nodeid,
25
- file: String(t.nodeid || "").split("::")[0] || undefined,
26
- message: typeof t.longrepr === "string" ? t.longrepr.split("\n")[0] : "test failed",
27
- }));
28
- const failed = failures.length;
29
- const passed = Number(data.summary?.passed ?? 0);
30
- return {
31
- tool: "pytest",
32
- status: failed > 0 ? "fail" : "pass",
33
- summary: failed > 0 ? `${failed} failed, ${passed} passed` : `${passed} passed`,
34
- failures,
35
- artifact,
36
- logPath: ctx.logPath,
37
- };
38
- },
39
- };
40
-
41
- export default parser;