@mycadet/cli 0.13.0-dev.202610200020.9f20440 → 0.13.0-dev.202610201855.522ff0d

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.
Files changed (80) hide show
  1. package/node_modules/@mycadet/assistant-api/constants/personality-sliders.test.ts +0 -8
  2. package/node_modules/@mycadet/assistant-api/constants/personality-sliders.ts +5 -41
  3. package/node_modules/@mycadet/assistant-api/events/message-complete.ts +0 -5
  4. package/node_modules/@mycadet/assistant-api/index.ts +0 -2
  5. package/node_modules/@mycadet/assistant-api/responses/conversation-message.ts +0 -10
  6. package/node_modules/@mycadet/environments/src/seeds.ts +6 -2
  7. package/node_modules/@mycadet/local-mode/node_modules/@mycadet/environments/src/seeds.ts +6 -2
  8. package/node_modules/@mycadet/local-mode/src/config.ts +0 -2
  9. package/node_modules/@mycadet/local-mode/src/index.ts +1 -1
  10. package/node_modules/@mycadet/local-mode/src/lockfile-contract.test.ts +1 -22
  11. package/node_modules/@mycadet/local-mode/src/lockfile-contract.ts +26 -7
  12. package/node_modules/@mycadet/local-mode/src/util.ts +1 -20
  13. package/node_modules/@mycadet/shared/github-repository.ts +5 -0
  14. package/node_modules/@mycadet/shared/package.json +10 -0
  15. package/node_modules/@mycadet/shared/scripts/run-isolated-tests.ts +700 -0
  16. package/package.json +3 -2
  17. package/src/__tests__/app-command.test.ts +164 -0
  18. package/src/__tests__/client-web-config.test.ts +0 -1
  19. package/src/__tests__/cloud-releases.test.ts +1 -1
  20. package/src/__tests__/local-version.test.ts +16 -0
  21. package/src/__tests__/nginx-ingress.test.ts +248 -630
  22. package/src/__tests__/ps-cloud-status.test.ts +85 -11
  23. package/src/__tests__/root-process-lockfile.test.ts +29 -2
  24. package/src/__tests__/tunnel-holder-heal.test.ts +119 -0
  25. package/src/__tests__/tunnel-stop.test.ts +6 -13
  26. package/src/__tests__/tunnel.test.ts +50 -193
  27. package/src/__tests__/wake.test.ts +6 -38
  28. package/src/__tests__/web-dist.test.ts +267 -89
  29. package/src/commands/app.ts +113 -0
  30. package/src/commands/backup.ts +5 -4
  31. package/src/commands/clean.ts +2 -1
  32. package/src/commands/client.ts +5 -68
  33. package/src/commands/completions.ts +12 -1
  34. package/src/commands/connect.ts +2 -1
  35. package/src/commands/debug.ts +2 -1
  36. package/src/commands/exec.ts +7 -6
  37. package/src/commands/gateway/token.ts +2 -1
  38. package/src/commands/gateway.ts +2 -1
  39. package/src/commands/hatch.ts +74 -80
  40. package/src/commands/login.ts +7 -6
  41. package/src/commands/logs.ts +2 -1
  42. package/src/commands/ps.ts +136 -6
  43. package/src/commands/restart.ts +2 -1
  44. package/src/commands/restore.ts +10 -9
  45. package/src/commands/retire.ts +4 -3
  46. package/src/commands/setup.ts +5 -4
  47. package/src/commands/sleep.ts +3 -2
  48. package/src/commands/teleport.ts +2 -1
  49. package/src/commands/terminal.ts +7 -6
  50. package/src/commands/tunnel.ts +196 -242
  51. package/src/commands/uninstall.ts +2 -1
  52. package/src/commands/upgrade.ts +2 -1
  53. package/src/commands/use.ts +2 -1
  54. package/src/commands/wake.ts +3 -2
  55. package/src/index.ts +5 -4
  56. package/src/lib/__tests__/web-dist.test.ts +1 -27
  57. package/src/lib/assistant-config.ts +101 -37
  58. package/src/lib/cli-name.ts +14 -0
  59. package/src/lib/cloud-client.ts +2 -1
  60. package/src/lib/cloud-login-poll.ts +2 -1
  61. package/src/lib/cloud-releases.ts +1 -1
  62. package/src/lib/docker.ts +32 -21
  63. package/src/lib/hatch-local.ts +1 -3
  64. package/src/lib/hatch-next-steps.ts +1 -9
  65. package/src/lib/ingress-config.ts +0 -31
  66. package/src/lib/local-version.ts +25 -4
  67. package/src/lib/local.ts +34 -9
  68. package/src/lib/nginx-ingress.ts +143 -244
  69. package/src/lib/provider-secrets.ts +0 -5
  70. package/src/lib/statefulset.ts +0 -1
  71. package/src/lib/tunnel.ts +244 -43
  72. package/src/lib/tunnels/ngrok.ts +57 -9
  73. package/src/lib/tunnels/tailscale.ts +2 -1
  74. package/src/lib/web-dist.ts +134 -56
  75. package/src/__tests__/flags.test.ts +0 -318
  76. package/src/__tests__/shell-mode.test.ts +0 -83
  77. package/src/commands/flags.ts +0 -255
  78. package/src/lib/flag-args.test.ts +0 -84
  79. package/src/lib/flag-args.ts +0 -74
  80. package/src/lib/github-repository.ts +0 -2
