Skip to content

Commit 21a80bd

Browse files
committed
refactor(ci): derive the audit list and stop shelling out through bunx
Review pass over the audit runner and the tool guards. The audit list was hand-maintained alongside package.json with nothing linking them, and it had already drifted: check:cron-parity exists, passes, and ran in no CI step at all. The list is now derived from the check:* scripts with an explicit exclusion map, so a new audit is opted out deliberately rather than forgotten. That picks up cron-parity — 22 audits now, not 21. check-realtime-prune-graph.ts still shelled out through `bunx turbo`, the same pattern that took the bridge audit from 1s to 39s once the audits ran concurrently. Both now go through scripts/local-bin.ts, which resolves node_modules/.bin — the same path check:native-typecheck asserts is the native TypeScript 7 compiler, so the one guarded path is the one that runs. Audits are spawned as their script rather than `bun run <name>`, which started a bun process only to read package.json and start a second one. Tool detection is memoized per process; it was re-spawning python3 on each of the 5 call sites, in every vitest worker. The CI throw is deliberately NOT memoized — memoizing it would turn every call after the first into a silent skip, which is the failure mode the guard exists to prevent. Verified it still throws for all three guarded tests, not just the first. Also: dropped the environment module from the @sim/testing barrel so node:child_process stays out of unrelated consumers' module graphs, restored the per-audit reporting the 21 separate steps used to give (collapsible groups, error annotations, and a timing table they never had), and trimmed comments that restated their code or duplicated the runner's own docs.
1 parent 9586102 commit 21a80bd

7 files changed

Lines changed: 152 additions & 125 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -117,20 +117,9 @@ jobs:
117117
- name: Lint code
118118
run: bun run lint:check
119119

120-
# These 21 audits are independent read-only passes over the tree, so running
121-
# them as 21 sequential steps spent the whole time waiting on single-threaded
122-
# file walks (~28s serial, ~5s at 13-way locally). scripts/run-audits.ts owns
123-
# the list; it buffers each audit's output and replays only the failures, so a
124-
# red run still names the audit and shows why. Audits that need a git base ref
125-
# or write files stay as their own steps below.
126-
#
127-
# Notable members: the desktop IPC audit complements the bridge audit by
128-
# deriving every fact from the source both sides execute, so it has no
129-
# snapshot blind spot; the import-specifier audit catches specifiers that only
130-
# webpack (which this job uses) resolves and that break every dev server on
131-
# Turbopack; and the native-typecheck audit catches a bare `tsc` falling back
132-
# to the ~10x slower JavaScript TypeScript 6 compiler, which otherwise still
133-
# passes and just burns minutes.
120+
# Every zero-argument `check:*` script, run concurrently. The list is derived in
121+
# scripts/run-audits.ts, which also writes the per-audit timing table to the job
122+
# summary and annotates failures. Audits needing a base ref stay separate below.
134123
- name: Repo audits
135124
run: bun run check:audits
136125

Lines changed: 45 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,63 @@
11
/**
2-
* Detects external command-line tools that a handful of tests shell out to.
2+
* Detects external command-line tools that a few suites shell out to.
33
*
4-
* A few suites deliberately execute the real thing rather than a mock — the cloud-review
5-
* helper's path and read-size bounds, and the code-placeholder compiler's generated Python.
6-
* That is the point of those tests, but it makes them depend on tools the repo does not
7-
* vendor, and the failure mode is a raw `SyntaxError` or `ENOENT` from a subprocess with
8-
* nothing tying it back to a missing tool.
9-
*
10-
* Locally these report `false` and print one actionable line, so the affected tests skip.
11-
* Under `CI` they throw instead: a missing tool there means the gate silently stopped
12-
* covering a security boundary, which is strictly worse than a red build.
4+
* Those suites deliberately execute the real thing rather than a mock, so they depend on tools
5+
* the repo does not vendor. Locally a missing tool skips them with a reason; under `CI` it
6+
* throws, because a silently-skipped security boundary is worse than a red build.
137
*/
148
import { spawnSync } from 'node:child_process'
159

16-
/**
17-
* Python 3.12 is the floor, set by PEP 701 f-strings rather than by `match` statements.
18-
*
19-
* The compiler suite generates `match` (3.10) but also f-strings that reuse the outer quote
20-
* and embed `#`. On 3.11 those raise `f-string: unmatched '('` and `f-string expression part
21-
* cannot include '#'` — exactly the raw SyntaxError this guard exists to prevent — so a 3.10
22-
* floor would have let two of the three guarded tests through and failed anyway.
23-
*/
24-
export const MIN_PYTHON: readonly [number, number] = [3, 12]
10+
/** PEP 701 f-strings set this floor, not the `match` statements (3.10) the suite also generates. */
11+
const MIN_PYTHON: readonly [number, number] = [3, 12]
2512

