diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 416d2e37a2..ca782dd09f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -173,7 +173,7 @@ jobs: - name: Build and pack CLI run: | pnpm build - pnpm check:bundle-owner-files + pnpm check:bundle mkdir -p .tmp/node-compat npm pack --ignore-scripts --pack-destination .tmp/node-compat diff --git a/.github/workflows/package-smoke.yml b/.github/workflows/package-smoke.yml index 295792e5bf..35c9f60199 100644 --- a/.github/workflows/package-smoke.yml +++ b/.github/workflows/package-smoke.yml @@ -12,6 +12,7 @@ on: - 'scripts/build-xcuitest-apple.sh' - 'scripts/package-android-helper.sh' - 'scripts/package-apple-runner-source.mjs' + - 'scripts/check-bundle-*.ts' - 'scripts/patch-xcuitest-runner-icon.ts' - 'scripts/sync-mcp-metadata.mjs' - 'scripts/write-xcuitest-cache-metadata.mjs' @@ -34,6 +35,7 @@ on: - 'scripts/build-xcuitest-apple.sh' - 'scripts/package-android-helper.sh' - 'scripts/package-apple-runner-source.mjs' + - 'scripts/check-bundle-*.ts' - 'scripts/patch-xcuitest-runner-icon.ts' - 'scripts/sync-mcp-metadata.mjs' - 'scripts/write-xcuitest-cache-metadata.mjs' diff --git a/package.json b/package.json index fc7210de4a..b767727c86 100644 --- a/package.json +++ b/package.json @@ -103,7 +103,7 @@ "package:android-ime-helper:npm": "rm -rf android/ime-helper/dist && AGENT_DEVICE_ANDROID_HELPER=ime sh ./scripts/package-android-helper.sh $(node -p \"require('./package.json').version\") android/ime-helper/dist", "build:macos-helper": "swift build -c release --package-path apple/macos-helper", "build:macos-helper:clean": "swift package --package-path apple/macos-helper clean && pnpm build:macos-helper", - "package:npm": "pnpm build && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm package:apple-runner:npm && pnpm build:android", + "package:npm": "pnpm build && pnpm check:bundle && pnpm build:xcuitest:ios && pnpm build:xcuitest:macos && pnpm build:xcuitest:tvos && pnpm build:xcuitest:visionos && pnpm build:macos-helper:clean && pnpm package:apple-runner:npm && pnpm build:android", "ad": "node bin/agent-device.mjs", "bench:help-conformance": "node scripts/help-conformance-bench.mjs", "maestro:conformance": "node --experimental-strip-types --test packages/maestro/test/conformance/verify.test.ts packages/maestro/test/conformance/differential/run.test.ts packages/maestro/test/conformance/differential/invariants.test.ts", @@ -133,7 +133,9 @@ "depgraph": "node --experimental-strip-types scripts/depgraph/build.ts", "depgraph:test": "node --experimental-strip-types --test scripts/depgraph/model.test.ts scripts/depgraph/affected.test.ts", "check:production-exports": "fallow dead-code --config fallow-production-exports.json --production --unused-exports --fail-on-issues", + "check:bundle": "pnpm check:bundle-owner-files && pnpm check:bundle-private-imports", "check:bundle-owner-files": "node --experimental-strip-types scripts/check-bundle-owner-files.ts", + "check:bundle-private-imports": "node --experimental-strip-types scripts/check-bundle-private-imports.ts", "check:command-docs": "vitest run --project unit-core src/__tests__/command-doc-coverage.test.ts", "check:replay-compat": "node --experimental-strip-types scripts/check-replay-compat-provenance.ts", "check:freerange": "fr", @@ -141,7 +143,7 @@ "sync:mcp-metadata": "node scripts/sync-mcp-metadata.mjs", "check:mcp-metadata": "node scripts/sync-mcp-metadata.mjs --check", "version": "pnpm sync:mcp-metadata && git add server.json", - "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:production-exports && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle-owner-files", + "check:tooling": "pnpm format:check && pnpm lint && pnpm typecheck && pnpm check:layering && pnpm depgraph:test && pnpm check:production-exports && pnpm check:mcp-metadata && pnpm build && pnpm check:bundle", "check:unit": "pnpm check:contention-retry && pnpm test:unit && pnpm test:smoke", "check": "pnpm check:tooling && pnpm check:fallow && pnpm check:unit", "prepack": "pnpm check:mcp-metadata && pnpm package:npm", diff --git a/scripts/check-bundle-private-imports.ts b/scripts/check-bundle-private-imports.ts new file mode 100644 index 0000000000..a424415029 --- /dev/null +++ b/scripts/check-bundle-private-imports.ts @@ -0,0 +1,65 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { runAsMain } from './lib/run-as-main.ts'; +import { walkFiles } from './lib/walk-files.ts'; + +const PRIVATE_WORKSPACE_IMPORT_RE = + /(?:\bfrom\s*|\bimport\s*\(\s*|\bimport\s*|\brequire\s*\(\s*|\bimport\.meta\.resolve\s*\(\s*)(["'])(@agent-device\/[^"'\\]+)\1/g; + +export type BundleSource = { + file: string; + content: string; +}; + +export type PrivateWorkspaceImportLeak = { + file: string; + specifier: string; +}; + +export function findPrivateWorkspaceImportLeaks( + sources: readonly BundleSource[], +): PrivateWorkspaceImportLeak[] { + const seen = new Set(); + const leaks: PrivateWorkspaceImportLeak[] = []; + for (const source of sources) { + for (const match of source.content.matchAll(PRIVATE_WORKSPACE_IMPORT_RE)) { + const specifier = match[2]; + if (!specifier) continue; + const key = `${source.file}\0${specifier}`; + if (seen.has(key)) continue; + seen.add(key); + leaks.push({ file: source.file, specifier }); + } + } + return leaks; +} + +function run(): number { + const repoRoot = path.resolve(import.meta.dirname, '..'); + const distRoot = path.join(repoRoot, 'dist', 'src'); + const bundleFiles = walkFiles(distRoot).filter((file) => file.endsWith('.js')); + if (bundleFiles.length === 0) { + throw new Error('No dist/src JavaScript files found. Run `pnpm build` first.'); + } + + const leaks = findPrivateWorkspaceImportLeaks( + bundleFiles.map((file) => ({ + file: path.relative(repoRoot, file), + content: fs.readFileSync(file, 'utf8'), + })), + ); + if (leaks.length > 0) { + const details = leaks.map(({ file, specifier }) => `- ${specifier} in ${file}`).join('\n'); + throw new Error( + `Private workspace imports leaked into production bundles:\n${details}\n` + + 'Run `pnpm install` so workspace packages resolve, then rebuild before publishing.', + ); + } + + process.stdout.write( + `Verified ${bundleFiles.length} production bundles contain no private workspace imports.\n`, + ); + return 0; +} + +runAsMain(import.meta.url, 'check:bundle-private-imports', run); diff --git a/src/__tests__/bundle-private-imports.test.ts b/src/__tests__/bundle-private-imports.test.ts new file mode 100644 index 0000000000..a1e826867e --- /dev/null +++ b/src/__tests__/bundle-private-imports.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { findPrivateWorkspaceImportLeaks } from '../../scripts/check-bundle-private-imports.ts'; + +test('finds private workspace package imports left in emitted chunks', () => { + const leaks = findPrivateWorkspaceImportLeaks([ + { + file: 'dist/src/sdk-selectors.js', + content: [ + 'import{x}from"@agent-device/ad-script";', + 'const replay=import("@agent-device/ad-replay");', + 'const duplicate=import("@agent-device/ad-script");', + ].join(''), + }, + { + file: 'dist/src/daemon.js', + content: 'const script=require("@agent-device/ad-script/internal");', + }, + ]); + + assert.deepEqual(leaks, [ + { file: 'dist/src/sdk-selectors.js', specifier: '@agent-device/ad-script' }, + { file: 'dist/src/sdk-selectors.js', specifier: '@agent-device/ad-replay' }, + { file: 'dist/src/daemon.js', specifier: '@agent-device/ad-script/internal' }, + ]); +}); + +test('ignores bundled mentions and external runtime dependencies', () => { + assert.deepEqual( + findPrivateWorkspaceImportLeaks([ + { + file: 'dist/src/cli.js', + content: [ + 'const diagnostic="@agent-device/ad-script";', + 'import{stringify}from"yaml";', + 'const scope="@agent-device-example/public";', + ].join(''), + }, + ]), + [], + ); +}); diff --git a/src/__tests__/npm-package-scripts.test.ts b/src/__tests__/npm-package-scripts.test.ts index 3ec791ca3e..d159f783cc 100644 --- a/src/__tests__/npm-package-scripts.test.ts +++ b/src/__tests__/npm-package-scripts.test.ts @@ -31,6 +31,7 @@ test('prepack builds the complete package without stopping the development daemo for (const input of [ 'pnpm-workspace.yaml', 'packages/**', + 'scripts/check-bundle-*.ts', 'scripts/patch-xcuitest-runner-icon.ts', 'scripts/sync-mcp-metadata.mjs', 'scripts/write-xcuitest-cache-metadata.mjs', @@ -58,6 +59,7 @@ test('Fallow exposes one changed-code gate and an explicit full-tree audit', () test('the npm package build covers every package-owned build output', () => { assert.deepEqual(script('package:npm').split(' && '), [ 'pnpm build', + 'pnpm check:bundle', 'pnpm build:xcuitest:ios', 'pnpm build:xcuitest:macos', 'pnpm build:xcuitest:tvos', @@ -66,6 +68,10 @@ test('the npm package build covers every package-owned build output', () => { 'pnpm package:apple-runner:npm', 'pnpm build:android', ]); + assert.deepEqual(script('check:bundle').split(' && '), [ + 'pnpm check:bundle-owner-files', + 'pnpm check:bundle-private-imports', + ]); assert.deepEqual(script('build:android').split(' && '), [ 'pnpm package:android-snapshot-helper:npm',