rvlib-shipkit 0.38.1 → 1.0.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 +367 -16
- package/README.md +26 -14
- package/package.json +2 -2
- package/src/assets/gen/comfyts-gen.ts +27 -10
- package/src/assets/gen/generate-roll.ts +1 -1
- package/src/assets/kit-config.ts +6 -6
- package/src/assets/run-assets.ts +14 -10
- package/src/assets/server/studio-server.ts +6 -5
- package/src/check.ts +343 -0
- package/src/{rules/SK001-package-json-shape.ts → checks/SK001-package-json-shape.check.ts} +44 -46
- package/src/checks/SK002-contracts-fulfillness.check.ts +24 -0
- package/src/checks/SK003-claude-md-shape.check.ts +66 -0
- package/src/checks/SK004-gitignore-policy.check.ts +309 -0
- package/src/{rules/SK005-rvlib-versions.ts → checks/SK005-rvlib-versions.check.ts} +46 -32
- package/src/{rules/SK006-npm-publish-safety.ts → checks/SK006-npm-publish-safety.check.ts} +25 -24
- package/src/{rules/SK007-github-visibility.ts → checks/SK007-github-visibility.check.ts} +20 -14
- package/src/{rules/SK008-reviewer-agent.ts → checks/SK008-reviewer-agent.check.ts} +29 -14
- package/src/{rules/SK009-commit-ai-tells.ts → checks/SK009-commit-ai-tells.check.ts} +24 -21
- package/src/checks/SK010-undeclared-unit.check.ts +111 -0
- package/src/{rules/SK011-sibling-unit-ranges.ts → checks/SK011-sibling-unit-ranges.check.ts} +21 -20
- package/src/{rules/SK012-config-barrel-import.ts → checks/SK012-config-barrel-import.check.ts} +18 -23
- package/src/{rules/SK013-one-way-in-policy.ts → checks/SK013-one-way-in-policy.check.ts} +58 -50
- package/src/{rules/SK014-prose-ste.ts → checks/SK014-prose-ste.check.ts} +27 -13
- package/src/{rules/SK015-nested-repo.ts → checks/SK015-nested-repo.check.ts} +15 -12
- package/src/checks/SK016-legacy-check-shapes.check.ts +182 -0
- package/src/checks/SK017-vscode-hub-task.check.ts +197 -0
- package/src/checks/SK018-test-files-run.check.ts +28 -0
- package/src/checks/SK019-tests-run-in-units.check.ts +76 -0
- package/src/checks/SK020-self-spawning-check.check.ts +147 -0
- package/src/checks/contracts.check.ts +20 -0
- package/src/cli/cmd_Asks.ts +3 -2
- package/src/cli/cmd_Assets.ts +2 -2
- package/src/cli/cmd_Bump.ts +1 -1
- package/src/cli/cmd_Check.ts +6 -6
- package/src/cli/cmd_Checks.ts +95 -0
- package/src/cli/cmd_Ci.ts +48 -13
- package/src/cli/cmd_Commands.ts +2 -6
- package/src/cli/cmd_ConfigKeys.ts +69 -0
- package/src/cli/cmd_Contracts.ts +1 -1
- package/src/cli/cmd_Fleet.ts +6 -2
- package/src/cli/cmd_Hooks.ts +1 -1
- package/src/cli/cmd_Keys.ts +43 -18
- package/src/cli/cmd_Mv.ts +2 -2
- package/src/cli/cmd_Prose.ts +10 -1
- package/src/cli/cmd_Releases.ts +2 -2
- package/src/cli/cmd_Serve.ts +1 -1
- package/src/cli/cmd_Start.ts +6 -6
- package/src/cli/cmd_Status.ts +25 -0
- package/src/cli/cmd_Tui.ts +2 -2
- package/src/cli.ts +7 -4
- package/src/collections/actions.ts +4 -4
- package/src/collections/checks.ts +258 -0
- package/src/collections/collection-types.ts +254 -1
- package/src/collections/comfy.ts +23 -0
- package/src/collections/md-body.ts +14 -0
- package/src/collections/pings.ts +38 -12
- package/src/collections/posts.ts +19 -3
- package/src/collections/providers.ts +198 -91
- package/src/collections/reflections.ts +41 -0
- package/src/collections/state-rules.ts +73 -0
- package/src/collections/test-time.ts +288 -0
- package/src/collections/tests.ts +181 -0
- package/src/collections/wikis.ts +66 -0
- package/src/comfy/comfy-panel.ts +292 -0
- package/src/contracts/apply-contracts.ts +17 -12
- package/src/contracts/contract-types.ts +16 -4
- package/src/contracts/contracts-engine.ts +78 -42
- package/src/contracts/manifests/discord.contract.ts +6 -6
- package/src/contracts/manifests/identity.contract.ts +3 -3
- package/src/contracts/manifests/itch.contract.ts +12 -15
- package/src/contracts/manifests/social.contract.ts +5 -5
- package/src/contracts/manifests/steam.contract.ts +16 -16
- package/src/contracts/run-contracts.ts +4 -3
- package/src/core/attach.ts +96 -0
- package/src/core/canonical-scripts.ts +25 -0
- package/src/core/check-ctx.ts +204 -14
- package/src/core/check-file-names.ts +112 -0
- package/src/core/check-pass.ts +84 -0
- package/src/core/check-runner.ts +796 -0
- package/src/core/config-keys.ts +256 -0
- package/src/core/config-rewrite.ts +77 -0
- package/src/core/discover-checks.ts +166 -90
- package/src/core/discover-legacy-checks.ts +26 -0
- package/src/core/doc-block.ts +13 -0
- package/src/core/floor-resolve.ts +264 -0
- package/src/core/floor.ts +20 -137
- package/src/core/git-bin.ts +24 -0
- package/src/core/git-root.ts +108 -0
- package/src/core/instance.ts +195 -0
- package/src/core/jobs.ts +118 -0
- package/src/core/layout.ts +52 -0
- package/src/core/legacy-shapes.ts +207 -0
- package/src/core/lib-root.ts +5 -0
- package/src/core/locked-write.ts +110 -39
- package/src/core/machine-commit.ts +5 -4
- package/src/core/migrate-layout.ts +557 -0
- package/src/core/motherhome.ts +2 -8
- package/src/core/nesting.ts +4 -12
- package/src/core/one-way-in-policy.ts +73 -17
- package/src/core/outcome-words.ts +45 -0
- package/src/core/package-manager.ts +86 -0
- package/src/core/parallel.ts +21 -0
- package/src/core/process-tree.ts +44 -0
- package/src/core/raw-access.ts +93 -0
- package/src/core/read-set.ts +152 -0
- package/src/core/run-check.ts +155 -213
- package/src/core/run-ci.ts +153 -189
- package/src/core/run-fix.ts +96 -0
- package/src/core/run-fixers.ts +53 -30
- package/src/core/self-update.ts +9 -3
- package/src/core/slots.ts +83 -0
- package/src/core/spawn-timeout.ts +34 -3
- package/src/core/when-block.ts +401 -0
- package/src/core/when.ts +135 -0
- package/src/core/write-status.ts +5 -2
- package/src/hooks/leak-guard.ts +10 -8
- package/src/hooks/one-way-in-guard.ts +13 -5
- package/src/hooks/root-identity.ts +2 -1
- package/src/hooks/run-hooks.ts +35 -23
- package/src/init-repo.ts +39 -23
- package/src/ledger/check-stats.ts +155 -0
- package/src/ledger/check-table.ts +179 -0
- package/src/ledger/db.ts +255 -0
- package/src/ledger/gate-checks.ts +433 -0
- package/src/ledger/git-key.ts +116 -77
- package/src/ledger/git-scan.ts +96 -35
- package/src/ledger/ledger.ts +116 -42
- package/src/ledger/live-runs.ts +111 -0
- package/src/ledger/live-timeline.ts +97 -0
- package/src/ledger/repo-view.ts +73 -0
- package/src/ledger/run-log.ts +448 -0
- package/src/ledger/run-timeline.ts +173 -0
- package/src/ledger/store.ts +23 -12
- package/src/ledger/suite-log.ts +161 -0
- package/src/ledger/toolchain-identity.ts +7 -1
- package/src/ledger/world-cache.ts +111 -0
- package/src/mv/mv-md.ts +16 -6
- package/src/problems/extract.ts +182 -0
- package/src/problems/problem-board.ts +37 -0
- package/src/problems/problem-line.ts +77 -0
- package/src/prose/prose-view.ts +90 -0
- package/src/prose/run-prose.ts +60 -19
- package/src/prose/ste.ts +140 -24
- package/src/reflections/reflections-queue.ts +81 -0
- package/src/release/git-facts.ts +15 -9
- package/src/release/run-bump.ts +6 -5
- package/src/release/run-release.ts +25 -11
- package/src/release/run-releases.ts +2 -2
- package/src/release/unit-gate.ts +7 -54
- package/src/release/unit-resolve.ts +282 -0
- package/src/release/units.ts +33 -217
- package/src/repo-config.ts +155 -85
- package/src/rules/rule.ts +36 -96
- package/src/rules/untrack-generated.ts +5 -4
- package/src/scaffold/files/vscode-tasks.json +10 -31
- package/src/scaffold/jsonc-spans.ts +113 -0
- package/src/scaffold/project-scaffold.ts +16 -43
- package/src/scan.ts +98 -0
- package/src/serve/gate-trigger.ts +148 -0
- package/src/serve/health-schedule.ts +102 -0
- package/src/serve/hub-app/chart-view.ts +179 -0
- package/src/serve/hub-app/check-view.tsx +345 -0
- package/src/serve/hub-app/check-words.ts +164 -0
- package/src/serve/hub-app/checks-svc.ts +280 -0
- package/src/serve/hub-app/clock-svc.ts +33 -0
- package/src/serve/hub-app/col-card.tsx +23 -39
- package/src/serve/hub-app/cols-svc.ts +44 -20
- package/src/serve/hub-app/comfy-svc.ts +171 -0
- package/src/serve/hub-app/comfy-view.tsx +81 -0
- package/src/serve/hub-app/dur.ts +41 -0
- package/src/serve/hub-app/hash-route.ts +121 -0
- package/src/serve/hub-app/header-bar.tsx +117 -0
- package/src/serve/hub-app/health-svc.ts +23 -28
- package/src/serve/hub-app/help.tsx +29 -0
- package/src/serve/hub-app/hub-app.tsx +122 -119
- package/src/serve/hub-app/hub-st.ts +115 -42
- package/src/serve/hub-app/hub-types.ts +117 -2
- package/src/serve/hub-app/hub.css +534 -192
- package/src/serve/hub-app/index.html +2 -0
- package/src/serve/hub-app/live-svc.ts +174 -0
- package/src/serve/hub-app/md.ts +15 -0
- package/src/serve/hub-app/media-svc.ts +4 -1
- package/src/serve/hub-app/media-view.tsx +15 -0
- package/src/serve/hub-app/next-run.ts +68 -0
- package/src/serve/hub-app/project-gutter.css +65 -0
- package/src/serve/hub-app/project-gutter.tsx +128 -0
- package/src/serve/hub-app/projects-svc.ts +226 -0
- package/src/serve/hub-app/prose-hits-st.ts +124 -0
- package/src/serve/hub-app/prose-hits-view.tsx +176 -0
- package/src/serve/hub-app/prose-hits.css +21 -0
- package/src/serve/hub-app/run-strip.ts +47 -0
- package/src/serve/hub-app/runs-tabs.ts +24 -0
- package/src/serve/hub-app/test-view.tsx +107 -0
- package/src/serve/hub-app/tests-svc.ts +62 -0
- package/src/serve/hub-app/tests-view.tsx +238 -0
- package/src/serve/hub-app/time-ago.tsx +34 -0
- package/src/serve/hub-app/timeline-chart.tsx +274 -0
- package/src/serve/hub-app/timeline-geometry.ts +314 -0
- package/src/serve/hub-app/timeline-svc.ts +62 -0
- package/src/serve/hub-app/timeline-view.tsx +245 -0
- package/src/serve/hub-app/tree-build.ts +410 -0
- package/src/serve/hub-app/tree-row.tsx +42 -0
- package/src/serve/hub-app/tree-svc.ts +174 -0
- package/src/serve/hub-app/wikis-view.tsx +53 -0
- package/src/serve/other-repos.ts +79 -0
- package/src/serve/project-gutter-route.ts +350 -0
- package/src/serve/project-gutter.ts +449 -0
- package/src/serve/run-serve.ts +245 -16
- package/src/serve/same-origin.ts +14 -0
- package/src/serve/serve-hub.ts +213 -18
- package/src/shipkit.ts +19 -12
- package/src/smoke/run-smoke.ts +3 -2
- package/src/start-repo.ts +75 -36
- package/src/suite/judge-suites.ts +209 -15
- package/src/suite/kept-timings.ts +58 -0
- package/src/suite/runner-shapes.ts +88 -0
- package/src/suite/suite-cli.ts +147 -24
- package/src/suite/suite-cpu-preload.ts +92 -0
- package/src/suite/test-units.ts +243 -0
- package/src/ts/config.ts +4 -69
- package/src/ts/jsonc.ts +69 -0
- package/src/ts/lex.ts +143 -0
- package/src/ts/rename.ts +73 -10
- package/src/ts/source-graph.ts +28 -8
- package/src/ts/tsconfig-projects.ts +6 -97
- package/src/ts/tsconfig-resolve.ts +108 -0
- package/src/tui/build-steps-panel.ts +61 -0
- package/src/tui/checks-panel.ts +24 -16
- package/src/tui/run-tui.ts +2 -2
- package/src/tui/tui-panel.ts +5 -4
- package/src/wiki/graph-colors.ts +68 -0
- package/src/wiki/graph-mount.ts +7 -9
- package/src/wiki/graph-page.ts +42 -25
- package/src/wiki/graph-positions.ts +105 -0
- package/src/wiki/run-wiki.ts +1 -1
- package/src/wiki/serve-graph.ts +33 -3
- package/src/cli/cmd_Rules.ts +0 -51
- package/src/cli/cmd_Units.ts +0 -82
- package/src/collections/graphs.ts +0 -32
- package/src/contracts/contracts-once.ts +0 -43
- package/src/core/check-suite.ts +0 -302
- package/src/core/repair-contract-dirs.ts +0 -136
- package/src/core/task-runner.ts +0 -339
- package/src/ledger/gate-units.ts +0 -138
- package/src/ledger/unit-table.ts +0 -105
- package/src/rules/SK002-contracts-fulfillness.ts +0 -24
- package/src/rules/SK003-claude-md-shape.ts +0 -70
- package/src/rules/SK004-gitignore-policy.ts +0 -238
- package/src/rules/SK010-undeclared-release-unit.ts +0 -127
- package/src/rules/rule-registry.ts +0 -64
- package/src/rules/run-fix.ts +0 -72
- package/src/tui/tasks-panel.ts +0 -60
package/src/suite/suite-cli.ts
CHANGED
|
@@ -6,17 +6,32 @@
|
|
|
6
6
|
// `Ran N tests` so the gate can still report what ran.
|
|
7
7
|
//
|
|
8
8
|
// usage: suite-cli [--budget-ms N] [--slow file]... [--serial file]... [--declared-in <where>] -- <bun test args>
|
|
9
|
-
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'fs'
|
|
10
|
-
import { tmpdir } from 'os'
|
|
9
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs'
|
|
10
|
+
import { availableParallelism, loadavg, tmpdir } from 'os'
|
|
11
11
|
import { join } from 'path'
|
|
12
|
-
import { FULL_TESTS_ENV } from '../ledger/gate-
|
|
13
|
-
import {
|
|
12
|
+
import { FULL_TESTS_ENV } from '../ledger/gate-checks.ts'
|
|
13
|
+
import { CHECK_ID_ENV, recordSuites, type SuitePhase } from '../ledger/suite-log.ts'
|
|
14
|
+
import { killTree } from '../core/spawn-timeout.ts'
|
|
15
|
+
import { parallelCap } from '../core/parallel.ts'
|
|
16
|
+
import { poolDir, poolOccupancy } from '../core/slots.ts'
|
|
17
|
+
import { keepTimings, seedTimings, timingsHome } from './kept-timings.ts'
|
|
18
|
+
import { aloneRunsNeeded, bunfigTest, readPhaseCpu, SUITE_CPU_ENV, type AloneRun, type Confirmed, confirmOver, crashedWorkers, type MachineLoad, WAIT_FACTOR, DEFAULT_BUDGET_MS, FULL_CEILING_MS, failedSuites, failLines, judgeSuites, normSuitePath, parseSuiteFlags, planPhases, QUICK_CEILING_MS, poolWorkers } from './judge-suites.ts'
|
|
14
19
|
|
|
15
|
-
type Phase = { code: number; signal: string | null; out: string; timings: Record<string, number
|
|
20
|
+
type Phase = { code: number; signal: string | null; stalled: boolean; out: string; timings: Record<string, number>; cpuMs: number | null }
|
|
16
21
|
|
|
17
|
-
async function runPhase(args: string[], timingsFile: string | null, echo = true): Promise<Phase> {
|
|
22
|
+
async function runPhase(args: string[], timingsFile: string | null, ceilingMs: number, echo = true, cpuFile: string | null = null): Promise<Phase> {
|
|
18
23
|
const timing = timingsFile != null ? [`--timings=${timingsFile}`, '--update-timings'] : []
|
|
19
|
-
|
|
24
|
+
// the per suite CPU figure rides in a preload of the runner's own, beside
|
|
25
|
+
// the repo's (a command line preload adds to bunfig's, never replaces it)
|
|
26
|
+
const cpu = cpuFile != null ? ['--preload', CPU_PRELOAD] : []
|
|
27
|
+
const env = cpuFile != null ? { ...process.env, [SUITE_CPU_ENV]: cpuFile } : process.env
|
|
28
|
+
const proc = Bun.spawn(['bun', 'test', ...cpu, ...timing, ...args], { stdout: 'pipe', stderr: 'pipe', stdin: 'ignore', env })
|
|
29
|
+
// the workers are children of `bun test`, and a stuck one is the process
|
|
30
|
+
// to reach, so the whole tree goes
|
|
31
|
+
let stalled = false
|
|
32
|
+
let tree: number[] = []
|
|
33
|
+
const ceiling = setTimeout(() => { stalled = true; tree = killTree(proc, 'SIGTERM') }, ceilingMs)
|
|
34
|
+
const killer = setTimeout(() => { killTree(proc, 'SIGKILL', tree) }, ceilingMs + 2000)
|
|
20
35
|
let out = ''
|
|
21
36
|
const pump = async (stream: ReadableStream<Uint8Array>, sink: NodeJS.WriteStream): Promise<void> => {
|
|
22
37
|
const dec = new TextDecoder()
|
|
@@ -28,20 +43,41 @@ async function runPhase(args: string[], timingsFile: string | null, echo = true)
|
|
|
28
43
|
}
|
|
29
44
|
await Promise.all([pump(proc.stdout, process.stdout), pump(proc.stderr, process.stderr)])
|
|
30
45
|
const code = await proc.exited
|
|
46
|
+
clearTimeout(ceiling)
|
|
47
|
+
clearTimeout(killer)
|
|
31
48
|
let timings: Record<string, number> = {}
|
|
32
49
|
try { if (timingsFile != null && existsSync(timingsFile)) timings = (JSON.parse(readFileSync(timingsFile, 'utf8')) as { files?: Record<string, number> }).files ?? {} }
|
|
33
50
|
catch { timings = {} }
|
|
34
|
-
|
|
51
|
+
// wait4 rusage: the process plus every child it reaped, which is where
|
|
52
|
+
// bun test's workers and a suite's own subprocesses land
|
|
53
|
+
const usage = proc.resourceUsage()
|
|
54
|
+
const cpuMs = usage != null ? Number(usage.cpuTime.user + usage.cpuTime.system) / 1000 : null
|
|
55
|
+
return { code, signal: stalled ? null : proc.signalCode ?? null, stalled, out, timings, cpuMs }
|
|
35
56
|
}
|
|
36
57
|
|
|
58
|
+
const CPU_PRELOAD = join(import.meta.dir, 'suite-cpu-preload.ts')
|
|
59
|
+
|
|
37
60
|
const secs = (ms: number): string => `${(ms / 1000).toFixed(1)}s`
|
|
61
|
+
|
|
62
|
+
const machineLoad = (): MachineLoad => ({ load1: loadavg()[0] ?? 0, cores: availableParallelism() })
|
|
63
|
+
|
|
64
|
+
const overWords = (c: Confirmed): string =>
|
|
65
|
+
c.over === 'cpu' ? `${c.file} ${secs(c.ms)} of CPU (${secs(c.wallMs)} wall)`
|
|
66
|
+
: c.over === 'wait' ? `${c.file} ${secs(c.wallMs)} wall on an idle machine, more than ${WAIT_FACTOR}x the budget, with ${secs(c.cpuMs ?? 0)} of CPU: it waits (a timer, a sleep, a poll)`
|
|
67
|
+
: `${c.file} ${secs(c.wallMs)} wall (no CPU figure)`
|
|
68
|
+
|
|
69
|
+
/** the suite FILES a phase names: a flag is not a file, and the repo's own
|
|
70
|
+
* `bun test` arguments are not files this runner put there */
|
|
71
|
+
const phaseFiles = (phase: string[], rawArgs: string[]): string[] => phase.filter((a) => !a.startsWith('-') && !rawArgs.includes(a))
|
|
38
72
|
const lastCount = (out: string, re: RegExp): number => Number([...out.matchAll(re)].at(-1)?.[1] ?? 0)
|
|
39
73
|
|
|
40
74
|
export async function suiteCli(argv: string[]): Promise<number> {
|
|
75
|
+
const t0 = Date.now()
|
|
41
76
|
const parsed = parseSuiteFlags(argv)
|
|
42
77
|
if (typeof parsed === 'string') { console.log(`FAIL: suite runner: ${parsed}`); return 2 }
|
|
43
|
-
const { tests, rawArgs, declaredIn } = parsed
|
|
78
|
+
const { tests, rawArgs, declaredIn, unit } = parsed
|
|
44
79
|
const tier = process.env[FULL_TESTS_ENV] === '1' ? 'full' : 'quick'
|
|
80
|
+
const ceilingMs = parsed.ceilingMs ?? (tier === 'full' ? FULL_CEILING_MS : QUICK_CEILING_MS)
|
|
45
81
|
const budget = tests.budgetMs ?? DEFAULT_BUDGET_MS
|
|
46
82
|
|
|
47
83
|
// a declaration naming a gone suite would silently stop excluding anything
|
|
@@ -58,14 +94,44 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
58
94
|
// gates, and sharing a run measured slower than sequential and timed out
|
|
59
95
|
const parallel = helpText.includes('--parallel') && tier === 'quick'
|
|
60
96
|
const timed = helpText.includes('--update-timings')
|
|
61
|
-
|
|
97
|
+
// bun drops these the moment any ignore reaches its command line
|
|
98
|
+
const bunfig = bunfigTest(existsSync('bunfig.toml') ? readFileSync('bunfig.toml', 'utf8') : null)
|
|
99
|
+
if (bunfig.error != null) { console.log(`FAIL: ${bunfig.error}`); return 1 }
|
|
100
|
+
// the pool is read, never taken: this process runs on its check's slot
|
|
101
|
+
const cores = availableParallelism()
|
|
102
|
+
const held = parallel ? poolOccupancy({ dir: poolDir(), size: parallelCap() }).held.length : 0
|
|
103
|
+
const workers = parallel ? poolWorkers({ cores, size: parallelCap(), held }) : undefined
|
|
104
|
+
const plan = planPhases({ tests, tier, rawArgs, parallel, noIsolate: helpText.includes('--no-isolate'), configIgnores: bunfig.ignores, workers })
|
|
62
105
|
const dir = mkdtempSync(join(tmpdir(), 'shipkit-suite-'))
|
|
63
106
|
const notes: string[] = []
|
|
64
107
|
if (!helpText.includes('--parallel')) notes.push(`skip: parallel suites (bun ${Bun.version} has no --parallel)`)
|
|
65
108
|
if (!timed) notes.push(`skip: suite budget (bun ${Bun.version} writes no --timings)`)
|
|
66
109
|
try {
|
|
67
|
-
const
|
|
68
|
-
|
|
110
|
+
const setupMs = Date.now() - t0
|
|
111
|
+
// the parallel phase reads the durations the last run kept, so bun
|
|
112
|
+
// starts the slowest files first, and keeps what it measured for the next
|
|
113
|
+
const kept = timed && parallel ? timingsHome(process.cwd(), unit) : null
|
|
114
|
+
if (kept != null) seedTimings(kept, join(dir, 'parallel.json'))
|
|
115
|
+
const cpuFile = timed && parallel ? join(dir, 'cpu.tsv') : null
|
|
116
|
+
const p1At = Date.now()
|
|
117
|
+
const p1 = await runPhase(plan.parallel, timed ? join(dir, 'parallel.json') : null, ceilingMs, true, cpuFile)
|
|
118
|
+
const p1Ms = Date.now() - p1At
|
|
119
|
+
// bun merges what it read into what it writes, so a suite deleted since
|
|
120
|
+
// the last run is judged and kept only if the workers say it ran here
|
|
121
|
+
// or it is still on disk. On disk alone is the floor: a suite whose
|
|
122
|
+
// line went missing is never dropped from the judgment
|
|
123
|
+
const phaseCpu = cpuFile != null && existsSync(cpuFile) ? readPhaseCpu(readFileSync(cpuFile, 'utf8'), process.cwd()) : { ran: [], cpu: {} }
|
|
124
|
+
if (kept != null) {
|
|
125
|
+
const ranHere = new Set(phaseCpu.ran)
|
|
126
|
+
p1.timings = Object.fromEntries(Object.entries(p1.timings).filter(([f]) => (ranHere.has(normSuitePath(f)) || existsSync(f))))
|
|
127
|
+
if (!p1.stalled && p1.signal == null) {
|
|
128
|
+
writeFileSync(join(dir, 'parallel.json'), JSON.stringify({ version: 1, files: p1.timings }))
|
|
129
|
+
keepTimings(join(dir, 'parallel.json'), kept)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
const p2At = Date.now()
|
|
133
|
+
const p2 = plan.serial != null ? await runPhase(plan.serial, timed ? join(dir, 'serial.json') : null, ceilingMs) : null
|
|
134
|
+
const p2Ms = p2 == null ? null : Date.now() - p2At
|
|
69
135
|
const phases = [p1, ...(p2 != null ? [p2] : [])]
|
|
70
136
|
const ran = phases.reduce((n, p) => n + lastCount(p.out, /Ran (\d+) tests?/g), 0)
|
|
71
137
|
const failedTests = phases.reduce((n, p) => n + lastCount(p.out, /^\s*(\d+) fail$/gm), 0)
|
|
@@ -73,38 +139,95 @@ export async function suiteCli(argv: string[]): Promise<number> {
|
|
|
73
139
|
const judged = judgeSuites({ timings, tests, tier })
|
|
74
140
|
const slowest = judged.slowest.length > 0 ? ` (slowest: ${judged.slowest.map((t) => `${t.file} ${secs(t.ms)}`).join(', ')})` : ''
|
|
75
141
|
|
|
76
|
-
// a parallel timing carries every other suite's load, so a suspect
|
|
77
|
-
//
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
142
|
+
// a parallel timing carries every other suite's load, so a suspect
|
|
143
|
+
// runs again alone, one process per suspect so its CPU is its own,
|
|
144
|
+
// before it can fail the task. One at a time: four alone runs side by
|
|
145
|
+
// side each burned about 40% more CPU than one alone (8 cores)
|
|
146
|
+
const aloneRuns: Record<string, AloneRun> = {}
|
|
147
|
+
const load = machineLoad()
|
|
148
|
+
const parallelCpu = phaseCpu.cpu
|
|
149
|
+
const { rerun, cleared: clearedInPhase } = aloneRunsNeeded(judged.over, parallelCpu, budget, load)
|
|
150
|
+
const aloneAt = Date.now()
|
|
151
|
+
for (const [i, t] of rerun.entries()) {
|
|
152
|
+
const run = await runPhase([...rawArgs, t.file], join(dir, `alone-${i}.json`), ceilingMs, false)
|
|
153
|
+
const wallMs = Object.entries(run.timings).find(([f]) => normSuitePath(f) === t.file)?.[1]
|
|
154
|
+
if (wallMs != null) aloneRuns[t.file] = { wallMs, cpuMs: run.cpuMs }
|
|
82
155
|
}
|
|
156
|
+
const aloneMs = Date.now() - aloneAt
|
|
157
|
+
const { over, cleared } = rerun.length > 0 ? confirmOver(rerun, aloneRuns, budget, load) : { over: [], cleared: [] }
|
|
158
|
+
for (const c of clearedInPhase) notes.push(`note: ${c.file} took ${secs(c.wallMs)} in the parallel run and burned ${secs(c.cpuMs ?? 0)} of CPU there, plus ${secs(parallelCpu[c.file]?.priorMs ?? 0)} a lone run loads first, inside the ${budget}ms budget, so it did not run again alone`)
|
|
159
|
+
for (const c of cleared) notes.push(`note: ${c.file} took ${secs(c.wallMs)} alone but burned ${c.cpuMs != null ? secs(c.cpuMs) : '?'} of CPU, inside the ${budget}ms budget (load ${load.load1.toFixed(1)} on ${load.cores} cores)`)
|
|
83
160
|
|
|
84
|
-
|
|
161
|
+
// a ceiling kill makes bun report every worker it held as crashed, and
|
|
162
|
+
// those are the suites that never finished, not an outside kill
|
|
163
|
+
const stalledIn = (name: string, p: Phase | null, files: string[]): string | null =>
|
|
164
|
+
p?.stalled === true
|
|
165
|
+
? `${name} ran past its ${secs(ceilingMs)} ceiling and was killed${files.length > 0 ? ` with ${files.join(', ')} still running` : ''}: a test worker stopped answering, most likely stuck in a synchronous call, which no per test timeout can interrupt`
|
|
166
|
+
: null
|
|
167
|
+
const crashed = crashedWorkers(p1.out)
|
|
168
|
+
const killed = p1.stalled ? [] : crashed.map((c) => `killed by ${c.signal} while running ${c.file} (out of memory, or killed from outside)`)
|
|
169
|
+
const stalls = [stalledIn('the parallel phase', p1, crashed.map((c) => c.file)), stalledIn('the serial phase', p2, phaseFiles(plan.serial ?? [], rawArgs).filter((a) => p2?.timings[normSuitePath(a)] == null))]
|
|
170
|
+
for (const line of stalls) if (line != null) killed.unshift(line)
|
|
85
171
|
if (p1.signal != null) killed.push(`killed by ${p1.signal} during the parallel run (out of memory, or killed from outside)`)
|
|
86
172
|
if (p2?.signal != null) {
|
|
87
|
-
const unfinished = (plan.serial ?? []).filter((a) =>
|
|
173
|
+
const unfinished = phaseFiles(plan.serial ?? [], rawArgs).filter((a) => p2.timings[normSuitePath(a)] == null)
|
|
88
174
|
killed.push(`killed by ${p2.signal} while running ${unfinished.join(' or ')} (out of memory, or killed from outside)`)
|
|
89
175
|
}
|
|
176
|
+
// the record behind the hub's test files to fix: every suite this run
|
|
177
|
+
// timed, with the alone timing where one was taken, and red when a
|
|
178
|
+
// test in it failed or its worker died
|
|
179
|
+
const red = new Set([...phases.flatMap((p) => failedSuites(p.out)), ...crashedWorkers(p1.out).map((c) => c.file)])
|
|
180
|
+
const at = Date.now()
|
|
181
|
+
const alone = new Map([...over, ...cleared, ...clearedInPhase].map((t) => [t.file, t.ms]))
|
|
182
|
+
const why = failLines(phases.map((p) => p.out).join('\n'))
|
|
183
|
+
for (const c of crashedWorkers(p1.out)) if (!why.has(c.file)) why.set(c.file, `its worker crashed: ${c.signal}`)
|
|
184
|
+
// which phase timed each file, and the CPU spent on it: an alone
|
|
185
|
+
// rerun, the parallel phase's own figure, or a phase that ran that one file
|
|
186
|
+
const phaseOf = (f: string): SuitePhase => (p2 != null && Object.keys(p2.timings).some((k) => normSuitePath(k) === f) ? 'serial' : parallel ? 'parallel' : 'sequential')
|
|
187
|
+
const soloCpu = (p: Phase | null): number | null => (p != null && Object.keys(p.timings).length === 1 ? p.cpuMs : null)
|
|
188
|
+
const cpuOf = (f: string): number | null => aloneRuns[f]?.cpuMs ?? (phaseOf(f) === 'serial' ? soloCpu(p2) : parallelCpu[f]?.cpuMs ?? soloCpu(p1))
|
|
189
|
+
const check = process.env[CHECK_ID_ENV] ?? ''
|
|
190
|
+
const serialFiles = p2 == null ? 0 : Object.keys(p2.timings).length
|
|
191
|
+
const unrecorded = recordSuites(process.cwd(), Object.entries(timings).map(([file, ms]) => {
|
|
192
|
+
const f = normSuitePath(file)
|
|
193
|
+
return {
|
|
194
|
+
at, file: f, ms: alone.get(f) ?? ms, status: red.has(f) ? 'fail' as const : 'pass' as const, tier, budgetMs: budget, check, unit,
|
|
195
|
+
cpuMs: cpuOf(f), phase: phaseOf(f), phaseMs: ms, failLine: red.has(f) ? why.get(f) ?? '' : '',
|
|
196
|
+
}
|
|
197
|
+
}), {
|
|
198
|
+
at, check, unit, tier, wallMs: Date.now() - t0, setupMs, workers: parallel ? workers ?? 0 : 0, files: Object.keys(timings).length,
|
|
199
|
+
parallelMs: p1Ms, parallelCpuMs: p1.cpuMs, serialMs: p2Ms, serialCpuMs: p2?.cpuMs ?? null, serialFiles,
|
|
200
|
+
aloneMs, aloneFiles: rerun.length, load1: load.load1, cores: load.cores,
|
|
201
|
+
})
|
|
202
|
+
if (unrecorded != null) notes.push(unrecorded)
|
|
90
203
|
// never blocking: a suite that got faster must not stop a release
|
|
91
204
|
const stale = judged.noLongerSlow.map((t) => `WARN ${declaredIn}.slow declares ${t.file}, which ran in ${secs(t.ms)}, under half the ${budget}ms budget: remove the declaration`)
|
|
92
205
|
|
|
93
206
|
for (const n of notes) console.log(n)
|
|
94
207
|
for (const s of stale) console.log(s)
|
|
208
|
+
// the root of a repo with units leaves every unit dir to its own check,
|
|
209
|
+
// so finding no file of its own is a finding, not a failure
|
|
210
|
+
if (unit === 'root' && ran === 0 && failedTests === 0 && killed.length === 0 && /\b0 test files matching\b|did not match any test files/.test(p1.out)) {
|
|
211
|
+
for (const n of notes) console.log(n)
|
|
212
|
+
console.log('ok: no test file outside the units: each unit runs its own in test:<unit id>')
|
|
213
|
+
return 0
|
|
214
|
+
}
|
|
215
|
+
// a suite that runs nothing proves nothing, and a green would say it did
|
|
216
|
+
const vacuous = ran === 0 && failedTests === 0 && killed.length === 0
|
|
95
217
|
const fails: string[] = [
|
|
96
218
|
...killed.map((k) => `FAIL: ${k}`),
|
|
97
|
-
...(
|
|
219
|
+
...(vacuous ? ['FAIL: ran 0 tests: a suite with no test in it proves nothing, write a real one'] : []),
|
|
220
|
+
...(!vacuous && (failedTests > 0 || (phases.some((p) => p.code !== 0 && p.signal == null) && killed.length === 0))
|
|
98
221
|
? [`FAIL: ${failedTests} test(s) failed, Ran ${ran} tests${slowest}`] : []),
|
|
99
222
|
...(over.length > 0
|
|
100
|
-
? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map(
|
|
223
|
+
? [`FAIL: ${over.length} suite(s) over the ${budget}ms budget: ${over.map(overWords).join(', ')} (run alone, load ${load.load1.toFixed(1)} on ${load.cores} cores). Make each fast, or declare it in ${declaredIn}.slow with what it catches and why it cannot be fast`]
|
|
101
224
|
: []),
|
|
102
225
|
]
|
|
103
226
|
// the most actionable failure prints LAST, where the gate reads its headline
|
|
104
227
|
for (const f of fails.slice(1).reverse()) console.log(f)
|
|
105
228
|
if (fails.length > 0) { console.log(fails[0]); return 1 }
|
|
106
|
-
const serialCount = plan.serial != null ? plan.serial
|
|
107
|
-
console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites, ${parallel ?
|
|
229
|
+
const serialCount = plan.serial != null ? phaseFiles(plan.serial, rawArgs).length : 0
|
|
230
|
+
console.log(`ok: Ran ${ran} tests across ${Object.keys(timings).length || '?'} suites, ${parallel ? `parallel on ${workers} workers` : 'one at a time'}${serialCount > 0 ? ` then ${serialCount} serial` : ''}, ${tier} tier${slowest}`)
|
|
108
231
|
return 0
|
|
109
232
|
} finally {
|
|
110
233
|
rmSync(dir, { recursive: true, force: true })
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// THE CPU EACH SUITE BURNED INSIDE THE PARALLEL PHASE, preloaded into every
|
|
2
|
+
// worker by the suite runner. A suite over the budget in wall time is run again
|
|
3
|
+
// alone to learn its CPU, one after the other, and on a loaded machine nearly
|
|
4
|
+
// every gate heavy suite goes over in wall time: those re-runs were most of the
|
|
5
|
+
// test check's wall time. A suite whose CPU in the parallel phase already
|
|
6
|
+
// bounds its alone run under the budget needs no re-run.
|
|
7
|
+
//
|
|
8
|
+
// One line per suite: `<file>\t<cpu ms>\t<prior ms>`. `cpu` is the worker's own
|
|
9
|
+
// CPU plus that of every child it reaped, from the end of the previous suite in
|
|
10
|
+
// this worker to the end of this one, so the file's import and its hooks count.
|
|
11
|
+
// `prior` is ALL the worker burned before this suite began: its start, and the
|
|
12
|
+
// load, top level and tests of every earlier suite. A lone run can only need
|
|
13
|
+
// what this worker computed by the end of the suite (a module an earlier suite
|
|
14
|
+
// loaded, a memo an earlier test filled), so cpu plus prior bounds it from
|
|
15
|
+
// above, and sharing the machine only raises CPU.
|
|
16
|
+
//
|
|
17
|
+
// A child still alive, or dead and not yet reaped, when the suite ends has CPU
|
|
18
|
+
// nobody counted yet, so that suite's line says `-` and it runs alone if it is
|
|
19
|
+
// a suspect. The lines also say which suites ran at all.
|
|
20
|
+
//
|
|
21
|
+
// It needs `--parallel`: without it bun runs a preload's hooks once for the
|
|
22
|
+
// whole run, not once per suite. Children come from getrusage(RUSAGE_CHILDREN)
|
|
23
|
+
// through bun:ffi: Bun's own resourceUsage() is the process alone, and a gate
|
|
24
|
+
// heavy suite burns most of its CPU in git. A runtime where either call cannot
|
|
25
|
+
// be made records nothing, and every suspect then runs alone as before.
|
|
26
|
+
import { appendFileSync, readdirSync, readFileSync } from 'fs'
|
|
27
|
+
import { afterAll } from 'bun:test'
|
|
28
|
+
import { SUITE_CPU_ENV } from './judge-suites.ts'
|
|
29
|
+
|
|
30
|
+
const RUSAGE_SELF = 0
|
|
31
|
+
const RUSAGE_CHILDREN = -1
|
|
32
|
+
|
|
33
|
+
type Probes = { cpu: () => number; liveChildren: () => number }
|
|
34
|
+
|
|
35
|
+
/** user plus system ms of this process and of its reaped children, and how
|
|
36
|
+
* many children it has that are alive or not yet reaped. null when the
|
|
37
|
+
* platform does not answer both */
|
|
38
|
+
function probes(): Probes | null {
|
|
39
|
+
try {
|
|
40
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
41
|
+
const { dlopen, FFIType, ptr } = require('bun:ffi') as typeof import('bun:ffi')
|
|
42
|
+
const darwin = process.platform === 'darwin'
|
|
43
|
+
const libc = darwin ? '/usr/lib/libSystem.B.dylib' : process.platform === 'linux' ? 'libc.so.6' : null
|
|
44
|
+
if (libc == null) return null
|
|
45
|
+
const lib = dlopen(libc, {
|
|
46
|
+
getrusage: { args: [FFIType.i32, FFIType.ptr], returns: FFIType.i32 },
|
|
47
|
+
...(darwin ? { proc_listchildpids: { args: [FFIType.i32, FFIType.ptr, FFIType.i32], returns: FFIType.i32 } } : {}),
|
|
48
|
+
})
|
|
49
|
+
// struct rusage opens with two struct timeval (64 bit seconds, then
|
|
50
|
+
// microseconds), user then system, on both platforms
|
|
51
|
+
const buf = new BigInt64Array(18)
|
|
52
|
+
const view = new DataView(buf.buffer)
|
|
53
|
+
const tv = (o: number): number => Number(view.getBigInt64(o, true)) * 1000 + view.getInt32(o + 8, true) / 1000
|
|
54
|
+
const one = (who: number): number => {
|
|
55
|
+
if (lib.symbols.getrusage(who, ptr(buf)) !== 0) throw new Error('getrusage failed')
|
|
56
|
+
return tv(0) + tv(16)
|
|
57
|
+
}
|
|
58
|
+
const pids = new Int32Array(1024)
|
|
59
|
+
const listChildren = (lib.symbols as Record<string, (...a: unknown[]) => unknown>)['proc_listchildpids']
|
|
60
|
+
const liveChildren = darwin
|
|
61
|
+
? (): number => Number(listChildren!(process.pid, ptr(pids), pids.byteLength))
|
|
62
|
+
// a zombie is still listed with this pid as its parent
|
|
63
|
+
: (): number => {
|
|
64
|
+
let n = 0
|
|
65
|
+
for (const d of readdirSync('/proc')) {
|
|
66
|
+
if (!/^\d+$/.test(d)) continue
|
|
67
|
+
try { if (readFileSync(`/proc/${d}/stat`, 'utf8').replace(/^.*\) /s, '').split(' ')[1] === String(process.pid)) n++ } catch { /* gone */ }
|
|
68
|
+
}
|
|
69
|
+
return n
|
|
70
|
+
}
|
|
71
|
+
liveChildren()
|
|
72
|
+
return { cpu: () => one(RUSAGE_SELF) + one(RUSAGE_CHILDREN), liveChildren }
|
|
73
|
+
} catch {
|
|
74
|
+
return null
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const out = process.env[SUITE_CPU_ENV]
|
|
79
|
+
const p = out != null && out !== '' ? probes() : null
|
|
80
|
+
if (out != null && p != null) {
|
|
81
|
+
let ended = p.cpu()
|
|
82
|
+
afterAll(() => {
|
|
83
|
+
const now = p.cpu()
|
|
84
|
+
// every suite gets a line, since the line is also how the runner knows
|
|
85
|
+
// it ran; a suite whose figure cannot be trusted says `-`
|
|
86
|
+
try {
|
|
87
|
+
const figure = p.liveChildren() === 0 ? `${Math.round(now - ended)}\t${Math.round(ended)}` : '-\t-'
|
|
88
|
+
appendFileSync(out, `${Bun.main}\t${figure}\n`)
|
|
89
|
+
} catch { /* no line: the suite runs alone if it is a suspect */ }
|
|
90
|
+
ended = now
|
|
91
|
+
})
|
|
92
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// WHICH TEST CHECK RUNS WHICH TEST FILE. Every `*.test.ts` in the repo is
|
|
2
|
+
// discovered, each one belongs to the unit whose directory holds it
|
|
3
|
+
// (the root takes the rest), and each unit's check is `test` for the root and
|
|
4
|
+
// `test:<unit id>` for a sub-unit. A file that no check runs is an ORPHAN.
|
|
5
|
+
//
|
|
6
|
+
// Pure: every read goes through the reader it is handed, so the gate hands it
|
|
7
|
+
// the disk and a check hands it its context. The constraint a reader breaks
|
|
8
|
+
// first: bun REPLACES the bunfig ignores with the command line ones, and the
|
|
9
|
+
// suite runner puts them back (judge-suites.ts planPhases), so what runs is
|
|
10
|
+
// always the UNION modelled here.
|
|
11
|
+
import { basename, posix } from 'path'
|
|
12
|
+
import { rawName, routesToShipkit, slotBody } from '../core/canonical-scripts.ts'
|
|
13
|
+
import { parseJsonc } from '../ts/jsonc.ts'
|
|
14
|
+
import { bunfigTest, normSuitePath, parseBunTestScript, type BunfigTest, type TestsConfig } from './judge-suites.ts'
|
|
15
|
+
|
|
16
|
+
export { bunfigTest, type BunfigTest }
|
|
17
|
+
|
|
18
|
+
/** bun's own discovery pattern, as a glob a listing takes */
|
|
19
|
+
export const TEST_GLOB = '**/*{.test,.spec,_test_,_spec_}.{js,ts,jsx,tsx}'
|
|
20
|
+
|
|
21
|
+
/** bun's own discovery pattern: `**{.test,.spec,_test_,_spec_}.{js,ts,jsx,tsx}` */
|
|
22
|
+
export const isTestFile = (rel: string): boolean => /(\.test|\.spec|_test_|_spec_)\.(js|ts|jsx|tsx)$/.test(rel)
|
|
23
|
+
|
|
24
|
+
/** the check id a unit's tests run under */
|
|
25
|
+
export const testCheckId = (unitId: string): string => (unitId === 'root' ? 'test' : `test:${unitId}`)
|
|
26
|
+
|
|
27
|
+
/** `bun test` flags that take the NEXT token as their value */
|
|
28
|
+
const VALUE_FLAGS = new Set(['--timeout', '--rerun-each', '-t', '--test-name-pattern', '--preload', '--coverage-reporter', '--coverage-dir', '--reporter', '--reporter-outfile', '--max-concurrency', '--shard', '--timings', '--path-ignore-patterns', '--seed', '--retry', '--cwd', '--config', '-c', '--env-file', '--tsconfig-override'])
|
|
29
|
+
|
|
30
|
+
/** what one test check hands `bun test`, as far as discovery is concerned */
|
|
31
|
+
export type TestRun = {
|
|
32
|
+
/** the `bun test` arguments, or null when the body is not a plain `bun test`
|
|
33
|
+
* and nothing can say which files it runs */
|
|
34
|
+
args: string[] | null
|
|
35
|
+
bunfig: BunfigTest
|
|
36
|
+
/** ignores the gate adds (the sub-unit dirs, for the root), relative to the run's cwd */
|
|
37
|
+
extraIgnores: string[]
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** does this run reach this file (relative to the run's cwd) ? */
|
|
41
|
+
export function runReaches(file: string, run: TestRun): { runs: true } | { runs: false; ignoredBy: string } | { runs: false; outside: string } | null {
|
|
42
|
+
if (run.args == null) return null
|
|
43
|
+
const f = normSuitePath(file)
|
|
44
|
+
const cli: string[] = []
|
|
45
|
+
const filters: string[] = []
|
|
46
|
+
for (let i = 0; i < run.args.length; i++) {
|
|
47
|
+
const a = run.args[i]!
|
|
48
|
+
if (a.startsWith('--path-ignore-patterns=')) { cli.push(a.slice('--path-ignore-patterns='.length)); continue }
|
|
49
|
+
if (a === '--path-ignore-patterns') { cli.push(run.args[++i] ?? ''); continue }
|
|
50
|
+
if (VALUE_FLAGS.has(a)) { i++; continue }
|
|
51
|
+
if (!a.startsWith('-')) filters.push(a)
|
|
52
|
+
}
|
|
53
|
+
for (const g of [...run.bunfig.ignores, ...cli, ...run.extraIgnores]) if (g !== '' && new Bun.Glob(g).match(f)) return { runs: false, ignoredBy: g }
|
|
54
|
+
if (run.bunfig.root != null && run.bunfig.root !== '.' && !f.startsWith(`${run.bunfig.root}/`))
|
|
55
|
+
return { runs: false, outside: `bunfig.toml [test] root = "${run.bunfig.root}"` }
|
|
56
|
+
if (filters.length > 0 && !filters.some((p) => (p.startsWith('./') || p.startsWith('/') ? f.startsWith(normSuitePath(p)) : f.includes(p))))
|
|
57
|
+
return { runs: false, outside: `the \`bun test\` arguments name only ${filters.join(', ')}` }
|
|
58
|
+
return { runs: true }
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** a sub-unit as its generated checks see it: declared, never scanned.
|
|
62
|
+
* `releases` false = a `release: false` unit, which only scopes checks */
|
|
63
|
+
export type TestUnitDecl = { id: string; rel: string; releases: boolean; tests?: TestsConfig; tsconfig?: string; deps?: string[] }
|
|
64
|
+
|
|
65
|
+
/** the sub-units a repo declares, read straight off `units[]`. A unit whose
|
|
66
|
+
* RELEASE resolution fails (a missing npm flag, a bad tag) still gets its
|
|
67
|
+
* tests run: a packaging defect is rule SK010's to report, not a reason to
|
|
68
|
+
* stop testing the code */
|
|
69
|
+
export function declaredTestUnits(units: { dir?: unknown; id?: string; tests?: TestsConfig; tsconfig?: string; release?: boolean; deps?: string[] }[] | undefined): TestUnitDecl[] {
|
|
70
|
+
const out: TestUnitDecl[] = []
|
|
71
|
+
const seen = new Set<string>(['root'])
|
|
72
|
+
for (const d of units ?? []) {
|
|
73
|
+
if (typeof d.dir !== 'string' || d.dir === '') continue
|
|
74
|
+
const rel = normSuitePath(d.dir).replace(/\/+$/, '')
|
|
75
|
+
const id = d.id ?? basename(rel)
|
|
76
|
+
if (seen.has(id)) continue
|
|
77
|
+
seen.add(id)
|
|
78
|
+
out.push({ id, rel, releases: d.release !== false, ...(d.tests == null ? {} : { tests: d.tests }), ...(d.tsconfig == null ? {} : { tsconfig: d.tsconfig }), ...(d.deps == null ? {} : { deps: d.deps }) })
|
|
79
|
+
}
|
|
80
|
+
return out
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
type Pkg = { name?: string; scripts?: Record<string, string>; dependencies?: Record<string, string>; devDependencies?: Record<string, string>; peerDependencies?: Record<string, string>; optionalDependencies?: Record<string, string> }
|
|
84
|
+
const parsePkg = (text: string | null): Pkg | null => {
|
|
85
|
+
if (text == null) return null
|
|
86
|
+
try { return JSON.parse(text) as Pkg } catch { return null }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** the script body a sub-unit's check runs: its own `test:raw`, else a `test`
|
|
90
|
+
* that does not route to shipkit, else a plain `bun test`. A unit's own
|
|
91
|
+
* preload and bunfig apply because the check runs in the unit's dir */
|
|
92
|
+
export function unitTestBody(pkgText: string | null): { script: string | null; body: string } {
|
|
93
|
+
const scripts = parsePkg(pkgText)?.scripts ?? {}
|
|
94
|
+
const raw = scripts[rawName('test')]
|
|
95
|
+
if (raw != null) return { script: rawName('test'), body: raw }
|
|
96
|
+
const t = scripts['test']
|
|
97
|
+
if (t != null && !routesToShipkit(t)) return { script: 'test', body: t }
|
|
98
|
+
return { script: null, body: 'bun test' }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** the sibling units a unit's tests can read: the ones its package.json names
|
|
102
|
+
* in any dependency block, and the ones its tsconfig `paths` point into (a
|
|
103
|
+
* bench resolving the libs at their source folders names them nowhere else).
|
|
104
|
+
* Their dirs belong to its inputs. Derived, never hand kept, and over
|
|
105
|
+
* declaring is safe where under declaring is a stale green */
|
|
106
|
+
export function siblingUnits(units: TestUnitDecl[], read: (rel: string) => string | null): Map<string, string[]> {
|
|
107
|
+
const pkgs = new Map(units.map((u) => [u.id, parsePkg(read(posix.join(u.rel, 'package.json')))]))
|
|
108
|
+
const byName = new Map<string, TestUnitDecl>()
|
|
109
|
+
for (const u of units) { const n = pkgs.get(u.id)?.name; if (n != null) byName.set(n, u) }
|
|
110
|
+
const out = new Map<string, string[]>()
|
|
111
|
+
for (const u of units) {
|
|
112
|
+
const p = pkgs.get(u.id)
|
|
113
|
+
const names = new Set([p?.dependencies, p?.devDependencies, p?.peerDependencies, p?.optionalDependencies].flatMap((b) => Object.keys(b ?? {})))
|
|
114
|
+
const tsconfig = unitTsconfig(u)
|
|
115
|
+
const targets = pathTargets(read(tsconfig)).map((t) => posix.normalize(posix.join(posix.dirname(tsconfig), t)))
|
|
116
|
+
out.set(u.id, units.filter((s) => s.id !== u.id && ([...names].some((n) => byName.get(n) === s) || targets.some((t) => under(t, s.rel)))).map((s) => s.rel))
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** the unit's own tsconfig, repo-relative: the declared one, else `<dir>/tsconfig.json` */
|
|
122
|
+
export const unitTsconfig = (u: TestUnitDecl): string => (u.tsconfig != null ? normSuitePath(u.tsconfig) : posix.join(u.rel, 'tsconfig.json'))
|
|
123
|
+
|
|
124
|
+
/** every target of a tsconfig `compilerOptions.paths`, as written */
|
|
125
|
+
function pathTargets(text: string | null): string[] {
|
|
126
|
+
if (text == null) return []
|
|
127
|
+
try {
|
|
128
|
+
const paths = (parseJsonc(text) as { compilerOptions?: { paths?: Record<string, unknown> } } | null)?.compilerOptions?.paths ?? {}
|
|
129
|
+
return Object.values(paths).flatMap((v) => (Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string') : []))
|
|
130
|
+
} catch { return [] }
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** the root run's ignores for the sub-unit dirs: each has a check of its own,
|
|
134
|
+
* run in its own dir with its own preload */
|
|
135
|
+
export const unitIgnores = (units: TestUnitDecl[]): string[] => units.map((u) => `${u.rel}/**`)
|
|
136
|
+
|
|
137
|
+
const under = (file: string, rel: string): boolean => rel === '' || file === rel || file.startsWith(`${rel}/`)
|
|
138
|
+
|
|
139
|
+
export type Orphan = { file: string; why: string }
|
|
140
|
+
export type TestPlan = {
|
|
141
|
+
/** check id → the repo-relative files it runs */
|
|
142
|
+
runs: Map<string, string[]>
|
|
143
|
+
/** a file a bunfig ignore keeps out, on purpose */
|
|
144
|
+
ignored: { file: string; pattern: string; checkId: string }[]
|
|
145
|
+
/** a file no check runs */
|
|
146
|
+
orphans: Orphan[]
|
|
147
|
+
/** checks whose body is not a plain `bun test`, so what they run is unknowable */
|
|
148
|
+
opaque: string[]
|
|
149
|
+
/** the files of those checks, each with the check it belongs to */
|
|
150
|
+
unread: { file: string; checkId: string }[]
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** which check runs each discovered test file. `rootRun` null = the repo has
|
|
154
|
+
* no test slot at all (unfilled, or turned off), so its files run nowhere */
|
|
155
|
+
export function planTests(o: {
|
|
156
|
+
files: string[]
|
|
157
|
+
units: TestUnitDecl[]
|
|
158
|
+
rootRun: TestRun | null
|
|
159
|
+
rootWhy?: string
|
|
160
|
+
unitRun: (u: TestUnitDecl) => TestRun
|
|
161
|
+
}): TestPlan {
|
|
162
|
+
const runs = new Map<string, string[]>([['test', []], ...o.units.map((u) => [testCheckId(u.id), []] as [string, string[]])])
|
|
163
|
+
const ignored: TestPlan['ignored'] = []
|
|
164
|
+
const orphans: Orphan[] = []
|
|
165
|
+
const opaque = new Set<string>()
|
|
166
|
+
const unread: TestPlan['unread'] = []
|
|
167
|
+
for (const file of o.files.filter(isTestFile).map(normSuitePath).sort()) {
|
|
168
|
+
// the deepest unit wins, so a unit nested in another still owns its own
|
|
169
|
+
const unit = o.units.filter((u) => under(file, u.rel)).sort((a, b) => b.rel.length - a.rel.length)[0]
|
|
170
|
+
const checkId = unit == null ? 'test' : testCheckId(unit.id)
|
|
171
|
+
const run = unit == null ? o.rootRun : o.unitRun(unit)
|
|
172
|
+
if (run == null) { orphans.push({ file, why: o.rootWhy ?? 'the repo has no test check' }); continue }
|
|
173
|
+
const rel = unit == null ? file : file.slice(unit.rel.length + 1)
|
|
174
|
+
const r = runReaches(rel, run)
|
|
175
|
+
if (r == null) { opaque.add(checkId); unread.push({ file, checkId }); continue }
|
|
176
|
+
if (r.runs) runs.get(checkId)!.push(file)
|
|
177
|
+
else if ('ignoredBy' in r) ignored.push({ file, pattern: r.ignoredBy, checkId })
|
|
178
|
+
else orphans.push({ file, why: `${checkId} never reaches it: ${r.outside}` })
|
|
179
|
+
}
|
|
180
|
+
return { runs, ignored, orphans, opaque: [...opaque], unread }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** the whole plan for a repo, from what its config and files say. `read`
|
|
184
|
+
* answers a repo-relative path, null when absent */
|
|
185
|
+
export function testPlanOf(o: {
|
|
186
|
+
config: { defaults?: Record<string, unknown>; units?: { dir?: unknown; id?: string; tests?: TestsConfig; tsconfig?: string; release?: boolean; deps?: string[] }[] }
|
|
187
|
+
files: string[]
|
|
188
|
+
read: (rel: string) => string | null
|
|
189
|
+
}): TestPlan & { units: TestUnitDecl[] } {
|
|
190
|
+
const units = declaredTestUnits(o.config.units)
|
|
191
|
+
let scripts: Record<string, string> = {}
|
|
192
|
+
try { scripts = (JSON.parse(o.read('package.json') ?? '{}') as { scripts?: Record<string, string> }).scripts ?? {} } catch { scripts = {} }
|
|
193
|
+
const slot = slotBody('test', o.config.defaults?.['test'] as { cmd?: unknown; logic?: unknown } | false | undefined, scripts)
|
|
194
|
+
const bunfig = (dir: string): BunfigTest => bunfigTest(o.read(dir === '' ? 'bunfig.toml' : posix.join(dir, 'bunfig.toml')))
|
|
195
|
+
const rootRun: TestRun | null = slot.kind === 'override' ? { args: null, bunfig: bunfig(''), extraIgnores: [] }
|
|
196
|
+
: slot.kind === 'script' ? { args: parseBunTestScript(slot.body), bunfig: bunfig(''), extraIgnores: unitIgnores(units) }
|
|
197
|
+
: null
|
|
198
|
+
const rootWhy = slot.kind === 'off' ? '`defaults.test: false` turns the root test check off, so nothing runs it'
|
|
199
|
+
: slot.kind === 'routed-empty' ? "the `test` script routes to shipkit and there is no 'test:raw' body, so the root test check runs nothing"
|
|
200
|
+
: 'no `test` or `test:raw` script, so the root has no test check'
|
|
201
|
+
const plan = planTests({
|
|
202
|
+
files: o.files,
|
|
203
|
+
units,
|
|
204
|
+
rootRun,
|
|
205
|
+
rootWhy,
|
|
206
|
+
unitRun: (u) => ({ args: parseBunTestScript(unitTestBody(o.read(posix.join(u.rel, 'package.json'))).body), bunfig: bunfig(u.rel), extraIgnores: [] }),
|
|
207
|
+
})
|
|
208
|
+
return { ...plan, units }
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** every suite declared slow, repo relative: the root's own, then each
|
|
212
|
+
* unit's under its dir. The runs page and the tests collection read the
|
|
213
|
+
* same list, so a file is slow on both or on neither */
|
|
214
|
+
export function declaredSlowFiles(c: { tests?: TestsConfig; units?: { dir?: unknown; tests?: TestsConfig }[] }): string[] {
|
|
215
|
+
const own = (c.tests?.slow ?? []).map((s) => normSuitePath(s.file))
|
|
216
|
+
const units = (c.units ?? []).flatMap((u) => (typeof u.dir === 'string' && u.dir !== '' ? (u.tests?.slow ?? []).map((s) => posix.join(normSuitePath(u.dir as string), normSuitePath(s.file))) : []))
|
|
217
|
+
return [...own, ...units]
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** the root's declared inputs with every sub-unit dir cut out: a path that
|
|
221
|
+
* holds a unit becomes the entries beside it, derived from the listing. The
|
|
222
|
+
* root check never runs a unit's tests, and what it imports from one comes
|
|
223
|
+
* back through its code closure */
|
|
224
|
+
export function inputsOutsideUnits(inputs: string[], unitRels: string[], files: string[]): string[] {
|
|
225
|
+
const out = new Set<string>()
|
|
226
|
+
for (const raw of inputs) {
|
|
227
|
+
const input = raw === '.' ? '' : normSuitePath(raw).replace(/\/+$/, '')
|
|
228
|
+
const inside = unitRels.filter((u) => under(u, input) && u !== input)
|
|
229
|
+
if (unitRels.includes(input)) continue
|
|
230
|
+
if (inside.length === 0) { out.add(raw); continue }
|
|
231
|
+
for (const f of files) {
|
|
232
|
+
if (!under(f, input) || inside.some((u) => under(f, u))) continue
|
|
233
|
+
// the shallowest prefix of f that holds no unit
|
|
234
|
+
const parts = f.split('/')
|
|
235
|
+
const skip = input === '' ? 0 : input.split('/').length
|
|
236
|
+
for (let i = skip + 1; i <= parts.length; i++) {
|
|
237
|
+
const p = parts.slice(0, i).join('/')
|
|
238
|
+
if (!inside.some((u) => under(u, p))) { out.add(p); break }
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
return [...out].sort()
|
|
243
|
+
}
|