rvlib-shipkit 1.7.0 → 1.8.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.
@@ -9,27 +9,39 @@
9
9
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
10
10
  import { availableParallelism, tmpdir } from 'os'
11
11
  import { machineLoad } from '../core/budget.ts'
12
- import { join } from 'path'
12
+ import { join, posix } from 'path'
13
13
  import { FULL_TESTS_ENV } from '../ledger/gate-checks.ts'
14
14
  import { CHECK_ID_ENV, recordSuites, type SuitePhase } from '../ledger/suite-log.ts'
15
15
  import { killTree } from '../core/spawn-timeout.ts'
16
16
  import { parallelCap } from '../core/parallel.ts'
17
17
  import { poolDir, poolOccupancy } from '../core/slots.ts'
18
18
  import { keepTimings, seedTimings, timingsHome } from './kept-timings.ts'
19
- import { aloneRunsNeeded, bunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, overWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
19
+ import { SUITE_READS_ENV, SUITE_ROOT_ENV, SUITE_SELECT_ENV } from './reads-env.ts'
20
+ import { changedKeys, dropStaticEdges, extras, isBunSuite, envFilesOf, foldReads, loadErrorModules, loadState, preloadsOf, saveState, selectStatePath, selectSuites, snapshot, suiteFiles, taintedSuites, walkGraph, type GraphEntry, type Selection, type SelectState } from './select.ts'
21
+ import { gitRoot } from '../core/git-root.ts'
22
+ import { loadConfig } from '../ts/config.ts'
23
+ import { aloneRunsNeeded, bunfigTest, type BunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, overWords, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
20
24
 
21
25
  type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
22
26
 
23
- async function runPhase(args: string[], timingsFile: string | null, ceilingMs: number, tmp: string, echo = true, cpuFile: string | null = null): Promise<Phase> {
27
+ /** where the read recorder writes, and the repo root it charges reads under */
28
+ type Recording = { file: string; top: string }
29
+
30
+ async function runPhase(args: string[], timingsFile: string | null, ceilingMs: number, tmp: string, echo = true, cpuFile: string | null = null, rec: Recording | null = null): Promise<Phase> {
24
31
  const timing = timingsFile != null ? [`--timings=${timingsFile}`, '--update-timings'] : []
25
32
  // the per suite CPU figure rides in a preload of the runner's own, beside
26
33
  // the repo's (a command line preload adds to bunfig's, never replaces it)
27
34
  const cpu = cpuFile != null ? ['--preload', CPU_PRELOAD] : []
35
+ const reads = rec != null ? ['--preload', READS_PRELOAD] : []
28
36
  // TMPDIR is the run's own dir, deleted with it: whatever a suite forgets to
29
37
  // clean dies here, never in the machine temp dir, whose listing bun reads on
30
38
  // the first import from a fixture there (90k leaked dirs cost ~100ms each time)
31
- const env = { ...process.env, TMPDIR: tmp, ...(cpuFile != null ? { [SUITE_CPU_ENV]: cpuFile } : {}) }
32
- const proc = Bun.spawn(['bun', 'test', ...cpu, ...timing, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env })
39
+ const env = {
40
+ ...process.env, TMPDIR: tmp,
41
+ ...(cpuFile != null ? { [SUITE_CPU_ENV]: cpuFile } : {}),
42
+ ...(rec != null ? { [SUITE_READS_ENV]: rec.file, [SUITE_ROOT_ENV]: rec.top } : {}),
43
+ }
44
+ const proc = Bun.spawn(['bun', 'test', ...cpu, ...reads, ...timing, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env })
33
45
  // the workers are children of `bun test`, and a stuck one is the process
34
46
  // to reach, so the whole tree goes
35
47
  let stalled = false
@@ -60,6 +72,8 @@ async function runPhase(args: string[], timingsFile: string | null, ceilingMs: n
60
72
  }
61
73
 
62
74
  const CPU_PRELOAD = join(import.meta.dir, 'suite-cpu-preload.ts')
75
+ const READS_PRELOAD = join(import.meta.dir, 'suite-reads-preload.ts')
76
+ const NO_PHASE: Phase = { code: 0, signal: null, stalled: false, out: '', timings: {}, cpuMs: 0 }
63
77
 
64
78
  const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
65
79
 
@@ -68,12 +82,30 @@ const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
68
82
  const phaseFiles = (phase: string[], rawArgs: string[]): string[] => phase.filter((a) => !a.startsWith('-') && !rawArgs.includes(a))
69
83
  const lastCount = (out: string, re: RegExp): number => Number([...out.matchAll(re)].at(-1)?.[1] ?? 0)
70
84
 
85
+ /** what the selection carries from before the run to after it */
86
+ type SelectCtx = { sel: Selecting | null; /** the suites that ran and passed, time included */ ran: string[]; tier: 'quick' | 'full'; /** every suite ran to its end, red or green: no phase stalled or was killed */ complete: boolean; /** what every phase printed */ out: string }
87
+
71
88
  export async function suiteCli(argv: string[]): Promise<number> {
89
+ const ctx: SelectCtx = { sel: null, ran: [], tier: 'quick', complete: false, out: '' }
90
+ try {
91
+ const code = await runSuites(argv, ctx)
92
+ if (ctx.sel != null) {
93
+ const problem = finishSelection(ctx.sel, ctx.ran, ctx.tier === 'full' && ctx.complete, ctx.out)
94
+ if (problem != null) console.log(problem)
95
+ }
96
+ return code
97
+ } finally {
98
+ if (ctx.sel != null) rmSync(ctx.sel.rec.file, { force: true })
99
+ }
100
+ }
101
+
102
+ async function runSuites(argv: string[], ctx: SelectCtx): Promise<number> {
72
103
  const t0 = Date.now()
73
104
  const parsed = parseSuiteFlags(argv)
74
105
  if (typeof parsed === 'string') { console.log(`FAIL: suite runner: ${parsed}`); return 2 }
75
106
  const { tests, rawArgs, declaredIn, unit } = parsed
76
107
  const tier = process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
108
+ ctx.tier = tier
77
109
  const ceilingMs = parsed.ceilingMs ?? (tier === 'full' ? FULL_CEILING_MS : QUICK_CEILING_MS)
78
110
  const budget = tests.budgetMs ?? DEFAULT_BUDGET_MS
79
111
 
@@ -97,8 +129,9 @@ export async function suiteCli(argv: string[]): Promise<number> {
97
129
  // the pool is read, never taken: this process runs on its check's slot
98
130
  const cores = availableParallelism()
99
131
  const held = parallel ? poolOccupancy({ dir: poolDir(), size: parallelCap() }).held.length : 0
100
- const workers = parallel ? poolWorkers({ cores, size: parallelCap(), held }) : undefined
101
- const plan = planPhases({ tests, tier, rawArgs, parallel, noIsolate: helpText.includes('--no-isolate'), configIgnores: bunfig.ignores, workers })
132
+ const pool = parallel ? poolWorkers({ cores, size: parallelCap(), held }) : undefined
133
+ const plan = planPhases({ tests, tier, rawArgs, parallel, noIsolate: helpText.includes('--no-isolate'), configIgnores: bunfig.ignores, workers: pool })
134
+ const { workers } = plan
102
135
  const dir = mkdtempSync(join(tmpdir(), 'shipkit-suite-'))
103
136
  const tmp = join(dir, 'temp')
104
137
  mkdirSync(tmp)
@@ -115,8 +148,28 @@ export async function suiteCli(argv: string[]): Promise<number> {
115
148
  // the load BEFORE this run: read after it, the one minute average
116
149
  // carries this run's own parallel phase, which is no other process
117
150
  const load = machineLoad()
151
+ const picked = prepareSelection({ tests, tier, rawArgs, bunfig, unit, plan })
152
+ if (typeof picked === 'string') notes.push(picked)
153
+ else {
154
+ ctx.sel = picked
155
+ for (const n of picked.notes) console.log(n)
156
+ if (picked.picked != null && picked.picked.length === 0) {
157
+ for (const n of notes) console.log(n)
158
+ console.log(`ok: no suite reaches what changed since the last run (${picked.changed} path(s)), and every one passed then, so none ran`)
159
+ return 0
160
+ }
161
+ }
162
+ const rec = ctx.sel?.rec ?? null
163
+ const pickedSet = ctx.sel?.picked == null ? null : new Set(ctx.sel.picked)
164
+ const serialSet = new Set((tests.serial ?? []).map((x) => normSuitePath(x.file)))
165
+ const parallelArgs = pickedSet == null ? plan.parallel : [...plan.parallel, ...[...pickedSet].filter((f) => !serialSet.has(f)).map((f) => `./${f}`)]
166
+ const serialArgs = plan.serial == null || pickedSet == null ? plan.serial : (() => {
167
+ const files = [...serialSet].filter((f) => pickedSet.has(f))
168
+ return files.length === 0 ? null : [...plan.alone, ...files]
169
+ })()
170
+ const parallelEmpty = pickedSet != null && parallelArgs.length === plan.parallel.length
118
171
  const p1At = Date.now()
119
- const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null, ceilingMs, tmp, true, cpuFile)
172
+ const p1 = parallelEmpty ? { ...NO_PHASE } : await runPhase(parallelArgs, timed ? join(dir, 'parallel.json') : null, ceilingMs, tmp, true, cpuFile, rec)
120
173
  const p1Ms = Date.now() - p1At
121
174
  // bun merges what it read into what it writes, so a suite deleted since
122
175
  // the last run is judged and kept only if the workers say it ran here
@@ -131,8 +184,11 @@ export async function suiteCli(argv: string[]): Promise<number> {
131
184
  keepTimings(join(dir, 'parallel.json'), kept)
132
185
  }
133
186
  }
187
+ // the kept durations keep every suite for the next run's schedule,
188
+ // and only the picked ones ran here, so only they are judged
189
+ if (pickedSet != null) p1.timings = Object.fromEntries(Object.entries(p1.timings).filter(([f]) => pickedSet.has(normSuitePath(f))))
134
190
  const p2At = Date.now()
135
- const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null, ceilingMs, tmp) : null
191
+ const p2 = serialArgs != null ? await runPhase(serialArgs, timed ? join(dir, 'serial.json') : null, ceilingMs, tmp, true, null, rec) : null
136
192
  const p2Ms = p2 == null ? null : Date.now() - p2At
137
193
  const phases = [p1, ...(p2 != null ? [p2] : [])]
138
194
  const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
@@ -150,7 +206,7 @@ export async function suiteCli(argv: string[]): Promise<number> {
150
206
  const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
151
207
  const aloneAt = Date.now()
152
208
  for (const [i, t] of rerun.entries()) {
153
- const run = await runPhase([...rawArgs, t.file], join(dir, `alone-${i}.json`), ceilingMs, tmp, false)
209
+ const run = await runPhase([...plan.alone, t.file], join(dir, `alone-${i}.json`), ceilingMs, tmp, false, null, rec)
154
210
  const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
155
211
  if (wallMs != null) aloneRuns[t.file] = { wallMs, cpuMs: run.cpuMs }
156
212
  }
@@ -167,17 +223,21 @@ export async function suiteCli(argv: string[]): Promise<number> {
167
223
  : null
168
224
  const crashed = crashedWorkers(p1.out)
169
225
  const killed = p1.stalled ? [] : crashed.map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
170
- const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(plan.serial ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null))]
226
+ const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null))]
171
227
  for (const line of stalls) if (line != null) killed.unshift(line)
172
228
  if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
173
229
  if (p2?.signal != null) {
174
- const unfinished = phaseFiles(plan.serial ?? [], rawArgs).filter((a) => p2.timings[normSuitePath(a)] == null)
230
+ const unfinished = phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2.timings[normSuitePath(a)] == null)
175
231
  killed.push(`killed by ${p2.signal} while running ${unfinished.join(' or ')} (out of memory, or killed from outside)`)
176
232
  }
