@stablekernel/pi-background-run 0.5.0 → 0.6.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.
@@ -0,0 +1,316 @@
1
+ /**
2
+ * Shipped digest presets for pi-bgrun's opt-in digest scorecards.
3
+ *
4
+ * Pure data: each preset is a POSIX-sh command that receives the job's log
5
+ * path as `$1` and prints a short pass/fail scorecard. Every command ends in
6
+ * `head -N` so output is bounded no matter what the log contains. Presets are
7
+ * consumed via `resolveDigest()`; they only ever run for trust-gated projects
8
+ * that explicitly opted in via the `digest` config section (see
9
+ * extension/index.ts). Nothing here runs unless configured — no built-in
10
+ * pattern guessing.
11
+ */
12
+
13
+ export interface DigestPreset {
14
+ id: string;
15
+ description: string;
16
+ command: string;
17
+ /**
18
+ * Advisory only — the conventional job `type` this preset is meant for,
19
+ * used by docs and the digest-config skill when scaffolding a config
20
+ * (e.g. `{ "type": "test", "preset": "go-test" }`). It carries NO runtime
21
+ * semantics: a preset entry never selects itself by type; the project's
22
+ * config still declares the `type` on each entry.
23
+ */
24
+ suggestedType: string;
25
+ }
26
+
27
+ export const DIGEST_PRESETS: DigestPreset[] = [
28
+ {
29
+ id: "go-test",
30
+ description: "Go test output: package ok/FAIL counts + failing test names",
31
+ suggestedType: "test",
32
+ // Count `^ok `/`^FAIL` package lines, then list `--- FAIL: TestX` names
33
+ // (duration suffix stripped). Ends in head.
34
+ command:
35
+ "printf 'pass: %s fail: %s\\n' \"$(grep -c '^ok ' \"$1\")\" \"$(awk '/^FAIL\\t/ {n++} END {print n + 0}' \"$1\")\"; grep '^--- FAIL: ' \"$1\" | sed 's/^--- FAIL: //; s/ (.*//' | sort -u | head -10",
36
+ },
37
+ {
38
+ id: "jest",
39
+ description: "Jest output: Tests/Test Suites summary + failed test names",
40
+ suggestedType: "test",
41
+ // Jest prints `Tests:`/`Test Suites:` summary lines (with or without
42
+ // color) and marks individual failures with `●` (default reporter) or
43
+ // `✕`/`×` (verbose). Strip the leading bullet so names stay readable.
44
+ command:
45
+ 'grep -E \'^(Test Suites|Tests):\' "$1"; grep -E \'●|✕|×\' "$1" | awk \'{sub(/^ *[^A-Za-z0-9]*/, ""); if ($0 != "") print}\' | sort -u | head -10',
46
+ },
47
+ {
48
+ id: "pytest",
49
+ description:
50
+ "pytest output: final passed/failed/error summary line + FAILED test ids",
51
+ suggestedType: "test",
52
+ // The short summary line looks like `===== 2 failed, 3 passed in 0.5s ===`;
53
+ // with -rA/-rf each failure also gets a `FAILED tests/test_x.py::test_y` line.
54
+ command:
55
+ "grep -E '^=+ [0-9]+ (passed|failed|error)' \"$1\"; grep '^FAILED ' \"$1\" | awk '{print $2}' | sort -u | head -10",
56
+ },
57
+ {
58
+ id: "junit-xml",
59
+ description: "JUnit XML: <failure>/<error> counts + failing testcase names",
60
+ suggestedType: "test",
61
+ // Count failure/error elements (attributes like failures="0" don't match —
62
+ // they lack the `<`), then pull the enclosing testcase's name attribute.
63
+ // Real pytest --junitxml emits the whole document on ONE line, so the scan
64
+ // is record-based (`RS='<testcase'`, not line-based); it trims each record at
65
+ // `</testcase>` and matches `[[:space:]]name="` so it never picks up
66
+ // `classname="..."` nor a `<failure` from text after the element.
67
+ command:
68
+ 'printf \'failures: %s errors: %s\\n\' "$(grep -o \'<failure\' "$1" | wc -l | tr -d \' \')" "$(grep -o \'<error\' "$1" | wc -l | tr -d \' \')"; awk -v RS=\'<testcase\' \'NR>1 { r=$0; e=index(r,"</testcase>"); if (e) r=substr(r,1,e-1); if (match(r,/[[:space:]]name="[^"]*"/)) { n=substr(r,RSTART+7,RLENGTH-8); if (r ~ /<failure|<error/) print n } }\' "$1" | sort -u | head -10',
69
+ },
70
+ ];
71
+
72
+ export const DIGEST_PRESET_IDS = DIGEST_PRESETS.map((p) => p.id);
73
+
74
+ /**
75
+ * Matchers selecting which jobs a digest entry applies to. Both are glob
76
+ * patterns tested against the bgrun job's `name` (optional) and command line
77
+ * respectively. `*` matches any run of characters (including none) and `?`
78
+ * matches exactly one UTF-16 code unit; everything else is literal, and `\`
79
+ * escapes the next character so `\*` / `\?` / `\\` match literally. Matching
80
+ * is case-insensitive and **whole-string** (write `*text*` for a substring).
81
+ * An absent, empty, or blank `match` matches every job.
82
+ */
83
+ export interface DigestMatch {
84
+ name?: string;
85
+ command?: string;
86
+ }
87
+
88
+ /**
89
+ * One scorecard entry in the `digest` config: an optional job `type`, an
90
+ * optional `match`, an optional wake label, and either a shipped preset id or
91
+ * a custom sh command. Config normalizes to an ordered list of these; the
92
+ * first entry that matches a job wins (put the default entry last).
93
+ *
94
+ * `type` and `match` compose (AND): when both are present the entry matches
95
+ * only a job with that exact type that ALSO satisfies the glob `match`. Use
96
+ * `type` for a first-class job type declared at spawn time; use `match` alone
97
+ * as the fallback selector for jobs without a type. An entry with neither
98
+ * selector matches every job.
99
+ */
100
+ export interface DigestEntry {
101
+ type?: string;
102
+ match?: DigestMatch;
103
+ label?: string;
104
+ preset?: string;
105
+ command?: string;
106
+ }
107
+
108
+ /**
109
+ * The job a digest entry is selected against at wake time. `type` is the
110
+ * job's agent-declared type (e.g. "test", "build"), matched exactly
111
+ * (case-insensitively) against type-gated entries before the glob fallback.
112
+ */
113
+ export interface DigestJobTarget {
114
+ name?: string;
115
+ type?: string;
116
+ command: string;
117
+ }
118
+
119
+ /** A digest entry resolved against a concrete job. */
120
+ export interface SelectedDigest {
121
+ command: string;
122
+ label: string;
123
+ }
124
+
125
+ /**
126
+ * Resolve a normalized digest entry into the concrete sh command to run (the
127
+ * job's log path arrives as `$1`). When both `preset` and `command` are
128
+ * configured, the preset wins — a curated, shipped preset is preferred over a
129
+ * hand-rolled command pointing at the same format. Returns undefined when
130
+ * nothing usable is configured.
131
+ */
132
+ export function resolveDigest(
133
+ digest: { preset?: string; command?: string } | undefined,
134
+ ): string | undefined {
135
+ if (!digest) return undefined;
136
+ if (digest.preset) {
137
+ const preset = DIGEST_PRESETS.find((p) => p.id === digest.preset);
138
+ if (preset) return preset.command;
139
+ }
140
+ if (digest.command) return digest.command;
141
+ return undefined;
142
+ }
143
+
144
+ /**
145
+ * Case-insensitive, whole-string glob match used by `match.name` /
146
+ * `match.command`. `*` matches any run of characters — including none, and
147
+ * including newlines so a multi-line command still matches — and `?` matches
148
+ * exactly one character. Every other character is literal. The pattern is
149
+ * escaped into a regex here, so no user text can become a regex metacharacter
150
+ * or quantifier: there is no backtracking hazard (ReDoS) and no anchoring
151
+ * ambiguity — the match is always against the whole string.
152
+ */
153
+ function globMatches(pattern: string, text: string): boolean {
154
+ let re = "^";
155
+ for (let i = 0; i < pattern.length; i++) {
156
+ const ch = pattern[i];
157
+ // `\` escapes the next character, so `\*` / `\?` / `\\` match literally.
158
+ if (ch === "\\" && i + 1 < pattern.length) {
159
+ re += escapeRegexChar(pattern[++i]);
160
+ } else if (ch === "*") {
161
+ re += "[\\s\\S]*";
162
+ } else if (ch === "?") {
163
+ re += "[\\s\\S]";
164
+ } else {
165
+ re += escapeRegexChar(ch);
166
+ }
167
+ }
168
+ re += "$";
169
+ return new RegExp(re, "i").test(text);
170
+ }
171
+
172
+ /** Escape one literal character for a RegExp, so it can never be a metacharacter. */
173
+ function escapeRegexChar(ch: string): string {
174
+ return ch.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
175
+ }
176
+
177
+ /**
178
+ * Does a digest entry's glob `match` apply to this job? No `match` (or an
179
+ * empty one) matches every job. A present `name`/`command` pattern must
180
+ * match; `name` against a job with no name never matches. When both fields are
181
+ * present both must match (AND). Matching is **whole-string** and
182
+ * **case-insensitive** — so `"*unit*"` matches `"unit-tests-run3"`, while a
183
+ * bare `"unit-tests"` matches only exactly that. This helper handles the glob
184
+ * matcher only; `selectDigestEntry` composes it with the entry's `type` gate
185
+ * (both must match).
186
+ */
187
+ export function entryMatchesJob(
188
+ entry: DigestEntry,
189
+ target: DigestJobTarget,
190
+ ): boolean {
191
+ const match = entry.match;
192
+ if (!match) return true;
193
+ if (match.name === undefined && match.command === undefined) return true;
194
+ if (match.name !== undefined) {
195
+ if (target.name === undefined) return false;
196
+ if (!globMatches(match.name, target.name)) return false;
197
+ }
198
+ if (match.command !== undefined) {
199
+ if (!globMatches(match.command, target.command)) return false;
200
+ }
201
+ return true;
202
+ }
203
+
204
+ /** Terminal label when an entry sets neither `label` nor a `match.name`. */
205
+ function defaultDigestLabel(entry: DigestEntry): string {
206
+ return entry.preset ?? "command";
207
+ }
208
+
209
+ /**
210
+ * Wake label derived from a glob pattern: drop the (unescaped) wildcards,
211
+ * unwrap escapes, so `*cargo*` → `cargo` and `e2e-\*` → `e2e-*`.
212
+ */
213
+ function labelFromMatchName(pattern: string): string {
214
+ let out = "";
215
+ for (let i = 0; i < pattern.length; i++) {
216
+ const ch = pattern[i];
217
+ if (ch === "\\" && i + 1 < pattern.length) out += pattern[++i];
218
+ else if (ch === "*" || ch === "?") continue;
219
+ else out += ch;
220
+ }
221
+ return out.trim();
222
+ }
223
+
224
+ /**
225
+ * One-line diagnostic for the silent no-digest case: a digest IS configured
226
+ * but no entry selected for this job. The usual causes are a `type` the agent
227
+ * never passes (or spells differently) and a `match` glob that never fires.
228
+ * Pure — the wake path decides whether to log it.
229
+ */
230
+ export function digestNoMatchWarning(
231
+ target: DigestJobTarget,
232
+ entries: DigestEntry[],
233
+ ): string {
234
+ const declaredTypes = [
235
+ ...new Set(
236
+ entries.map((e) => e.type).filter((t): t is string => typeof t === "string"),
237
+ ),
238
+ ];
239
+ const job =
240
+ target.type === undefined
241
+ ? target.name === undefined
242
+ ? "a job with no type or name"
243
+ : `job name "${target.name}"`
244
+ : `job type "${target.type}"`;
245
+ const types = declaredTypes.length
246
+ ? ` — configured types: ${declaredTypes.join(", ")}`
247
+ : "";
248
+ return `[pi-bgrun] digest configured but selected no entry for ${job}${types}`;
249
+ }
250
+
251
+ /**
252
+ * Select the digest entry for a job and resolve it to a concrete command +
253
+ * wake label. Selection order:
254
+ *
255
+ * 1. Type entries first: an entry declaring `type` is eligible only for a
256
+ * job declaring that same type (exact, case-insensitive) AND satisfying
257
+ * the entry's `match` when it has one — `type` and `match` compose (AND).
258
+ * Checked in config order, ahead of every match entry regardless of where
259
+ * it sits in the list. First match wins.
260
+ * 2. Fallback: ordered scan over entries WITHOUT a `type` — `match.name` /
261
+ * `match.command` globs (case-insensitive, whole-string) and no-`match`
262
+ * defaults. First match wins. Jobs with no type therefore behave exactly
263
+ * as before.
264
+ * 3. Nothing matched → undefined (no digest).
265
+ *
266
+ * Label precedence: entry `label` → (type entry) the type string → (match
267
+ * entry) the matched `match.name` → the entry's preset id (or "command"), so a
268
+ * bare preset entry labels the wake `digest (go-test):` instead of the old
269
+ * opaque "project-config". Returns undefined when the list is empty.
270
+ */
271
+ export function selectDigestEntry(
272
+ entries: DigestEntry[] | undefined,
273
+ target: DigestJobTarget,
274
+ ): SelectedDigest | undefined {
275
+ if (!entries) return undefined;
276
+
277
+ // First entry (in the given order) whose `match` passes and which resolves to
278
+ // a command. Shared by the type-first pass and the fallback pass so the
279
+ // matching/resolution rules can never drift between them.
280
+ const pick = (
281
+ candidates: DigestEntry[],
282
+ ): { entry: DigestEntry; command: string } | undefined => {
283
+ for (const entry of candidates) {
284
+ if (!entryMatchesJob(entry, target)) continue;
285
+ const command = resolveDigest(entry);
286
+ if (command) return { entry, command };
287
+ }
288
+ return undefined;
289
+ };
290
+
291
+ // 1. Type-first selection. Only entries declaring a type are eligible here,
292
+ // and only when the job declared one. A `match` on the entry, if any, must
293
+ // also pass. Config order decides ties.
294
+ if (target.type !== undefined) {
295
+ const want = target.type.toLowerCase();
296
+ const hit = pick(entries.filter((e) => e.type?.toLowerCase() === want));
297
+ // A type-matching entry always carries a non-empty type, so `entry.type` is
298
+ // the label fallback — no need for the preset-id default here.
299
+ if (hit?.entry.type) {
300
+ return { command: hit.command, label: hit.entry.label || hit.entry.type };
301
+ }
302
+ }
303
+
304
+ // 2. Glob/default fallback over entries without a type.
305
+ const hit = pick(entries.filter((e) => !e.type));
306
+ if (hit) {
307
+ const entry = hit.entry;
308
+ const matchLabel =
309
+ entry.match?.name === undefined ? "" : labelFromMatchName(entry.match.name);
310
+ return {
311
+ command: hit.command,
312
+ label: entry.label || matchLabel || defaultDigestLabel(entry),
313
+ };
314
+ }
315
+ return undefined;
316
+ }