@stacknyu/pi-check 0.0.0-stage → 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/extension.ts ADDED
@@ -0,0 +1,215 @@
1
+ /**
2
+ * pi-check — one shared check runner per repo for every Pi session and worktree.
3
+ *
4
+ * check tool agents run tests / typecheck / lint for what their changes affect; results are
5
+ * shared across sessions, cached by file content, and pre-existing failures are separated
6
+ * bash routing raw `go test`, `pytest`, `vitest`, `tsc`, `eslint`… commands go through the broker too
7
+ * /check status | run [all] | baseline | known | speculate | cancel | stop | restart | init | log
8
+ */
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { Type } from "typebox";
12
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
+ import { CLI, SHIM_DIR, call, connect, installShims } from "./lib/client.mjs";
14
+ import { CONFIG_FILE, loadConfig } from "./lib/repo.mjs";
15
+ import { STATE_DIR, repoRoots, shq, tailLines } from "./lib/util.mjs";
16
+ import { isCheckCommand, isGoCommand } from "./lib/commands.mjs";
17
+
18
+ const rootsMemo = new Map<string, any>();
19
+ function roots(cwd: string): any {
20
+ if (!rootsMemo.has(cwd)) rootsMemo.set(cwd, repoRoots(cwd) ?? null);
21
+ return rootsMemo.get(cwd);
22
+ }
23
+
24
+ function config(cwd: string): any {
25
+ const r = roots(cwd);
26
+ return r ? loadConfig(r.main) : undefined;
27
+ }
28
+
29
+ function statusLine(s: any): string | undefined {
30
+ if (!s) return undefined;
31
+ const run = s.running?.filter((j: any) => !j.speculative).length ?? 0;
32
+ const spec = s.running?.filter((j: any) => j.speculative).length ?? 0;
33
+ const q = s.pending?.requested ?? 0;
34
+ if (!run && !spec && !q) return s.stats?.savedMs > 60000 ? `checks idle · reused ${s.stats.saved} unit-runtime` : undefined;
35
+ return `checks: ${run} running${spec ? ` +${spec} ahead-of-time` : ""}${q ? ` · ${q} queued` : ""}`;
36
+ }
37
+
38
+ export default function (pi: ExtensionAPI) {
39
+ let shimsReady = false;
40
+ try {
41
+ installShims();
42
+ shimsReady = true;
43
+ } catch {}
44
+
45
+ let timer: any;
46
+ const watchStatus = (ctx: any) => {
47
+ clearInterval(timer);
48
+ if (!ctx?.hasUI && !ctx?.ui) return;
49
+ timer = setInterval(async () => {
50
+ try {
51
+ const r = roots(ctx.cwd);
52
+ if (!r || !existsSync(join(r.main, STATE_DIR, "broker.json"))) return ctx.ui?.setStatus?.("check", undefined);
53
+ const c = await connect(ctx.cwd, { start: false });
54
+ if (!c.info) return ctx.ui?.setStatus?.("check", undefined);
55
+ const s = await call(ctx.cwd, "/status", undefined, undefined, { start: false, timeoutMs: 2000 });
56
+ ctx.ui?.setStatus?.("check", statusLine(s));
57
+ } catch {}
58
+ }, 4000);
59
+ timer.unref?.();
60
+ };
61
+
62
+ pi.on("session_start", async (_e: any, ctx: any) => {
63
+ const r = roots(ctx.cwd);
64
+ if (!r) return;
65
+ // repos that use pi-check: make sure this checkout is watched, so checks run ahead of time
66
+ if (existsSync(join(r.main, CONFIG_FILE)) || existsSync(join(r.main, STATE_DIR))) {
67
+ call(ctx.cwd, "/touch", {}).catch(() => {});
68
+ watchStatus(ctx);
69
+ }
70
+ });
71
+ pi.on("session_shutdown", async () => clearInterval(timer));
72
+
73
+ pi.on("tool_call", async (event: any, ctx: any) => {
74
+ if (event.toolName !== "bash" || typeof event.input?.command !== "string") return;
75
+ const command: string = event.input.command;
76
+ if (command.includes("pi-check") || command.includes("PI_CHECK_BYPASS")) return;
77
+ const r = roots(ctx.cwd);
78
+ if (!r) return;
79
+ const cfg = config(ctx.cwd);
80
+ if (!cfg || cfg.intercept === "off") return;
81
+ if (isCheckCommand(command)) {
82
+ if (cfg.intercept === "block")
83
+ return { block: true, reason: "Run checks with the `check` tool instead (it runs only what your changes affect and shares results with other sessions). For a specific command: pi-check exec -- <command>." };
84
+ event.input.command = `${shq(process.execPath)} ${shq(CLI)} exec --cwd ${shq(ctx.cwd)} -- ${shq(command)}`;
85
+ return;
86
+ }
87
+ if (shimsReady && cfg.shimGo && isGoCommand(command)) event.input.command = `export PATH=${shq(SHIM_DIR)}:"$PATH"; ${command}`;
88
+ });
89
+
90
+ pi.registerTool({
91
+ name: "check",
92
+ label: "Check",
93
+ description:
94
+ "Run the repo's tests, type checks and linters (Go, TypeScript/JavaScript, Python) through the shared check broker. By default only what your changes affect (vs the base branch) runs: packages/test files/projects whose inputs changed. Results are cached by file content and shared with every other session and worktree, identical runs in progress are joined instead of repeated, and failures that also fail on the base branch are reported as pre-existing (they do not fail the check).",
95
+ promptSnippet: "check: run affected tests/typecheck/lint via the shared, cached check broker",
96
+ promptGuidelines: [
97
+ "Use the `check` tool to run tests, type checks and linters instead of running go test, pytest, vitest, jest, tsc, eslint or ruff in bash: it runs only what your changes affect, reuses results other sessions already have, and separates pre-existing failures from ones you caused.",
98
+ "Call `check` after a meaningful set of edits, not after every small edit; it also runs affected checks in the background while you work, so results are often ready.",
99
+ "Fix failures marked new; failures marked pre-existing also fail on the base branch — leave them unless your task is to fix them.",
100
+ ],
101
+ parameters: Type.Object({
102
+ scope: Type.Optional(Type.Union([Type.Literal("changed"), Type.Literal("all")], { description: "changed (default): what your changes affect. all: everything." })),
103
+ files: Type.Optional(Type.Array(Type.String(), { description: "Only checks that depend on these files (repo- or cwd-relative)" })),
104
+ kinds: Type.Optional(Type.Array(Type.Union(["test", "typecheck", "lint", "vet", "custom"].map((k) => Type.Literal(k))))),
105
+ projects: Type.Optional(Type.Array(Type.String({ description: "Project dirs, e.g. apps/dashboard" }))),
106
+ strict: Type.Optional(Type.Boolean({ description: "Count pre-existing failures too" })),
107
+ refresh: Type.Optional(Type.Boolean({ description: "Ignore cached results and run again" })),
108
+ }),
109
+ async execute(_id: string, p: any, _signal: any, onUpdate: any, ctx: any) {
110
+ if (!roots(ctx.cwd)) return { content: [{ type: "text", text: "check: not inside a git repository." }], details: {} };
111
+ const scope = p.files?.length ? "files" : p.scope === "all" ? "all" : "changed";
112
+ const r = roots(ctx.cwd);
113
+ const files = (p.files ?? []).map((f: string) => {
114
+ const abs = f.startsWith("/") ? f : join(ctx.cwd, f);
115
+ return abs.startsWith(`${r.root}/`) ? abs.slice(r.root.length + 1) : f;
116
+ });
117
+ let lastText = "";
118
+ try {
119
+ const res: any = await call(ctx.cwd, "/run", { scope, files, kinds: p.kinds, projects: p.projects, strict: p.strict, refresh: p.refresh, label: "check tool" }, (ev: any) => {
120
+ const t = ev.type === "start" ? `checking ${ev.units} units: ${ev.cached} cached, ${ev.joined} already running elsewhere, ${ev.queued} to run` : ev.type === "progress" ? `${ev.done ?? ""}/${ev.total ?? ""} · ${ev.text ?? ""}` : "";
121
+ if (t && t !== lastText) {
122
+ lastText = t;
123
+ onUpdate?.({ content: [{ type: "text", text: t }], details: {} });
124
+ }
125
+ }, { signal: _signal });
126
+ const rep = res.report;
127
+ return { content: [{ type: "text", text: rep.text }], details: { ok: rep.ok, newFailures: rep.newFailures, knownFailures: rep.knownFailures, units: rep.units, ran: rep.ran, cached: rep.cache, shared: rep.shared }, isError: !rep.ok };
128
+ } catch (e: any) {
129
+ if (_signal?.aborted) return { content: [{ type: "text", text: "check: cancelled this request; other sessions' checks were not cancelled." }], details: {}, isError: true };
130
+ return { content: [{ type: "text", text: `check: the check broker failed: ${e?.message ?? e}. Inspect /check status before retrying; do not start an unbudgeted duplicate.` }], details: {}, isError: true };
131
+ }
132
+ },
133
+ } as any);
134
+
135
+ pi.registerCommand("check", {
136
+ description: "Shared check broker: /check [status] | run [all] | baseline | known [clear] | speculate [all] | cancel | reload | stop | restart | init | log",
137
+ handler: async (args: string, ctx: any) => {
138
+ const [cmd = "status", ...rest] = (args ?? "").trim().split(/\s+/).filter(Boolean);
139
+ const cwd = ctx.cwd;
140
+ const r = roots(cwd);
141
+ if (!r) return ctx.ui.notify("check: not inside a git repository.", "warning");
142
+ try {
143
+ if (cmd === "status") {
144
+ const c = await connect(cwd, { start: false });
145
+ if (!c.info) return ctx.ui.notify("check: the broker is not running here; it starts with the first check (/check run).", "info");
146
+ const s: any = await call(cwd, "/status");
147
+ const st = s.stats;
148
+ ctx.ui.notify(
149
+ [
150
+ `pi-check · ${s.cpus} cores, load ${s.load} · ${s.maxJobs} job slots, ${s.allocatedCpus ?? "?"}/${s.cpuBudget ?? s.cpus} CPU budget allocated${s.configPending ? " · config waiting for idle" : ""} · ahead-of-time runs ${s.speculate ? "on" : "off"}`,
151
+ `running: ${s.running.map((j: any) => `${j.root}: ${j.label}${j.speculative ? " (ahead of time)" : ""} ${j.for}${j.activity ? ` · ${j.activity}` : ""}`).join(" | ") || "nothing"}`,
152
+ `queued: ${s.pending.requested} requested, ${s.pending.speculative} ahead-of-time`,
153
+ `checkouts watched: ${s.roots.map((x: any) => x.name).join(", ")}`,
154
+ `asked ${st.unitsAsked} units: ${st.cacheHits} cached, ${st.joined} joined a run in progress, ${st.ran} ran · ${st.speculativeRan} ran ahead of time · reused unit-runtime ≈ ${st.saved} (not wall-time savings)`,
155
+ `pre-existing failures known: ${s.known}`,
156
+ ].join("\n"),
157
+ "info",
158
+ );
159
+ watchStatus(ctx);
160
+ return;
161
+ }
162
+ if (cmd === "run" || cmd === "baseline") {
163
+ const base = cmd === "baseline";
164
+ ctx.ui.notify(base ? "check: running everything on the main checkout…" : "check: running…", "info");
165
+ const res: any = await call(base ? r.main : cwd, "/run", { scope: base || rest[0] === "all" ? "all" : "changed", label: base ? "baseline" : "/check" });
166
+ ctx.ui.notify(tailLines(res.report.text, 60), res.report.ok ? "info" : "warning");
167
+ watchStatus(ctx);
168
+ return;
169
+ }
170
+ if (cmd === "known") {
171
+ const res: any = await call(cwd, "/known", rest[0] === "clear" ? { action: "clear" } : rest[0] === "remove" ? { action: "remove", pattern: rest[1] } : {});
172
+ const e = Object.entries(res.known);
173
+ ctx.ui.notify(`${e.map(([id, k]: any) => `${k.source === "manual" ? "[manual] " : ""}${k.title ?? id}`).join("\n") || "(none)"}\n${e.length} known failure(s)`, "info");
174
+ return;
175
+ }
176
+ if (cmd === "speculate") {
177
+ const res: any = await call(cwd, "/speculate", { all: rest[0] === "all" });
178
+ ctx.ui.notify(`check: ${res.queued} unit(s) queued to run ahead of time.`, "info");
179
+ return;
180
+ }
181
+ if (cmd === "cancel") {
182
+ const res: any = await call(cwd, "/cancel", { root: r.root });
183
+ ctx.ui.notify(`check: cancelled ${res.cancelled}.`, "info");
184
+ return;
185
+ }
186
+ if (cmd === "reload") {
187
+ const res: any = await call(cwd, "/reload", {});
188
+ ctx.ui.notify(res.deferred ? "check: configuration queued; applies after requested work finishes." : "check: configuration applied.", "info");
189
+ return;
190
+ }
191
+ if (cmd === "stop" || cmd === "restart") {
192
+ const c = await connect(cwd, { start: false });
193
+ if (c.info) await call(cwd, "/stop", {}, undefined, { start: false, timeoutMs: 3000 }).catch(() => {});
194
+ if (cmd === "restart") {
195
+ await new Promise((res) => setTimeout(res, 700));
196
+ await connect(cwd);
197
+ }
198
+ ctx.ui.notify(`check: broker ${cmd === "stop" ? "stopped" : "restarted"}.`, "info");
199
+ return;
200
+ }
201
+ if (cmd === "init" || cmd === "log" || cmd === "projects") {
202
+ const { spawnSync } = await import("node:child_process");
203
+ const out = spawnSync(process.execPath, [CLI, cmd], { cwd, encoding: "utf8" });
204
+ ctx.ui.notify(`${out.stdout}${out.stderr}`.trim() || "(no output)", "info");
205
+ return;
206
+ }
207
+ ctx.ui.notify("Usage: /check [status] | run [all] | baseline | known [clear|remove <text>] | speculate [all] | cancel | reload | stop | restart | init | projects | log", "warning");
208
+ } catch (e: any) {
209
+ ctx.ui.notify(`check: ${e?.message ?? e}`, "error");
210
+ }
211
+ },
212
+ });
213
+ }
214
+
215
+ export { statusLine };
@@ -0,0 +1,35 @@
1
+ // Custom checks from pi-check.json: { name, cwd, cmd, inputs: [globs, repo-relative], timeout, env, parallel }
2
+ import { cleanEnv, matchAny, parseDuration, runProc, sha } from "../util.mjs";
3
+
4
+ const adapter = {
5
+ lang: "custom",
6
+ version: 2,
7
+ detect(rc) {
8
+ return (rc.config.custom ?? [])
9
+ .filter((c) => c?.name && c?.cmd)
10
+ .map((c) => ({ id: `custom:${c.name}`, lang: "custom", dir: c.cwd ?? ".", name: c.name, kinds: ["custom"], meta: c }));
11
+ },
12
+ units(rc, project) {
13
+ const c = project.meta;
14
+ const globs = c.inputs?.length ? c.inputs : [project.dir === "." ? "**" : `${project.dir}/**`];
15
+ return [{ target: c.name, label: c.name, inputs: rc.listing().files.filter((f) => matchAny(globs, f)) }];
16
+ },
17
+ group(project) {
18
+ return { name: `custom:${project.meta.name}`, limit: project.meta.parallel ?? 1 };
19
+ },
20
+ batchSize: () => 1,
21
+ argsKey(_opts, project) {
22
+ return JSON.stringify({ cmd: project.meta.cmd, env: project.meta.env ?? {} });
23
+ },
24
+ async run({ rc, project, units, signal, nice, threads }) {
25
+ const c = project.meta;
26
+ const env = cleanEnv({ ...(c.env ?? {}), PI_CHECK_BYPASS: "1", GOMAXPROCS: String(threads), GOFLAGS: `${process.env.GOFLAGS ?? ""} -p=1`.trim() });
27
+ const r = await runProc({ cmd: "/bin/sh", args: ["-c", c.cmd], cwd: rc.path(project.dir), env, signal, nice, timeoutMs: parseDuration(c.timeout, 30 * 60000) });
28
+ const out = `${r.stdout}${r.stderr ? `\n${r.stderr}` : ""}`.trim();
29
+ const u = units[0];
30
+ const results = new Map([[u.target, r.code === 0 ? { status: "pass", failures: [], passed: 1, output: out.slice(-20000) } : { status: r.killed ? "error" : "fail", failures: [{ id: `custom:${c.name}:${sha(out.split(rc.abs).join("<checkout>")).slice(0, 16)}`, title: `${c.name} failed (exit ${r.code})`, output: out.slice(-6000) }], output: out.slice(-20000) }]]);
31
+ return { results, ms: r.ms, killed: r.killed };
32
+ },
33
+ };
34
+
35
+ export default adapter;
@@ -0,0 +1,358 @@
1
+ // Go: one unit per package. Inputs = the package's files plus every in-repo package it (and its tests) import.
2
+ import { spawnSync } from "node:child_process";
3
+ import { join } from "node:path";
4
+ import { kindOptions, LOCKFILES } from "../repo.mjs";
5
+ import { SHIM_DIR, cleanEnv, baseOf, dirOf, isUnder, joinRel, matchGlob, normRel, parseDuration, runProc, sha, which } from "../util.mjs";
6
+
7
+ const SKIP_SEG = /(^|\/)(vendor|testdata|node_modules|_[^/]*|\.[^/]+)(\/|$)/;
8
+ const GO_LOCKFILES = ["go.work", "go.work.sum"];
9
+ const NATIVE_SOURCE = /\.(go|c|cc|cpp|cxx|m|mm|h|hh|hpp|hxx|s|S|sx|f|F|for|f90|swig|swigcxx|syso)$/;
10
+
11
+ function goHeader(text) {
12
+ const cut = text.search(/^(func|type|var|const)\b/m);
13
+ return cut > 0 ? text.slice(0, cut) : text;
14
+ }
15
+
16
+ function parseGoFile(text) {
17
+ const head = goHeader(text);
18
+ const imports = [];
19
+ const re = /^\s*import\s*(?:\(([\s\S]*?)\)|(?:[\w.]+\s+)?"([^"]+)")/gm;
20
+ let m;
21
+ while ((m = re.exec(head))) {
22
+ if (m[2]) imports.push(m[2]);
23
+ else if (m[1]) for (const mm of m[1].matchAll(/"([^"]+)"/g)) imports.push(mm[1]);
24
+ }
25
+ const embeds = [];
26
+ for (const em of text.matchAll(/^\s*\/\/go:embed\s+(.+)$/gm)) {
27
+ for (const token of em[1].match(/"(?:[^"\\]|\\.)*"|`[^`]*`|\S+/g) ?? []) {
28
+ let pattern = token;
29
+ try { if (token.startsWith('"')) pattern = JSON.parse(token); } catch { pattern = "**"; }
30
+ embeds.push(pattern.replace(/^`|`$/g, "").replace(/^all:/, ""));
31
+ }
32
+ }
33
+ return { imports, embeds };
34
+ }
35
+
36
+ /** Modules, packages and the import graph of a checkout (memoized per checkout version). */
37
+ export function goGraph(rc) {
38
+ const cached = rc.memo.get("go:graph");
39
+ if (cached) return cached;
40
+ const files = rc.listing().files;
41
+ const modules = [];
42
+ for (const f of files) {
43
+ if (baseOf(f) !== "go.mod" || SKIP_SEG.test(dirOf(f) === "." ? "" : dirOf(f))) continue;
44
+ const m = rc.text(f).match(/^\s*module\s+(\S+)/m);
45
+ if (m) modules.push({ dir: dirOf(f), path: m[1].replace(/"/g, "") });
46
+ }
47
+ modules.sort((a, b) => b.dir.length - a.dir.length);
48
+ const moduleOf = (dir) => modules.find((m) => isUnder(dir, m.dir));
49
+ const pkgs = new Map(); // dir -> pkg
50
+ const byDir = new Map(); // dir -> all files directly in dir
51
+ for (const f of files) {
52
+ const d = dirOf(f);
53
+ if (!byDir.has(d)) byDir.set(d, []);
54
+ byDir.get(d).push(f);
55
+ }
56
+ for (const f of files) {
57
+ if (!f.endsWith(".go")) continue;
58
+ const d = dirOf(f);
59
+ if (SKIP_SEG.test(d === "." ? "" : d)) continue;
60
+ const mod = moduleOf(d);
61
+ if (!mod) continue;
62
+ let p = pkgs.get(d);
63
+ if (!p) {
64
+ const relInMod = d === mod.dir ? "" : mod.dir === "." ? d : d.slice(mod.dir.length + 1);
65
+ p = { dir: d, module: mod, importPath: relInMod ? `${mod.path}/${relInMod}` : mod.path, rel: relInMod ? `./${relInMod}` : ".", goFiles: [], imports: new Set(), testImports: new Set(), embeds: [], productionEmbeds: [], hasTests: false };
66
+ pkgs.set(d, p);
67
+ }
68
+ p.goFiles.push(f);
69
+ const memoKey = `go:file:${rc.hash(f)}`;
70
+ let parsed = rc.hasher.memo.get(memoKey);
71
+ if (!parsed) {
72
+ parsed = parseGoFile(rc.text(f));
73
+ rc.hasher.memo.set(memoKey, parsed);
74
+ }
75
+ const isTest = f.endsWith("_test.go");
76
+ if (isTest) p.hasTests = true;
77
+ for (const i of parsed.imports) (isTest ? p.testImports : p.imports).add(i);
78
+ for (const e of parsed.embeds) { p.embeds.push(e); if (!isTest) p.productionEmbeds.push(e); }
79
+ }
80
+ const byImport = new Map();
81
+ for (const p of pkgs.values()) byImport.set(p.importPath, p);
82
+ for (const p of pkgs.values()) {
83
+ p.extra = [];
84
+ p.productionExtra = [];
85
+ p.dirFiles = byDir.get(p.dir) ?? [];
86
+ }
87
+ // Visit ancestor packages, not the entire repository once per package.
88
+ for (const f of files) {
89
+ for (let d = dirOf(f); ; d = dirOf(d)) {
90
+ const p = pkgs.get(d);
91
+ if (p) {
92
+ const r = d === "." ? f : f.slice(d.length + 1);
93
+ const parts = r.split("/");
94
+ // Conservatively include character-class matches; the generic glob matcher has no Go path.Match classes.
95
+ const matches = (e) => parts.some((_, i) => matchGlob(e.replace(/\[[^\]]+\]/g, "?"), parts.slice(0, i + 1).join("/")));
96
+ if (r.startsWith("testdata/") || p.embeds.some(matches)) p.extra.push(f);
97
+ if (p.productionEmbeds.some(matches)) p.productionExtra.push(f);
98
+ }
99
+ if (d === ".") break;
100
+ }
101
+ }
102
+ const g = { modules, pkgs, byImport, depMemo: new Map() };
103
+ rc.memo.set("go:graph", g);
104
+ return g;
105
+ }
106
+
107
+ function deps(g, p) {
108
+ // transitive in-repo non-test imports
109
+ let s = g.depMemo.get(p.dir);
110
+ if (s) return s;
111
+ s = new Set();
112
+ g.depMemo.set(p.dir, s);
113
+ for (const i of p.imports) {
114
+ const q = g.byImport.get(i);
115
+ if (!q || s.has(q.dir)) continue;
116
+ s.add(q.dir);
117
+ for (const d of deps(g, q)) s.add(d);
118
+ }
119
+ return s;
120
+ }
121
+
122
+ function closure(g, p) {
123
+ const s = new Set([p.dir, ...deps(g, p)]);
124
+ for (const i of p.testImports) {
125
+ const q = g.byImport.get(i);
126
+ if (!q) continue;
127
+ s.add(q.dir);
128
+ for (const d of deps(g, q)) s.add(d);
129
+ }
130
+ return s;
131
+ }
132
+
133
+ const adapter = {
134
+ lang: "go",
135
+ version: 4,
136
+
137
+ detect(rc) {
138
+ if (!rc.config.languages.go) return [];
139
+ const g = goGraph(rc);
140
+ return [...g.modules]
141
+ .sort((a, b) => a.dir.localeCompare(b.dir))
142
+ .map((m) => ({ id: `go:${m.dir}`, lang: "go", dir: m.dir, name: m.path, kinds: ["vet", "test"].filter((k) => kindOptions(rc.config, m.dir, k) !== false) }));
143
+ },
144
+
145
+ units(rc, project, kind, request = {}) {
146
+ const g = goGraph(rc);
147
+ const opts = kindOptions(rc.config, project.dir, kind) || {};
148
+ const candidates = [...g.pkgs.values()].filter((p) => p.module.dir === project.dir);
149
+ let eligible;
150
+ // Ask Go only when build constraints can exclude whole packages; ordinary graphs stay filesystem-only.
151
+ if (candidates.some((p) => p.goFiles.some((f) => /_(?:aix|android|darwin|dragonfly|freebsd|illumos|ios|js|linux|netbsd|openbsd|plan9|solaris|wasip1|windows|386|amd64|arm|arm64|loong64|mips\w*|ppc64\w*|riscv64|s390x|wasm)(?:_test)?\.go$/.test(f) || /^\/\/(?:go:build|\s*\+build)\s/m.test(rc.text(f))))) {
152
+ const env = cleanEnv({ ...request.env, ...opts.env });
153
+ const args = [...(opts.args ?? []), ...(request.extraArgs ?? [])];
154
+ const tags = args.flatMap((a, i) => /^-(?:tags|mod)$/.test(a) ? [a, args[i + 1]] : /^-(?:tags|race|msan|asan|mod)(?:=|$)/.test(a) ? [a] : []);
155
+ const listed = spawnSync(which("go", { skipDir: SHIM_DIR, pathEnv: env.PATH }) ?? "go", ["list", "-e", ...tags, "-f", "{{if or .GoFiles .CgoFiles .TestGoFiles .XTestGoFiles}}{{.ImportPath}}{{end}}", "./..."], { cwd: rc.path(project.dir), env, encoding: "utf8", timeout: 30000 });
156
+ if (listed.status !== 0) throw new Error(`Go package discovery failed: ${listed.error?.message ?? listed.stderr}`);
157
+ eligible = new Set(listed.stdout.trim().split(/\s+/));
158
+ }
159
+ const extraGlobs = opts.inputs ?? [];
160
+ const extraFiles = extraGlobs.length ? rc.filesUnder(project.dir).filter((f) => extraGlobs.some((gl) => matchGlob(gl, project.dir === "." ? f : f.slice(project.dir.length + 1)))) : [];
161
+ const roots = GO_LOCKFILES.filter((f) => rc.has(f));
162
+ const out = [];
163
+ for (const p of candidates) {
164
+ if (eligible && !eligible.has(p.importPath)) continue;
165
+ const inputs = [...p.dirFiles.filter((f) => !LOCKFILES.includes(baseOf(f)) || GO_LOCKFILES.includes(baseOf(f))), ...p.extra, ...extraFiles, ...roots];
166
+ const mods = new Set([p.module]);
167
+ for (const d of closure(g, p)) {
168
+ const q = g.pkgs.get(d);
169
+ if (!q) continue;
170
+ mods.add(q.module);
171
+ if (q === p) continue;
172
+ inputs.push(...q.dirFiles.filter((f) => NATIVE_SOURCE.test(f) && !f.endsWith("_test.go")), ...q.productionExtra);
173
+ }
174
+ for (const m of mods) for (const f of ["go.mod", "go.sum"]) if (rc.has(joinRel(m.dir, f))) inputs.push(joinRel(m.dir, f));
175
+ out.push({ target: p.dir, label: p.importPath, inputs });
176
+ }
177
+ return out;
178
+ },
179
+
180
+ group(project, kind, opts) {
181
+ // A project can opt into serialization for shared ports/databases. Otherwise the
182
+ // broker budgets package processes globally, independently of agent count.
183
+ return { name: `go:${project.dir}:${kind}`, limit: opts?.parallel ?? Infinity };
184
+ },
185
+
186
+ batchSize: () => 1,
187
+
188
+ /** Args that change results (part of the cache key). */
189
+ argsKey(opts) {
190
+ return JSON.stringify({ args: opts?.args ?? [], env: opts?.env ?? {}, timeout: opts?.timeout ?? "10m" });
191
+ },
192
+
193
+ executionKey(rc, project, opts) {
194
+ const env = cleanEnv(opts?.env);
195
+ const binary = which("go", { skipDir: SHIM_DIR, pathEnv: env.PATH }) ?? "go";
196
+ const normalize = (k, v) => k === "GOWORK" && v.startsWith(`${rc.abs}/`) ? v.slice(rc.abs.length + 1) : v;
197
+ const settings = Object.entries(env).filter(([k]) => /^(GO[A-Z0-9_]*|CGO_[A-Z0-9_]+|CC|CXX|PKG_CONFIG|PATH)$/.test(k)).map(([k, v]) => [k, normalize(k, v)]).sort();
198
+ const key = `go:environment:${project.dir}:${sha(JSON.stringify([binary, rc.hasher.hash(binary), settings]))}`;
199
+ if (!rc.memo.has(key)) {
200
+ const r = spawnSync(binary, ["env", "-json", "GOVERSION", "GOROOT", "GOOS", "GOARCH", "GOFLAGS", "GOWORK", "CGO_ENABLED", "GOEXPERIMENT", "CC", "CXX"], { cwd: rc.path(project.dir), env, encoding: "utf8", timeout: 10000, maxBuffer: 1024 * 1024 });
201
+ let values;
202
+ try { values = Object.fromEntries(Object.entries(JSON.parse(r.stdout)).map(([k, v]) => [k, normalize(k, v)])); } catch { values = r.stdout ?? ""; }
203
+ rc.memo.set(key, sha(JSON.stringify([key, r.status, values, r.stderr ?? ""])));
204
+ }
205
+ return rc.memo.get(key);
206
+ },
207
+
208
+ async run({ rc, project, kind, units, opts, threads, signal, nice, extraArgs = [], onUnit, onProgress }) {
209
+ const g = goGraph(rc);
210
+ const goBin = which("go", { skipDir: SHIM_DIR, pathEnv: opts?.env?.PATH }) ?? "go";
211
+ const cwd = rc.path(project.dir);
212
+ const pkgs = units.map((u) => g.pkgs.get(u.target)).filter(Boolean);
213
+ const env = cleanEnv({ ...(opts?.env ?? {}), GOMAXPROCS: String(threads), GOFLAGS: `${opts?.env?.GOFLAGS ?? process.env.GOFLAGS ?? ""} -p=${threads}`.trim() });
214
+ const results = new Map();
215
+ if (kind === "vet") {
216
+ const r = await runProc({ cmd: goBin, args: ["vet", ...(opts?.args ?? []), ...extraArgs, ...pkgs.map((p) => p.rel)], cwd, env, signal, nice, timeoutMs: parseDuration(opts?.timeout, 15 * 60000) });
217
+ const diags = new Map();
218
+ let header;
219
+ for (const raw of `${r.stdout}\n${r.stderr}`.split("\n")) {
220
+ const line = raw.replace(/^vet: /, "");
221
+ const h = line.match(/^# (\S+)/);
222
+ if (h) {
223
+ header = g.byImport.get(h[1]);
224
+ continue;
225
+ }
226
+ const m = line.match(/^(?:\.\/)?([^:\s][^:]*\.go):(\d+):(?:(\d+):)?\s*(.*)$/);
227
+ if (!m) {
228
+ if (header && line.trim()) (diags.get(header.dir) ?? diags.set(header.dir, []).get(header.dir)).push({ file: "", msg: line.trim(), line: 0 });
229
+ continue;
230
+ }
231
+ const file = normRel(joinRel(project.dir, m[1]));
232
+ const owner = g.pkgs.get(dirOf(file)) ?? header;
233
+ if (!owner) continue;
234
+ (diags.get(owner.dir) ?? diags.set(owner.dir, []).get(owner.dir)).push({ file, line: Number(m[2]), msg: m[4] });
235
+ }
236
+ for (const p of pkgs) {
237
+ const d = diags.get(p.dir) ?? [];
238
+ if (d.length) results.set(p.dir, { status: "fail", failures: d.map((x) => ({ id: `${p.importPath}:vet:${x.file}:${x.msg}`, title: `${x.file ? `${x.file}:${x.line}: ` : ""}${x.msg}`, output: "" })), output: d.map((x) => `${x.file}:${x.line}: ${x.msg}`).join("\n") });
239
+ else if (r.code !== 0 && !diags.size) results.set(p.dir, { status: "fail", failures: [{ id: `${p.importPath}:vet:[error]`, title: "go vet failed", output: `${r.stdout}\n${r.stderr}`.trim().slice(-4000) }], output: r.stderr.slice(-4000) });
240
+ else results.set(p.dir, { status: "pass", failures: [], passed: 1, output: "" });
241
+ }
242
+ return { results, ms: r.ms, killed: r.killed };
243
+ }
244
+ // test — events stream in; each package's result is reported as soon as go finishes it
245
+ const args = ["test", "-json", "-count=1", ...(opts?.args ?? []), ...extraArgs];
246
+ if (opts?.timeout && !extraArgs.some((a) => a.startsWith("-timeout"))) args.push("-timeout", String(opts.timeout));
247
+ args.push(...pkgs.map((p) => p.rel));
248
+ const timeoutIndex = args.findLastIndex((a) => a === "-timeout" || a.startsWith("-timeout="));
249
+ const timeout = timeoutIndex < 0 ? "10m" : args[timeoutIndex].includes("=") ? args[timeoutIndex].slice(9) : args[timeoutIndex + 1];
250
+ const goTimeout = parseDuration(timeout, 10 * 60000);
251
+ const per = new Map();
252
+ const buildOut = new Map();
253
+ const byIp = new Map(pkgs.map((p) => [p.importPath, p]));
254
+ const get = (ip) => {
255
+ let x = per.get(ip);
256
+ if (!x) per.set(ip, (x = { tests: new Map(), out: [], status: undefined, elapsed: 0 }));
257
+ return x;
258
+ };
259
+ const finish = (p, killed = false, stderr = "") => {
260
+ const x = per.get(p.importPath);
261
+ const out = x ? x.out.join("") : "";
262
+ if (x && (x.status === "pass" || x.status === "skip")) {
263
+ const passed = [...x.tests.entries()].filter(([n, t]) => t.status === "pass" && !n.includes("/")).length;
264
+ return { status: "pass", failures: [], passed, output: out.slice(-200000), seconds: x.elapsed };
265
+ }
266
+ if (x && x.status === "fail") {
267
+ const failed = [...x.tests.entries()].filter(([, t]) => t.status === "fail");
268
+ const leaves = failed.filter(([n]) => !failed.some(([m]) => m !== n && m.startsWith(`${n}/`)));
269
+ const failures = leaves.map(([n, t]) => ({ id: `${p.importPath}:${n}`, title: `${p.importPath} ${n}`, output: t.out.join("").slice(-6000) }));
270
+ const timedOut = /panic: test timed out after/.test(out);
271
+ if (!failures.length || timedOut) {
272
+ const why = (buildOut.get(p.importPath) ?? "") + out;
273
+ const built = buildOut.has(p.importPath) || /\[build failed\]|\[setup failed\]/.test(out);
274
+ if (!failures.length) failures.push({ id: `${p.importPath}:${built ? "[build]" : timedOut ? "[timeout]" : "[package]"}`, title: `${p.importPath} ${built ? "does not build" : timedOut ? `timed out (${(out.match(/timed out after (\S+)/) ?? [])[1] ?? ""}) — raise the timeout: go test -timeout 30m, or "timeout" for this project in pi-check.json` : "failed (no single test to blame: panic or TestMain)"}`, output: why.slice(-6000) });
275
+ }
276
+ const passed = [...x.tests.entries()].filter(([n, t]) => t.status === "pass" && !n.includes("/")).length;
277
+ // a timeout says nothing about the code: report it, never cache it
278
+ return { status: timedOut ? "error" : "fail", failures, passed, output: out.slice(-200000), seconds: x.elapsed };
279
+ }
280
+ const why = buildOut.get(p.importPath) ?? "";
281
+ if (killed) return { status: "error", failures: [{ id: `${p.importPath}:[killed]`, title: `${p.importPath}: run stopped (timeout or cancelled)`, output: stderr.slice(-3000) }], output: "" };
282
+ return { status: "fail", failures: [{ id: `${p.importPath}:[build]`, title: `${p.importPath} does not build`, output: (why || stderr).slice(-6000) }], output: why };
283
+ };
284
+ const done = new Set();
285
+ let buf = "";
286
+ const onLine = (line) => {
287
+ if (!line.startsWith("{")) return;
288
+ let e;
289
+ try {
290
+ e = JSON.parse(line);
291
+ } catch {
292
+ return;
293
+ }
294
+ if (e.Action === "build-output" || e.Action === "build-fail") {
295
+ const ip = String(e.ImportPath ?? "").replace(/\s.*$/, "");
296
+ if (e.Output) buildOut.set(ip, (buildOut.get(ip) ?? "") + e.Output);
297
+ return;
298
+ }
299
+ if (!e.Package) return;
300
+ const x = get(e.Package);
301
+ if (["run", "pass", "fail", "skip"].includes(e.Action)) onProgress?.(`${e.Package}${e.Test ? ` ${e.Test}` : ""}: ${e.Action}`);
302
+ if (e.Test) {
303
+ let t = x.tests.get(e.Test);
304
+ if (!t) x.tests.set(e.Test, (t = { status: undefined, out: [] }));
305
+ if (e.Action === "output") {
306
+ t.out.push(e.Output);
307
+ x.out.push(e.Output);
308
+ } else if (["pass", "fail", "skip"].includes(e.Action)) t.status = e.Action;
309
+ } else if (e.Action === "output") x.out.push(e.Output);
310
+ else if (["pass", "fail", "skip"].includes(e.Action)) {
311
+ x.status = e.Action;
312
+ x.elapsed = e.Elapsed ?? 0;
313
+ const p = byIp.get(e.Package);
314
+ if (p && !done.has(p.dir)) {
315
+ done.add(p.dir);
316
+ const res = finish(p);
317
+ results.set(p.dir, res);
318
+ onUnit?.(p.dir, res);
319
+ }
320
+ }
321
+ };
322
+ const r = await runProc({
323
+ cmd: goBin,
324
+ args,
325
+ cwd,
326
+ env,
327
+ signal,
328
+ nice,
329
+ timeoutMs: goTimeout === 0 ? undefined : goTimeout * Math.max(1, Math.ceil(pkgs.length / threads)) + 120000,
330
+ onData: (d, stream) => {
331
+ if (stream !== "stdout") return;
332
+ buf += d;
333
+ let i;
334
+ while ((i = buf.indexOf("\n")) >= 0) {
335
+ onLine(buf.slice(0, i));
336
+ buf = buf.slice(i + 1);
337
+ }
338
+ },
339
+ });
340
+ if (buf) onLine(buf);
341
+ // build errors printed to stderr by older Go versions: "# importpath" blocks
342
+ let cur;
343
+ for (const line of r.stderr.split("\n")) {
344
+ const h = line.match(/^# (\S+)/);
345
+ if (h) {
346
+ cur = h[1];
347
+ continue;
348
+ }
349
+ if (cur && line.trim()) buildOut.set(cur, (buildOut.get(cur) ?? "") + `${line}\n`);
350
+ }
351
+ for (const p of pkgs) if (!done.has(p.dir)) results.set(p.dir, finish(p, r.killed, r.stderr));
352
+ return { results, ms: r.ms, killed: r.killed };
353
+ },
354
+ };
355
+
356
+ export default adapter;
357
+ export { parseGoFile, closure as goClosure };
358
+ export const goBinary = () => which("go", { skipDir: SHIM_DIR }) ?? "go";