rvlib-shipkit 1.7.0 → 1.9.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/package.json +1 -1
  3. package/src/assets/gen/comfyts-gen.ts +31 -14
  4. package/src/assets/gen/generate-roll.ts +20 -7
  5. package/src/assets/kit-config.ts +9 -0
  6. package/src/assets/kits/identity-kit.ts +7 -1
  7. package/src/assets/run-assets.ts +3 -3
  8. package/src/assets/server/studio-server.ts +3 -3
  9. package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
  10. package/src/checks/SK033-global-contribution.check.ts +47 -0
  11. package/src/cli/cmd_Ci.ts +5 -4
  12. package/src/cli/cmd_GlobalContext.ts +47 -0
  13. package/src/cli/cmd_GlobalProcesses.ts +38 -1
  14. package/src/cli.ts +4 -1
  15. package/src/collections/providers.ts +1 -1
  16. package/src/core/check-runner.ts +13 -4
  17. package/src/core/discover-checks.ts +37 -8
  18. package/src/core/fix-scope.ts +5 -0
  19. package/src/core/floor-resolve.ts +2 -1
  20. package/src/core/git-root.ts +22 -12
  21. package/src/core/global-contribution.ts +42 -0
  22. package/src/core/one-lib.ts +4 -2
  23. package/src/core/run-ci.ts +12 -11
  24. package/src/core/slots.ts +36 -6
  25. package/src/home/global-context.ts +145 -0
  26. package/src/home/gui-app/ShipkitGui.swift +96 -0
  27. package/src/home/home-routes.ts +10 -0
  28. package/src/home/resources.ts +3 -0
  29. package/src/plan/planner.ts +21 -4
  30. package/src/plan/repo-source.ts +7 -3
  31. package/src/release/run-release.ts +17 -8
  32. package/src/release/unit-gate.ts +12 -5
  33. package/src/repo-config.ts +7 -0
  34. package/src/serve/hub-runtime.ts +34 -23
  35. package/src/serve/project-gutter.ts +5 -2
  36. package/src/serve/run-lanes.ts +138 -0
  37. package/src/smoke/run-smoke.ts +13 -6
  38. package/src/suite/judge-suites.ts +79 -11
  39. package/src/suite/reads-env.ts +8 -0
  40. package/src/suite/select.ts +510 -0
  41. package/src/suite/suite-cli.ts +213 -23
  42. package/src/suite/suite-reads-preload.ts +335 -0
  43. package/src/ts/typecheck-cache.ts +11 -0
  44. package/src/ts/typecheck-cli.ts +2 -1
@@ -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, interruptedSuites, overWords, ranAs, retryAlone, smallerRun, stallWords, 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,14 +184,19 @@ 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)
139
195
  const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
140
196
  const timings = Object.assign({}, ...phases.map((p) => p.timings)) as Record<string, number>
141
- const judged = judgeSuites({ timings, tests, tier })
197
+ const crashed = crashedWorkers(p1.out)
198
+ const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashed.map((c) => c.file)])
199
+ const judged = judgeSuites({ timings, tests, tier, red: [...red] })
142
200
  const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
143
201
 
144
202
  // a parallel timing carries every other suite's load, so a suspect
