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.
@@ -0,0 +1,134 @@
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
11
+ //
12
+ // the one constraint a reader breaks first: the stamp is taken when a run
13
+ // STARTS, before its body reads anything, so a tree that moves during the run
14
+ // can only make a later asker run on its own, never adopt a stale verdict.
15
+ import { createHash } from 'crypto'
16
+ import { statSync } from 'fs'
17
+ import { join } from 'path'
18
+ import { gitBin } from '../core/git-bin.ts'
19
+
20
+ export type LaneAsk = {
21
+ /** what makes two runs the same run, null: never shared */
22
+ key: string | null
23
+ /** the run rewrites tracked files (a repair pass) */
24
+ writes: boolean
25
+ print: (line: string) => void
26
+ /** no note lines: a --json run prints the json and nothing else */
27
+ quiet?: boolean
28
+ }
29
+
30
+ type Entry = {
31
+ key: string | null
32
+ writes: boolean
33
+ running: boolean
34
+ stamp: string | null
35
+ lines: string[]
36
+ listeners: Set<(line: string) => void>
37
+ done: Promise<number>
38
+ }
39
+
40
+ export const ATTACHED_NOTE = 'note: an identical run is in flight on this tree, so this one takes its lines and its verdict'
41
+ export const WAIT_NOTE = 'note: a run that rewrites files is in flight, so this one starts when it ends'
42
+
43
+ export class RunLanes {
44
+ private entries: Entry[] = []
45
+ constructor(private readonly stampOf: () => Promise<string | null> | string | null) {}
46
+
47
+ /** runs in flight, queued ones included */
48
+ get size(): number {
49
+ return this.entries.length
50
+ }
51
+
52
+ async submit(ask: LaneAsk, body: (print: (line: string) => void) => Promise<number>): Promise<number> {
53
+ if (ask.key != null) {
54
+ const same = this.entries.find((e) => e.key === ask.key)
55
+ if (same != null && (!same.running || (same.stamp != null && same.stamp === (await this.stampOf())))) return this.attach(same, ask.print)
56
+ }
57
+ const ahead = this.entries.filter((e) => ask.writes || e.writes).map((e) => e.done.catch(() => 1))
58
+ let finish!: (code: number) => void
59
+ let fail!: (e: unknown) => void
60
+ const entry: Entry = {
61
+ key: ask.key,
62
+ writes: ask.writes,
63
+ running: false,
64
+ stamp: null,
65
+ lines: [],
66
+ listeners: new Set([ask.print]),
67
+ done: new Promise<number>((res, rej) => { finish = res; fail = rej }),
68
+ }
69
+ // the asker gets the throw; an attacher, if any, its own rejection
70
+ entry.done.catch(() => {})
71
+ this.entries.push(entry)
72
+ const tee = (line: string): void => {
73
+ entry.lines.push(line)
74
+ for (const l of entry.listeners) l(line)
75
+ }
76
+ try {
77
+ if (ahead.length > 0) {
78
+ if (ask.quiet !== true) tee(WAIT_NOTE)
79
+ await Promise.all(ahead)
80
+ }
81
+ entry.stamp = ask.key == null ? null : await this.stampOf()
82
+ entry.running = true
83
+ const code = await body(tee)
84
+ finish(code)
85
+ return code
86
+ } catch (e) {
87
+ fail(e)
88
+ throw e
89
+ } finally {
90
+ this.entries = this.entries.filter((x) => x !== entry)
91
+ }
92
+ }
93
+
94
+ private attach(e: Entry, print: (line: string) => void): Promise<number> {
95
+ print(ATTACHED_NOTE)
96
+ for (const l of e.lines) print(l)
97
+ e.listeners.add(print)
98
+ return e.done.finally(() => e.listeners.delete(print))
99
+ }
100
+ }
101
+
102
+ /** the key of a run: its kind and its options, whoever asked */
103
+ export function runKey(kind: string, opts: Record<string, unknown>): string {
104
+ const { invokedBy: _by, ...rest } = opts
105
+ const sorted = Object.fromEntries(Object.entries(rest).filter(([, v]) => v !== undefined).sort(([a], [b]) => (a < b ? -1 : 1)))
106
+ return `${kind} ${JSON.stringify(sorted)}`
107
+ }
108
+
109
+ /** a run with its repair pass on: `fix` and `fixRules` default to on */
110
+ export const writesTree = (opts: Record<string, unknown>): boolean => opts.fix !== false || opts.fixRules !== false
111
+
112
+ /** HEAD, what git status lists, and the stat of each listed path, hashed:
113
+ * equal stamps mean no tracked or untracked file moved in between. Null
114
+ * outside git, where nothing attaches */
115
+ export function treeStamp(root: string): string | null {
116
+ const git = (args: string[]): string | null => {
117
+ const p = Bun.spawnSync([gitBin(), '--no-optional-locks', ...args], { cwd: root, stdout: 'pipe', stderr: 'pipe' })
118
+ return p.exitCode === 0 ? p.stdout.toString() : null
119
+ }
120
+ const head = git(['rev-parse', '-q', '--verify', 'HEAD']) ?? 'none'
121
+ const status = git(['status', '--porcelain=v1', '-z', '-uall', '--no-renames', '--ignore-submodules'])
122
+ if (status == null) return null
123
+ const h = createHash('sha256').update(head).update('\0').update(status)
124
+ for (const entry of status.split('\0')) {
125
+ if (entry.length < 4) continue
126
+ try {
127
+ const st = statSync(join(root, entry.slice(3)))
128
+ h.update(`\0${st.size}:${st.mtimeMs}:${st.ctimeMs}`)
129
+ } catch {
130
+ h.update('\0gone')
131
+ }
132
+ }
133
+ return h.digest('hex')
134
+ }
@@ -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
@@ -46,10 +48,10 @@ export const FULL_TIMEOUT_MS = 120_000
46
48
  export const QUICK_CEILING_MS = 120_000
