Files
orca/config/scripts/check-runtime-electron-ratchet.mjs
T
Neil e217fdd10f build(orcad): gate orcad's own graph, and prove it loads under plain Node (#16368)
* fix(orcad): close the browser-provider gaps

The providers landed without enforced coverage, so a regression in either path
would have landed silently.

- CI: the external-Chromium integration test was gated on ORCA_BROWSER_EXECUTABLE
  and nothing ever set it, so it skipped forever. It now runs in its own job
  against the runner's Chrome and FAILS when Chrome is absent rather than
  skipping, because an unset variable is exactly how it went uncovered. Timeout
  raised to 120s: a warm run is ~7s but the first launch against an unseeded
  profile took 30s and hit Vitest's default, and CI is always that cold case.
- Electron provider had no test at all. It is the path anyone with the desktop
  app hits.
- Browser unavailability reported one message for four causes, including telling
  an operator to set a variable they had already set.

Fixes a live defect found while covering it: the runtime advertises
browser.tabCreate.known-id.v1 unconditionally, so a web client sends a
provisional page id for a page that does not exist yet — and the sidecar's
generic requestedPageId branch ran require() on it first and threw. Every
known-id create against the Electron provider failed. The adoption logic was
already there; only the ordering was wrong.

Also updates the workflow-parallelism guard, which correctly caught the new job
missing from verify's required-check list, and asserts verify actually reads it.

* build(orcad): gate orcad's own graph, and prove it loads under plain Node

Two gaps the artifact's own comment asked for.

The ratchet measured only orca-runtime + runtime-rpc, but orcad imports ipc/pty
directly to install the PTY controller, so its graph is strictly larger. The gate
could read zero while the shipped artifact regressed. orcad's entry is now a
ratchet entry point, and the baseline stays empty with it included.

orcad cannot join plain-node-entry-guard — that is a rollup plugin keyed on
electron-vite input names, and orcad is an esbuild artifact. But the half that
matters here is the guard's smoke-load: scanning the metafile proves no module
NAMES electron, not that the graph resolves under plain Node. A dynamic require,
a missing native or a top-level throw all pass the scan and fail at runtime.
build-orcad now runs the bundle with a bogus flag and requires the argv rejection
that only a fully loaded graph can produce.

Verified: a bundle that builds but throws on load fails the gate.
2026-08-24 22:21:26 -07:00

175 lines
6.6 KiB
JavaScript

