diff --git a/bun.lock b/bun.lock index 4cde14918..587c7d05e 100644 --- a/bun.lock +++ b/bun.lock @@ -76,6 +76,7 @@ "dependencies": { "@agentclientprotocol/sdk": "catalog:", "@babel/parser": "catalog:", + "@babel/traverse": "catalog:", "@mozilla/readability": "catalog:", "@oh-my-pi/hashline": "catalog:", "@oh-my-pi/omp-stats": "catalog:", diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b915e340a..dc1e59c91 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -84,6 +84,9 @@ - Fixed in-progress aborts awaiting `session_stop` extension handlers whose results would be discarded. - Fixed `/retry` reporting "Nothing to retry" after a stream stalled or aborted mid-tool-call. - Fixed locally consumed extension commands triggering automatic title generation and exposing their command text to the title model. +### Fixed + +- Fixed compiled binaries failing to load legacy Pi extensions with minified imports, `pi-ai/compat`, or transitive runtime dependencies. The compatibility loader now follows compact static imports, resolves transitive on-disk ESM imports and CommonJS requires with package conditions, and restores the legacy `copyToClipboard` and `decodeKittyPrintable` root exports used by `pi-vimmode` and `pi-web-access`. ## [17.0.7] - 2026-07-21 diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index 530b7a001..567d59c5a 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -51,6 +51,7 @@ "dependencies": { "@agentclientprotocol/sdk": "catalog:", "@babel/parser": "catalog:", + "@babel/traverse": "catalog:", "@mozilla/readability": "catalog:", "@oh-my-pi/hashline": "catalog:", "@oh-my-pi/omp-stats": "catalog:", diff --git a/packages/coding-agent/scripts/legacy-pi-virtual-module.ts b/packages/coding-agent/scripts/legacy-pi-virtual-module.ts index b759a6044..a83ef4f7f 100644 --- a/packages/coding-agent/scripts/legacy-pi-virtual-module.ts +++ b/packages/coding-agent/scripts/legacy-pi-virtual-module.ts @@ -19,7 +19,7 @@ const BUNDLED_PACKAGES: readonly BundledPackage[] = [ { dir: "ai", identifier: "PiAi", rootShim: "legacy-pi-ai-shim.ts" }, { dir: "coding-agent", identifier: "PiCodingAgent", rootShim: "legacy-pi-coding-agent-shim.ts" }, { dir: "natives", identifier: "PiNatives", rootShim: null }, - { dir: "tui", identifier: "PiTui", rootShim: null }, + { dir: "tui", identifier: "PiTui", rootShim: "legacy-pi-tui-shim.ts" }, { dir: "utils", identifier: "PiUtils", rootShim: null }, ]; diff --git a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts index 6542a6481..ef2c08a6e 100644 --- a/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts +++ b/packages/coding-agent/src/extensibility/legacy-pi-coding-agent-shim.ts @@ -1362,4 +1362,5 @@ export function getPackageDir(): string { export * from "../index"; export { formatBytes as formatSize } from "../tools/render-utils"; +export { copyToClipboard } from "../utils/clipboard"; export { Type } from "./typebox"; diff --git a/packages/coding-agent/src/extensibility/legacy-pi-tui-shim.ts b/packages/coding-agent/src/extensibility/legacy-pi-tui-shim.ts new file mode 100644 index 000000000..4ac5f57fd --- /dev/null +++ b/packages/coding-agent/src/extensibility/legacy-pi-tui-shim.ts @@ -0,0 +1,10 @@ +/** + * Compatibility shim for legacy extensions importing the package root of + * `@earendil-works/pi-tui` or `@mariozechner/pi-tui`. + * + * The historical root exported `decodeKittyPrintable`; the canonical TUI now + * exposes the equivalent, broader `decodePrintableKey` helper. Keep the legacy + * name available without reintroducing it into the canonical package surface. + */ +export * from "@oh-my-pi/pi-tui"; +export { decodePrintableKey as decodeKittyPrintable } from "@oh-my-pi/pi-tui"; diff --git a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts index 40d1a7ca8..7d9b287f2 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -1,13 +1,35 @@ /// + import * as fs from "node:fs"; import { createRequire, isBuiltin } from "node:module"; import * as path from "node:path"; import * as url from "node:url"; +import type { ParseResult, ParserPlugin } from "@babel/parser"; +import { parse as parseBabel } from "@babel/parser"; +import * as traverseModule from "@babel/traverse"; import { isCompiledBinary, stripWindowsExtendedLengthPathPrefix } from "@oh-my-pi/pi-utils"; import { registerPluginCacheInvalidator } from "../../discovery/helpers"; const IS_COMPILED_BINARY = isCompiledBinary(); +function isBabelTraverse(value: unknown): value is typeof traverseModule.default { + return typeof value === "function"; +} + +// Bun's compiled CJS interop wraps Babel traverse's default one level deeper. +const traverseDefault: unknown = traverseModule.default; +const nestedTraverse = + traverseDefault !== null && typeof traverseDefault === "object" && "default" in traverseDefault + ? traverseDefault.default + : undefined; +const traverseCandidate = isBabelTraverse(traverseDefault) ? traverseDefault : nestedTraverse; +if (!isBabelTraverse(traverseCandidate)) { + throw new TypeError( + `Invalid @babel/traverse export: expected function, got default=${typeof traverseDefault}, nested=${typeof nestedTraverse}`, + ); +} +const traverseAst = traverseCandidate; + // === Bundled host modules (issue #3423) === // // Bun 1.3.14 stopped exposing `--compile` extras through any filesystem-style @@ -42,6 +64,99 @@ interface BundledVirtualResolveResult { namespace: typeof BUNDLED_VIRTUAL_NAMESPACE; } +interface ExtensionSpecifierReference { + readonly kind: "import" | "require"; + readonly specifier: string; + readonly start: number; + readonly end: number; +} + +function parseExtensionSource(source: string, importerPath: string): ParseResult { + const extension = path.extname(importerPath).toLowerCase(); + const plugins: ParserPlugin[] = ["decorators-legacy", "explicitResourceManagement"]; + if (extension === ".ts" || extension === ".mts" || extension === ".cts" || extension === ".tsx") { + plugins.push("typescript"); + } + if (extension === ".jsx" || extension === ".tsx") { + plugins.push("jsx"); + } + + try { + return parseBabel(source, { + sourceType: "unambiguous", + allowAwaitOutsideFunction: true, + allowReturnOutsideFunction: true, + allowImportExportEverywhere: true, + allowNewTargetOutsideFunction: true, + allowSuperOutsideMethod: true, + allowUndeclaredExports: true, + errorRecovery: true, + plugins, + }); + } catch (error) { + throw new Error( + `Failed to parse extension source for dependency rewriting: ${importerPath}: ${error instanceof Error ? error.message : String(error)}`, + { cause: error }, + ); + } +} + +function collectExtensionSpecifierReferences( + source: string, + importerPath: string, + ast: ParseResult = parseExtensionSource(source, importerPath), +): ExtensionSpecifierReference[] { + const references: ExtensionSpecifierReference[] = []; + const record = (kind: ExtensionSpecifierReference["kind"], literal: unknown): void => { + if (!literal || typeof literal !== "object") return; + const node = literal as { type?: string; value?: unknown; start?: number | null; end?: number | null }; + if ( + node.type === "StringLiteral" && + typeof node.value === "string" && + typeof node.start === "number" && + typeof node.end === "number" + ) { + references.push({ kind, specifier: node.value, start: node.start, end: node.end }); + } + }; + traverseAst(ast, { + enter(nodePath) { + const node = nodePath.node; + if ( + node.type === "ImportDeclaration" || + node.type === "ExportNamedDeclaration" || + node.type === "ExportAllDeclaration" + ) { + record("import", node.source); + } else if (node.type === "ImportExpression") { + record("import", node.source); + } else if (node.type === "CallExpression") { + if (node.callee.type === "Import") { + record("import", node.arguments[0]); + } else if ( + node.callee.type === "Identifier" && + node.callee.name === "require" && + !nodePath.scope.hasBinding("require", true) + ) { + record("require", node.arguments[0]); + } + } + }, + }); + return references; +} + +function applySpecifierReplacements( + source: string, + replacements: ReadonlyArray, +): string { + let rewritten = source; + for (const reference of [...replacements].sort((left, right) => right.start - left.start)) { + rewritten = `${rewritten.slice(0, reference.start)}${JSON.stringify(reference.replacement)}${rewritten.slice(reference.end)}`; + } + return rewritten; +} + const loadedBundledModules: Record = {}; let bundledModuleLoadersPromise: Promise | null = null; @@ -180,6 +295,7 @@ const PI_PACKAGE_ALTERNATION = PI_PACKAGE_NAMES.join("|"); const PI_SUBPATH_REMAPS: ReadonlyMap = new Map([ ["pi-ai/utils/oauth", "pi-ai/oauth"], ["pi-ai/utils/oauth/", "pi-ai/oauth/"], + ["pi-ai/compat", "pi-ai"], ]); function remapLegacyPiSubpath(rest: string): string { @@ -198,18 +314,16 @@ function remapLegacyPiSubpath(rest: string): string { } const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`); -const LEGACY_PI_IMPORT_SPECIFIER_REGEX = new RegExp( - `((?:from\\s+|import\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`, - "g", -); const resolvedSpecifierFallbacks = new Map(); const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"] as const; const SUPPORTED_PACKAGE_IMPORT_CONDITIONS = new Set(["bun", "node", "import", "default"]); +const SUPPORTED_PACKAGE_REQUIRE_CONDITIONS = new Set(["bun", "node", "require", "default"]); const packageRootCache = new Map(); const packageImportsCache = new Map | null>(); const nodePackageRootCache = new Map>(); const packageManifestCache = new Map | null>>(); const bareDependencyResolutionCache = new Map>(); +const bareRequireResolutionCache = new Map>(); const realpathCache = new Map>(); const nativeAddonResolutionCache = new Map>(); const nativeAddonRequireScanCache = new Map>(); @@ -222,6 +336,7 @@ function clearLegacyPiResolutionCaches(): void { nodePackageRootCache.clear(); packageManifestCache.clear(); bareDependencyResolutionCache.clear(); + bareRequireResolutionCache.clear(); nativeAddonResolutionCache.clear(); nativeAddonRequireScanCache.clear(); nativeAddonLoaderModulePaths.clear(); @@ -338,14 +453,21 @@ const LEGACY_PI_CODING_AGENT_SHIM_PATH = IS_COMPILED_BINARY ? bundledModuleVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-coding-agent`) : sourceShimPath("legacy-pi-coding-agent-shim.ts"); -// Package-root overrides. Shim entries (`pi-ai`, `pi-coding-agent`) always -// replace the canonical surface so the legacy `Type` runtime and the legacy -// helpers stay reachable. The bundled host packages (`pi-agent-core`, -// `pi-natives`, `pi-tui`, `pi-utils`) are added only in compiled-binary mode -// to route extensions onto the in-process module instance — in dev / -// source-link / installed-package mode the canonical specifier resolves -// cleanly through `Bun.resolveSync` and hardcoding a source-tree path would -// miss installs where the bundled packages live at `node_modules/@oh-my-pi/pi-*`. +// Legacy pi-tui exported `decodeKittyPrintable` from its package root. The +// canonical TUI replaced it with the broader `decodePrintableKey`; route only +// legacy root imports through a sibling shim that preserves the old name. +const LEGACY_PI_TUI_SHIM_PATH = IS_COMPILED_BINARY + ? bundledModuleVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-tui`) + : sourceShimPath("legacy-pi-tui-shim.ts"); + +// Package-root overrides. Shim entries (`pi-ai`, `pi-coding-agent`, `pi-tui`) +// always replace the canonical surface so legacy helpers stay reachable. The +// other bundled host packages (`pi-agent-core`, `pi-natives`, `pi-utils`) are +// added only in compiled-binary mode to route extensions onto the in-process +// module instance — in dev / source-link / installed-package mode the canonical +// specifier resolves cleanly through `Bun.resolveSync` and hardcoding a +// source-tree path would miss installs where bundled packages live at +// `node_modules/@oh-my-pi/pi-*`. // // Compiled-binary entries are `omp-legacy-pi-bundled:` specifiers handed // to the synthetic onLoad in `installLegacyPiSpecifierShim()` — bunfs paths @@ -390,6 +512,7 @@ export function __buildLegacyPiPackageRootOverrides( const candidates: Record = { [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH, [`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH, + [`${CANONICAL_PI_SCOPE}/pi-tui`]: LEGACY_PI_TUI_SHIM_PATH, }; if (isCompiled) { for (const key of bundledModuleKeys) { @@ -473,33 +596,6 @@ function toImportSpecifier(resolvedPath: string): string { return url.pathToFileURL(stripWindowsExtendedLengthPathPrefix(resolvedPath)).href; } -function rewriteLegacyPiImports(source: string): string { - return source.replace( - LEGACY_PI_IMPORT_SPECIFIER_REGEX, - (match, prefix: string, specifier: string, suffix: string) => { - const remappedSpecifier = remapLegacyPiSpecifier(specifier); - if (!remappedSpecifier) { - return match; - } - - try { - return `${prefix}${toImportSpecifier(resolveCanonicalPiSpecifier(remappedSpecifier))}${suffix}`; - } catch { - // Resolution failed — typically in compiled binary mode where - // Bun.resolveSync cannot walk up from /$bunfs/root to find the - // bundled node_modules. Leave the specifier unchanged so Bun - // resolves it natively against the extension's own peer deps. - return match; - } - }, - ); -} - -// Match the bare TypeBox import specifiers (static + dynamic). Subpath imports -// like `@sinclair/typebox/compiler` are intentionally excluded — they expose -// TypeBox-only APIs the Zod-backed shim does not provide. -const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(@sinclair\/typebox|typebox)(["'])/g; - /** * Rewrite the extension-owned specifiers OMP must host-resolve — legacy * `@(scope)/pi-*`, bare TypeBox packages, package `imports` aliases like @@ -508,43 +604,61 @@ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["' * are left untouched so Bun resolves them from the extension's real on-disk * location. * - * When `mtimeTag` is provided, extension-owned graph specifiers (relative - * `./`/`../`, package `#alias/*`, and extension-local bare deps) also carry a - * `?mtime=` cache-bust so Bun rekeys them on same-process reloads. Host - * package rewrites (legacy `@(scope)/pi-*`, TypeBox shim) always emit - * `file://` URLs because they resolve to in-process host code that never - * changes between reloads. + * When `mtimeTag` is provided, extension-owned relative graph specifiers + * (`./`/`../`) and, by default, resolved package `#alias/*` and extension-local + * bare deps also carry a `?mtime=` cache-bust so Bun rekeys them on + * same-process reloads. `resolvedImportMtimeTag` can disable the tag for + * resolved package and bare ESM imports inside third-party dependencies, whose + * transitive ESM imports retain a query-free importer path for Bun's runtime + * `node_modules` resolution. Host package rewrites (legacy + * `@(scope)/pi-*`, TypeBox shim) always emit `file://` URLs because they resolve + * to in-process host code that never changes between reloads. */ async function rewriteLegacyExtensionSource( source: string, importerPath: string, mtimeTag: string | null = null, + resolvedImportMtimeTag: string | null = mtimeTag, ): Promise { // Compiled mode completes the override map from the build-supplied module // keys on first use; every rewrite path must see the full map. await ensureLegacyPiOverridesReady(); - const withPi = rewriteLegacyPiImports(source); - // When the TypeBox shim is missing (release build dropped the entrypoint — - // issue #3414), leave bare specifiers untouched so Bun resolves a real - // `typebox` / `@sinclair/typebox` install from the extension's own - // `node_modules`. `resolveTypeBoxSpecifier` mirrors the fall-through. - const withTypeBox = TYPEBOX_SHIM_PATH - ? withPi.replace( - TYPEBOX_IMPORT_SPECIFIER_REGEX, - (_match, prefix: string, _specifier: string, suffix: string) => - `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`, - ) - : withPi; - const withPkg = await rewriteExtensionPackageImports(withTypeBox, importerPath, mtimeTag); - const withBare = await rewriteExtensionBareImports(withPkg, importerPath, mtimeTag); - const withNativeAddons = await rewriteExtensionNativeAddonRequires(withBare, importerPath); - if (!mtimeTag) { - return withNativeAddons; + const references = collectExtensionSpecifierReferences(source, importerPath); + const replacements: Array = []; + for (const reference of references) { + if (reference.kind !== "import") continue; + + const specifier = reference.specifier; + let replacement: string | null = null; + const remappedSpecifier = remapLegacyPiSpecifier(specifier); + if (remappedSpecifier) { + try { + replacement = toImportSpecifier(resolveCanonicalPiSpecifier(remappedSpecifier)); + } catch { + // Compiled fallback may be absent from a malformed build. Continue to + // the extension's on-disk peer dependency resolution below. + } + } + if (!replacement && TYPEBOX_SHIM_PATH && (specifier === "typebox" || specifier === "@sinclair/typebox")) { + replacement = toImportSpecifier(TYPEBOX_SHIM_PATH); + } + if (!replacement && specifier.startsWith("#")) { + const resolved = await resolvePackageImportSpecifier(specifier, importerPath); + if (resolved) replacement = toGraphImportSpecifier(resolved, resolvedImportMtimeTag); + } + if (!replacement && isBareExtensionDependencySpecifier(specifier)) { + const resolved = await resolveExtensionBareDependency(specifier, importerPath); + if (resolved) replacement = toGraphImportSpecifier(resolved, resolvedImportMtimeTag); + } + if (!replacement && mtimeTag && /^\.\.?\//.test(specifier) && !specifier.includes("?")) { + replacement = `${specifier}?mtime=${mtimeTag}`; + } + if (replacement && replacement !== specifier) { + replacements.push({ ...reference, replacement }); + } } - return withNativeAddons.replace( - RELATIVE_GRAPH_IMPORT_SPECIFIER_REGEX, - (_match, prefix: string, specifier: string, suffix: string) => `${prefix}${specifier}?mtime=${mtimeTag}${suffix}`, - ); + const withImports = applySpecifierReplacements(source, replacements); + return rewriteExtensionSpecifiers(withImports, importerPath); } /** Test seam for compiled-binary legacy extension source rewriting. */ @@ -552,15 +666,11 @@ export async function __rewriteLegacyExtensionSourceForTests( source: string, importerPath: string, mtimeTag: string | null = null, + resolvedImportMtimeTag: string | null = mtimeTag, ): Promise { - return rewriteLegacyExtensionSource(source, importerPath, mtimeTag); + return rewriteLegacyExtensionSource(source, importerPath, mtimeTag, resolvedImportMtimeTag); } -// Match relative graph specifiers so their `./foo.ts` /`../foo` targets get a -// `?mtime=` cache-bust suffix without disturbing already-rewritten -// `file://` URLs or bare/host specifiers. -const RELATIVE_GRAPH_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(\.\.?\/[^"'?\s]*)(["'])/g; - /** * Build the import specifier for a graph-resolved absolute path. POSIX * emits a bare filesystem path with an optional `?mtime=` (Bun keys @@ -628,6 +738,33 @@ async function resolveSourceModuleFile(basePath: string): Promise return null; } +function isPathInsideRoot(rootPath: string, candidatePath: string): boolean { + const relative = path.relative(rootPath, candidatePath); + return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)); +} + +async function resolvePackageSourceTarget(packageRoot: string, targetPath: string): Promise { + const candidate = path.resolve(targetPath); + if (!isPathInsideRoot(path.resolve(packageRoot), candidate)) { + return null; + } + const resolved = await resolveSourceModuleFile(candidate); + if (!resolved) { + return null; + } + const realPackageRoot = await realpathOrSelf(packageRoot); + return isPathInsideRoot(realPackageRoot, resolved) ? resolved : null; +} + +async function resolvePackageFileTarget(packageRoot: string, targetPath: string): Promise { + const candidate = path.resolve(targetPath); + if (!isPathInsideRoot(path.resolve(packageRoot), candidate) || !(await pathExists(candidate))) { + return null; + } + const [realPackageRoot, resolved] = await Promise.all([realpathOrSelf(packageRoot), realpathOrSelf(candidate)]); + return isPathInsideRoot(realPackageRoot, resolved) ? resolved : null; +} + async function findPackageRoot(importerPath: string): Promise { let dir = path.dirname(importerPath); while (true) { @@ -672,7 +809,10 @@ async function readPackageImports(packageRoot: string): Promise = SUPPORTED_PACKAGE_IMPORT_CONDITIONS, +): PackageImportTargetSelection { if (entry === null) { return PACKAGE_IMPORT_EXCLUDED; } @@ -681,7 +821,7 @@ function selectPackageImportTarget(entry: unknown): PackageImportTargetSelection } if (Array.isArray(entry)) { for (const item of entry) { - const target = selectPackageImportTarget(item); + const target = selectPackageImportTarget(item, conditions); if (target !== null) return target; } return null; @@ -690,10 +830,10 @@ function selectPackageImportTarget(entry: unknown): PackageImportTargetSelection return null; } for (const [condition, value] of Object.entries(entry)) { - if (!SUPPORTED_PACKAGE_IMPORT_CONDITIONS.has(condition)) { + if (!conditions.has(condition)) { continue; } - const target = selectPackageImportTarget(value); + const target = selectPackageImportTarget(value, conditions); if (target !== null) return target; } return null; @@ -708,7 +848,7 @@ async function resolvePackageImportTarget( return null; } const substituted = wildcard === null ? target : target.replaceAll("*", wildcard); - return resolveSourceModuleFile(path.resolve(packageRoot, substituted)); + return resolvePackageSourceTarget(packageRoot, path.resolve(packageRoot, substituted)); } async function resolvePackageImportSpecifier(specifier: string, importerPath: string): Promise { @@ -765,38 +905,6 @@ async function resolvePackageImportSpecifier(specifier: string, importerPath: st return resolvePackageImportTarget(packageRoot, bestMatch.target, bestMatch.wildcard); } -const PACKAGE_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(#[^"'()\s]+)(["'])/g; - -async function rewriteExtensionPackageImports( - source: string, - importerPath: string, - mtimeTag: string | null = null, -): Promise { - let rewritten = ""; - let lastIndex = 0; - for (const match of source.matchAll(PACKAGE_IMPORT_SPECIFIER_REGEX)) { - const matchIndex = match.index; - if (matchIndex === undefined) continue; - - const [fullMatch, prefix, specifier, suffix] = match; - if (!prefix || !specifier || !suffix) continue; - - const resolved = await resolvePackageImportSpecifier(specifier, importerPath); - if (!resolved) continue; - - rewritten += source.slice(lastIndex, matchIndex); - rewritten += `${prefix}${toGraphImportSpecifier(resolved, mtimeTag)}${suffix}`; - lastIndex = matchIndex + fullMatch.length; - } - - if (lastIndex === 0) { - return source; - } - return `${rewritten}${source.slice(lastIndex)}`; -} - -const BARE_EXTENSION_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])([^"'()\s]+)(["'])/g; - function isBareExtensionDependencySpecifier(specifier: string): boolean { if ( specifier.startsWith(".") || @@ -872,6 +980,58 @@ async function readPackageManifestUncached(packageRoot: string): Promise { + const extension = path.extname(modulePath).toLowerCase(); + if (extension === ".cjs" || extension === ".cts") { + return true; + } + if (extension !== ".js" && extension !== ".jsx") { + return false; + } + + const packageRoot = await findPackageRoot(modulePath); + const manifest = packageRoot ? await readPackageManifest(packageRoot) : null; + if (manifest?.type === "module") { + return false; + } + if (manifest?.type === "commonjs") { + return true; + } + const parsedSourceType = + sourceType ?? parseExtensionSource(await Bun.file(modulePath).text(), modulePath).program.sourceType; + if (parsedSourceType === "module") { + return false; + } + if (inheritedKind) { + return inheritedKind === "commonjs"; + } + const declaredModuleEntry = + packageRoot && typeof manifest?.module === "string" + ? await resolvePackageSourceTarget(packageRoot, path.resolve(packageRoot, manifest.module)) + : null; + return !declaredModuleEntry || path.resolve(modulePath) !== path.resolve(declaredModuleEntry); +} + +async function isGraphOwnedCommonJsModule( + modulePath: string, + entryRealPath: string, + sourceType?: "script" | "module", + inheritedKind?: ExtensionModuleKind, +): Promise { + const extension = path.extname(modulePath).toLowerCase(); + if (modulePath === entryRealPath && extension !== ".cjs" && extension !== ".cts") { + return false; + } + return isCommonJsModulePath(modulePath, sourceType, inheritedKind); +} + async function resolvePackageExportTarget( packageRoot: string, target: string, @@ -881,16 +1041,17 @@ async function resolvePackageExportTarget( return null; } const substituted = wildcard === null ? target : target.replaceAll("*", wildcard); - return resolveSourceModuleFile(path.resolve(packageRoot, substituted)); + return resolvePackageSourceTarget(packageRoot, path.resolve(packageRoot, substituted)); } async function resolveNodePackageExport( packageRoot: string, subpath: string | null, manifest: Record, + conditions: ReadonlySet = SUPPORTED_PACKAGE_IMPORT_CONDITIONS, ): Promise { const exportsField = manifest.exports; - const rootTarget = subpath === null ? selectPackageImportTarget(exportsField) : null; + const rootTarget = subpath === null ? selectPackageImportTarget(exportsField, conditions) : null; if (rootTarget !== null && rootTarget !== PACKAGE_IMPORT_EXCLUDED) { return resolvePackageExportTarget(packageRoot, rootTarget, null); } @@ -899,30 +1060,41 @@ async function resolveNodePackageExport( } const exactKey = subpath === null ? "." : `./${subpath}`; - const exactTarget = selectPackageImportTarget(exportsField[exactKey]); - if (exactTarget !== null && exactTarget !== PACKAGE_IMPORT_EXCLUDED) { - return resolvePackageExportTarget(packageRoot, exactTarget, null); + if (Object.hasOwn(exportsField, exactKey)) { + const exactTarget = selectPackageImportTarget(exportsField[exactKey], conditions); + return exactTarget !== null && exactTarget !== PACKAGE_IMPORT_EXCLUDED + ? resolvePackageExportTarget(packageRoot, exactTarget, null) + : null; } + let bestMatch: { + keyLength: number; + prefixLength: number; + target: PackageImportTargetSelection; + wildcard: string; + } | null = null; for (const [key, entry] of Object.entries(exportsField)) { const starIndex = key.indexOf("*"); - if (starIndex === -1 || subpath === null) continue; + if (starIndex === -1 || subpath === null || !key.startsWith("./")) continue; const prefix = key.slice(2, starIndex); const suffix = key.slice(starIndex + 1); - if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) { - continue; + if (!subpath.startsWith(prefix) || !subpath.endsWith(suffix)) continue; + if ( + !bestMatch || + prefix.length > bestMatch.prefixLength || + (prefix.length === bestMatch.prefixLength && key.length > bestMatch.keyLength) + ) { + bestMatch = { + keyLength: key.length, + prefixLength: prefix.length, + target: selectPackageImportTarget(entry, conditions), + wildcard: subpath.slice(prefix.length, subpath.length - suffix.length), + }; } - const target = selectPackageImportTarget(entry); - if (target === null || target === PACKAGE_IMPORT_EXCLUDED) { - continue; - } - return resolvePackageExportTarget( - packageRoot, - target, - subpath.slice(prefix.length, subpath.length - suffix.length), - ); } - return null; + return bestMatch?.target && bestMatch.target !== PACKAGE_IMPORT_EXCLUDED + ? resolvePackageExportTarget(packageRoot, bestMatch.target, bestMatch.wildcard) + : null; } async function resolveNodePackageFallback( @@ -931,16 +1103,16 @@ async function resolveNodePackageFallback( manifest: Record, ): Promise { if (subpath !== null) { - return resolveSourceModuleFile(path.join(packageRoot, subpath)); + return resolvePackageSourceTarget(packageRoot, path.join(packageRoot, subpath)); } for (const field of ["module", "main"]) { const target = manifest[field]; if (typeof target === "string") { - const resolved = await resolveSourceModuleFile(path.resolve(packageRoot, target)); + const resolved = await resolvePackageSourceTarget(packageRoot, path.resolve(packageRoot, target)); if (resolved) return resolved; } } - return resolveSourceModuleFile(path.join(packageRoot, "index")); + return resolvePackageSourceTarget(packageRoot, path.join(packageRoot, "index")); } async function resolveNodePackageDependency(specifier: string, importerPath: string): Promise { @@ -950,9 +1122,72 @@ async function resolveNodePackageDependency(specifier: string, importerPath: str if (!packageRoot) return null; const manifest = await readPackageManifest(packageRoot); if (!manifest) return null; - return ( - (await resolveNodePackageExport(packageRoot, parsed.subpath, manifest)) ?? - (await resolveNodePackageFallback(packageRoot, parsed.subpath, manifest)) + return Object.hasOwn(manifest, "exports") + ? resolveNodePackageExport(packageRoot, parsed.subpath, manifest) + : resolveNodePackageFallback(packageRoot, parsed.subpath, manifest); +} + +async function resolveNodePackageRequire(specifier: string, importerPath: string): Promise { + const parsed = splitBarePackageSpecifier(specifier); + if (!parsed) return null; + const packageRoot = await findNodePackageRoot(parsed.name, importerPath); + if (!packageRoot) return null; + const manifest = await readPackageManifest(packageRoot); + if (!manifest) return null; + + if (Object.hasOwn(manifest, "exports")) { + return resolveNodePackageExport(packageRoot, parsed.subpath, manifest, SUPPORTED_PACKAGE_REQUIRE_CONDITIONS); + } + if (parsed.subpath !== null) { + return resolvePackageSourceTarget(packageRoot, path.join(packageRoot, parsed.subpath)); + } + const main = manifest.main; + return typeof main === "string" + ? await resolvePackageSourceTarget(packageRoot, path.resolve(packageRoot, main)) + : await resolvePackageSourceTarget(packageRoot, path.join(packageRoot, "index")); +} + +async function validateResolvedBarePackagePath( + specifier: string, + importerPath: string, + resolvedPath: string, +): Promise { + const parsed = splitBarePackageSpecifier(specifier); + const packageRoot = parsed ? await findNodePackageRoot(parsed.name, importerPath) : null; + return packageRoot ? resolvePackageFileTarget(packageRoot, resolvedPath) : null; +} + +async function isSelectedNoTypeEsmPackageBranch( + specifier: string, + importerPath: string, + resolvedPath: string, +): Promise { + const parsed = splitBarePackageSpecifier(specifier); + const packageRoot = parsed ? await findNodePackageRoot(parsed.name, importerPath) : null; + const manifest = packageRoot ? await readPackageManifest(packageRoot) : null; + if (!packageRoot || !manifest || manifest.type !== undefined) { + return false; + } + if (parsed?.subpath === null && typeof manifest.module === "string") { + const moduleEntry = await resolvePackageSourceTarget(packageRoot, path.resolve(packageRoot, manifest.module)); + if (moduleEntry && path.resolve(moduleEntry) === path.resolve(resolvedPath)) { + return true; + } + } + if (!Object.hasOwn(manifest, "exports")) { + return false; + } + const importTarget = await resolveNodePackageExport(packageRoot, parsed?.subpath ?? null, manifest); + const requireTarget = await resolveNodePackageExport( + packageRoot, + parsed?.subpath ?? null, + manifest, + SUPPORTED_PACKAGE_REQUIRE_CONDITIONS, + ); + return Boolean( + importTarget && + path.resolve(importTarget) === path.resolve(resolvedPath) && + (!requireTarget || path.resolve(requireTarget) !== path.resolve(importTarget)), ); } @@ -971,24 +1206,26 @@ async function resolveExtensionBareDependency(specifier: string, importerPath: s } async function resolveExtensionBareDependencyUncached(specifier: string, importerPath: string): Promise { + // Resolve against the runtime package manifest first. Besides working in a + // compiled binary, this preserves the package's ESM `import` condition when + // the absolute target is later loaded outside normal package resolution. + const packageResolved = await resolveNodePackageDependency(specifier, importerPath); + if (packageResolved) { + return packageResolved; + } try { const resolved = Bun.resolveSync(specifier, path.dirname(importerPath)); if (resolved && resolved !== specifier && !resolved.startsWith("node:") && !resolved.startsWith("bun:")) { - return resolved; + return validateResolvedBarePackagePath(specifier, importerPath, resolved); } } catch { // Compiled binaries do not reliably resolve runtime extension node_modules. } - return resolveNodePackageDependency(specifier, importerPath); + return null; } const NATIVE_ADDON_EXTENSION = ".node"; -// Match CommonJS require calls so bare native-addon specifiers can be pinned -// to absolute paths. Only requires whose resolution lands on a `.node` addon -// are rewritten; everything else stays on Bun's native resolver. -const NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX = /(\brequire\s*\(\s*["'])([^"'()\s]+)(["']\s*\))/g; - /** * Resolve a bare specifier whose target is a native `.node` addon — either a * package subpath ending in `.node`, or a package whose `main` points at an @@ -1025,48 +1262,119 @@ async function resolveExtensionNativeAddonUncached(specifier: string, importerPa target = typeof main === "string" && main.endsWith(NATIVE_ADDON_EXTENSION) ? path.resolve(packageRoot, main) : null; } - if (!target || !(await pathExists(target))) { + if (!target) { return null; } - return realpathOrSelf(target); + return resolvePackageFileTarget(packageRoot, target); +} + +async function resolveExtensionBareRequire(specifier: string, importerPath: string): Promise { + if (!isBareExtensionDependencySpecifier(specifier)) { + return null; + } + + const cacheKey = `${specifier}\0${path.resolve(path.dirname(importerPath))}`; + const cached = bareRequireResolutionCache.get(cacheKey); + if (cached) return cached; + + const resolution = (async () => { + const nativeAddon = await resolveExtensionNativeAddon(specifier, importerPath); + if (nativeAddon) { + return nativeAddon; + } + const packageResolved = await resolveNodePackageRequire(specifier, importerPath); + if (packageResolved) { + return realpathOrSelf(packageResolved); + } + try { + const resolved = createRequire(importerPath).resolve(specifier); + return resolved === specifier || resolved.startsWith("node:") || resolved.startsWith("bun:") + ? null + : await validateResolvedBarePackagePath(specifier, importerPath, resolved); + } catch { + return null; + } + })(); + bareRequireResolutionCache.set(cacheKey, resolution); + return resolution; +} + +async function resolveExtensionCommonJsRequire(specifier: string, importerPath: string): Promise { + const remappedSpecifier = remapLegacyPiSpecifier(specifier); + if (remappedSpecifier) { + try { + const resolved = resolveCanonicalPiSpecifier(remappedSpecifier); + if (isBundledVirtualSpecifier(resolved)) { + const moduleKey = resolved.slice(BUNDLED_VIRTUAL_SCHEME.length); + if (!(moduleKey in loadedBundledModules)) { + await loadBundledModule(moduleKey); + } + } + return resolved; + } catch { + // A malformed compiled registry can still fall through to an + // extension-installed legacy peer dependency. + } + } + return resolveExtensionBareRequire(specifier, importerPath); } /** - * Rewrite bare `require()` specifiers that resolve to native `.node` addons - * into absolute-path requires. In `bun build --compile` binaries, Bun's bare - * resolution fails for packages whose `main` is a `.node` addon ("Cannot find - * module '@scope/pkg-'") even when the package sits in the - * extension's own node_modules; requiring the addon by absolute path works. + * Rewrite CommonJS graph specifiers that cannot resolve from the bridge's + * generated function: bare `require()` calls and, for graph-owned CommonJS + * sources, import specifiers. Resolved targets are retained for synchronous + * lazy hydration after load-time source caches clear. */ -async function rewriteExtensionNativeAddonRequires(source: string, importerPath: string): Promise { - let rewritten = ""; - let lastIndex = 0; - for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) { - const matchIndex = match.index; - if (matchIndex === undefined) continue; - - const [fullMatch, prefix, specifier, suffix] = match; - if (!prefix || !specifier || !suffix) continue; - - const resolved = await resolveExtensionNativeAddon(specifier, importerPath); +async function rewriteExtensionSpecifiers( + source: string, + importerPath: string, + rewriteImports = false, +): Promise { + const references = collectExtensionSpecifierReferences(source, importerPath); + const resolvedSpecifierTargets = new Map(); + const replacements: Array = []; + for (const reference of references) { + let resolved: string | null = null; + if (reference.kind === "require") { + resolved = await resolveExtensionCommonJsRequire(reference.specifier, importerPath); + } else if (rewriteImports) { + if (reference.specifier.startsWith(".")) { + const candidate = Bun.resolveSync(reference.specifier, path.dirname(importerPath)); + resolved = hasSourceModuleExtension(candidate) ? await realpathOrSelf(candidate) : null; + } else if (reference.specifier.startsWith("#")) { + resolved = await resolvePackageImportSpecifier(reference.specifier, importerPath); + } else { + resolved = await resolveExtensionBareDependency(reference.specifier, importerPath); + } + } if (!resolved) continue; - - rewritten += source.slice(lastIndex, matchIndex); - // Forward slashes keep Windows paths valid inside single- or double-quoted literals. - rewritten += `${prefix}${stripWindowsExtendedLengthPathPrefix(resolved).replaceAll("\\", "/")}${suffix}`; - lastIndex = matchIndex + fullMatch.length; + const replacement = stripWindowsExtendedLengthPathPrefix(resolved).replaceAll("\\", "/"); + resolvedSpecifierTargets.set(`${reference.kind}\0${reference.specifier}`, replacement); + replacements.push({ ...reference, replacement }); } + extensionSynchronousSpecifierTargets.set(importerPath, resolvedSpecifierTargets); + return applySpecifierReplacements(source, replacements); +} - if (lastIndex === 0) { +function rewriteExtensionSpecifiersFromCache(source: string, importerPath: string): string { + const resolvedSpecifierTargets = extensionSynchronousSpecifierTargets.get(importerPath); + if (!resolvedSpecifierTargets || resolvedSpecifierTargets.size === 0) { return source; } - return `${rewritten}${source.slice(lastIndex)}`; + const replacements: Array = []; + for (const reference of collectExtensionSpecifierReferences(source, importerPath)) { + const replacement = resolvedSpecifierTargets.get(`${reference.kind}\0${reference.specifier}`); + if (replacement) { + replacements.push({ ...reference, replacement }); + } + } + return applySpecifierReplacements(source, replacements); } /** * Whether a module's source contains a bare require that resolves to a native * `.node` addon — i.e. a napi-rs style loader that must be hooked into the - * extension graph so {@link rewriteExtensionNativeAddonRequires} can pin its + * extension graph so {@link rewriteExtensionSpecifiers} can pin its * platform-package requires to absolute paths. */ async function moduleRequiresNativeAddon(modulePath: string): Promise { @@ -1085,63 +1393,33 @@ async function moduleRequiresNativeAddonUncached(modulePath: string): Promise { - let rewritten = ""; - let lastIndex = 0; - for (const match of source.matchAll(BARE_EXTENSION_IMPORT_SPECIFIER_REGEX)) { - const matchIndex = match.index; - if (matchIndex === undefined) continue; - - const [fullMatch, prefix, specifier, suffix] = match; - if (!prefix || !specifier || !suffix) continue; - - const resolved = await resolveExtensionBareDependency(specifier, importerPath); - if (!resolved) continue; - - rewritten += source.slice(lastIndex, matchIndex); - rewritten += `${prefix}${toGraphImportSpecifier(resolved, mtimeTag)}${suffix}`; - lastIndex = matchIndex + fullMatch.length; - } - - if (lastIndex === 0) { - return source; - } - return `${rewritten}${source.slice(lastIndex)}`; -} - function escapeRegExp(value: string): string { return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } -// Match source modules in an extension graph: relative imports, package -// `imports` aliases such as `#src/*`, and extension-local bare dependency -// entries. Bare imports inside node_modules dependencies remain native Bun -// resolutions; once the dependency entry is hooked, its relative children are -// still collected and rewritten with the reload mtime tag. `require()` calls -// are scanned too so CJS entries and napi-rs loaders reached without an -// import statement still join the graph. -const EXTENSION_GRAPH_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])([^"'()\s]+)(["'])/g; +// Source modules in an extension graph are discovered from parsed static, +// dynamic, re-export, and direct CommonJS require specifiers. Parsing keeps +// import-looking text in strings, templates, regex literals, and comments out +// of dependency resolution. // Extension source realpaths already covered by an installed load-time hook for // each entry. `Bun.plugin()` registrations are process-global and permanent, so // reloads install supplemental hooks only for modules added to the graph since // the previous load. const extensionGraphHookModules = new Map>(); +const extensionGraphCacheBustResolvedImportModules = new Map>(); const commonJsModuleSources = new Map(); const commonJsFallbackModulePaths = new Map(); +const extensionSynchronousSpecifierTargets = new Map>(); +const commonJsGraphModulePaths = new Set(); const COMMONJS_REQUIRE_GLOBAL = "__ompLegacyPiRequireGraphModule"; const commonJsModuleDefinitions = new Map(); const commonJsModuleCache = new Map< @@ -1162,7 +1440,13 @@ function evaluateGraphCommonJs(modulePath: string): unknown { if (cached) { return cached.exports; } - const definition = commonJsModuleDefinitions.get(modulePath); + let definition = commonJsModuleDefinitions.get(modulePath); + if (!definition && commonJsGraphModulePaths.has(modulePath)) { + const targetPath = commonJsFallbackModulePaths.get(modulePath) ?? modulePath; + const source = rewriteExtensionSpecifiersFromCache(fs.readFileSync(targetPath, "utf8"), modulePath); + synthesizeCommonJsDefaultModule(modulePath, source, targetPath); + definition = commonJsModuleDefinitions.get(modulePath); + } if (!definition) { throw new Error(`Missing graph-owned CommonJS definition: ${modulePath}`); } @@ -1179,6 +1463,14 @@ function evaluateGraphCommonJs(modulePath: string): unknown { commonJsModuleCache.set(modulePath, module); const graphRequire: NodeJS.Require = Object.assign( (specifier: string) => { + if (isBundledVirtualSpecifier(specifier)) { + const moduleKey = specifier.slice(BUNDLED_VIRTUAL_SCHEME.length); + const bundledModule = loadedBundledModules[moduleKey]; + if (!bundledModule) { + throw new Error(`Missing bundled CommonJS host module: ${moduleKey}`); + } + return bundledModule; + } const resolved = nativeRequire.resolve(specifier); let graphPath = resolved; try { @@ -1186,7 +1478,7 @@ function evaluateGraphCommonJs(modulePath: string): unknown { } catch { // Builtins and virtual modules have no filesystem realpath. } - return commonJsModuleDefinitions.has(graphPath) ? evaluateGraphCommonJs(graphPath) : nativeRequire(specifier); + return commonJsGraphModulePaths.has(graphPath) ? evaluateGraphCommonJs(graphPath) : nativeRequire(specifier); }, { resolve: nativeRequire.resolve, @@ -1234,30 +1526,43 @@ async function realpathOrSelfUncached(p: string): Promise { } } +interface ExtensionModuleGraph { + readonly modules: Map; + readonly cacheBustResolvedImportModules: Set; + readonly commonJsPaths: Set; +} + /** * Walk the extension's import graph starting at `entryRealPath`, returning the * realpath of every reachable source module OMP must rewrite at load time. - * Relative imports and package `imports` aliases are always graph-owned. - * Extension-local bare dependency entries are also included so their relative - * children receive the reload mtime tag; bare imports inside those dependencies - * remain native Bun resolutions to avoid taking over full third-party graphs. - * CommonJS modules reached through `require()` stay on Bun's native loader - * unless they resolve native addons. CommonJS reached through ESM imports stays - * graph-owned so the load hook can expose its exports through an ESM default. + * Relative imports, package `imports` aliases, and ESM bare dependencies are + * graph-owned recursively because compiled Bun cannot resolve runtime + * `node_modules` from those modules. Graph-owned CommonJS modules also own + * their relative and bare CommonJS descendants, which are evaluated by the + * synchronous bridge. Resolved ESM imports inside third-party dependencies + * omit the reload tag so their importer paths stay query-free. */ -async function collectExtensionModules(entryRealPath: string): Promise> { +async function collectExtensionModules(entryRealPath: string): Promise { const modules = new Map(); - const queuedFollowBareDependencies = new Map([[entryRealPath, true]]); - const queue: Array<{ file: string; followBareDependencies: boolean }> = [ - { file: entryRealPath, followBareDependencies: true }, - ]; + const commonJsPaths = new Set(); + const queuedCacheBustResolvedImports = new Map([[entryRealPath, true]]); + const queuedModuleKinds = new Map([[entryRealPath, "esm"]]); + const queuedEsmBranchPaths = new Set(); + const queue: Array<{ + file: string; + cacheBustResolvedImports: boolean; + moduleKind?: ExtensionModuleKind; + esmBranch?: boolean; + }> = [{ file: entryRealPath, cacheBustResolvedImports: true, moduleKind: "esm" }]; while (queue.length > 0) { const item = queue.pop(); if (!item) { continue; } const file = item.file; - const followBareDependencies = queuedFollowBareDependencies.get(file) ?? item.followBareDependencies; + const cacheBustResolvedImports = queuedCacheBustResolvedImports.get(file) ?? item.cacheBustResolvedImports; + const inheritedModuleKind = queuedModuleKinds.get(file) ?? item.moduleKind; + const esmBranch = queuedEsmBranchPaths.has(file) || item.esmBranch === true; if (modules.has(file)) { continue; } @@ -1268,109 +1573,330 @@ async function collectExtensionModules(entryRealPath: string): Promise(); - const requiredSpecifiers = new Set(); - for (const match of source.matchAll(EXTENSION_GRAPH_SPECIFIER_REGEX)) { - if (match[2]) specifiers.add(match[2]); - } - for (const match of source.matchAll(NATIVE_ADDON_REQUIRE_SPECIFIER_REGEX)) { - if (match[2]) { - specifiers.add(match[2]); - requiredSpecifiers.add(match[2]); - } - } - for (const specifier of specifiers) { + const references = collectExtensionSpecifierReferences(source, file, ast); + for (const reference of references) { + const specifier = reference.specifier; try { let resolved: string | null = null; - let nextFollowsBareDependencies = followBareDependencies; - const isRequired = requiredSpecifiers.has(specifier); + let nextCacheBustResolvedImports = cacheBustResolvedImports; + let resolvedModuleKind: ExtensionModuleKind | undefined; + let resolvedEsmBranch = false; + let requiresNativeAddonRewrite = false; + const isRequired = reference.kind === "require"; if (specifier.startsWith(".")) { const candidate = Bun.resolveSync(specifier, dir); - if ( - hasSourceModuleExtension(candidate) && - (!isRequired || (await moduleRequiresNativeAddon(candidate))) - ) { - resolved = await realpathOrSelf(candidate); + if (hasSourceModuleExtension(candidate)) { + const inheritedTargetKind = isRequired + ? sourceIsCommonJs + ? "commonjs" + : undefined + : sourceIsCommonJs + ? "commonjs" + : esmBranch + ? "esm" + : undefined; + const targetIsCommonJs = await isCommonJsModulePath(candidate, undefined, inheritedTargetKind); + const isCommonJsDescendant = isRequired && sourceIsCommonJs && targetIsCommonJs; + requiresNativeAddonRewrite = + isRequired && !isCommonJsDescendant && (await moduleRequiresNativeAddon(candidate)); + if (!isRequired || isCommonJsDescendant || requiresNativeAddonRewrite) { + resolved = await realpathOrSelf(candidate); + resolvedModuleKind = targetIsCommonJs ? "commonjs" : "esm"; + resolvedEsmBranch = !targetIsCommonJs && esmBranch; + } } } else if (specifier.startsWith("#")) { const candidate = await resolvePackageImportSpecifier(specifier, file); - if (candidate && (!isRequired || (await moduleRequiresNativeAddon(candidate)))) { - resolved = candidate; + if (candidate) { + const inheritedTargetKind = isRequired + ? sourceIsCommonJs + ? "commonjs" + : undefined + : sourceIsCommonJs + ? "commonjs" + : esmBranch + ? "esm" + : undefined; + const targetIsCommonJs = await isCommonJsModulePath(candidate, undefined, inheritedTargetKind); + const isCommonJsDescendant = isRequired && sourceIsCommonJs && targetIsCommonJs; + requiresNativeAddonRewrite = + isRequired && !isCommonJsDescendant && (await moduleRequiresNativeAddon(candidate)); + if (!isRequired || isCommonJsDescendant || requiresNativeAddonRewrite) { + resolved = candidate; + resolvedModuleKind = targetIsCommonJs ? "commonjs" : "esm"; + resolvedEsmBranch = !targetIsCommonJs && esmBranch; + } } } else if ( - followBareDependencies && isBareExtensionDependencySpecifier(specifier) && !remapLegacyPiSpecifier(specifier) && specifier !== "typebox" && specifier !== "@sinclair/typebox" ) { - const parsed = splitBarePackageSpecifier(specifier); - const packageRoot = parsed ? await findNodePackageRoot(parsed.name, file) : null; - const manifest = packageRoot ? await readPackageManifest(packageRoot) : null; - const dependencyEntry = manifest ? await resolveExtensionBareDependency(specifier, file) : null; - const dependencyExtension = dependencyEntry ? path.extname(dependencyEntry) : null; - const isCommonJsEntry = - dependencyExtension === ".cjs" || - dependencyExtension === ".cts" || - ((dependencyExtension === ".js" || dependencyExtension === ".jsx") && manifest?.type !== "module"); + const dependencyEntry = isRequired + ? await resolveExtensionBareRequire(specifier, file) + : await resolveExtensionBareDependency(specifier, file); const isHookableEntry = Boolean(dependencyEntry && hasSourceModuleExtension(dependencyEntry)); - const hookCommonJsEntry = - isHookableEntry && isCommonJsEntry && dependencyEntry - ? await moduleRequiresNativeAddon(dependencyEntry) + const selectedEsmBranch = + !isRequired && + isHookableEntry && + dependencyEntry !== null && + (await isSelectedNoTypeEsmPackageBranch(specifier, file, dependencyEntry)); + const inheritedTargetKind = isRequired + ? sourceIsCommonJs + ? "commonjs" + : undefined + : selectedEsmBranch + ? "esm" + : undefined; + const isCommonJsEntry = + isHookableEntry && dependencyEntry + ? await isCommonJsModulePath(dependencyEntry, undefined, inheritedTargetKind) : false; - if (isHookableEntry && dependencyEntry && ((!isRequired && !isCommonJsEntry) || hookCommonJsEntry)) { + if (isHookableEntry && dependencyEntry && (!isRequired || (sourceIsCommonJs && isCommonJsEntry))) { resolved = await realpathOrSelf(dependencyEntry); + } else if (isHookableEntry && dependencyEntry && isRequired) { + requiresNativeAddonRewrite = await moduleRequiresNativeAddon(dependencyEntry); + if (requiresNativeAddonRewrite) { + resolved = await realpathOrSelf(dependencyEntry); + } } - if (resolved && hookCommonJsEntry) { - nativeAddonLoaderModulePaths.add(resolved); + if (resolved) { + resolvedModuleKind = isCommonJsEntry ? "commonjs" : "esm"; + resolvedEsmBranch = selectedEsmBranch && !isCommonJsEntry; } - nextFollowsBareDependencies = false; + nextCacheBustResolvedImports = false; } - if (resolved && isRequired) { + if (resolved && requiresNativeAddonRewrite) { nativeAddonLoaderModulePaths.add(resolved); } - if (resolved && !modules.has(resolved)) { - const queuedFollowsBareDependencies = queuedFollowBareDependencies.get(resolved) ?? false; - const mergedFollowsBareDependencies = queuedFollowsBareDependencies || nextFollowsBareDependencies; - queuedFollowBareDependencies.set(resolved, mergedFollowsBareDependencies); - queue.push({ file: resolved, followBareDependencies: mergedFollowsBareDependencies }); + if (resolved) { + const queuedCacheBust = queuedCacheBustResolvedImports.get(resolved) ?? false; + const mergedCacheBust = queuedCacheBust || nextCacheBustResolvedImports; + queuedCacheBustResolvedImports.set(resolved, mergedCacheBust); + const queuedModuleKind = queuedModuleKinds.get(resolved); + if (queuedModuleKind && resolvedModuleKind && queuedModuleKind !== resolvedModuleKind) { + throw new ExtensionModuleKindConflictError( + `Conflicting extension module kinds for ${resolved}: ${queuedModuleKind} and ${resolvedModuleKind}`, + ); + } + const mergedModuleKind = queuedModuleKind ?? resolvedModuleKind; + if (mergedModuleKind) { + queuedModuleKinds.set(resolved, mergedModuleKind); + } + if (resolvedEsmBranch) { + queuedEsmBranchPaths.add(resolved); + } + if (!modules.has(resolved)) { + queue.push({ + file: resolved, + cacheBustResolvedImports: mergedCacheBust, + moduleKind: mergedModuleKind, + esmBranch: resolvedEsmBranch, + }); + } + } + } catch (error) { + if (error instanceof ExtensionModuleKindConflictError) { + throw error; } - } catch { // Unresolvable import (e.g. a type-only path); skip it. } } } - for (const modulePath of nativeAddonLoaderModulePaths) { - const source = modules.get(modulePath); - if (source !== undefined) { - modules.set(modulePath, await rewriteExtensionNativeAddonRequires(source, modulePath)); + for (const [modulePath, source] of modules) { + if (commonJsPaths.has(modulePath) || nativeAddonLoaderModulePaths.has(modulePath)) { + modules.set(modulePath, await rewriteExtensionSpecifiers(source, modulePath, commonJsPaths.has(modulePath))); } } - return modules; + return { + modules, + commonJsPaths, + cacheBustResolvedImportModules: new Set( + [...queuedCacheBustResolvedImports] + .filter(([modulePath, enabled]) => enabled && modules.has(modulePath)) + .map(([modulePath]) => modulePath), + ), + }; } /** * Discovers CommonJS export names Bun normally exposes to ESM importers. The * bridge must declare them statically because its default export is synthetic. */ -function collectCommonJsNamedExports(source: string): string[] { - const names = new Set(); - const assignmentPattern = /(?:^|[;\n])\s*(?:exports|module\.exports)\.([A-Za-z_$][\w$]*)\s*=/gm; - for (const match of source.matchAll(assignmentPattern)) { - const name = match[1]; - if (name && name !== "default") { - names.add(name); - } +const COMMONJS_NAMED_EXPORT_IDENTIFIER = /^[A-Za-z_$][\w$]*$/; + +function collectCommonJsNamedExports(source: string, modulePath: string, visited = new Set()): string[] { + let realModulePath = modulePath; + try { + realModulePath = fs.realpathSync(modulePath); + } catch { + // The caller's path remains the stable cycle key when realpath fails. } - const objectPattern = /module\.exports\s*=\s*\{([\s\S]*?)\}/g; - for (const objectMatch of source.matchAll(objectPattern)) { - const propertyPattern = /(?:^|,)\s*(?:([A-Za-z_$][\w$]*)\s*(?=[:,]|$)|["']([A-Za-z_$][\w$]*)["']\s*:)/g; - for (const propertyMatch of objectMatch[1]?.matchAll(propertyPattern) ?? []) { - const name = propertyMatch[1] ?? propertyMatch[2]; - if (name && name !== "default") { + if (visited.has(realModulePath)) { + return []; + } + visited.add(realModulePath); + + const names = new Set(); + + const reexportSpecifiers = new Set(); + const ast = parseExtensionSource(source, modulePath); + traverseAst(ast, { + enter(nodePath) { + const node = nodePath.node; + if (node.type === "CallExpression") { + const definePropertyCall = + node.callee.type === "MemberExpression" && + !node.callee.computed && + node.callee.object.type === "Identifier" && + node.callee.object.name === "Object" && + node.callee.property.type === "Identifier" && + node.callee.property.name === "defineProperty" && + !nodePath.scope.hasBinding("Object", true); + if (definePropertyCall) { + const target = node.arguments[0]; + const property = node.arguments[1]; + const targetsExports = + (target?.type === "Identifier" && + target.name === "exports" && + !nodePath.scope.hasBinding("exports", true)) || + (target?.type === "MemberExpression" && + !target.computed && + target.object.type === "Identifier" && + target.object.name === "module" && + target.property.type === "Identifier" && + target.property.name === "exports" && + !nodePath.scope.hasBinding("module", true)); + if ( + targetsExports && + property?.type === "StringLiteral" && + property.value !== "default" && + COMMONJS_NAMED_EXPORT_IDENTIFIER.test(property.value) + ) { + names.add(property.value); + } + return; + } + if (node.callee.type === "Identifier" && node.callee.name === "__exportStar") { + const source = node.arguments[0]; + const target = node.arguments[1]; + const targetsExports = + (target?.type === "Identifier" && + target.name === "exports" && + !nodePath.scope.hasBinding("exports", true)) || + (target?.type === "MemberExpression" && + !target.computed && + target.object.type === "Identifier" && + target.object.name === "module" && + target.property.type === "Identifier" && + target.property.name === "exports" && + !nodePath.scope.hasBinding("module", true)); + if ( + targetsExports && + source?.type === "CallExpression" && + source.callee.type === "Identifier" && + source.callee.name === "require" && + !nodePath.scope.hasBinding("require", true) + ) { + const argument = source.arguments[0]; + if (argument?.type === "StringLiteral") { + reexportSpecifiers.add(argument.value); + } + } + return; + } + } + if (node.type !== "AssignmentExpression" || node.operator !== "=" || node.left.type !== "MemberExpression") { + return; + } + const left = node.left; + const propertyName = + !left.computed && left.property.type === "Identifier" + ? left.property.name + : left.computed && left.property.type === "StringLiteral" + ? left.property.value + : null; + const object = left.object; + const assignsExportsProperty = + propertyName !== null && + ((object.type === "Identifier" && + object.name === "exports" && + !nodePath.scope.hasBinding("exports", true)) || + (object.type === "MemberExpression" && + !object.computed && + object.object.type === "Identifier" && + object.object.name === "module" && + object.property.type === "Identifier" && + object.property.name === "exports" && + !nodePath.scope.hasBinding("module", true))); + if (assignsExportsProperty) { + if (propertyName !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(propertyName)) { + names.add(propertyName); + } + return; + } + const assignsModuleExports = + !left.computed && + left.object.type === "Identifier" && + left.object.name === "module" && + left.property.type === "Identifier" && + left.property.name === "exports" && + !nodePath.scope.hasBinding("module", true); + if (!assignsModuleExports) return; + + const right = node.right; + if (right.type === "ObjectExpression") { + for (const property of right.properties) { + if ((property.type !== "ObjectProperty" && property.type !== "ObjectMethod") || property.computed) { + continue; + } + const name = + property.key.type === "Identifier" + ? property.key.name + : property.key.type === "StringLiteral" + ? property.key.value + : null; + if (name && name !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(name)) { + names.add(name); + } + } + return; + } + if ( + right.type === "CallExpression" && + right.callee.type === "Identifier" && + right.callee.name === "require" && + !nodePath.scope.hasBinding("require", true) + ) { + const argument = right.arguments[0]; + if (argument?.type === "StringLiteral") { + reexportSpecifiers.add(argument.value); + } + } + }, + }); + const nativeRequire = createRequire(modulePath); + for (const specifier of reexportSpecifiers) { + try { + const resolved = fs.realpathSync(nativeRequire.resolve(specifier)); + const reexportedSource = rewriteExtensionSpecifiersFromCache(fs.readFileSync(resolved, "utf8"), resolved); + for (const name of collectCommonJsNamedExports(reexportedSource, resolved, visited)) { names.add(name); } + } catch { + // Native modules and non-source re-exports do not expose analyzable names. } } return [...names]; @@ -1397,7 +1923,7 @@ function synthesizeCommonJsDefaultModule(modulePath: string, source: string, tar }); commonJsModuleCache.delete(modulePath); const exportsBinding = "__ompLegacyPiCommonJsExports"; - const namedExports = collectCommonJsNamedExports(executableSource) + const namedExports = collectCommonJsNamedExports(executableSource, targetPath) .map( (name, index) => `const __ompLegacyPiCommonJsExport${index} = ${exportsBinding}[${JSON.stringify(name)}]; export { __ompLegacyPiCommonJsExport${index} as ${name} };`, @@ -1428,24 +1954,23 @@ async function prepareCommonJsDefaultModule(modulePath: string, source: string): /** * Install exact-path load hooks for the current extension graph. ESM/TS source - * retains the async rewrite path. CommonJS wrappers and native-addon loaders - * stay synchronous because Bun rejects `require()` targets backed by async - * `onLoad` callbacks. + * retains the async rewrite path. Graph-owned CommonJS modules and native-addon + * loaders stay synchronous because Bun rejects `require()` targets backed by + * async `onLoad` callbacks. */ async function installExtensionGraphHook( entryRealPath: string, modules: Map, commonJsPaths: Set, + cacheBustResolvedImportModules: ReadonlySet, ): Promise<{ asyncModules: Map; syncSourceModules: Map }> { const asyncModules = new Map(); const syncSourceModules = new Map(); for (const [modulePath, source] of modules) { - const extension = path.extname(modulePath); - if (extension === ".cjs" || extension === ".cts") { - if (!commonJsPaths.has(modulePath)) { - throw new Error(`Missing CommonJS compatibility source: ${modulePath}`); - } - } else if (nativeAddonLoaderModulePaths.has(modulePath)) { + if (commonJsPaths.has(modulePath)) { + continue; + } + if (nativeAddonLoaderModulePaths.has(modulePath)) { syncSourceModules.set(modulePath, source); } else { asyncModules.set(modulePath, source); @@ -1472,8 +1997,9 @@ async function installExtensionGraphHook( } else { raw = await Bun.file(sourcePath).text(); } + const resolvedImportMtimeTag = cacheBustResolvedImportModules.has(sourcePath) ? mtimeTag : null; return { - contents: await rewriteLegacyExtensionSource(raw, sourcePath, mtimeTag), + contents: await rewriteLegacyExtensionSource(raw, sourcePath, mtimeTag, resolvedImportMtimeTag), loader: getLoader(sourcePath), }; }); @@ -1491,13 +2017,12 @@ async function installExtensionGraphHook( build.onLoad({ filter, namespace: "file" }, args => { const queryIndex = args.path.indexOf("?mtime="); const sourcePath = queryIndex >= 0 ? args.path.slice(0, queryIndex) : args.path; - const source = - commonJsModuleSources.get(sourcePath) ?? - synthesizeCommonJsDefaultModule( - sourcePath, - fs.readFileSync(commonJsFallbackModulePaths.get(sourcePath) ?? sourcePath, "utf8"), - commonJsFallbackModulePaths.get(sourcePath) ?? sourcePath, - ); + let source = commonJsModuleSources.get(sourcePath); + if (source === undefined) { + const targetPath = commonJsFallbackModulePaths.get(sourcePath) ?? sourcePath; + const raw = rewriteExtensionSpecifiersFromCache(fs.readFileSync(targetPath, "utf8"), sourcePath); + source = synthesizeCommonJsDefaultModule(sourcePath, raw, targetPath); + } return { contents: source, loader: getLoader(sourcePath) }; }); }, @@ -1537,13 +2062,23 @@ async function installExtensionGraphHook( * during the initial load; `undefined` when no new modules were discovered. */ async function ensureExtensionGraphHook(entryRealPath: string): Promise<{ clear(): void } | undefined> { - const currentModules = await collectExtensionModules(entryRealPath); - const commonJsPaths = new Set(); + const { + modules: currentModules, + commonJsPaths, + cacheBustResolvedImportModules: discoveredCacheBustModules, + } = await collectExtensionModules(entryRealPath); + let cacheBustResolvedImportModules = extensionGraphCacheBustResolvedImportModules.get(entryRealPath); + if (!cacheBustResolvedImportModules) { + cacheBustResolvedImportModules = new Set(); + extensionGraphCacheBustResolvedImportModules.set(entryRealPath, cacheBustResolvedImportModules); + } + for (const modulePath of discoveredCacheBustModules) { + cacheBustResolvedImportModules.add(modulePath); + } for (const [modulePath, source] of currentModules) { - const extension = path.extname(modulePath); - if (extension === ".cjs" || extension === ".cts") { + if (commonJsPaths.has(modulePath)) { commonJsModuleSources.set(modulePath, await prepareCommonJsDefaultModule(modulePath, source)); - commonJsPaths.add(modulePath); + commonJsGraphModulePaths.add(modulePath); } } let hookedModules = extensionGraphHookModules.get(entryRealPath); @@ -1573,6 +2108,7 @@ async function ensureExtensionGraphHook(entryRealPath: string): Promise<{ clear( entryRealPath, pendingModules, pendingCommonJsPaths, + cacheBustResolvedImportModules, )); for (const modulePath of pendingModules.keys()) { hookedModules.add(modulePath); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts index 3e7b7e0ce..62d918cb7 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-ai-type-remap.test.ts @@ -69,6 +69,25 @@ describe("legacy-pi @(scope)/pi-ai root `Type` remap (issue #1437)", () => { expect(loaded.schema.safeParse({ name: "ok", extra: 1 }).success).toBe(false); }); + it("redirects the legacy pi-ai compat entrypoint through the root compatibility shim", async () => { + const entry = await writeFixtureExtension( + [ + 'import { StringEnum, complete, type Model } from "@earendil-works/pi-ai/compat";', + 'export const schema = StringEnum(["red", "green"] as const);', + "export const completeType = typeof complete;", + "export type LegacyModel = Model;", + ].join("\n"), + ); + + const loaded = (await loadLegacyPiModule(entry)) as { + schema: { safeParse: (input: unknown) => { success: boolean } }; + completeType: string; + }; + expect(loaded.schema.safeParse("red").success).toBe(true); + expect(loaded.schema.safeParse("blue").success).toBe(false); + expect(loaded.completeType).toBe("function"); + }); + it('redirects `import { Type } from "@oh-my-pi/pi-ai"` for plugins published against the canonical scope', async () => { const entry = await writeFixtureExtension( ['import { Type } from "@oh-my-pi/pi-ai";', "export const probe = Type;"].join("\n"), @@ -208,6 +227,30 @@ describe("legacy pi package root remaps (issue #1474)", () => { expect(loaded.loadedVersion).toMatch(/^\d+\.\d+\.\d+/); }); + it("loads pi-vimmode's minified legacy imports", async () => { + const entry = await writeFixtureExtension( + [ + 'import{CustomEditor,copyToClipboard}from"@earendil-works/pi-coding-agent";', + 'import{CURSOR_MARKER,decodeKittyPrintable,matchesKey,parseKey,truncateToWidth,visibleWidth}from"@earendil-works/pi-tui";', + "export const apiTypes=[typeof CustomEditor,typeof copyToClipboard,typeof CURSOR_MARKER,typeof decodeKittyPrintable,typeof matchesKey,typeof parseKey,typeof truncateToWidth,typeof visibleWidth];", + 'export const printable=decodeKittyPrintable("\\x1b[97u");', + ].join("\n"), + ); + + const loaded = (await loadLegacyPiModule(entry)) as { apiTypes: string[]; printable: string }; + expect(loaded.apiTypes).toEqual([ + "function", + "function", + "string", + "function", + "function", + "function", + "function", + "function", + ]); + expect(loaded.printable).toBe("a"); + }); + it("preserves legacy defineTool root imports and usable coding tools", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-legacy-coding-tools-")); tempRoots.push(dir); diff --git a/packages/coding-agent/test/extensibility/legacy-pi-bundled-subpath-overrides.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-bundled-subpath-overrides.test.ts index d7bd91ced..6d2919361 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-bundled-subpath-overrides.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-bundled-subpath-overrides.test.ts @@ -120,10 +120,17 @@ process.stdout.write(JSON.stringify([ const overrides = __buildLegacyPiPackageRootOverrides(true, bundledModuleKeys); const missing: string[] = []; for (const key of bundledModuleKeys) { - // pi-ai/pi-coding-agent roots intentionally use the legacy compat shims - // (they re-attach `Type`, `defineTool`, etc. dropped from the canonical - // package surface); typebox is served via TYPEBOX_SHIM_PATH. - if (key === "@oh-my-pi/pi-ai" || key === "@oh-my-pi/pi-coding-agent" || key === "typebox") continue; + // pi-ai/pi-coding-agent/pi-tui roots intentionally use the legacy compat + // shims (they re-attach `Type`, `defineTool`, `decodeKittyPrintable`, etc. + // dropped from the canonical package surfaces); typebox is served via + // TYPEBOX_SHIM_PATH. + if ( + key === "@oh-my-pi/pi-ai" || + key === "@oh-my-pi/pi-coding-agent" || + key === "@oh-my-pi/pi-tui" || + key === "typebox" + ) + continue; if (overrides[key] !== `omp-legacy-pi-bundled:${key}`) { missing.push(key); } @@ -131,7 +138,7 @@ process.stdout.write(JSON.stringify([ expect(missing).toEqual([]); }); - it("keeps pi-ai/pi-coding-agent roots routed to their compat shims in compiled mode", () => { + it("keeps pi-ai/pi-coding-agent/pi-tui roots routed to their compat shims in compiled mode", () => { // The shim entries themselves resolve to virtual bundled specifiers in // compiled mode (the shim files are bundled under their own registry // keys); the test asserts only that the roots stay distinct from the @@ -141,6 +148,7 @@ process.stdout.write(JSON.stringify([ expect(overrides["@oh-my-pi/pi-ai"]).toBeDefined(); expect(overrides["@oh-my-pi/pi-ai"]).not.toBe("omp-legacy-pi-bundled:@oh-my-pi/pi-ai/oauth"); expect(overrides["@oh-my-pi/pi-coding-agent"]).toBeDefined(); + expect(overrides["@oh-my-pi/pi-tui"]).toBeDefined(); }); it("does not register subpath overrides in dev/install mode", () => { diff --git a/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts b/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts index f77afbff2..df4525529 100644 --- a/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts +++ b/packages/coding-agent/test/extensibility/legacy-pi-inplace-load.test.ts @@ -78,6 +78,44 @@ describe("legacy-pi in-place module loading (issue #1674)", () => { expect(mod.value).toBe("config-ok"); }); + it("loads a relative CommonJS helper imported by a TypeScript extension", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "relative-cjs-import-ext", version: "1.0.0" }), + "helper.js": "module.exports = { value: 42 };\n", + "index.ts": [ + 'import helper from "./helper.js";', + "export const value = helper.value;", + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { value: number }; + + expect(mod.value).toBe(42); + }); + + it("remaps legacy Pi requires in graph-owned CommonJS packages to the host shim", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "cjs-legacy-pi-require-ext", version: "1.0.0", type: "module" }), + "node_modules/direct/package.json": JSON.stringify({ + name: "direct", + version: "1.0.0", + main: "index.js", + }), + "node_modules/direct/index.js": 'module.exports = require("@mariozechner/pi-ai").Type;\n', + "index.ts": [ + 'import { Type } from "@oh-my-pi/pi-ai";', + 'import requiredType from "direct";', + "export const sharesHostType = requiredType === Type;", + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { sharesHostType: boolean }; + + expect(mod.sharesHostType).toBe(true); + }); + it("loads a default import from linkedom's CommonJS canvas fallback", async () => { const dir = await writePackage({ "package.json": JSON.stringify({ name: "linkedom-consumer", version: "1.0.0", type: "module" }), @@ -330,6 +368,308 @@ describe("legacy-pi in-place module loading (issue #1674)", () => { expect(second.depValue).toBe("dep-v2"); }); + it("keeps transitive bare dependency importer paths query-free", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "transitive-bare-dep-ext", version: "1.0.0", type: "module" }), + "node_modules/directdep/package.json": JSON.stringify({ + name: "directdep", + version: "1.0.0", + type: "module", + exports: "./index.js", + }), + "node_modules/directdep/index.js": 'export { nestedUrl } from "nesteddep";\n', + "node_modules/nesteddep/package.json": JSON.stringify({ + name: "nesteddep", + version: "1.0.0", + type: "module", + exports: "./index.js", + }), + "node_modules/nesteddep/index.js": "export const nestedUrl = import.meta.url;\n", + "index.ts": 'export { nestedUrl } from "directdep";\nexport default function (pi) { void pi; }\n', + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { nestedUrl: string }; + const expectedNestedUrl = url.pathToFileURL( + await fs.realpath(path.join(dir, "node_modules/nesteddep/index.js")), + ).href; + + expect(mod.nestedUrl).toBe(expectedNestedUrl); + }); + + it("preserves named re-exports from relative ESM children in a no-type dual package", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "dual-package-ext", version: "1.0.0", type: "module" }), + "node_modules/dual-package/package.json": JSON.stringify({ + name: "dual-package", + version: "1.0.0", + main: "lib/commonjs/index.js", + module: "lib/es/index.js", + }), + "node_modules/dual-package/lib/commonjs/index.js": `exports.stringify = value => \`cjs:\${value}\`;\n`, + "node_modules/dual-package/lib/es/index.js": + 'import "./setup.js";\nexport { stringify } from "./stringify.js";\n', + "node_modules/dual-package/lib/es/stringify.js": `export const stringify = value => \`esm:\${value}\`;\n`, + "node_modules/dual-package/lib/es/setup.js": "await Promise.resolve();\n", + "index.ts": [ + 'import { stringify } from "dual-package";', + 'export const result = stringify("value");', + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { result: string }; + + expect(mod.result).toBe("esm:value"); + }); + + it("keeps a syntax-ambiguous conditional import target on its ESM branch", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "conditional-esm-ext", version: "1.0.0", type: "module" }), + "node_modules/conditional/package.json": JSON.stringify({ + name: "conditional", + version: "1.0.0", + exports: { + ".": { + import: "./esm.js", + require: "./cjs.js", + }, + }, + }), + "node_modules/conditional/esm.js": "await Promise.resolve();\n", + "node_modules/conditional/cjs.js": 'throw new Error("require branch loaded");\n', + "index.ts": [ + 'import "conditional";', + "export const loaded = true;", + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { loaded: boolean }; + + expect(mod.loaded).toBe(true); + }); + + it("reloads a hoisted CommonJS dependency required by a relative CommonJS child", async () => { + const entrySource = (version: string): string => + [ + 'import dependency from "directdep";', + "export const dependencyValue = dependency.value;", + `export const entryVersion = ${JSON.stringify(version)};`, + "export default function (pi) { void pi; }", + ].join("\n"); + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "relative-transitive-cjs-ext", version: "1.0.0", type: "module" }), + "node_modules/directdep/package.json": JSON.stringify({ + name: "directdep", + version: "1.0.0", + main: "index.js", + }), + "node_modules/directdep/index.js": 'module.exports = require("./child.js");\n', + "node_modules/directdep/child.js": [ + "void 'export { ignored } from \"ignored\";';", + '// import ignored from "ignored";', + 'module.exports = require("transitive-dependency");', + ].join("\n"), + "node_modules/transitive-dependency/package.json": JSON.stringify({ + name: "transitive-dependency", + version: "1.0.0", + main: "index.js", + }), + "node_modules/transitive-dependency/index.js": 'module.exports = { value: "dep-v1" };\n', + "index.ts": entrySource("v1"), + }); + const entry = path.join(dir, "index.ts"); + const transitiveDependency = path.join(dir, "node_modules", "transitive-dependency", "index.js"); + + const first = (await loadLegacyPiModule(entry)) as { entryVersion: string; dependencyValue: string }; + expect(first.entryVersion).toBe("v1"); + expect(first.dependencyValue).toBe("dep-v1"); + + const firstEntryStat = await fs.stat(entry); + const firstDependencyStat = await fs.stat(transitiveDependency); + await fs.writeFile(entry, entrySource("v2"), "utf8"); + await fs.writeFile(transitiveDependency, 'module.exports = { value: "dep-v2" };\n', "utf8"); + const bumpedEntryMtime = new Date(Math.ceil(firstEntryStat.mtimeMs) + 2_000); + const bumpedDependencyMtime = new Date(Math.ceil(firstDependencyStat.mtimeMs) + 2_000); + await fs.utimes(entry, bumpedEntryMtime, bumpedEntryMtime); + await fs.utimes(transitiveDependency, bumpedDependencyMtime, bumpedDependencyMtime); + + const second = (await loadLegacyPiModule(entry)) as { entryVersion: string; dependencyValue: string }; + expect(second.entryVersion).toBe("v2"); + expect(second.dependencyValue).toBe("dep-v2"); + }); + + it("preserves named imports through CommonJS package re-exports", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "named-cjs-reexport-ext", version: "1.0.0", type: "module" }), + "node_modules/direct/package.json": JSON.stringify({ + name: "direct", + version: "1.0.0", + main: "index.js", + }), + "node_modules/direct/index.js": 'module.exports = require("middle");\n', + "node_modules/middle/package.json": JSON.stringify({ + name: "middle", + version: "1.0.0", + main: "index.js", + }), + "node_modules/middle/index.js": 'module.exports = require("leaf");\n', + "node_modules/leaf/package.json": JSON.stringify({ + name: "leaf", + version: "1.0.0", + main: "index.js", + }), + "node_modules/leaf/index.js": [ + 'module.exports = require("direct");', + 'module.exports.value = "named-reexport-ok";', + ].join("\n"), + "index.ts": [ + 'import { value } from "direct";', + "export { value };", + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { value: string }; + + expect(mod.value).toBe("named-reexport-ok"); + }); + + it("preserves named imports from CommonJS defineProperty and exportStar patterns", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "cjs-export-helper-ext", version: "1.0.0", type: "module" }), + "node_modules/direct/package.json": JSON.stringify({ + name: "direct", + version: "1.0.0", + main: "index.js", + }), + "node_modules/direct/index.js": [ + 'Object.defineProperty(exports, "local", { enumerable: true, get: () => "local-ok" });', + "const __exportStar = (mod, target) => {", + " for (const key in mod) {", + ' if (key !== "default" && !Object.prototype.hasOwnProperty.call(target, key)) {', + " Object.defineProperty(target, key, { enumerable: true, get: () => mod[key] });", + " }", + " }", + "};", + '__exportStar(require("leaf"), exports);', + ].join("\n"), + "node_modules/leaf/package.json": JSON.stringify({ + name: "leaf", + version: "1.0.0", + main: "index.js", + }), + "node_modules/leaf/index.js": 'module.exports = { value: "star-ok" };\n', + "index.ts": [ + 'import { local, value } from "direct";', + "export { local, value };", + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { + local: string; + value: string; + }; + + expect(mod.local).toBe("local-ok"); + expect(mod.value).toBe("star-ok"); + }); + + it("keeps dynamic-import children of a CommonJS package on its CommonJS branch", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "dynamic-cjs-child-ext", version: "1.0.0", type: "module" }), + "node_modules/direct/package.json": JSON.stringify({ + name: "direct", + version: "1.0.0", + main: "index.js", + }), + "node_modules/direct/index.js": + 'module.exports = { load: () => import("./child.js").then(mod => mod.default.value) };\n', + "node_modules/direct/child.js": 'module.exports = { value: "dynamic-child-ok" };\n', + "index.ts": [ + 'import direct from "direct";', + "export const result = await direct.load();", + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { result: string }; + + expect(mod.result).toBe("dynamic-child-ok"); + }); + + it("preserves default imports from CommonJS objects with non-identifier keys", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "hyphenated-cjs-export-ext", version: "1.0.0", type: "module" }), + "node_modules/direct/package.json": JSON.stringify({ + name: "direct", + version: "1.0.0", + main: "index.js", + }), + "node_modules/direct/index.js": 'module.exports = { "foo-bar": true, value: "default-ok" };\n', + "index.ts": [ + 'import direct from "direct";', + "export const result = direct.value;", + "export default function (pi) { void pi; }", + ].join("\n"), + }); + + const mod = (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { result: string }; + + expect(mod.result).toBe("default-ok"); + }); + + it("reloads a lazily imported CommonJS package re-export", async () => { + const entrySource = (version: string): string => + [ + 'export const loadValue = () => import("direct").then(mod => mod.default.value);', + `export const entryVersion = ${JSON.stringify(version)};`, + "export default function (pi) { void pi; }", + ].join("\n"); + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "lazy-cjs-reexport-ext", version: "1.0.0", type: "module" }), + "node_modules/direct/package.json": JSON.stringify({ + name: "direct", + version: "1.0.0", + main: "index.js", + }), + "node_modules/direct/index.js": 'module.exports = require("leaf");\n', + "node_modules/leaf/package.json": JSON.stringify({ + name: "leaf", + version: "1.0.0", + main: "index.js", + }), + "node_modules/leaf/index.js": 'module.exports = { value: "lazy-v1" };\n', + "index.ts": entrySource("v1"), + }); + const entry = path.join(dir, "index.ts"); + const leaf = path.join(dir, "node_modules", "leaf", "index.js"); + + const first = (await loadLegacyPiModule(entry)) as { + entryVersion: string; + loadValue(): Promise; + }; + expect(first.entryVersion).toBe("v1"); + expect(await first.loadValue()).toBe("lazy-v1"); + + const firstEntryStat = await fs.stat(entry); + const firstLeafStat = await fs.stat(leaf); + await fs.writeFile(entry, entrySource("v2"), "utf8"); + await fs.writeFile(leaf, 'module.exports = { value: "lazy-v2" };\n', "utf8"); + const bumpedEntryMtime = new Date(Math.ceil(firstEntryStat.mtimeMs) + 2_000); + const bumpedLeafMtime = new Date(Math.ceil(firstLeafStat.mtimeMs) + 2_000); + await fs.utimes(entry, bumpedEntryMtime, bumpedEntryMtime); + await fs.utimes(leaf, bumpedLeafMtime, bumpedLeafMtime); + + const second = (await loadLegacyPiModule(entry)) as { + entryVersion: string; + loadValue(): Promise; + }; + expect(second.entryVersion).toBe("v2"); + expect(await second.loadValue()).toBe("lazy-v2"); + }); + it("reloads modules added to the relative import graph after the first load", async () => { const entrySource = (version: string, includeHelper: boolean): string => [ @@ -607,7 +947,7 @@ describe("legacy-pi in-place module loading (issue #1674)", () => { [ 'import * as path from "node:path";', 'import { value } from "esmdep/value";', - 'import { rootValue } from "rootdep";', + 'import{rootValue}from"rootdep";', "export const loaded = value + rootValue;", ].join("\n"), importer, @@ -626,9 +966,107 @@ describe("legacy-pi in-place module loading (issue #1674)", () => { expect(rewritten).toContain('from "node:path"'); }); - it("pins native-addon package requires to absolute extension paths", async () => { + it("honors export pattern specificity and package encapsulation", async () => { const dir = await writePackage({ - "package.json": JSON.stringify({ name: "native-require-ext", version: "1.0.0" }), + "package.json": JSON.stringify({ name: "exports-contract-ext", version: "1.0.0", type: "module" }), + "node_modules/pattern-dep/package.json": JSON.stringify({ + name: "pattern-dep", + version: "1.0.0", + type: "module", + exports: { "./*": "./fallback/*.js", "./feature/*": "./features/*.js" }, + }), + "node_modules/pattern-dep/fallback/feature/x.js": "export const selected = 'fallback';", + "node_modules/pattern-dep/features/x.js": "export const selected = 'specific';", + "node_modules/blocked-dep/package.json": JSON.stringify({ + name: "blocked-dep", + version: "1.0.0", + main: "./legacy.cjs", + exports: { ".": { import: "./esm.js" }, "./private": null }, + }), + "node_modules/blocked-dep/esm.js": "export default {};", + "node_modules/blocked-dep/legacy.cjs": "module.exports = {};", + "node_modules/blocked-dep/private.js": "export default {};", + "index.ts": "", + }); + const importer = path.join(dir, "index.ts"); + const rewritten = await __rewriteLegacyExtensionSourceForTests( + [ + 'import { selected } from "pattern-dep/feature/x";', + 'import privateValue from "blocked-dep/private";', + 'const privateRequire = require("blocked-dep/private");', + 'const rootRequire = require("blocked-dep");', + ].join("\n"), + importer, + ); + + const specificUrl = url.pathToFileURL( + await fs.realpath(path.join(dir, "node_modules/pattern-dep/features/x.js")), + ).href; + expect(rewritten).toContain(specificUrl); + expect(rewritten).not.toContain("fallback/feature/x.js"); + expect(rewritten).toContain('from "blocked-dep/private"'); + expect(rewritten).toContain('require("blocked-dep/private")'); + expect(rewritten).toContain('require("blocked-dep")'); + }); + + it("rejects package resolutions that escape the package root", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "package-boundary-ext", version: "1.0.0", type: "module" }), + "node_modules/main-escape/package.json": JSON.stringify({ + name: "main-escape", + version: "1.0.0", + main: "../outside/index.js", + }), + "node_modules/exports-escape/package.json": JSON.stringify({ + name: "exports-escape", + version: "1.0.0", + exports: { "./*": "./../outside/*.js" }, + }), + "node_modules/subpath-escape/package.json": JSON.stringify({ + name: "subpath-escape", + version: "1.0.0", + }), + "node_modules/native-escape/package.json": JSON.stringify({ + name: "native-escape", + version: "1.0.0", + }), + "node_modules/outside/index.js": "export default {};", + "node_modules/outside/value.js": "export default {};", + "node_modules/outside/addon.node": "native fixture", + "index.ts": "", + "node_modules/symlink-escape/package.json": JSON.stringify({ + name: "symlink-escape", + version: "1.0.0", + exports: { "./value": "./link.js" }, + }), + }); + await fs.symlink( + path.join(dir, "node_modules", "outside", "value.js"), + path.join(dir, "node_modules", "symlink-escape", "link.js"), + ); + const importer = path.join(dir, "index.ts"); + const rewritten = await __rewriteLegacyExtensionSourceForTests( + [ + 'import mainEscape from "main-escape";', + 'import exportsEscape from "exports-escape/value";', + 'import subpathEscape from "subpath-escape/../outside";', + 'import symlinkEscape from "symlink-escape/value";', + 'const nativeEscape = require("native-escape/../outside/addon.node");', + "export { exportsEscape, mainEscape, nativeEscape, subpathEscape, symlinkEscape };", + ].join("\n"), + importer, + ); + + expect(rewritten).toContain('from "main-escape"'); + expect(rewritten).toContain('from "exports-escape/value"'); + expect(rewritten).toContain('from "subpath-escape/../outside"'); + expect(rewritten).toContain('require("native-escape/../outside/addon.node")'); + expect(rewritten).toContain('from "symlink-escape/value"'); + }); + + it("pins bare package requires to absolute extension paths", async () => { + const dir = await writePackage({ + "package.json": JSON.stringify({ name: "bare-require-ext", version: "1.0.0" }), "node_modules/@fixture/native-platform/package.json": JSON.stringify({ name: "@fixture/native-platform", version: "1.0.0", @@ -641,6 +1079,13 @@ describe("legacy-pi in-place module loading (issue #1674)", () => { main: "index.js", }), "node_modules/plain-dep/index.js": "module.exports = {};", + "node_modules/condition-dep/package.json": JSON.stringify({ + name: "condition-dep", + version: "1.0.0", + exports: { import: "./esm.js", require: "./cjs.cjs" }, + }), + "node_modules/condition-dep/esm.js": "export default {};", + "node_modules/condition-dep/cjs.cjs": "module.exports = {};", "index.ts": "", }); const importer = path.join(dir, "index.ts"); @@ -648,16 +1093,28 @@ describe("legacy-pi in-place module loading (issue #1674)", () => { [ 'const binding = require("@fixture/native-platform");', 'const plain = require("plain-dep");', + 'const conditional = require("condition-dep");', 'const local = require("./local.node");', - "export { binding, plain, local };", + 'const member = loader.require("plain-dep");', + 'const importText = `from"plain-dep"`;', + 'const importPattern = /from"typebox"/;', + 'function shadowed(require) { return require("plain-dep"); }', + "export { binding, conditional, importPattern, importText, local, member, plain, shadowed };", ].join("\n"), importer, ); const addon = await fs.realpath(path.join(dir, "node_modules/@fixture/native-platform/binding.node")); + const plainDep = await fs.realpath(path.join(dir, "node_modules/plain-dep/index.js")); + const conditionalDep = await fs.realpath(path.join(dir, "node_modules/condition-dep/cjs.cjs")); expect(rewritten).toContain(`require("${addon.replaceAll("\\", "/")}")`); - expect(rewritten).toContain('require("plain-dep")'); + expect(rewritten).toContain(`require("${plainDep.replaceAll("\\", "/")}")`); + expect(rewritten).toContain(`require("${conditionalDep.replaceAll("\\", "/")}")`); expect(rewritten).toContain('require("./local.node")'); + expect(rewritten).toContain('loader.require("plain-dep")'); + expect(rewritten).toContain('`from"plain-dep"`'); + expect(rewritten).toContain('/from"typebox"/'); + expect(rewritten).toContain('function shadowed(require) { return require("plain-dep"); }'); }); it("preserves native-addon rewrites inside wrapped CommonJS dependencies", async () => { diff --git a/packages/coding-agent/test/extensibility/typebox-remap.test.ts b/packages/coding-agent/test/extensibility/typebox-remap.test.ts index a8ae94ef7..4b62d6047 100644 --- a/packages/coding-agent/test/extensibility/typebox-remap.test.ts +++ b/packages/coding-agent/test/extensibility/typebox-remap.test.ts @@ -73,4 +73,16 @@ describe("legacy-pi TypeBox remap", () => { required: ["path"], }); }); + + it("redirects minified bare typebox imports without whitespace around from", async () => { + const entry = await writeFixtureExtension( + 'import{Type}from"typebox";export const schema=Type.Object({name:Type.String()});', + ); + + const loaded = (await loadLegacyPiModule(entry)) as { + schema: { safeParse: (input: unknown) => { success: boolean } }; + }; + expect(loaded.schema.safeParse({ name: "ok" }).success).toBe(true); + expect(loaded.schema.safeParse({}).success).toBe(false); + }); });