47
49
  /** the full tier runs every suite one after the other, about three minutes */
48
50
  export const FULL_CEILING_MS = 1_800_000
49
- const TESTS_KEYS = ['budgetMs', 'slow', 'serial']
51
+ const TESTS_KEYS = ['budgetMs', 'slow', 'serial', 'select']
50
52
  const SLOW_KEYS = ['file', 'catches', 'whyNotFast']
51
53
  const SERIAL_KEYS = ['file', 'why']
52
- const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceiling-ms', '--unit']
54
+ const OWN_FLAGS = ['--budget-ms', '--slow', '--serial', '--declared-in', '--ceiling-ms', '--unit', '--select']
53
55
 
54
56
  export const normSuitePath = (file: string): string => posix.normalize(file.replace(/\\/g, '/')).replace(/^\.\//, '')
55
57
 
@@ -84,13 +86,14 @@ export function suiteFlags(t: TestsConfig | undefined): string[] {
84
86
  ...(t.budgetMs != null ? ['--budget-ms', String(t.budgetMs)] : []),
85
87
  ...(t.slow ?? []).flatMap((s) => ['--slow', s.file]),
86
88
  ...(t.serial ?? []).flatMap((s) => ['--serial', s.file]),
89
+ ...(t.select === true ? ['--select', 'on'] : []),
87
90
  ]
88
91
  }
89
92
 
90
93
  /** the inverse of suiteFlags, plus where the declarations live (printed in
91
94
  * every fix line) and the `bun test` arguments after `--` */
92
95
  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: [] }
96
+ const tests: Required<TestsConfig> = { budgetMs: DEFAULT_BUDGET_MS, slow: [], serial: [], select: false }
94
97
  let declaredIn = 'repo.config.ts tests'
95
98
  let ceilingMs: number | null = null
96
99
  let unit = ''
@@ -109,6 +112,7 @@ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs:
109
112
  else if (flag === '--serial') tests.serial.push({ file: value, why: '(declared)' })
110
113
  else if (flag === '--ceiling-ms') ceilingMs = Number(value)
111
114
  else if (flag === '--unit') unit = value
