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,138 @@
1
+ // THE RUNS OF ONE HUB: who attaches, who runs beside whom, who waits.
2
+ //
3
+ // - a run with a key identical to one in flight attaches to it: the lines it
4
+ // printed so far, then the rest, then its exit code. To a queued one always
5
+ // (it reads the tree after both asks), to a running one only while the tree
6
+ // stamp equals the one taken at its start
7
+ // - a run that writes the tree (a repair pass) waits for every run ahead of
8
+ // it, and every later run waits for it
9
+ // - every other run starts at once: the machine slot pool each check body
10
+ // takes bounds the CPU, never this queue. It admits fewer slots on a
11
+ // loaded machine (core/slots.ts), so a run past it waits and says why
12
+ //
13
+ // the one constraint a reader breaks first: the stamp is taken when a run
14
+ // STARTS, before its body reads anything, so a tree that moves during the run
15
+ // can only make a later asker run on its own, never adopt a stale verdict.
16
+ import { createHash } from 'crypto'
17
+ import { statSync } from 'fs'
18
+ import { join } from 'path'
19
+ import { fixPassRuns } from '../core/fix-scope.ts'
20
+ import { gitBin } from '../core/git-bin.ts'
21
+
22
+ export type LaneAsk = {
23
+ /** what makes two runs the same run, null: never shared */
24
+ key: string | null
25
+ /** the run rewrites tracked files (a repair pass) */
26
+ writes: boolean
27
+ print: (line: string) => void
28
+ /** no note lines: a --json run prints the json and nothing else */
29
+ quiet?: boolean
30
+ }
31
+
32
+ type Entry = {
33
+ key: string | null
34
+ writes: boolean
35
+ running: boolean
36
+ stamp: string | null
37
+ lines: string[]
38
+ listeners: Set<(line: string) => void>
39
+ done: Promise<number>
40
+ }
41
+
42
+ export const ATTACHED_NOTE = 'note: an identical run is in flight on this tree, so this one takes its lines and its verdict'
43
+ export const WAIT_NOTE = 'note: a run that rewrites files is in flight, so this one starts when it ends'
44
+
45
+ export class RunLanes {
46
+ private entries: Entry[] = []
47
+ constructor(private readonly stampOf: () => Promise<string | null> | string | null) {}
48
+
49
+ /** runs in flight, queued ones included */
50
+ get size(): number {
51
+ return this.entries.length
52
+ }
53
+
54
+ async submit(ask: LaneAsk, body: (print: (line: string) => void) => Promise<number>): Promise<number> {
55
+ if (ask.key != null) {
56
+ const same = this.entries.find((e) => e.key === ask.key)
57
+ if (same != null && (!same.running || (same.stamp != null && same.stamp === (await this.stampOf())))) return this.attach(same, ask.print)
58
+ }
59
+ const ahead = this.entries.filter((e) => ask.writes || e.writes).map((e) => e.done.catch(() => 1))
60
+ let finish!: (code: number) => void
61
+ let fail!: (e: unknown) => void
62
+ const entry: Entry = {
63
+ key: ask.key,
64
+ writes: ask.writes,
65
+ running: false,
66
+ stamp: null,
67
+ lines: [],
68
+ listeners: new Set([ask.print]),
69
+ done: new Promise<number>((res, rej) => { finish = res; fail = rej }),
70
+ }
71
+ // the asker gets the throw; an attacher, if any, its own rejection
72
+ entry.done.catch(() => {})
73
+ this.entries.push(entry)
74
+ const tee = (line: string): void => {
75
+ entry.lines.push(line)
76
+ for (const l of entry.listeners) l(line)
77
+ }
78
+ try {
79
+ if (ahead.length > 0) {
80
+ if (ask.quiet !== true) tee(WAIT_NOTE)
81
+ await Promise.all(ahead)
82
+ }
83
+ entry.stamp = ask.key == null ? null : await this.stampOf()
84
+ entry.running = true
85
+ const code = await body(tee)
86
+ finish(code)
87
+ return code
88
+ } catch (e) {
89
+ fail(e)
90
+ throw e
91
+ } finally {
92
+ this.entries = this.entries.filter((x) => x !== entry)
93
+ }
94
+ }
95
+
96
+ private attach(e: Entry, print: (line: string) => void): Promise<number> {
97
+ print(ATTACHED_NOTE)
98
+ for (const l of e.lines) print(l)
99
+ e.listeners.add(print)
100
+ return e.done.finally(() => e.listeners.delete(print))
101
+ }
102
+ }
103
+
104
+ /** the key of a run: its kind and its options, whoever asked */
105
+ export function runKey(kind: string, opts: Record<string, unknown>): string {
106
+ const { invokedBy: _by, ...rest } = opts
107
+ const sorted = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined).sort(([a], [b]) => (a < b ? -1 : 1)))
108
+ return `${kind} ${JSON.stringify(sorted)}`
109
+ }
110
+
111
+ /** a run whose repair pass runs (core/fix-scope.ts): the structural fixers
112
+ * and the hooks only ever run inside it */
113
+ export const writesTree = (opts: Record<string, unknown>): boolean =>
114
+ fixPassRuns({ fix: typeof opts.fix === 'boolean' ? opts.fix : undefined, onlyIds: Array.isArray(opts.onlyIds) ? (opts.onlyIds as string[]) : undefined })
115
+
116
+ /** HEAD, what git status lists, and the stat of each listed path, hashed:
117
+ * equal stamps mean no tracked or untracked file moved in between. Null
118
+ * outside git, where nothing attaches */
119
+ export function treeStamp(root: string): string | null {
120
+ const git = (args: string[]): string | null => {
121
+ const p = Bun.spawnSync([gitBin(), '--no-optional-locks', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
122
+ return p.exitCode === 0 ? p.stdout.toString() : null
123
+ }
124
+ const head = git(['rev-parse', '-q', '--verify', 'HEAD']) ?? 'none'
125
+ const status = git(['status', '--porcelain=v1', '-z', '-uall', '--no-renames', '--ignore-submodules'])
126
+ if (status == null) return null
127
+ const h = createHash('sha256').update(head).update('\0').update(status)
128
+ for (const entry of status.split('\0')) {
129
+ if (entry.length < 4) continue
130
+ try {
131
+ const st = statSync(join(root, entry.slice(3)))
132
+ h.update(`\0${st.size}:${st.mtimeMs}:${st.ctimeMs}`)
133
+ } catch {
134
+ h.update('\0gone')
135
+ }
136
+ }
137
+ return h.digest('hex')
138
+ }
@@ -27,6 +27,10 @@ export type SmokeRoute = {
27
27
  * match's href after this route and judges the landed page as its own
28
28
  * row. How a detail page with a real id gets visited without knowing one. */
29
29
  open?: string
30
+ /** the visible text a settled route needs before it counts as rendered,
31
+ * default 40. A canvas app (a game) draws everything into one canvas and
32
+ * shows no text: `{ path: '/', waitFor: 'canvas', minChars: 0 }`. */
33
+ minChars?: number
30
34
  }
31
35
 
32
36
  export type SmokeLogin = {
@@ -63,8 +67,9 @@ export type SmokeConfig = {
63
67
  const SMOKE_KEYS = new Set(['base', 'routes', 'login', 'paceMs', 'shots'])
64
68
  const LOGIN_KEYS = new Set(['path', 'email', 'emailSecret', 'passwordSecret', 'form'])
65
69
  const FORM_KEYS = new Set(['email', 'password', 'submit'])
66
- const ROUTE_KEYS = new Set(['path', 'waitFor', 'open'])
70
+ const ROUTE_KEYS = new Set(['path', 'waitFor', 'open', 'minChars'])
67
71
  export const DEFAULT_LOGIN_PATH = '/login'
72
+ export const DEFAULT_MIN_CHARS = 40
68
73
 
69
74
  export const DEFAULT_PACE_MS = 3000
70
75
  export const DEFAULT_SHOTS = `${LAYOUT.tmp}/smoke`
@@ -86,6 +91,8 @@ export function validateSmokeConfig(c: unknown): SmokeConfig {
86
91
  const v = (r as Record<string, unknown>)[k]
87
92
  if (v != null && (typeof v !== 'string' || v === '')) throw new Error(`defineRepo: smoke.routes['${path}'].${k} must be a non-empty selector`)
88
93
  }
94
+ const min = (r as Record<string, unknown>)['minChars']
95
+ if (min != null && (typeof min !== 'number' || !Number.isInteger(min) || min < 0)) throw new Error(`defineRepo: smoke.routes['${path}'].minChars must be a whole number of characters, 0 or more`)
89
96
  }
90
97
  }
91
98
  if (s['login'] != null) {
@@ -152,8 +159,8 @@ export type SmokeReport = {
152
159
  * nothing threw, an empty `waitFor` on a listing is exactly how a broken
153
160
  * migration looks, and a page that scrolls sideways is a layout defect only
154
161
  * a real browser can see (happy-dom does no layout). */
155
- export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; overflowPx: number }): RouteReport {
156
- const empty = r.chars < 40
162
+ export function judgeRoute(r: { route: string; chars: number; matches: number; problems: number; waitFor?: string; minChars?: number; overflowPx: number }): RouteReport {
163
+ const empty = r.chars < (r.minChars ?? DEFAULT_MIN_CHARS)
157
164
  const noMatch = r.waitFor != null && r.matches === 0
158
165
  const sideways = r.overflowPx > 1
159
166
  const note = empty ? 'EMPTY PAGE' : noMatch ? `NO MATCH FOR ${r.waitFor}` : sideways ? `PAGE SCROLLS SIDEWAYS +${r.overflowPx}px` : r.problems > 0 ? `${r.problems} problem(s)` : ''
@@ -368,7 +375,7 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
368
375
  }
369
376
 
370
377
  /** visit one url, settle it, judge it, screenshot it */
371
- const visit = async (label: string, url: string, waitFor?: string): Promise<RouteReport> => {
378
+ const visit = async (label: string, url: string, waitFor?: string, minChars?: number): Promise<RouteReport> => {
372
379
  // let the previous page's requests drain out of the concurrency
373
380
  // window BEFORE the label moves, or its late failures land on this row
374
381
  if (paceMs > 0) await page.waitForTimeout(paceMs)
@@ -387,11 +394,11 @@ export async function runSmoke(root: string, config: SmokeConfig, opts: SmokeRun
387
394
  return Math.max(de.scrollWidth - de.clientWidth, doc.body.scrollWidth - doc.body.clientWidth)
388
395
  }).catch(() => 0)
389
396
  await page.screenshot({ path: join(shots, `${routeSlug(label)}.png`), fullPage: true })
390
- return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, overflowPx })
397
+ return judgeRoute({ route: label, chars, matches, problems: problems.length - before, waitFor, minChars, overflowPx })
391
398
  }
392
399
 
393
400
  for (const route of routes) {
394
- reports.push(await visit(route.path, route.path, route.waitFor))
401
+ reports.push(await visit(route.path, route.path, route.waitFor, route.minChars))
395
402
  if (route.open == null) continue
396
403
  // a detail page with a real id: the first link the selector matches
397
404
  const href = await page.locator(route.open).first().getAttribute('href').catch(() => null)
@@ -24,6 +24,8 @@ export type TestsConfig = {
24
24
  slow?: SlowSuite[]
25
25
  /** suites that cannot share a run: they run one at a time after the parallel phase */
26
26
  serial?: SerialSuite[]
27
+ /** true: the quick tier runs only the suites a change since the last run can reach (their import closure and what its modules read at runtime) and the ones that did not pass then. Off by default: every suite runs every time. The full tier always runs every suite */
28
+ select?: boolean
27
29
  }
28
30
 
29
31
  export const DEFAULT_BUDGET_MS = 2000
@@ -39,17 +41,18 @@ export const DEFAULT_BUDGET_MS = 2000
39
41
  * 5s default while the preload said otherwise. */
40
42
  export const FULL_TIMEOUT_MS = 120_000
41
43
  /** the wall ceiling of one `bun test` phase. bun's per test timeout never
42
- * fires on a worker stuck in a SYNCHRONOUS call, and bun's spawnSync has been
44
+ * fires on a worker stuck in a SYNCHRONOUS call, or in `expect(p).resolves`
45
+ * or `.rejects` on a promise that never settles, and bun's spawnSync has been
43
46
  * seen spinning on a child that already exited (planPhases avoids the known
44
47
  * trigger), so without this a hang lasts until something outside kills it. The quick
45
48
  * tier answers in seconds, so two minutes is far from any real run. */
46
49
  export const QUICK_CEILING_MS = 120_000
47
50
  /** the full tier runs every suite one after the other, about three minutes */
48
51
  export const FULL_CEILING_MS = 1_800_000
49
- const TESTS_KEYS = ['budgetMs', 'slow', 'serial']
52
+ const TESTS_KEYS = ['budgetMs', 'slow', 'serial', 'select']
50
53
  const SLOW_KEYS = ['file', 'catches', 'whyNotFast']
51
54
  const SERIAL_KEYS = ['file', 'why']
52
- const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceiling-ms', '--unit']
55
+ const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceiling-ms', '--unit', '--select']
53
56
 
54
57
  export const normSuitePath = (file: string): string => posix.normalize(file.replace(/\\/g, '/')).replace(/^\.\//, '')
55
58
 
@@ -84,13 +87,14 @@ export function suiteFlags(t: TestsConfig | undefined): string[] {
84
87
  ...(t.budgetMs != null ? ['--budget-ms', String(t.budgetMs)] : []),
85
88
  ...(t.slow ?? []).flatMap((s) => ['--slow', s.file]),
86
89
  ...(t.serial ?? []).flatMap((s) => ['--serial', s.file]),
90
+ ...(t.select === true ? ['--select', 'on'] : []),
87
91
  ]
88
92
  }
89
93
 
90
94
  /** the inverse of suiteFlags, plus where the declarations live (printed in
91
95
  * every fix line) and the `bun test` arguments after `--` */
92
96
  export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs: string[]; declaredIn: string; ceilingMs: number | null; unit: string } | string {
93
- const tests: Required<TestsConfig> = { budgetMs: DEFAULT_BUDGET_MS, slow: [], serial: [] }
97
+ const tests: Required<TestsConfig> = { budgetMs: DEFAULT_BUDGET_MS, slow: [], serial: [], select: false }
94
98
  let declaredIn = 'repo.config.ts tests'
95
99
  let ceilingMs: number | null = null
96
100
  let unit = ''
@@ -109,6 +113,7 @@ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs:
109
113
  else if (flag === '--serial') tests.serial.push({ file: value, why: '(declared)' })
110
114
  else if (flag === '--ceiling-ms') ceilingMs = Number(value)
111
115
  else if (flag === '--unit') unit = value
116
+ else if (flag === '--select') { if (value !== 'off' && value !== 'on') return '--select takes on or off'; tests.select = value === 'on' }
112
117
  else declaredIn = value
113
118
  }
114
119
  if (!(tests.budgetMs > 0)) return '--budget-ms must be a positive number'
@@ -116,6 +121,8 @@ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs:
116
121
  return { tests, rawArgs: argv.slice(i), declaredIn, ceilingMs, unit }
117
122
  }
