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
@@ -0,0 +1,510 @@
1
+ // THE QUICK TIER RUNS ONLY THE SUITES A CHANGE CAN REACH. A run leaves the
2
+ // snapshot of the work tree it started from (every file's git blob id) and the
3
+ // suites it ran that did not pass, the DIRTY ones. The next quick run diffs the
4
+ // disk against that snapshot, and runs every dirty suite plus every suite that
5
+ // depends on something in the diff:
6
+ //
7
+ // - its static import closure, json and `?raw` text included, and the
8
+ // closure of every preload, which each suite loads first;
9
+ // - what any module of that closure READ at runtime, as the read recorder
10
+ // charged it (suite-reads-preload.ts): a file, a listing, a walked tree;
11
+ // - the modules a module of that closure imported at runtime that no
12
+ // static import names (a computed import).
13
+ //
14
+ // Why that holds: a suite that is not dirty passed on a tree where everything
15
+ // it depends on was as it is in the snapshot, so a diff that reaches none of
16
+ // it cannot change its verdict. A suite red in a shared tree stays dirty and
17
+ // runs every time, while every other suite moves on.
18
+ //
19
+ // A suite also runs when it has never run under the recorder, when its
20
+ // closure holds a module that spawned a process nobody records, and every
21
+ // suite runs when there is no snapshot yet, when a file that configures the
22
+ // run changed (bunfig, package.json, the lockfile, a tsconfig, the env file),
23
+ // or when this file's format moved. When the selection is unsure it runs
24
+ // more, never less. The full tier runs everything and replaces the reads.
25
+ import { createHash } from 'crypto'
26
+ import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, realpathSync, statSync } from 'fs'
27
+ import { dirname, join, posix, relative, resolve, sep } from 'path'
28
+ import { atomicWrite } from '../core/locked-write.ts'
29
+ import { gitBin } from '../core/git-bin.ts'
30
+ import { blobOid, listingHash } from '../core/read-set.ts'
31
+ import { shipkitRoot } from '../ledger/db.ts'
32
+ import { extractSpecs, resolveSpec } from '../ts/source-graph.ts'
33
+ import { type RvtConfig } from '../ts/config.ts'
34
+
35
+ export const SELECT_VERSION = 5
36
+
37
+ /** what the recorder charged to one module, repo-relative: r a file read, d a
38
+ * dir listed, g a tree walked, o a spawn nobody records, l a module loaded at
39
+ * runtime from a call this module was on the stack of, L one loaded while
40
+ * this module itself was being loaded */
41
+ export type ModuleReads = { r?: string[]; d?: string[]; g?: string[]; o?: string[]; l?: string[]; L?: string[] }
42
+
43
+ /** one parsed source: its stat signature and the repo files it imports. An
44
+ * `import.meta.glob` is no edge: bun has none, so no suite that passes ran one */
45
+ export type GraphEntry = { sig: string; deps: string[] }
46
+
47
+ export type SelectState = {
48
+ version: number
49
+ /** path → blob id (or a listing hash) the last run started from, null before one */
50
+ base: Record<string, string> | null
51
+ /** suites the last run picked that did not pass (red, killed, over the budget, or never reached) */
52
+ dirty: string[]
53
+ /** module → what it read in any recorded run since the last full one */
54
+ modules: Record<string, ModuleReads>
55
+ /** suites that ran under the recorder at least once */
56
+ recorded: string[]
57
+ /** the parse of every source the graph walked, by stat signature */
58
+ graph: Record<string, GraphEntry>
59
+ /** the bun that ran the last run: another one runs every suite */
60
+ bun?: string
61
+ }
62
+
63
+ export const emptyState = (): SelectState => ({ version: SELECT_VERSION, base: null, dirty: [], modules: {}, recorded: [], graph: {} })
64
+
65
+ /** where one checkout keeps its selection state: per work tree, since each
66
+ * holds its own files, beside the rest of what the repo remembers */
67
+ export function selectStatePath(top: string, unit: string): string {
68
+ const id = createHash('sha256').update(resolve(top)).digest('hex').slice(0, 12)
69
+ return join(shipkitRoot(top), 'suite-select', id, `${unit === '' ? 'root' : unit}.json`)
70
+ }
71
+
72
+ export function loadState(path: string): SelectState {
73
+ try {
74
+ const s = JSON.parse(readFileSync(path, 'utf8')) as SelectState
75
+ if (s.version !== SELECT_VERSION || typeof s.modules !== 'object' || !Array.isArray(s.recorded) || !Array.isArray(s.dirty)) return emptyState()
76
+ return { ...emptyState(), ...s }
77
+ } catch {
78
+ return emptyState()
79
+ }
80
+ }
81
+
82
+ export function saveState(path: string, s: SelectState): void {
83
+ mkdirSync(dirname(path), { recursive: true })
84
+ atomicWrite(path, JSON.stringify(s))
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // the static graph
89
+
90
+ const CODE = /\.(?:[cm]?[jt]sx?)$/
91
+ const statSig = (abs: string): string | null => {
92
+ try {
93
+ const st = statSync(abs)
94
+ return st.isFile() ? `${st.size}:${st.mtimeMs}:${st.ctimeMs}` : null
95
+ } catch {
96
+ return null
97
+ }
98
+ }
99
+
100
+ /** parse one file, from the manifest when its stat signature held */
101
+ function entryOf(cfg: RvtConfig, file: string, old: GraphEntry | undefined): GraphEntry | null {
102
+ const abs = join(cfg.root, file)
103
+ const sig = statSig(abs)
104
+ if (sig == null) return null
105
+ if (old != null && old.sig === sig) return old
106
+ // a symlink loads its target
107
+ let link: string[] = []
108
+ try {
109
+ if (lstatSync(abs).isSymbolicLink()) {
110
+ const t = relative(cfg.root, realpathSync(abs)).split(sep).join('/')
111
+ if (!t.startsWith('..') && t !== file) link = [t]
112
+ }
113
+ } catch { /* gone between the stat and here: no edge */ }
114
+ if (!CODE.test(file)) return { sig, deps: link }
115
+ let text: string
116
+ try { text = readFileSync(abs, 'utf8') } catch { return null }
117
+ const has = (p: string): boolean => statSig(join(cfg.root, p)) != null
118
+ const deps = new Set<string>()
119
+ // only what LOADING the module runs: a require or an import() in a body
120
+ // runs when called, and the recorder sees it then, with who called
121
+ for (const raw of extractSpecs(text, { dynamic: false })) {
122
+ const spec = raw.replace(/\?.*$/, '')
123
+ const target = resolveSpec(cfg, file, spec, has)
124
+ if (target != null && target !== file) deps.add(target)
125
+ }
126
+ return { sig, deps: [...new Set([...deps, ...link])].sort() }
127
+ }
128
+
129
+ /** every file the entries reach by import, parsed through the manifest. The
130
+ * returned map is the new manifest: only what this walk reached */
131
+ export function walkGraph(cfg: RvtConfig, entries: string[], manifest: Record<string, GraphEntry>): Record<string, GraphEntry> {
132
+ const out: Record<string, GraphEntry> = {}
133
+ const todo = [...entries]
134
+ while (todo.length > 0) {
135
+ const f = todo.pop()!
136
+ if (out[f] != null) continue
137
+ const e = entryOf(cfg, f, manifest[f])
138
+ if (e == null) continue
139
+ out[f] = e
140
+ for (const d of e.deps) if (out[d] == null) todo.push(d)
141
+ }
142
+ return out
143
+ }
144
+
145
+ export function closureOf(graph: Record<string, GraphEntry>, entries: Iterable<string>): Set<string> {
146
+ const seen = new Set<string>()
147
+ const todo = [...entries]
148
+ while (todo.length > 0) {
149
+ const f = todo.pop()!
150
+ if (seen.has(f)) continue
151
+ seen.add(f)
152
+ for (const d of graph[f]?.deps ?? []) if (!seen.has(d)) todo.push(d)
153
+ }
154
+ return seen
155
+ }
156
+
157
+ // ---------------------------------------------------------------------------
158
+ // the snapshot
159
+
160
+ /** keys of a listing and of a walked tree, beside the file paths */
161
+ export const listKey = (dir: string): string => `${dir === '.' ? '' : dir}/`
162
+ export const treeKey = (dir: string): string => `${dir === '.' ? '' : dir}/**`
163
+
164
+ function git(top: string, args: string[]): string {
165
+ const p = Bun.spawnSync([gitBin(), '--no-optional-locks', ...args], { cwd: top, stdout: 'pipe', stderr: 'pipe' })
166
+ if (p.exitCode !== 0) throw new Error(`git ${args.join(' ')} exited ${p.exitCode}: ${p.stderr.toString().trim().split('\n')[0]}`)
167
+ return p.stdout.toString()
168
+ }
169
+
170
+ const listing = (abs: string, deep: boolean): string | null => {
171
+ const names: string[] = []
172
+ const walk = (dir: string, pre: string): boolean => {
173
+ let entries
174
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return false }
175
+ for (const e of entries) {
176
+ if (e.name === 'node_modules' || e.name === '.git') continue
177
+ names.push(`${pre}${e.name}${e.isDirectory() ? '/' : ''}`)
178
+ if (deep && e.isDirectory()) walk(join(dir, e.name), `${pre}${e.name}/`)
179
+ }
180
+ return true
181
+ }
182
+ if (!walk(abs, '')) return null
183
+ return listingHash(names.sort())
184
+ }
185
+
186
+ /** the work tree as blob ids: what git tracks or would add (one ls-files and
187
+ * one status, no index write), every recorded read git does not see, and a
188
+ * hash of every recorded listing and walked tree */
189
+ export function snapshot(top: string, modules: Record<string, ModuleReads>): Record<string, string> {
190
+ const snap: Record<string, string> = {}
191
+ for (const line of git(top, ['ls-files', '-s', '-z']).split('\0')) {
192
+ const tab = line.indexOf('\t')
193
+ if (tab < 0) continue
194
+ snap[line.slice(tab + 1)] = line.split(' ')[1]!
195
+ }
196
+ const status = git(top, ['status', '--porcelain=v1', '-z', '-uall', '--no-renames', '--ignore-submodules']).split('\0')
197
+ for (const entry of status) {
198
+ if (entry.length < 4) continue
199
+ const path = entry.slice(3)
200
+ const abs = join(top, path)
201
+ try {
202
+ const st = statSync(abs)
203
+ if (st.isFile()) snap[path] = blobOid(readFileSync(abs))
204
+ else delete snap[path]
205
+ } catch {
206
+ delete snap[path]
207
+ }
208
+ }
209
+ return { ...snap, ...extras(top, modules, snap) }
210
+ }
211
+
212
+ /** the recorded reads git does not see, and every recorded listing and walked
213
+ * tree, keyed beside the file paths. `since`: leave out a key whose path
214
+ * moved after that instant, since what a run read may then be either form */
215
+ export function extras(top: string, modules: Record<string, ModuleReads>, have: Record<string, string>, since?: number): Record<string, string> {
216
+ const out: Record<string, string> = {}
217
+ const moved = (abs: string): boolean => {
218
+ if (since == null) return false
219
+ try {
220
+ const st = statSync(abs)
221
+ return st.mtimeMs > since || st.ctimeMs > since
222
+ } catch {
223
+ return false
224
+ }
225
+ }
226
+ const reads = new Set<string>()
227
+ const lists = new Set<string>()
228
+ const trees = new Set<string>()
229
+ for (const m of Object.values(modules)) {
230
+ for (const r of m.r ?? []) reads.add(r)
231
+ for (const d of m.d ?? []) lists.add(d)
232
+ for (const g of m.g ?? []) trees.add(g)
233
+ }
234
+ for (const r of reads) {
235
+ if (r === '.' || have[r] != null) continue
236
+ const abs = join(top, r)
237
+ if (moved(abs)) continue
238
+ try {
239
+ const st = statSync(abs)
240
+ out[r] = st.isFile() ? blobOid(readFileSync(abs)) : st.isDirectory() ? 'dir' : 'other'
241
+ } catch {
242
+ out[r] = 'absent'
243
+ }
244
+ }
245
+ for (const d of lists) if (have[listKey(d)] == null && !moved(join(top, d))) out[listKey(d)] = listing(join(top, d), false) ?? 'absent'
246
+ for (const g of trees) if (have[treeKey(g)] == null && !moved(join(top, g))) out[treeKey(g)] = listing(join(top, g), true) ?? 'absent'
247
+ return out
248
+ }
249
+
250
+ /** the keys whose value differs, one side missing included */
251
+ export function changedKeys(before: Record<string, string>, now: Record<string, string>): Set<string> {
252
+ const out = new Set<string>()
253
+ for (const [k, v] of Object.entries(now)) if (before[k] !== v) out.add(k)
254
+ for (const k of Object.keys(before)) if (now[k] == null) out.add(k)
255
+ return out
256
+ }
257
+
258
+ // ---------------------------------------------------------------------------
259
+ // the recorder's lines
260
+
261
+ /** the recorder's lines folded into the module reads */
262
+ export function foldReads(base: Record<string, ModuleReads>, text: string): Record<string, ModuleReads> {
263
+ const sets = new Map<string, Map<string, Set<string>>>()
264
+ const at = (mod: string, kind: string): Set<string> => {
265
+ let m = sets.get(mod)
266
+ if (m == null) sets.set(mod, (m = new Map()))
267
+ let s = m.get(kind)
268
+ if (s == null) m.set(kind, (s = new Set()))
269
+ return s
270
+ }
271
+ for (const [mod, reads] of Object.entries(base)) for (const [kind, list] of Object.entries(reads)) for (const p of list as string[]) at(mod, kind).add(p)
272
+ for (const line of text.split('\n')) {
273
+ const [kind, mod, path] = line.split('\t')
274
+ if (kind == null || mod == null || path == null || !['r', 'd', 'g', 'o', 'l', 'L'].includes(kind)) continue
275
+ at(mod, kind).add(path)
276
+ }
277
+ const out: Record<string, ModuleReads> = {}
278
+ for (const [mod, kinds] of [...sets].sort(([a], [b]) => (a < b ? -1 : 1))) {
279
+ const r: ModuleReads = {}
280
+ for (const [kind, s] of kinds) (r as Record<string, string[]>)[kind] = [...s].sort()
281
+ out[mod] = r
282
+ }
283
+ return out
284
+ }
285
+
286
+ /** the runtime import edges a static import already names carry nothing */
287
+ export function dropStaticEdges(modules: Record<string, ModuleReads>, graph: Record<string, GraphEntry>): Record<string, ModuleReads> {
288
+ const out: Record<string, ModuleReads> = {}
289
+ for (const [m, r] of Object.entries(modules)) {
290
+ const known = new Set(graph[m]?.deps ?? [])
291
+ const kept: ModuleReads = { ...r }
292
+ for (const k of ['l', 'L'] as const) {
293
+ const left = (r[k] ?? []).filter((p) => !known.has(p))
294
+ if (left.length > 0) kept[k] = left
295
+ else delete kept[k]
296
+ }
297
+ if (Object.keys(kept).length > 0) out[m] = kept
298
+ }
299
+ return out
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // the selection
304
+
305
+ export type Selection =
306
+ | { all: true; why: string }
307
+ | { all: false; run: string[]; why: Map<string, string>; changed: number }
308
+
309
+ /** files whose change moves every suite: they configure the run itself */
310
+ export const isRunConfig = (path: string, envFiles: string[]): boolean =>
311
+ /(^|\/)(bunfig\.toml|package\.json|bun\.lockb?|tsconfig(\.[^/]*)?\.json|repo\.config\.ts)$/.test(path) || envFiles.includes(path)
312
+
313
+ /** a file bun's test runner runs: `*.test.*`, `*_test.*`, `*.spec.*`, `*_spec.*` in js or ts */
314
+ export const isBunSuite = (path: string): boolean => /[._](test|spec)\.[cm]?[jt]sx?$/.test(path)
315
+
316
+ export function selectSuites(o: {
317
+ suites: string[]
318
+ preloads: string[]
319
+ envFiles: string[]
320
+ graph: Record<string, GraphEntry>
321
+ modules: Record<string, ModuleReads>
322
+ recorded: Set<string>
323
+ dirty?: Set<string>
324
+ changed: Set<string>
325
+ /** the dirs a suite reads code and fixtures from: a change there that no
326
+ * import and no recorded read claims runs every suite */
327
+ roots?: string[]
328
+ }): Selection {
329
+ for (const c of o.changed) if (isRunConfig(c, o.envFiles)) return { all: true, why: `${c} changed, and it configures every suite` }
330
+ // the recorder sees only what it hooks (a preload's own load, a tool it
331
+ // does not know, an API it does not wrap go unseen), so a change where
332
+ // suites live that nothing claims is unsure, and unsure runs everything
333
+ const lists = new Set<string>()
334
+ const trees: string[] = []
335
+ const readSet = new Set<string>()
336
+ for (const r of Object.values(o.modules)) {
337
+ for (const p of r.r ?? []) readSet.add(p)
338
+ for (const p of [...(r.l ?? []), ...(r.L ?? [])]) readSet.add(p)
339
+ for (const d of r.d ?? []) lists.add(d)
340
+ for (const g of r.g ?? []) trees.push(g === '.' ? '' : `${g}/`)
341
+ }
342
+ const claimed = (c: string): boolean =>
343
+ c.endsWith('/') || c.endsWith('/**') || o.graph[c] != null || readSet.has(c) || lists.has(posix.dirname(c)) || trees.some((t) => c.startsWith(t))
344
+ const under = (c: string): boolean => (o.roots ?? []).some((r) => r === '' || r === '.' || c.startsWith(`${r}/`))
345
+ for (const c of o.changed) if (!claimed(c) && under(c)) return { all: true, why: `${c} changed, and neither an import nor a recorded read claims it` }
346
+ // a changed file moves every module that loads it with itself, and one a
347
+ // preload loads moves every suite: the preload ran it before any suite
348
+ const importersOf = (withRuntime: boolean): Map<string, string[]> => {
349
+ const out = new Map<string, string[]>()
350
+ const add = (f: string, d: string): void => { out.set(d, [...(out.get(d) ?? []), f]) }
351
+ for (const [f, e] of Object.entries(o.graph)) for (const d of e.deps) add(f, d)
352
+ for (const [m, r] of Object.entries(o.modules)) {
353
+ for (const d of r.L ?? []) add(m, d)
354
+ if (withRuntime) for (const d of r.l ?? []) add(m, d)
355
+ }
356
+ return out
357
+ }
358
+ const spread = (seeds: Map<string, string>, importers: Map<string, string[]>, fence: Set<string>): Map<string, string> => {
359
+ const why = new Map<string, string>()
360
+ const todo: string[] = []
361
+ for (const [m, w] of seeds) if (!why.has(m)) { why.set(m, w); todo.push(m) }
362
+ while (todo.length > 0) {
363
+ const f = todo.pop()!
364
+ for (const i of importers.get(f) ?? []) {
365
+ if (why.has(i) || fence.has(i)) continue
366
+ why.set(i, why.get(f)!)
367
+ todo.push(i)
368
+ }
369
+ }
370
+ return why
371
+ }
372
+ const changedSeeds = new Map([...o.changed].map((c) => [c, `${c} changed`]))
373
+ const statically = spread(changedSeeds, importersOf(false), new Set())
374
+ const preHit = o.preloads.find((p) => statically.has(p))
375
+ if (preHit != null) return { all: true, why: `${statically.get(preHit)!}, and every suite loads ${preHit} first` }
376
+ // a module whose read changed, and every module that loaded a changed file
377
+ // at runtime, moves its importers, but never through a preload: the
378
+ // recorder charged a read or a lazy load inside a hook a preload installed
379
+ // to every module on the stack, the one that asked included
380
+ // a listing runs its reader when a file in that dir changes too: what lists
381
+ // a dir mostly opens what it found, and an image library or a database
382
+ // opens it where no recorder sees
383
+ const changedDirs = new Set([...o.changed].map((c) => posix.dirname(c)))
384
+ const reads = new Map<string, string>()
385
+ for (const [m, r] of Object.entries(o.modules)) {
386
+ if ((r.o ?? []).length > 0) { reads.set(m, `${m} spawns ${r.o![0]}, whose reads nobody records`); continue }
387
+ const read = (r.r ?? []).find((p) => o.changed.has(p))
388
+ ?? (r.d ?? []).find((d) => o.changed.has(listKey(d)) || changedDirs.has(d))
389
+ ?? (r.g ?? []).find((g) => o.changed.has(treeKey(g)) || [...o.changed].some((c) => g === '.' || c.startsWith(`${g}/`)))
390
+ if (read != null) reads.set(m, `${m} reads ${read}, which changed`)
391
+ }
392
+ if (reads.has('-')) return { all: true, why: reads.get('-')!.replace(/^- reads/, 'a read no module owns:') }
393
+ const why = spread(new Map([...statically, ...reads]), importersOf(true), new Set(o.preloads))
394
+ const run: string[] = []
395
+ const reasons = new Map<string, string>()
396
+ for (const s of o.suites) {
397
+ const w = !o.recorded.has(s) ? 'it never ran under the read recorder' : o.dirty?.has(s) === true ? 'it did not pass last time' : why.get(s)
398
+ if (w == null) continue
399
+ run.push(s)
400
+ reasons.set(s, w)
401
+ }
402
+ return { all: false, run, why: reasons, changed: o.changed.size }
403
+ }
404
+
405
+ const BETWEEN_TESTS = '# Unhandled error between tests'
406
+ const RULE = /^-{20,}$/
407
+
408
+ /** the repo modules named in the frames of every error bun printed between
409
+ * tests (a module whose top level threw), repo-relative to `top` */
410
+ export function loadErrorModules(out: string, top: string): string[] {
411
+ const abs = resolve(top)
412
+ const roots = [abs, abs.startsWith('/private/') ? abs.slice('/private'.length) : `/private${abs}`]
413
+ const found = new Set<string>()
414
+ const lines = out.split('\n')
415
+ for (let i = 0; i < lines.length; i++) {
416
+ if (lines[i]!.trim() !== BETWEEN_TESTS) continue
417
+ // the block: a rule, the frames, a closing rule
418
+ let rules = 0
419
+ for (let j = i + 1; j < lines.length && rules < 2; j++) {
420
+ const line = lines[j]!
421
+ if (RULE.test(line.trim())) { rules++; continue }
422
+ const m = /^\s+at .*?\(?((?:\/|[A-Za-z]:\\)[^():]+):\d+(?::\d+)?\)?\s*$/.exec(line)
423
+ if (m == null) continue
424
+ const base = roots.find((r) => m[1]!.startsWith(`${r}/`))
425
+ if (base == null) continue
426
+ const rel = relative(base, m[1]!).split(sep).join('/')
427
+ if (!rel.split('/').includes('node_modules')) found.add(rel)
428
+ }
429
+ }
430
+ return [...found].sort()
431
+ }
432
+
433
+ /** the picked suites a module that threw while loading leaves untrusted:
434
+ * every one that loads it, statically or at runtime */
435
+ export function taintedSuites(picked: string[], thrown: string[], graph: Record<string, GraphEntry>, modules: Record<string, ModuleReads>, preloads: string[]): Set<string> {
436
+ if (thrown.length === 0) return new Set()
437
+ // a broken module poisons only what LOADS it: reads and spawns carry nothing here
438
+ const loads: Record<string, ModuleReads> = {}
439
+ for (const [m, r] of Object.entries(modules)) if (r.l != null || r.L != null) loads[m] = { ...(r.l != null ? { l: r.l } : {}), ...(r.L != null ? { L: r.L } : {}) }
440
+ const sel = selectSuites({ suites: picked, preloads, envFiles: [], graph, modules: loads, recorded: new Set(picked), changed: new Set(thrown) })
441
+ return new Set(sel.all ? picked : sel.run)
442
+ }
443
+
444
+ /** the preloads a bunfig and a command line hand every suite, repo-relative
445
+ * to `top`. `cwd` is where bun runs */
446
+ export function preloadsOf(bunfigText: string | null, rawArgs: string[], cwdRel: string): string[] {
447
+ const out: string[] = []
448
+ const add = (p: unknown): void => {
449
+ if (typeof p !== 'string') return
450
+ out.push(posix.normalize(posix.join(cwdRel, p)))
451
+ }
452
+ if (bunfigText != null) {
453
+ try {
454
+ const t = Bun.TOML.parse(bunfigText) as { preload?: unknown; test?: { preload?: unknown } }
455
+ for (const p of [t.preload, t.test?.preload]) (Array.isArray(p) ? p : [p]).forEach(add)
456
+ } catch { /* the runner reports an unparseable bunfig */ }
457
+ }
458
+ for (let i = 0; i < rawArgs.length; i++) {
459
+ const a = rawArgs[i]!
460
+ if (a === '--preload' || a === '-r') add(rawArgs[++i])
461
+ else if (a.startsWith('--preload=')) add(a.slice('--preload='.length))
462
+ }
463
+ return out
464
+ }
465
+
466
+ /** the env files a command line names (`--env-file`) */
467
+ export function envFilesOf(rawArgs: string[], cwdRel: string): string[] {
468
+ const out: string[] = []
469
+ for (let i = 0; i < rawArgs.length; i++) {
470
+ const a = rawArgs[i]!
471
+ const v = a === '--env-file' ? rawArgs[++i] : a.startsWith('--env-file=') ? a.slice('--env-file='.length) : null
472
+ if (v != null) out.push(posix.normalize(posix.join(cwdRel, v)))
473
+ }
474
+ return out
475
+ }
476
+
477
+ /** the test files bun would run here: tracked or new, under `testRoot`,
478
+ * outside every ignore pattern, cwd-relative */
479
+ export function suiteFiles(top: string, cwdRel: string, testRoot: string | null, ignores: string[], isTest: (f: string) => boolean): string[] {
480
+ const under = posix.normalize(posix.join(cwdRel, testRoot ?? '.'))
481
+ const at = under === '.' ? '.' : under
482
+ // bun discovers what git ignores too, an install excepted
483
+ const listed = [
484
+ ...git(top, ['ls-files', '-z', '--cached', '--others', '--exclude-standard', '--', at]).split('\0'),
485
+ ...git(top, ['ls-files', '-z', '--others', '--ignored', '--exclude-standard', '--', at]).split('\0').filter((f) => !f.split('/').includes('node_modules')),
486
+ ]
487
+ const res = ignores.map((g) => globRe(g))
488
+ const out: string[] = []
489
+ for (const f of listed) {
490
+ if (f === '' || !isTest(f) || !existsSync(join(top, f))) continue
491
+ const rel = cwdRel === '' || cwdRel === '.' ? f : f.slice(cwdRel.length + 1)
492
+ if (res.some((re) => re.test(rel))) continue
493
+ out.push(rel)
494
+ }
495
+ return [...new Set(out)].sort()
496
+ }
497
+
498
+ /** a bun ignore pattern as a regexp: `**` any depth, `*` one segment */
499
+ export function globRe(glob: string): RegExp {
500
+ const g = glob.replace(/^\.\//, '')
501
+ let re = ''
502
+ for (let i = 0; i < g.length; i++) {
503
+ const c = g[i]!
504
+ if (c === '*' && g[i + 1] === '*') { re += '.*'; i++; if (g[i + 1] === '/') i++ }
505
+ else if (c === '*') re += '[^/]*'
506
+ else if (c === '?') re += '[^/]'
507
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&')
508
+ }
509
+ return new RegExp(`^${re}$`)
510
+ }