26-
/** Reasons to pass to vitest's `ctx.skip(...)` so the report says why, not just that. */
27-
export const PYTHON_SKIP_REASON = 'needs python3 >= 3.12 (PEP 701 f-strings); macOS ships 3.9'
13+
/** Reasons for vitest's `ctx.skip(...)`, so the report says why and not just that. */
14+
export const PYTHON_SKIP_REASON = `needs python3 >= ${MIN_PYTHON.join('.')} (PEP 701 f-strings); macOS ships 3.9`
2815
export const RIPGREP_SKIP_REASON = 'needs ripgrep (`rg`) on PATH'
2916

30-
const warned = new Set<string>()
31-
32-
function unavailable(tool: string, hint: string): false {
33-
if (process.env.CI) {
34-
throw new Error(
35-
`${tool} is required to run this suite and was not found. CI must never skip these tests — they cover behavior that is only observable by running the real tool. ${hint}`
36-
)
37-
}
38-
if (!warned.has(tool)) {
39-
warned.add(tool)
40-
console.warn(`[@sim/testing] Skipping tests that require ${tool}. ${hint}`)
17+
/**
18+
* Wraps a probe so it runs at most once per process, warns at most once, and always throws
19+
* under CI — memoizing the throw would turn every call after the first into a silent skip.
20+
*/
21+
function toolGuard(label: string, detect: () => { ok: boolean; hint: string }): () => boolean {
22+
let result: { ok: boolean; hint: string } | undefined
23+
let warned = false
24+
return () => {
25+
result ??= detect()
26+
if (result.ok) return true
27+
if (process.env.CI) {
28+
throw new Error(
29+
`${label} is required to run this suite and was not found. CI must never skip these tests — they cover behavior that is only observable by running the real tool. ${result.hint}`
30+
)
31+
}
32+
if (!warned) {
33+
warned = true
34+
console.warn(`[@sim/testing] Skipping tests that require ${label}. ${result.hint}`)
35+
}
36+
return false
4137
}
42-
return false
43-
}
44-
45-
/** Parses `python3 --version`, returning null when the interpreter is missing or unreadable. */
46-
export function detectPython3(): { major: number; minor: number } | null {
47-
const result = spawnSync('python3', ['--version'], { encoding: 'utf8' })
48-
const match = /Python (\d+)\.(\d+)/.exec(`${result.stdout ?? ''}${result.stderr ?? ''}`)
49-
if (!match) return null
50-
return { major: Number(match[1]), minor: Number(match[2]) }
5138
}
5239

5340
/**
5441
* True when `python3` resolves to at least {@link MIN_PYTHON}.
5542
*
56-
* macOS ships 3.9 as the system `python3` and Homebrew's newer builds are not linked as
57-
* `python3`, so this is false on a stock Mac even when a modern Python is installed.
43+
* False on a stock Mac: macOS ships 3.9 as the system `python3`, and Homebrew's newer builds
44+
* are not linked under that name.
5845
*/
59-
export function hasPython3(): boolean {
60-
const version = detectPython3()
46+
export const hasPython3 = toolGuard(`python3 >= ${MIN_PYTHON.join('.')}`, () => {
47+
const probe = spawnSync('python3', ['--version'], { encoding: 'utf8' })
48+
const match = /Python (\d+)\.(\d+)/.exec(`${probe.stdout ?? ''}${probe.stderr ?? ''}`)
49+
const found = match ? { major: Number(match[1]), minor: Number(match[2]) } : null
6150
const [minMajor, minMinor] = MIN_PYTHON
62-
const label = `python3 >= ${minMajor}.${minMinor}`
63-
const hint = `Found ${version ? `${version.major}.${version.minor}` : 'no python3 on PATH'}. Install a newer Python (e.g. \`brew install python@3.13\`) and put it on PATH ahead of /usr/bin.`
64-
if (!version) return unavailable(label, hint)
65-
if (version.major > minMajor) return true
66-
if (version.major === minMajor && version.minor >= minMinor) return true
67-
return unavailable(label, hint)
68-
}
51+
return {
52+
ok: Boolean(
53+
found && (found.major > minMajor || (found.major === minMajor && found.minor >= minMinor))
54+
),
55+
hint: `Found ${found ? `${found.major}.${found.minor}` : 'no python3 on PATH'}. Install a newer Python (e.g. \`brew install python@3.13\`) and put it on PATH ahead of /usr/bin.`,
56+
}
57+
})
6958

7059
/** True when `rg` is an executable on PATH. */
71-
export function hasRipgrep(): boolean {
72-
const result = spawnSync('rg', ['--version'], { encoding: 'utf8' })
73-
if (result.status === 0) return true
74-
return unavailable('ripgrep (`rg`)', 'Install it with `brew install ripgrep`.')
75-
}
60+
export const hasRipgrep = toolGuard('ripgrep (`rg`)', () => ({
61+
ok: spawnSync('rg', ['--version'], { encoding: 'utf8' }).status === 0,
62+
hint: 'Install it with `brew install ripgrep`.',
63+
}))

packages/testing/src/index.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,6 @@
4343

4444
export * from './assertions'
4545
export * from './builders'
46-
export * from './environment'
4746
export * from './factories'
4847
export * from './mocks'
4948
export * from './types'

scripts/check-desktop-bridge-contract.ts

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -31,21 +31,14 @@
3131
import { spawnSync } from 'node:child_process'
3232
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
3333
import { readFile, writeFile } from 'node:fs/promises'
34-
import { createRequire } from 'node:module'
3534
import { tmpdir } from 'node:os'
3635
import { dirname, join, resolve } from 'node:path'
3736
import { fileURLToPath } from 'node:url'
3837
import { formatGeneratedSource } from './format-generated-source'
38+
import { localBin } from './local-bin'
3939

4040
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
4141
const ROOT = resolve(SCRIPT_DIR, '..')
42-
43-
/** The native TypeScript 7 compiler entry point, resolved once. */
44-
const TSC_ENTRY = join(
45-
dirname(createRequire(import.meta.url).resolve('typescript/package.json')),
46-
'bin',
47-
'tsc'
48-
)
4942
const BRIDGE_SOURCE_PATH = resolve(ROOT, 'packages/desktop-bridge/src/index.ts')
5043
const PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/browser-protocol/src/index.ts')
5144
const TERMINAL_PROTOCOL_SOURCE_PATH = resolve(ROOT, 'packages/terminal-protocol/src/index.ts')
@@ -181,12 +174,7 @@ function checkCompatibility(): { compatible: boolean; output: string } {
181174
try {
182175
writeFileSync(join(dir, 'compat.ts'), compatSource)
183176
writeFileSync(join(dir, 'tsconfig.json'), JSON.stringify(tsconfig, null, 2))
184-
// Spawned as a resolved module path rather than via `bunx`. `bunx` re-resolves the
185-
// package on every call against the shared install cache, which on CI is a network-backed
186-
// sticky-disk mount — cheap when this audit runs alone, but it serialized behind the
187-
// other audits once they started running concurrently and took this step from 1s to 39s,
188-
// making it the entire wall clock of the batch.
189-
const result = spawnSync(process.execPath, [TSC_ENTRY, '-p', dir, '--pretty', 'false'], {
177+
const result = spawnSync(localBin('tsc'), ['-p', dir, '--pretty', 'false'], {
190178
cwd: ROOT,
191179
encoding: 'utf8',
192180
})

scripts/check-realtime-prune-graph.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { mkdtemp, readdir, rm, stat } from 'node:fs/promises'
33
import { tmpdir } from 'node:os'
44
import path from 'node:path'
55
import { $ } from 'bun'
6+
import { localBin } from './local-bin'
67

78
const MAX_PRUNED_PACKAGE_COUNT = 25
89

@@ -27,7 +28,7 @@ async function main() {
2728
const scratch = await mkdtemp(path.join(tmpdir(), 'sim-realtime-prune-'))
2829
try {
2930
console.log(`Pruning @sim/realtime into ${scratch}`)
30-
await $`bunx turbo prune @sim/realtime --docker --out-dir=${scratch}`.quiet()
31+
await $`${localBin('turbo')} prune @sim/realtime --docker --out-dir=${scratch}`.quiet()
3132

3233
const apps = await listPackages(path.join(scratch, 'json', 'apps'))
3334
const packages = await listPackages(path.join(scratch, 'json', 'packages'))

scripts/local-bin.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import path from 'node:path'
2+
3+
const ROOT = path.resolve(import.meta.dir, '..')
4+
5+
/**
6+
* Absolute path to a locally-installed executable.
7+
*
8+
* Scripts spawn these instead of shelling out through `bunx`, which re-resolves the package on
9+
* every call against the shared install cache — a network-backed sticky-disk mount on CI. That
10+
* is cheap for a lone caller and serializes badly once the audits run concurrently: it took the
11+
* desktop-bridge audit from 1s to 39s and made it the entire wall clock of the batch.
12+
*
13+
* `tsc` resolves here to the native TypeScript 7 compiler, which `check:native-typecheck`
14+
* asserts — so this is the one path guarded against the bin-shadowing that otherwise silently
15+
* selects the ~10x slower JavaScript compiler.
16+
*/
17+
export function localBin(name: string): string {
18+
return path.join(ROOT, 'node_modules', '.bin', name)
19+
}

scripts/run-audits.ts

Lines changed: 81 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,65 @@
11
#!/usr/bin/env bun
22
/**
3-
* Runs the independent repo audits concurrently.
3+
* Runs the repo's independent audits concurrently.
44
*
55
* Each audit is a self-contained read-only pass over the tree, so running them as 20-odd
66
* sequential CI steps spent most of its wall clock waiting on single-threaded file walks.
7-
* Only audits that need no extra arguments, working directory, or git base ref belong here —
8-
* the ones that diff against a base ref (block registry, migration safety) or write files
9-
* (drizzle generate) stay as their own steps.
107
*
11-
* Output is buffered per audit and replayed only for failures, so a green run stays quiet
12-
* and a red one still shows exactly which audit failed and why.
8+
* The list is derived from the `check:*` scripts in package.json rather than restated here,
9+
* so a new audit is picked up by default and has to be opted *out* deliberately. The previous
10+
* hand-maintained list had already drifted: `check:cron-parity` existed, passed, and ran
11+
* nowhere. Audits that need a git base ref or write files stay excluded and keep their own
12+
* workflow step.
1313
*/
14-
const AUDITS = [
15-
'check:boundaries',
16-
'check:api-validation:strict',
17-
'check:desktop-bridge',
18-
'check:desktop-ipc',
19-
'check:utils',
20-
'check:zustand-v5',
21-
'check:react-query',
22-
'check:client-boundary',
23-
'check:bare-icons',
24-
'check:icon-paths',
25-
'check:realtime-prune',
26-
'check:tool-registry-boundary',
27-
'check:tool-request-boundary',
28-
'check:trigger-block-cycle',
29-
'check:import-specifiers',
30-
'check:sql-date-binding',
31-
'check:native-typecheck',
14+
import path from 'node:path'
15+
16+
/** `check:*` scripts this runner deliberately does not own, and why. */
17+
const EXCLUDED: Record<string, string> = {
18+
'check:audits': 'this runner',
19+
'check:migrations': 'needs a git base ref argument',
20+
'check:api-validation': 'superseded by the :strict variant, which this runner does run',
21+
}
22+
23+
/**
24+
* Generated-artifact checks that live outside the `check:*` namespace. Listed explicitly
25+
* because the `*:check` namespace also holds checks that need a sibling repo or network.
26+
*/
27+
const EXTRA_AUDITS = [
3228
'tool-metadata:check',
3329
'integration-catalog:check',
3430
'skills:check',
3531
'agent-stream-docs:check',
3632
] as const
3733

34+
const ROOT = path.resolve(import.meta.dir, '..')
35+
3836
interface AuditResult {
3937
script: string
4038
ok: boolean
4139
durationMs: number
4240
output: string
4341
}
4442

45-
const CONCURRENCY = Math.max(2, navigator.hardwareConcurrency - 1)
43+
async function auditScripts(): Promise<string[]> {
44+
const manifest = await Bun.file(path.join(ROOT, 'package.json')).json()
45+
const scripts = manifest.scripts as Record<string, string>
46+
const derived = Object.keys(scripts).filter(
47+
(name) => name.startsWith('check:') && !(name in EXCLUDED)
48+
)
49+
return [...derived, ...EXTRA_AUDITS]
50+
}
4651

47-
async function runAudit(script: string): Promise<AuditResult> {
52+
/**
53+
* Runs one audit, capturing its output.
54+
*
55+
* Spawns the script directly rather than `bun run <name>`, which would start a bun process
56+
* only to have it read package.json and start a second one.
57+
*/
58+
async function runAudit(script: string, command: string): Promise<AuditResult> {
4859
const startedAt = performance.now()
49-
const proc = Bun.spawn(['bun', 'run', script], {
60+
const argv = command.replace(/^bun run /, '').split(/\s+/)
61+
const proc = Bun.spawn([process.execPath, ...argv], {
62+
cwd: ROOT,
5063
stdout: 'pipe',
5164
stderr: 'pipe',
5265
env: { ...process.env, FORCE_COLOR: '0' },
@@ -64,33 +77,63 @@ async function runAudit(script: string): Promise<AuditResult> {
6477
}
6578
}
6679

67-
const queue = [...AUDITS]
80+
const manifest = await Bun.file(path.join(ROOT, 'package.json')).json()
81+
const commands = manifest.scripts as Record<string, string>
82+
const queue = await auditScripts()
83+
const total = queue.length
84+
// The coordinator only awaits, so it does not need a core reserved for it.
85+
const workers = Math.min(Math.max(2, navigator.hardwareConcurrency), total)
6886
const results: AuditResult[] = []
6987

7088
async function worker(): Promise<void> {
71-
for (let script = queue.shift(); script; script = queue.shift()) {
72-
const result = await runAudit(script)
89+
let script: string | undefined
90+
while ((script = queue.shift())) {
91+
const result = await runAudit(script, commands[script])
7392
results.push(result)
7493
console.log(`${result.ok ? '✓' : '✗'} ${result.script} (${Math.round(result.durationMs)}ms)`)
7594
}
7695
}
7796

7897
const startedAt = performance.now()
79-
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, AUDITS.length) }, worker))
98+
await Promise.all(Array.from({ length: workers }, worker))
8099
const wallMs = performance.now() - startedAt
81-
82-
const failures = results.filter((result) => !result.ok)
83-
const serialMs = results.reduce((total, result) => total + result.durationMs, 0)
100+
const serialMs = results.reduce((sum, result) => sum + result.durationMs, 0)
84101

85102
console.log(
86-
`\n${results.length} audits in ${(wallMs / 1000).toFixed(1)}s wall (${(serialMs / 1000).toFixed(1)}s serial, ${CONCURRENCY}-way)`
103+
`\n${total} audits in ${(wallMs / 1000).toFixed(1)}s wall (${(serialMs / 1000).toFixed(1)}s serial, ${workers}-way)`
87104
)
88105

106+
/**
107+
* Restores what the per-step workflow gave up: collapsible per-audit output and inline
108+
* failure annotations in the GitHub UI, plus a timing table the separate steps never had.
109+
*/
110+
if (process.env.GITHUB_ACTIONS) {
111+
const summary = [
112+
'| Audit | Result | Duration |',
113+
'| --- | --- | --- |',
114+
...[...results]
115+
.sort((a, b) => b.durationMs - a.durationMs)
116+
.map((r) => `| \`${r.script}\` | ${r.ok ? '✓' : '✗'} | ${Math.round(r.durationMs)}ms |`),
117+
'',
118+
`${total} audits in ${(wallMs / 1000).toFixed(1)}s wall (${(serialMs / 1000).toFixed(1)}s serial, ${workers}-way)`,
119+
].join('\n')
120+
if (process.env.GITHUB_STEP_SUMMARY) {
121+
await Bun.write(process.env.GITHUB_STEP_SUMMARY, `${summary}\n`)
122+
}
123+
}
124+
125+
const failures = results.filter((result) => !result.ok)
89126
if (failures.length > 0) {
90127
for (const failure of failures) {
91-
console.error(`\n${'─'.repeat(72)}\n✗ ${failure.script}\n${'─'.repeat(72)}`)
92-
console.error(failure.output || '(no output)')
128+
if (process.env.GITHUB_ACTIONS) {
129+
console.error(`::group::✗ ${failure.script}`)
130+
console.error(failure.output || '(no output)')
131+
console.error('::endgroup::')
132+
console.error(`::error title=${failure.script}::audit failed — see the group above`)
133+
} else {
134+
console.error(`\n${'─'.repeat(72)}\n✗ ${failure.script}\n${'─'.repeat(72)}`)
135+
console.error(failure.output || '(no output)')
136+
}
93137
}
94-
console.error(`\n${failures.length} audit(s) failed: ${failures.map((f) => f.script).join(', ')}`)
95138
process.exit(1)
96139
}

0 commit comments

Comments
 (0)