diff --git a/bun.lock b/bun.lock index a8f7b37ba..cbde5635d 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 1c6b04a12..2d826750b 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -55,6 +55,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 5c637d6c4..0e131d816 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..dbde63e33 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 { 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,95 @@ interface BundledVirtualResolveResult { namespace: typeof BUNDLED_VIRTUAL_NAMESPACE; } +interface ExtensionSpecifierReference { + readonly kind: "import" | "require"; + readonly specifier: string; + readonly start: number; + readonly end: number; +} + +function collectExtensionSpecifierReferences(source: string, importerPath: string): ExtensionSpecifierReference[] { + 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"); + } + + const ast = (() => { + 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 }, + ); + } + })(); + + 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 +291,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 +310,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 +332,7 @@ function clearLegacyPiResolutionCaches(): void { nodePackageRootCache.clear(); packageManifestCache.clear(); bareDependencyResolutionCache.clear(); + bareRequireResolutionCache.clear(); nativeAddonResolutionCache.clear(); nativeAddonRequireScanCache.clear(); nativeAddonLoaderModulePaths.clear(); @@ -338,14 +449,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 +508,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 +592,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 +600,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 imports inside third-party dependencies, whose + * transitive bare imports must 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 = await 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 rewriteExtensionBareRequires(withImports, importerPath); } /** Test seam for compiled-binary legacy extension source rewriting. */ @@ -552,15 +662,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 @@ -672,7 +778,10 @@ async function readPackageImports(packageRoot: string): Promise = SUPPORTED_PACKAGE_IMPORT_CONDITIONS, +): PackageImportTargetSelection { if (entry === null) { return PACKAGE_IMPORT_EXCLUDED; } @@ -681,7 +790,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 +799,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; @@ -765,38 +874,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(".") || @@ -888,9 +965,10 @@ 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 +977,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( @@ -950,10 +1039,29 @@ 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 resolveSourceModuleFile(path.join(packageRoot, parsed.subpath)); + } + const main = manifest.main; + return typeof main === "string" + ? await resolveSourceModuleFile(path.resolve(packageRoot, main)) + : await resolveSourceModuleFile(path.join(packageRoot, "index")); } async function resolveExtensionBareDependency(specifier: string, importerPath: string): Promise { @@ -971,6 +1079,13 @@ 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:")) { @@ -979,16 +1094,11 @@ async function resolveExtensionBareDependencyUncached(specifier: string, importe } 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 @@ -1031,42 +1141,63 @@ async function resolveExtensionNativeAddonUncached(specifier: string, importerPa return realpathOrSelf(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 realpathOrSelf(resolved); + } catch { + return null; + } + })(); + bareRequireResolutionCache.set(cacheKey, resolution); + return resolution; +} + /** - * 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 bare `require()` specifiers into absolute-path requires. In + * `bun build --compile` binaries, runtime resolution fails even when the + * package sits in the extension's own node_modules. Manifest resolution + * preserves CommonJS `require` export conditions and native-addon entrypoints; + * relative and builtin requires remain untouched. */ -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 rewriteExtensionBareRequires(source: string, importerPath: string): Promise { + const references = await collectExtensionSpecifierReferences(source, importerPath); + const replacements: Array = []; + for (const reference of references) { + if (reference.kind !== "require") continue; + const resolved = await resolveExtensionBareRequire(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; + replacements.push({ + ...reference, + replacement: stripWindowsExtendedLengthPathPrefix(resolved).replaceAll("\\", "/"), + }); } - - if (lastIndex === 0) { - return source; - } - return `${rewritten}${source.slice(lastIndex)}`; + 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 rewriteExtensionBareRequires} can pin its * platform-package requires to absolute paths. */ async function moduleRequiresNativeAddon(modulePath: string): Promise { @@ -1085,61 +1216,29 @@ 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 COMMONJS_REQUIRE_GLOBAL = "__ompLegacyPiRequireGraphModule"; @@ -1234,22 +1333,27 @@ async function realpathOrSelfUncached(p: string): Promise { } } +interface ExtensionModuleGraph { + readonly modules: Map; + readonly cacheBustResolvedImportModules: 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. + * Relative imports, package `imports` aliases, and ESM bare dependencies are + * graph-owned recursively because compiled Bun cannot resolve runtime + * `node_modules` from those modules. Resolved imports inside third-party + * dependencies omit the reload tag so their importer paths stay query-free. * 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. */ -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 queuedCacheBustResolvedImports = new Map([[entryRealPath, true]]); + const queue: Array<{ file: string; cacheBustResolvedImports: boolean }> = [ + { file: entryRealPath, cacheBustResolvedImports: true }, ]; while (queue.length > 0) { const item = queue.pop(); @@ -1257,7 +1361,7 @@ 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 = await collectExtensionSpecifierReferences(source, file); + 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; + const isRequired = reference.kind === "require"; if (specifier.startsWith(".")) { const candidate = Bun.resolveSync(specifier, dir); if ( @@ -1299,7 +1394,6 @@ async function collectExtensionModules(entryRealPath: string): Promise enabled && modules.has(modulePath)) + .map(([modulePath]) => modulePath), + ), + }; } /** @@ -1436,6 +1555,7 @@ async function installExtensionGraphHook( entryRealPath: string, modules: Map, commonJsPaths: Set, + cacheBustResolvedImportModules: ReadonlySet, ): Promise<{ asyncModules: Map; syncSourceModules: Map }> { const asyncModules = new Map(); const syncSourceModules = new Map(); @@ -1472,8 +1592,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), }; }); @@ -1537,7 +1658,16 @@ 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 { modules: currentModules, 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); + } const commonJsPaths = new Set(); for (const [modulePath, source] of currentModules) { const extension = path.extname(modulePath); @@ -1573,6 +1703,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..0cdc2a16c 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 @@ -330,6 +330,34 @@ 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("reloads modules added to the relative import graph after the first load", async () => { const entrySource = (version: string, includeHelper: boolean): string => [ @@ -607,7 +635,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 +654,52 @@ 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("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 +712,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 +726,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); + }); });