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.
- package/CHANGELOG.md +16 -0
- package/package.json +1 -1
- package/src/checks/SK027-agent-wiki-links.check.ts +6 -2
- package/src/checks/SK033-global-contribution.check.ts +47 -0
- package/src/cli/cmd_GlobalContext.ts +47 -0
- package/src/cli/cmd_GlobalProcesses.ts +38 -1
- package/src/cli.ts +4 -1
- package/src/core/global-contribution.ts +42 -0
- package/src/home/global-context.ts +145 -0
- package/src/home/home-routes.ts +10 -0
- package/src/serve/hub-runtime.ts +31 -21
- package/src/serve/run-lanes.ts +134 -0
- package/src/suite/judge-suites.ts +27 -7
- package/src/suite/reads-env.ts +8 -0
- package/src/suite/select.ts +510 -0
- package/src/suite/suite-cli.ts +196 -14
- package/src/suite/suite-reads-preload.ts +335 -0
|
@@ -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
|
-
|
|
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: [...
|
|
144
|
-
serial: serialRun.length > 0 ? [...
|
|
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'
|