118
123
 
124
+ const PARALLEL_COUNT = /^--parallel=([1-9]\d*)$/
125
+
119
126
  /** what `bun test` receives in each phase. The serial phase names its files
120
127
  * explicitly, because bun reports no tests when a path is both named and ignored.
121
128
  *
@@ -124,8 +131,15 @@ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs:
124
131
  * did. `--parallel` implies per file isolation, and under it a worker's
125
132
  * spawnSync can spin forever on a child that already exited (bun issue
126
133
  * 34069), which hung one full run in eight. A repo that passes `--isolate`
127
- * itself keeps it. */
128
- export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawArgs: string[]; parallel: boolean; noIsolate?: boolean; configIgnores?: string[]; workers?: number }): { parallel: string[]; serial: string[] | null } {
134
+ * itself keeps it.
135
+ *
136
+ * `workers` (the pool share) is a ceiling under the repo's own
137
+ * `--parallel=<n>`: the phase takes the smaller, as the one flag on its line,
138
+ * since bun reads the last `--parallel` it is given. No other line names a
139
+ * worker count: the serial phase, a phase one that is not parallel (the full
140
+ * tier) and `alone` (what a suspect's rerun puts before its file) each run
141
+ * one suite at a time, so the repo's `--parallel` stays off them. */
142
+ export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawArgs: string[]; parallel: boolean; noIsolate?: boolean; configIgnores?: string[]; workers?: number }): { parallel: string[]; serial: string[] | null; alone: string[]; workers: number | null } {
129
143
  const slow = (p.tests.slow ?? []).map((s) => normSuitePath(s.file))
130
144
  const serial = (p.tests.serial ?? []).map((s) => normSuitePath(s.file))
131
145
  const own = [...new Set([...(p.tier === 'quick' ? slow : []), ...serial])]
@@ -139,9 +153,15 @@ export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawA
139
153
  const named = p.rawArgs.some((a) => a === '--timeout' || a.startsWith('--timeout='))
140
154
  const args = p.tier === 'full' && !named ? [`--timeout=${FULL_TIMEOUT_MS}`, ...p.rawArgs] : p.rawArgs
141
155
  const isolated = p.rawArgs.some((a) => a === '--isolate' || a === '--no-isolate')
156
+ const repoCount = p.rawArgs.map((a) => PARALLEL_COUNT.exec(a)?.[1]).filter((n) => n != null).map(Number).at(-1)
157
+ const bounds = [p.workers, repoCount].filter((n) => n != null)
158
+ const workers = p.parallel && bounds.length > 0 ? Math.min(...bounds) : null
159
+ const solo = args.filter((a) => a !== '--parallel' && !PARALLEL_COUNT.test(a))
142
160
  return {
143
- parallel: [...args, ...(p.parallel ? [p.workers != null ? `--parallel=${p.workers}` : '--parallel'] : []), ...(p.parallel && p.noIsolate === true && !isolated ? ['--no-isolate'] : []), ...ignored.map((f) => `--path-ignore-patterns=${f}`)],
144
- serial: serialRun.length > 0 ? [...args, ...serialRun] : null,
161
+ parallel: [...solo, ...(p.parallel ? [workers != null ? `--parallel=${workers}` : '--parallel'] : []), ...(p.parallel && p.noIsolate === true && !isolated ? ['--no-isolate'] : []), ...ignored.map((f) => `--path-ignore-patterns=${f}`)],
162
+ serial: serialRun.length > 0 ? [...solo, ...serialRun] : null,
163
+ alone: solo,
164
+ workers,
145
165
  }
146
166
  }
