Skip to content

Commit bbde273

Browse files
committed
perf(ci): parallelize the repo audits and guard env-dependent tests
The 21 independent audits ran as 21 sequential CI steps, each a single-threaded read-only walk of the tree. scripts/run-audits.ts runs them concurrently: 28s serial -> 5.0s wall locally at 13-way. It buffers each audit's output and replays only failures, so a green run stays quiet and a red one still names the audit and shows why. Audits needing a git base ref (block registry, migration safety) or that write files (drizzle generate) stay as their own steps. Also fixes 5 tests that fail for every macOS dev and are invisible in CI. They shell out to python3 using `match` statements and 3.12 f-string nesting, which need >= 3.10; stock macOS ships 3.9.6, so `bun run test` produced raw Python SyntaxErrors with no guard and nothing tying them to a missing tool. One also needs ripgrep, which CI installs and a Mac usually does not. @sim/testing/environment detects both and the tests skip with a reason via vitest's ctx.skip(). Under CI it throws instead: these suites deliberately run the real helper rather than a mock -- the cloud-review path/read-size bounds and the placeholder compiler's generated Python are only observable that way -- so a missing tool in CI means a security boundary silently stopped being covered, which is worse than a red build. Drops the Codecov upload. The workflow already documented it as a dead path: nothing generates apps/sim/coverage, vitest runs without --coverage, and fail_ci_if_error hides it, so it reported green having uploaded nothing.
1 parent e1f2bf8 commit bbde273

9 files changed

Lines changed: 205 additions & 90 deletions

File tree

.github/workflows/test-build.yml

Lines changed: 16 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -117,70 +117,22 @@ jobs:
117117
- name: Lint code
118118
run: bun run lint:check
119119

120-
- name: Enforce monorepo boundaries
121-
run: bun run check:boundaries
122-
123-
- name: API contract boundary audit
124-
run: bun run check:api-validation:strict
125-
126-
- name: Desktop bridge contract audit
127-
run: bun run check:desktop-bridge
128-
129-
# Complements the bridge audit above, which compares against a snapshot
130-
# this same PR is allowed to regenerate. This one derives every fact from
131-
# the source both sides execute, so it has no such blind spot.
132-
- name: Desktop IPC contract audit
133-
run: bun run check:desktop-ipc
134-
135-
- name: Shared utils enforcement audit
136-
run: bun run check:utils
137-
138-
- name: Zustand v5 selector audit
139-
run: bun run check:zustand-v5
140-
141-
- name: React Query pattern audit
142-
run: bun run check:react-query
143-
144-
- name: Client boundary import audit
145-
run: bun run check:client-boundary
146-
147-
- name: Bare-icon theme-safety audit
148-
run: bun run check:bare-icons
149-
150-
- name: Icon SVG path validity audit
151-
run: bun run check:icon-paths
152-
153-
- name: Verify realtime prune graph
154-
run: bun run check:realtime-prune
155-
156-
- name: Tool registry client-boundary audit
157-
run: bun run check:tool-registry-boundary
158-
159-
- name: Tool request transport boundary audit
160-
run: bun run check:tool-request-boundary
161-
162-
- name: Trigger/block initialization cycle audit
163-
run: bun run check:trigger-block-cycle
164-
165-
# This job builds with webpack; devs run Turbopack. A specifier only webpack
166-
# resolves passes here and breaks every dev server.
167-
- name: Import specifier hygiene audit
168-
run: bun run check:import-specifiers
169-
170-
- name: SQL Date binding audit
171-
run: bun run check:sql-date-binding
172-
173-
- name: Verify generated tool metadata is in sync
174-
run: bun run tool-metadata:check
175-
176-
- name: Verify integration deployment metadata is in sync
177-
run: bun run integration-catalog:check
178-
179-
- name: Verify skill projections are in sync
180-
run: bun run skills:check
181-
182-
- name: Verify agent stream capability docs are in sync
183-
run: bun run agent-stream-docs:check
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.
134+
- name: Repo audits
135+
run: bun run check:audits
184136

