@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/CHANGELOG.md +7 -0
- package/LICENSE +21 -0
- package/README.md +118 -2
- package/cli.mjs +348 -0
- package/extension.ts +215 -0
- package/lib/adapters/custom.mjs +35 -0
- package/lib/adapters/go.mjs +358 -0
- package/lib/adapters/python.mjs +320 -0
- package/lib/adapters/ts.mjs +445 -0
- package/lib/broker.mjs +1189 -0
- package/lib/client.mjs +131 -0
- package/lib/commands.mjs +27 -0
- package/lib/repo.mjs +185 -0
- package/lib/util.mjs +395 -0
- package/package.json +46 -3
- package/skills/pi-check/SKILL.md +69 -0
package/lib/util.mjs
ADDED
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
// Shared helpers: paths, git, hashing, globs, processes.
|
|
2
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { accessSync, constants, existsSync, mkdirSync, readFileSync, statSync, writeFileSync, renameSync, appendFileSync } from "node:fs";
|
|
5
|
+
import { cpus, homedir } from "node:os";
|
|
6
|
+
import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const STATE_DIR = ".pi-check";
|
|
9
|
+
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
10
|
+
export const HOME_DIR = join(process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent"), "pi-check");
|
|
11
|
+
export const SHIM_DIR = join(HOME_DIR, "shims");
|
|
12
|
+
|
|
13
|
+
export const CPUS = Math.max(1, cpus()?.length || 2);
|
|
14
|
+
|
|
15
|
+
// ---------- small io ----------
|
|
16
|
+
|
|
17
|
+
export function readText(p) {
|
|
18
|
+
try {
|
|
19
|
+
return readFileSync(p, "utf8");
|
|
20
|
+
} catch {
|
|
21
|
+
return "";
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function loadJson(p, fallback = undefined) {
|
|
26
|
+
try {
|
|
27
|
+
return JSON.parse(readFileSync(p, "utf8"));
|
|
28
|
+
} catch {
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function writeJson(p, data) {
|
|
34
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
35
|
+
const tmp = `${p}.${process.pid}.tmp`;
|
|
36
|
+
writeFileSync(tmp, JSON.stringify(data, null, 2));
|
|
37
|
+
renameSync(tmp, p);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function ensureDir(p) {
|
|
41
|
+
mkdirSync(p, { recursive: true });
|
|
42
|
+
return p;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** JSON with comments and trailing commas (tsconfig, etc). */
|
|
46
|
+
export function parseJsonc(text) {
|
|
47
|
+
let out = "";
|
|
48
|
+
let i = 0;
|
|
49
|
+
let inStr = false;
|
|
50
|
+
while (i < text.length) {
|
|
51
|
+
const c = text[i];
|
|
52
|
+
const n = text[i + 1];
|
|
53
|
+
if (inStr) {
|
|
54
|
+
out += c;
|
|
55
|
+
if (c === "\\") {
|
|
56
|
+
out += n ?? "";
|
|
57
|
+
i += 2;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
if (c === '"') inStr = false;
|
|
61
|
+
i++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (c === '"') {
|
|
65
|
+
inStr = true;
|
|
66
|
+
out += c;
|
|
67
|
+
i++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (c === "/" && n === "/") {
|
|
71
|
+
while (i < text.length && text[i] !== "\n") i++;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (c === "/" && n === "*") {
|
|
75
|
+
i += 2;
|
|
76
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i++;
|
|
77
|
+
i += 2;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
out += c;
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
out = out.replace(/,(\s*[}\]])/g, "$1");
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(out);
|
|
86
|
+
} catch {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export function loadJsonc(p) {
|
|
92
|
+
const t = readText(p);
|
|
93
|
+
return t ? parseJsonc(t) : undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ---------- paths ----------
|
|
97
|
+
|
|
98
|
+
export const toPosix = (p) => (sep === "/" ? p : p.split(sep).join("/"));
|
|
99
|
+
export const rel = (from, to) => toPosix(relative(from, to)) || ".";
|
|
100
|
+
|
|
101
|
+
export function isUnder(relPath, dir) {
|
|
102
|
+
if (!dir || dir === ".") return true;
|
|
103
|
+
return relPath === dir || relPath.startsWith(dir + "/");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function parentDirs(relPath) {
|
|
107
|
+
// "a/b/c.ts" -> ["a/b", "a", "."]
|
|
108
|
+
const out = [];
|
|
109
|
+
let d = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : ".";
|
|
110
|
+
while (true) {
|
|
111
|
+
out.push(d);
|
|
112
|
+
if (d === ".") break;
|
|
113
|
+
d = d.includes("/") ? d.slice(0, d.lastIndexOf("/")) : ".";
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export const dirOf = (relPath) => (relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : ".");
|
|
119
|
+
export const baseOf = (relPath) => relPath.slice(relPath.lastIndexOf("/") + 1);
|
|
120
|
+
export const joinRel = (a, b) => (a === "." || !a ? b : b === "." ? a : `${a}/${b}`);
|
|
121
|
+
|
|
122
|
+
export function normRel(p) {
|
|
123
|
+
const parts = [];
|
|
124
|
+
for (const seg of toPosix(p).split("/")) {
|
|
125
|
+
if (!seg || seg === ".") continue;
|
|
126
|
+
if (seg === "..") parts.pop();
|
|
127
|
+
else parts.push(seg);
|
|
128
|
+
}
|
|
129
|
+
return parts.join("/") || ".";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------- git ----------
|
|
133
|
+
|
|
134
|
+
export function git(cwd, args, opts = {}) {
|
|
135
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8", maxBuffer: 256 * 1024 * 1024, env: { ...process.env, GIT_OPTIONAL_LOCKS: "0" }, ...opts });
|
|
136
|
+
return { ok: r.status === 0, out: (r.stdout ?? "").toString(), err: (r.stderr ?? "").toString() };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The checkout this path is in, and the repo's main checkout (shared by all worktrees). */
|
|
140
|
+
export function repoRoots(cwd) {
|
|
141
|
+
const top = git(cwd, ["rev-parse", "--show-toplevel"]);
|
|
142
|
+
if (!top.ok) return undefined;
|
|
143
|
+
const root = top.out.trim();
|
|
144
|
+
const common = git(cwd, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
|
|
145
|
+
let main = root;
|
|
146
|
+
if (common.ok) {
|
|
147
|
+
const c = common.out.trim();
|
|
148
|
+
if (c.endsWith("/.git") || c.endsWith(`${sep}.git`)) main = dirname(c);
|
|
149
|
+
}
|
|
150
|
+
return { root, main };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------- hashing ----------
|
|
154
|
+
|
|
155
|
+
export const sha = (s, algo = "sha256") => createHash(algo).update(s).digest("hex");
|
|
156
|
+
|
|
157
|
+
/** Content hashes with an (mtime, size, inode) memo, so unchanged files are never re-read. */
|
|
158
|
+
export class FileHasher {
|
|
159
|
+
constructor() {
|
|
160
|
+
this.memo = new Map();
|
|
161
|
+
this.reads = 0;
|
|
162
|
+
}
|
|
163
|
+
hash(abs) {
|
|
164
|
+
let st;
|
|
165
|
+
try {
|
|
166
|
+
st = statSync(abs);
|
|
167
|
+
} catch {
|
|
168
|
+
this.memo.delete(abs);
|
|
169
|
+
return "-";
|
|
170
|
+
}
|
|
171
|
+
if (!st.isFile()) return "d";
|
|
172
|
+
const m = this.memo.get(abs);
|
|
173
|
+
if (m && m.mtimeMs === st.mtimeMs && m.size === st.size && m.ino === st.ino) return m.hash;
|
|
174
|
+
let hash;
|
|
175
|
+
try {
|
|
176
|
+
hash = createHash("sha1").update(readFileSync(abs)).digest("hex");
|
|
177
|
+
this.reads++;
|
|
178
|
+
} catch {
|
|
179
|
+
hash = "-";
|
|
180
|
+
}
|
|
181
|
+
this.memo.set(abs, { mtimeMs: st.mtimeMs, size: st.size, ino: st.ino, hash });
|
|
182
|
+
if (this.memo.size > 400000) this.memo.clear();
|
|
183
|
+
return hash;
|
|
184
|
+
}
|
|
185
|
+
/** Read a text file through the same memo (used for import scanning). */
|
|
186
|
+
text(abs) {
|
|
187
|
+
return readText(abs);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ---------- globs ----------
|
|
192
|
+
|
|
193
|
+
const globCache = new Map();
|
|
194
|
+
export function globToRegex(glob) {
|
|
195
|
+
let g = globCache.get(glob);
|
|
196
|
+
if (g) return g;
|
|
197
|
+
let re = "";
|
|
198
|
+
let i = 0;
|
|
199
|
+
while (i < glob.length) {
|
|
200
|
+
const c = glob[i];
|
|
201
|
+
if (c === "*") {
|
|
202
|
+
if (glob[i + 1] === "*") {
|
|
203
|
+
const slash = glob[i + 2] === "/";
|
|
204
|
+
re += slash ? "(?:.*/)?" : ".*";
|
|
205
|
+
i += slash ? 3 : 2;
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
re += "[^/]*";
|
|
209
|
+
} else if (c === "?") re += "[^/]";
|
|
210
|
+
else if (c === "{") {
|
|
211
|
+
const end = glob.indexOf("}", i);
|
|
212
|
+
if (end > i) {
|
|
213
|
+
re += `(?:${glob
|
|
214
|
+
.slice(i + 1, end)
|
|
215
|
+
.split(",")
|
|
216
|
+
.map((s) => s.replace(/[.+^$()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*"))
|
|
217
|
+
.join("|")})`;
|
|
218
|
+
i = end + 1;
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
re += "\\{";
|
|
222
|
+
} else if (".+^$()|[]\\".includes(c)) re += `\\${c}`;
|
|
223
|
+
else re += c;
|
|
224
|
+
i++;
|
|
225
|
+
}
|
|
226
|
+
g = new RegExp(`^${re}$`);
|
|
227
|
+
globCache.set(glob, g);
|
|
228
|
+
return g;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export const matchGlob = (glob, relPath) => globToRegex(glob).test(relPath);
|
|
232
|
+
export const matchAny = (globs, relPath) => (globs ?? []).some((g) => matchGlob(g, relPath));
|
|
233
|
+
|
|
234
|
+
// ---------- processes ----------
|
|
235
|
+
|
|
236
|
+
export function which(cmd, { skipDir, pathEnv = process.env.PATH ?? "" } = {}) {
|
|
237
|
+
if (isAbsolute(cmd)) return existsSync(cmd) ? cmd : undefined;
|
|
238
|
+
for (const d of pathEnv.split(delimiter)) {
|
|
239
|
+
if (!d || (skipDir && resolve(d) === resolve(skipDir))) continue;
|
|
240
|
+
const p = join(d, cmd);
|
|
241
|
+
try {
|
|
242
|
+
accessSync(p, constants.X_OK);
|
|
243
|
+
if (statSync(p).isFile()) return p;
|
|
244
|
+
} catch {}
|
|
245
|
+
}
|
|
246
|
+
return undefined;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** First executable named `name` in node_modules/.bin from dir up to stopAt (inclusive), then alt roots. */
|
|
250
|
+
export function findBin(name, absDir, stopAt, alsoTry = []) {
|
|
251
|
+
const tryDirs = [];
|
|
252
|
+
let d = absDir;
|
|
253
|
+
while (true) {
|
|
254
|
+
tryDirs.push(d);
|
|
255
|
+
if (d === stopAt || dirname(d) === d) break;
|
|
256
|
+
d = dirname(d);
|
|
257
|
+
}
|
|
258
|
+
for (const extra of alsoTry) if (extra) tryDirs.push(extra);
|
|
259
|
+
for (const dir of tryDirs) {
|
|
260
|
+
const p = join(dir, "node_modules", ".bin", name);
|
|
261
|
+
if (existsSync(p)) return p;
|
|
262
|
+
}
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export const NICE = which("nice");
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Run a process in its own process group. Resolves { code, stdout, stderr, killed, ms }.
|
|
270
|
+
* `signal` (AbortSignal) kills the whole group.
|
|
271
|
+
*/
|
|
272
|
+
export function runProc({ cmd, args = [], cwd, env, signal, timeoutMs, nice = 10, onData, maxBytes = 8 * 1024 * 1024 }) {
|
|
273
|
+
return new Promise((resolveP) => {
|
|
274
|
+
const started = Date.now();
|
|
275
|
+
const argv = nice && NICE ? [NICE, "-n", String(nice), cmd, ...args] : [cmd, ...args];
|
|
276
|
+
let child;
|
|
277
|
+
try {
|
|
278
|
+
child = spawn(argv[0], argv.slice(1), { cwd, env, detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
279
|
+
} catch (e) {
|
|
280
|
+
resolveP({ code: 127, stdout: "", stderr: String(e?.message ?? e), killed: false, ms: 0 });
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
let stdout = "";
|
|
284
|
+
let stderr = "";
|
|
285
|
+
let killed = false, killTimer;
|
|
286
|
+
const cap = (buf, add) => {
|
|
287
|
+
buf += add;
|
|
288
|
+
return buf.length > maxBytes ? buf.slice(buf.length - maxBytes) : buf;
|
|
289
|
+
};
|
|
290
|
+
child.stdout.on("data", (d) => {
|
|
291
|
+
const s = d.toString();
|
|
292
|
+
stdout = cap(stdout, s);
|
|
293
|
+
onData?.(s, "stdout");
|
|
294
|
+
});
|
|
295
|
+
child.stderr.on("data", (d) => {
|
|
296
|
+
const s = d.toString();
|
|
297
|
+
stderr = cap(stderr, s);
|
|
298
|
+
onData?.(s, "stderr");
|
|
299
|
+
});
|
|
300
|
+
const kill = () => {
|
|
301
|
+
if (killed) return;
|
|
302
|
+
killed = true;
|
|
303
|
+
try {
|
|
304
|
+
process.kill(-child.pid, "SIGTERM");
|
|
305
|
+
} catch {}
|
|
306
|
+
killTimer = setTimeout(() => {
|
|
307
|
+
try {
|
|
308
|
+
process.kill(-child.pid, "SIGKILL");
|
|
309
|
+
} catch {}
|
|
310
|
+
}, 3000).unref();
|
|
311
|
+
};
|
|
312
|
+
const onAbort = () => kill();
|
|
313
|
+
signal?.addEventListener("abort", onAbort);
|
|
314
|
+
if (signal?.aborted) kill();
|
|
315
|
+
const timer = timeoutMs ? setTimeout(kill, timeoutMs) : undefined;
|
|
316
|
+
child.on("error", (e) => {
|
|
317
|
+
stderr += String(e?.message ?? e);
|
|
318
|
+
});
|
|
319
|
+
child.on("close", (code) => {
|
|
320
|
+
if (timer) clearTimeout(timer);
|
|
321
|
+
clearTimeout(killTimer);
|
|
322
|
+
signal?.removeEventListener("abort", onAbort);
|
|
323
|
+
resolveP({ code: code ?? (killed ? 143 : 1), stdout, stderr, killed, ms: Date.now() - started });
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Environment for checks: no npm/pnpm script variables leaking from whoever started the broker. */
|
|
329
|
+
const POLLUTED = /^(npm_.*|PNPM_.*|pnpm_.*|INIT_CWD|NODE_ENV|NODE_OPTIONS|NEXT_.*|PORT|HOSTNAME|PI_WEB_.*|PI_CHECK_SHIM_DIR|VITEST.*|JEST_WORKER_ID)$/;
|
|
330
|
+
export function cleanEnv(extra = {}) {
|
|
331
|
+
const env = {};
|
|
332
|
+
for (const [k, v] of Object.entries(process.env)) if (!POLLUTED.test(k)) env[k] = v;
|
|
333
|
+
return { ...env, ...extra };
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** PATH as the user's login shell sets it (Homebrew, Go, pyenv…), merged into this process once. */
|
|
337
|
+
export function adoptLoginPath() {
|
|
338
|
+
const sh = process.env.SHELL || "/bin/sh";
|
|
339
|
+
try {
|
|
340
|
+
const r = spawnSync(sh, ["-lc", 'printf "%s" "$PATH"'], { encoding: "utf8", timeout: 8000 });
|
|
341
|
+
const login = (r.stdout ?? "").trim();
|
|
342
|
+
if (!login) return;
|
|
343
|
+
const cur = (process.env.PATH ?? "").split(delimiter).filter(Boolean);
|
|
344
|
+
const add = login.split(delimiter).filter((d) => d && !cur.includes(d));
|
|
345
|
+
if (add.length) process.env.PATH = [...cur, ...add].join(delimiter);
|
|
346
|
+
} catch {}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export function parseDuration(v, fallbackMs) {
|
|
350
|
+
if (v == null || v === "") return fallbackMs;
|
|
351
|
+
if (typeof v === "number") return v * 1000;
|
|
352
|
+
const m = String(v).match(/^(\d+(?:\.\d+)?)(ms|s|m|h)?$/);
|
|
353
|
+
if (!m) return fallbackMs;
|
|
354
|
+
const n = Number(m[1]);
|
|
355
|
+
return n * ({ ms: 1, s: 1000, m: 60000, h: 3600000 }[m[2] ?? "s"] ?? 1000);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export function fmtMs(ms) {
|
|
359
|
+
if (ms < 1000) return `${Math.round(ms)}ms`;
|
|
360
|
+
const s = ms / 1000;
|
|
361
|
+
if (s < 90) return `${s.toFixed(s < 10 ? 1 : 0)}s`;
|
|
362
|
+
const m = Math.floor(s / 60);
|
|
363
|
+
return `${m}m${String(Math.round(s % 60)).padStart(2, "0")}s`;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export function tailLines(text, n) {
|
|
367
|
+
const lines = String(text ?? "").replace(/\s+$/, "").split("\n");
|
|
368
|
+
return lines.length <= n ? lines.join("\n") : `… (${lines.length - n} earlier lines)\n${lines.slice(-n).join("\n")}`;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export function headLines(text, n) {
|
|
372
|
+
const lines = String(text ?? "").replace(/\s+$/, "").split("\n");
|
|
373
|
+
return lines.length <= n ? lines.join("\n") : `${lines.slice(0, n).join("\n")}\n… (${lines.length - n} more lines)`;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export const shq = (s) => `'${String(s).replace(/'/g, `'\\''`)}'`;
|
|
377
|
+
|
|
378
|
+
export function logTo(file) {
|
|
379
|
+
return (...parts) => {
|
|
380
|
+
const line = `${new Date().toISOString()} ${parts.map((p) => (typeof p === "string" ? p : JSON.stringify(p))).join(" ")}\n`;
|
|
381
|
+
try {
|
|
382
|
+
appendFileSync(file, line);
|
|
383
|
+
} catch {}
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
export function pidAlive(pid) {
|
|
388
|
+
if (!pid) return false;
|
|
389
|
+
try {
|
|
390
|
+
process.kill(pid, 0);
|
|
391
|
+
return true;
|
|
392
|
+
} catch (e) {
|
|
393
|
+
return e?.code === "EPERM";
|
|
394
|
+
}
|
|
395
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,49 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@stacknyu/pi-check",
|
|
3
|
-
"version": "
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "One shared, cached, speculative check runner per repo for every Pi session and worktree: Go, TypeScript/JavaScript and Python tests, type checks and linters run once per file content, pre-run while you edit, and throttled so parallel agents stop fighting for the CPU.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=22.19.0"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public",
|
|
12
|
+
"registry": "https://registry.npmjs.org/"
|
|
13
|
+
},
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "git+https://github.com/stacknyu/stack-pi-plugins.git",
|
|
17
|
+
"directory": "packages/pi-check"
|
|
18
|
+
},
|
|
19
|
+
"homepage": "https://github.com/stacknyu/stack-pi-plugins/tree/main/packages/pi-check#readme",
|
|
20
|
+
"bugs": {
|
|
21
|
+
"url": "https://github.com/stacknyu/stack-pi-plugins/issues"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"extension.ts",
|
|
25
|
+
"cli.mjs",
|
|
26
|
+
"lib",
|
|
27
|
+
"skills",
|
|
28
|
+
"CHANGELOG.md"
|
|
29
|
+
],
|
|
30
|
+
"keywords": [
|
|
31
|
+
"pi-package"
|
|
32
|
+
],
|
|
33
|
+
"bin": {
|
|
34
|
+
"pi-check": "./cli.mjs"
|
|
35
|
+
},
|
|
36
|
+
"pi": {
|
|
37
|
+
"extensions": [
|
|
38
|
+
"./extension.ts"
|
|
39
|
+
],
|
|
40
|
+
"skills": [
|
|
41
|
+
"./skills"
|
|
42
|
+
]
|
|
43
|
+
},
|
|
44
|
+
"peerDependencies": {
|
|
45
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
46
|
+
"@earendil-works/pi-ai": "*",
|
|
47
|
+
"typebox": "*"
|
|
48
|
+
}
|
|
6
49
|
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-check
|
|
3
|
+
description: How the shared check broker (pi-check) works — the check tool, reading its report, pre-existing failures, configuring pi-check.json, and fixing slow or wrong checks. Use when running tests/typecheck/lint, when a check result is confusing, or when the user asks about pi-check, /check, check speed or CPU load.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# pi-check
|
|
7
|
+
|
|
8
|
+
One broker per repo runs every test, type check and lint for all sessions and worktrees. Results are keyed by file content, so identical work runs once and is shared.
|
|
9
|
+
|
|
10
|
+
## Running checks
|
|
11
|
+
|
|
12
|
+
- Use the `check` tool, not `go test` / `pytest` / `vitest` / `tsc` / `eslint` / `ruff` in bash.
|
|
13
|
+
- Default scope: what your changes affect, compared with the base branch.
|
|
14
|
+
- `scope: "all"`: everything.
|
|
15
|
+
- `files: [...]`: only checks that depend on those files.
|
|
16
|
+
- `kinds: ["test"]` / `projects: ["apps/x"]` narrow it further.
|
|
17
|
+
- Run it after a meaningful batch of edits. The broker already runs affected checks in the background while you edit, so results are often instant.
|
|
18
|
+
- Raw commands in bash still work: they are routed through the broker. Commands that change files (`--fix`, `-u`, `install`) run normally.
|
|
19
|
+
|
|
20
|
+
## Reading the report
|
|
21
|
+
|
|
22
|
+
```
|
|
23
|
+
pi-check · w2 · scope: changed vs main · 41 units: 8 ran, 28 cached, 5 shared · 3m12s
|
|
24
|
+
✗ go apps/api · test: 36/37 pass — 2 pre-existing failures (also fail on base)
|
|
25
|
+
✗ example.com/api/billing TestInvoiceTotals
|
|
26
|
+
invoice_test.go:42: got 10, want 12
|
|
27
|
+
RESULT: FAIL — 1 new failure
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
- **cached / shared**: the result came from an identical earlier run, here or in another checkout. It is as valid as a fresh run.
|
|
31
|
+
- **new failure**: not failing on the base branch. Yours to fix.
|
|
32
|
+
- **pre-existing**: also fails on the base branch. Don't fix it unless that is your task. It doesn't fail the check.
|
|
33
|
+
- **"files changed during the run"**: the result may not match the current files. Run `check` again.
|
|
34
|
+
- **could not run** (tool missing, timeout): an environment problem, not a code verdict. Mention it, don't loop on it.
|
|
35
|
+
|
|
36
|
+
## For the user
|
|
37
|
+
|
|
38
|
+
- `/check`: what runs, what waits, time saved.
|
|
39
|
+
- `/check baseline`: records pre-existing failures from the main checkout.
|
|
40
|
+
- `/check known`: lists them.
|
|
41
|
+
- `/check run all`: runs everything.
|
|
42
|
+
- `/check init`: writes `pi-check.json` to commit and tune.
|
|
43
|
+
- In `pi-check.json`:
|
|
44
|
+
- `projects.<dir>.<kind>`: `false` to disable, or `{ timeout, env, args, inputs, exclude, parallel }`.
|
|
45
|
+
- `maxJobs`: maximum concurrent jobs (0 = CPU budget).
|
|
46
|
+
- `cpuBudget`: total cooperative allocation (0 = available cores), independent of agent count.
|
|
47
|
+
- `threads`: per-job ceiling (0 = divide spare allocation at dispatch).
|
|
48
|
+
- `fastLane`: reserves one total job slot/CPU for short work when capacity permits. Three slots allow two heavy jobs plus one fast job; two slots allow one of each. It never preempts running requested work.
|
|
49
|
+
- `projects.<dir>.<kind>.lane` / `custom[].lane`: `fast` or `heavy`; put slow integration suites/large typechecks in heavy. Native lint/vet/typechecks/TS tests and recognizable raw type/lint/Node-test commands default fast; builds/Go tests/unknown commands default heavy.
|
|
50
|
+
- `warmBase`: full-suite cache warming, off by default; normal speculation is affected-only.
|
|
51
|
+
- `speculate`: ahead-of-time runs.
|
|
52
|
+
- `intercept`: `route` | `block` | `off`.
|
|
53
|
+
- `ignore`: globs that are never inputs.
|
|
54
|
+
- `custom`: extra checks.
|
|
55
|
+
- After editing `pi-check.json`: `/check reload`. It waits for requested work to finish; active checks are not restarted. Source-code updates need a fresh broker process after a natural idle exit; config reload alone cannot hot-swap JavaScript.
|
|
56
|
+
- Broker errors are not permission to launch competing direct checks. Inspect status first. An upgrade must wait for active checks to finish.
|
|
57
|
+
- Whole-command jobs hold their own allocation; nested shims execute inside it. Prefer native units for fine-grained cache sharing.
|
|
58
|
+
- Progress includes the latest Go test event and elapsed time. Reused unit-runtime is not elapsed time saved.
|
|
59
|
+
|
|
60
|
+
## When something is off
|
|
61
|
+
|
|
62
|
+
- **A change didn't rerun a test that reads a file at run time:** add that file's glob to the project's `inputs`.
|
|
63
|
+
- **A unit keeps rerunning by itself:** its tests write files into the repo. Git-ignore them, or add them to `ignore`.
|
|
64
|
+
- **Go tests time out:** set `projects.<module dir>.test.timeout` (e.g. `"30m"`). Timeouts are never cached.
|
|
65
|
+
- **The machine is still busy:** lower `maxJobs`, set `speculate: false`, or raise `nice`.
|
|
66
|
+
- **Wrong "pre-existing" list:**
|
|
67
|
+
- `/check known clear`, then `/check baseline`.
|
|
68
|
+
- "Pre-existing" means failing on the base branch tip, in the main checkout (or an identical worktree).
|
|
69
|
+
- **The broker misbehaves:** `/check log`, then `/check restart`. The cache is in `.pi-check/cache/`. `pi-check forget` clears it.
|