147
167
 
@@ -163,14 +183,16 @@ export function poolWorkers(p: { cores: number; size: number; held: number }): n
163
183
 
164
184
  type Timed = { file: string; ms: number }
165
185
 
166
- export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full' }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
186
+ export function judgeSuites(p: { timings: Record<string, number>; tests: TestsConfig; tier: 'quick' | 'full'; red?: string[] }): { over: Timed[]; noLongerSlow: Timed[]; slowest: Timed[] } {
167
187
  const budget = p.tests.budgetMs ?? DEFAULT_BUDGET_MS
168
188
  const slow = new Set((p.tests.slow ?? []).map((s) => normSuitePath(s.file)))
189
+ const red = new Set((p.red ?? []).map(normSuitePath))
169
190
  const timed = Object.entries(p.timings).map(([file, ms]) => ({ file: normSuitePath(file), ms })).sort((a, b) => b.ms - a.ms)
170
191
  return {
171
192
  // the budget guards the tier every edit waits on. The full tier is
172
- // where slow suites belong, and it only reports
173
- over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file)) : [],
193
+ // where slow suites belong, and it only reports. A red suite took
194
+ // the time of its failure (a timeout, a crash), which its red explains
195
+ over: p.tier === 'quick' ? timed.filter((t) => t.ms > budget && !slow.has(t.file) && !red.has(t.file)) : [],
174
196
  // half the budget, so a declaration does not flap on timing noise
