rvlib-shipkit 1.6.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 +26 -2
- 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/cmd_Insights.ts +78 -0
- package/src/cli.ts +6 -1
- package/src/core/global-contribution.ts +42 -0
- package/src/core/trace-audit.ts +16 -7
- package/src/home/global-context.ts +145 -0
- package/src/home/gui-app/ShipkitGui.swift +125 -41
- package/src/home/home-routes.ts +25 -0
- package/src/home/insights-shape.ts +124 -0
- package/src/home/insights.ts +90 -0
- package/src/home/serve-web.ts +3 -3
- package/src/home/widgets.ts +12 -2
- package/src/serve/hub-app/home-st.ts +12 -5
- package/src/serve/hub-app/home-strip.css +2 -0
- package/src/serve/hub-app/home-strip.tsx +4 -0
- package/src/serve/hub-app/home.html +1 -0
- package/src/serve/hub-app/hub-app.tsx +2 -0
- package/src/serve/hub-app/icons.tsx +1 -0
- package/src/serve/hub-app/index.html +1 -0
- package/src/serve/hub-app/insights-st.ts +111 -0
- package/src/serve/hub-app/insights-view.tsx +112 -0
- package/src/serve/hub-app/insights.css +24 -0
- package/src/serve/hub-app/keys.ts +1 -1
- package/src/serve/hub-app/machine-st.ts +23 -0
- package/src/serve/hub-app/palette-st.ts +2 -1
- package/src/serve/hub-app/project-list.tsx +3 -2
- package/src/serve/hub-app/projects-svc.ts +1 -1
- package/src/serve/hub-app/repo-st.ts +1 -1
- package/src/serve/hub-runtime.ts +31 -21
- package/src/serve/run-lanes.ts +134 -0
- package/src/shipkit-files.ts +3 -2
- 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,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
|
+
}
|