177
233
  // the record behind the hub's test files to fix: every suite this run
178
234
  // timed, with the alone timing where one was taken, and red when a
179
235
  // test in it failed or its worker died
180
236
  const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashedWorkers(p1.out).map((c) => c.file)])
237
+ const overSet = new Set(over.map((o) => o.file))
238
+ ctx.complete = killed.length === 0
239
+ ctx.out = phases.map((p) => p.out).join('\n')
240
+ ctx.ran = Object.keys(timings).map(normSuitePath).filter((f) => !red.has(f) && !overSet.has(f))
181
241
  const at = Date.now()
182
242
  const alone = new Map([...over, ...cleared, ...clearedInPhase].map((t) => [t.file, t.ms]))
183
243
  const why = failLines(phases.map((p) => p.out).join('\n'))
@@ -227,12 +287,134 @@ export async function suiteCli(argv: string[]): Promise<number> {
227
287
  // the most actionable failure prints LAST, where the gate reads its headline
228
288
  for (const f of fails.slice(1).reverse()) console.log(f)
229
289
  if (fails.length > 0) { console.log(fails[0]); return 1 }
230
- const serialCount = plan.serial != null ? phaseFiles(plan.serial, rawArgs).length : 0
231
- console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
290
+ const serialCount = serialArgs != null ? phaseFiles(serialArgs, rawArgs).length : 0
291
+ const chosen = ctx.sel?.picked != null ? `, the ${ctx.sel.picked.length} of ${ctx.sel.total} a change reaches` : ''
292
+ console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites${chosen}, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
232
293
  return 0
233
294
  } finally {
234
295
  rmSync(dir, { recursive: true, force: true })
235
296
  }