185137
- name: Migration safety (zero-downtime) audit
186138
run: |
@@ -192,12 +144,6 @@ jobs:
192144
fi
193145
bun run check:migrations "$BASE_REF"
194146
195-
# Runs before the type-check itself: if a bare `tsc` has fallen back to the
196-
# JavaScript TypeScript 6 compiler that `@typescript/typescript6` drags in
197-
# transitively, the type-check below still passes — it just takes ~10x longer.
198-
- name: Native type-check audit
199-
run: bun run check:native-typecheck
200-
201147
# Every workspace, not just realtime. packages/emcn, packages/utils,
202148
# apps/desktop and apps/docs had no type check in CI at all; apps/sim's
203149
# source was covered only as a side effect of `next build` in the separate
@@ -236,21 +182,6 @@ jobs:
236182
fi
237183
echo "✅ Schema and migrations are in sync"
238184
239-
# DEAD PATH: nothing generates `apps/sim/coverage`. The test step runs
240-
# `vitest run` without `--coverage`, and vitest.config.ts declares no
241-
# coverage provider, so this uploads nothing and still reports success in
242-
# ~1s (`fail_ci_if_error: false` hides it). `@vitest/coverage-v8` IS
243-
# installed, so wiring it up is possible — but coverage instrumentation
244-
# costs test time and nothing gates on the result today. Left in place
245-
# pending a decision to either enable coverage or drop this step; do not
246-
# read its green tick as "coverage was published".
247-
- name: Upload coverage to Codecov
248-
uses: codecov/codecov-action@0fb7174895f61a3b6b78fc075e0cd60383518dac # v5
249-
with:
250-
directory: ./apps/sim/coverage
251-
fail_ci_if_error: false
252-
verbose: true
253-
254185
# Next.js production build, in parallel with lint + tests. Sticky disks are
255186
# cloned from the last committed snapshot per job and committed last-writer-
256187
# wins, so concurrent mounts are safe. The bun/node_modules disks are shared