@@ -149,10 +207,20 @@ export async function suiteCli(argv: string[]): Promise<number> {
149
207
  const parallelCpu = phaseCpu.cpu
150
208
  const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
151
209
  const aloneAt = Date.now()
152
- for (const [i, t] of rerun.entries()) {
153
- const run = await runPhase([...rawArgs, t.file], join(dir, `alone-${i}.json`), ceilingMs, tmp, false)
210
+ const runAlone = async (t: { file: string }, name: string): Promise<AloneRun | null> => {
211
+ const run = await runPhase([...plan.alone, t.file], join(dir, `${name}.json`), ceilingMs, tmp, false, null, rec)
154
212
  const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
155
- if (wallMs != null) aloneRuns[t.file] = { wallMs, cpuMs: run.cpuMs }
213
+ return wallMs == null ? null : { wallMs, cpuMs: run.cpuMs }
214
+ }
215
+ for (const [i, t] of rerun.entries()) {
216
+ const first = await runAlone(t, `alone-${i}`)
217
+ if (first == null) continue
218
+ aloneRuns[t.file] = first
219
+ if (!retryAlone(t, first, budget, load)) continue
220
+ const second = await runAlone(t, `alone-${i}-again`)
221
+ if (second == null) continue
222
+ aloneRuns[t.file] = smallerRun(first, second)
223
+ notes.push(`note: ${t.file} ran alone twice, ${secs(first.cpuMs ?? first.wallMs)} then ${secs(second.cpuMs ?? second.wallMs)} of CPU, and the smaller stands: a lone run the scheduler spills onto slower cores burns more CPU for the same work`)
156
224
  }
157
225
  const aloneMs = Date.now() - aloneAt
158
226
  const { over, cleared } = rerun.length > 0 ? confirmOver(rerun, aloneRuns, budget, load) : { over: [], cleared: [] }
@@ -162,26 +230,26 @@ export async function suiteCli(argv: string[]): Promise<number> {
162
230
  // a ceiling kill makes bun report every worker it held as crashed, and
163
231
  // those are the suites that never finished, not an outside kill
164
232
  const stalledIn = (name: string, p: Phase | null, files: string[]): string | null =>
165
- p?.stalled === true
166
- ? `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test worker stopped answering, most likely stuck in a synchronous call, which no per test timeout can interrupt`
167
- : null
168
- const crashed = crashedWorkers(p1.out)
233
+ p?.stalled === true ? stallWords(name, ceilingMs, [...new Set([...files, ...interruptedSuites(p.out)])]) : null
169
234
  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))]
235
+ 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).map(normSuitePath))]
171
236
  for (const line of stalls) if (line != null) killed.unshift(line)
172
237
  if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
173
238
  if (p2?.signal != null) {
174
- const unfinished = phaseFiles(plan.serial ?? [], rawArgs).filter((a) => p2.timings[normSuitePath(a)] == null)
239
+ const unfinished = phaseFiles(serialArgs ?? [], rawArgs).filter((a) => p2.timings[normSuitePath(a)] == null)
175
240
  killed.push(`killed by ${p2.signal} while running ${unfinished.join(' or ')} (out of memory, or killed from outside)`)
176
241
  }
177
242
  // the record behind the hub's test files to fix: every suite this run
178
243
  // timed, with the alone timing where one was taken, and red when a
179
244
  // test in it failed or its worker died
180
- const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashedWorkers(p1.out).map((c) => c.file)])
245
+ const overSet = new Set(over.map((o) => o.file))
246
+ ctx.complete = killed.length === 0
247
+ ctx.out = phases.map((p) => p.out).join('\n')
248
+ ctx.ran = Object.keys(timings).map(normSuitePath).filter((f) => !red.has(f) && !overSet.has(f))
181
249
  const at = Date.now()
182
250
  const alone = new Map([...over, ...cleared, ...clearedInPhase].map((t) => [t.file, t.ms]))
183
251
  const why = failLines(phases.map((p) => p.out).join('\n'))
184
- for (const c of crashedWorkers(p1.out)) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
252
+ for (const c of crashed) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
185
253
  // which phase timed each file, and the CPU spent on it: an alone
186
254
  // rerun, the parallel phase's own figure, or a phase that ran that one file
187
255
  const phaseOf = (f: string): SuitePhase => (p2 != null && Object.keys(p2.timings).some((k) => normSuitePath(k) === f) ? 'serial' : parallel ? 'parallel' : 'sequential')
@@ -227,12 +295,134 @@ export async function suiteCli(argv: string[]): Promise<number> {
227
295
  // the most actionable failure prints LAST, where the gate reads its headline
228
296
  for (const f of fails.slice(1).reverse()) console.log(f)
229
297
  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}`)
298
+ const serialCount = serialArgs != null ? phaseFiles(serialArgs, rawArgs).length : 0
299
+ const chosen = ctx.sel?.picked != null ? `, the ${ctx.sel.picked.length} of ${ctx.sel.total} a change reaches` : ''
300
+ 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, ${ranAs(plan.parallel, rawArgs)}${slowest}`)
232
301
  return 0