236
297
  }
237
298
 
299
+ /** a selection in flight: the recorder's file, the state it started from,
300
+ * the snapshot taken before the run, and the suites it picked (cwd
301
+ * relative, null = every suite) */
302
+ type Selecting = {
303
+ rec: Recording
304
+ statePath: string
305
+ state: SelectState
306
+ now: Record<string, string>
307
+ graph: Record<string, GraphEntry>
308
+ preloads: string[]
309
+ cwdRel: string
310
+ picked: string[] | null
311
+ /** when the snapshot was taken */
312
+ started: number
313
+ /** every suite of the run, repo-relative */
314
+ allSuites: string[]
315
+ total: number
316
+ changed: number
317
+ notes: string[]
318
+ }
319
+
320
+ /** a bun test argument that is neither a flag nor a flag's value: a path or
321
+ * a filter the repo's own script names, which a selection would override */
322
+ function positional(rawArgs: string[]): string | null {
323
+ for (let i = 0; i < rawArgs.length; i++) {
324
+ const a = rawArgs[i]!
325
+ if (a.startsWith('-')) {
326
+ if (!a.includes('=') && VALUE_FLAGS.includes(a)) i++
327
+ continue
328
+ }
329
+ return a
330
+ }
331
+ return null
332
+ }
333
+ const VALUE_FLAGS = ['--preload', '-r', '--env-file', '--timeout', '-t', '--test-name-pattern', '--path-ignore-patterns', '--reporter', '--reporter-outfile', '--coverage-dir', '--rerun-each', '--seed', '--max-concurrency', '--cwd', '--config', '-c', '--tsconfig-override', '--define', '-d', '--loader', '-l', '--jsx-factory', '--jsx-fragment', '--jsx-import-source', '--conditions', '--timings']
334
+
335
+ /** the recorder's file and, in the quick tier, the suites a change since the
336
+ * last green run reaches. A string says why nothing is recorded */
337
+ function prepareSelection(o: { tests: TestsConfigLike; tier: 'quick' | 'full'; rawArgs: string[]; bunfig: BunfigTest; unit: string; plan: { parallel: string[] } }): Selecting | string {
338
+ const env = process.env[SUITE_SELECT_ENV]
339
+ if (env === '0') return `skip: suite selection (${SUITE_SELECT_ENV}=0): every suite runs`
340
+ if (o.tests.select !== true && env !== '1') return `skip: suite selection (tests.select is not true, ${SUITE_SELECT_ENV}=1 turns it on for one run): every suite runs`
341
+ // a run that filters tests by name passes suites it ran only in part: it can neither pick nor clear one
342
+ const filter = o.rawArgs.find((a) => /^(-t|--test-name-pattern|--only|--todo)(=|$)/.test(a))
343
+ if (filter != null) return `skip: suite selection (the run filters tests with ${filter}): every suite runs, and this run records nothing`
344
+ const g = gitRoot(process.cwd())
345
+ if (g == null) return 'skip: suite selection (not in a git work tree): every suite runs'
346
+ const top = g.top
347
+ const cwdRel = g.prefix.replace(/\/$/, '')
348
+ const notes: string[] = []
349
+ const t0 = performance.now()
350
+ try {
351
+ const statePath = selectStatePath(top, o.unit)
352
+ const state = loadState(statePath)
353
+ const cfg = loadConfig(top)
354
+ const ignores = [
355
+ ...o.bunfig.ignores,
356
+ ...o.plan.parallel.filter((a) => a.startsWith('--path-ignore-patterns=')).map((a) => a.slice('--path-ignore-patterns='.length)),
357
+ ]
358
+ const suites = suiteFiles(top, cwdRel, o.bunfig.root, ignores, isBunSuite)
359
+ const toTop = (f: string): string => (cwdRel === '' ? f : `${cwdRel}/${f}`)
360
+ const bunfigText = existsSync('bunfig.toml') ? readFileSync('bunfig.toml', 'utf8') : null
361
+ const preloads = preloadsOf(bunfigText, o.rawArgs, cwdRel)
362
+ const loads = Object.values(state.modules).flatMap((m) => m.l ?? [])
363
+ const graph = walkGraph(cfg, [...suites.map(toTop), ...preloads, ...loads], state.graph)
364
+ const started = Date.now()
365
+ const now = snapshot(top, state.modules)
366
+ const rec: Recording = { file: join(tmpdir(), `shipkit-reads-${process.pid}-${Date.now()}.tsv`), top }
367
+ const base = { rec, statePath, state, now, started, graph, preloads, cwdRel, allSuites: suites.map(toTop), total: suites.length, notes, changed: 0 }
368
+ if (o.tier !== 'quick') return { ...base, picked: null }
369
+ const pos = positional(o.rawArgs)
370
+ if (pos != null) { notes.push(`note: every suite runs: the test script names ${pos}, which a selection would override`); return { ...base, picked: null } }
371
+ if (state.base == null) { notes.push('note: every suite runs: no run recorded yet, the next run selects'); return { ...base, picked: null } }
372
+ const changed = changedKeys(state.base, now)
373
+ if (state.bun !== Bun.version) { notes.push(`note: every suite runs: bun ${Bun.version} ran no recorded run yet`); return { ...base, picked: null } }
374
+ const roots = [...new Set([...cfg.roots, posix.normalize(posix.join(cwdRel, o.bunfig.root ?? '.')), ...preloads.map((p) => posix.dirname(p))])]
375
+ const sel: Selection = selectSuites({ suites: suites.map(toTop), preloads, envFiles: envFilesOf(o.rawArgs, cwdRel), graph, modules: state.modules, recorded: new Set(state.recorded), dirty: new Set(state.dirty), changed, roots })
376
+ if (sel.all) { notes.push(`note: every suite runs: ${sel.why}`); return { ...base, picked: null, changed: changed.size } }
377
+ const fromTop = (f: string): string => (cwdRel === '' ? f : f.slice(cwdRel.length + 1))
378
+ const picked = sel.run.map(fromTop)
379
+ const reasons = [...new Set(sel.why.values())]
380
+ if (picked.length > 0) notes.push(`note: ${picked.length} of ${suites.length} suites reach what changed since the last run (${changed.size} path(s)), or did not pass then, picked in ${Math.round(performance.now() - t0)}ms: ${reasons.slice(0, 3).join('; ')}${reasons.length > 3 ? `; ${reasons.length - 3} more` : ''}`)
381
+ return { ...base, picked, changed: changed.size }
382
+ } catch (e) {
383
+ return `WARN: suite selection failed, so every suite runs: ${e instanceof Error ? e.message : String(e)}`
384
+ }
385
+ }
386
+
387
+ type TestsConfigLike = { select?: boolean }
388
+
389
+ /** what a run leaves for the next: the reads it recorded, the snapshot it
390
+ * started from, and the suites it picked that did not pass. A full run where
391
+ * every suite ran to its end saw every read, so it replaces the reads
392
+ * instead of adding to them: a quick run sees only the paths its suites took */
393
+ function finishSelection(sel: Selecting, passed: string[], replace: boolean, out: string): string | null {
394
+ try {
395
+ const text = existsSync(sel.rec.file) ? readFileSync(sel.rec.file, 'utf8') : ''
396
+ const modules = dropStaticEdges(foldReads(replace ? {} : sel.state.modules, text), sel.graph)
397
+ const toTop = (f: string): string => (sel.cwdRel === '' ? f : `${sel.cwdRel}/${f}`)
398
+ const ok = new Set(passed.map(toTop))
399
+ const recorded = [...new Set([...(replace ? [] : sel.state.recorded), ...ok])].sort()
400
+ // a suite this run picked and did not see pass stays dirty; with no
401
+ // pick, every suite was picked. One the run did not pick keeps its state
402
+ const picked = sel.picked == null ? sel.allSuites : sel.picked.map(toTop)
403
+ const before = sel.picked == null ? [] : sel.state.dirty.filter((d) => !picked.includes(d))
404
+ // a module that threw while loading loads silently in the next file of
405
+ // a worker that shares its modules, so a suite reaching it is not
406
+ // trusted green, whatever bun said of it
407
+ const thrown = loadErrorModules(out, sel.rec.top)
408
+ const tainted = taintedSuites(picked, thrown, sel.graph, modules, sel.preloads)
409
+ const dirty = [...new Set([...before, ...picked.filter((s) => !ok.has(s) || tainted.has(s))])].sort()
410
+ // a read first recorded by this run joins the snapshot it started from,
411
+ // unless its path moved since the start
412
+ const base = { ...sel.now, ...extras(sel.rec.top, modules, sel.now, sel.started) }
413
+ saveState(sel.statePath, { ...sel.state, base, dirty, modules, recorded, graph: sel.graph, bun: Bun.version })
414
+ return null
415
+ } catch (e) {
416
+ return `WARN: suite selection could not keep this run (${e instanceof Error ? e.message : String(e)}), so the next run selects from the last one`
417
+ }
418
+ }
419
+
238
420
  if (import.meta.main) process.exit(await suiteCli(process.argv.slice(2)))