@@ -0,0 +1,700 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ mkdtempSync,
5
+ readdirSync,
6
+ readFileSync,
7
+ rmSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { availableParallelism, tmpdir } from "node:os";
11
+ import { basename, join, relative } from "node:path";
12
+
13
+ import { Glob } from "bun";
14
+
15
+ export interface IsolatedTestOptions {
16
+ /** Test files outside the collected globs that should always run. */
17
+ extraFiles?: string[];
18
+ /**
19
+ * Drops a collected file from the run (assistant uses this for benchmark,
20
+ * known-broken, and experimental-only files).
21
+ */
22
+ filter?: (file: string) => boolean;
23
+ /** Extra arguments passed through to every `bun test` invocation. */
24
+ bunTestArgs?: string[];
25
+ /** Runs once before collection (assistant: bundled catalog sync). */
26
+ setup?: () => void;
27
+ /**
28
+ * Builds a test fixture once up front in a subprocess (assistant: the
29
+ * pre-migrated DB). The fixture root is exported as `envVar` and the build
30
+ * subprocess receives the fixture's "migrated" directory as its argument.
31
+ */
32
+ fixture?: { script: string; envVar: string };
33
+ }
34
+
35
+ // Run only test files changed vs main; fails open when the diff cannot be
36
+ // computed — a stale or absent base must never green-light a PR.
37
+ const TEST_DIFF_ONLY = process.env.TEST_DIFF_ONLY === "true";
38
+ const TEST_DIFF_BASE = "origin/main";
39
+ // One pattern set for every consumer: globs that match nothing in a given
40
+ // package are simply empty, so the union stays harmless everywhere.
41
+ const PATTERNS = [
42
+ "src/**/*.test.{ts,tsx}",
43
+ "scripts/**/*.test.{ts,js}",
44
+ ".storybook/**/*.test.{ts,tsx}",
45
+ "eslint-rules/**/*.test.mjs",
46
+ ];
47
+ // Shallow depth for fetching refs the diff needs. Pull-request checkouts run
48
+ // at a limited depth, so `origin/main` is often absent; this is deep enough
49
+ // for any realistic merge-base while staying ~10x cheaper than a full clone.
50
+ const DIFF_FETCH_DEPTH = "200";
51
+ // Wall-clock kill per test file: bun processes that pass but don't exit (open
52
+ // handles) would otherwise wedge a worker until the job timeout. SIGTERM
53
+ // first, then SIGKILL after a grace period, mirroring `timeout -k 10`.
54
+ const PER_TEST_TIMEOUT_MS =
55
+ (Number.parseInt(process.env.PER_TEST_TIMEOUT ?? "", 10) || 120) * 1000;
56
+ const KILL_GRACE_MS = 10_000;
57
+
58
+ export async function runIsolatedTests({
59
+ extraFiles = [],
60
+ filter,
61
+ bunTestArgs = [],
62
+ setup,
63
+ fixture,
64
+ }: IsolatedTestOptions = {}): Promise<void> {
65
+ const cwd = process.cwd();
66
+ setup?.();
67
+
68
+ // Timing-sensitive suites (real timers, `waitFor` deadlines) fail once the
69
+ // cores are saturated, so the default leaves half of them free.
70
+ const defaultConcurrency = Math.min(
71
+ 8,
72
+ Math.max(2, Math.floor(availableParallelism() / 2)),
73
+ );
74
+ const concurrency = Math.max(
75
+ 1,
76
+ Number.parseInt(
77
+ process.env.TEST_CONCURRENCY ?? process.env.TEST_WORKERS ?? "",
78
+ 10,
79
+ ) || defaultConcurrency,
80
+ );
81
+ const shard = parseShard(process.env.TEST_SHARD);
82
+ // Bun's default per-test timeout is 5s. Under saturated CI cores, RTL
83
+ // `waitFor` loops and slow integration tests can stretch past that even
84
+ // though they settle locally. TEST_TIMEOUT_MS / BUN_TEST_TIMEOUT lift the
85
+ // ceiling; 30s still fails a genuine hang, just after a slower one.
86
+ const timeoutMs =
87
+ Number.parseInt(process.env.TEST_TIMEOUT_MS ?? "", 10) ||
88
+ Number.parseInt(process.env.BUN_TEST_TIMEOUT ?? "", 10) ||
89
+ 30000;
90
+
91
+ const allFiles = [
92
+ // `dot` is off by default, and the walker skips dot-directories even
93
+ // when a pattern spells one out, so suites under `.storybook/` need
94
+ // it on to be found at all.
95
+ // https://bun.sh/docs/api/glob#scan
96
+ ...PATTERNS.flatMap((pattern) => [
97
+ ...new Glob(pattern).scanSync({ cwd, dot: true }),
98
+ ]),
99
+ ...extraFiles,
100
+ ]
101
+ .sort()
102
+ .filter((file) => filter?.(file) ?? true);
103
+ if (allFiles.length === 0) {
104
+ console.error("No test files found");
105
+ process.exit(1);
106
+ }
107
+
108
+ const diffed = TEST_DIFF_ONLY ? diffTestFiles(allFiles, cwd) : null;
109
+ const scoped = diffed ?? allFiles;
110
+ if (diffed && scoped.length === 0) {
111
+ console.log(
112
+ `No test files changed vs ${TEST_DIFF_BASE} — nothing to run.`,
113
+ );
114
+ return;
115
+ }
116
+ // Longest-first ordering (when a durations file exists) before sharding, so
117
+ // round-robin shards split the slow files evenly.
118
+ const sorted = sortLongestFirst(scoped);
119
+ const files = shard
120
+ ? sorted.filter((_, index) => index % shard.total === shard.index - 1)
121
+ : sorted;
122
+ if (files.length === 0) {
123
+ console.log(`Nothing to run in shard ${shard?.index}/${shard?.total}`);
124
+ return;
125
+ }
126
+
127
+ // Build the fixture once, up front, in a dedicated subprocess. Each test
128
+ // process inherits the fixtures root through the exported env var.
129
+ const fixturesRoot = mkdtempSync(join(tmpdir(), "cadet-test-fixtures-"));
130
+ if (fixture) {
131
+ const fixtureDir = join(fixturesRoot, "migrated");
132
+ const proc = Bun.spawn(["bun", "run", fixture.script, fixtureDir], {
133
+ cwd,
134
+ stdout: "inherit",
135
+ stderr: "inherit",
136
+ });
137
+ const code = await proc.exited;
138
+ if (code !== 0) {
139
+ console.error(`Failed to build test fixture (exit ${code})`);
140
+ process.exit(1);
141
+ }
142
+ process.env[fixture.envVar] = fixturesRoot;
143
+ }
144
+
145
+ console.log(
146
+ `Running ${files.length} test file(s) (${concurrency} workers)${
147
+ shard ? `, shard ${shard.index}/${shard.total}` : ""
148
+ }`,
149
+ );
150
+
151
+ const resultsDir = mkdtempSync(join(tmpdir(), "cadet-test-run-"));
152
+ const coverage = process.env.COVERAGE === "true";
153
+ let coverageBase = "";
154
+ if (coverage) {
155
+ coverageBase = join(cwd, "coverage");
156
+ rmSync(coverageBase, { recursive: true, force: true });
157
+ mkdirSync(coverageBase, { recursive: true });
158
+ }
159
+
160
+ const results: FileResult[] = [];
161
+
162
+ // A rolling pool keeps every slot busy, so a slow file occupies only its
163
+ // own slot while the others keep pulling work.
164
+ let next = 0;
165
+ async function worker(): Promise<void> {
166
+ while (next < files.length) {
167
+ const file = files[next++];
168
+ results.push(
169
+ await runOneFile(file, { cwd, timeoutMs, bunTestArgs, resultsDir, coverage }),
170
+ );
171
+ }
172
+ }
173
+ await Promise.all(
174
+ Array.from({ length: Math.min(concurrency, files.length) }, worker),
175
+ );
176
+
177
+ // Record durations for longest-first scheduling in future runs. Written
178
+ // before the failure exit so durations persist even when tests fail.
179
+ const durationsOutput = process.env.TEST_DURATIONS_OUTPUT ?? "";
180
+ if (durationsOutput) {
181
+ const sortedResults = [...results].sort((a, b) => b.elapsedMs - a.elapsedMs);
182
+ writeFileSync(
183
+ durationsOutput,
184
+ sortedResults.map((r) => `${r.elapsedMs}\t${r.file}`).join("\n") + "\n",
185
+ );
186
+ console.log(`Wrote test durations to ${durationsOutput}`);
187
+ }
188
+
189
+ const failures = results.filter((r) => r.outcome === "fail");
190
+ if (failures.length > 0) {
191
+ console.log("");
192
+ for (const result of failures) {
193
+ console.log("──────────────────────────────────────────");
194
+ console.log(`FAIL: ${result.file}`);
195
+ console.log("──────────────────────────────────────────");
196
+ process.stdout.write(result.output);
197
+ console.log("");
198
+ }
199
+ console.log("========================================");
200
+ console.log(` FAILED TEST FILES (${failures.length}):`);
201
+ console.log("========================================");
202
+ for (const result of failures) {
203
+ console.log(` ✗ ${result.file}`);
204
+ }
205
+ console.log("========================================");
206
+ rmSync(resultsDir, { recursive: true, force: true });
207
+ rmSync(fixturesRoot, { recursive: true, force: true });
208
+ process.exit(1);
209
+ }
210
+
211
+ if (coverage) {
212
+ const mergedPath = join(coverageBase, "lcov.info");
213
+ if (mergeLcov(resultsDir, mergedPath)) {
214
+ console.log("Coverage report written to coverage/lcov.info");
215
+ } else {
216
+ console.log("Warning: no coverage data was generated");
217
+ }
218
+ }
219
+
220
+ rmSync(resultsDir, { recursive: true, force: true });
221
+ rmSync(fixturesRoot, { recursive: true, force: true });
222
+
223
+ const passed = results.filter((r) => r.outcome !== "fail").length;
224
+ console.log(
225
+ `\n${passed} passed, ${failures.length} failed (${files.length} test files${
226
+ shard ? `, shard ${shard.index}/${shard.total}` : ""
227
+ })`,
228
+ );
229
+ }
230
+
231
+ // ---------------------------------------------------------------------------
232
+ // Running a single test file
233
+ // ---------------------------------------------------------------------------
234
+
235
+ interface RunResult {
236
+ exitCode: number;
237
+ output: string;
238
+ timedOut: boolean;
239
+ }
240
+
241
+ const hasFailedAssertion = (out: string): boolean => /^\(fail\)/m.test(out);
242
+ const reachedEndOfRun = (out: string): boolean =>
243
+ /^Ran \d+ tests? across/m.test(out);
244
+
245
+ async function runBunTest(
246
+ file: string,
247
+ opts: {
248
+ cwd: string;
249
+ timeoutMs: number;
250
+ bunTestArgs: string[];
251
+ coverageArgs: string[];
252
+ },
253
+ ): Promise<RunResult> {
254
+ // `bun test` reads a bare argument as a filename filter, and a filter never
255
+ // matches a path under a dot-directory, so every file is passed as an
256
+ // explicit relative path.
257
+ // https://bun.sh/docs/cli/test#run-specific-tests
258
+ const path =
259
+ file.startsWith("./") || file.startsWith("/") ? file : `./${file}`;
260
+ const args = [
261
+ "test",
262
+ ...opts.coverageArgs,
263
+ ...opts.bunTestArgs,
264
+ `--timeout=${opts.timeoutMs}`,
265
+ path,
266
+ ];
267
+ const proc = Bun.spawn(["bun", ...args], {
268
+ cwd: opts.cwd,
269
+ stdout: "pipe",
270
+ stderr: "pipe",
271
+ });
272
+
273
+ let timedOut = false;
274
+ let killTimer: ReturnType<typeof setTimeout> | null = null;
275
+ let graceTimer: ReturnType<typeof setTimeout> | null = null;
276
+ killTimer = setTimeout(() => {
277
+ timedOut = true;
278
+ proc.kill("SIGTERM");
279
+ graceTimer = setTimeout(() => {
280
+ try {
281
+ proc.kill("SIGKILL");
282
+ } catch {
283
+ /* already exited */
284
+ }
285
+ }, KILL_GRACE_MS);
286
+ }, PER_TEST_TIMEOUT_MS);
287
+
288
+ const [stdout, stderr, exitCode] = await Promise.all([
289
+ new Response(proc.stdout).text(),
290
+ new Response(proc.stderr).text(),
291
+ proc.exited,
292
+ ]);
293
+ if (killTimer) {
294
+ clearTimeout(killTimer);
295
+ }
296
+ if (graceTimer) {
297
+ clearTimeout(graceTimer);
298
+ }
299
+
300
+ return { exitCode, output: stdout + stderr, timedOut };
301
+ }
302
+
303
+ type Outcome = "pass" | "fail" | "hung-pass";
304
+
305
+ interface FileResult {
306
+ file: string;
307
+ outcome: Outcome;
308
+ elapsedMs: number;
309
+ output: string;
310
+ }
311
+
312
+ interface RunOneFileOpts {
313
+ cwd: string;
314
+ timeoutMs: number;
315
+ bunTestArgs: string[];
316
+ resultsDir: string;
317
+ coverage: boolean;
318
+ }
319
+
320
+ async function runOneFile(
321
+ file: string,
322
+ opts: RunOneFileOpts,
323
+ ): Promise<FileResult> {
324
+ const base = basename(file);
325
+ const coverageArgs: string[] = [];
326
+ if (opts.coverage) {
327
+ const safe = file.replace(/\//g, "_");
328
+ const covDir = join(opts.resultsDir, `cov_${safe}`);
329
+ mkdirSync(covDir, { recursive: true });
330
+ coverageArgs.push(
331
+ "--coverage",
332
+ "--coverage-reporter=lcov",
333
+ `--coverage-dir=${covDir}`,
334
+ );
335
+ }
336
+
337
+ const start = performance.now();
338
+ let { exitCode, output, timedOut } = await runBunTest(file, {
339
+ ...opts,
340
+ coverageArgs,
341
+ });
342
+
343
+ // Retry once when a failing run produced no conclusive test evidence:
344
+ // - transient loader/runtime crash (non-zero, non-timeout exit)
345
+ // - transient hang (timeout kill before the end-of-run summary)
346
+ // Neither leaves a "(fail)" line; genuine assertion failures print one and
347
+ // are reported immediately (fast feedback, no masking of intermittent
348
+ // failures). A timed-out run that reached the end-of-run summary is the
349
+ // passed-but-hung-at-exit case — no retry needed.
350
+ let retryReason = "";
351
+ if (exitCode !== 0 && !hasFailedAssertion(output)) {
352
+ if (timedOut) {
353
+ if (!reachedEndOfRun(output)) {
354
+ retryReason = "hung without completing";
355
+ }
356
+ } else {
357
+ retryReason = `transient crash, exit ${exitCode}`;
358
+ }
359
+ }
360
+ if (retryReason) {
361
+ console.log(` ↻ ${base} (${retryReason} — retrying once)`);
362
+ ({ exitCode, output, timedOut } = await runBunTest(file, {
363
+ ...opts,
364
+ coverageArgs,
365
+ }));
366
+ }
367
+
368
+ const elapsedMs = Math.round(performance.now() - start);
369
+
370
+ let outcome: Outcome;
371
+ let line: string;
372
+ if (timedOut) {
373
+ // The process was killed by the timeout. It passed only if there were no
374
+ // failures AND the end-of-run summary is present; without the summary it
375
+ // was killed mid-run.
376
+ if (hasFailedAssertion(output)) {
377
+ outcome = "fail";
378
+ line = ` ✗ ${base} (killed after wall-clock timeout — tests failed and process hung)`;
379
+ } else if (reachedEndOfRun(output)) {
380
+ outcome = "hung-pass";
381
+ line = ` ⚠ ${base} (tests passed but process hung — likely open handles)`;
382
+ } else {
383
+ outcome = "fail";
384
+ line = ` ✗ ${base} (killed after wall-clock timeout — test run did not complete)`;
385
+ }
386
+ } else if (exitCode !== 0) {
387
+ outcome = "fail";
388
+ line = ` ✗ ${base} (${elapsedMs}ms)`;
389
+ } else {
390
+ outcome = "pass";
391
+ line = ` ✓ ${base} (${elapsedMs}ms)`;
392
+ }
393
+ console.log(line);
394
+
395
+ return { file, outcome, elapsedMs, output };
396
+ }
397
+
398
+ // ---------------------------------------------------------------------------
399
+ // Collection helpers
400
+ // ---------------------------------------------------------------------------
401
+
402
+ /**
403
+ * Sorts tests longest-first using durations from a previous run, so slow
404
+ * tests start immediately across all workers instead of piling up at the end
405
+ * and becoming long poles. No-op without TEST_DURATIONS_FILE.
406
+ */
407
+ function sortLongestFirst(files: string[]): string[] {
408
+ const durationsFile = process.env.TEST_DURATIONS_FILE ?? "";
409
+ if (!durationsFile || !existsSync(durationsFile)) {
410
+ return files;
411
+ }
412
+ const durMap = new Map<string, number>();
413
+ for (const line of readFileSync(durationsFile, "utf-8").split("\n")) {
414
+ if (!line) {
415
+ continue;
416
+ }
417
+ const tab = line.indexOf("\t");
418
+ if (tab === -1) {
419
+ continue;
420
+ }
421
+ const ms = Number.parseInt(line.slice(0, tab), 10);
422
+ const path = line.slice(tab + 1);
423
+ if (path) {
424
+ durMap.set(path, ms);
425
+ }
426
+ }
427
+ const known: string[] = [];
428
+ const unknown: string[] = [];
429
+ for (const f of files) {
430
+ (durMap.has(f) ? known : unknown).push(f);
431
+ }
432
+ known.sort((a, b) => (durMap.get(b) ?? 0) - (durMap.get(a) ?? 0));
433
+ console.log(
434
+ `Sorted tests longest-first using ${durationsFile} (${known.length} known, ${unknown.length} new)`,
435
+ );
436
+ return [...known, ...unknown];
437
+ }
438
+
439
+ /**
440
+ * Intersects the collected test files with those changed vs main under the
441
+ * calling package's own directory (derived from its cwd). Returns null when
442
+ * the diff cannot be computed (missing base ref, shallow clone), which callers
443
+ * treat as "run everything" — a stale or absent base must never green-light a
444
+ * PR.
445
+ */
446
+ function diffTestFiles(files: string[], cwd: string): string[] | null {
447
+ // Three levels up from packages/shared/scripts.
448
+ const repoRoot = join(import.meta.dir, "..", "..", "..");
449
+ ensureBaseRef(repoRoot);
450
+ const pkgDir = relative(repoRoot, cwd);
451
+ const proc = Bun.spawnSync(
452
+ ["git", "diff", "--name-only", `${TEST_DIFF_BASE}...HEAD`, "--", pkgDir],
453
+ { cwd: repoRoot, stdout: "pipe", stderr: "pipe" },
454
+ );
455
+ if (proc.exitCode !== 0) {
456
+ console.log(
457
+ `Could not diff vs ${TEST_DIFF_BASE} (exit ${proc.exitCode}: ${proc.stderr.toString().trim()}) — running the full suite.`,
458
+ );
459
+ return null;
460
+ }
461
+ const prefix = pkgDir === "" ? "" : `${pkgDir}/`;
462
+ const changed = new Set(
463
+ proc.stdout
464
+ .toString()
465
+ .split("\n")
466
+ .filter(Boolean)
467
+ .filter((p) => p.startsWith(prefix))
468
+ .map((p) => p.slice(prefix.length)),
469
+ );
470
+ const scoped = files.filter((f) => changed.has(f));
471
+ console.log(
472
+ `Diff vs ${TEST_DIFF_BASE} touched ${changed.size} file(s); ${scoped.length} of ${files.length} collected test files match.`,
473
+ );
474
+ return scoped;
475
+ }
476
+
477
+ /**
478
+ * PR checkouts run at a limited fetch depth, so `origin/main` is usually
479
+ * absent. Fetch it shallowly (best effort) so the diff can compute; when this
480
+ * fails the diff falls open to the full suite.
481
+ */
482
+ function ensureBaseRef(repoRoot: string): void {
483
+ const check = Bun.spawnSync(
484
+ ["git", "rev-parse", "--verify", "--quiet", TEST_DIFF_BASE],
485
+ { cwd: repoRoot, stdout: "ignore", stderr: "ignore" },
486
+ );
487
+ if (check.exitCode === 0) {
488
+ return;
489
+ }
490
+ console.log(
491
+ `${TEST_DIFF_BASE} not fetched — fetching it at depth ${DIFF_FETCH_DEPTH}...`,
492
+ );
493
+ Bun.spawnSync(
494
+ [
495
+ "git",
496
+ "fetch",
497
+ "--no-tags",
498
+ `--depth=${DIFF_FETCH_DEPTH}`,
499
+ "origin",
500
+ "main",
501
+ ],
502
+ { cwd: repoRoot, stdout: "ignore", stderr: "ignore" },
503
+ );
504
+ }
505
+
506
+ /**
507
+ * Parses `TEST_SHARD=<index>/<total>` (1-based), which splits the sorted file
508
+ * list round-robin so CI can fan one suite out across parallel jobs.
509
+ */
510
+ function parseShard(
511
+ value: string | undefined,
512
+ ): { index: number; total: number } | undefined {
513
+ if (!value) {
514
+ return undefined;
515
+ }
516
+ const match = /^(\d+)\/(\d+)$/.exec(value);
517
+ const index = match ? Number(match[1]) : 0;
518
+ const total = match ? Number(match[2]) : 0;
519
+ if (index < 1 || index > total) {
520
+ throw new Error(`TEST_SHARD must look like "1/4", got "${value}"`);
521
+ }
522
+ return { index, total };
523
+ }
524
+
525
+ // ---------------------------------------------------------------------------
526
+ // Coverage merge
527
+ //
528
+ // Merges per-file lcov reports, deduplicating source files that appear in
529
+ // multiple runs — raw concatenation would count shared files multiple times.
530
+ // DA/FNDA/BRDA execution counts are summed; FN/DA/BRDA identities keep their
531
+ // first-seen order.
532
+ // ---------------------------------------------------------------------------
533
+
534
+ function mergeLcov(rawLcovDir: string, mergedPath: string): boolean {
535
+ interface FileCov {
536
+ daOrder: string[]; // line numbers in first-seen order
537
+ da: Map<string, number>; // line -> summed count
538
+ fnOrder: string[]; // "line,name" in first-seen order
539
+ fnSeen: Set<string>;
540
+ fndaOrder: string[]; // "line,name" in first-seen order
541
+ fnda: Map<string, number>; // "line,name" -> summed count
542
+ brdaOrder: string[]; // "line,block,branch" in first-seen order
543
+ brda: Map<string, number>; // key -> summed taken
544
+ brdaNumeric: Set<string>; // keys that had a numeric taken
545
+ }
546
+ const filesMap = new Map<string, FileCov>();
547
+ const fileOrder: string[] = [];
548
+
549
+ const emptyCov = (): FileCov => ({
550
+ daOrder: [],
551
+ da: new Map(),
552
+ fnOrder: [],
553
+ fnSeen: new Set(),
554
+ fndaOrder: [],
555
+ fnda: new Map(),
556
+ brdaOrder: [],
557
+ brda: new Map(),
558
+ brdaNumeric: new Set(),
559
+ });
560
+
561
+ // Concatenate all per-file lcov files, then walk line by line.
562
+ const covDirs = readdirSync(rawLcovDir).filter((d) => d.startsWith("cov_"));
563
+ let sawAny = false;
564
+ for (const dir of covDirs) {
565
+ const lcovFile = join(rawLcovDir, dir, "lcov.info");
566
+ if (!existsSync(lcovFile)) {
567
+ continue;
568
+ }
569
+ sawAny = true;
570
+ let cur: FileCov | null = null;
571
+ // FN/FNDA are paired by position WITHIN a file's SF block.
572
+ let blockFnAt: string[] = [];
573
+ let blockFndaIdx = 0;
574
+ for (const raw of readFileSync(lcovFile, "utf-8").split("\n")) {
575
+ if (raw.startsWith("SF:")) {
576
+ const sf = raw.slice(3);
577
+ if (!filesMap.has(sf)) {
578
+ filesMap.set(sf, emptyCov());
579
+ fileOrder.push(sf);
580
+ }
581
+ cur = filesMap.get(sf)!;
582
+ blockFnAt = [];
583
+ blockFndaIdx = 0;
584
+ } else if (raw.startsWith("DA:") && cur) {
585
+ const [line, countStr] = raw.slice(3).split(",");
586
+ const count = Number.parseInt(countStr, 10) || 0;
587
+ if (cur.da.has(line)) {
588
+ cur.da.set(line, cur.da.get(line)! + count);
589
+ } else {
590
+ cur.da.set(line, count);
591
+ cur.daOrder.push(line);
592
+ }
593
+ } else if (raw.startsWith("FN:") && cur) {
594
+ const entry = raw.slice(3); // "line,name"
595
+ if (!cur.fnSeen.has(entry)) {
596
+ cur.fnSeen.add(entry);
597
+ cur.fnOrder.push(entry);
598
+ }
599
+ blockFnAt.push(entry);
600
+ } else if (raw.startsWith("FNDA:") && cur) {
601
+ const rest = raw.slice(5); // "count,name"
602
+ const count = Number.parseInt(rest.split(",")[0], 10) || 0;
603
+ // Pair with the FN at the same position within this block.
604
+ const fnFull =
605
+ blockFnAt[blockFndaIdx] ?? rest.slice(rest.indexOf(",") + 1);
606
+ blockFndaIdx++;
607
+ if (cur.fnda.has(fnFull)) {
608
+ cur.fnda.set(fnFull, cur.fnda.get(fnFull)! + count);
609
+ } else {
610
+ cur.fnda.set(fnFull, count);
611
+ cur.fndaOrder.push(fnFull);
612
+ }
613
+ } else if (raw.startsWith("BRDA:") && cur) {
614
+ const parts = raw.slice(5).split(",");
615
+ const key = `${parts[0]},${parts[1]},${parts[2]}`;
616
+ const taken = parts[3];
617
+ if (taken === "-") {
618
+ if (!cur.brdaNumeric.has(key) && !cur.brda.has(key)) {
619
+ cur.brda.set(key, 0);
620
+ }
621
+ } else {
622
+ const takenCount = Number.parseInt(taken, 10) || 0;
623
+ if (cur.brdaNumeric.has(key)) {
624
+ cur.brda.set(key, cur.brda.get(key)! + takenCount);
625
+ } else {
626
+ cur.brda.set(key, takenCount);
627
+ }
628
+ cur.brdaNumeric.add(key);
629
+ }
630
+ if (!cur.brdaOrder.includes(key)) {
631
+ cur.brdaOrder.push(key);
632
+ }
633
+ }
634
+ }
635
+ }
636
+
637
+ if (!sawAny) {
638
+ return false;
639
+ }
640
+
641
+ const out: string[] = [];
642
+ for (const sf of fileOrder) {
643
+ const cov = filesMap.get(sf)!;
644
+ out.push(`SF:${sf}`);
645
+ for (const fn of cov.fnOrder) {
646
+ out.push(`FN:${fn}`);
647
+ }
648
+ let fnf = 0;
649
+ let fnh = 0;
650
+ for (const entry of cov.fndaOrder) {
651
+ const count = cov.fnda.get(entry)!;
652
+ // entry is "line,name" — strip leading "line," to get the name.
653
+ const comma = entry.indexOf(",");
654
+ const name = comma === -1 ? entry : entry.slice(comma + 1);
655
+ out.push(`FNDA:${count},${name}`);
656
+ fnf++;
657
+ if (count > 0) {
658
+ fnh++;
659
+ }
660
+ }
661
+ out.push(`FNF:${fnf}`);
662
+ out.push(`FNH:${fnh}`);
663
+ let brf = 0;
664
+ let brh = 0;
665
+ const brLines: string[] = [];
666
+ for (const key of cov.brdaOrder) {
667
+ if (cov.brdaNumeric.has(key)) {
668
+ const taken = cov.brda.get(key)!;
669
+ brLines.push(`BRDA:${key},${taken}`);
670
+ brf++;
671
+ if (taken > 0) {
672
+ brh++;
673
+ }
674
+ } else {
675
+ brLines.push(`BRDA:${key},-`);
676
+ brf++;
677
+ }
678
+ }
679
+ out.push(...brLines);
680
+ if (brf > 0) {
681
+ out.push(`BRF:${brf}`);
682
+ out.push(`BRH:${brh}`);
683
+ }
684
+ let lf = 0;
685
+ let lh = 0;
686
+ for (const line of cov.daOrder) {
687
+ const count = cov.da.get(line)!;
688
+ out.push(`DA:${line},${count}`);
689
+ lf++;
690
+ if (count > 0) {
691
+ lh++;
692
+ }
693
+ }
694
+ out.push(`LF:${lf}`);
695
+ out.push(`LH:${lh}`);
696
+ out.push("end_of_record");
697
+ }
698
+ writeFileSync(mergedPath, out.join("\n") + "\n");
699
+ return true;
700
+ }