diff --git a/bun.lock b/bun.lock index 2ed8e59f8..8e7c23c90 100644 --- a/bun.lock +++ b/bun.lock @@ -76,7 +76,6 @@ "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/package.json b/packages/coding-agent/package.json index ff13962b8..05b13baff 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -51,7 +51,6 @@ "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/src/extensibility/plugins/legacy-pi-compat.ts b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts index 2dfb76858..019db5638 100644 --- a/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts +++ b/packages/coding-agent/src/extensibility/plugins/legacy-pi-compat.ts @@ -6,30 +6,11 @@ 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 @@ -101,6 +82,365 @@ function parseExtensionSource(source: string, importerPath: string): ParseResult } } +const REQUIRE_BINDING = 1 << 0; +const OBJECT_BINDING = 1 << 1; +const EXPORTS_BINDING = 1 << 2; +const MODULE_BINDING = 1 << 3; + +interface StructuralAstNode { + readonly type: string; + readonly [key: string]: unknown; +} + +interface BindingScope { + readonly parent: BindingScope | null; + readonly ownsVarBindings: boolean; + bindings: number; +} + +interface ScopedAstNode { + readonly node: StructuralAstNode; + readonly scope: BindingScope; + readonly order: number; +} + +interface ScopeWalkItem { + readonly node: StructuralAstNode; + readonly scope: BindingScope | null; + readonly parent: StructuralAstNode | null; + readonly parentKey: string | null; +} + +function asAstNode(value: unknown): StructuralAstNode | null { + if (!value || typeof value !== "object" || Array.isArray(value)) return null; + const candidate = value as { readonly type?: unknown }; + return typeof candidate.type === "string" ? (candidate as StructuralAstNode) : null; +} + +function nodeArray(node: StructuralAstNode, key: string): readonly unknown[] | null { + const value = node[key]; + return Array.isArray(value) ? value : null; +} + +function nodeArgument(node: StructuralAstNode | null, index: number): StructuralAstNode | null { + if (!node) return null; + const values = nodeArray(node, "arguments"); + return values ? asAstNode(values[index]) : null; +} + +function isIdentifier(node: StructuralAstNode | null, name: string): boolean { + return node?.type === "Identifier" && node.name === name; +} + +function trackedBinding(name: unknown): number { + switch (name) { + case "require": + return REQUIRE_BINDING; + case "Object": + return OBJECT_BINDING; + case "exports": + return EXPORTS_BINDING; + case "module": + return MODULE_BINDING; + default: + return 0; + } +} + +function addPatternBindings(scope: BindingScope, pattern: unknown): void { + const stack: unknown[] = [pattern]; + while (stack.length > 0) { + const node = asAstNode(stack.pop()); + if (!node) continue; + switch (node.type) { + case "Identifier": + scope.bindings |= trackedBinding(node.name); + break; + case "AssignmentPattern": + stack.push(node.left); + break; + case "RestElement": + stack.push(node.argument); + break; + case "ArrayPattern": { + const elements = nodeArray(node, "elements"); + if (elements) stack.push(...elements); + break; + } + case "ObjectPattern": { + const properties = nodeArray(node, "properties"); + if (!properties) break; + for (const value of properties) { + const property = asAstNode(value); + if (!property) continue; + stack.push(property.type === "RestElement" ? property.argument : property.value); + } + break; + } + case "TSParameterProperty": + stack.push(node.parameter); + break; + } + } +} + +function isFunctionScopeNode(node: StructuralAstNode): boolean { + switch (node.type) { + case "FunctionDeclaration": + case "FunctionExpression": + case "ArrowFunctionExpression": + case "ObjectMethod": + case "ClassMethod": + case "ClassPrivateMethod": + case "TSDeclareFunction": + case "TSDeclareMethod": + case "DeclareFunction": + return true; + default: + return false; + } +} + +function isFunctionDeclarationNode(node: StructuralAstNode): boolean { + return node.type === "FunctionDeclaration" || node.type === "TSDeclareFunction" || node.type === "DeclareFunction"; +} + +function isClassScopeNode(node: StructuralAstNode): boolean { + return node.type === "ClassDeclaration" || node.type === "ClassExpression"; +} + +function scopeKind(node: StructuralAstNode, parent: StructuralAstNode | null, parentKey: string | null): 0 | 1 | 2 { + if ( + node.type === "Program" || + isFunctionScopeNode(node) || + node.type === "StaticBlock" || + node.type === "TSModuleBlock" + ) { + return 2; + } + if ( + isClassScopeNode(node) || + node.type === "CatchClause" || + node.type === "ForStatement" || + node.type === "ForInStatement" || + node.type === "ForOfStatement" || + node.type === "SwitchStatement" + ) { + return 1; + } + if (node.type === "BlockStatement" && !(parent && isFunctionScopeNode(parent) && parentKey === "body")) { + return 1; + } + return 0; +} + +function nearestVarScope(scope: BindingScope): BindingScope { + let current = scope; + while (!current.ownsVarBindings && current.parent) current = current.parent; + return current; +} + +function registerOuterDeclaration(node: StructuralAstNode, scope: BindingScope | null): void { + if (!scope) return; + if ( + (isFunctionDeclarationNode(node) && node.type !== "TSDeclareFunction" && node.type !== "DeclareFunction") || + (node.type === "ClassDeclaration" && node.declare !== true) + ) { + addPatternBindings(scope, node.id); + } +} + +function registerScopeBindings(node: StructuralAstNode, scope: BindingScope): void { + if (isFunctionScopeNode(node)) { + addPatternBindings(scope, node.id); + const parameters = nodeArray(node, "params"); + if (parameters) { + for (const parameter of parameters) addPatternBindings(scope, parameter); + } + } + if (isClassScopeNode(node)) addPatternBindings(scope, node.id); + if (node.type === "CatchClause") addPatternBindings(scope, node.param); + + if (node.type === "ImportDeclaration") { + const specifiers = nodeArray(node, "specifiers"); + if (specifiers) { + for (const value of specifiers) { + const specifier = asAstNode(value); + if (specifier) addPatternBindings(scope, specifier.local); + } + } + } else if (node.type === "TSImportEqualsDeclaration") { + addPatternBindings(scope, node.id); + } else if (node.type === "VariableDeclaration") { + const target = node.kind === "var" ? nearestVarScope(scope) : scope; + const declarations = nodeArray(node, "declarations"); + if (declarations) { + for (const value of declarations) { + const declaration = asAstNode(value); + if (declaration) addPatternBindings(target, declaration.id); + } + } + } +} + +function isAstMetadataKey(key: string): boolean { + switch (key) { + case "loc": + case "extra": + case "range": + case "comments": + case "tokens": + case "errors": + case "leadingComments": + case "trailingComments": + case "innerComments": + case "parent": + case "parentPath": + case "scope": + case "hub": + return true; + default: + return false; + } +} + +function scopeForChild( + node: StructuralAstNode, + key: string, + outerScope: BindingScope | null, + nodeScope: BindingScope, +): BindingScope { + if (node.type === "SwitchStatement" && key === "discriminant") { + return outerScope ?? nodeScope; + } + if (isFunctionScopeNode(node) && (key === "key" || key === "decorators")) { + return outerScope ?? nodeScope; + } + if (isClassScopeNode(node) && key === "decorators") { + return outerScope ?? nodeScope; + } + return nodeScope; +} + +/** + * Builds only the lexical information needed by legacy extension rewriting. + * Scope frames are fully populated before selected nodes are returned, so + * hoisted and TDZ bindings behave independently of textual declaration order. + */ +function collectScopedAstNodes(root: unknown, select: (node: StructuralAstNode) => boolean): ScopedAstNode[] { + const rootNode = asAstNode(root); + if (!rootNode) return []; + + const selected: ScopedAstNode[] = []; + const stack: ScopeWalkItem[] = [{ node: rootNode, scope: null, parent: null, parentKey: null }]; + const seen = new WeakSet(); + let order = 0; + while (stack.length > 0) { + const item = stack.pop(); + if (!item || seen.has(item.node)) continue; + seen.add(item.node); + + registerOuterDeclaration(item.node, item.scope); + const kind = scopeKind(item.node, item.parent, item.parentKey); + const activeScope: BindingScope | null = + kind === 0 + ? item.scope + : { + parent: item.scope, + ownsVarBindings: kind === 2, + bindings: 0, + }; + if (activeScope) { + registerScopeBindings(item.node, activeScope); + if (select(item.node)) selected.push({ node: item.node, scope: activeScope, order }); + } + order++; + + for (const key in item.node) { + if (isAstMetadataKey(key)) continue; + const childScope = activeScope ? scopeForChild(item.node, key, item.scope, activeScope) : null; + const value = item.node[key]; + if (Array.isArray(value)) { + for (const element of value) { + const child = asAstNode(element); + if (child) stack.push({ node: child, scope: childScope, parent: item.node, parentKey: key }); + } + } else { + const child = asAstNode(value); + if (child) stack.push({ node: child, scope: childScope, parent: item.node, parentKey: key }); + } + } + } + + selected.sort((left, right) => { + const leftStart = typeof left.node.start === "number" ? left.node.start : Number.MAX_SAFE_INTEGER; + const rightStart = typeof right.node.start === "number" ? right.node.start : Number.MAX_SAFE_INTEGER; + return leftStart - rightStart || left.order - right.order; + }); + return selected; +} + +function scopeHasBinding(scope: BindingScope, binding: number): boolean { + let current: BindingScope | null = scope; + while (current) { + if ((current.bindings & binding) !== 0) return true; + current = current.parent; + } + return false; +} + +function isSpecifierReferenceNode(node: StructuralAstNode): boolean { + switch (node.type) { + case "ImportDeclaration": + case "ExportNamedDeclaration": + case "ExportAllDeclaration": + case "ImportExpression": + case "TSImportEqualsDeclaration": + case "CallExpression": + return true; + default: + return false; + } +} + +function isUncomputedMember(node: StructuralAstNode | null, objectName: string, propertyName: string): boolean { + if (node?.type !== "MemberExpression" || node.computed === true) return false; + return isIdentifier(asAstNode(node.object), objectName) && isIdentifier(asAstNode(node.property), propertyName); +} + +function isUnshadowedExportsTarget(node: StructuralAstNode | null, scope: BindingScope): boolean { + return ( + (isIdentifier(node, "exports") && !scopeHasBinding(scope, EXPORTS_BINDING)) || + (isUncomputedMember(node, "module", "exports") && !scopeHasBinding(scope, MODULE_BINDING)) + ); +} + +function isGlobalRequireCall(node: StructuralAstNode | null, scope: BindingScope): boolean { + return ( + node?.type === "CallExpression" && + isIdentifier(asAstNode(node.callee), "require") && + !scopeHasBinding(scope, REQUIRE_BINDING) + ); +} + +function staticMemberPropertyName(node: StructuralAstNode): string | null { + const property = asAstNode(node.property); + if (node.computed !== true && property?.type === "Identifier" && typeof property.name === "string") { + return property.name; + } + if (node.computed === true && property?.type === "StringLiteral" && typeof property.value === "string") { + return property.value; + } + return null; +} + +function staticObjectPropertyName(node: StructuralAstNode): string | null { + if (node.computed === true) return null; + const key = asAstNode(node.key); + if (key?.type === "Identifier" && typeof key.name === "string") return key.name; + return key?.type === "StringLiteral" && typeof key.value === "string" ? key.value : null; +} + function collectExtensionSpecifierReferences( source: string, importerPath: string, @@ -108,10 +448,9 @@ function collectExtensionSpecifierReferences( ): 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 }; + const node = asAstNode(literal); if ( - node.type === "StringLiteral" && + node?.type === "StringLiteral" && typeof node.value === "string" && typeof node.start === "number" && typeof node.end === "number" @@ -119,35 +458,29 @@ function collectExtensionSpecifierReferences( 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 === "TSImportEqualsDeclaration" && - node.moduleReference.type === "TSExternalModuleReference" - ) { - record("require", node.moduleReference.expression); - } 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]); - } + for (const { node, scope } of collectScopedAstNodes(ast, isSpecifierReferenceNode)) { + 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 === "TSImportEqualsDeclaration") { + const moduleReference = asAstNode(node.moduleReference); + if (moduleReference?.type === "TSExternalModuleReference") { + record("require", moduleReference.expression); } - }, - }); + } else if (node.type === "CallExpression") { + const callee = asAstNode(node.callee); + if (callee?.type === "Import") { + record("import", nodeArgument(node, 0)); + } else if (isIdentifier(callee, "require") && !scopeHasBinding(scope, REQUIRE_BINDING)) { + record("require", nodeArgument(node, 0)); + } + } + } return references; } @@ -1776,140 +2109,74 @@ function collectCommonJsNamedExports(source: string, modulePath: string, visited 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); + for (const { node, scope } of collectScopedAstNodes( + ast, + candidate => candidate.type === "CallExpression" || candidate.type === "AssignmentExpression", + )) { + if (node.type === "CallExpression") { + const callee = asAstNode(node.callee); + if (isUncomputedMember(callee, "Object", "defineProperty") && !scopeHasBinding(scope, OBJECT_BINDING)) { + const target = nodeArgument(node, 0); + const property = nodeArgument(node, 1); + if ( + isUnshadowedExportsTarget(target, scope) && + property?.type === "StringLiteral" && + typeof property.value === "string" && + property.value !== "default" && + COMMONJS_NAMED_EXPORT_IDENTIFIER.test(property.value) + ) { + names.add(property.value); + } + continue; + } + if (isIdentifier(callee, "__exportStar")) { + const sourceCall = nodeArgument(node, 0); + const target = nodeArgument(node, 1); + if (isUnshadowedExportsTarget(target, scope) && isGlobalRequireCall(sourceCall, scope)) { + const argument = nodeArgument(sourceCall, 0); + if (argument?.type === "StringLiteral" && typeof argument.value === "string") { + reexportSpecifiers.add(argument.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; } + continue; } - 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; + } + if (node.type !== "AssignmentExpression" || node.operator !== "=") continue; + const left = asAstNode(node.left); + if (left?.type !== "MemberExpression") continue; - 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; + const propertyName = staticMemberPropertyName(left); + const object = asAstNode(left.object); + if (propertyName !== null && isUnshadowedExportsTarget(object, scope)) { + if (propertyName !== "default" && COMMONJS_NAMED_EXPORT_IDENTIFIER.test(propertyName)) { + names.add(propertyName); + } + continue; + } + if (!isUncomputedMember(left, "module", "exports") || scopeHasBinding(scope, MODULE_BINDING)) continue; + + const right = asAstNode(node.right); + if (right?.type === "ObjectExpression") { + const properties = nodeArray(right, "properties"); + if (properties) { + for (const value of properties) { + const property = asAstNode(value); + if (!property || (property.type !== "ObjectProperty" && property.type !== "ObjectMethod")) continue; + const name = staticObjectPropertyName(property); 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); - } + continue; + } + if (isGlobalRequireCall(right, scope)) { + const argument = nodeArgument(right, 0); + if (argument?.type === "StringLiteral" && typeof argument.value === "string") { + reexportSpecifiers.add(argument.value); } - }, - }); + } + } const nativeRequire = createRequire(modulePath); for (const specifier of reexportSpecifiers) { try { diff --git a/packages/coding-agent/test/fixtures/legacy-pi-babel-cache-positive-control.ts b/packages/coding-agent/test/fixtures/legacy-pi-babel-cache-positive-control.ts new file mode 100644 index 000000000..a6f51dcb0 --- /dev/null +++ b/packages/coding-agent/test/fixtures/legacy-pi-babel-cache-positive-control.ts @@ -0,0 +1,15 @@ +import * as fs from "node:fs"; +import "@babel/traverse"; + +const modules = Object.keys(require.cache) + .filter(modulePath => { + const normalizedPath = modulePath.replaceAll("\\", "/"); + return ( + normalizedPath.includes("/node_modules/@babel/traverse/") || + normalizedPath.includes("/node_modules/@babel/types/") + ); + }) + .sort(); +const bytes = modules.reduce((total, modulePath) => total + fs.statSync(modulePath).size, 0); + +process.stdout.write(JSON.stringify({ modules: modules.length, bytes, paths: modules })); diff --git a/packages/coding-agent/test/fixtures/legacy-pi-babel-cache-probe.ts b/packages/coding-agent/test/fixtures/legacy-pi-babel-cache-probe.ts new file mode 100644 index 000000000..07d3eca5d --- /dev/null +++ b/packages/coding-agent/test/fixtures/legacy-pi-babel-cache-probe.ts @@ -0,0 +1,15 @@ +import * as fs from "node:fs"; +import "../../src/extensibility/plugins/legacy-pi-compat"; + +const modules = Object.keys(require.cache) + .filter(modulePath => { + const normalizedPath = modulePath.replaceAll("\\", "/"); + return ( + normalizedPath.includes("/node_modules/@babel/traverse/") || + normalizedPath.includes("/node_modules/@babel/types/") + ); + }) + .sort(); +const bytes = modules.reduce((total, modulePath) => total + fs.statSync(modulePath).size, 0); + +process.stdout.write(JSON.stringify({ modules: modules.length, bytes, paths: modules })); diff --git a/packages/coding-agent/test/legacy-pi-ast-behavior.test.ts b/packages/coding-agent/test/legacy-pi-ast-behavior.test.ts new file mode 100644 index 000000000..d07a284ae --- /dev/null +++ b/packages/coding-agent/test/legacy-pi-ast-behavior.test.ts @@ -0,0 +1,384 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import * as fs from "node:fs/promises"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as url from "node:url"; +import { + __rewriteLegacyExtensionSourceForTests, + loadLegacyPiModule, +} from "@oh-my-pi/pi-coding-agent/extensibility/plugins/legacy-pi-compat"; +import { removeWithRetries } from "@oh-my-pi/pi-utils"; + +interface RewriteCase { + name: string; + source: string; + expected(importTarget: string, requireTarget: string): string; +} + +interface CommonJsCase { + name: string; + source: string; + files?: Record; + expected: Record; +} + +let rewriteRoot: string; +let rewriteImporter: string; +let importTarget: string; +let requireTarget: string; +const tempRoots: string[] = []; + +beforeAll(async () => { + rewriteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "omp-legacy-ast-rewrite-")); + tempRoots.push(rewriteRoot); + const dependencyPath = path.join(rewriteRoot, "node_modules", "tracked-dep", "index.js"); + await fs.mkdir(path.dirname(dependencyPath), { recursive: true }); + await fs.writeFile( + path.join(rewriteRoot, "node_modules", "tracked-dep", "package.json"), + JSON.stringify({ name: "tracked-dep", version: "1.0.0", main: "index.js" }), + "utf8", + ); + await fs.writeFile(dependencyPath, 'module.exports = { marker: "tracked" };\n', "utf8"); + rewriteImporter = path.join(rewriteRoot, "extension.ts"); + importTarget = url.pathToFileURL(dependencyPath).href; + requireTarget = dependencyPath.replaceAll("\\", "/"); +}); + +afterAll(async () => { + for (const dir of tempRoots) await removeWithRetries(dir); +}); + +const rewriteCases: RewriteCase[] = [ + { + name: "import, re-export, dynamic import, and TS import-equals sources", + source: [ + 'import value from "tracked-dep";', + 'export { default as named } from "tracked-dep";', + 'export * from "tracked-dep";', + 'const lazy = import("tracked-dep");', + 'import tracked = require("tracked-dep");', + ].join("\n"), + expected: (importPath, requirePath) => + [ + `import value from ${JSON.stringify(importPath)};`, + `export { default as named } from ${JSON.stringify(importPath)};`, + `export * from ${JSON.stringify(importPath)};`, + `const lazy = import(${JSON.stringify(importPath)});`, + `import tracked = require(${JSON.stringify(requirePath)});`, + ].join("\n"), + }, + { + name: "comments, strings, templates, regexes, and member calls are excluded", + source: [ + "const text = 'require(\"tracked-dep\")';", + 'const template = `import("tracked-dep")`;', + 'const pattern = /require\\("tracked-dep"\\)/;', + '// require("tracked-dep");', + '/* import("tracked-dep"); */', + 'loader.require("tracked-dep");', + 'require.resolve("tracked-dep");', + '(0, require)("tracked-dep");', + ].join("\n"), + expected: () => + [ + "const text = 'require(\"tracked-dep\")';", + 'const template = `import("tracked-dep")`;', + 'const pattern = /require\\("tracked-dep"\\)/;', + '// require("tracked-dep");', + '/* import("tracked-dep"); */', + 'loader.require("tracked-dep");', + 'require.resolve("tracked-dep");', + '(0, require)("tracked-dep");', + ].join("\n"), + }, + { + name: "global require is rewritten without disturbing adjacent excluded calls", + source: ['const loaded = require("tracked-dep");', 'loader.require("tracked-dep");'].join("\n"), + expected: (_importPath, requirePath) => + [`const loaded = require(${JSON.stringify(requirePath)});`, 'loader.require("tracked-dep");'].join("\n"), + }, + { + name: "import declarations shadow require across the program", + source: ['import require from "node:module";', 'require("tracked-dep");'].join("\n"), + expected: () => ['import require from "node:module";', 'require("tracked-dep");'].join("\n"), + }, + { + name: "TS import-equals declarations shadow require only in their namespace", + source: [ + 'namespace Nested { import require = require("node:module"); require("tracked-dep"); }', + 'require("tracked-dep");', + ].join("\n"), + expected: (_importPath, requirePath) => + [ + 'namespace Nested { import require = require("node:module"); require("tracked-dep"); }', + `require(${JSON.stringify(requirePath)});`, + ].join("\n"), + }, + { + name: "erased ambient TS function, class, and namespace declarations do not shadow require", + source: [ + 'declare function require(id: string): unknown; require("tracked-dep");', + 'declare class require {} require("tracked-dep");', + 'declare namespace require {} require("tracked-dep");', + ].join("\n"), + expected: (_importPath, requirePath) => + [ + `declare function require(id: string): unknown; require(${JSON.stringify(requirePath)});`, + `declare class require {} require(${JSON.stringify(requirePath)});`, + `declare namespace require {} require(${JSON.stringify(requirePath)});`, + ].join("\n"), + }, + { + name: "ambient TS var declarations retain Babel value bindings", + source: 'declare var require: unknown; require("tracked-dep");', + expected: () => 'declare var require: unknown; require("tracked-dep");', + }, + { + name: "ambient TS const declarations retain Babel value bindings", + source: 'declare const require: unknown; require("tracked-dep");', + expected: () => 'declare const require: unknown; require("tracked-dep");', + }, + { + name: "non-ambient TS enum and namespace names do not create Babel value bindings", + source: [ + 'function enumOuter() { { enum require { A } require("tracked-dep"); } require("tracked-dep"); }', + 'function namespaceOuter() { namespace require {} require("tracked-dep"); }', + 'namespace Outer { namespace require {} require("tracked-dep"); }', + 'require("tracked-dep");', + ].join("\n"), + expected: (_importPath, requirePath) => + [ + `function enumOuter() { { enum require { A } require(${JSON.stringify(requirePath)}); } require(${JSON.stringify(requirePath)}); }`, + `function namespaceOuter() { namespace require {} require(${JSON.stringify(requirePath)}); }`, + `namespace Outer { namespace require {} require(${JSON.stringify(requirePath)}); }`, + `require(${JSON.stringify(requirePath)});`, + ].join("\n"), + }, + { + name: "function declaration and expression names shadow require before textual declaration", + source: [ + 'function declarationScope() { require("tracked-dep"); function require() {} }', + 'const named = function require() { require("tracked-dep"); };', + 'require("tracked-dep");', + ].join("\n"), + expected: (_importPath, requirePath) => + [ + 'function declarationScope() { require("tracked-dep"); function require() {} }', + 'const named = function require() { require("tracked-dep"); };', + `require(${JSON.stringify(requirePath)});`, + ].join("\n"), + }, + { + name: "default, rest, and destructured parameters shadow require", + source: [ + 'function defaulted(require = () => {}) { require("tracked-dep"); }', + 'function rested(...require) { require("tracked-dep"); }', + 'function destructured({ loader: require = () => {} }, [other, ...tail]) { require("tracked-dep"); }', + ].join("\n"), + expected: () => + [ + 'function defaulted(require = () => {}) { require("tracked-dep"); }', + 'function rested(...require) { require("tracked-dep"); }', + 'function destructured({ loader: require = () => {} }, [other, ...tail]) { require("tracked-dep"); }', + ].join("\n"), + }, + { + name: "var, let, const, class, and block function declarations shadow irrespective of order", + source: [ + 'function varScope() { require("tracked-dep"); var require; }', + 'function letScope() { { require("tracked-dep"); let require; } }', + 'function constScope() { { require("tracked-dep"); const require = () => {}; } }', + 'function classScope() { { require("tracked-dep"); class require {} } }', + 'function declarationScope() { { require("tracked-dep"); function require() {} } }', + 'require("tracked-dep");', + ].join("\n"), + expected: (_importPath, requirePath) => + [ + 'function varScope() { require("tracked-dep"); var require; }', + 'function letScope() { { require("tracked-dep"); let require; } }', + 'function constScope() { { require("tracked-dep"); const require = () => {}; } }', + 'function classScope() { { require("tracked-dep"); class require {} } }', + 'function declarationScope() { { require("tracked-dep"); function require() {} } }', + `require(${JSON.stringify(requirePath)});`, + ].join("\n"), + }, + { + name: "for, switch, class, and static-block scopes do not leak", + source: [ + 'for (let require = () => false; false; ) { require("tracked-dep"); }', + 'switch (0) { case 0: require("tracked-dep"); break; case 1: const require = () => {}; }', + 'switch (require("tracked-dep")) { case 0: let require; require("tracked-dep"); }', + 'const Holder = class require { method() { require("tracked-dep"); } };', + 'const Heritage = class require extends require("tracked-dep") {};', + 'class StaticHolder { static { require("tracked-dep"); let require; } }', + 'require("tracked-dep");', + ].join("\n"), + expected: (_importPath, requirePath) => + [ + 'for (let require = () => false; false; ) { require("tracked-dep"); }', + 'switch (0) { case 0: require("tracked-dep"); break; case 1: const require = () => {}; }', + `switch (require(${JSON.stringify(requirePath)})) { case 0: let require; require("tracked-dep"); }`, + 'const Holder = class require { method() { require("tracked-dep"); } };', + 'const Heritage = class require extends require("tracked-dep") {};', + 'class StaticHolder { static { require("tracked-dep"); let require; } }', + `require(${JSON.stringify(requirePath)});`, + ].join("\n"), + }, + { + name: "catch, nested function, and nested block bindings stay lexical", + source: [ + 'try {} catch (require) { require("tracked-dep"); }', + 'function outer(require) { function inner() { require("tracked-dep"); } }', + '{ const require = () => {}; { require("tracked-dep"); } }', + 'require("tracked-dep");', + ].join("\n"), + expected: (_importPath, requirePath) => + [ + 'try {} catch (require) { require("tracked-dep"); }', + 'function outer(require) { function inner() { require("tracked-dep"); } }', + '{ const require = () => {}; { require("tracked-dep"); } }', + `require(${JSON.stringify(requirePath)});`, + ].join("\n"), + }, +]; + +async function loadCommonJsCase(testCase: CommonJsCase): Promise<{ keys: string[]; named: Record }> { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "omp-legacy-ast-exports-")); + tempRoots.push(dir); + const files: Record = { + "package.json": JSON.stringify({ name: `cjs-${testCase.name}`, version: "1.0.0", type: "module" }), + "subject.cjs": testCase.source, + "index.ts": [ + 'import * as subject from "./subject.cjs";', + "export const keys = Object.keys(subject).sort();", + 'export const named = Object.fromEntries(keys.filter(key => key !== "default").map(key => {', + " const value = subject[key];", + ' return [key, typeof value === "function" ? value() : value];', + "}));", + ].join("\n"), + ...testCase.files, + }; + for (const [relativePath, contents] of Object.entries(files)) { + const absolutePath = path.join(dir, relativePath); + await fs.mkdir(path.dirname(absolutePath), { recursive: true }); + await fs.writeFile(absolutePath, contents, "utf8"); + } + return (await loadLegacyPiModule(path.join(dir, "index.ts"))) as { + keys: string[]; + named: Record; + }; +} + +const commonJsCases: CommonJsCase[] = [ + { + name: "direct-assignments", + source: [ + 'exports.alpha = "alpha";', + 'module.exports.bravo = "bravo";', + 'module.exports["charlie"] = "charlie";', + 'exports["invalid-name"] = "excluded";', + ].join("\n"), + expected: { alpha: "alpha", bravo: "bravo", charlie: "charlie" }, + }, + { + name: "define-property", + source: [ + 'Object.defineProperty(exports, "delta", { enumerable: true, value: "delta" });', + 'Object.defineProperty(module.exports, "echo", { enumerable: true, value: "echo" });', + 'Object.defineProperty(exports, "default", { enumerable: true, value: "excluded" });', + ].join("\n"), + expected: { delta: "delta", echo: "echo" }, + }, + { + name: "module-exports-object", + source: + 'module.exports = { foxtrot: "foxtrot", golf() { return "golf"; }, "hotel": "hotel", ["computed"]: "excluded", default: "excluded", "invalid-name": "excluded" };', + expected: { foxtrot: "foxtrot", golf: "golf", hotel: "hotel" }, + }, + { + name: "module-exports-require", + source: 'module.exports = require("./leaf.cjs");', + files: { "leaf.cjs": 'exports.india = "india";\nmodule.exports.juliet = "juliet";\n' }, + expected: { india: "india", juliet: "juliet" }, + }, + { + name: "export-star", + source: [ + "const __exportStar = (source, target) => {", + ' for (const key of Object.keys(source)) if (key !== "default") target[key] = source[key];', + "};", + '__exportStar(require("./leaf.cjs"), exports);', + ].join("\n"), + files: { "leaf.cjs": 'exports.kilo = "kilo";\n' }, + expected: { kilo: "kilo" }, + }, + { + name: "parameter-shadows", + source: [ + 'exports.good = "good";', + 'function objectShadow(Object) { Object.defineProperty(exports, "badObject", {}); }', + 'function exportsShadow(exports) { exports.badExports = true; Object.defineProperty(exports, "badDefined", {}); }', + "function moduleShadow(module) { module.exports.badModule = true; module.exports = { badObject: true }; }", + 'function requireShadow(require) { module.exports = require("./leaf.cjs"); __exportStar(require("./leaf.cjs"), exports); }', + ].join("\n"), + files: { "leaf.cjs": 'exports.badLeaf = "excluded";\n' }, + expected: { good: "good" }, + }, + { + name: "hoisted-program-exports-shadow", + source: ['exports.badExports = "excluded";', "var exports;"].join("\n"), + expected: {}, + }, + { + name: "hoisted-program-module-shadow", + source: ['module.exports.badModule = "excluded";', "var module;", 'exports.good = "good";'].join("\n"), + expected: { good: "good" }, + }, + { + name: "hoisted-program-require-shadow", + source: ['module.exports = require("./leaf.cjs");', "var require;"].join("\n"), + files: { "leaf.cjs": 'exports.badLeaf = "excluded";\n' }, + expected: {}, + }, + { + name: "program-Object-shadow", + source: [ + "const Object = globalThis.Object;", + 'Object.defineProperty(exports, "badObject", { enumerable: true, value: "excluded" });', + 'exports.good = "good";', + ].join("\n"), + expected: { good: "good" }, + }, + { + name: "block-catch-for-switch-class-static-shadows", + source: [ + 'exports.good = "good";', + "function decoys() {", + " { const exports = {}; exports.badBlock = true; }", + " try {} catch (module) { module.exports.badCatch = true; }", + " for (const module of []) { module.exports.badFor = true; }", + ' switch (0) { case 0: { const Object = globalThis.Object; Object.defineProperty(exports, "badSwitch", {}); } }', + ' const Named = class Object { method() { Object.defineProperty(exports, "badClass", {}); } };', + ' class Static { static { const Object = globalThis.Object; Object.defineProperty(exports, "badStatic", {}); } }', + "}", + ].join("\n"), + expected: { good: "good" }, + }, +]; + +describe("legacy Pi Babel AST behavior baseline", () => { + test("rewrites exact source bytes with Babel binding semantics", async () => { + for (const testCase of rewriteCases) { + const actual = await __rewriteLegacyExtensionSourceForTests(testCase.source, rewriteImporter); + expect(actual, testCase.name).toBe(testCase.expected(importTarget, requireTarget)); + } + }); + + test("discovers exact CommonJS named exports with Babel binding semantics", async () => { + for (const testCase of commonJsCases) { + const actual = await loadCommonJsCase(testCase); + expect(actual.keys, testCase.name).toEqual(["default", ...Object.keys(testCase.expected)].sort()); + expect(actual.named, testCase.name).toEqual(testCase.expected); + } + }, 30_000); +}); diff --git a/packages/coding-agent/test/legacy-pi-traverse-runtime.test.ts b/packages/coding-agent/test/legacy-pi-traverse-runtime.test.ts new file mode 100644 index 000000000..b35d196cf --- /dev/null +++ b/packages/coding-agent/test/legacy-pi-traverse-runtime.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, test } from "bun:test"; +import * as path from "node:path"; + +interface CacheProbeResult { + modules: number; + bytes: number; + paths: string[]; +} + +const probePath = path.resolve(import.meta.dir, "fixtures", "legacy-pi-babel-cache-probe.ts"); +const positiveControlPath = path.resolve(import.meta.dir, "fixtures", "legacy-pi-babel-cache-positive-control.ts"); + +async function runProbe(childPath: string): Promise { + const proc = Bun.spawn([process.execPath, childPath], { + cwd: path.resolve(import.meta.dir, "../.."), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + expect(exitCode, stderr).toBe(0); + return JSON.parse(stdout) as CacheProbeResult; +} + +describe("legacy Pi Babel startup closure", () => { + test("detector observes a substantial traverse/types positive-control closure", async () => { + const result = await runProbe(positiveControlPath); + expect(result.modules, JSON.stringify(result)).toBeGreaterThanOrEqual(100); + expect(result.bytes, JSON.stringify(result)).toBeGreaterThanOrEqual(600_000); + }, 30_000); + + test("static legacy compatibility import evaluates no traverse/types CommonJS modules", async () => { + const result = await runProbe(probePath); + expect({ modules: result.modules, bytes: result.bytes }, JSON.stringify(result)).toEqual({ + modules: 0, + bytes: 0, + }); + }, 30_000); +});