@@ -0,0 +1,335 @@
1
+ // WHAT EACH MODULE READ, preloaded into every test worker and every bun
2
+ // process a suite spawns. The quick tier runs only the suites whose reads
3
+ // changed since the last green run (select.ts), and a module's static imports
4
+ // are only part of what it reads: a suite also reads files by path, lists
5
+ // dirs, globs and spawns. This preload records each of those, attributed to
6
+ // every module of the repo on the call stack. A memo filled by one suite and
7
+ // used by the next is still charged to the module that holds it, and every
8
+ // suite whose import closure holds that module depends on the read.
9
+ //
10
+ // Lines, appended to the file SHIPKIT_SUITE_READS names: `<kind>\t<module>\t<path>`.
11
+ // r a file read, probed or stat'ed (its content, or its absence, matters)
12
+ // d a dir listed (its names matter)
13
+ // g a tree walked by a glob (every name under it matters)
14
+ // o a process spawned that reads what nobody records (git or a shell run
15
+ // in the repo): the module is opaque, and every suite that reaches it
16
+ // runs every time
17
+ // l a repo module loaded at runtime from a call this module was on the
18
+ // stack of (a require or an import() in a function body)
19
+ // L a repo module loaded while this module itself was being loaded
20
+ // A read with no repo frame on its stack is charged to `-`, which every suite
21
+ // depends on. A bun child records under the module that spawned it.
22
+ //
23
+ // node:fs is replaced through bun:test's mock.module, the one hook that also
24
+ // reaches a named ESM import, and it updates modules imported before it.
25
+ import { mock, afterAll } from 'bun:test'
26
+ import * as fsLive from 'node:fs'
27
+ import * as fspLive from 'node:fs/promises'
28
+ import * as cpLive from 'node:child_process'
29
+ import { appendFileSync, realpathSync } from 'node:fs'
30
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path'
31
+ import { fileURLToPath } from 'node:url'
32
+ import { SUITE_OWNER_ENV, SUITE_READS_ENV, SUITE_ROOT_ENV } from './reads-env.ts'
33
+
34
+ const out = process.env[SUITE_READS_ENV]
35
+ const root = process.env[SUITE_ROOT_ENV]
36
+ const owner = process.env[SUITE_OWNER_ENV]
37
+ const SELF = import.meta.path
38
+
39
+ if (out != null && out !== '' && root != null && root !== '') install(out, resolve(root))
40
+
41
+ function install(outFile: string, rootDir: string): void {
42
+ const realFs = { ...fsLive } as Record<string, unknown>
43
+ const realFsp = { ...fspLive } as Record<string, unknown>
44
+ const realCp = { ...cpLive } as Record<string, unknown>
45
+ // the root as git prints it is a realpath, and a path a module names can
46
+ // go through a symlink to it (macOS: /var is /private/var), so each
47
+ // spelling of the root counts
48
+ const real = ((): string => { try { return realpathSync(rootDir) } catch { return rootDir } })()
49
+ const roots = [...new Set([rootDir, real, ...[rootDir, real].filter((r) => r.startsWith('/private/')).map((r) => r.slice('/private'.length))])]
50
+ const seen = new Set<string>()
51
+ let buf: string[] = []
52
+
53
+ /** repo-relative posix path, null outside the repo, in an install, or in git's own dir */
54
+ const rel = (p: unknown): string | null => {
55
+ let s: string
56
+ if (typeof p === 'string') s = p
57
+ else if (p instanceof URL) { if (p.protocol !== 'file:') return null; s = fileURLToPath(p) }
58
+ else if (p instanceof Uint8Array) s = Buffer.from(p).toString()
59
+ else return null
60
+ if (s.startsWith('file:')) { try { s = fileURLToPath(s) } catch { return null } }
61
+ const abs = isAbsolute(s) ? resolve(s) : resolve(process.cwd(), s)
62
+ const base = roots.find((r) => abs === r || abs.startsWith(r.endsWith(sep) ? r : `${r}${sep}`))
63
+ if (base == null) return null
64
+ const r = relative(base, abs).split(sep).join('/')
65
+ if (r === '' ) return '.'
66
+ if (r === 'node_modules' || r.startsWith('node_modules/') || r.includes('/node_modules/')) return null
67
+ return r
68
+ }
69
+ /** the innermost repo module on the stack, outside this file */
70
+ // a read is charged to EVERY repo module on the stack: the one that read
71
+ // it, the ones that hold a memo of what it read, and the one that asked. A
72
+ // cache a preload installs is asked through the module that holds its
73
+ // hook, and every suite reusing the memo calls that module again
74
+ const callers = (): string[] => {
75
+ if (owner != null && owner !== '') return [owner]
76
+ const limit = Error.stackTraceLimit
77
+ Error.stackTraceLimit = 80
78
+ const stack = new Error().stack ?? ''
79
+ Error.stackTraceLimit = limit
80
+ const out: string[] = []
81
+ for (const line of stack.split('\n')) {
82
+ const m = /\(?((?:\/|[A-Za-z]:\\)[^():]+):\d+:\d+\)?\s*$/.exec(line)
83
+ const file = m?.[1]
84
+ if (file == null || file === SELF) continue
85
+ const r = rel(file)
86
+ if (r != null && r !== '.' && !out.includes(r)) out.push(r)
87
+ }
88
+ return out.length > 0 ? out : ['-']
89
+ }
90
+ /** the module a child records under: the innermost one that spawned it */
91
+ const caller = (): string => callers()[0]!
92
+ /** a read through a symlink reads its target too */
93
+ const target = (path: string): string | null => {
94
+ try {
95
+ const real = rel((realFs['realpathSync'] as (p: string) => string)(join(rootDir, path)))
96
+ return real != null && real !== path ? real : null
97
+ } catch {
98
+ return null
99
+ }
100
+ }
101
+ const note = (kind: string, path: string | null, mods: string | string[] = callers()): void => {
102
+ if (path == null) return
103
+ if (kind !== 'o') {
104
+ const t = target(path)
105
+ if (t != null) note(kind, t, mods)
106
+ }
107
+ for (const mod of typeof mods === 'string' ? [mods] : mods) {
108
+ const line = `${kind}\t${mod}\t${path}`
109
+ if (seen.has(line)) continue
110
+ seen.add(line)
111
+ buf.push(line)
112
+ }
113
+ }
114
+ const flush = (): void => {
115
+ if (buf.length === 0) return
116
+ try { appendFileSync(outFile, `${buf.join('\n')}\n`) } catch { /* no record: the next full run fills it */ }
117
+ buf = []
118
+ }
119
+
120
+ const READS = ['readFileSync', 'readFile', 'existsSync', 'exists', 'statSync', 'stat', 'lstatSync', 'lstat', 'accessSync', 'access', 'openSync', 'open', 'createReadStream', 'readlinkSync', 'readlink', 'realpathSync', 'realpath', 'opendirSync', 'opendir', 'copyFileSync', 'copyFile', 'watchFile', 'statfsSync', 'statfs']
121
+ // a copy or a watch of a dir takes the whole tree under it
122
+ const TREES = ['cpSync', 'cp', 'watch']
123
+ const LISTS = ['readdirSync', 'readdir']
124
+ const GLOBS = ['globSync', 'glob']
125
+ const wrap = (real: Record<string, unknown>, kind: string, names: string[]): Record<string, unknown> => {
126
+ const shim: Record<string, unknown> = {}
127
+ for (const n of names) {
128
+ const f = real[n]
129
+ if (typeof f !== 'function') continue
130
+ const g = function (this: unknown, ...a: unknown[]): unknown {
131
+ if (kind === 'g') note('g', globRoot(a[0], a[1]))
132
+ else if (kind === 't') {
133
+ const r = rel(a[0])
134
+ note(r != null && exists(join(rootDir, r), false) ? 'r' : 'g', r)
135
+ }
136
+ else {
137
+ const r = rel(a[0])
138
+ // a recursive listing is a walk of the whole tree
139
+ const recursive = kind === 'd' && typeof a[1] === 'object' && a[1] != null && (a[1] as { recursive?: unknown }).recursive === true
140
+ note(recursive ? 'g' : n.startsWith('opendir') ? 'd' : kind, r)
141
+ }
142
+ return (f as (...x: unknown[]) => unknown).apply(this, a)
143
+ }
144
+ Object.assign(g, f)
145
+ shim[n] = g
146
+ }
147
+ return shim
148
+ }
149
+ const globRoot = (pattern: unknown, opts: unknown): string | null => {
150
+ const pats = Array.isArray(pattern) ? pattern : [pattern]
151
+ const cwd = typeof opts === 'object' && opts != null && typeof (opts as { cwd?: unknown }).cwd === 'string' ? (opts as { cwd: string }).cwd : process.cwd()
152
+ const roots = pats.filter((p): p is string => typeof p === 'string').map((p) => {
153
+ const lit = p.split('/').filter((_, i, a) => !/[*?[{]/.test(a.slice(0, i + 1).join('/'))).join('/')
154
+ return rel(join(cwd, lit))
155
+ })
156
+ return roots.length === 1 ? roots[0]! : rel(cwd)
157
+ }
158
+ const fsShim: Record<string, unknown> = { ...realFs, ...wrap(realFs, 'r', READS), ...wrap(realFs, 'd', LISTS), ...wrap(realFs, 'g', GLOBS), ...wrap(realFs, 't', TREES) }
159
+ const fspShim: Record<string, unknown> = { ...realFsp, ...wrap(realFsp, 'r', READS), ...wrap(realFsp, 'd', LISTS), ...wrap(realFsp, 'g', GLOBS), ...wrap(realFsp, 't', TREES) }
160
+ fsShim['promises'] = fspShim
161
+ mock.module('node:fs', () => ({ ...fsShim, default: fsShim }))
162
+ mock.module('fs', () => ({ ...fsShim, default: fsShim }))
163
+ mock.module('node:fs/promises', () => ({ ...fspShim, default: fspShim }))
164
+ mock.module('fs/promises', () => ({ ...fspShim, default: fspShim }))
165
+
166
+ // a bun child runs this same preload and records under the spawning module.
167
+ // Any other child reads what nobody records
168
+ /** a path that stands there (a file, or a dir when `dir`) */
169
+ const exists = (abs: string, dir: boolean): boolean => {
170
+ try {
171
+ const st = (realFs['statSync'] as (p: string) => { isFile(): boolean; isDirectory(): boolean })(abs)
172
+ return st.isFile() || (dir && st.isDirectory())
173
+ } catch { return false }
174
+ }
175
+ const SHELLS = new Set(['sh', 'bash', 'zsh', 'dash', 'fish', 'env', 'xargs', 'nice', 'time', 'timeout'])
176
+ // tools that read only the files their arguments name, nothing they find through them
177
+ const FILE_TOOLS = new Set(['true', 'false', 'echo', 'sleep', 'kill', 'rm', 'mkdir', 'touch', 'cwebp', 'dwebp'])
178
+ const isBun = (bin: unknown): boolean => typeof bin === 'string' && (bin === 'bun' || bin === process.execPath || /(^|\/)bun$/.test(bin))
179
+ const childEnv = (env: unknown): Record<string, string | undefined> => {
180
+ const base = (typeof env === 'object' && env != null ? env : process.env) as Record<string, string | undefined>
181
+ const opts = base['BUN_OPTIONS'] ?? ''
182
+ return {
183
+ ...base,
184
+ [SUITE_READS_ENV]: outFile,
185
+ [SUITE_ROOT_ENV]: rootDir,
186
+ [SUITE_OWNER_ENV]: caller(),
187
+ BUN_OPTIONS: opts.includes(SELF) ? opts : `${opts} --preload=${SELF}`.trim(),
188
+ }
189
+ }
190
+ // a child that is not bun reads what nobody records. It is trusted only
191
+ // when it runs outside the repo and names nothing in it (git in a temp
192
+ // repo); a tool known to read only the files it names (cwebp) records
193
+ // them; any other child in or about the repo makes its module opaque
194
+ const spawned = (cmd: unknown, cwdOpt: unknown): void => {
195
+ const argv = (Array.isArray(cmd) ? cmd : typeof cmd === 'string' ? [cmd] : []).map(String)
196
+ if (isBun(argv[0])) return
197
+ const tool = (argv[0] ?? '?').split('/').pop() ?? '?'
198
+ const cwd = typeof cwdOpt === 'string' ? resolve(cwdOpt) : cwdOpt instanceof URL ? fileURLToPath(cwdOpt) : process.cwd()
199
+ const shell = SHELLS.has(tool)
200
+ const words = argv.slice(1).flatMap((a) => (shell ? a.split(/[\s;&|<>()'"`=]+/) : [a.replace(/^--?[\w-]+=/, '')])).filter((w) => w !== '' && !w.startsWith('-'))
201
+ const inRepo: string[] = []
202
+ for (const w of words) {
203
+ const abs = isAbsolute(w) ? w : join(cwd, w)
204
+ if (!isAbsolute(w) && !w.includes('/') && !exists(abs, true)) continue
205
+ const r = rel(abs)
206
+ if (r != null) inRepo.push(r)
207
+ }
208
+ // a tool known to read only the files it names records them; any other
209
+ // tool is trusted only far from the repo: run outside it, naming nothing in it
210
+ if (!shell && FILE_TOOLS.has(tool)) {
211
+ for (const r of inRepo) note(exists(join(rootDir, r), false) ? 'r' : 'g', r)
212
+ return
213
+ }
214
+ if (rel(cwd) != null || inRepo.length > 0) note('o', tool)
215
+ }
216
+ const realSpawn = Bun.spawn
217
+ const realSpawnSync = Bun.spawnSync
218
+ const viaBun = (real: typeof Bun.spawn | typeof Bun.spawnSync) => function (this: unknown, ...a: unknown[]): unknown {
219
+ const first = a[0]
220
+ const cmd = Array.isArray(first) ? first : (first as { cmd?: unknown } | undefined)?.cmd
221
+ spawned(cmd, ((Array.isArray(first) ? a[1] : first) as { cwd?: unknown } | undefined)?.cwd)
222
+ if (Array.isArray(first)) {
223
+ const opts = (a[1] ?? {}) as { env?: unknown }
224
+ return (real as (...x: unknown[]) => unknown).call(this, first, { ...opts, env: childEnv(opts.env) })
225
+ }
226
+ const opts = (first ?? {}) as { env?: unknown }
227
+ return (real as (...x: unknown[]) => unknown).call(this, { ...opts, env: childEnv(opts.env) })
228
+ }
229
+ Bun.spawn = viaBun(realSpawn) as typeof Bun.spawn
230
+ Bun.spawnSync = viaBun(realSpawnSync) as typeof Bun.spawnSync
231
+ const cpWrap = (n: string, cmdAt: (a: unknown[]) => unknown, optsAt: number | ((a: unknown[]) => number)): unknown => {
232
+ const f = realCp[n]
233
+ if (typeof f !== 'function') return f
234
+ return function (this: unknown, ...a: unknown[]): unknown {
235
+ const cmd = cmdAt(a)
236
+ const i = typeof optsAt === 'number' ? optsAt : optsAt(a)
237
+ const opts = (typeof a[i] === 'object' && a[i] != null && !Array.isArray(a[i]) ? a[i] : {}) as { env?: unknown; cwd?: unknown }
238
+ if (n === 'exec' || n === 'execSync') spawned(['sh', '-c', cmd], opts.cwd)
239
+ else spawned([cmd, ...(Array.isArray(a[1]) ? a[1] : [])], opts.cwd)
240
+ const args = [...a]
241
+ while (args.length < i) args.push(undefined)
242
+ args[i] = { ...opts, env: childEnv(opts.env) }
243
+ return (f as (...x: unknown[]) => unknown).apply(this, args)
244
+ }
245
+ }
246
+ const withArgs = (a: unknown[]): number => (Array.isArray(a[1]) ? 2 : 1)
247
+ const cpShim: Record<string, unknown> = {
248
+ ...realCp,
249
+ spawn: cpWrap('spawn', (a) => a[0], withArgs),
250
+ spawnSync: cpWrap('spawnSync', (a) => a[0], withArgs),
251
+ execFile: cpWrap('execFile', (a) => a[0], withArgs),
252
+ execFileSync: cpWrap('execFileSync', (a) => a[0], withArgs),
253
+ exec: cpWrap('exec', (a) => a[0], 1),
254
+ execSync: cpWrap('execSync', (a) => a[0], 1),
255
+ fork: cpWrap('fork', () => 'bun', withArgs),
256
+ }
257
+ mock.module('node:child_process', () => ({ ...cpShim, default: cpShim }))
258
+ mock.module('child_process', () => ({ ...cpShim, default: cpShim }))
259
+ const realShell = Bun.$
260
+ Bun.$ = new Proxy(realShell, { apply: (t, self, a) => { note('o', 'sh'); return Reflect.apply(t, self, a) } })
261
+
262
+ // a file:// fetch is a read; a Worker runs code no preload of ours sees
263
+ const realFetch = globalThis.fetch
264
+ globalThis.fetch = Object.assign(function (this: unknown, input: unknown, init?: unknown): unknown {
265
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.href : (input as { url?: unknown } | null)?.url
266
+ if (typeof url === 'string' && url.startsWith('file:')) note('r', rel(url))
267
+ return (realFetch as (...x: unknown[]) => unknown).call(this, input, init)
268
+ }, realFetch) as typeof fetch
269
+ for (const name of ['Worker', 'SharedWorker']) {
270
+ const Real = (globalThis as Record<string, unknown>)[name] as (new (...a: unknown[]) => object) | undefined
271
+ if (typeof Real !== 'function') continue
272
+ const Wrapped = function (this: unknown, ...a: unknown[]): object {
273
+ note('o', name.toLowerCase())
274
+ return new Real(...a)
275
+ } as unknown as typeof Real
276
+ Wrapped.prototype = Real.prototype
277
+ ;(globalThis as Record<string, unknown>)[name] = Wrapped
278
+ }
279
+
280
+ const realFile = Bun.file
281
+ Bun.file = function (this: unknown, p: unknown, o?: unknown): unknown {
282
+ note('r', rel(p))
283
+ return (realFile as (...x: unknown[]) => unknown).call(this, p, o)
284
+ } as typeof Bun.file
285
+ // a Glob keeps no pattern a caller can read, so each one remembers its own:
286
+ // the walked tree is the cwd joined with the pattern's literal head
287
+ const RealGlob = Bun.Glob
288
+ const patterns = new WeakMap<object, string>()
289
+ const Glob = function (this: unknown, pattern: string): unknown {
290
+ const g = new RealGlob(pattern)
291
+ patterns.set(g, pattern)
292
+ return g
293
+ } as unknown as typeof Bun.Glob
294
+ Glob.prototype = RealGlob.prototype
295
+ Bun.Glob = Glob
296
+ const G = RealGlob.prototype as unknown as Record<string, (...a: unknown[]) => unknown>
297
+ for (const n of ['scan', 'scanSync']) {
298
+ const f = G[n]
299
+ if (typeof f !== 'function') continue
300
+ G[n] = function (this: object, opts?: unknown): unknown {
301
+ const cwd = typeof opts === 'string' ? opts : typeof opts === 'object' && opts != null && typeof (opts as { cwd?: unknown }).cwd === 'string' ? (opts as { cwd: string }).cwd : process.cwd()
302
+ note('g', globRoot(patterns.get(this) ?? '**', { cwd }))
303
+ return f.call(this, opts)
304
+ }
305
+ }
306
+
307
+ // every import resolved at runtime, as an edge from its importer and from
308
+ // every module on the stack: a module a computed import loads is in no
309
+ // static closure, and the edge charges it wherever the load was asked
310
+ // from, whichever suite ran first. A key already seen asks nothing again
311
+ const edges = new Set<string>()
312
+ const imported = (spec: string, importer: string | undefined): void => {
313
+ if (importer == null || importer === '' || spec.startsWith('node:') || spec.startsWith('bun:')) return
314
+ const from = rel(importer)
315
+ if (from == null || from === '.') return
316
+ const key = `${from}\0${spec}\0${new Error().stack ?? ''}`
317
+ if (edges.has(key)) return
318
+ edges.add(key)
319
+ const bare = spec.replace(/\?.*$/, '')
320
+ const base = isAbsolute(bare) ? bare : bare.startsWith('.') ? join(importer, '..', bare) : join(rootDir, bare)
321
+ const abs = [base, `${base}.ts`, `${base}.tsx`, join(base, 'index.ts')].find((x) => exists(x, false))
322
+ if (abs == null) return
323
+ const to = rel(abs)
324
+ // charged like a read: a lazy load inside a hook a preload installed
325
+ // is reached through the modules that called the hook
326
+ if (to == null || to === from) return
327
+ const asked = callers().filter((c) => c !== from && c !== '-')
328
+ if (asked.length === 0) note('L', to, from)
329
+ else note('l', to, [from, ...asked])
330
+ }
331
+ Bun.plugin({ name: 'shipkit-suite-reads', setup(b) { b.onResolve({ filter: /.*/ }, (a) => { imported(a.path, a.importer); return undefined }) } })
332
+
333
+ try { afterAll(flush) } catch { /* not under bun test: the exit hook below records it */ }
334
+ process.on('exit', flush)
335
+ }