233
302
  } finally {
234
303
  rmSync(dir, { recursive: true, force: true })
235
304
  }
236
305
  }
237
306
 
307
+ /** a selection in flight: the recorder's file, the state it started from,
308
+ * the snapshot taken before the run, and the suites it picked (cwd
309
+ * relative, null = every suite) */
310
+ type Selecting = {
311
+ rec: Recording
312
+ statePath: string
313
+ state: SelectState
314
+ now: Record<string, string>
315
+ graph: Record<string, GraphEntry>
316
+ preloads: string[]
317
+ cwdRel: string
318
+ picked: string[] | null
319
+ /** when the snapshot was taken */
320
+ started: number
321
+ /** every suite of the run, repo-relative */
322
+ allSuites: string[]
323
+ total: number
324
+ changed: number
325
+ notes: string[]
326
+ }
327
+
328
+ /** a bun test argument that is neither a flag nor a flag's value: a path or
329
+ * a filter the repo's own script names, which a selection would override */
330
+ function positional(rawArgs: string[]): string | null {
331
+ for (let i = 0; i < rawArgs.length; i++) {
332
+ const a = rawArgs[i]!
333
+ if (a.startsWith('-')) {
334
+ if (!a.includes('=') && VALUE_FLAGS.includes(a)) i++
335
+ continue
336
+ }
337
+ return a
338
+ }
339
+ return null
340
+ }
341
+ 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']
342
+
343
+ /** the recorder's file and, in the quick tier, the suites a change since the
344
+ * last green run reaches. A string says why nothing is recorded */
345
+ function prepareSelection(o: { tests: TestsConfigLike; tier: 'quick' | 'full'; rawArgs: string[]; bunfig: BunfigTest; unit: string; plan: { parallel: string[] } }): Selecting | string {
346
+ const env = process.env[SUITE_SELECT_ENV]
347
+ if (env === '0') return `skip: suite selection (${SUITE_SELECT_ENV}=0): every suite runs`
348
+ 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`
349
+ // a run that filters tests by name passes suites it ran only in part: it can neither pick nor clear one
350
+ const filter = o.rawArgs.find((a) => /^(-t|--test-name-pattern|--only|--todo)(=|$)/.test(a))
351
+ if (filter != null) return `skip: suite selection (the run filters tests with ${filter}): every suite runs, and this run records nothing`
352
+ const g = gitRoot(process.cwd())
353
+ if (g == null) return 'skip: suite selection (not in a git work tree): every suite runs'
354
+ const top = g.top
355
+ const cwdRel = g.prefix.replace(/\/$/, '')
356
+ const notes: string[] = []
357
+ const t0 = performance.now()
358
+ try {
359
+ const statePath = selectStatePath(top, o.unit)
360
+ const state = loadState(statePath)
361
+ const cfg = loadConfig(top)
362
+ const ignores = [
363
+ ...o.bunfig.ignores,
364
+ ...o.plan.parallel.filter((a) => a.startsWith('--path-ignore-patterns=')).map((a) => a.slice('--path-ignore-patterns='.length)),
365
+ ]
366
+ const suites = suiteFiles(top, cwdRel, o.bunfig.root, ignores, isBunSuite)
367
+ const toTop = (f: string): string => (cwdRel === '' ? f : `${cwdRel}/${f}`)
368
+ const bunfigText = existsSync('bunfig.toml') ? readFileSync('bunfig.toml', 'utf8') : null
369
+ const preloads = preloadsOf(bunfigText, o.rawArgs, cwdRel)
370
+ const loads = Object.values(state.modules).flatMap((m) => m.l ?? [])
371
+ const graph = walkGraph(cfg, [...suites.map(toTop), ...preloads, ...loads], state.graph)
372
+ const started = Date.now()
373
+ const now = snapshot(top, state.modules)
374
+ const rec: Recording = { file: join(tmpdir(), `shipkit-reads-${process.pid}-${Date.now()}.tsv`), top }
375
+ const base = { rec, statePath, state, now, started, graph, preloads, cwdRel, allSuites: suites.map(toTop), total: suites.length, notes, changed: 0 }
376
+ if (o.tier !== 'quick') return { ...base, picked: null }
377
+ const pos = positional(o.rawArgs)
378
+ if (pos != null) { notes.push(`note: every suite runs: the test script names ${pos}, which a selection would override`); return { ...base, picked: null } }
379
+ if (state.base == null) { notes.push('note: every suite runs: no run recorded yet, the next run selects'); return { ...base, picked: null } }
380
+ const changed = changedKeys(state.base, now)
381
+ if (state.bun !== Bun.version) { notes.push(`note: every suite runs: bun ${Bun.version} ran no recorded run yet`); return { ...base, picked: null } }
382
+ const roots = [...new Set([...cfg.roots, posix.normalize(posix.join(cwdRel, o.bunfig.root ?? '.')), ...preloads.map((p) => posix.dirname(p))])]
383
+ 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 })
384
+ if (sel.all) { notes.push(`note: every suite runs: ${sel.why}`); return { ...base, picked: null, changed: changed.size } }
385
+ const fromTop = (f: string): string => (cwdRel === '' ? f : f.slice(cwdRel.length + 1))
386
+ const picked = sel.run.map(fromTop)
387
+ const reasons = [...new Set(sel.why.values())]
388
+ 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` : ''}`)
389
+ return { ...base, picked, changed: changed.size }
390
+ } catch (e) {
391
+ return `WARN: suite selection failed, so every suite runs: ${e instanceof Error ? e.message : String(e)}`
392
+ }
393
+ }
394
+
395
+ type TestsConfigLike = { select?: boolean }
396
+
397
+ /** what a run leaves for the next: the reads it recorded, the snapshot it
398
+ * started from, and the suites it picked that did not pass. A full run where
399
+ * every suite ran to its end saw every read, so it replaces the reads
400
+ * instead of adding to them: a quick run sees only the paths its suites took */
401
+ function finishSelection(sel: Selecting, passed: string[], replace: boolean, out: string): string | null {
402
+ try {
403
+ const text = existsSync(sel.rec.file) ? readFileSync(sel.rec.file, 'utf8') : ''
404
+ const modules = dropStaticEdges(foldReads(replace ? {} : sel.state.modules, text), sel.graph)
405
+ const toTop = (f: string): string => (sel.cwdRel === '' ? f : `${sel.cwdRel}/${f}`)
406
+ const ok = new Set(passed.map(toTop))
407
+ const recorded = [...new Set([...(replace ? [] : sel.state.recorded), ...ok])].sort()
408
+ // a suite this run picked and did not see pass stays dirty; with no
409
+ // pick, every suite was picked. One the run did not pick keeps its state
410
+ const picked = sel.picked == null ? sel.allSuites : sel.picked.map(toTop)
411
+ const before = sel.picked == null ? [] : sel.state.dirty.filter((d) => !picked.includes(d))
412
+ // a module that threw while loading loads silently in the next file of
413
+ // a worker that shares its modules, so a suite reaching it is not
414
+ // trusted green, whatever bun said of it
415
+ const thrown = loadErrorModules(out, sel.rec.top)
416
+ const tainted = taintedSuites(picked, thrown, sel.graph, modules, sel.preloads)
417
+ const dirty = [...new Set([...before, ...picked.filter((s) => !ok.has(s) || tainted.has(s))])].sort()
418
+ // a read first recorded by this run joins the snapshot it started from,
419
+ // unless its path moved since the start
420
+ const base = { ...sel.now, ...extras(sel.rec.top, modules, sel.now, sel.started) }
421
+ saveState(sel.statePath, { ...sel.state, base, dirty, modules, recorded, graph: sel.graph, bun: Bun.version })
422
+ return null
423
+ } catch (e) {
424
+ 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`
425
+ }
426
+ }
427
+
238
428
  if (import.meta.main) process.exit(await suiteCli(process.argv.slice(2)))