apps/sim/executor/handlers/pi/cloud-review-tools.test.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import { tmpdir } from 'node:os'
77
import { join } from 'node:path'
88
import { promisify } from 'node:util'
99
import * as sdk from '@earendil-works/pi-coding-agent'
10+
import {
11+
hasPython3,
12+
hasRipgrep,
13+
PYTHON_SKIP_REASON,
14+
RIPGREP_SKIP_REASON,
15+
} from '@sim/testing/environment'
1016
import { beforeEach, describe, expect, it, vi } from 'vitest'
1117
import type { PiSandboxRunner } from '@/lib/execution/remote-sandbox'
1218
import {
@@ -61,7 +67,9 @@ describe('cloud review tools', () => {
6167
expect(source).not.toContain('--unified=20')
6268
})
6369

64-
it('enforces read-size and canonical path bounds in the actual helper', async () => {
70+
it('enforces read-size and canonical path bounds in the actual helper', async (ctx) => {
71+
if (!hasPython3()) ctx.skip(PYTHON_SKIP_REASON)
72+
if (!hasRipgrep()) ctx.skip(RIPGREP_SKIP_REASON)
6573
await installCloudReviewTools(runner)
6674
const source = writeFile.mock.calls[0][1] as string
6775
const testDir = await mkdtemp(join(tmpdir(), 'sim-review-tools-'))

apps/sim/lib/execution/code-placeholders/compiler.test.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
* @vitest-environment node
33
*/
44
import { spawnSync } from 'node:child_process'
5+
import { hasPython3, PYTHON_SKIP_REASON } from '@sim/testing/environment'
56
import { afterEach, describe, expect, it } from 'vitest'
67
import {
78
analyzeCodePlaceholders,
@@ -580,7 +581,8 @@ describe('code placeholder compiler', () => {
580581
)
581582
})
582583

583-
it('distinguishes Python lambda identifiers, match guards, and soft keywords from names', async () => {
584+
it('distinguishes Python lambda identifiers, match guards, and soft keywords from names', async (ctx) => {
585+
if (!hasPython3()) ctx.skip(PYTHON_SKIP_REASON)
584586
const compiled = await compileCodePlaceholders({
585587
code: [
586588
'lambda_value = "prefix-"',
@@ -688,7 +690,8 @@ describe('code placeholder compiler', () => {
688690
expect(executePython(compiled.code, compiled.bindings)).toBe(' 7\n')
689691
})
690692

691-
it('supports quoted and embedded strings inside Python f-string expressions', async () => {
693+
it('supports quoted and embedded strings inside Python f-string expressions', async (ctx) => {
694+
if (!hasPython3()) ctx.skip(PYTHON_SKIP_REASON)
692695
const value = 'quote"\\\n{{OTHER}}'
693696
const compiled = await compileCodePlaceholders({
694697
code: [
@@ -734,7 +737,8 @@ describe('code placeholder compiler', () => {
734737
}
735738
})
736739

737-
it('ignores Python f-string comments and does not group adjacent strings across dedents', async () => {
740+
it('ignores Python f-string comments and does not group adjacent strings across dedents', async (ctx) => {
741+
if (!hasPython3()) ctx.skip(PYTHON_SKIP_REASON)
738742
const code = [
739743
'def build():',
740744
' value = "{{KEY}}"',

apps/sim/lib/execution/remote-sandbox/fellows-digest.acceptance.test.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { createHash } from 'node:crypto'
77
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
88
import { tmpdir } from 'node:os'
99
import { join, resolve } from 'node:path'
10+
import { hasPython3, PYTHON_SKIP_REASON } from '@sim/testing/environment'
1011
import { afterEach, describe, expect, it, vi } from 'vitest'
1112
import { CodeLanguage } from '@/lib/execution/languages'
1213
import {
@@ -159,7 +160,8 @@ describe('Fellows Council Weekly sandbox acceptance fixture', () => {
159160
)
160161
})
161162

162-
it('stubs every external service and exports deterministic HTML and JSON in a ZIP', () => {
163+
it('stubs every external service and exports deterministic HTML and JSON in a ZIP', (ctx) => {
164+
if (!hasPython3()) ctx.skip(PYTHON_SKIP_REASON)
163165
const firstRoot = makeTempRoot()
164166
const secondRoot = makeTempRoot()
165167
const first = runFixture(firstRoot)

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"check:icon-paths": "bun run scripts/check-icon-paths.ts",
4545
"check:migrations": "bun run scripts/check-migrations-safety.ts",
4646
"check:native-typecheck": "bun run scripts/check-native-typecheck.ts",
47+
"check:audits": "bun run scripts/run-audits.ts",
4748
"check:desktop-bridge": "bun run scripts/check-desktop-bridge-contract.ts --check",
4849
"check:desktop-ipc": "bun run scripts/check-desktop-ipc-contract.ts",
4950
"desktop-bridge-contract:update": "bun run scripts/check-desktop-bridge-contract.ts --update",

packages/testing/package.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@
3939
"./setup": {
4040
"types": "./src/setup/vitest.setup.ts",
4141
"default": "./src/setup/vitest.setup.ts"
42+
},
43+
"./environment": {
44+
"types": "./src/environment/index.ts",
45+
"default": "./src/environment/index.ts"
4246
}
4347
},
4448
"scripts": {
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Detects external command-line tools that a handful of tests shell out to.
3+
*
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.
13+
*/
14+
import { spawnSync } from 'node:child_process'
15+
16+
/** Python 3.10 is the floor: the compiler suite generates `match` statements. */
17+
export const MIN_PYTHON: readonly [number, number] = [3, 10]
18+
19+
/** Reasons to pass to vitest's `ctx.skip(...)` so the report says why, not just that. */
20+
export const PYTHON_SKIP_REASON = 'needs python3 >= 3.10; macOS ships 3.9'
21+
export const RIPGREP_SKIP_REASON = 'needs ripgrep (`rg`) on PATH'
22+
23+
const warned = new Set<string>()
24+
25+
function unavailable(tool: string, hint: string): false {
26+
if (process.env.CI) {
27+
throw new Error(
28+
`${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}`
29+
)
30+
}
31+
if (!warned.has(tool)) {
32+
warned.add(tool)
33+
console.warn(`[@sim/testing] Skipping tests that require ${tool}. ${hint}`)
34+
}
35+
return false
36+
}
37+
38+
/** Parses `python3 --version`, returning null when the interpreter is missing or unreadable. */
39+
export function detectPython3(): { major: number; minor: number } | null {
40+
const result = spawnSync('python3', ['--version'], { encoding: 'utf8' })
41+
const match = /Python (\d+)\.(\d+)/.exec(`${result.stdout ?? ''}${result.stderr ?? ''}`)
42+
if (!match) return null
43+
return { major: Number(match[1]), minor: Number(match[2]) }
44+
}
45+
46+
/**
47+
* True when `python3` resolves to at least {@link MIN_PYTHON}.
48+
*
49+
* macOS ships 3.9 as the system `python3` and Homebrew's newer builds are not linked as
50+
* `python3`, so this is false on a stock Mac even when a modern Python is installed.
51+
*/
52+
export function hasPython3(): boolean {
53+
const version = detectPython3()
54+
const [minMajor, minMinor] = MIN_PYTHON
55+
const label = `python3 >= ${minMajor}.${minMinor}`
56+
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.`
57+
if (!version) return unavailable(label, hint)
58+
if (version.major > minMajor) return true
59+
if (version.major === minMajor && version.minor >= minMinor) return true
60+
return unavailable(label, hint)
61+
}
62+
63+
/** True when `rg` is an executable on PATH. */
64+
export function hasRipgrep(): boolean {
65+
const result = spawnSync('rg', ['--version'], { encoding: 'utf8' })
66+
if (result.status === 0) return true
67+
return unavailable('ripgrep (`rg`)', 'Install it with `brew install ripgrep`.')
68+
}

packages/testing/src/index.ts

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

4444
export * from './assertions'
4545
export * from './builders'
46+
export * from './environment'
4647
export * from './factories'
4748
export * from './mocks'
4849
export * from './types'

scripts/run-audits.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
#!/usr/bin/env bun
2+
/**
3+
* Runs the independent repo audits concurrently.
4+
*
5+
* Each audit is a self-contained read-only pass over the tree, so running them as 20-odd
6+
* 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.
10+
*
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.
13+
*/
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',
32+
'tool-metadata:check',
33+
'integration-catalog:check',
34+
'skills:check',
35+
'agent-stream-docs:check',
36+
] as const
37+
38+
interface AuditResult {
39+
script: string
40+
ok: boolean
41+
durationMs: number
42+
output: string
43+
}
44+
45+
const CONCURRENCY = Math.max(2, navigator.hardwareConcurrency - 1)
46+
47+
async function runAudit(script: string): Promise<AuditResult> {
48+
const startedAt = performance.now()
49+
const proc = Bun.spawn(['bun', 'run', script], {
50+
stdout: 'pipe',
51+
stderr: 'pipe',
52+
env: { ...process.env, FORCE_COLOR: '0' },
53+
})
54+
const [stdout, stderr, exitCode] = await Promise.all([
55+
new Response(proc.stdout).text(),
56+
new Response(proc.stderr).text(),
57+
proc.exited,
58+
])
59+
return {
60+
script,
61+
ok: exitCode === 0,
62+
durationMs: performance.now() - startedAt,
63+
output: `${stdout}${stderr}`.trimEnd(),
64+
}
65+
}
66+
67+
const queue = [...AUDITS]
68+
const results: AuditResult[] = []
69+
70+
async function worker(): Promise<void> {
71+
for (let script = queue.shift(); script; script = queue.shift()) {
72+
const result = await runAudit(script)
73+
results.push(result)
74+
console.log(`${result.ok ? '✓' : '✗'} ${result.script} (${Math.round(result.durationMs)}ms)`)
75+
}
76+
}
77+
78+
const startedAt = performance.now()
79+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, AUDITS.length) }, worker))
80+
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)
84+
85+
console.log(
86+
`\n${results.length} audits in ${(wallMs / 1000).toFixed(1)}s wall (${(serialMs / 1000).toFixed(1)}s serial, ${CONCURRENCY}-way)`
87+
)
88+
89+
if (failures.length > 0) {
90+
for (const failure of failures) {
91+
console.error(`\n${'─'.repeat(72)}\n✗ ${failure.script}\n${'─'.repeat(72)}`)
92+
console.error(failure.output || '(no output)')
93+
}
94+
console.error(`\n${failures.length} audit(s) failed: ${failures.map((f) => f.script).join(', ')}`)
95+
process.exit(1)
96+
}

0 commit comments

Comments
 (0)