175
197
  noLongerSlow: timed.filter((t) => slow.has(t.file) && t.ms < budget / 2),
176
198
  slowest: timed.slice(0, 3),
@@ -234,6 +256,20 @@ export function confirmOver(suspects: Timed[], alone: Record<string, AloneRun>,
234
256
  return { over: judged.filter((c) => c.over != null), cleared: judged.filter((c) => c.over == null) }
235
257
  }
236
258
 
259
+ /** a lone run over the budget on CPU runs once more. Under load the
260
+ * scheduler can spill a lone run onto the efficiency cores, where the same
261
+ * retired instructions cost up to 4x the CPU time (measured on Apple
262
+ * silicon), and a spill only ever raises the figure. A wait or a wall
263
+ * verdict is not CPU, so it keeps its one run */
264
+ export const retryAlone = (t: Timed, run: AloneRun, budgetMs: number, load: MachineLoad): boolean =>
265
+ confirmOver([t], { [t.file]: run }, budgetMs, load).over[0]?.over === 'cpu'
266
+
267
+ /** the run charged less, as confirmOver charges it: the smaller of CPU and wall */
268
+ export const smallerRun = (a: AloneRun, b: AloneRun): AloneRun => {
269
+ const charge = (r: AloneRun): number => Math.min(r.cpuMs ?? r.wallMs, r.wallMs)
270
+ return charge(b) < charge(a) ? b : a
271
+ }
272
+
237
273
  const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
