From 9c1c939219dda4f0372b0c2dd6cb881d8f95819c Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 29 Apr 2026 17:22:48 -0700 Subject: [PATCH 1/3] fix(compartment-mapper): policy, parser type fixes - Fixes `CompartmentDescriptor` so that it is generic on the `PackagePolicy`; externally-defined `ParseFn`s can now refer to the specific contents of a custom `PackagePolicy` present in a `CompartmentDescriptor`. - Introduces `ParseSourceMapHook`; differentiated from `@endo/module-source`'s `SourceMapHook`. - Fixes type of `PolicyItem`; eliminates confusion between `void` (no extra union members) and `any` (`SomePackagePolicy`). --- .changeset/huge-mammals-give.md | 9 +++ .../src/types/compartment-map-schema.ts | 40 ++++++++-- .../compartment-mapper/src/types/external.ts | 79 ++++++++++++++----- .../src/types/policy-schema.ts | 78 ++++++++++++------ .../src/types/typescript.ts | 8 ++ 5 files changed, 160 insertions(+), 54 deletions(-) create mode 100644 .changeset/huge-mammals-give.md diff --git a/.changeset/huge-mammals-give.md b/.changeset/huge-mammals-give.md new file mode 100644 index 0000000000..e1a35cfd83 --- /dev/null +++ b/.changeset/huge-mammals-give.md @@ -0,0 +1,9 @@ +--- +'@endo/compartment-mapper': patch +--- + +Fixes `CompartmentDescriptor` so that it is generic on the `PackagePolicy`; externally-defined `ParseFn`s can now refer to the specific contents of a custom `PackagePolicy` present in a `CompartmentDescriptor`. + +Introduces `ParseSourceMapHook`; differentiated from `@endo/module-source`'s `SourceMapHook`. + +Fixes type of `PolicyItem`; eliminates confusion between `void` (no extra union members) and `any` (`SomePackagePolicy`). diff --git a/packages/compartment-mapper/src/types/compartment-map-schema.ts b/packages/compartment-mapper/src/types/compartment-map-schema.ts index 0cc4b32392..1a14508bdf 100644 --- a/packages/compartment-mapper/src/types/compartment-map-schema.ts +++ b/packages/compartment-mapper/src/types/compartment-map-schema.ts @@ -14,7 +14,7 @@ import type { } from '../policy-format.js'; import type { CanonicalName } from './canonical-name.js'; import type { FileUrlString } from './external.js'; -import type { SomePackagePolicy } from './policy-schema.js'; +import type { PackagePolicy, SomePackagePolicy } from './policy-schema.js'; import type { PatternDescriptor } from './pattern-replacement.js'; import type { LiteralUnion } from './typescript.js'; @@ -122,23 +122,24 @@ export interface PackageCompartmentDescriptor * one for a given library or application `package.json`. */ export interface CompartmentDescriptor< - T extends ModuleConfiguration = ModuleConfiguration, - U extends string = string, + TModuleConfiguration extends ModuleConfiguration = ModuleConfiguration, + TCompartmentName extends string = string, + TPackagePolicy extends SomePackagePolicy = SomePackagePolicy, > { - label: CanonicalName; + label: CanonicalName; /** * the name of the originating package suitable for constructing a sourceURL * prefix that will match it to files in a developer workspace. */ name: string; - modules: Record; + modules: Record; scopes?: Record; /** language for extension */ parsers?: LanguageForExtension; /** language for module specifier */ types?: LanguageForModuleSpecifier; /** policy specific to compartment */ - policy?: SomePackagePolicy; + policy?: TPackagePolicy; location: string; /** @@ -154,9 +155,32 @@ export interface CompartmentDescriptor< retained?: true; } +/** + * Any {@link CompartmentDescriptor} + */ +export type SomeCompartmentDescriptor = CompartmentDescriptor; + +/** + * Any {@link CompartmentDescriptor} with a non-nullish + * {@link CompartmentDescriptor.policy} property + */ +export type SomeCompartmentDescriptorWithPolicy = + CompartmentDescriptorWithPolicy; + +/** + * A {@link CompartmentDescriptor} with a non-nullish + * {@link CompartmentDescriptor.policy} property + */ export type CompartmentDescriptorWithPolicy< - T extends ModuleConfiguration = ModuleConfiguration, -> = Omit, 'policy'> & { policy: SomePackagePolicy }; + TModuleConfiguration extends ModuleConfiguration = ModuleConfiguration, + TCompartmentName extends string = string, + TPackagePolicy extends SomePackagePolicy = SomePackagePolicy, +> = Omit< + CompartmentDescriptor, + 'policy' +> & { + policy: TPackagePolicy; +}; /** * A compartment descriptor digested by `digestCompartmentMap()` diff --git a/packages/compartment-mapper/src/types/external.ts b/packages/compartment-mapper/src/types/external.ts index 763de6003f..5c1035d768 100644 --- a/packages/compartment-mapper/src/types/external.ts +++ b/packages/compartment-mapper/src/types/external.ts @@ -18,6 +18,7 @@ import type { } from '../policy-format.js'; import type { CanonicalName } from './canonical-name.js'; import type { + SomeCompartmentDescriptor, CompartmentDescriptor, CompartmentMapDescriptor, DigestedCompartmentMapDescriptor, @@ -650,6 +651,15 @@ export type SourceMapHookDetails = { sha512: string; }; +/** + * Source map hook as received by {@link ParseFn}. + * + * The import hook wraps the public {@link SourceMapHook} into this shape; it + * receives the raw source map object from the code generator, not a JSON + * string. + */ +export type ParseSourceMapHook = (sourceMapObject: object) => void; + export type ModuleTransforms = Record; export type SyncModuleTransforms = Record; @@ -725,29 +735,51 @@ interface BaseParserImplementation { heuristicImports: boolean; } -export interface ParserImplementation extends BaseParserImplementation { - parse: ParseFn; +export interface ParserImplementation< + TCompartmentDescriptor extends + SomeCompartmentDescriptor = SomeCompartmentDescriptor, +> extends BaseParserImplementation { + parse: ParseFn; synchronous: true; } -export interface AsyncParserImplementation extends BaseParserImplementation { - parse: AsyncParseFn; +export interface AsyncParserImplementation< + TCompartmentDescriptor extends + SomeCompartmentDescriptor = SomeCompartmentDescriptor, +> extends BaseParserImplementation { + parse: AsyncParseFn; synchronous: false; } -type ParseArguments = [ +/** + * Options bag for a {@link ParseFn} or {@link AsyncParseFn}. + * + * @template TCompartmentDescriptor The compartment descriptor to use for the parse + */ +export type ParseOptions< + TCompartmentDescriptor extends + SomeCompartmentDescriptor = SomeCompartmentDescriptor, +> = Partial<{ + sourceMap: string | undefined; + sourceMapHook: ParseSourceMapHook | undefined; + sourceMapUrl: string | undefined; + readPowers: ReadFn | ReadPowers | undefined; + compartmentDescriptor: TCompartmentDescriptor | undefined; +}> & + ArchiveOnlyOption; + +/** + * Arguments for a {@link ParseFn} or {@link AsyncParseFn}. + */ +export type ParseArguments< + TCompartmentDescriptor extends + SomeCompartmentDescriptor = SomeCompartmentDescriptor, +> = [ bytes: Uint8Array, specifier: string, moduleLocation: string, packageLocation: string, - options?: Partial<{ - sourceMap: string | undefined; - sourceMapHook: SourceMapHook | undefined; - sourceMapUrl: string | undefined; - readPowers: ReadFn | ReadPowers | undefined; - compartmentDescriptor: CompartmentDescriptor | undefined; - }> & - ArchiveOnlyOption, + options?: ParseOptions, ]; /** @@ -770,17 +802,17 @@ export type ParseResult = { * Because {@link ParseResult} contains {@link FinalStaticModuleType} from * `ses`, those types would want to be moved out of `ses` with it. */ -export type ParseFn = { isSyncParser?: true } & (( - ...args: ParseArguments -) => ParseResult); +export interface ParseFn< + TCompartmentDescriptor extends + SomeCompartmentDescriptor = SomeCompartmentDescriptor, +> { + isSyncParser?: true; + (...args: ParseArguments): ParseResult; +} /** * An asynchronous module parsing function. */ -export type AsyncParseFn = { isSyncParser?: false } & (( - ...args: ParseArguments -) => Promise); - /** * Mapping of `Language` to synchronous {@link ParserImplementation}s only. * @@ -790,6 +822,13 @@ export type SyncParserForLanguage = Record< Language | string, ParserImplementation >; +export interface AsyncParseFn< + TCompartmentDescriptor extends + SomeCompartmentDescriptor = SomeCompartmentDescriptor, +> { + isSyncParser?: false; + (...args: ParseArguments): Promise; +} /** * Mapping of `Language` to {@link ParserImplementation diff --git a/packages/compartment-mapper/src/types/policy-schema.ts b/packages/compartment-mapper/src/types/policy-schema.ts index 6189cfe467..636f2d3bac 100644 --- a/packages/compartment-mapper/src/types/policy-schema.ts +++ b/packages/compartment-mapper/src/types/policy-schema.ts @@ -6,6 +6,7 @@ */ import type { WILDCARD_POLICY_VALUE } from '../policy-format.js'; +import type { IsAny } from './typescript.js'; /* eslint-disable no-use-before-define */ @@ -31,9 +32,16 @@ export type ImplicitAttenuationDefinition = [any, ...any[]]; export type AttenuationDefinition = | FullAttenuationDefinition | ImplicitAttenuationDefinition; + +/** + * Information about the attenuator implementation + */ export type UnifiedAttenuationDefinition = { + /** Name of the attenuator (for error messages) */ displayName: string; + /** The module specifier of the implementation */ specifier: string | null; + /** Parameters to pass to the attenuator at invocation */ params?: any[] | undefined; }; @@ -52,10 +60,24 @@ export type PropertyPolicy = Record; * A type representing a policy item, which can be a {@link WildcardPolicy * wildcard policy}, a property policy, `undefined`, or defined by an * attenuator + * + * @remarks + * The void-vs-custom `T` branch was originally `[T] extends [void] ? … : …`, but + * the type `any` also makes that test succeed, so `PolicyItem` used to + * reduce to the same as `void` and + * `PackagePolicy = AnyPackagePolicy` was not a supertype of + * policies with extra string literals (for example, LavaMoat's {@code "root"} on + * package imports). A separate branch for a wide + * `any` type parameter yields + * `PolicyItem = WildcardPolicy | PropertyPolicy | any` so + * `AnyPackagePolicy` correctly accepts all package policy item shapes. */ -export type PolicyItem = [T] extends [void] - ? WildcardPolicy | PropertyPolicy - : WildcardPolicy | PropertyPolicy | T; +export type PolicyItem = + IsAny extends true + ? WildcardPolicy | PropertyPolicy | T + : [T] extends [void] + ? WildcardPolicy | PropertyPolicy + : WildcardPolicy | PropertyPolicy | T; /** * An object representing a nested attenuation definition. @@ -69,10 +91,10 @@ export type NestedAttenuationDefinition = Record< * An object representing a base package policy. */ export type PackagePolicy< - PackagePolicyItem = void, - GlobalsPolicyItem = void, - BuiltinsPolicyItem = void, - ExtraOptions = unknown, + PackagePolicyExtra = void, + GlobalsPolicyExtra = void, + BuiltinsPolicyExtra = void, + Options = unknown, > = { /** * The default attenuator, if any. @@ -81,17 +103,17 @@ export type PackagePolicy< /** * The policy item for packages. */ - packages?: PolicyItem | undefined; + packages?: PolicyItem | undefined; /** * The policy item or full attenuation definition for globals. */ - globals?: AttenuationDefinition | PolicyItem | undefined; + globals?: AttenuationDefinition | PolicyItem | undefined; /** * The policy item or nested attenuation definition for builtins. */ builtins?: | NestedAttenuationDefinition - | PolicyItem + | PolicyItem | undefined; /** * Whether to disable global freeze. @@ -104,26 +126,26 @@ export type PackagePolicy< /** * Any additional user-defined options can be added to the policy here */ - options?: ExtraOptions | undefined; + options?: Options | undefined; }; /** * An object representing a base policy. */ export type Policy< - PackagePolicyItem = void, - GlobalsPolicyItem = void, - BuiltinsPolicyItem = void, - ExtraOptions = unknown, + PackagePolicyExtra = void, + GlobalsPolicyExtra = void, + BuiltinsPolicyExtra = void, + Options = unknown, > = { /** The package policies for the resources. */ resources: Record< string, PackagePolicy< - PackagePolicyItem, - GlobalsPolicyItem, - BuiltinsPolicyItem, - ExtraOptions + PackagePolicyExtra, + GlobalsPolicyExtra, + BuiltinsPolicyExtra, + Options > >; /** The default attenuator. */ @@ -131,16 +153,20 @@ export type Policy< /** The package policy for the entry. */ entry?: | PackagePolicy< - PackagePolicyItem, - GlobalsPolicyItem, - BuiltinsPolicyItem, - ExtraOptions + PackagePolicyExtra, + GlobalsPolicyExtra, + BuiltinsPolicyExtra, + Options > | undefined; }; -/** Any {@link Policy} */ +/** + * Any {@link Policy} + */ export type SomePolicy = Policy; -/** Any {@link PackagePolicy} */ -export type SomePackagePolicy = PackagePolicy; +/** + * Any {@link PackagePolicy} + */ +export type SomePackagePolicy = PackagePolicy; diff --git a/packages/compartment-mapper/src/types/typescript.ts b/packages/compartment-mapper/src/types/typescript.ts index 871a6b4c31..3379e72e9d 100644 --- a/packages/compartment-mapper/src/types/typescript.ts +++ b/packages/compartment-mapper/src/types/typescript.ts @@ -77,3 +77,11 @@ export type UnionToIntersection = ( * Makes a nicer tooltip for `T` in IDEs (most of the time). */ export type Simplify = { [K in keyof T]: T[K] } & {}; + +/** + * `true` when the type parameter is a wide `any` + * + * `0 extends 1 & T` is true for `T = any` and false for `void` and specific literals + * @see {@link https://github.com/microsoft/TypeScript/issues/30029} + */ +export type IsAny = 0 extends 1 & T ? true : false; From 223aca49835117df0b3887794cd6f6141ace6f2b Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 29 Apr 2026 17:28:50 -0700 Subject: [PATCH 2/3] feat(module-source): expose AST-based CJS parser Exposes AST-based parser for CJS, as well as an `analyzeCjs` function from the `analyzer.js` subpath export. `CjsModuleSource` is also exported from the main entry point. --- .changeset/open-mammals-scream.md | 5 + packages/module-source/README.md | 10 + packages/module-source/analyzer.js | 1 + packages/module-source/src/cjs-analyzer.js | 51 + .../module-source/src/cjs-babel-plugin.js | 781 ++++++++++++++ packages/module-source/src/cjs-functor.js | 80 ++ .../module-source/src/cjs-module-source.js | 50 + .../src/cjs-transform-analyze.js | 92 ++ packages/module-source/src/source-options.js | 43 + .../module-source/src/types/cjs-analyzer.ts | 7 + .../src/types/cjs-module-source.ts | 53 + packages/module-source/src/types/external.ts | 2 + .../module-source/test/cjs-analyzer.test.js | 76 ++ .../module-source/test/cjs-compat.test.js | 952 ++++++++++++++++++ .../test/cjs-module-source.test.js | 246 +++++ 15 files changed, 2449 insertions(+) create mode 100644 .changeset/open-mammals-scream.md create mode 100644 packages/module-source/src/cjs-analyzer.js create mode 100644 packages/module-source/src/cjs-babel-plugin.js create mode 100644 packages/module-source/src/cjs-functor.js create mode 100644 packages/module-source/src/cjs-module-source.js create mode 100644 packages/module-source/src/cjs-transform-analyze.js create mode 100644 packages/module-source/src/types/cjs-analyzer.ts create mode 100644 packages/module-source/src/types/cjs-module-source.ts create mode 100644 packages/module-source/test/cjs-analyzer.test.js create mode 100644 packages/module-source/test/cjs-compat.test.js create mode 100644 packages/module-source/test/cjs-module-source.test.js diff --git a/.changeset/open-mammals-scream.md b/.changeset/open-mammals-scream.md new file mode 100644 index 0000000000..89b8dd321e --- /dev/null +++ b/.changeset/open-mammals-scream.md @@ -0,0 +1,5 @@ +--- +'@endo/module-source': minor +--- + +Exposes AST-based parser for CJS, as well as an `analyzeCjs` function from the `analyzer.js` subpath export. diff --git a/packages/module-source/README.md b/packages/module-source/README.md index 37e2c276ba..b4cbf9f368 100644 --- a/packages/module-source/README.md +++ b/packages/module-source/README.md @@ -83,6 +83,16 @@ That is, the XS native `bindings` will be translated to `imports`, `exports`, and `reexports` getters. This form of `ModuleSource` ignores all options. +## CommonJS-Specific Variant + +In the default/Node variant of `@endo/module-source`, a `CjsModuleSource` +constructor provides a similar interface for CommonJS modules. It accepts the +same options as `ModuleSource` and produces a `CjsModuleSourceRecord` object +containing the analysis data plus a pre-built functor source string. + +Note: the `xs` condition currently provides only `ModuleSource`; +`CjsModuleSource` is not available under `xs`. + ## Bug Disclosure Please help us practice coordinated security bug disclosure, by using the diff --git a/packages/module-source/analyzer.js b/packages/module-source/analyzer.js index a3ba2b26c2..044ed15657 100644 --- a/packages/module-source/analyzer.js +++ b/packages/module-source/analyzer.js @@ -2,3 +2,4 @@ export * from './src/external.types.js'; export { analyzeModule } from './src/analyzer.js'; +export { analyzeCjs } from './src/cjs-analyzer.js'; diff --git a/packages/module-source/src/cjs-analyzer.js b/packages/module-source/src/cjs-analyzer.js new file mode 100644 index 0000000000..1029bb5ee1 --- /dev/null +++ b/packages/module-source/src/cjs-analyzer.js @@ -0,0 +1,51 @@ +/** + * Low-level module analysis primitives for CJS. + * + * {@link analyzeCjs} returns a per-parse context object + * holding plain `{ visitor }` objects and a `buildRecord` function. + * + * Consumers are responsible for: + * 1. Traversing the AST with `ctx.analyzePass.visitor`. + * 2. Traversing the (mutated) AST with `ctx.transformPass.visitor`. + * 3. Generating code. + * 4. Calling `ctx.buildRecord(code, location)` to produce the module record. + * + * @module + */ + +import makeCjsModulePlugins from './cjs-babel-plugin.js'; +import { buildCjsFunctorSource, buildCjsModuleRecord } from './cjs-functor.js'; +import { createCjsSourceOptions } from './source-options.js'; + +/** + * @import {AnalysisOptions} from './types/analyzer.js' + * @import {CjsAnalysisContext} from './types/cjs-analyzer.js' + */ + +/** + * Creates a fresh CJS analysis context for a single module parse. + * + * @param {AnalysisOptions} [options] + * @returns {CjsAnalysisContext} + */ + +export const analyzeCjs = (options = {}) => { + const { allowHidden = false } = options; + const sourceOptions = createCjsSourceOptions({ allowHidden }); + const { analyzePlugin, transformPlugin } = + makeCjsModulePlugins(sourceOptions); + + return { + analyzePass: analyzePlugin, + transformPass: transformPlugin, + + buildRecord(generatedCode, location) { + const functorSource = buildCjsFunctorSource( + generatedCode, + sourceOptions, + location, + ); + return buildCjsModuleRecord(sourceOptions, functorSource); + }, + }; +}; diff --git a/packages/module-source/src/cjs-babel-plugin.js b/packages/module-source/src/cjs-babel-plugin.js new file mode 100644 index 0000000000..85ad0752ff --- /dev/null +++ b/packages/module-source/src/cjs-babel-plugin.js @@ -0,0 +1,781 @@ +/* eslint max-lines: 0 */ + +/** + * Babel plugin for analyzing and transforming CommonJS module source code. + * + * Provides the CJS counterpart to {@link makeModulePlugins} in + * `babel-plugin.js`. Creates paired analyze/transform Babel plugins that detect + * CJS patterns (`require()`, `exports`, `module.exports`, + * `Object.defineProperty`, Babel/TS reexport helpers, esbuild hints) and + * rewrite `import()` calls for SES evasion. + * + * @module + */ + +import * as t from '@babel/types'; +import * as h from './hidden.js'; + +/** + * @import {CjsTransformSourceParams} from './types/cjs-module-source.js' + * @import {VisitorPlugin} from './types/analyzer.js' + * @import {Node, + * CallExpression, + * Expression, + * PrivateName, + * MemberExpression, + * ObjectProperty, + * ObjectMethod, + * BlockStatement, + * ObjectExpression, + * Identifier, + * } from '@babel/types' + * @import {Visitor} from '@babel/traverse' + */ + +const strictReserved = new Set([ + 'implements', + 'interface', + 'let', + 'package', + 'private', + 'protected', + 'public', + 'static', + 'yield', + 'enum', +]); + +/** + * @param {string} name + * @returns {boolean} + */ +const isValidExportName = name => { + if (strictReserved.has(name)) return false; + return /^[\p{ID_Start}$_][\p{ID_Continue}$]*$/u.test(name); +}; + +/** + * @param {Expression | PrivateName} node + * @param {boolean} computed + * @returns {string | null} + */ +const getPropertyKeyName = (node, computed) => { + if (computed) return null; + if (node.type === 'Identifier') return node.name; + if (node.type === 'StringLiteral') return node.value; + return null; +}; + +/** + * @param {CallExpression} node + * @returns {string | null} + */ +const getStringCallArg = node => { + if (node.arguments.length !== 1) return null; + const arg = node.arguments[0]; + if (arg.type === 'StringLiteral') return arg.value; + return null; +}; + +/** + * @param {CallExpression} node + * @returns {boolean} + */ +const isRequireCall = node => + node.callee.type === 'Identifier' && + node.callee.name === 'require' && + node.arguments.length >= 1 && + node.arguments[0].type === 'StringLiteral'; + +/** + * @param {Node} node + * @param {string} obj + * @param {string} prop + * @returns {boolean} + */ +const isMember = (node, obj, prop) => + node.type === 'MemberExpression' && + !node.computed && + node.object.type === 'Identifier' && + node.object.name === obj && + node.property.type === 'Identifier' && + node.property.name === prop; + +/** + * @param {Node} node + * @returns {boolean} + */ +const isModuleExports = node => isMember(node, 'module', 'exports'); + +/** + * @param {Node} node + * @returns {boolean} + */ +const isExportsIdentifier = node => + node.type === 'Identifier' && node.name === 'exports'; + +/** + * @param {MemberExpression} node + * @returns {string | null} + */ +const getMemberPropertyName = node => { + if (node.computed) { + if (node.property.type === 'StringLiteral') return node.property.value; + return null; + } + if (node.property.type === 'Identifier') return node.property.name; + return null; +}; + +/** + * @param {Node} node + * @param {string} keyParam + * @returns {boolean} + */ +const isExportsBracketAccess = (node, keyParam) => + node.type === 'MemberExpression' && + node.computed === true && + isExportsIdentifier(node.object) && + node.property.type === 'Identifier' && + node.property.name === keyParam; + +/** + * @param {Node} node + * @param {string} objName + * @param {string} keyParam + * @returns {boolean} + */ +const isBracketAccess = (node, objName, keyParam) => + node.type === 'MemberExpression' && + node.computed === true && + node.object.type === 'Identifier' && + node.object.name === objName && + node.property.type === 'Identifier' && + node.property.name === keyParam; + +/** + * Extracts the BlockStatement body from a getter property (either an + * ObjectMethod or an ObjectProperty with a function/arrow value). + * + * @param {ObjectProperty | ObjectMethod} prop + * @returns {BlockStatement | null} + */ +const extractGetterBody = prop => { + if (prop.type === 'ObjectMethod') { + return prop.body; + } + if ( + prop.type === 'ObjectProperty' && + (prop.value.type === 'FunctionExpression' || + prop.value.type === 'ArrowFunctionExpression') && + prop.value.body.type === 'BlockStatement' + ) { + return prop.value.body; + } + return null; +}; + +/** + * @param {ObjectExpression} node + * @param {Set} exportsSet + * @param {Set} reexportsSet + * @param {string[]} requiresList + */ +const collectObjectExports = (node, exportsSet, reexportsSet, requiresList) => { + for (const prop of node.properties) { + if (prop.type === 'SpreadElement') { + const arg = prop.argument; + if (arg.type === 'CallExpression' && isRequireCall(arg)) { + const specifier = getStringCallArg(arg); + if (specifier !== null) { + reexportsSet.add(specifier); + requiresList.push(specifier); + } + } + } else if (prop.type === 'ObjectProperty' || prop.type === 'ObjectMethod') { + const name = getPropertyKeyName(prop.key, prop.computed); + if (name !== null && isValidExportName(name)) { + exportsSet.add(name); + } + } + } +}; + +/** + * @param {ObjectExpression} descriptorNode + * @returns {boolean} + */ +const hasUnsafeGetter = descriptorNode => { + let hasGetter = false; + let getterIsSafe = false; + let hasValue = false; + let hasEnumerableFalse = false; + + for (const prop of descriptorNode.properties) { + if (prop.type === 'ObjectProperty' || prop.type === 'ObjectMethod') { + const keyName = getPropertyKeyName(prop.key, prop.computed); + + if (keyName === 'value') { + hasValue = true; + } else if (keyName === 'enumerable') { + if ( + prop.type === 'ObjectProperty' && + prop.value.type === 'BooleanLiteral' && + prop.value.value === false + ) { + hasEnumerableFalse = true; + } + } else if (keyName === 'get') { + hasGetter = true; + const body = extractGetterBody(prop); + + if (body && body.body.length === 1) { + const stmt = body.body[0]; + if ( + stmt.type === 'ReturnStatement' && + stmt.argument && + stmt.argument.type === 'MemberExpression' + ) { + const mem = stmt.argument; + if ( + mem.object.type === 'Identifier' && + ((!mem.computed && mem.property.type === 'Identifier') || + (mem.computed && mem.property.type === 'StringLiteral')) + ) { + getterIsSafe = true; + } + } + } + } + } + } + + if (hasValue) return false; + if (hasGetter && (hasEnumerableFalse || !getterIsSafe)) return true; + return false; +}; + +/** + * @param {CallExpression} node + * @returns {{ target: Expression, name: string, descriptor: ObjectExpression } | null} + */ +const matchDefineProperty = node => { + if ( + !isMember(node.callee, 'Object', 'defineProperty') || + node.arguments.length < 3 + ) { + return null; + } + const [target, nameArg, descriptor] = node.arguments; + if (nameArg.type !== 'StringLiteral') return null; + if (descriptor.type !== 'ObjectExpression') return null; + const typedTarget = /** @type {Expression} */ (target); + return { target: typedTarget, name: nameArg.value, descriptor }; +}; + +/** + * @param {Expression} test + * @param {string} keyParam + * @returns {boolean} + */ +const containsDefaultGuard = (test, keyParam) => { + if (test.type === 'BinaryExpression') { + if ( + test.operator === '===' && + test.left.type === 'Identifier' && + test.left.name === keyParam && + test.right.type === 'StringLiteral' && + test.right.value === 'default' + ) { + return true; + } + } + if (test.type === 'LogicalExpression') { + return ( + containsDefaultGuard(test.left, keyParam) || + containsDefaultGuard(test.right, keyParam) + ); + } + return false; +}; + +/** + * Checks if an expression contains `keyParam !== "default"`. + * + * @param {Expression} test + * @param {string} keyParam + * @returns {boolean} + */ +const containsNegatedDefaultGuard = (test, keyParam) => { + if (test.type === 'BinaryExpression') { + if ( + test.operator === '!==' && + test.left.type === 'Identifier' && + test.left.name === keyParam && + test.right.type === 'StringLiteral' && + test.right.value === 'default' + ) { + return true; + } + } + if (test.type === 'LogicalExpression') { + return ( + containsNegatedDefaultGuard(test.left, keyParam) || + containsNegatedDefaultGuard(test.right, keyParam) + ); + } + return false; +}; + +/** + * @param {CallExpression} node + * @param {string} keyParam + * @param {string} varName + * @returns {boolean} + */ +const matchDefinePropertyDynamic = (node, keyParam, varName) => { + if ( + !isMember(node.callee, 'Object', 'defineProperty') || + node.arguments.length < 3 + ) { + return false; + } + const [target, keyArg, descriptor] = node.arguments; + if (!isExportsIdentifier(target)) return false; + if (keyArg.type !== 'Identifier' || keyArg.name !== keyParam) return false; + if (descriptor.type !== 'ObjectExpression') return false; + + let hasEnumerableTrue = false; + let hasValidGetter = false; + + for (const prop of descriptor.properties) { + if (prop.type === 'ObjectProperty' || prop.type === 'ObjectMethod') { + const propName = getPropertyKeyName(prop.key, prop.computed); + + if (propName === 'enumerable') { + if ( + prop.type === 'ObjectProperty' && + prop.value.type === 'BooleanLiteral' && + prop.value.value === true + ) { + hasEnumerableTrue = true; + } + } + + if (propName === 'get') { + const body = extractGetterBody(prop); + if (body && body.body.length === 1) { + const stmt = body.body[0]; + if ( + stmt.type === 'ReturnStatement' && + stmt.argument && + isBracketAccess(stmt.argument, varName, keyParam) + ) { + hasValidGetter = true; + } + } + } + } + } + + return hasEnumerableTrue && hasValidGetter; +}; + +/** + * Detects the Babel-compiled star-reexport pattern. + * + * @param {CallExpression} node + * @param {Record} starExportMap + * @returns {string | null} + */ +const matchBabelReexportPattern = (node, starExportMap) => { + if ( + node.callee.type !== 'MemberExpression' || + node.callee.property.type !== 'Identifier' || + node.callee.property.name !== 'forEach' + ) { + return null; + } + + const receiver = node.callee.object; + if ( + receiver.type !== 'CallExpression' || + !isMember(receiver.callee, 'Object', 'keys') || + receiver.arguments.length !== 1 || + receiver.arguments[0].type !== 'Identifier' + ) { + return null; + } + + const varName = receiver.arguments[0].name; + + if ( + node.arguments.length !== 1 || + (node.arguments[0].type !== 'FunctionExpression' && + node.arguments[0].type !== 'ArrowFunctionExpression') + ) { + return null; + } + + const fn = node.arguments[0]; + if (fn.params.length !== 1 || fn.params[0].type !== 'Identifier') { + return null; + } + const keyParam = fn.params[0].name; + + const body = fn.body.type === 'BlockStatement' ? fn.body.body : null; + if (!body || body.length < 1) return null; + + const firstStmt = body[0]; + if (firstStmt.type !== 'IfStatement') return null; + + // Pattern A: if (key === "default" || ...) return; ... exports[key] = x[key] + if (body.length >= 2 && containsDefaultGuard(firstStmt.test, keyParam)) { + const exportStmt = body[body.length - 1]; + + if (exportStmt.type === 'ExpressionStatement') { + const expr = exportStmt.expression; + + if ( + expr.type === 'AssignmentExpression' && + expr.operator === '=' && + isExportsBracketAccess(expr.left, keyParam) && + isBracketAccess(expr.right, varName, keyParam) + ) { + return starExportMap[varName] || null; + } + + if (expr.type === 'CallExpression') { + if (matchDefinePropertyDynamic(expr, keyParam, varName)) { + return starExportMap[varName] || null; + } + } + } + return null; + } + + // Pattern B: if (k !== 'default') exports[k] = x[k] + // or: if (k !== 'default' && ...) exports[k] = x[k] + if (containsNegatedDefaultGuard(firstStmt.test, keyParam)) { + const consequent = + firstStmt.consequent.type === 'BlockStatement' + ? firstStmt.consequent.body[0] + : firstStmt.consequent; + + if (consequent && consequent.type === 'ExpressionStatement') { + const expr = consequent.expression; + + if ( + expr.type === 'AssignmentExpression' && + expr.operator === '=' && + isExportsBracketAccess(expr.left, keyParam) && + isBracketAccess(expr.right, varName, keyParam) + ) { + return starExportMap[varName] || null; + } + + if (expr.type === 'CallExpression') { + if (matchDefinePropertyDynamic(expr, keyParam, varName)) { + return starExportMap[varName] || null; + } + } + } + } + + return null; +}; + +/** + * Extracts a callee name from a CallExpression, handling both bare identifiers + * and member expressions (e.g. `tslib.__exportStar`). + * + * @param {CallExpression} node + * @returns {string | null} + */ +const getCalleeName = node => { + if (node.callee.type === 'Identifier') { + return node.callee.name; + } + if ( + node.callee.type === 'MemberExpression' && + node.callee.property.type === 'Identifier' + ) { + return node.callee.property.name; + } + return null; +}; + +/** + * Creates paired analyze and transform visitor passes for CJS module source code. + * + * @param {CjsTransformSourceParams} options + * @returns {{ analyzePlugin: VisitorPlugin, transformPlugin: VisitorPlugin }} + */ +export default function makeCjsModulePlugins(options) { + const { + requires, + exports: exportsSet, + reexports, + imports: importsArr, + unsafeGetters, + dynamicImport, + starExportMap, + } = options; + + const analyzePlugin = { + /** @type {Visitor} */ + visitor: { + CallExpression(path) { + const { node } = path; + + if (isRequireCall(node)) { + const specifier = getStringCallArg(node); + if (specifier !== null) { + requires.push(specifier); + } + return; + } + + if (node.callee.type === 'Import') { + dynamicImport.present = true; + const specifier = getStringCallArg(node); + if (specifier !== null) { + importsArr.push(specifier); + } + return; + } + + if ( + node.callee.type === 'Identifier' && + node.callee.name === '_interopRequireWildcard' && + node.arguments[0] && + node.arguments[0].type === 'CallExpression' && + isRequireCall(node.arguments[0]) + ) { + const specifier = getStringCallArg(node.arguments[0]); + if (specifier !== null) { + requires.push(specifier); + } + return; + } + + const calleeName = getCalleeName(node); + + if ( + (calleeName === '__export' || calleeName === '__exportStar') && + node.arguments[0] && + node.arguments[0].type === 'CallExpression' && + isRequireCall(node.arguments[0]) + ) { + const specifier = getStringCallArg(node.arguments[0]); + if (specifier !== null) { + requires.push(specifier); + reexports.add(specifier); + } + return; + } + + const dp = matchDefineProperty(node); + if (dp) { + const { target, name, descriptor } = dp; + if (isExportsIdentifier(target) || isModuleExports(target)) { + if (isValidExportName(name)) { + if (hasUnsafeGetter(descriptor)) { + unsafeGetters.add(name); + } else { + exportsSet.add(name); + } + } + } + return; + } + + const reexportSpecifier = matchBabelReexportPattern( + node, + starExportMap, + ); + if (reexportSpecifier !== null) { + reexports.add(reexportSpecifier); + } + }, + + AssignmentExpression(path) { + const { node } = path; + const { left, right } = node; + if (node.operator !== '=') return; + + if ( + left.type === 'MemberExpression' && + isExportsIdentifier(left.object) + ) { + const name = getMemberPropertyName(left); + if (name !== null && isValidExportName(name)) { + exportsSet.add(name); + } + return; + } + + if (left.type === 'MemberExpression' && isModuleExports(left.object)) { + const name = getMemberPropertyName(left); + if (name !== null && isValidExportName(name)) { + exportsSet.add(name); + } + return; + } + + if (isModuleExports(left)) { + if (right.type === 'CallExpression' && isRequireCall(right)) { + const specifier = getStringCallArg(right); + if (specifier !== null) { + requires.push(specifier); + reexports.clear(); + reexports.add(specifier); + } + return; + } + + if (right.type === 'ObjectExpression') { + collectObjectExports(right, exportsSet, reexports, requires); + return; + } + + exportsSet.add('default'); + } + }, + + ExpressionStatement(path) { + const { node } = path; + if ( + node.expression.type !== 'LogicalExpression' || + node.expression.operator !== '&&' || + node.expression.left.type !== 'NumericLiteral' || + node.expression.left.value !== 0 + ) { + return; + } + + const rhs = node.expression.right; + /** @type {Expression[]} */ + const parts = []; + /** @param {Expression} expr */ + const collectParts = expr => { + if (expr.type === 'LogicalExpression' && expr.operator === '&&') { + collectParts(expr.left); + collectParts(expr.right); + } else { + parts.push(expr); + } + }; + collectParts(rhs); + + for (const part of parts) { + if ( + part.type === 'AssignmentExpression' && + part.operator === '=' && + isModuleExports(part.left) && + part.right.type === 'ObjectExpression' + ) { + for (const prop of part.right.properties) { + if ( + prop.type === 'ObjectProperty' || + prop.type === 'ObjectMethod' + ) { + const name = getPropertyKeyName(prop.key, prop.computed); + if (name !== null && isValidExportName(name)) { + exportsSet.add(name); + } + } + } + } + if (part.type === 'CallExpression') { + const cn = getCalleeName(part); + if ( + (cn === '__export' || cn === '__exportStar') && + part.arguments.length >= 1 && + part.arguments[0].type === 'CallExpression' && + isRequireCall(part.arguments[0]) + ) { + const specifier = getStringCallArg(part.arguments[0]); + if (specifier !== null) { + requires.push(specifier); + reexports.add(specifier); + } + } + } + } + }, + + VariableDeclarator(path) { + const { node } = path; + if (node.id.type !== 'Identifier' || !node.init) { + return; + } + // Only track top-level declarations for star-export backtracking. + // Block-scoped declarations (inside { }, if, etc.) are not reexport + // candidates. + const declParent = path.parentPath?.parentPath; + if (!declParent || declParent.node.type !== 'Program') { + return; + } + + let specifier = null; + + if (node.init.type === 'CallExpression' && isRequireCall(node.init)) { + specifier = getStringCallArg(node.init); + } else if ( + node.init.type === 'CallExpression' && + node.init.callee.type === 'Identifier' && + node.init.callee.name === '_interopRequireWildcard' && + node.init.arguments[0] && + node.init.arguments[0].type === 'CallExpression' && + isRequireCall(node.init.arguments[0]) + ) { + specifier = getStringCallArg(node.init.arguments[0]); + } + + if (specifier !== null) { + starExportMap[node.id.name] = specifier; + } + }, + }, + }; + + /** @type {WeakSet} */ + const allowedHiddens = new WeakSet(); + + /** @param {string} hi */ + const hiddenIdentifier = hi => { + const ident = t.identifier(hi); + allowedHiddens.add(ident); + return ident; + }; + + const transformPlugin = { + /** @type {Visitor} */ + visitor: { + Identifier(path) { + if (options.allowHidden || allowedHiddens.has(path.node)) { + return; + } + const i = h.HIDDEN_IDENTIFIERS.indexOf(path.node.name); + if (i >= 0) { + throw path.buildCodeFrameError( + `The ${h.HIDDEN_IDENTIFIERS[i]} identifier is reserved`, + ); + } + if (path.node.name.startsWith(h.HIDDEN_CONST_VAR_PREFIX)) { + throw path.buildCodeFrameError( + `The ${path.node.name} constant variable is reserved`, + ); + } + }, + CallExpression(path) { + if (path.node.callee.type === 'Import') { + path.node.callee = hiddenIdentifier(h.HIDDEN_IMPORT); + } + }, + }, + }; + + return { analyzePlugin, transformPlugin }; +} diff --git a/packages/module-source/src/cjs-functor.js b/packages/module-source/src/cjs-functor.js new file mode 100644 index 0000000000..2a16fdd14e --- /dev/null +++ b/packages/module-source/src/cjs-functor.js @@ -0,0 +1,80 @@ +/** + * Builds the CJS functor source string and assembles the frozen CJS module + * record. + * + * Analogous to {@link functor.js} for ESM, but much simpler; CJS needs no + * import-map preamble or hoisted declarations. + * + * @module + */ + +import * as h from './hidden.js'; + +/** + * @import {CjsTransformSourceParams, CjsModuleSourceRecord} from './types/cjs-module-source.js' + */ + +const { freeze: objectFreeze } = Object; + +/** @type {(v: T) => T} */ +const freeze = /** @type {any} */ (objectFreeze); + +/** + * Wraps transformed CJS source in a function expression suitable for + * `compartment.evaluate()`. + * + * When dynamic `import()` was detected, the `$h͏_import` hidden identifier is + * included as a parameter so the compartment can inject the import function. + * + * @param {string} scriptSource - The code produced by `@babel/generator`. + * @param {CjsTransformSourceParams} sourceOptions - The mutable state bag. + * @param {string} [sourceUrl] - The source URL for the module. + * @returns {string} The functor source string. + */ +export const buildCjsFunctorSource = ( + scriptSource, + sourceOptions, + sourceUrl, +) => { + const needsImport = sourceOptions.dynamicImport.present; + const params = needsImport + ? `require, exports, module, __filename, __dirname, ${h.HIDDEN_IMPORT}` + : 'require, exports, module, __filename, __dirname'; + + let functorSource = `(function (${params}) { 'use strict'; ${scriptSource} //*/\n})\n`; + if (sourceUrl) { + functorSource += `//# sourceURL=${sourceUrl}\n`; + } + return functorSource; +}; + +/** + * Assembles a frozen `CjsModuleSourceRecord` from the analysis state and the + * functor source string. + * + * @param {CjsTransformSourceParams} sourceOptions - The mutable state bag + * populated by `makeCjsModulePlugins`. + * @param {string} functorSource - The functor source string from + * {@link buildCjsFunctorSource}. + * @returns {CjsModuleSourceRecord} + */ +export const buildCjsModuleRecord = (sourceOptions, functorSource) => { + const exports = [...sourceOptions.exports].filter( + name => !sourceOptions.unsafeGetters.has(name), + ); + if (!exports.includes('default')) { + exports.push('default'); + } + + const imports = sourceOptions.dynamicImport.present + ? [...new Set([...sourceOptions.requires, ...sourceOptions.imports])] + : [...sourceOptions.requires]; + + return freeze({ + imports: freeze(imports), + exports: freeze(exports), + reexports: freeze([...sourceOptions.reexports]), + cjsFunctor: functorSource, + __needsImport__: sourceOptions.dynamicImport.present, + }); +}; diff --git a/packages/module-source/src/cjs-module-source.js b/packages/module-source/src/cjs-module-source.js new file mode 100644 index 0000000000..76272ad39b --- /dev/null +++ b/packages/module-source/src/cjs-module-source.js @@ -0,0 +1,50 @@ +/* eslint no-underscore-dangle: ["off"] */ + +/** + * Provides {@link CjsModuleSource}, a constructor that parses, analyzes, + * transforms, and builds a CJS module record in one step. + * + * Parallel to {@link ModuleSource} for ESM. + * + * @module + */ + +import { makeCjsAnalyzer } from './cjs-transform-analyze.js'; + +/** + * @import {ModuleSourceOptions} from './types/module-source.js' + */ + +const freeze = /** @type {(v: T) => T} */ (Object.freeze); + +const analyzeCjs = makeCjsAnalyzer(); + +/** + * `CjsModuleSource` captures the effort of parsing and analyzing CommonJS + * module text, producing a frozen record with import/export metadata and a + * pre-built functor source string. + * + * @class + * @param {string} source - The CommonJS source text. + * @param {string | ModuleSourceOptions} [opts] + */ +export function CjsModuleSource(source, opts = {}) { + if (new.target === undefined) { + throw TypeError( + "Class constructor CjsModuleSource cannot be invoked without 'new'", + ); + } + if (typeof opts === 'string') { + opts = { sourceUrl: opts }; + } + const record = analyzeCjs(source, opts); + this.imports = record.imports; + this.exports = record.exports; + this.reexports = record.reexports; + this.cjsFunctor = record.cjsFunctor; + this.__needsImport__ = record.__needsImport__; + freeze(this); +} + +freeze(CjsModuleSource.prototype); +freeze(CjsModuleSource); diff --git a/packages/module-source/src/cjs-transform-analyze.js b/packages/module-source/src/cjs-transform-analyze.js new file mode 100644 index 0000000000..cda73dbc72 --- /dev/null +++ b/packages/module-source/src/cjs-transform-analyze.js @@ -0,0 +1,92 @@ +/** + * Composes the CJS Babel plugin with the parse/traverse/generate cycle and + * the CJS functor builder. + * + * Parallel to {@link makeModuleAnalyzer} for ESM. + * + * @module + */ + +import { generate as generateBabel } from '@babel/generator'; +import { parse as parseBabel } from '@babel/parser'; +import babelTraverse from '@babel/traverse'; +import { analyzeCjs } from './cjs-analyzer.js'; + +/** + * @import {ModuleSourceOptions} from './types/module-source.js' + * @import {CjsModuleSourceRecord} from './types/cjs-module-source.js' + * @import {AnalysisOptions} from './types/analyzer.js' + */ + +const { default: traverseBabel } = babelTraverse; + +/** + * Creates a CJS module analyzer function. Call the returned function with CJS + * source to get a frozen `CjsModuleSourceRecord`. + * + * @returns {(source: string, options?: ModuleSourceOptions & AnalysisOptions) => CjsModuleSourceRecord} + */ +export const makeCjsAnalyzer = () => { + /** + * @param {string} moduleSource + * @param {ModuleSourceOptions & AnalysisOptions} [options] + */ + return function analyzeFromCjsSource( + moduleSource, + { sourceUrl, sourceMapUrl, sourceMap, sourceMapHook, allowHidden } = {}, + ) { + const ctx = analyzeCjs({ allowHidden }); + + if (moduleSource.startsWith('#!')) { + moduleSource = `//${moduleSource}`; + } + + let scriptSource; + try { + const ast = parseBabel(moduleSource, { + sourceType: 'commonjs', + tokens: true, + createParenthesizedExpressions: true, + }); + + traverseBabel(ast, ctx.analyzePass.visitor); + traverseBabel(ast, ctx.transformPass.visitor); + + const { code: transformedSource, map: transformedSourceMap } = + generateBabel( + ast, + { + sourceFileName: sourceMapUrl, + sourceMaps: !!sourceMapHook, + // @ts-expect-error undocumented + inputSourceMap: sourceMap, + experimental_preserveFormat: true, + preserveFormat: true, + retainLines: true, + verbatim: true, + }, + moduleSource, + ); + + if (sourceMapHook && transformedSourceMap) { + sourceMapHook(transformedSourceMap, { + sourceUrl, + sourceMapUrl, + source: moduleSource, + }); + } + + scriptSource = transformedSource; + } catch (err) { + const moduleLocation = sourceUrl + ? JSON.stringify(sourceUrl) + : ''; + throw SyntaxError( + `Error transforming CJS source in ${moduleLocation}: ${/** @type {Error} */ (err).message}`, + { cause: err }, + ); + } + + return ctx.buildRecord(scriptSource, sourceUrl); + }; +}; diff --git a/packages/module-source/src/source-options.js b/packages/module-source/src/source-options.js index c6a6e5967d..0f638d58ee 100644 --- a/packages/module-source/src/source-options.js +++ b/packages/module-source/src/source-options.js @@ -6,6 +6,7 @@ /** * @import {TransformSourceParams} from './types/module-source.js' + * @import {CjsTransformSourceParams} from './types/cjs-module-source.js' */ /** @@ -53,3 +54,45 @@ export const createSourceOptions = overrides => ({ importMeta: { present: false }, ...(overrides ?? {}), }); + +/** + * Creates a fresh `sourceOptions` object with the mutable state properties + * that `makeCjsModulePlugins` populates during CJS analysis and transform + * passes. + * + * @template {object} T + * @overload + * @param {T} overrides + * @returns {CjsTransformSourceParams & T} + */ +/** + * Creates a fresh `sourceOptions` object with the mutable state properties + * that `makeCjsModulePlugins` populates during CJS analysis and transform + * passes. + * + * @overload + * @returns {CjsTransformSourceParams} + */ + +/** + * @template {object} T + * @param {T} [overrides] + */ +export const createCjsSourceOptions = overrides => + /** @type {CjsTransformSourceParams} */ ({ + sourceType: 'commonjs', + /** @type {string[]} */ + requires: [], + /** @type {Set} */ + exports: new Set(), + /** @type {Set} */ + reexports: new Set(), + /** @type {string[]} */ + imports: [], + /** @type {Set} */ + unsafeGetters: new Set(), + dynamicImport: { present: false }, + /** @type {Record} */ + starExportMap: Object.create(null), + ...(overrides ?? {}), + }); diff --git a/packages/module-source/src/types/cjs-analyzer.ts b/packages/module-source/src/types/cjs-analyzer.ts new file mode 100644 index 0000000000..8a872f4659 --- /dev/null +++ b/packages/module-source/src/types/cjs-analyzer.ts @@ -0,0 +1,7 @@ +import type { AnalysisContext } from './analyzer.js'; +import type { CjsModuleSourceRecord } from './cjs-module-source.js'; + +/** + * Context for CJS module analysis. + */ +export type CjsAnalysisContext = AnalysisContext; diff --git a/packages/module-source/src/types/cjs-module-source.ts b/packages/module-source/src/types/cjs-module-source.ts new file mode 100644 index 0000000000..ffe86f8f22 --- /dev/null +++ b/packages/module-source/src/types/cjs-module-source.ts @@ -0,0 +1,53 @@ +import type { SourceMapHook } from './module-source.js'; + +/** + * Mutable state bag for the CJS Babel plugin, populated during analysis and + * transform passes. + */ + +export interface CjsTransformSourceParams { + sourceType: 'commonjs'; + requires: string[]; + exports: Set; + reexports: Set; + imports: string[]; + unsafeGetters: Set; + dynamicImport: { present: boolean }; + starExportMap: Record; + sourceUrl?: string; + sourceMapUrl?: string; + sourceMap?: unknown; + sourceMapHook?: SourceMapHook; + allowHidden?: boolean; +} +/** + * The frozen record produced by {@link CjsModuleSource} or the CJS + * `buildRecord` function. + * + * This is NOT a `PrecompiledModuleSource`. It contains the analysis data plus + * a pre-built CJS functor source string. + */ + +export interface CjsModuleSourceRecord { + /** Combined specifiers: `require()` + dynamic `import()` (deduped). */ + readonly imports: string[]; + + /** Export names (always includes `'default'`). */ + readonly exports: string[]; + + /** Specifiers that are wholesale reexported. */ + readonly reexports: string[]; + + /** + * The CJS function expression wrapping the transformed source. + * + * @example + * ``` + * (function (require, exports, module, __filename, __dirname, $h_import) { 'use strict'; ... }) + * ``` + */ + readonly cjsFunctor: string; + + /** Whether any `import()` calls were found. */ + readonly __needsImport__: boolean; +} diff --git a/packages/module-source/src/types/external.ts b/packages/module-source/src/types/external.ts index 7061c0c3e0..472f4c60d1 100644 --- a/packages/module-source/src/types/external.ts +++ b/packages/module-source/src/types/external.ts @@ -9,3 +9,5 @@ export type { AnalysisOptions, ModuleAnalysisContext, } from './analyzer.ts'; +export type { CjsAnalysisContext } from './cjs-analyzer.ts'; +export type { CjsModuleSourceRecord } from './cjs-module-source.ts'; diff --git a/packages/module-source/test/cjs-analyzer.test.js b/packages/module-source/test/cjs-analyzer.test.js new file mode 100644 index 0000000000..6b405a297c --- /dev/null +++ b/packages/module-source/test/cjs-analyzer.test.js @@ -0,0 +1,76 @@ +/* eslint-disable no-underscore-dangle */ +import test from '@endo/ses-ava/prepare-endo.js'; +import { parse as parseBabel } from '@babel/parser'; +import babelTraverse from '@babel/traverse'; +import { generate as generateBabel } from '@babel/generator'; +import { analyzeCjs } from '../src/cjs-analyzer.js'; + +const { default: traverseBabel } = babelTraverse; + +test('analyzeCjs returns context with analyzePass, transformPass, buildRecord', t => { + const ctx = analyzeCjs(); + t.is(typeof ctx.analyzePass, 'object'); + t.is(typeof ctx.analyzePass.visitor, 'object'); + t.is(typeof ctx.transformPass, 'object'); + t.is(typeof ctx.transformPass.visitor, 'object'); + t.is(typeof ctx.buildRecord, 'function'); +}); + +test('analyzeCjs() identifies requires and exports via buildRecord', t => { + const source = ` + const dep = require('some-dep'); + exports.hello = () => dep.greet(); + `; + + const ctx = analyzeCjs(); + const ast = parseBabel(source, { + sourceType: 'commonjs', + tokens: true, + createParenthesizedExpressions: true, + }); + + traverseBabel(ast, ctx.analyzePass.visitor); + traverseBabel(ast, ctx.transformPass.visitor); + + const { code } = generateBabel( + ast, + // @ts-expect-error undocumented option + { retainLines: true, verbatim: true }, + source, + ); + + const record = ctx.buildRecord(code); + // CJS: record.imports combines require() + import() specifiers + t.true(record.imports.includes('some-dep')); + t.true(record.exports.includes('hello')); +}); + +test('analyzeCjs().buildRecord produces a record with cjsFunctor', t => { + const source = `exports.answer = 42;`; + + const ctx = analyzeCjs(); + const ast = parseBabel(source, { + sourceType: 'commonjs', + tokens: true, + createParenthesizedExpressions: true, + }); + + traverseBabel(ast, ctx.analyzePass.visitor); + traverseBabel(ast, ctx.transformPass.visitor); + + const { code } = generateBabel( + ast, + { + // @ts-expect-error undocumented + experimental_preserveFormat: true, + preserveFormat: true, + retainLines: true, + verbatim: true, + }, + source, + ); + + const record = ctx.buildRecord(code); + t.is(typeof record.cjsFunctor, 'string'); + t.true(record.cjsFunctor.includes('function')); +}); diff --git a/packages/module-source/test/cjs-compat.test.js b/packages/module-source/test/cjs-compat.test.js new file mode 100644 index 0000000000..ca659eacbd --- /dev/null +++ b/packages/module-source/test/cjs-compat.test.js @@ -0,0 +1,952 @@ +/* eslint-disable no-underscore-dangle */ + +/** + * Compatibility tests ported from `@endo/cjs-module-analyzer`. + * + * Each test mirrors a test from `packages/cjs-module-analyzer/test/cjs-module-analyzer.test.js` + * using `CjsModuleSource` instead of `analyzeCommonJS`. + * + * Key differences from the lexer: + * - `CjsModuleSource` returns `imports` (combined requires + dynamic imports) + * instead of a separate `requires` field. + * - `exports` always includes `'default'` (added by `buildCjsModuleRecord`). + * - The Babel AST parser correctly parses full object literals, so it can find + * more export keys than the character lexer in some edge cases. + * - Invalid JS that the character lexer could handle (e.g. `{ a = 5 }` as an + * object literal) causes a parse error with Babel. These tests are adapted. + */ +import test from '@endo/ses-ava/prepare-endo.js'; +import { CjsModuleSource } from '../src/cjs-module-source.js'; + +const analyze = source => new CjsModuleSource(source); + +test('analyze exports', t => { + const { exports, reexports } = analyze(` + exports.meaning = 42; + `); + t.true(exports.includes('meaning')); + t.true(exports.includes('default')); + t.deepEqual([...reexports], []); +}); + +test('analyze default export figure', t => { + const { exports, reexports } = analyze(` + module.exports = function () {}; + `); + t.deepEqual([...exports], ['default']); + t.deepEqual([...reexports], []); +}); + +test('analyze exported restructured name', t => { + const { exports, reexports } = analyze(` + function a() {} + module.exports = {a}; + `); + t.true(exports.includes('a')); + t.true(exports.includes('default')); + t.deepEqual([...reexports], []); +}); + +test('analyze exported quoted identifier to identifier', t => { + const { exports, reexports } = analyze(` + function a() {} + module.exports = {"a": a}; + `); + t.true(exports.includes('a')); + t.deepEqual([...reexports], []); +}); + +test('analyze mix of quoted and unquoted destructed identifiers', t => { + const { exports, reexports } = analyze(` + function a() {} + function b() {} + function c() {} + function d() {} + module.exports = {"a": a, 'b': b, c: c, d}; + `); + t.true(exports.includes('a')); + t.true(exports.includes('b')); + t.true(exports.includes('c')); + t.true(exports.includes('d')); + t.deepEqual([...reexports], []); +}); + +test('analyze reexports', t => { + const { exports, reexports } = analyze(` + module.exports = require('./x.js'); + `); + // No named exports besides 'default' -- the module.exports = require() + // produces a reexport, not named exports + t.deepEqual([...exports], ['default']); + t.deepEqual([...reexports], ['./x.js']); +}); + +test('esbuild hint style', t => { + const { exports, reexports } = analyze(` + 0 && (module.exports = {a, b, c}) && __exportStar(require('fs')); + `); + + t.true(exports.includes('a')); + t.true(exports.includes('b')); + t.true(exports.includes('c')); + t.deepEqual([...reexports], ['fs']); +}); + +test('Getter opt-outs', t => { + const { exports } = analyze(` + Object.defineProperty(exports, 'a', { + enumerable: true, + get: function () { + return q.p; + } + }); + + if (false) { + Object.defineProperty(exports, 'a', { + enumerable: false, + get: function () { + return dynamic(); + } + }); + } + `); + + // Both defineProperty calls for 'a' are visited. The second one has an + // unsafe getter, so 'a' ends up in unsafeGetters and gets filtered out. + t.false(exports.includes('a')); +}); + +test('TypeScript reexports', t => { + const { exports, reexports } = analyze(` + "use strict"; + function __export(m) { + for (const p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; + } + Object.defineProperty(exports, "__esModule", { value: true }); + __export(require("external1")); + tslib.__export(require("external2")); + __exportStar(require("external3")); + tslib1.__exportStar(require("external4")); + + "use strict"; + Object.defineProperty(exports, "__esModule", { value: true }); + const color_factory_1 = require("./color-factory"); + Object.defineProperty(exports, "colorFactory", { enumerable: true, get: function () { return color_factory_1.colorFactory; }, }); + `); + t.true(exports.includes('__esModule')); + t.true(exports.includes('colorFactory')); + t.true(reexports.includes('external1')); + t.true(reexports.includes('external2')); + t.true(reexports.includes('external3')); + t.true(reexports.includes('external4')); +}); + +test('Rollup Babel reexport getter', t => { + // Adapted: removed the `get: functionget ()` case which is invalid JS that + // Babel can't parse. Also removed the `get () { return external; }` case + // where the body returns a bare identifier (not a member expression) -- + // the AST parser correctly classifies this as an unsafe getter since the + // return is not `x.y` or `x['y']`. + const { exports } = analyze(` + Object.defineProperty(exports, 'a', { + enumerable: true, + get: function () { + return q.p; + } + }); + + Object.defineProperty(exports, 'b', { + enumerable: false, + get: function () { + return q.p; + } + }); + + Object.defineProperty(exports, "c", { + get: function get () { + return q['p' ]; + } + }); + + Object.defineProperty(exports, 'd', { + get: function () { + return __ns.val; + } + }); + `); + t.true(exports.includes('a')); + t.true(exports.includes('c')); + t.true(exports.includes('d')); + // 'b' has enumerable: false with a getter -- unsafe per our heuristic + t.false(exports.includes('b')); +}); + +test('Rollup Babel reexports', t => { + const { exports, reexports } = analyze(` + "use strict"; + + exports.__esModule = true; + + not.detect = require("ignored"); + + var _external = require("external"); + + // Babel <7.12.0, loose mode + Object.keys(_external).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + exports[key] = _external[key]; + }); + + var _external2 = require("external2"); + + // Babel <7.12.0 + Object.keys(_external2).forEach(function (key) { + if (key === "default" || /*comment!*/ key === "__esModule") return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _external2[key]; + } + }); + }); + + var _external001 = require("external001"); + + // Babel >=7.12.0, loose mode + Object.keys(_external001).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _external001[key]) return; + exports[key] = _external001[key]; + }); + + var _external003 = require("external003"); + + // Babel >=7.12.0, loose mode, reexports conflicts filter + Object.keys(_external003).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _external003[key]) return; + exports[key] = _external003[key]; + }); + + var _external002 = require("external002"); + + // Babel >=7.12.0 + Object.keys(_external002).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (key in exports && exports[key] === _external002[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _external002[key]; + } + }); + }); + + var _external004 = require("external004"); + + // Babel >=7.12.0, reexports conflict filter + Object.keys(_external004).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + if (key in exports && exports[key] === _external004[key]) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _external004[key]; + } + }); + }); + + let external3 = require('external3'); + const external4 = require('external4'); + + Object.keys(external3).forEach(function (k) { + if (k !== 'default') Object.defineProperty(exports, k, { + enumerable: true, + get: function () { + return external3[k]; + } + }); + }); + Object.keys(external4).forEach(function (k) { + if (k !== 'default') exports[k] = external4[k]; + }); + + const externalǽ = require('external😃'); + Object.keys(externalǽ).forEach(function (k) { + if (k !== 'default') exports[k] = externalǽ[k]; + }); + + let external5 = require('e5'); + let external6 = require('e6'); + Object.keys(external5).forEach(function (k) { + if (k !== 'default' && !Object.hasOwnProperty.call(exports, k)) exports[k] = external5[k]; + }); + + Object.keys(external6).forEach(function (k) { + if (k !== 'default' && !external6.hasOwnProperty(k)) exports[k] = external6[k]; + }); + + const external𤭢 = require('external𤭢'); + Object.keys(external𤭢).forEach(function (k) { + if (k !== 'default') exports[k] = external𤭢[k]; + }); + + const notexternal1 = require('notexternal1'); + Object.keys(notexternal1); + + const notexternal2 = require('notexternal2'); + Object.keys(notexternal2).each(function(){ + }); + + const notexternal3 = require('notexternal3'); + Object.keys(notexternal2).forEach(function () { + }); + + const notexternal4 = require('notexternal4'); + Object.keys(notexternal2).forEach(function (x) { + }); + + const notexternal5 = require('notexternal5'); + Object.keys(notexternal5).forEach(function (x) { + if (true); + }); + + const notexternal6 = require('notexternal6'); + Object.keys(notexternal6).forEach(function (x) { + if (x); + }); + + const notexternal7 = require('notexternal7'); + Object.keys(notexternal7).forEach(function(x){ + if (x ==='default'); + }); + + const notexternal8 = require('notexternal8'); + Object.keys(notexternal8).forEach(function(x){ + if (x ==='default'||y); + }); + + const notexternal9 = require('notexternal9'); + Object.keys(notexternal9).forEach(function(x){ + if (x ==='default'||x==='__esM'); + }); + + const notexternal10 = require('notexternal10'); + Object.keys(notexternal10).forEach(function(x){ + if (x !=='default') return + }); + + const notexternal11 = require('notexternal11'); + Object.keys(notexternal11).forEach(function(x){ + if (x ==='default'||x==='__esModule') return + }); + + // notexternal12 removed: contains export[y] which Babel rejects as + // ESM syntax in commonjs sourceType + + const notexternal13 = require('notexternal13'); + Object.keys(notexternal13).forEach(function(x){ + if (x ==='default'||x==='__esModule') return + exports[y] = notexternal13[y]; + }); + + const notexternal14 = require('notexternal14'); + Object.keys(notexternal14).forEach(function(x){ + if (x ==='default'||x==='__esModule') return + Object.defineProperty(exports, k, { + enumerable: false, + get: function () { + return external14[k]; + } + }); + }); + + const notexternal15 = require('notexternal15'); + Object.keys(notexternal15).forEach(function(x){ + if (x ==='default'||x==='__esModule') return + Object.defineProperty(exports, k, { + enumerable: false, + get: function () { + return externalnone[k]; + } + }); + }); + + const notexternal16 = require('notexternal16'); + Object.keys(notexternal16).forEach(function(x){ + if (x ==='default'||x==='__esModule') return + exports[x] = notexternal16[x]; + extra; + }); + + { + const notexternal17 = require('notexternal17'); + Object.keys(notexternal17).forEach(function(x){ + if (x ==='default'||x==='__esModule') return + exports[x] = notexternal17[x]; + }); + } + + var _styles = require("./styles"); + Object.keys(_styles).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function get() { + return _styles[key]; + } + }); + }); + + var _styles2 = require("./styles2"); + Object.keys(_styles2).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + Object.defineProperty(exports, key, { + enumerable: true, + get () { + return _styles2[key]; + } + }); + }); + + var _Accordion = _interopRequireWildcard(require("./Accordion")); + Object.keys(_Accordion).forEach(function (key) { + if (key === "default" || key === "__esModule") return; + if (Object.prototype.hasOwnProperty.call(_exportNames, key)) return; + Object.defineProperty(exports, key, { + enumerable: true, + get: function () { + return _Accordion[key]; + } + }); + }); + `); + t.true(exports.includes('__esModule')); + t.true(reexports.includes('external')); + t.true(reexports.includes('external2')); + t.true(reexports.includes('external001')); + t.true(reexports.includes('external003')); + t.true(reexports.includes('external002')); + t.true(reexports.includes('external004')); + t.true(reexports.includes('external3')); + t.true(reexports.includes('external4')); + t.true(reexports.includes('external😃')); + t.true(reexports.includes('e5')); + t.true(reexports.includes('e6')); + t.true(reexports.includes('external𤭢')); + t.true(reexports.includes('./styles')); + t.true(reexports.includes('./styles2')); + t.true(reexports.includes('./Accordion')); + + // None of the "notexternal" patterns should be detected as reexports + t.false(reexports.includes('notexternal1')); + t.false(reexports.includes('notexternal2')); + t.false(reexports.includes('notexternal3')); + t.false(reexports.includes('notexternal4')); + t.false(reexports.includes('notexternal5')); + t.false(reexports.includes('notexternal6')); + t.false(reexports.includes('notexternal7')); + t.false(reexports.includes('notexternal8')); + t.false(reexports.includes('notexternal9')); + t.false(reexports.includes('notexternal10')); + t.false(reexports.includes('notexternal11')); + t.false(reexports.includes('notexternal13')); + t.false(reexports.includes('notexternal14')); + t.false(reexports.includes('notexternal15')); + t.false(reexports.includes('notexternal16')); + t.false(reexports.includes('notexternal17')); +}); + +test('Identify require calls in function arguments', t => { + const { imports } = analyze(` + let Mime = require('./Mime'); + Mime(require('./types/standard'), require('./types/other')); + `); + t.is(imports.length, 3); +}); + +test('Identify some invalid require calls as a side effect', t => { + const { imports } = analyze(` + const requireBackup = require; + function neverCalled() { + const require = ()=>{}; + require('a'); + require('b','c'); + require('./a'); + require(b); + requireBackup('not-a-chance'); + } + `); + // The AST parser (like the lexer) doesn't do scope analysis, so it still + // detects require() calls inside shadowed scopes. + t.true(imports.includes('a')); + t.true(imports.includes('./a')); + t.false(imports.includes('b')); + t.false(imports.includes('not-a-chance')); +}); + +test('invalid exports cases', t => { + const { exports } = analyze(` + module.exports['?invalid'] = 'asdf'; + `); + // Only 'default' should be present (invalid identifier filtered out) + t.deepEqual([...exports], ['default']); +}); + +test('module exports reexport spread', t => { + const { exports, reexports, imports } = analyze(` + module.exports = { + ...a, + ...b, + ...require('dep1'), + c: d, + ...require('dep2'), + name + }; + `); + t.true(exports.includes('c')); + t.true(exports.includes('name')); + t.deepEqual([...reexports], ['dep1', 'dep2']); + t.true(imports.includes('dep1')); + t.true(imports.includes('dep2')); +}); + +test('Regexp case', t => { + t.notThrows(() => + analyze(` + class Number { + + } + + /("|')(?(\\\\(\\1)|[^\\1])*)?(\\1)/.exec(\`'\\\\"\\\\'aa'\`); + + const x = \`"\${label.replace(/"/g, "\\\\\\"")}"\` + `), + ); +}); + +test('Regexp division', t => { + t.notThrows(() => + analyze(`\nconst x = num / /'/.exec(l)[0].slice(1, -1)//'"`), + ); +}); + +test('Multiline string escapes', t => { + t.notThrows(() => + analyze( + "const str = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAAeCAYAAAA7MK6iAAAABmJLR0QA/wAAAAAzJ3zzAAAGTElEQV\\\r\n\t\tRIx+VXe1BU1xn/zjn7ugvL4sIuQnll5U0ELAQxig7WiQYz6NRHa6O206qdSXXSxs60dTK200zNY9q0dcRpMs1jkrRNWmaijCVoaU';\r\n", + ), + ); +}); + +test('Dotted number', t => { + t.notThrows(() => + analyze(` + const x = 5. / 10; + `), + ); +}); + +test('Division operator case', t => { + t.notThrows(() => + analyze(` + function log(r){ + if(g>=0){u[g++]=m;g>=n.logSz&&(g=0)}else{u.push(m);u.length>=n.logSz&&(g=0)}/^(DBG|TICK): /.test(r)||t.Ticker.tick(454,o.slice(0,200)); + } + + (function(n){ + })(); + `), + ); +}); + +test('Single parse cases', t => { + t.notThrows(() => analyze(`'asdf'`)); + t.notThrows(() => analyze(`/asdf/`)); + t.notThrows(() => analyze('`asdf`')); + t.notThrows(() => analyze(`/**/`)); + t.notThrows(() => analyze(`//`)); +}); + +test('shebang', t => { + { + const { exports } = analyze(`#!`); + t.deepEqual([...exports], ['default']); + } + + { + const { exports } = analyze(`#! ( { + exports.asdf = 'asdf'; + `); + t.true(exports.includes('asdf')); + } +}); + +test('module.exports', t => { + const { exports } = analyze(` + module.exports.asdf = 'asdf'; + `); + t.true(exports.includes('asdf')); +}); + +test('identifiers', t => { + const { exports } = analyze(` + exports['not identifier'] = 'asdf'; + exports['@notidentifier'] = 'asdf'; + Object.defineProperty(exports, "%notidentifier"); + Object.defineProperty(exports, 'hm🤔'); + exports['⨉'] = 45; + exports['α'] = 54; + exports.package = 'RESERVED!'; + `); + t.true(exports.includes('α')); + t.false(exports.includes('not identifier')); + t.false(exports.includes('@notidentifier')); + t.false(exports.includes('⨉')); + t.false(exports.includes('package')); +}); + +test('Literal exports', t => { + const { exports } = analyze(` + module.exports = { a, b: c, d, 'e': f }; + `); + t.true(exports.includes('a')); + t.true(exports.includes('b')); + t.true(exports.includes('d')); + t.true(exports.includes('e')); +}); + +// Skipped: `{ a = 5, b }` is invalid as an object literal. Babel throws a +// SyntaxError. The lexer handled this because it does character-level scanning +// without full parsing. +// test('Literal exports unsupported') + +test('Literal exports example', t => { + const { exports } = analyze(` + module.exports = { + // These WILL be detected as exports + a: a, + b: b, + + // This WILL be detected as an export + e: require('d'), + + // DIVERGENCE: The AST parser correctly sees all object keys, unlike the + // lexer which stops at the first non-identifier value expression. + f: 'f' + } + `); + t.true(exports.includes('a')); + t.true(exports.includes('b')); + t.true(exports.includes('e')); + // AST parser finds 'f' too -- the lexer didn't because it stopped scanning + // at the require() value + t.true(exports.includes('f')); +}); + +test('Literal exports complex', t => { + const { exports } = analyze(` + function defineProp(name, value) { + delete module.exports[name]; + module.exports[name] = value; + return value; + } + + module.exports = { + Parser: Parser, + Tokenizer: require("./Tokenizer.js"), + ElementType: require("domelementtype"), + DomHandler: DomHandler, + get FeedHandler() { + return defineProp("FeedHandler", require("./FeedHandler.js")); + }, + get Stream() { + return defineProp("Stream", require("./Stream.js")); + }, + get WritableStream() { + return defineProp("WritableStream", require("./WritableStream.js")); + }, + get ProxyHandler() { + return defineProp("ProxyHandler", require("./ProxyHandler.js")); + }, + get DomUtils() { + return defineProp("DomUtils", require("domutils")); + }, + get CollectingHandler() { + return defineProp( + "CollectingHandler", + require("./CollectingHandler.js") + ); + }, + // For legacy support + DefaultHandler: DomHandler, + get RssHandler() { + return defineProp("RssHandler", this.FeedHandler); + }, + //helper methods + parseDOM: function(data, options) { + var handler = new DomHandler(options); + new Parser(handler, options).end(data); + return handler.dom; + }, + parseFeed: function(feed, options) { + var handler = new module.exports.FeedHandler(options); + new Parser(handler, options).end(feed); + return handler.dom; + }, + createDomStream: function(cb, options, elementCb) { + var handler = new DomHandler(cb, options, elementCb); + return new Parser(handler, options); + }, + // List of all events that the parser emits + EVENTS: { + /* Format: eventname: number of arguments */ + attribute: 2, + cdatastart: 0, + cdataend: 0, + text: 1, + processinginstruction: 2, + comment: 1, + commentend: 0, + closetag: 1, + opentag: 2, + opentagname: 1, + error: 1, + end: 0 + } + }; + `); + // DIVERGENCE: The AST parser sees the full object structure. The lexer only + // found Parser and Tokenizer because it stopped at require() as a value. + t.true(exports.includes('Parser')); + t.true(exports.includes('Tokenizer')); + t.true(exports.includes('ElementType')); + t.true(exports.includes('DomHandler')); + t.true(exports.includes('DefaultHandler')); + t.true(exports.includes('EVENTS')); + // getter methods and function-valued properties are ObjectMethod/ObjectProperty + // which are detected by collectObjectExports + t.true(exports.includes('FeedHandler')); + t.true(exports.includes('parseDOM')); +}); + +test('defineProperty value', t => { + const { exports } = analyze(` + Object.defineProperty(exports, 'namedExport', { enumerable: false, value: true }); + Object.defineProperty(exports, 'namedExport', { configurable: false, value: true }); + + Object.defineProperty(exports, 'a', { + enumerable: false, + get () { + return p; + } + }); + Object.defineProperty(exports, 'b', { + configurable: true, + get () { + return p; + } + }); + Object.defineProperty(exports, 'c', { + get: () => p + }); + Object.defineProperty(exports, 'd', { + enumerable: true, + get: function () { + return dynamic(); + } + }); + Object.defineProperty(exports, 'e', { + enumerable: true, + get () { + return 'str'; + } + }); + + Object.defineProperty(module.exports, 'thing', { value: true }); + Object.defineProperty(exports, "other", { enumerable: true, value: true }); + Object.defineProperty(exports, "__esModule", { value: true }); + `); + t.true(exports.includes('namedExport')); + t.true(exports.includes('thing')); + t.true(exports.includes('other')); + t.true(exports.includes('__esModule')); + // 'a' has a getter returning a bare identifier (not x.y) -- unsafe + // 'b' same + // 'c' has arrow function getter -- unsafe (not a block with return x.y) + // 'd' getter calls dynamic() -- unsafe + // 'e' getter returns a string literal -- unsafe + t.false(exports.includes('d')); + t.false(exports.includes('e')); +}); + +test('module assign', t => { + const { exports, reexports } = analyze(` + module.exports.asdf = 'asdf'; + exports = 'asdf'; + module.exports = require('./asdf'); + if (maybe) + module.exports = require("./another"); + `); + t.true(exports.includes('asdf')); + // Last module.exports = require() wins, clearing previous reexports + t.deepEqual([...reexports], ['./another']); +}); + +test('Simple export with unicode conversions', t => { + t.throws(() => analyze(`export var p𓀀s,q`)); +}); + +test('Simple import', t => { + t.throws(() => + analyze(` + import test from "test"; + console.log(test); + `), + ); +}); + +test('Exported function', t => { + t.throws(() => + analyze(` + export function a𓀀 () { + + } + export class Q{ + + } + `), + ); +}); + +test('Export destructuring', t => { + t.throws(() => + analyze(` + export const { a, b } = foo; + + export { ok }; + `), + ); +}); + +test('Minified import syntax', t => { + t.throws(() => + analyze( + `import{TemplateResult as t}from"lit-html";import{a as e}from"./chunk-4be41b30.js";export{j as SVGTemplateResult,i as TemplateResult,g as html,h as svg}from"./chunk-4be41b30.js";window.JSCompiler_renameProperty='asdf';`, + ), + ); +}); + +test('plus plus division', t => { + t.notThrows(() => + analyze(` + tick++/fetti;f=(1)+")"; + `), + ); +}); + +test('return bracket division', t => { + t.notThrows(() => analyze(`function variance(){return s/(a-1)}`)); +}); + +test('import.meta', t => { + // Contains `export var` which Babel rejects in CJS mode + t.throws(() => + analyze(` + export var hello = 'world'; + console.log(import.meta.url); + `), + ); +}); + +test('import meta edge cases', t => { + // Contains `import.\nmeta` which Babel parses as import.meta in CJS and + // rejects with a syntax error + t.throws(() => + analyze(` + // Import meta + import. + meta + // Not import meta + a. + import. + meta + `), + ); +}); + +test('dynamic import method', t => { + t.notThrows(() => + analyze(` + class A { + import() { + } + } + `), + ); +}); + +test('Bracket matching', t => { + t.notThrows(() => + analyze(` + instance.extend('parseExprAtom', function (nextMethod) { + return function () { + function parseExprAtom(refDestructuringErrors) { + if (this.type === tt._import) { + return parseDynamicImport.call(this); + } + return c(refDestructuringErrors); + } + }(); + }); + `), + ); +}); + +test('Division / Regex ambiguity', t => { + t.notThrows(() => + analyze(` + /as)df/; x(); + a / 2; ' / ' + while (true) + /test'/ + x-/a'/g + try {} + finally{}/a'/g + (x);{f()}/d'export { b }/g + ;{}/e'/g; + {}/f'/g + a / 'b' / c; + /a'/ - /b'/; + +{} /g -'/g' + ('a')/h -'/g' + if //x + ('a')/i'/g; + /asdf/ / /as'df/; // ' + p = \`\${/test/ + 5}\`; + /regex/ / x; + function m() { + return /*asdf8*// 5/; + } + `), + ); +}); + +test('Template string expression ambiguity', t => { + const { exports } = analyze(` + \`$\` + import('a'); + \`\` + exports.a = 'a'; + \`a$b\` + exports['b'] = 'b'; + \`{$}\` + exports['b'].b; + `); + t.true(exports.includes('a')); + t.true(exports.includes('b')); +}); diff --git a/packages/module-source/test/cjs-module-source.test.js b/packages/module-source/test/cjs-module-source.test.js new file mode 100644 index 0000000000..129411e996 --- /dev/null +++ b/packages/module-source/test/cjs-module-source.test.js @@ -0,0 +1,246 @@ +/* eslint-disable no-underscore-dangle */ +import test from '@endo/ses-ava/prepare-endo.js'; +import { CjsModuleSource } from '../src/cjs-module-source.js'; + +test('CjsModuleSource is a frozen constructor', t => { + t.is(typeof CjsModuleSource, 'function'); + t.true(Object.isFrozen(CjsModuleSource)); + t.true(Object.isFrozen(CjsModuleSource.prototype)); +}); + +test('CjsModuleSource requires new', t => { + t.throws(() => CjsModuleSource(''), { instanceOf: TypeError }); +}); + +test('CjsModuleSource rejects import statements', t => { + t.throws(() => new CjsModuleSource(`import 'foo';`), { + instanceOf: SyntaxError, + }); +}); + +test('CjsModuleSource produces a frozen record', t => { + const ms = new CjsModuleSource(`exports.a = 1;`); + t.true(Object.isFrozen(ms)); + t.true(Object.isFrozen(ms.imports)); + t.true(Object.isFrozen(ms.exports)); + t.true(Object.isFrozen(ms.reexports)); +}); + +test('exports.name = value', t => { + const ms = new CjsModuleSource(`exports.meaning = 42;`); + t.true(ms.exports.includes('meaning')); + t.true(ms.exports.includes('default')); +}); + +test('module.exports = function', t => { + const ms = new CjsModuleSource(`module.exports = function () {};`); + t.deepEqual([...ms.exports], ['default']); +}); + +test('module.exports = { a, b }', t => { + const ms = new CjsModuleSource(` + function a() {} + module.exports = {a}; + `); + t.true(ms.exports.includes('a')); + t.true(ms.exports.includes('default')); +}); + +test('module.exports = require reexport', t => { + const ms = new CjsModuleSource(`module.exports = require('./x.js');`); + t.deepEqual([...ms.reexports], ['./x.js']); + t.true(ms.imports.includes('./x.js')); +}); + +test('require specifiers are collected', t => { + const ms = new CjsModuleSource(` + const a = require('foo'); + const b = require('bar'); + `); + t.true(ms.imports.includes('foo')); + t.true(ms.imports.includes('bar')); +}); + +test('cjsFunctor wraps source', t => { + const ms = new CjsModuleSource(`exports.x = 1;`); + t.true(ms.cjsFunctor.startsWith('(function (require, exports, module,')); + t.true(ms.cjsFunctor.includes('exports.x = 1')); +}); + +test('cjsFunctor includes $h_import param when import() is present', t => { + const ms = new CjsModuleSource(`import('foo');`); + t.true(ms.__needsImport__); + t.true(ms.cjsFunctor.includes('$h')); + t.true(ms.imports.includes('foo')); +}); + +test('sourceUrl option adds sourceURL comment', t => { + const ms = new CjsModuleSource(`exports.x = 1;`, { + sourceUrl: 'file:///test.js', + }); + t.true(ms.cjsFunctor.includes('//# sourceURL=file:///test.js')); +}); + +test('string opts treated as sourceUrl', t => { + const ms = new CjsModuleSource(`exports.x = 1;`, 'file:///test.js'); + t.true(ms.cjsFunctor.includes('//# sourceURL=file:///test.js')); +}); + +test('shebang is commented out', t => { + const ms = new CjsModuleSource(`#!/usr/bin/env node\nexports.x = 1;`); + t.true(ms.exports.includes('x')); + t.true(ms.cjsFunctor.includes('//#!/usr/bin/env node')); +}); + +test('module.exports.name = value', t => { + const ms = new CjsModuleSource(`module.exports.asdf = 'asdf';`); + t.true(ms.exports.includes('asdf')); +}); + +test('esbuild hint', t => { + const ms = new CjsModuleSource(` + 0 && (module.exports = {a, b, c}) && __exportStar(require('fs')); + `); + t.true(ms.exports.includes('a')); + t.true(ms.exports.includes('b')); + t.true(ms.exports.includes('c')); + t.true(ms.reexports.includes('fs')); +}); + +test('__needsImport__ is false when no import()', t => { + const ms = new CjsModuleSource(`const x = require('foo');`); + t.false(ms.__needsImport__); +}); + +test('mixed quoted and unquoted destructured identifiers', t => { + const ms = new CjsModuleSource(` + function a() {} + function b() {} + function c() {} + function d() {} + module.exports = {"a": a, 'b': b, c: c, d}; + `); + t.true(ms.exports.includes('a')); + t.true(ms.exports.includes('b')); + t.true(ms.exports.includes('c')); + t.true(ms.exports.includes('d')); +}); + +test('module.exports reassignment clears reexports (last wins)', t => { + const ms = new CjsModuleSource(` + module.exports.asdf = 'asdf'; + exports = 'asdf'; + module.exports = require('./asdf'); + if (maybe) + module.exports = require("./another"); + `); + t.true(ms.exports.includes('asdf')); + t.deepEqual([...ms.reexports], ['./another']); +}); + +test('TypeScript reexport helpers', t => { + const ms = new CjsModuleSource(` + "use strict"; + function __export(m) { + for (const p in m) if (!exports.hasOwnProperty(p)) exports[p] = m[p]; + } + Object.defineProperty(exports, "__esModule", { value: true }); + __export(require("external1")); + tslib.__export(require("external2")); + __exportStar(require("external3")); + tslib1.__exportStar(require("external4")); + `); + t.true(ms.reexports.includes('external1')); + t.true(ms.reexports.includes('external2')); + t.true(ms.reexports.includes('external3')); + t.true(ms.reexports.includes('external4')); +}); + +test('Object.defineProperty with value descriptor', t => { + const ms = new CjsModuleSource(` + Object.defineProperty(exports, 'namedExport', { enumerable: false, value: true }); + Object.defineProperty(module.exports, 'thing', { value: true }); + Object.defineProperty(exports, "other", { enumerable: true, value: true }); + Object.defineProperty(exports, "__esModule", { value: true }); + `); + t.true(ms.exports.includes('namedExport')); + t.true(ms.exports.includes('thing')); + t.true(ms.exports.includes('other')); + t.true(ms.exports.includes('__esModule')); +}); + +test('Object.defineProperty with safe getter', t => { + const ms = new CjsModuleSource(` + Object.defineProperty(exports, 'a', { + enumerable: true, + get: function () { + return q.p; + } + }); + `); + t.true(ms.exports.includes('a')); +}); + +test('Object.defineProperty with unsafe getter is excluded', t => { + const ms = new CjsModuleSource(` + Object.defineProperty(exports, 'd', { + enumerable: true, + get: function () { + return dynamic(); + } + }); + `); + t.false(ms.exports.includes('d')); +}); + +test('exports bracket string assignment', t => { + const ms = new CjsModuleSource(`exports['myExport'] = 42;`); + t.true(ms.exports.includes('myExport')); +}); + +test('non-identifier export names are excluded', t => { + const ms = new CjsModuleSource(` + exports['not identifier'] = 'asdf'; + exports['@notidentifier'] = 'asdf'; + `); + t.false(ms.exports.includes('not identifier')); + t.false(ms.exports.includes('@notidentifier')); +}); + +test('strict reserved words as export names are excluded', t => { + const ms = new CjsModuleSource(`exports.package = 'RESERVED!';`); + t.false(ms.exports.includes('package')); +}); + +test('require in function arguments', t => { + const ms = new CjsModuleSource(` + let Mime = require('./Mime'); + Mime(require('./types/standard'), require('./types/other')); + `); + t.is(ms.imports.length, 3); +}); + +test('non-string require args are ignored', t => { + const ms = new CjsModuleSource(` + require(variable); + require('valid'); + `); + t.deepEqual([...ms.imports], ['valid']); +}); + +test('module.exports = { ...require spread reexport }', t => { + const ms = new CjsModuleSource(` + module.exports = { + ...a, + ...b, + ...require('dep1'), + c: d, + ...require('dep2'), + name + }; + `); + t.true(ms.exports.includes('c')); + t.true(ms.exports.includes('name')); + t.true(ms.reexports.includes('dep1')); + t.true(ms.reexports.includes('dep2')); +}); From edbf51e749f109377674da85ede17f8af29a4b4a Mon Sep 17 00:00:00 2001 From: Christopher Hiller Date: Wed, 29 Apr 2026 17:32:02 -0700 Subject: [PATCH 3/3] feat(compartment-mapper): expose Babel-based CJS parser w/ dynamic import support Expose Babel-based CJS parser, `parse-cjs-babel`. Expose shared functionality for wrapping CJS functors with `__dirname`, `__filename`, etc. Add support for dynamic `import()` (`parse-cjs-babel` only). --- .changeset/famous-seals-pick.md | 5 + packages/compartment-mapper/cjs.js | 4 + packages/compartment-mapper/import-parsers.js | 5 +- packages/compartment-mapper/package.json | 2 + packages/compartment-mapper/src/cjs.js | 79 ++++++++++ .../compartment-mapper/src/import-parsers.js | 13 +- .../compartment-mapper/src/parse-cjs-babel.js | 52 +++++++ .../src/parse-cjs-shared-export-wrapper.js | 19 ++- .../test/cjs-compat.test.js | 141 ++++++++++++------ .../node_modules/dynamic-import/index.js | 3 + .../node_modules/dynamic-import/package.json | 7 + 11 files changed, 280 insertions(+), 50 deletions(-) create mode 100644 .changeset/famous-seals-pick.md create mode 100644 packages/compartment-mapper/cjs.js create mode 100644 packages/compartment-mapper/src/cjs.js create mode 100644 packages/compartment-mapper/src/parse-cjs-babel.js create mode 100644 packages/compartment-mapper/test/fixtures-cjs-compat/node_modules/dynamic-import/index.js create mode 100644 packages/compartment-mapper/test/fixtures-cjs-compat/node_modules/dynamic-import/package.json diff --git a/.changeset/famous-seals-pick.md b/.changeset/famous-seals-pick.md new file mode 100644 index 0000000000..b1013d9e85 --- /dev/null +++ b/.changeset/famous-seals-pick.md @@ -0,0 +1,5 @@ +--- +'@endo/compartment-mapper': minor +--- + +Expose Babel-based CJS parser, `parse-cjs-babel`. Expose shared functionality for wrapping CJS functors with `__dirname`, `__filename`, etc. Add support for dynamic `import()` (`parse-cjs-babel` only). diff --git a/packages/compartment-mapper/cjs.js b/packages/compartment-mapper/cjs.js new file mode 100644 index 0000000000..69b4460693 --- /dev/null +++ b/packages/compartment-mapper/cjs.js @@ -0,0 +1,4 @@ +// eslint-disable-next-line import/export +export * from './src/types-external.js'; + +export * from './src/cjs.js'; diff --git a/packages/compartment-mapper/import-parsers.js b/packages/compartment-mapper/import-parsers.js index 53111ba60a..68df640fa6 100644 --- a/packages/compartment-mapper/import-parsers.js +++ b/packages/compartment-mapper/import-parsers.js @@ -1,4 +1,7 @@ // eslint-disable-next-line import/export -- just types export * from './src/types-external.js'; -export { defaultParserForLanguage } from './src/import-parsers.js'; +export { + defaultParserForLanguage, + parserForLanguageWithCjsBabel, +} from './src/import-parsers.js'; diff --git a/packages/compartment-mapper/package.json b/packages/compartment-mapper/package.json index 07fa5443ba..581831ea86 100644 --- a/packages/compartment-mapper/package.json +++ b/packages/compartment-mapper/package.json @@ -23,6 +23,7 @@ "main": "./index.js", "exports": { ".": "./index.js", + "./cjs.js": "./cjs.js", "./import.js": "./import.js", "./import-lite.js": "./import-lite.js", "./import-parsers.js": "./import-parsers.js", @@ -45,6 +46,7 @@ "./script-lite.js": "./script-lite.js", "./node-powers.js": "./node-powers.js", "./node-modules.js": "./node-modules.js", + "./policy.js": "./policy.js", "./package.json": "./package.json" }, "scripts": { diff --git a/packages/compartment-mapper/src/cjs.js b/packages/compartment-mapper/src/cjs.js new file mode 100644 index 0000000000..7b75d30482 --- /dev/null +++ b/packages/compartment-mapper/src/cjs.js @@ -0,0 +1,79 @@ +/* eslint-disable no-underscore-dangle */ +/** + * Provides {@link buildCjsExecuteRecord}, a function that converts a + * {@link CjsModuleSourceRecord} into a {@link FinalStaticModuleType}. + * + * For use with `@endo/parser-pipeline`'s `createComposedParser()`. + * + * @module + */ + +import { getModulePaths, wrap } from './parse-cjs-shared-export-wrapper.js'; + +/** + * @import {CjsModuleSourceRecord} from '@endo/module-source' + * @import {ReadFn, ReadPowers} from './types.js' + * @import {FinalStaticModuleType} from 'ses' + */ + +const { freeze } = Object; + +/** + * Converts a {@link CjsModuleSourceRecord} (which has a `cjsFunctor` string) + * into a `FinalStaticModuleType`-compatible record (which has an `execute` + * function). This is the bridge between the composed-pipeline CJS analysis and + * the compartment-mapper execution model. + * + * Used by both {@link parseCjsBabel} (single-shot parser) and + * {@link createCjsExecParser} (composed-pipeline parser) so the execution + * logic lives in exactly one place. + * + * @param {CjsModuleSourceRecord} cjsRecord + * @param {string} location + * @param {ReadFn | ReadPowers | undefined} readPowers + * @returns {FinalStaticModuleType} + */ + +export const buildCjsExecuteRecord = (cjsRecord, location, readPowers) => { + const { filename, dirname } = getModulePaths(readPowers, location); + + /** + * @param {object} moduleEnvironmentRecord + * @param {Compartment} compartment + * @param {Record} resolvedImports + */ + const execute = (moduleEnvironmentRecord, compartment, resolvedImports) => { + const functor = compartment.evaluate(cjsRecord.cjsFunctor); + + const wrapResult = wrap({ + moduleEnvironmentRecord, + compartment, + resolvedImports, + location, + readPowers, + }); + + const args = [ + wrapResult.require, + wrapResult.moduleExports, + wrapResult.module, + filename, + dirname, + ]; + + if (cjsRecord.__needsImport__ && wrapResult.importFn) { + args.push(wrapResult.importFn); + } + + functor.call(wrapResult.moduleExports, ...args); + + wrapResult.afterExecute(); + }; + + return freeze({ + imports: cjsRecord.imports, + exports: cjsRecord.exports, + reexports: cjsRecord.reexports, + execute, + }); +}; diff --git a/packages/compartment-mapper/src/import-parsers.js b/packages/compartment-mapper/src/import-parsers.js index 99c5f04cc5..c86ee9865f 100644 --- a/packages/compartment-mapper/src/import-parsers.js +++ b/packages/compartment-mapper/src/import-parsers.js @@ -11,9 +11,12 @@ import parserText from './parse-text.js'; import parserBytes from './parse-bytes.js'; import parserCjs from './parse-cjs.js'; import parserMjs from './parse-mjs.js'; +import parserCjsBabel from './parse-cjs-babel.js'; + +const { freeze } = Object; /** @satisfies {Readonly} */ -export const defaultParserForLanguage = Object.freeze( +export const defaultParserForLanguage = freeze( /** @type {const} */ ({ mjs: parserMjs, cjs: parserCjs, @@ -22,3 +25,11 @@ export const defaultParserForLanguage = Object.freeze( bytes: parserBytes, }), ); + +/** @satisfies {Readonly} */ +export const parserForLanguageWithCjsBabel = freeze( + /** @type {const} */ ({ + ...defaultParserForLanguage, + cjs: parserCjsBabel, + }), +); diff --git a/packages/compartment-mapper/src/parse-cjs-babel.js b/packages/compartment-mapper/src/parse-cjs-babel.js new file mode 100644 index 0000000000..c347e58e04 --- /dev/null +++ b/packages/compartment-mapper/src/parse-cjs-babel.js @@ -0,0 +1,52 @@ +/* eslint-disable no-underscore-dangle */ +/** + * Provides language behavior (parser) for importing CommonJS as a virtual + * module source, using Babel AST analysis instead of the character-level lexer. + * + * Drop-in replacement for {@link parse-cjs.js}. Consumers opt in via the + * pre-built parser map: + * + * ```js + * import { parserForLanguageWithCjsBabel } from '@endo/compartment-mapper/import-parsers.js'; + * + * await importLocation(readPowers, entryUrl, { + * parserForLanguage: parserForLanguageWithCjsBabel, + * }); + * ``` + * + * @module + */ + +/** + * @import {ParseFn, ParserImplementation} from './types.js' + */ + +import { CjsModuleSource } from '@endo/module-source'; +import { buildCjsExecuteRecord } from './cjs.js'; + +const textDecoder = new TextDecoder(); + +/** @type {ParseFn} */ +export const parseCjsBabel = ( + bytes, + _specifier, + location, + _packageLocation, + { readPowers } = {}, +) => { + const source = textDecoder.decode(bytes); + const cjsRecord = new CjsModuleSource(source, { sourceUrl: location }); + + return { + parser: 'cjs', + bytes, + record: buildCjsExecuteRecord(cjsRecord, location, readPowers), + }; +}; + +/** @type {ParserImplementation} */ +export default { + parse: parseCjsBabel, + heuristicImports: true, + synchronous: true, +}; diff --git a/packages/compartment-mapper/src/parse-cjs-shared-export-wrapper.js b/packages/compartment-mapper/src/parse-cjs-shared-export-wrapper.js index 4645241ee1..cc7e852fac 100644 --- a/packages/compartment-mapper/src/parse-cjs-shared-export-wrapper.js +++ b/packages/compartment-mapper/src/parse-cjs-shared-export-wrapper.js @@ -83,10 +83,11 @@ export const getModulePaths = (readPowers, location) => { * @param {string} in.location * @param {ReadFn | ReadPowers | undefined} in.readPowers * @returns {{ - * module: { exports: any }, - * moduleExports: any, - * afterExecute: Function, - * require: Function, + * module: { exports: unknown }, + * moduleExports: unknown, + * afterExecute: () => void, + * require: (specifier: string) => unknown, + * importFn: (specifier: string) => Promise, * }} */ export const wrap = ({ @@ -204,6 +205,15 @@ export const wrap = ({ freeze(require); + /** @param {string} importSpecifier */ + const importFn = async importSpecifier => { + const specifier = has(resolvedImports, importSpecifier) + ? resolvedImports[importSpecifier] + : importSpecifier; + return compartment.import(specifier); + }; + freeze(importFn); + const afterExecute = () => { const finalExports = module.exports; // in case it's a getter, only call it once const exportsHaveBeenOverwritten = finalExports !== originalExports; @@ -231,5 +241,6 @@ export const wrap = ({ moduleExports: originalExports, afterExecute, require, + importFn, }; }; diff --git a/packages/compartment-mapper/test/cjs-compat.test.js b/packages/compartment-mapper/test/cjs-compat.test.js index 26420a88f7..85e7ce3a39 100644 --- a/packages/compartment-mapper/test/cjs-compat.test.js +++ b/packages/compartment-mapper/test/cjs-compat.test.js @@ -6,9 +6,14 @@ import 'ses'; import test from 'ava'; import path from 'path'; import { scaffold } from './scaffold.js'; +import { + defaultParserForLanguage, + parserForLanguageWithCjsBabel, +} from '../src/import-parsers.js'; /** * @import {FixtureAssertionFn} from './test.types.js'; + * @import {ThirdPartyStaticModuleInterface} from 'ses' */ const fixture = new URL( @@ -19,9 +24,13 @@ const fixtureDirname = new URL( 'fixtures-cjs-compat/node_modules/app/dirname.js', import.meta.url, ).toString(); +const fixtureDynamicImport = new URL( + 'fixtures-cjs-compat/node_modules/dynamic-import/index.js', + import.meta.url, +).toString(); const q = JSON.stringify; - +const { freeze } = Object; /** * @type {FixtureAssertionFn<{requireResolvePaths: string[]}>} */ @@ -70,50 +79,94 @@ const assertFixture = (t, { namespace, testCategoryHint }) => { const fixtureAssertionCount = 2; -scaffold( - 'fixtures-cjs-compat', - test, - fixture, - assertFixture, - fixtureAssertionCount, -); +const parsersForLanguage = { + default: defaultParserForLanguage, + babel: parserForLanguageWithCjsBabel, +}; + +for (const [name, parserForLanguage] of Object.entries(parsersForLanguage)) { + scaffold( + `fixtures-cjs-compat-${name}`, + test, + fixture, + assertFixture, + fixtureAssertionCount, + { + parserForLanguage, + }, + ); -// Exit module errors are also deferred -scaffold( - 'fixtures-cjs-compat-exit-module', - test, - fixture, - assertFixture, - fixtureAssertionCount, - { - additionalOptions: { - importHook: async specifier => { - throw Error(`${q(specifier)} is NOT an exit module.`); + // Exit module errors are also deferred + scaffold( + `fixtures-cjs-compat-exit-module-${name}`, + test, + fixture, + assertFixture, + fixtureAssertionCount, + { + additionalOptions: { + importHook: async specifier => { + throw Error(`${q(specifier)} is NOT an exit module.`); + }, }, + parserForLanguage, }, - }, -); + ); -scaffold( - 'fixtures-cjs-compat-__dirname', - test, - fixtureDirname, - (t, { namespace, testCategoryHint }) => { - if (testCategoryHint === 'Location') { - const { __filename, __dirname } = namespace; - t.is(__filename, path.join(__dirname, '/dirname.js')); - t.assert(!__dirname.startsWith('file://')); - t.notRegex( - __dirname, - /[\\/]$/, - 'Expected __dirname to NOT have a trailing slash', - ); - } else { - const { __filename, __dirname } = namespace; - t.is(__dirname, null); - t.is(__filename, null); - t.pass(); - } - }, - 3, -); + scaffold( + `fixtures-cjs-compat-__dirname-${name}`, + test, + fixtureDirname, + (t, { namespace, testCategoryHint }) => { + if (testCategoryHint === 'Location') { + const { __filename, __dirname } = namespace; + t.is(__filename, path.join(__dirname, '/dirname.js')); + t.assert(!__dirname.startsWith('file://')); + t.notRegex( + __dirname, + /[\\/]$/, + 'Expected __dirname to NOT have a trailing slash', + ); + } else { + const { __filename, __dirname } = namespace; + t.is(__dirname, null); + t.is(__filename, null); + t.pass(); + } + }, + 3, + { + parserForLanguage, + }, + ); + + scaffold( + `fixtures-cjs-compat-dynamic-import-${name}`, + test, + fixtureDynamicImport, + async (t, { namespace }) => { + const { namespace: dynamicNamespace } = + // @ts-expect-error - untyped + await namespace.dynamicImport('a'); + t.is(dynamicNamespace.foo, 'foo'); + }, + 1, + { + // NOTE: this should fail with parse-cjs, but not parse-cjs-babel + knownFailure: name === 'default', + parserForLanguage, + additionalOptions: { + importHook: async () => { + /** @type {ThirdPartyStaticModuleInterface} */ + return freeze({ + imports: [], + exports: ['foo'], + execute: moduleExports => { + moduleExports.foo = 'foo'; + }, + }); + }, + }, + }, + ); +} diff --git a/packages/compartment-mapper/test/fixtures-cjs-compat/node_modules/dynamic-import/index.js b/packages/compartment-mapper/test/fixtures-cjs-compat/node_modules/dynamic-import/index.js new file mode 100644 index 0000000000..2b9b0ad095 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cjs-compat/node_modules/dynamic-import/index.js @@ -0,0 +1,3 @@ +module.exports.dynamicImport = async () => { + return import('node:fs'); +}; diff --git a/packages/compartment-mapper/test/fixtures-cjs-compat/node_modules/dynamic-import/package.json b/packages/compartment-mapper/test/fixtures-cjs-compat/node_modules/dynamic-import/package.json new file mode 100644 index 0000000000..30461633b4 --- /dev/null +++ b/packages/compartment-mapper/test/fixtures-cjs-compat/node_modules/dynamic-import/package.json @@ -0,0 +1,7 @@ +{ + "name": "dynamic-import", + "main": "./index.js", + "scripts": { + "preinstall": "echo DO NOT INSTALL TEST FIXTURES; exit -1" + } +}