#!/usr/bin/env node
/**
* Ratchet gate for Electron imports reachable from the Orca runtime.
*
* The runtime is meant to become host-agnostic so it can also run on plain Node
* (see docs/design/node-only-runtime-backend.html). Nothing enforces that today:
* `orca-runtime.ts` reaches ~50 modules that import `electron`, and the number
* silently grows whenever someone adds an import several hops away, because no
* single reviewer sees the transitive edge.
*
* This bundles the runtime with esbuild, reads the metafile for every module that
* imports `electron`, and compares that set to a checked-in baseline. A NEW module
* fails the build; a removed one must be dropped from the baseline. The baseline
* may only shrink, so the migration is measurable and cannot regress.
*
* This is a reachability check, not a lint rule: the point is precisely the edges
* that no per-file rule can see.
*
* Usage: node config/scripts/check-runtime-electron-ratchet.mjs [--write]
*/
import { build } from 'esbuild'
import { readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import process from 'node:process'
// Why absolute, not cwd-relative: `pnpm lint` runs from the repo root but CI steps and
// editors do not always, and a cwd-relative miss surfaced as an unhandled ENOENT stack
// instead of a usable message.
const ROOT = path.join(import.meta.dirname, '..', '..')
const BASELINE_PATH = path.join(ROOT, 'config', 'runtime-electron-baseline.txt')
// The two module graphs a Node backend would have to boot: the runtime service
// itself and the RPC server that fronts it.
const ENTRY_POINTS = [
path.join(ROOT, 'src', 'main', 'runtime', 'orca-runtime.ts'),
path.join(ROOT, 'src', 'main', 'runtime', 'runtime-rpc.ts'),
// Why orcad too: it imports ipc/pty directly to install the PTY controller, so its
// graph is strictly larger than the two runtime entries. Measuring only those let the
// two numbers drift — the gate would read zero while the shipped artifact regressed.
path.join(ROOT, 'src', 'main', 'orcad', 'main.ts')
]
// Native addons and electron cannot be bundled; externalising them is what the
// relay build already does (config/scripts/build-relay.mjs).
const EXTERNAL = [
'electron',
'node-pty',
'@parcel/watcher',
'better-sqlite3',
'keytar',
'fsevents',
'cpu-features'
]
/**
* Why: some optional native deps (ssh2's cpu-features) reference a prebuilt `.node`
* that only exists where a build toolchain has run. Resolving them made this gate
* pass on a developer machine and hard-fail on CI. Nothing here needs the addon —
* only the import graph — so mark every `.node` external instead.
*/
const externalNativeAddons = {
name: 'external-native-addons',
setup(pluginBuild) {
pluginBuild.onResolve({ filter: /\.node$/ }, (args) => ({ path: args.path, external: true }))
}
}
export async function collectElectronImporters(entryPoints = ENTRY_POINTS) {
const result = await build({
entryPoints,
bundle: true,
write: false,
// Why outdir with write:false: esbuild refuses multiple entry points without one,
// even though nothing is emitted — the metafile is all this reads.
outdir: path.join(ROOT, 'runtime-electron-ratchet-metafile-only'),
platform: 'node',
target: 'node20',
format: 'cjs',
external: EXTERNAL,
metafile: true,
absWorkingDir: ROOT,
logLevel: 'silent',
plugins: [externalNativeAddons]
})
const importers = new Set()
for (const [file, info] of Object.entries(result.metafile.inputs)) {
for (const imported of info.imports ?? []) {
// Subpaths (electron/main) are as unavailable under plain Node as the bare module.
if (imported.path === 'electron' || imported.path.startsWith('electron/')) {
importers.add(path.relative(ROOT, path.resolve(ROOT, file)).split(path.sep).join('/'))
}
}
}
return [...importers].sort()
}
export function readBaseline(text) {
return text
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith('#'))
.sort()
}
export function diffAgainstBaseline(current, baseline) {
const baselineSet = new Set(baseline)
const currentSet = new Set(current)
return {
added: current.filter((file) => !baselineSet.has(file)),
removed: baseline.filter((file) => !currentSet.has(file))
}
}
function renderBaseline(files) {
return [
'# Modules reachable from the Orca runtime that import `electron`.',
'# Generated by config/scripts/check-runtime-electron-ratchet.mjs.',
'# This list is EMPTY and must stay that way: the runtime boots on plain Node',
'# (see `pnpm run build:orcad`). Any entry means the runtime got less portable;',
'# migrate the module behind a host port instead (src/main/host/).',
'',
...files
].join('\n')
}
async function main() {
const write = process.argv.includes('--write')
const current = await collectElectronImporters()
if (write) {
writeFileSync(BASELINE_PATH, `${renderBaseline(current)}\n`)
console.log(`[runtime-electron-ratchet] wrote ${current.length} entries to ${BASELINE_PATH}`)
return
}
const baseline = readBaseline(readFileSync(BASELINE_PATH, 'utf8'))
const { added, removed } = diffAgainstBaseline(current, baseline)
if (added.length > 0) {
console.error(
`[runtime-electron-ratchet] ${added.length} new module(s) reachable from the Orca runtime now import electron:
${added.map((file) => ` + ${file}`).join('\n')}
The runtime must stay bootable on plain Node. Put the Electron facility behind a port in
src/main/host/ and depend on the port, or move the code out of the runtime's import graph.
See docs/design/node-only-runtime-backend.html.`
)
process.exitCode = 1
return
}
if (removed.length > 0) {
console.error(
`[runtime-electron-ratchet] ${removed.length} module(s) no longer import electron — nice.
Refresh the baseline so the gate keeps its new, tighter floor:
${removed.map((file) => ` - ${file}`).join('\n')}
node config/scripts/check-runtime-electron-ratchet.mjs --write`
)
process.exitCode = 1
return
}
console.log(`[runtime-electron-ratchet] ok — ${current.length} entries, unchanged.`)
}
// Why pathToFileURL and not a `file://` template: on Windows process.argv[1] is a
// native path (C:\repo\...) while import.meta.url is file:///C:/repo/..., so the
// template never matches and the gate would exit 0 without checking anything — a
// lint gate that fails open. Same idiom as check-max-lines-ratchet.mjs:225.
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main()
}