From d427a4b2bb7b84564c5d2232de13942bc75fe9d2 Mon Sep 17 00:00:00 2001 From: Bogdan Date: Fri, 7 Aug 2026 04:55:01 +0300 Subject: [PATCH] perf(cli): enable Node module compile cache (#2092) * perf(cli): enable Node module compile cache Warm CLI invocations spend substantial time compiling the bundled ESM entrypoint. Enable Node's optional on-disk compile cache only in the process that imports the bundle, while preserving early Node 22 compatibility and making cache failures non-fatal. Add deterministic launcher coverage, packaging checks, and a reproducible benchmark procedure so the startup benefit can be measured without flaky CI thresholds. * fix(ci): isolate minimum Node launcher check The full validation suite depends on knip and oxc-parser behavior unavailable in Node 22.0.0. Keep full CI on the active Node 22 line and exercise the declared runtime floor in a dedicated build-and-launch job. * fix(benchmark): harden startup measurements Keep environment setup outside the timed process window, document the API's Node 22.8 floor, and preserve completed benchmark results when git metadata is unavailable. * test(cli): verify compile cache disable behavior Pair NODE_DISABLE_COMPILE_CACHE with a temporary cache directory and assert that supported Node releases leave it empty while preserving normal launcher output. --- .github/workflows/pr-checks.yml | 35 ++- README.md | 24 ++ bin/node-compile-cache.mjs | 17 ++ bin/openclaude | 2 + package.json | 1 + scripts/benchmark-openclaude-startup.mjs | 206 ++++++++++++++++++ .../instrument-node-compile-cache.mjs | 24 ++ scripts/openclaude-bin-compile-cache.test.ts | 204 +++++++++++++++++ scripts/openclaude-bin-heap.test.ts | 19 +- scripts/verify-clean-install.ts | 1 + 10 files changed, 529 insertions(+), 4 deletions(-) create mode 100644 bin/node-compile-cache.mjs create mode 100644 scripts/benchmark-openclaude-startup.mjs create mode 100644 scripts/fixtures/instrument-node-compile-cache.mjs create mode 100644 scripts/openclaude-bin-compile-cache.test.ts diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml index e3231c794..ae573d1e7 100644 --- a/.github/workflows/pr-checks.yml +++ b/.github/workflows/pr-checks.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [22, "24.11.x"] + node-version: ["22", "24.11.x"] steps: - name: Check out repository @@ -38,6 +38,11 @@ jobs: - name: Smoke and full unit test suite run: bun run check + - name: Launcher compatibility + run: | + node bin/openclaude --version + NODE_DISABLE_COMPILE_CACHE=1 node bin/openclaude --version + - name: Suspicious PR intent scan env: PR_SCAN_BASE: ${{ github.event_name == 'pull_request' && github.event.pull_request.base.sha || 'origin/main' }} @@ -49,6 +54,34 @@ jobs: - name: Provider recommendation tests run: npm run test:provider-recommendation + launcher-node-floor: + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false + + - name: Set up minimum supported Node.js + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: "22.0.0" + + - name: Set up Bun + uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version-file: .bun-version + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Build + run: bun run build + + - name: Launch on the minimum supported Node.js + run: node bin/openclaude --version + typecheck: runs-on: ubuntu-latest diff --git a/README.md b/README.md index 6a3b25fa2..40e3d6c56 100644 --- a/README.md +++ b/README.md @@ -400,6 +400,30 @@ Day-to-day commands: Focused suites: `bun run test:provider`, `bun run test:provider-recommendation`. +To benchmark the launcher module compile cache, build the CLI and run: + +```bash +bun run build +bun run benchmark:startup +``` + +The benchmark requires Node `>=22.8.0`, where the compile-cache API was added; +the built OpenClaude launcher continues to support the declared Node `>=22.0.0` +runtime range. + +The benchmark defaults to 30 separate-process warm runs and 10 isolated +empty-cache runs. It reports the median, IQR, MAD, first cache-populating run, +first warm-up, Node/OS/CPU details, bundle size, and commit. Direct bundle +timings are included only as a secondary diagnostic; the full launcher result +is the decision signal. Use +`bun run benchmark:startup -- --warm-runs 40 --cold-runs 10` to request a +larger sample set. The benchmark records results without enforcing a timing +threshold in CI. + +OpenClaude leaves Node's standard compile-cache controls authoritative. Set +`NODE_DISABLE_COMPILE_CACHE=1` to disable the optimization, including for V8 +coverage runs that require uncached compilation. + Recommended validation before opening a PR: - `bun run build` diff --git a/bin/node-compile-cache.mjs b/bin/node-compile-cache.mjs new file mode 100644 index 000000000..326ebbe9a --- /dev/null +++ b/bin/node-compile-cache.mjs @@ -0,0 +1,17 @@ +import * as nodeModule from 'node:module' + +/** @typedef {{ enableCompileCache?: () => unknown }} CompileCacheModule */ + +/** + * @param {CompileCacheModule} [module=nodeModule] + */ +export function enableNodeCompileCacheIfAvailable(module = nodeModule) { + const enable = module.enableCompileCache + if (typeof enable !== 'function') return + + try { + enable() + } catch { + // Compile caching is optional and must never block startup. + } +} diff --git a/bin/openclaude b/bin/openclaude index 9f3f66e7f..f36a49946 100755 --- a/bin/openclaude +++ b/bin/openclaude @@ -11,6 +11,7 @@ import { existsSync } from 'fs' import { join, dirname } from 'path' import { fileURLToPath, pathToFileURL } from 'url' import { spawnSync } from 'child_process' +import { enableNodeCompileCacheIfAvailable } from './node-compile-cache.mjs' const __dirname = dirname(fileURLToPath(import.meta.url)) const distPath = join(__dirname, '..', 'dist', 'cli.mjs') @@ -107,6 +108,7 @@ function relaunchWithLongSessionHeapIfNeeded() { if (existsSync(distPath)) { relaunchWithLongSessionHeapIfNeeded() + enableNodeCompileCacheIfAvailable() await import(pathToFileURL(distPath).href) } else { console.error(` diff --git a/package.json b/package.json index bb5cacb2c..5edabe7c1 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ ], "scripts": { "build": "bun run scripts/build.ts", + "benchmark:startup": "node scripts/benchmark-openclaude-startup.mjs", "integrations:generate": "bun run scripts/generate-integrations-artifacts.ts", "integrations:check": "bun run scripts/generate-integrations-artifacts.ts --check", "dev": "bun run build && node bin/openclaude", diff --git a/scripts/benchmark-openclaude-startup.mjs b/scripts/benchmark-openclaude-startup.mjs new file mode 100644 index 000000000..e6ac2d05b --- /dev/null +++ b/scripts/benchmark-openclaude-startup.mjs @@ -0,0 +1,206 @@ +#!/usr/bin/env node + +import { spawnSync } from 'node:child_process' +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' +import * as nodeModule from 'node:module' +import { cpus, platform, release, tmpdir } from 'node:os' +import { join } from 'node:path' +import { performance } from 'node:perf_hooks' + +const MIN_WARM_RUNS = 20 +const DEFAULT_WARM_RUNS = 30 +const DEFAULT_COLD_RUNS = 10 +const REPO_ROOT = join(import.meta.dirname, '..') +const LAUNCHER_PATH = join(REPO_ROOT, 'bin', 'openclaude') +const BUNDLE_PATH = join(REPO_ROOT, 'dist', 'cli.mjs') +const PACKAGE_VERSION = JSON.parse( + readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'), +).version +const EXPECTED_VERSION_OUTPUT = `${PACKAGE_VERSION} (OpenClaude)` + +function readPositiveInteger(name, fallback) { + const index = process.argv.indexOf(name) + if (index === -1) return fallback + const parsed = Number.parseInt(process.argv[index + 1] ?? '', 10) + if (!Number.isSafeInteger(parsed) || parsed <= 0) { + throw new Error(`${name} must be a positive integer`) + } + return parsed +} + +const warmRuns = readPositiveInteger('--warm-runs', DEFAULT_WARM_RUNS) +const coldRuns = readPositiveInteger('--cold-runs', DEFAULT_COLD_RUNS) +if (warmRuns < MIN_WARM_RUNS) { + throw new Error(`--warm-runs must be at least ${MIN_WARM_RUNS}`) +} +if (typeof nodeModule.enableCompileCache !== 'function') { + throw new Error(`Node ${process.version} does not expose module.enableCompileCache; the benchmark requires Node >=22.8.0`) +} +if (!existsSync(BUNDLE_PATH) || !statSync(BUNDLE_PATH).isFile()) { + throw new Error('dist/cli.mjs is missing; run `bun run build` first') +} + +const scratch = mkdtempSync(join(tmpdir(), 'openclaude-startup-benchmark-')) + +function childEnv(tempRoot, cacheMode) { + mkdirSync(tempRoot, { recursive: true }) + const env = { + ...process.env, + CI: '1', + NO_COLOR: '1', + OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'), + TEMP: tempRoot, + TMP: tempRoot, + TMPDIR: tempRoot, + } + delete env.NODE_COMPILE_CACHE + delete env.NODE_DISABLE_COMPILE_CACHE + delete env.OPENCLAUDE_HEAP_RELAUNCHED + delete env.OPENCLAUDE_DISABLE_HEAP_RELAUNCH + + if (cacheMode === 'disabled') { + env.NODE_DISABLE_COMPILE_CACHE = '1' + } else if (cacheMode === 'environment') { + env.NODE_COMPILE_CACHE = join(tempRoot, 'node-compile-cache') + } + return env +} + +function sample(target, tempRoot, cacheMode) { + const path = target === 'launcher' ? LAUNCHER_PATH : BUNDLE_PATH + const env = childEnv(tempRoot, cacheMode) + const started = performance.now() + const result = spawnSync(process.execPath, [path, '--version'], { + cwd: REPO_ROOT, + encoding: 'utf8', + env, + timeout: 30_000, + }) + const elapsedMs = performance.now() - started + if ( + result.status !== 0 + || result.stdout.trim() !== EXPECTED_VERSION_OUTPUT + || result.stderr !== '' + ) { + throw new Error(`benchmark command failed: ${JSON.stringify({ + target, + cacheMode, + status: result.status, + stdout: result.stdout, + stderr: result.stderr, + })}`) + } + return elapsedMs +} + +function percentile(sorted, fraction) { + return sorted[Math.floor((sorted.length - 1) * fraction)] +} + +function summarize(values) { + const sorted = [...values].sort((a, b) => a - b) + const median = percentile(sorted, 0.5) + const deviations = sorted + .map(value => Math.abs(value - median)) + .sort((a, b) => a - b) + const p25 = percentile(sorted, 0.25) + const p75 = percentile(sorted, 0.75) + return { + samples: sorted.length, + medianMs: Number(median.toFixed(1)), + p25Ms: Number(p25.toFixed(1)), + p75Ms: Number(p75.toFixed(1)), + iqrMs: Number((p75 - p25).toFixed(1)), + madMs: Number(percentile(deviations, 0.5).toFixed(1)), + minMs: Number(sorted[0].toFixed(1)), + maxMs: Number(sorted.at(-1).toFixed(1)), + } +} + +function measureWarmPair(target, enabledCacheMode, prefix) { + const enabledRoot = join(scratch, `${prefix}-enabled`) + const disabledRoot = join(scratch, `${prefix}-disabled`) + const populationMs = sample(target, enabledRoot, enabledCacheMode) + const firstWarmupMs = sample(target, enabledRoot, enabledCacheMode) + const enabled = [] + const disabled = [] + + for (let index = 0; index < warmRuns; index++) { + if (index % 2 === 0) { + disabled.push(sample(target, disabledRoot, 'disabled')) + enabled.push(sample(target, enabledRoot, enabledCacheMode)) + } else { + enabled.push(sample(target, enabledRoot, enabledCacheMode)) + disabled.push(sample(target, disabledRoot, 'disabled')) + } + } + + return { + cachePopulationMs: Number(populationMs.toFixed(1)), + firstWarmupMs: Number(firstWarmupMs.toFixed(1)), + disabled: summarize(disabled), + enabledWarm: summarize(enabled), + } +} + +function measureColdLauncher() { + const samples = [] + for (let index = 0; index < coldRuns; index++) { + samples.push(sample('launcher', join(scratch, `launcher-cold-${index}`), 'default')) + } + return summarize(samples) +} + +function comparison(disabled, enabled) { + const savedMs = disabled.medianMs - enabled.medianMs + return { + savedMs: Number(savedMs.toFixed(1)), + improvementPercent: Number((savedMs / disabled.medianMs * 100).toFixed(1)), + } +} + +try { + const launcher = measureWarmPair('launcher', 'default', 'launcher-warm') + const launcherCold = measureColdLauncher() + const directBundle = measureWarmPair('bundle', 'environment', 'bundle-warm') + const gitResult = spawnSync('git', ['rev-parse', 'HEAD'], { + cwd: REPO_ROOT, + encoding: 'utf8', + }) + const gitCommit = gitResult.status === 0 && typeof gitResult.stdout === 'string' + ? gitResult.stdout.trim() || 'unknown' + : 'unknown' + + console.log(JSON.stringify({ + environment: { + node: process.version, + os: `${platform()} ${release()}`, + arch: process.arch, + cpu: cpus()[0]?.model, + logicalCpus: cpus().length, + bundleBytes: statSync(BUNDLE_PATH).size, + commit: gitCommit, + }, + methodology: { + coldRuns, + warmRuns, + separateProcesses: true, + coldDefinition: 'empty Node compile-cache root; filesystem caches are not flushed', + launcherEnabledMode: 'zero-argument launcher API with default cache location under isolated temp roots', + launcherDisabledMode: 'NODE_DISABLE_COMPILE_CACHE=1', + directBundleRole: 'secondary diagnostic using NODE_COMPILE_CACHE for explicit activation', + performanceThresholdEnforced: false, + }, + launcher: { + compileCacheCold: launcherCold, + ...launcher, + comparison: comparison(launcher.disabled, launcher.enabledWarm), + }, + directBundle: { + ...directBundle, + comparison: comparison(directBundle.disabled, directBundle.enabledWarm), + }, + }, null, 2)) +} finally { + rmSync(scratch, { recursive: true, force: true }) +} diff --git a/scripts/fixtures/instrument-node-compile-cache.mjs b/scripts/fixtures/instrument-node-compile-cache.mjs new file mode 100644 index 000000000..8e547f8e1 --- /dev/null +++ b/scripts/fixtures/instrument-node-compile-cache.mjs @@ -0,0 +1,24 @@ +import { appendFileSync } from 'node:fs' +import { createRequire, syncBuiltinESMExports } from 'node:module' + +const markerPath = process.env.OPENCLAUDE_TEST_COMPILE_CACHE_MARKER +const behavior = process.env.OPENCLAUDE_TEST_COMPILE_CACHE_BEHAVIOR +const builtinModule = createRequire(import.meta.url)('node:module') + +if (behavior === 'absent') { + builtinModule.enableCompileCache = undefined +} else if (typeof builtinModule.enableCompileCache === 'function') { + builtinModule.enableCompileCache = () => { + if (markerPath) { + appendFileSync(markerPath, `${JSON.stringify({ + pid: process.pid, + heapRelaunched: process.env.OPENCLAUDE_HEAP_RELAUNCHED === '1', + })}\n`) + } + if (behavior === 'throw') throw new Error('injected compile-cache failure') + if (behavior === 'failed-status') return { status: 0, message: 'injected failure' } + return { status: 1, directory: '/injected/cache' } + } +} + +syncBuiltinESMExports() diff --git a/scripts/openclaude-bin-compile-cache.test.ts b/scripts/openclaude-bin-compile-cache.test.ts new file mode 100644 index 000000000..058829007 --- /dev/null +++ b/scripts/openclaude-bin-compile-cache.test.ts @@ -0,0 +1,204 @@ +import { spawnSync } from 'node:child_process' +import { existsSync, mkdtempSync, readFileSync, readdirSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, test } from 'bun:test' + +import { enableNodeCompileCacheIfAvailable } from '../bin/node-compile-cache.mjs' + +const REPO_ROOT = join(import.meta.dir, '..') +const BIN_PATH = join(REPO_ROOT, 'bin', 'openclaude') +const INSTRUMENTATION_PATH = join( + import.meta.dir, + 'fixtures', + 'instrument-node-compile-cache.mjs', +) +const PACKAGE_VERSION = JSON.parse( + readFileSync(join(REPO_ROOT, 'package.json'), 'utf8'), +).version +const EXPECTED_VERSION_OUTPUT = `${PACKAGE_VERSION} (OpenClaude)\n` + +type LauncherResult = { + status: number | null + stdout: string + stderr: string +} + +function launcherEnv(overrides: NodeJS.ProcessEnv = {}): NodeJS.ProcessEnv { + const env = { + ...process.env, + CI: '1', + NO_COLOR: '1', + ...overrides, + } + delete env.OPENCLAUDE_HEAP_RELAUNCHED + delete env.OPENCLAUDE_DISABLE_HEAP_RELAUNCH + if (!Object.hasOwn(overrides, 'NODE_OPTIONS')) delete env.NODE_OPTIONS + if (!Object.hasOwn(overrides, 'NODE_COMPILE_CACHE')) delete env.NODE_COMPILE_CACHE + if (!Object.hasOwn(overrides, 'NODE_DISABLE_COMPILE_CACHE')) delete env.NODE_DISABLE_COMPILE_CACHE + return env +} + +function runLauncher( + env: NodeJS.ProcessEnv, + nodeArgs: string[] = [], +): LauncherResult { + const result = spawnSync('node', [...nodeArgs, BIN_PATH, '--version'], { + cwd: REPO_ROOT, + encoding: 'utf8', + env, + timeout: 30_000, + }) + return { + status: result.status, + stdout: result.stdout ?? '', + stderr: result.stderr ?? '', + } +} + +function expectNormalVersion(result: LauncherResult): void { + expect(result.status).toBe(0) + expect(result.stdout).toBe(EXPECTED_VERSION_OUTPUT) + expect(result.stderr).toBe('') +} + +function nodeSupportsCompileCache(): boolean { + return spawnSync( + 'node', + ['-e', "process.exit(typeof require('node:module').enableCompileCache === 'function' ? 0 : 1)"], + ).status === 0 +} + +function hasFileContent(path: string): boolean { + if (!existsSync(path)) return false + for (const entry of readdirSync(path, { withFileTypes: true })) { + const child = join(path, entry.name) + if (entry.isDirectory()) { + if (hasFileContent(child)) return true + } else { + return true + } + } + return false +} + +describe('enableNodeCompileCacheIfAvailable', () => { + test('leaves startup unchanged when the API is absent', () => { + expect(() => enableNodeCompileCacheIfAvailable({})).not.toThrow() + }) + + test('invokes an available API exactly once without a directory', () => { + const calls: unknown[][] = [] + enableNodeCompileCacheIfAvailable({ + enableCompileCache: (...args: unknown[]) => { + calls.push(args) + return { status: 1, directory: '/tmp/cache' } + }, + }) + + expect(calls).toEqual([[]]) + }) + + test('ignores a failed status result', () => { + expect(() => enableNodeCompileCacheIfAvailable({ + enableCompileCache: () => ({ status: 0, message: 'not writable' }), + })).not.toThrow() + }) + + test('swallows unexpected implementation throws', () => { + expect(() => enableNodeCompileCacheIfAvailable({ + enableCompileCache: () => { + throw new Error('unexpected') + }, + })).not.toThrow() + }) +}) + +describe('openclaude launcher compile cache', () => { + test('does not inherit ambient NODE_OPTIONS into boundary launches', () => { + const originalNodeOptions = process.env.NODE_OPTIONS + try { + process.env.NODE_OPTIONS = '--max-old-space-size=256 --expose-gc' + expect(launcherEnv().NODE_OPTIONS).toBeUndefined() + } finally { + if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS + else process.env.NODE_OPTIONS = originalNodeOptions + } + }) + + test('the real absent binding remains non-fatal and silent', () => { + const scratch = mkdtempSync(join(tmpdir(), 'openclaude-compile-cache-absent-')) + try { + const result = runLauncher( + launcherEnv({ + OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'), + OPENCLAUDE_TEST_COMPILE_CACHE_BEHAVIOR: 'absent', + }), + ['--import', INSTRUMENTATION_PATH], + ) + expectNormalVersion(result) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } + }) + + for (const behavior of ['success', 'failed-status', 'throw'] as const) { + test(`the final importing process survives ${behavior} setup`, () => { + if (!nodeSupportsCompileCache()) return + const scratch = mkdtempSync(join(tmpdir(), `openclaude-compile-cache-${behavior}-`)) + const markerPath = join(scratch, 'calls.jsonl') + try { + const result = runLauncher( + launcherEnv({ + OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'), + OPENCLAUDE_TEST_COMPILE_CACHE_BEHAVIOR: behavior, + OPENCLAUDE_TEST_COMPILE_CACHE_MARKER: markerPath, + }), + ['--import', INSTRUMENTATION_PATH], + ) + expectNormalVersion(result) + + const calls = readFileSync(markerPath, 'utf8') + .trim() + .split('\n') + .map(line => JSON.parse(line)) + expect(calls).toHaveLength(1) + expect(calls[0]).toMatchObject({ heapRelaunched: true }) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } + }) + } + + test('NODE_DISABLE_COMPILE_CACHE remains authoritative', () => { + const scratch = mkdtempSync(join(tmpdir(), 'openclaude-compile-cache-disabled-')) + const cacheDir = join(scratch, 'cache') + try { + expectNormalVersion(runLauncher(launcherEnv({ + NODE_COMPILE_CACHE: cacheDir, + NODE_DISABLE_COMPILE_CACHE: '1', + OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'), + }))) + if (nodeSupportsCompileCache()) expect(hasFileContent(cacheDir)).toBe(false) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } + }) + + test('a temporary NODE_COMPILE_CACHE gains content without changing output', () => { + if (!nodeSupportsCompileCache()) return + const scratch = mkdtempSync(join(tmpdir(), 'openclaude-compile-cache-real-')) + const cacheDir = join(scratch, 'cache') + try { + const env = launcherEnv({ + NODE_COMPILE_CACHE: cacheDir, + OPENCLAUDE_CONFIG_DIR: join(scratch, 'config'), + }) + expectNormalVersion(runLauncher(env)) + expectNormalVersion(runLauncher(env)) + expect(hasFileContent(cacheDir)).toBe(true) + } finally { + rmSync(scratch, { recursive: true, force: true }) + } + }) +}) diff --git a/scripts/openclaude-bin-heap.test.ts b/scripts/openclaude-bin-heap.test.ts index 896fa5b4c..74c2b8aa7 100644 --- a/scripts/openclaude-bin-heap.test.ts +++ b/scripts/openclaude-bin-heap.test.ts @@ -3,6 +3,7 @@ import { join } from 'node:path' import { describe, expect, test } from 'bun:test' const BIN_PATH = join(import.meta.dir, '..', 'bin', 'openclaude') +const COMPILE_CACHE_PATH = join(import.meta.dir, '..', 'bin', 'node-compile-cache.mjs') describe('openclaude launcher heap guard', () => { test('raises the current Node heap before loading dist/cli.mjs', () => { @@ -11,9 +12,14 @@ describe('openclaude launcher heap guard', () => { expect(source).toContain('--max-old-space-size=') expect(source).toContain('--expose-gc') expect(source).toContain('spawnSync(process.execPath') - expect(source.indexOf('relaunchWithLongSessionHeapIfNeeded()')).toBeLessThan( - source.indexOf("await import(pathToFileURL(distPath).href)"), - ) + const importingBranch = source.slice(source.indexOf('if (existsSync(distPath))')) + const relaunchIndex = importingBranch.indexOf('relaunchWithLongSessionHeapIfNeeded()') + const compileCacheIndex = importingBranch.indexOf('enableNodeCompileCacheIfAvailable()') + const importIndex = importingBranch.indexOf("await import(pathToFileURL(distPath).href)") + + expect(relaunchIndex).toBeGreaterThanOrEqual(0) + expect(compileCacheIndex).toBeGreaterThan(relaunchIndex) + expect(importIndex).toBeGreaterThan(compileCacheIndex) }) test('keeps user and troubleshooting escape hatches', () => { @@ -24,4 +30,11 @@ describe('openclaude launcher heap guard', () => { expect(source).toContain('process.env.NODE_OPTIONS') expect(source).toContain("hasNodeOptionFlag('--max-old-space-size')") }) + + test('feature-detects the compile-cache API without a named builtin import', () => { + const source = readFileSync(COMPILE_CACHE_PATH, 'utf-8') + + expect(source).toContain("import * as nodeModule from 'node:module'") + expect(source).not.toMatch(/import\s*\{[^}]*enableCompileCache[^}]*\}\s*from\s*['"]node:module['"]/s) + }) }) diff --git a/scripts/verify-clean-install.ts b/scripts/verify-clean-install.ts index 22671ff4e..8c9b1d5f7 100644 --- a/scripts/verify-clean-install.ts +++ b/scripts/verify-clean-install.ts @@ -298,6 +298,7 @@ function checkTarballContents(tarballPath: string): void { const required = [ 'package/package.json', 'package/bin/openclaude', + 'package/bin/node-compile-cache.mjs', 'package/dist/cli.mjs', 'package/dist/sdk.mjs', 'package/src/entrypoints/sdk.d.ts',