238
274
 
239
275
  /** the line a confirmed suite fails with */
@@ -339,6 +375,37 @@ export function failLines(output: string): Map<string, string> {
339
375
 
340
376
  const clip = (s: string): string => (s.length > 300 ? `${s.slice(0, 299)}…` : s)
341
377
 
378
+ /** the clause the headline carries for the gate line: the command phase one
379
+ * ran, and the flags the runner added to the script's own. Per file ignores
380
+ * stay out, the slow and serial counts already say what they leave out */
381
+ export function ranAs(phase: string[], rawArgs: string[]): string {
382
+ const shown = phase.filter((a) => !a.startsWith('--path-ignore-patterns'))
383
+ const added = shown.filter((a) => !rawArgs.includes(a))
384
+ return `as \`${['bun', 'test', ...shown].join(' ')}\`${added.length > 0 ? ` (added ${added.join(' ')})` : ''}`
385
+ }
386
+
387
+ /** the suites bun lists under `Interrupted while still running:` when a
388
+ * phase is killed, one indented `<file> (<n>s)` line each */
389
+ export function interruptedSuites(output: string): string[] {
390
+ const out: string[] = []
391
+ let inBlock = false
392
+ for (const line of output.split('\n')) {
393
+ if (/^Interrupted while still running:\s*$/.test(line)) { inBlock = true; continue }
394
+ if (!inBlock) continue
395
+ const m = /^\s+(\S+)\s+\(\d+(?:\.\d+)?m?s\)\s*$/.exec(line)
396
+ if (m == null) { inBlock = false; continue }
397
+ out.push(normSuitePath(m[1]!))
398
+ }
399
+ return out
400
+ }
401
+
402
+ /** the line a phase killed at its ceiling fails with. Measured on bun 1.4.3:
403
+ * a plain await of a promise that never settles fails at the per test
404
+ * timeout, while `expect(p).resolves` or `.rejects` on one blocks the worker
405
+ * past it, as a synchronous loop does */
406
+ export const stallWords = (name: string, ceilingMs: number, files: string[]): string =>
407
+ `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test blocked its worker past the per test timeout, either a synchronous loop or expect(...).resolves or .rejects on a promise that never settles`
408
+
342
409
  /** the suites bun names as a crashed parallel worker */
343
410
  export function crashedWorkers(output: string): { file: string; signal: string }[] {
344
411
  return [...output.matchAll(/^\s*✗\s+(\S+)\s+\(worker crashed: (SIG[A-Z0-9]+)\)\s*$/gm)].map((m) => ({ file: normSuitePath(m[1]!), signal: m[2]! }))
@@ -348,6 +415,7 @@ export function testsConfigProblems(t: TestsConfig): string[] {
348
415
  const problems: string[] = []
349
416
  for (const k of Object.keys(t)) if (!TESTS_KEYS.includes(k)) problems.push(`tests.${k} is not a tests field (fields: ${TESTS_KEYS.join(', ')})`)
350
417
  if (t.budgetMs != null && !(typeof t.budgetMs === 'number' && t.budgetMs > 0)) problems.push('tests.budgetMs must be a positive number of milliseconds')
418
+ if (t.select != null && typeof t.select !== 'boolean') problems.push('tests.select must be true or false')
351
419
  const lists: [string, { file: string }[], string[], [string, string][]][] = [
352
420
  ['slow', t.slow ?? [], SLOW_KEYS, [['catches', 'what it catches that no fast test can'], ['whyNotFast', 'why it cannot be made fast']]],
353
421
  ['serial', t.serial ?? [], SERIAL_KEYS, [['why', 'what breaks when it shares a run']]],
@@ -0,0 +1,8 @@
1
+ // the variables that hand the read recorder (suite-reads-preload.ts) its job:
2
+ // where to write, the root it charges reads under, and, in a spawned bun
3
+ // child, the module that spawned it
4
+ export const SUITE_READS_ENV = 'SHIPKIT_SUITE_READS'
5
+ export const SUITE_ROOT_ENV = 'SHIPKIT_SUITE_ROOT'
6
+ export const SUITE_OWNER_ENV = 'SHIPKIT_SUITE_READS_OWNER'
7
+ /** `0` runs every suite in the quick tier and records nothing, `1` selects even where tests.select is not true */
8
+ export const SUITE_SELECT_ENV = 'SHIPKIT_TEST_SELECT'