Files
openclaude/scripts/build.ts
JATMNandGitHub f12eb1c9e8 Harden test isolation for smoke stability (#1192)
* Fix flaky smoke build checks

Replace feature-flag build preprocessing with a Bun onLoad transform so smoke/build no longer rewrites tracked src files while tests may be reading them.

Keep telemetry stubs ahead of the feature transform in both CLI and SDK builds, preserve non-empty text token counts in hybrid context splitting, and make the corrupted Orama stress test assert against the actual project directory used by the test.

Verified with bun run smoke, bun test src/utils/hybridContextStrategy.test.ts, bun test src/utils/knowledgeGraph.stress.test.ts --rerun-each 3, and bun test --max-concurrency=1.

* Harden KnowledgeGraph smoke stress isolation

Give each KnowledgeGraph stress test its own temporary config directory and remove it during teardown so Orama, SQLite, and corrupted-file state cannot bleed between stress cases or later PR test runs.

Reviewed at least 20 open PRs and found the recurring smoke-and-tests failure cluster is the full unit suite, especially KnowledgeGraph corrupted Orama recovery. Verified with bun test src/utils/knowledgeGraph.stress.test.ts --rerun-each 5, bun test --max-concurrency=1, and bun run smoke.

* Harden smoke test isolation

Audit and harden broad smoke-adjacent test suites for process-global leaks, including env/config restoration, shared registry/module mock cleanup, fetch/axios/mock restoration, and global MACRO/platform/sandbox mutations.

Replace fragile render sleeps in interactive tests with output-driven waits, and isolate provider/model/profile tests behind the shared mutation lock so unrelated PRs do not inherit stale process state.

Make SQLite knowledge graph cleanup clear closed on-disk databases before best-effort file cleanup, with coverage for the stale database reset path.

Verified: bun run smoke; bun test --max-concurrency=1; python -m pytest -q python/tests; bun run security:pr-scan -- --base origin/main; bun run test:provider; npm run test:provider-recommendation.

* Harden test isolation across smoke suite

Guard process-global test mutations with the shared mutation lock across env, module mock, config cache, and storage tests.\n\nDeep-copy global config snapshots, restore transient globals precisely, and make plugin/LSP mocks expose compatible export surfaces so concurrent test loading does not poison unrelated suites.\n\nReplace fixed SDK cleanup sleeps with call polling to remove timing sensitivity.\n\nVerification:\n- bun test --max-concurrency=1\n- bun run smoke\n- python -m pytest -q python/tests\n- bun run test:provider\n- npm run test:provider-recommendation\n- bun run security:pr-scan -- --base origin/main\n- git diff --check

* Close remaining test global-state leaks

Guard remaining cache, plugin, console, and VS Code module-mock tests with the shared mutation lock.\n\nThis follow-up audit covers non-env process-global state that can leak across test files: tool schema cache, cache stats tracker state, plugin loader caches, console.error replacement, and VS Code mock.module usage.\n\nVerification:\n- leak-surface scans for env/global/mock.module/cache outliers\n- duplicate top-level mock collision cluster\n- affected tests cluster\n- bun test --max-concurrency=1\n- bun run smoke\n- git diff --check

* Guard remaining mock restore cleanup

Lock tests that call bun:test mock.restore without installing module mocks themselves.\n\nmock.restore is process-global, so these cleanup hooks can still tear down another test file's active module mocks when files run concurrently.\n\nVerification:\n- expanded leak scans for env, globals, module mocks, mock.restore, timers, argv, and caches\n- bun test src/components/useCodexOAuthFlow.test.tsx src/services/github/deviceFlow.test.ts\n- bun test --max-concurrency=1\n- bun run smoke\n- git diff --check

* Harden test isolation for smoke stability

Serialize tests that mutate process-global state behind the shared mutation lock, including process.env, transient globals, global config/cache state, storage mocks, and Bun module mocks.

Add isolated env mutex instances for SDK mutex tests so timeout coverage no longer manipulates the live process-global mutex.

Move top-level mock.module setup behind lock acquisition and restore mocks before releasing locks to prevent cross-file leakage under parallel smoke runs.

Verified with: bun test --max-concurrency=1; bun test; bun run smoke.
2026-05-16 15:18:42 +08:00

889 lines
38 KiB
TypeScript

/**
* OpenClaude build script — bundles the TypeScript source into a single
* distributable JS file using Bun's bundler.
*
* Handles:
* - bun:bundle feature() flags for the open build
* - MACRO.* globals → inlined version/build-time constants
* - src/ path aliases
*/
import { readFileSync } from 'fs'
import { noTelemetryPlugin } from './no-telemetry-plugin'
import { CLI_EXTERNALS, SDK_EXTERNALS } from './externals.js'
const pkg = JSON.parse(readFileSync('./package.json', 'utf-8'))
const version = pkg.version
// Feature flags for the open build.
// Most Anthropic-internal features stay off; open-build features can be
// selectively enabled here when their full source exists in the mirror.
const featureFlags: Record<string, boolean> = {
// ── Disabled: require Anthropic infrastructure or missing source ─────
VOICE_MODE: false, // Push-to-talk STT via claude.ai OAuth endpoint
PROACTIVE: false, // Autonomous agent mode (missing proactive/ module)
KAIROS: false, // Persistent assistant/session mode (cloud backend)
BRIDGE_MODE: false, // Remote desktop bridge via CCR infrastructure
DAEMON: false, // Background daemon process (stubbed in open build)
AGENT_TRIGGERS: false, // Scheduled remote agent triggers
ABLATION_BASELINE: false, // A/B testing harness for eval experiments
CONTEXT_COLLAPSE: false, // Context collapsing optimization (stubbed)
COMMIT_ATTRIBUTION: false, // Co-Authored-By metadata in git commits
UDS_INBOX: false, // Unix Domain Socket inter-session messaging
BG_SESSIONS: false, // Background sessions via tmux (stubbed)
WEB_BROWSER_TOOL: false, // Built-in browser automation (source not mirrored)
CHICAGO_MCP: false, // Computer-use MCP (native Swift modules stubbed)
COWORKER_TYPE_TELEMETRY: false, // Telemetry for agent/coworker type classification
MCP_SKILLS: false, // Dynamic MCP skill discovery (src/skills/mcpSkills.ts not mirrored; enabling this causes "fetchMcpSkillsForClient is not a function" when MCP servers with resources connect — see #856)
// ── Enabled: upstream defaults ──────────────────────────────────────
COORDINATOR_MODE: true, // Multi-agent coordinator with worker delegation
BUILTIN_EXPLORE_PLAN_AGENTS: true, // Built-in Explore/Plan specialized subagents
BUDDY: true, // Buddy mode for paired programming
MONITOR_TOOL: true, // MCP server monitoring/streaming tool
TEAMMEM: true, // Team memory management
MESSAGE_ACTIONS: true, // Message action buttons in the UI
// ── Enabled: new activations ────────────────────────────────────────
DUMP_SYSTEM_PROMPT: true, // --dump-system-prompt CLI flag for debugging
CACHED_MICROCOMPACT: true, // Cache-aware tool result truncation optimization
AWAY_SUMMARY: true, // "While you were away" recap after 5min blur
TRANSCRIPT_CLASSIFIER: true, // Auto-approval classifier for safe tool uses
ULTRATHINK: true, // Deep thinking mode — type "ultrathink" to boost reasoning
TOKEN_BUDGET: true, // Token budget tracking with usage warnings
HISTORY_PICKER: true, // Enhanced interactive prompt history picker
QUICK_SEARCH: true, // Ctrl+G quick search across prompts
SHOT_STATS: true, // Shot distribution stats in session summary
EXTRACT_MEMORIES: true, // Auto-extract durable memories from conversations
FORK_SUBAGENT: true, // Implicit context-forking when omitting subagent_type
VERIFICATION_AGENT: true, // Built-in read-only agent for test/verification
PROMPT_CACHE_BREAK_DETECTION: true, // Detect & log unexpected prompt cache invalidations
HOOK_PROMPTS: true, // Allow tools to request interactive user prompts
}
// ── Pre-process: replace feature() calls with boolean literals ──────
// Bun v1.3.9+ resolves `import { feature } from 'bun:bundle'` natively
// before plugins can intercept it via onResolve. The bun: namespace is
// handled by Bun's C++ resolver which runs before the JS plugin phase,
// so the previous onResolve/onLoad shim was silently ineffective — ALL
// feature() calls evaluated to false regardless of the featureFlags map.
//
// Fix: transform source as Bun loads each module, stripping the bun:bundle
// import and replacing feature('FLAG') calls with their boolean literal.
// The working tree stays immutable while smoke/build runs.
// Match feature('FLAG') calls, including multi-line: feature(\n 'FLAG',\n)
const featureCallRe = /\bfeature\(\s*['"](\w+)['"][,\s]*\)/gs
const featureImportRe = /import\s*\{[^}]*\bfeature\b[^}]*\}\s*from\s*['"]bun:bundle['"];?\s*\n?/g
const featureFlagTransformedFiles = new Set<string>()
const featureFlagPreprocessPlugin = {
name: 'feature-flag-preprocess',
setup(build) {
build.onLoad({ filter: /\.[cm]?tsx?$/ }, args => {
const normalizedPath = args.path.replace(/\\/g, '/')
if (!normalizedPath.includes('/src/')) return null
const raw = readFileSync(args.path, 'utf-8')
if (!raw.includes('feature(')) return null
let contents = raw
contents = contents.replace(featureImportRe, '')
contents = contents.replace(featureCallRe, (_match, name) =>
String((featureFlags as Record<string, boolean>)[name] ?? false),
)
if (contents === raw) return null
featureFlagTransformedFiles.add(args.path)
return {
contents,
loader: args.path.endsWith('.tsx') || args.path.endsWith('.jsx')
? 'tsx'
: 'ts',
}
})
},
}
let result: Awaited<ReturnType<typeof Bun.build>> | undefined
let sdkResult: Awaited<ReturnType<typeof Bun.build>> | undefined
try {
result = await Bun.build({
entrypoints: ['./src/entrypoints/cli.tsx'],
outdir: './dist',
target: 'node',
format: 'esm',
splitting: false,
sourcemap: 'external',
minify: false,
naming: 'cli.mjs',
define: {
// MACRO.* build-time constants
// Keep the internal compatibility version high enough to pass
// first-party minimum-version guards, but expose the real package
// version separately in Open Claude branding.
'MACRO.VERSION': JSON.stringify('99.0.0'),
'MACRO.DISPLAY_VERSION': JSON.stringify(version),
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
'MACRO.ISSUES_EXPLAINER':
JSON.stringify('report the issue at https://github.com/Gitlawb/openclaude/issues'),
'MACRO.FEEDBACK_CHANNEL':
JSON.stringify('https://github.com/Gitlawb/openclaude/issues'),
'MACRO.PACKAGE_URL': JSON.stringify('@gitlawb/openclaude'),
'MACRO.NATIVE_PACKAGE_URL': 'undefined',
},
plugins: [
noTelemetryPlugin,
featureFlagPreprocessPlugin,
{
name: 'bun-bundle-shim',
setup(build) {
const internalFeatureStubModules = new Map([
[
'../daemon/workerRegistry.js',
'export async function runDaemonWorker() { throw new Error("Daemon worker is unavailable in the open build."); }',
],
[
'../daemon/main.js',
'export async function daemonMain() { throw new Error("Daemon mode is unavailable in the open build."); }',
],
[
'../cli/bg.js',
`
export async function psHandler() { throw new Error("Background sessions are unavailable in the open build."); }
export async function logsHandler() { throw new Error("Background sessions are unavailable in the open build."); }
export async function attachHandler() { throw new Error("Background sessions are unavailable in the open build."); }
export async function killHandler() { throw new Error("Background sessions are unavailable in the open build."); }
export async function handleBgFlag() { throw new Error("Background sessions are unavailable in the open build."); }
`,
],
[
'../cli/handlers/templateJobs.js',
'export async function templatesMain() { throw new Error("Template jobs are unavailable in the open build."); }',
],
[
'../environment-runner/main.js',
'export async function environmentRunnerMain() { throw new Error("Environment runner is unavailable in the open build."); }',
],
[
'../self-hosted-runner/main.js',
'export async function selfHostedRunnerMain() { throw new Error("Self-hosted runner is unavailable in the open build."); }',
],
] as const)
// bun:bundle feature() replacement is handled by featureFlagPreprocessPlugin.
// The previous onResolve/onLoad shim was ineffective in Bun
// v1.3.9+ because the bun: namespace is resolved natively
// before the JS plugin phase runs.
build.onResolve(
{ filter: /^\.\.\/(daemon\/workerRegistry|daemon\/main|cli\/bg|cli\/handlers\/templateJobs|environment-runner\/main|self-hosted-runner\/main)\.js$/ },
args => {
if (!internalFeatureStubModules.has(args.path)) return null
return {
path: args.path,
namespace: 'internal-feature-stub',
}
},
)
build.onLoad(
{ filter: /.*/, namespace: 'internal-feature-stub' },
args => ({
contents:
internalFeatureStubModules.get(args.path) ??
'export {}',
loader: 'js',
}),
)
// Resolve react/compiler-runtime to the standalone package
build.onResolve({ filter: /^react\/compiler-runtime$/ }, () => ({
path: 'react/compiler-runtime',
namespace: 'react-compiler-shim',
}))
build.onLoad(
{ filter: /.*/, namespace: 'react-compiler-shim' },
() => ({
contents: `export function c(size) { return new Array(size).fill(Symbol.for('react.memo_cache_sentinel')); }`,
loader: 'js',
}),
)
// Resolve native addon and missing snapshot imports to stubs
for (const mod of [
'audio-capture-napi',
'audio-capture.node',
'image-processor-napi',
'modifiers-napi',
'url-handler-napi',
'color-diff-napi',
'@anthropic-ai/mcpb',
'@ant/claude-for-chrome-mcp',
'@anthropic-ai/sandbox-runtime',
'asciichart',
'plist',
'cacache',
'fuse',
'code-excerpt',
'stack-utils',
]) {
build.onResolve({ filter: new RegExp(`^${mod}$`) }, () => ({
path: mod,
namespace: 'native-stub',
}))
}
build.onLoad(
{ filter: /.*/, namespace: 'native-stub' },
() => ({
// Comprehensive stub that handles any named export via Proxy
contents: `
const noop = () => null;
const noopClass = class {};
const handler = {
get(_, prop) {
if (prop === '__esModule') return true;
if (prop === 'default') return new Proxy({}, handler);
if (prop === 'SandboxRuntimeConfigSchema') return { parse: () => ({}) };
return noop;
}
};
const stub = new Proxy(noop, handler);
export default stub;
export const __stub = true;
// Named exports for all known imports
export const SandboxViolationStore = null;
export const SandboxManager = new Proxy({}, { get: () => noop });
export const SandboxRuntimeConfigSchema = { parse: () => ({}) };
export const BROWSER_TOOLS = [];
export const getMcpConfigForManifest = noop;
export const ColorDiff = null;
export const ColorFile = null;
export const getSyntaxTheme = noop;
export const plot = noop;
export const createClaudeForChromeMcpServer = noop;
`,
loader: 'js',
}),
)
// Resolve .md and .txt file imports to empty string stubs
build.onResolve({ filter: /\.(md|txt)$/ }, (args) => ({
path: args.path,
namespace: 'text-stub',
}))
build.onLoad(
{ filter: /.*/, namespace: 'text-stub' },
() => ({
contents: `export default '';`,
loader: 'js',
}),
)
// Pre-scan: find all missing modules that need stubbing
// (Bun's onResolve corrupts module graph even when returning null,
// so we use exact-match resolvers instead of catch-all patterns)
const fs = require('fs')
const pathMod = require('path')
const srcDir = pathMod.resolve(__dirname, '..', 'src')
const missingModules = new Set<string>()
const missingModuleExports = new Map<string, Set<string>>()
// Known missing external packages
for (const pkg of [
'@ant/computer-use-mcp',
'@ant/computer-use-mcp/sentinelApps',
'@ant/computer-use-mcp/types',
'@ant/computer-use-swift',
'@ant/computer-use-input',
]) {
missingModules.add(pkg)
}
// Scan source to find imports that can't resolve
function scanForMissingImports() {
function checkAndRegister(specifier: string, fileDir: string, namedPart: string) {
const names = namedPart.split(',')
.map((s: string) => s.trim().replace(/^type\s+/, ''))
.filter((s: string) => s && !s.startsWith('type '))
// Check src/tasks/ non-relative imports
if (specifier.startsWith('src/tasks/')) {
const resolved = pathMod.resolve(__dirname, '..', specifier)
const candidates = [
resolved,
`${resolved}.ts`, `${resolved}.tsx`,
resolved.replace(/\.js$/, '.ts'), resolved.replace(/\.js$/, '.tsx'),
pathMod.join(resolved, 'index.ts'), pathMod.join(resolved, 'index.tsx'),
]
if (!candidates.some((c: string) => fs.existsSync(c))) {
missingModules.add(specifier)
}
}
// Check relative .js imports
else if (specifier.endsWith('.js') && (specifier.startsWith('./') || specifier.startsWith('../'))) {
const resolved = pathMod.resolve(fileDir, specifier)
const tsVariant = resolved.replace(/\.js$/, '.ts')
const tsxVariant = resolved.replace(/\.js$/, '.tsx')
if (!fs.existsSync(resolved) && !fs.existsSync(tsVariant) && !fs.existsSync(tsxVariant)) {
missingModules.add(specifier)
}
}
// Track named exports for missing modules
if (names.length > 0) {
if (!missingModuleExports.has(specifier)) missingModuleExports.set(specifier, new Set())
for (const n of names) missingModuleExports.get(specifier)!.add(n)
}
}
function walk(dir: string) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = pathMod.join(dir, ent.name)
if (ent.isDirectory()) { walk(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const rawCode: string = fs.readFileSync(full, 'utf-8')
const fileDir = pathMod.dirname(full)
// Strip comments before scanning for imports/requires.
// The regex scanner matches require()/import() patterns
// inside JSDoc comments, causing false-positive missing
// module detection that breaks the build with noop stubs.
const code = rawCode
.replace(/\/\*[\s\S]*?\*\//g, '') // block comments
.replace(/\/\/.*$/gm, '') // line comments
// Collect static imports: import { X } from '...'
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
checkAndRegister(m[4], fileDir, m[1] || m[3] || '')
}
// Collect dynamic requires: require('...') — these are used
// behind feature() gates and become live when flags are enabled.
for (const m of code.matchAll(/require\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
checkAndRegister(m[1], fileDir, '')
}
// Collect dynamic imports: import('...')
for (const m of code.matchAll(/import\(\s*['"](\.\.?\/[^'"]+)['"]\s*\)/g)) {
checkAndRegister(m[1], fileDir, '')
}
}
}
walk(srcDir)
}
scanForMissingImports()
// Register exact-match resolvers for each missing module
for (const mod of missingModules) {
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
path: mod,
namespace: 'missing-module-stub',
}))
}
build.onLoad(
{ filter: /.*/, namespace: 'missing-module-stub' },
(args) => {
const names = missingModuleExports.get(args.path) ?? new Set()
const exports = [...names].map(n => `export const ${n} = noop;`).join('\n')
return {
contents: `
const noop = () => null;
export default noop;
${exports}
`,
loader: 'js',
}
},
)
},
},
],
external: CLI_EXTERNALS,
})
if (!result.success) {
console.error('Build failed:')
for (const log of result.logs) {
console.error(log)
}
process.exitCode = 1
} else {
console.log(`✓ Built openclaude v${version} → dist/cli.mjs`)
}
// ── SDK Bundle Build ──────────────────────────────────────────────────────
// SDK is a separate bundle for npm consumption - must NOT bundle React/Ink
console.log('Building SDK bundle...')
sdkResult = await Bun.build({
entrypoints: ['./src/entrypoints/sdk/index.ts'],
outdir: './dist',
target: 'node',
format: 'esm',
splitting: false,
sourcemap: 'external',
minify: false,
naming: 'sdk.mjs',
define: {
'MACRO.VERSION': JSON.stringify(version),
'MACRO.DISPLAY_VERSION': JSON.stringify(version),
'MACRO.BUILD_TIME': JSON.stringify(new Date().toISOString()),
'MACRO.ISSUES_EXPLAINER':
JSON.stringify('report the issue at https://github.com/Gitlawb/openclaude/issues'),
'MACRO.FEEDBACK_CHANNEL':
JSON.stringify('https://github.com/Gitlawb/openclaude/issues'),
'MACRO.PACKAGE_URL': JSON.stringify('@gitlawb/openclaude'),
'MACRO.NATIVE_PACKAGE_URL': 'undefined',
},
// External: everything TUI-related + native modules
external: SDK_EXTERNALS,
plugins: [
noTelemetryPlugin,
featureFlagPreprocessPlugin,
// Stub missing internal/optional modules (same pattern as CLI build)
{
name: 'sdk-missing-stub',
setup(build) {
const missingModules = [
'@anthropic-ai/mcpb',
'@ant/claude-for-chrome-mcp',
'@ant/computer-use-mcp',
'@ant/computer-use-swift',
'@ant/computer-use-input',
'@anthropic-ai/sandbox-runtime',
'audio-capture-napi', 'audio-capture.node',
'image-processor-napi', 'modifiers-napi', 'url-handler-napi', 'color-diff-napi',
'asciichart', 'plist', 'cacache', 'fuse', 'code-excerpt', 'stack-utils',
]
for (const mod of missingModules) {
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
path: mod,
namespace: 'sdk-missing-stub',
}))
}
// Stub relative imports to TUI directories
// Use (\.\.?\/)+ to match multiple ../ prefixes like ../../components/
build.onResolve({ filter: /^(\.\.?\/)+components\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^(\.\.?\/)+ink\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^(\.\.?\/)+commands\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^(\.\.?\/)+cli\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
// Stub relative imports to state/ directory EXCEPT for store.js and AppStateStore.js
// which are React-free utilities needed by the SDK for state management.
build.onResolve({ filter: /^(\.\.?\/)+state\// }, (args) => {
// Exclude React-free state utilities from stubbing
const isReactFreeStateModule =
args.path.endsWith('store.js') ||
args.path.endsWith('AppStateStore.js') ||
args.path.endsWith('store.ts') ||
args.path.endsWith('AppStateStore.ts')
if (isReactFreeStateModule) {
return null // Let Bun resolve normally
}
return {
path: args.path,
namespace: 'sdk-missing-stub',
}
})
build.onResolve({ filter: /^(\.\.?\/)+context\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
// Stub root ink.js barrel imports (../ink.js, ../../ink.js, ./ink.js)
// These are TUI entry points that import React directly.
build.onResolve({ filter: /^(\.\.?\/)+ink\.js$/ }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
// Also stub ./ paths used by re-exports in src/ink.ts, src/components/, etc.
build.onResolve({ filter: /^\.\/components\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^\.\/ink\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^\.\/commands\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^\.\/cli\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
// Stub tool UI.js imports from within src/tools/ subdirectories.
// Tool UI modules render React/TUI components that are not needed
// in the SDK (headless) bundle. Only stub when the importer is
// inside src/tools/ to avoid blind-matching other UI.js files.
build.onResolve({ filter: /(?:^|\/)UI\.js$/ }, (args) => {
// Normalize path separators for cross-platform matching
const importer = (args.importer || '').replace(/\\/g, '/')
if (importer.includes('src/tools/')) {
return {
path: args.path,
namespace: 'sdk-missing-stub',
}
}
return null
})
// Stub src/ alias imports that resolve to TUI directories
// These are used by require('src/components/...') style imports
build.onResolve({ filter: /^src\/components\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^src\/ink\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
// Stub src/ink.js root barrel import (used by some files via 'src/ink.js')
build.onResolve({ filter: /^src\/ink\.js$/ }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^src\/commands\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^src\/cli\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
// src/state/ contains AppState.tsx with React hooks, but store.ts and AppStateStore.ts
// are React-free utilities needed by the SDK - exclude them from stubbing.
build.onResolve({ filter: /^src\/state\// }, (args) => {
// Exclude React-free state utilities from stubbing
const isReactFreeStateModule =
args.path.endsWith('store.js') ||
args.path.endsWith('AppStateStore.js') ||
args.path.endsWith('store.ts') ||
args.path.endsWith('AppStateStore.ts')
if (isReactFreeStateModule) {
return null // Let Bun resolve normally
}
return {
path: args.path,
namespace: 'sdk-missing-stub',
}
})
build.onResolve({ filter: /^src\/context\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
// Stub src/keybindings/ — React-dependent keybinding system not needed in SDK
build.onResolve({ filter: /^src\/keybindings\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
build.onResolve({ filter: /^(\.\.?\/)+keybindings\// }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
// Stub react-compiler-runtime — not needed in SDK bundle
build.onResolve({ filter: /^react-compiler-runtime$/ }, () => ({
path: 'react-compiler-runtime',
namespace: 'sdk-missing-stub',
}))
// Stub TUI-only React hook files that leak into SDK via tool imports.
// These are imported transitively through spawnMultiAgent → It2SetupPrompt
// and through keybinding hooks. The SDK doesn't use TUI features.
for (const hookPath of [
'useDoublePress.js', 'useExitOnCtrlCD.js', 'useExitOnCtrlCDWithKeybindings.js',
'useTerminalSize.js', 'useShortcutDisplay.js',
]) {
const escaped = hookPath.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
build.onResolve({ filter: new RegExp(`(^|/)${escaped}$`) }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
}
// Stub It2SetupPrompt.tsx — TUI component pulled in by spawnMultiAgent
build.onResolve({ filter: /It2SetupPrompt\.js$/ }, (args) => ({
path: args.path,
namespace: 'sdk-missing-stub',
}))
// Stub react/jsx-dev-runtime with local no-op — tool .tsx files compile
// to jsxDEV() calls that are never rendered in SDK headless mode.
// This eliminates the external react/jsx-dev-runtime import entirely.
build.onResolve({ filter: /^react\/jsx-dev-runtime$/ }, () => ({
path: 'react/jsx-dev-runtime',
namespace: 'sdk-jsx-stub',
}))
build.onLoad({ filter: /.*/, namespace: 'sdk-jsx-stub' }, () => ({
contents: `
// No-op jsxDEV: returns null (SDK never renders JSX)
export function jsxDEV(type, props, key, isStaticChildren, source, self) {
return null;
}
// No-op Fragment: returns null (never used in SDK rendering)
export const Fragment = null;
`,
loader: 'js',
}))
// Resolve .md and .txt file imports (used by yolo-classifier etc.) to empty string stubs
build.onResolve({ filter: /\.(md|txt)$/, namespace: 'file' }, (args) => ({
path: args.path,
namespace: 'sdk-text-stub',
}))
build.onLoad(
{ filter: /.*/, namespace: 'sdk-text-stub' },
() => ({
contents: `export default '';`,
loader: 'js',
}),
)
// Stub require() calls to modules that don't exist on disk.
// These are feature-gated lazy imports (e.g. cachedMCConfig, VerifyPlanExecutionTool,
// mcpSkills) that only resolve when the feature flag is enabled at build time.
// Pre-scan source files for require('...') to non-existent .js paths.
const sdkRequireScanDir = require('path').resolve(__dirname, '..', 'src')
const sdkMissingRequires = new Set<string>()
const sdkPathMod = require('path')
const sdkFs = require('fs')
function scanSdkRequireImports() {
function walkRequireScan(dir: string) {
for (const ent of sdkFs.readdirSync(dir, { withFileTypes: true })) {
const full = sdkPathMod.join(dir, ent.name)
if (ent.isDirectory()) { walkRequireScan(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const fileDir = sdkPathMod.dirname(full)
const rawCode: string = sdkFs.readFileSync(full, 'utf-8')
// Strip comments
const code = rawCode
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
// Collect require('...') calls for relative .js paths
for (const m of code.matchAll(/require\(\s*['"](\.\.?\/[^'"]+\.js)['"]\s*\)/g)) {
const specifier = m[1]
const resolved = sdkPathMod.resolve(fileDir, specifier)
const tsVariant = resolved.replace(/\.js$/, '.ts')
if (!sdkFs.existsSync(resolved) && !sdkFs.existsSync(tsVariant)) {
sdkMissingRequires.add(specifier)
}
}
}
}
walkRequireScan(sdkRequireScanDir)
}
scanSdkRequireImports()
for (const mod of sdkMissingRequires) {
const escaped = mod.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
build.onResolve({ filter: new RegExp(`^${escaped}$`) }, () => ({
path: mod,
namespace: 'sdk-missing-stub',
}))
}
// Pre-scan: find all named imports for each stubbed module so we can
// generate matching exports dynamically (avoids the whack-a-mole of
// static export lists that break whenever a new import is added).
const fs = require('fs')
const pathMod = require('path')
const srcDir = pathMod.resolve(__dirname, '..', 'src')
const sdkStubExports = new Map<string, Set<string>>() // module path → set of imported names
function scanSdkStubImports() {
function register(specifier: string, namedPart: string) {
const rawNames = namedPart.split(',')
.map((s: string) => s.trim().replace(/^type\s+/, ''))
.filter((s: string) => s && !s.startsWith('type '))
if (rawNames.length === 0) return
if (!sdkStubExports.has(specifier)) sdkStubExports.set(specifier, new Set())
const names = sdkStubExports.get(specifier)!
for (const s of rawNames) {
// Handle "originalName as localName" — export BOTH names
// because Bun validates the original export name exists
const asMatch = s.match(/^(\w+)\s+as\s+(\w+)$/)
if (asMatch) {
names.add(asMatch[1]) // original name
names.add(asMatch[2]) // aliased name
} else {
names.add(s)
}
}
}
const isStubbedSpecifier = (s: string) =>
missingModules.includes(s) ||
/^(\.\.?\/)+(components|ink|commands|cli|context|state|keybindings)\//.test(s) ||
/^(\.\.?\/)+ink\.js$/.test(s) ||
/^src\/(components|ink|commands|cli|state|context|keybindings)\//.test(s) ||
/^src\/ink\.js$/.test(s) ||
/(?:^|\/)UI\.js$/.test(s) ||
s === 'react-compiler-runtime' ||
/(?:^|\/)It2SetupPrompt\.js$/.test(s) ||
/(?:^|\/)(useDoublePress|useExitOnCtrlCD|useExitOnCtrlCDWithKeybindings|useTerminalSize|useShortcutDisplay)\.js$/.test(s)
function walk(dir: string) {
for (const ent of fs.readdirSync(dir, { withFileTypes: true })) {
const full = pathMod.join(dir, ent.name)
if (ent.isDirectory()) { walk(full); continue }
if (!/\.(ts|tsx)$/.test(ent.name)) continue
const fileDir = pathMod.dirname(full)
const rawCode: string = fs.readFileSync(full, 'utf-8')
// Strip comments
const code = rawCode
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
// Collect static imports: import { X } from '...'
for (const m of code.matchAll(/import\s+(?:\{([^}]*)\}|(\w+))?\s*(?:,\s*\{([^}]*)\})?\s*from\s+['"](.*?)['"]/g)) {
const specifier = m[4]
if (isStubbedSpecifier(specifier)) {
register(specifier, m[1] || m[3] || '')
}
}
// Collect re-exports: export { X, Y } from '...'
for (const m of code.matchAll(/export\s+\{([^}]*)\}\s*from\s+['"](.*?)['"]/g)) {
const specifier = m[2]
if (isStubbedSpecifier(specifier)) {
register(specifier, m[1])
}
}
// Collect star re-exports: export * from '...'
// These re-export all named exports from the source module.
// For stubbed modules, we need to scan the re-exported module
// to find its exports and register them under the stubbed specifier.
for (const m of code.matchAll(/export\s+\*\s+from\s+['"](.*?)['"]/g)) {
const specifier = m[1]
if (isStubbedSpecifier(specifier)) {
// The re-exported module might itself be stubbed, so we need
// to find its exports. Parse the relative path and scan it.
const reexportPath = pathMod.resolve(fileDir, specifier)
const reexportBase = reexportPath.replace(/\.js$/, '')
const candidates = [
`${reexportBase}.ts`,
`${reexportBase}.tsx`,
reexportPath,
`${reexportPath}.ts`,
`${reexportPath}.tsx`,
]
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
const reexportCode = fs.readFileSync(candidate, 'utf-8')
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/\/\/.*$/gm, '')
// Collect exports from the re-exported module
for (const exp of reexportCode.matchAll(/export\s+(?:const|let|var|function|class|type|interface)\s+(\w+)/g)) {
register(specifier, exp[1])
}
for (const exp of reexportCode.matchAll(/export\s+\{([^}]*)\}/g)) {
register(specifier, exp[1])
}
break
}
}
}
}
}
}
walk(srcDir)
}
scanSdkStubImports()
// Special default exports for known modules
const defaultExportOverrides: Record<string, string> = {
'stringWidth': '(s) => s?.length || 0',
'wrapAnsi': '(s) => s',
'instances': 'new Map()',
'selectableUserMessagesFilter': '() => true',
'messagesAfterAreOnlySynthetic': '() => false',
'SandboxManager': 'class { static isSupportedPlatform = () => false; static create = noop; static Version = \'\'; }',
'SandboxRuntimeConfigSchema': '{ parse: noop }',
'SandboxViolationStore': 'null',
'BaseSandboxManager': 'class { static isSupportedPlatform = () => false; }',
'ExportResultCode': '{ SUCCESS: 0, FAILED: 1 }',
'linkifyUrlsInText': '(s) => s',
}
build.onLoad({ filter: /.*/, namespace: 'sdk-missing-stub' }, (args) => {
const names = sdkStubExports.get(args.path) ?? new Set()
const parts: string[] = []
for (const n of names) {
if (n === 'default') continue // handled by `export default noop` below
const val = defaultExportOverrides[n] ?? 'noop'
parts.push(`export const ${n} = ${val};`)
}
return {
contents: `
const noop = () => null;
export default noop;
export const __stub = true;
${parts.join('\n')}
`,
loader: 'js',
}
})
},
},
],
})
if (!sdkResult.success) {
console.error('SDK build failed:')
for (const log of sdkResult.logs) {
console.error(log)
}
process.exitCode = 1
} else {
console.log(`✓ Built SDK bundle → dist/sdk.mjs`)
}
} finally {
console.log(` 🔄 feature-flags: transformed ${featureFlagTransformedFiles.size} files during bundling`)
}
// ── Validate SDK bundle for React/Ink leakage ──────────────────────────────
if (sdkResult?.success) {
const sdkBundle = readFileSync('./dist/sdk.mjs', 'utf-8')
// Patterns that indicate React/Ink code leaked into the SDK bundle.
const reactInkPatterns = [
/from\s+["']react["']/, // direct react import
/from\s+["']ink["']/, // direct ink import
/from\s+["']react\/jsx-dev-runtime["']/, // JSX runtime (must be stubbed, not external)
]
const leaks: string[] = []
for (const pattern of reactInkPatterns) {
const match = sdkBundle.match(pattern)
if (match) leaks.push(match[0])
}
if (leaks.length > 0) {
console.error(`\n❌ SDK bundle contains React/Ink imports (must be stubbed):`)
for (const leak of leaks) console.error(` - ${leak}`)
process.exitCode = 1
} else {
console.log(`✓ SDK bundle: no React/Ink leakage detected`)
}
}
// ── Validate external lists ──────────────────────────────────────────────
if (result?.success && sdkResult?.success) {
console.log('\nValidating external lists...')
const validation = Bun.spawnSync(['bun', 'run', 'scripts/validate-externals.ts'], {
stdout: 'inherit',
stderr: 'inherit',
})
if (validation.exitCode !== 0) {
process.exitCode = 1
}
}