115
+ else if (flag === '--select') { if (value !== 'off' && value !== 'on') return '--select takes on or off'; tests.select = value === 'on' }
112
116
  else declaredIn = value
113
117
  }
114
118
  if (!(tests.budgetMs > 0)) return '--budget-ms must be a positive number'
@@ -116,6 +120,8 @@ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs:
116
120
  return { tests, rawArgs: argv.slice(i), declaredIn, ceilingMs, unit }
117
121
  }
118
122
 
123
+ const PARALLEL_COUNT = /^--parallel=([1-9]\d*)$/
124
+
119
125
  /** what `bun test` receives in each phase. The serial phase names its files
120
126
  * explicitly, because bun reports no tests when a path is both named and ignored.
121
127
  *
@@ -124,8 +130,15 @@ export function parseSuiteFlags(argv: string[]): { tests: TestsConfig; rawArgs:
124
130
  * did. `--parallel` implies per file isolation, and under it a worker's
125
131
  * spawnSync can spin forever on a child that already exited (bun issue
126
132
  * 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 } {
133
+ * itself keeps it.
134
+ *
135
+ * `workers` (the pool share) is a ceiling under the repo's own
136
+ * `--parallel=<n>`: the phase takes the smaller, as the one flag on its line,
137
+ * since bun reads the last `--parallel` it is given. No other line names a
138
+ * worker count: the serial phase, a phase one that is not parallel (the full
139
+ * tier) and `alone` (what a suspect's rerun puts before its file) each run
140
+ * one suite at a time, so the repo's `--parallel` stays off them. */
141
+ 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
142
  const slow = (p.tests.slow ?? []).map((s) => normSuitePath(s.file))
130
143
  const serial = (p.tests.serial ?? []).map((s) => normSuitePath(s.file))
131
144
  const own = [...new Set([...(p.tier === 'quick' ? slow : []), ...serial])]
@@ -139,9 +152,15 @@ export function planPhases(p: { tests: TestsConfig; tier: 'quick' | 'full'; rawA
139
152
  const named = p.rawArgs.some((a) => a === '--timeout' || a.startsWith('--timeout='))
140
153
  const args = p.tier === 'full' && !named ? [`--timeout=${FULL_TIMEOUT_MS}`, ...p.rawArgs] : p.rawArgs
141
154
  const isolated = p.rawArgs.some((a) => a === '--isolate' || a === '--no-isolate')
155
+ const repoCount = p.rawArgs.map((a) => PARALLEL_COUNT.exec(a)?.[1]).filter((n) => n != null).map(Number).at(-1)
156
+ const bounds = [p.workers, repoCount].filter((n) => n != null)
157
+ const workers = p.parallel && bounds.length > 0 ? Math.min(...bounds) : null
158
+ const solo = args.filter((a) => a !== '--parallel' && !PARALLEL_COUNT.test(a))
142
159
  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,
160
+ parallel: [...solo, ...(p.parallel ? [workers != null ? `--parallel=${workers}` : '--parallel'] : []), ...(p.parallel && p.noIsolate === true && !isolated ? ['--no-isolate'] : []), ...ignored.map((f) => `--path-ignore-patterns=${f}`)],
161
+ serial: serialRun.length > 0 ? [...solo, ...serialRun] : null,
162
+ alone: solo,
163
+ workers,
145
164
  }
146
165
  }
147
166
 
@@ -348,6 +367,7 @@ export function testsConfigProblems(t: TestsConfig): string[] {
348
367
  const problems: string[] = []
349
368
  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
369
  if (t.budgetMs != null && !(typeof t.budgetMs === 'number' && t.budgetMs > 0)) problems.push('tests.budgetMs must be a positive number of milliseconds')
370
+ if (t.select != null && typeof t.select !== 'boolean') problems.push('tests.select must be true or false')
351
371
  const lists: [string, { file: string }[], string[], [string, string][]][] = [
352
372
  ['slow', t.slow ?? [], SLOW_KEYS, [['catches', 'what it catches that no fast test can'], ['whyNotFast', 'why it cannot be made fast']]],
353
373
  ['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'