From d46cdc3db06c685f9226b76b34dcede15dc39861 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 09:36:39 -0600 Subject: [PATCH 1/7] chore: set up cjs exports types and table, refactor utils. --- package-lock.json | 1 - package.json | 2 +- src/format.ts | 4 +- src/module.ts | 2 +- src/types.ts | 8 ++ src/utils.ts | 90 ++++++++++++++++- src/utils/exports.ts | 96 ++++++++++++++++++ src/utils/identifiers.ts | 210 +++++++++++++++++++++++++++++++++++++++ src/utils/lang.ts | 26 +++++ src/utils/url.ts | 11 ++ 10 files changed, 445 insertions(+), 5 deletions(-) create mode 100644 src/utils/exports.ts create mode 100644 src/utils/identifiers.ts create mode 100644 src/utils/lang.ts create mode 100644 src/utils/url.ts diff --git a/package-lock.json b/package-lock.json index 0f9fd38..13044de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5251,7 +5251,6 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" diff --git a/package.json b/package.json index e18e427..87b775d 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "imports": { "#parse": "./src/parse.js", "#format": "./src/format.js", - "#utils": "./src/utils.js", + "#utils/*": "./src/utils/*.js", "#walk": "./src/walk.js", "#helpers/*": "./src/helpers/*.js", "#formatters/*": "./src/formatters/*.js" diff --git a/src/format.ts b/src/format.ts index 41f5f44..73011ce 100644 --- a/src/format.ts +++ b/src/format.ts @@ -6,7 +6,9 @@ import { identifier } from './formatters/identifier.js' import { metaProperty } from './formatters/metaProperty.js' import { memberExpression } from './formatters/memberExpression.js' import { assignmentExpression } from './formatters/assignmentExpression.js' -import { isValidUrl, exportsRename, collectModuleIdentifiers } from './utils.js' +import { isValidUrl } from './utils/url.js' +import { exportsRename } from './utils/exports.js' +import { collectModuleIdentifiers } from './utils/identifiers.js' import { isIdentifierName } from './helpers/identifier.js' import { ancestorWalk } from './walk.js' diff --git a/src/module.ts b/src/module.ts index 7bfbb42..3cf4b61 100644 --- a/src/module.ts +++ b/src/module.ts @@ -5,7 +5,7 @@ import { specifier } from '@knighted/specifier' import { parse } from './parse.js' import { format } from './format.js' -import { getLangFromExt } from './utils.js' +import { getLangFromExt } from './utils/lang.js' import type { ModuleOptions } from './types.js' const defaultOptions = { diff --git a/src/types.ts b/src/types.ts index 0a7a551..f4e586a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -41,6 +41,14 @@ export type ExportsMeta = { defaultExportValue: unknown } +export type CjsExport = { + key: string + writes: SpannedNode[] + fromIdentifier?: string + via: Set<'exports' | 'module.exports'> + reassignments: SpannedNode[] +} + export type IdentMeta = { /* `var` can be redeclared in the same scope. diff --git a/src/utils.ts b/src/utils.ts index 6bc7b45..78ec425 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -4,7 +4,7 @@ import { ancestorWalk } from './walk.js' import type { Node } from 'oxc-parser' import type { Specifier } from '@knighted/specifier' -import type { IdentMeta, SpannedNode, Scope } from './types.js' +import type { IdentMeta, SpannedNode, Scope, CjsExport } from './types.js' import { scopes as scopeNodes } from './helpers/scope.js' import { identifier } from './helpers/identifier.js' @@ -39,6 +39,93 @@ const isValidUrl = (url: string) => { const exportsRename = '__exports' const requireMainRgx = /(require\.main\s*===\s*module|module\s*===\s*require\.main)/g +const resolveExportTarget = (node: Node) => { + if (node.type !== 'MemberExpression') return null + + const base = node.object + const prop = node.property + + if (prop.type !== 'Identifier') return null + + if (base.type === 'Identifier' && base.name === 'exports') { + return { key: prop.name, via: 'exports' as const } + } + + if ( + base.type === 'MemberExpression' && + base.object.type === 'Identifier' && + base.object.name === 'module' && + base.property.type === 'Identifier' && + base.property.name === 'exports' + ) { + return { key: prop.name, via: 'module.exports' as const } + } + + if ( + base.type === 'Identifier' && + base.name === 'module' && + prop.type === 'Identifier' && + prop.name === 'exports' + ) { + return { key: 'default', via: 'module.exports' as const } + } + + return null +} + +const collectCjsExports = async (ast: Node) => { + const exportsMap = new Map() + const localToExport = new Map>() + + await ancestorWalk(ast, { + enter(node) { + if (node.type === 'AssignmentExpression') { + const target = resolveExportTarget(node.left) + + if (target) { + const entry = exportsMap.get(target.key) ?? { + key: target.key, + writes: [], + via: new Set(), + reassignments: [], + } + + entry.via.add(target.via) + entry.writes.push(node) + + if (node.right.type === 'Identifier') { + entry.fromIdentifier ??= node.right.name + if (entry.fromIdentifier) { + const set = localToExport.get(entry.fromIdentifier) ?? new Set() + set.add(target.key) + localToExport.set(entry.fromIdentifier, set) + } + } + + exportsMap.set(target.key, entry) + return + } + + if (node.left.type === 'Identifier') { + const keys = localToExport.get(node.left.name) + + if (keys) { + keys.forEach(key => { + const entry = exportsMap.get(key) + if (entry) { + entry.reassignments.push(node) + exportsMap.set(key, entry) + } + }) + } + } + } + }, + }) + + return exportsMap +} + const collectScopeIdentifiers = (node: Node, scopes: Scope[]) => { const { type } = node @@ -246,6 +333,7 @@ export { isValidUrl, collectScopeIdentifiers, collectModuleIdentifiers, + collectCjsExports, exportsRename, requireMainRgx, } diff --git a/src/utils/exports.ts b/src/utils/exports.ts new file mode 100644 index 0000000..2000646 --- /dev/null +++ b/src/utils/exports.ts @@ -0,0 +1,96 @@ +import type { Node } from 'oxc-parser' + +import type { CjsExport } from '../types.js' +import { ancestorWalk } from '../walk.js' + +const exportsRename = '__exports' +const requireMainRgx = /(require\.main\s*===\s*module|module\s*===\s*require\.main)/g + +const resolveExportTarget = (node: Node) => { + if (node.type !== 'MemberExpression') return null + + const base = node.object + const prop = node.property + + if (prop.type !== 'Identifier') return null + + if (base.type === 'Identifier' && base.name === 'exports') { + return { key: prop.name, via: 'exports' as const } + } + + if ( + base.type === 'MemberExpression' && + base.object.type === 'Identifier' && + base.object.name === 'module' && + base.property.type === 'Identifier' && + base.property.name === 'exports' + ) { + return { key: prop.name, via: 'module.exports' as const } + } + + if ( + base.type === 'Identifier' && + base.name === 'module' && + prop.type === 'Identifier' && + prop.name === 'exports' + ) { + return { key: 'default', via: 'module.exports' as const } + } + + return null +} + +const collectCjsExports = async (ast: Node) => { + const exportsMap = new Map() + const localToExport = new Map>() + + await ancestorWalk(ast, { + enter(node) { + if (node.type === 'AssignmentExpression') { + const target = resolveExportTarget(node.left) + + if (target) { + const entry = exportsMap.get(target.key) ?? { + key: target.key, + writes: [], + via: new Set(), + reassignments: [], + } + + entry.via.add(target.via) + entry.writes.push(node) + + if (node.right.type === 'Identifier') { + entry.fromIdentifier ??= node.right.name + if (entry.fromIdentifier) { + const set = localToExport.get(entry.fromIdentifier) ?? new Set() + set.add(target.key) + localToExport.set(entry.fromIdentifier, set) + } + } + + exportsMap.set(target.key, entry) + return + } + + if (node.left.type === 'Identifier') { + const keys = localToExport.get(node.left.name) + + if (keys) { + keys.forEach(key => { + const entry = exportsMap.get(key) + if (entry) { + entry.reassignments.push(node) + exportsMap.set(key, entry) + } + }) + } + } + } + }, + }) + + return exportsMap +} + +export { exportsRename, requireMainRgx, collectCjsExports } diff --git a/src/utils/identifiers.ts b/src/utils/identifiers.ts new file mode 100644 index 0000000..102c0f5 --- /dev/null +++ b/src/utils/identifiers.ts @@ -0,0 +1,210 @@ +import type { Node } from 'oxc-parser' + +import type { IdentMeta, SpannedNode, Scope } from '../types.js' +import { ancestorWalk } from '../walk.js' +import { scopes as scopeNodes } from '../helpers/scope.js' +import { identifier } from '../helpers/identifier.js' + +const collectScopeIdentifiers = (node: Node, scopes: Scope[]) => { + const { type } = node + + switch (type) { + case 'BlockStatement': + case 'ClassBody': + scopes.push({ node, type: 'Block', name: type, idents: new Set() }) + break + case 'FunctionDeclaration': + case 'FunctionExpression': + case 'ArrowFunctionExpression': + { + const name = node.id ? node.id.name : 'anonymous' + const scope = { node, name, type: 'Function', idents: new Set() } + + node.params + .map(param => { + if (param.type === 'TSParameterProperty') { + return param.parameter + } + + if (param.type === 'RestElement') { + return param.argument + } + + if (param.type === 'AssignmentPattern') { + return param.left + } + + return param + }) + .filter(identifier.isNamed) + .forEach(param => { + scope.idents.add(param.name) + }) + + /** + * If a FunctionExpression has an id, it is a named function expression. + * The function expression name shadows the module scope identifier, so + * we don't want to count reads of module identifers that have the same name. + * They also do not cause a SyntaxError if the function expression name is + * the same as a module scope identifier. + * + * TODO: Is this necessary for FunctionDeclaration? + */ + if (node.type === 'FunctionExpression' && node.id) { + scope.idents.add(node.id.name) + } + + // First add the function to any previous scopes + if (scopes.length > 0) { + scopes[scopes.length - 1].idents.add(name) + } + + // Then add the function scope to the scopes stack + scopes.push(scope) + } + break + case 'ClassDeclaration': + { + const className = node.id ? node.id.name : 'anonymous' + + // First add the class to any previous scopes + if (scopes.length > 0) { + scopes[scopes.length - 1].idents.add(className) + } + + // Then add the class to the scopes stack + scopes.push({ node, name: className, type: 'Class', idents: new Set() }) + } + break + case 'ClassExpression': + { + } + break + case 'VariableDeclaration': + if (scopes.length > 0) { + const scope = scopes[scopes.length - 1] + + node.declarations.forEach(decl => { + if (decl.type === 'VariableDeclarator' && decl.id.type === 'Identifier') { + scope.idents.add(decl.id.name) + } + }) + } + break + } +} + +/** + * Collects all module scope identifiers in the AST. + * + * Ignores identifiers that are in functions or classes. + * Ignores new scopes for StaticBlock nodes (can only reference static class members). + * + * Special case handling for these which create their own scopes, + * but are also valid module scope identifiers: + * - ClassDeclaration + * - FunctionDeclaration + * + * Special case handling for var inside BlockStatement + * which are also valid module scope identifiers. + */ +const collectModuleIdentifiers = async (ast: Node, hoisting: boolean = true) => { + const identifiers = new Map() + const globalReads = new Map() + const scopes: Scope[] = [] + + await ancestorWalk(ast, { + enter(node, ancestors) { + const { type } = node + + collectScopeIdentifiers(node, scopes) + + // Add module scope identifiers to the registry map + + if (type === 'Identifier') { + const { name } = node + const meta = identifiers.get(name) ?? { declare: [], read: [] } + const isDeclaration = identifier.isDeclaration(ancestors) + const inScope = scopes.some( + scope => scope.idents.has(name) || scope.name === name, + ) + + if ( + hoisting && + !identifier.isDeclaration(ancestors) && + !identifier.isFunctionExpressionId(ancestors) && + !identifier.isExportSpecifierAlias(ancestors) && + !identifier.isClassPropertyKey(ancestors) && + !identifier.isMethodDefinitionKey(ancestors) && + !identifier.isMemberKey(ancestors) && + !identifier.isPropertyKey(ancestors) && + !identifier.isIife(ancestors) && + !inScope + ) { + if (globalReads.has(name)) { + globalReads.get(name)?.push(node) + } else { + globalReads.set(name, [node]) + } + } + + if (isDeclaration) { + const isModuleScope = identifier.isModuleScope(ancestors) + const isClassOrFuncDeclaration = + identifier.isClassOrFuncDeclarationId(ancestors) + const isVarDeclarationInGlobalScope = + identifier.isVarDeclarationInGlobalScope(ancestors) + + if ( + isModuleScope || + isClassOrFuncDeclaration || + isVarDeclarationInGlobalScope + ) { + meta.declare.push(node) + + // Check for hoisted reads + if (hoisting && globalReads.has(name)) { + const reads = globalReads.get(name) + + if (reads) { + reads.forEach(read => { + if (!meta.read.includes(read)) { + meta.read.push(read) + } + }) + } + } + + identifiers.set(name, meta) + } + } else { + if ( + identifiers.has(name) && + !inScope && + !identifier.isIife(ancestors) && + !identifier.isFunctionExpressionId(ancestors) && + !identifier.isExportSpecifierAlias(ancestors) && + !identifier.isClassPropertyKey(ancestors) && + !identifier.isMethodDefinitionKey(ancestors) && + !identifier.isMemberKey(ancestors) && + !identifier.isPropertyKey(ancestors) + ) { + // Closure is referencing module scope identifier + meta.read.push(node) + } + } + } + }, + leave(node) { + const { type } = node + + if (scopeNodes.includes(type)) { + scopes.pop() + } + }, + }) + + return identifiers +} + +export { collectScopeIdentifiers, collectModuleIdentifiers } diff --git a/src/utils/lang.ts b/src/utils/lang.ts new file mode 100644 index 0000000..577c59e --- /dev/null +++ b/src/utils/lang.ts @@ -0,0 +1,26 @@ +import { extname } from 'node:path' +import type { Specifier } from '@knighted/specifier' + +// Determine language from filename extension for specifier rewrite. +type UpdateSrcLang = Parameters[1] +const getLangFromExt = (filename: string): UpdateSrcLang => { + const ext = extname(filename) + + if (ext.endsWith('.js')) { + return 'js' + } + + if (ext.endsWith('.ts')) { + return 'ts' + } + + if (ext === '.tsx') { + return 'tsx' + } + + if (ext === '.jsx') { + return 'jsx' + } +} + +export { getLangFromExt } diff --git a/src/utils/url.ts b/src/utils/url.ts new file mode 100644 index 0000000..daaa77f --- /dev/null +++ b/src/utils/url.ts @@ -0,0 +1,11 @@ +// URL validation used when rewriting import.meta.url assignments. +const isValidUrl = (url: string) => { + try { + new URL(url) + return true + } catch { + return false + } +} + +export { isValidUrl } From fd7372b120f6a557e8e04d1ab611bc8c46b3044c Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 09:48:55 -0600 Subject: [PATCH 2/7] refactor: package.json imports. --- package.json | 6 +++--- src/format.ts | 18 +++++++++--------- src/formatters/assignmentExpression.ts | 2 +- src/formatters/identifier.ts | 4 ++-- src/formatters/memberExpression.ts | 2 +- src/module.ts | 6 +++--- src/utils/exports.ts | 2 +- src/utils/identifiers.ts | 6 +++--- test/helpers/identifier/isModuleScope.ts | 2 +- test/utils.ts | 4 ++-- tsconfig.json | 2 ++ tsconfig.test.json | 1 - 12 files changed, 28 insertions(+), 27 deletions(-) diff --git a/package.json b/package.json index 87b775d..ec25a6a 100644 --- a/package.json +++ b/package.json @@ -21,10 +21,10 @@ "imports": { "#parse": "./src/parse.js", "#format": "./src/format.js", - "#utils/*": "./src/utils/*.js", + "#utils/*.js": "./src/utils/*.js", "#walk": "./src/walk.js", - "#helpers/*": "./src/helpers/*.js", - "#formatters/*": "./src/formatters/*.js" + "#helpers/*.js": "./src/helpers/*.js", + "#formatters/*.js": "./src/formatters/*.js" }, "engines": { "node": ">=20.11.0" diff --git a/src/format.ts b/src/format.ts index 73011ce..d6d9b8f 100644 --- a/src/format.ts +++ b/src/format.ts @@ -2,15 +2,15 @@ import type { ParseResult } from 'oxc-parser' import type { FormatterOptions, ExportsMeta } from './types.js' import MagicString from 'magic-string' -import { identifier } from './formatters/identifier.js' -import { metaProperty } from './formatters/metaProperty.js' -import { memberExpression } from './formatters/memberExpression.js' -import { assignmentExpression } from './formatters/assignmentExpression.js' -import { isValidUrl } from './utils/url.js' -import { exportsRename } from './utils/exports.js' -import { collectModuleIdentifiers } from './utils/identifiers.js' -import { isIdentifierName } from './helpers/identifier.js' -import { ancestorWalk } from './walk.js' +import { identifier } from '#formatters/identifier.js' +import { metaProperty } from '#formatters/metaProperty.js' +import { memberExpression } from '#formatters/memberExpression.js' +import { assignmentExpression } from '#formatters/assignmentExpression.js' +import { isValidUrl } from '#utils/url.js' +import { exportsRename } from '#utils/exports.js' +import { collectModuleIdentifiers } from '#utils/identifiers.js' +import { isIdentifierName } from '#helpers/identifier.js' +import { ancestorWalk } from '#walk' /** * Note, there is no specific conversion for `import.meta.main` as it does not exist. diff --git a/src/formatters/assignmentExpression.ts b/src/formatters/assignmentExpression.ts index 65c33a9..390ac54 100644 --- a/src/formatters/assignmentExpression.ts +++ b/src/formatters/assignmentExpression.ts @@ -1,6 +1,6 @@ import MagicString from 'magic-string' import type { Node, AssignmentExpression } from 'oxc-parser' -import { walk } from '../walk.js' +import { walk } from '#walk' import type { FormatterOptions, ExportsMeta } from '../types.js' diff --git a/src/formatters/identifier.ts b/src/formatters/identifier.ts index fb59679..0b79479 100644 --- a/src/formatters/identifier.ts +++ b/src/formatters/identifier.ts @@ -2,8 +2,8 @@ import MagicString from 'magic-string' import type { Node, IdentifierName } from 'oxc-parser' import type { FormatterOptions, ExportsMeta } from '../types.js' -import { exportsRename } from '../utils.js' -import { identifier as ident } from '../helpers/identifier.js' +import { exportsRename } from '#utils/exports.js' +import { identifier as ident } from '#helpers/identifier.js' type IdentifierArg = { node: IdentifierName diff --git a/src/formatters/memberExpression.ts b/src/formatters/memberExpression.ts index c2908e8..d14c86b 100644 --- a/src/formatters/memberExpression.ts +++ b/src/formatters/memberExpression.ts @@ -2,7 +2,7 @@ import MagicString from 'magic-string' import type { MemberExpression, Node } from 'oxc-parser' import type { FormatterOptions } from '../types.js' -import { exportsRename } from '../utils.js' +import { exportsRename } from '#utils/exports.js' export const memberExpression = ( node: MemberExpression, diff --git a/src/module.ts b/src/module.ts index 3cf4b61..7e882bd 100644 --- a/src/module.ts +++ b/src/module.ts @@ -3,9 +3,9 @@ import { readFile, writeFile } from 'node:fs/promises' import { specifier } from '@knighted/specifier' -import { parse } from './parse.js' -import { format } from './format.js' -import { getLangFromExt } from './utils/lang.js' +import { parse } from '#parse' +import { format } from '#format' +import { getLangFromExt } from '#utils/lang.js' import type { ModuleOptions } from './types.js' const defaultOptions = { diff --git a/src/utils/exports.ts b/src/utils/exports.ts index 2000646..c0391a8 100644 --- a/src/utils/exports.ts +++ b/src/utils/exports.ts @@ -1,7 +1,7 @@ import type { Node } from 'oxc-parser' import type { CjsExport } from '../types.js' -import { ancestorWalk } from '../walk.js' +import { ancestorWalk } from '#walk' const exportsRename = '__exports' const requireMainRgx = /(require\.main\s*===\s*module|module\s*===\s*require\.main)/g diff --git a/src/utils/identifiers.ts b/src/utils/identifiers.ts index 102c0f5..523c8d0 100644 --- a/src/utils/identifiers.ts +++ b/src/utils/identifiers.ts @@ -1,9 +1,9 @@ import type { Node } from 'oxc-parser' import type { IdentMeta, SpannedNode, Scope } from '../types.js' -import { ancestorWalk } from '../walk.js' -import { scopes as scopeNodes } from '../helpers/scope.js' -import { identifier } from '../helpers/identifier.js' +import { ancestorWalk } from '#walk' +import { scopes as scopeNodes } from '#helpers/scope.js' +import { identifier } from '#helpers/identifier.js' const collectScopeIdentifiers = (node: Node, scopes: Scope[]) => { const { type } = node diff --git a/test/helpers/identifier/isModuleScope.ts b/test/helpers/identifier/isModuleScope.ts index fc5f085..71be92f 100644 --- a/test/helpers/identifier/isModuleScope.ts +++ b/test/helpers/identifier/isModuleScope.ts @@ -4,7 +4,7 @@ import assert from 'node:assert/strict' import { ancestorWalk } from '#walk' import { parse } from '#parse' -import { identifier } from '#helpers/identifier' +import { identifier } from '#helpers/identifier.js' const { isModuleScope } = identifier diff --git a/test/utils.ts b/test/utils.ts index 6345b27..afb369d 100644 --- a/test/utils.ts +++ b/test/utils.ts @@ -4,8 +4,8 @@ import { resolve, join } from 'node:path' import { readFile } from 'node:fs/promises' import { spawnSync } from 'node:child_process' -import { parse } from '../src/parse.js' -import { collectModuleIdentifiers } from '../src/utils.js' +import { parse } from '#parse' +import { collectModuleIdentifiers } from '#utils/identifiers.js' // Use fixtures to more easily track character offsets and line numbers in test cases. const fixtures = resolve(import.meta.dirname, 'fixtures') diff --git a/tsconfig.json b/tsconfig.json index 5c47cf0..76f9bd1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,6 +1,8 @@ { "compilerOptions": { + "rootDir": ".", "module": "NodeNext", + "moduleResolution": "NodeNext", "declaration": true, "outDir": "dist", "emitDeclarationOnly": true, diff --git a/tsconfig.test.json b/tsconfig.test.json index 029ab97..02021a7 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -1,7 +1,6 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "rootDir": ".", "noEmit": true, "types": ["node"] }, From bb25c049004e882f8f1e8aa14faa0b2e935a8a22 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 10:24:43 -0600 Subject: [PATCH 3/7] feat: strengthen cjs to esm exports. --- docs/cjs-exports.md | 25 +++ src/format.ts | 48 +++++- src/utils/exports.ts | 205 ++++++++++++++++++----- test/fixtures/exportsAlias.cjs | 10 ++ test/fixtures/exportsAliasChain.cjs | 7 + test/fixtures/exportsAssign.cjs | 6 + test/fixtures/exportsAugment.cjs | 6 + test/fixtures/exportsComputed.cjs | 8 + test/fixtures/exportsDestructure.cjs | 7 + test/fixtures/exportsDynamicComputed.cjs | 6 + test/fixtures/exportsObjectAssign.cjs | 4 + test/module.ts | 95 +++++++++++ 12 files changed, 386 insertions(+), 41 deletions(-) create mode 100644 docs/cjs-exports.md create mode 100644 test/fixtures/exportsAlias.cjs create mode 100644 test/fixtures/exportsAliasChain.cjs create mode 100644 test/fixtures/exportsAssign.cjs create mode 100644 test/fixtures/exportsAugment.cjs create mode 100644 test/fixtures/exportsComputed.cjs create mode 100644 test/fixtures/exportsDestructure.cjs create mode 100644 test/fixtures/exportsDynamicComputed.cjs create mode 100644 test/fixtures/exportsObjectAssign.cjs diff --git a/docs/cjs-exports.md b/docs/cjs-exports.md new file mode 100644 index 0000000..d6260ce --- /dev/null +++ b/docs/cjs-exports.md @@ -0,0 +1,25 @@ +# CommonJS export discovery + +## Supported patterns + +- `module.exports = value` → captured as default; if assigned an identifier, we re-use it, otherwise we export the `__exports` bag. Overwrite-then-augment flows like `module.exports = fn; module.exports.extra = 1` are supported. +- Property writes: `exports.foo = x`, `module.exports.bar = y`, and computed literals like `exports['foo']`, `exports[42]`, `module.exports[`template`]`. +- Aliases to the export bag: `const e = exports; e.foo = 1`, `const m = module.exports; m.bar = 2` (alias chains are tracked). +- Destructuring to properties: `({ value: exports.foo } = obj)`. +- `Object.assign(exports, { foo, bar: baz })` and `Object.assign(module.exports, { ... })` with literal keys. + +## Ignored or intentionally excluded + +- Non-literal computed keys (e.g., `exports[key()] = x`) are ignored for static export emission; they may still exist on the runtime bag but no ESM binding is generated. +- Patterns that do not resolve to `exports`/`module.exports` or aliases are ignored. + +## Emission rules + +- We rename the CJS surface to `__exports` and emit ESM bindings from the collected table. +- Default export: `module.exports = id` emits `export default id`; otherwise `export default __exports`. +- Named exports reuse identifiers when available; otherwise we create a temporary binding that reads from `__exports` (using bracket access for non-identifier names). + +## Testing strategy + +- Fixtures cover each supported pattern: computed literals, alias chains, destructuring, Object.assign fan-out, and overwrite-then-augment sequences. Dynamic non-literal keys have a fixture to verify they remain un-exported. +- Each transformed fixture is executed with Node before assertions to catch runtime/syntax issues, then its exports are asserted via ESM import. diff --git a/src/format.ts b/src/format.ts index d6d9b8f..3d77552 100644 --- a/src/format.ts +++ b/src/format.ts @@ -7,14 +7,15 @@ import { metaProperty } from '#formatters/metaProperty.js' import { memberExpression } from '#formatters/memberExpression.js' import { assignmentExpression } from '#formatters/assignmentExpression.js' import { isValidUrl } from '#utils/url.js' -import { exportsRename } from '#utils/exports.js' +import { exportsRename, collectCjsExports } from '#utils/exports.js' import { collectModuleIdentifiers } from '#utils/identifiers.js' import { isIdentifierName } from '#helpers/identifier.js' import { ancestorWalk } from '#walk' /** - * Note, there is no specific conversion for `import.meta.main` as it does not exist. - * @see https://github.com/nodejs/node/issues/49440 + * Node added support for import.meta.main. + * Added in: v24.2.0, v22.18.0 + * @see https://nodejs.org/api/esm.html#importmetamain */ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) => { const code = new MagicString(src) @@ -24,6 +25,8 @@ const format = async (src: string, ast: ParseResult, opts: FormatterOptions) => hasDefaultExportBeenReassigned: false, hasDefaultExportBeenAssigned: false, } satisfies ExportsMeta + const exportTable = + opts.target === 'module' ? await collectCjsExports(ast.program) : null await collectModuleIdentifiers(ast.program) if (opts.target === 'module' && opts.transformSyntax) { @@ -146,6 +149,45 @@ void import.meta.filename; }, }) + if (opts.target === 'module' && opts.transformSyntax && exportTable) { + const isValidExportName = (name: string) => /^[$A-Z_a-z][$\w]*$/.test(name) + const asExportName = (name: string) => + isValidExportName(name) ? name : JSON.stringify(name) + const accessProp = (name: string) => + isValidExportName(name) + ? `${exportsRename}.${name}` + : `${exportsRename}[${JSON.stringify(name)}]` + const tempNameFor = (name: string) => { + const sanitized = name.replace(/[^$\w]/g, '_') || 'value' + const safe = /^[0-9]/.test(sanitized) ? `_${sanitized}` : sanitized + return `__export_${safe}` + } + + const lines: string[] = [] + + const defaultEntry = exportTable.get('default') + if (defaultEntry) { + const def = defaultEntry.fromIdentifier ?? exportsRename + lines.push(`export default ${def};`) + } + + for (const [key, entry] of exportTable) { + if (key === 'default') continue + + if (entry.fromIdentifier) { + lines.push(`export { ${entry.fromIdentifier} as ${asExportName(key)} };`) + } else { + const temp = tempNameFor(key) + lines.push(`const ${temp} = ${accessProp(key)};`) + lines.push(`export { ${temp} as ${asExportName(key)} };`) + } + } + + if (lines.length) { + code.append(`\n${lines.join('\n')}\n`) + } + } + return code.toString() } diff --git a/src/utils/exports.ts b/src/utils/exports.ts index c0391a8..291402c 100644 --- a/src/utils/exports.ts +++ b/src/utils/exports.ts @@ -6,35 +6,84 @@ import { ancestorWalk } from '#walk' const exportsRename = '__exports' const requireMainRgx = /(require\.main\s*===\s*module|module\s*===\s*require\.main)/g -const resolveExportTarget = (node: Node) => { +const literalPropName = ( + prop: Node, + literals?: Map, +): string | null => { + if (prop.type === 'Identifier') return literals?.get(prop.name)?.toString() ?? prop.name + if ( + prop.type === 'Literal' && + (typeof prop.value === 'string' || typeof prop.value === 'number') + ) { + return String(prop.value) + } + if ( + prop.type === 'TemplateLiteral' && + prop.expressions.length === 0 && + prop.quasis.length === 1 + ) { + return prop.quasis[0].value.cooked ?? prop.quasis[0].value.raw + } + return null +} + +type ExportRef = { key: string; via: 'exports' | 'module.exports' } +type SimpleIdentifier = { type: 'Identifier'; name: string } + +const resolveExportTarget = ( + node: Node, + aliases: Map, + literals?: Map, +) => { + if (node.type === 'Identifier' && node.name === 'exports') { + return { key: 'default', via: 'exports' as const } + } + + if ( + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'module' && + node.property.type === 'Identifier' && + node.property.name === 'exports' + ) { + return { key: 'default', via: 'module.exports' as const } + } + if (node.type !== 'MemberExpression') return null const base = node.object const prop = node.property + const key = literalPropName(prop, literals) + if (!key) return null - if (prop.type !== 'Identifier') return null + const baseVia = resolveBase(base, aliases) + if (!baseVia) return null - if (base.type === 'Identifier' && base.name === 'exports') { - return { key: prop.name, via: 'exports' as const } + if (baseVia === 'module.exports' && key === 'exports') { + return { key: 'default', via: 'module.exports' as const } } - if ( - base.type === 'MemberExpression' && - base.object.type === 'Identifier' && - base.object.name === 'module' && - base.property.type === 'Identifier' && - base.property.name === 'exports' - ) { - return { key: prop.name, via: 'module.exports' as const } + return { key, via: baseVia } +} + +const resolveBase = ( + node: Node, + aliases: Map, +): ExportRef['via'] | null => { + if (node.type === 'Identifier') { + if (node.name === 'exports') return 'exports' + const alias = aliases.get(node.name) + if (alias) return alias } if ( - base.type === 'Identifier' && - base.name === 'module' && - prop.type === 'Identifier' && - prop.name === 'exports' + node.type === 'MemberExpression' && + node.object.type === 'Identifier' && + node.object.name === 'module' && + node.property.type === 'Identifier' && + node.property.name === 'exports' ) { - return { key: 'default', via: 'module.exports' as const } + return 'module.exports' } return null @@ -43,33 +92,68 @@ const resolveExportTarget = (node: Node) => { const collectCjsExports = async (ast: Node) => { const exportsMap = new Map() const localToExport = new Map>() + const aliases = new Map() + const literals = new Map() + + const addExport = (ref: ExportRef, node: Node, rhs?: SimpleIdentifier) => { + const entry = exportsMap.get(ref.key) ?? { + key: ref.key, + writes: [], + via: new Set(), + reassignments: [], + } + + entry.via.add(ref.via) + entry.writes.push(node as any) + + if (rhs) { + entry.fromIdentifier ??= rhs.name + const set = localToExport.get(rhs.name) ?? new Set() + set.add(ref.key) + localToExport.set(rhs.name, set) + } + + exportsMap.set(ref.key, entry) + } await ancestorWalk(ast, { enter(node) { - if (node.type === 'AssignmentExpression') { - const target = resolveExportTarget(node.left) + if ( + node.type === 'VariableDeclarator' && + node.id.type === 'Identifier' && + node.init + ) { + const via = resolveBase(node.init, aliases) + if (via) { + aliases.set(node.id.name, via) + } - if (target) { - const entry = exportsMap.get(target.key) ?? { - key: target.key, - writes: [], - via: new Set(), - reassignments: [], - } + if ( + node.init.type === 'Literal' && + (typeof node.init.value === 'string' || typeof node.init.value === 'number') + ) { + literals.set(node.id.name, node.init.value) + } - entry.via.add(target.via) - entry.writes.push(node) + if ( + node.init.type === 'TemplateLiteral' && + node.init.expressions.length === 0 && + node.init.quasis.length === 1 + ) { + const cooked = node.init.quasis[0].value.cooked ?? node.init.quasis[0].value.raw + literals.set(node.id.name, cooked) + } + } - if (node.right.type === 'Identifier') { - entry.fromIdentifier ??= node.right.name - if (entry.fromIdentifier) { - const set = localToExport.get(entry.fromIdentifier) ?? new Set() - set.add(target.key) - localToExport.set(entry.fromIdentifier, set) - } - } + if (node.type === 'AssignmentExpression') { + const target = resolveExportTarget(node.left, aliases, literals) - exportsMap.set(target.key, entry) + if (target) { + const rhsIdent = + node.right.type === 'Identifier' + ? (node.right as SimpleIdentifier) + : undefined + addExport(target, node, rhsIdent) return } @@ -80,12 +164,57 @@ const collectCjsExports = async (ast: Node) => { keys.forEach(key => { const entry = exportsMap.get(key) if (entry) { - entry.reassignments.push(node) + entry.reassignments.push(node as any) exportsMap.set(key, entry) } }) } } + + if (node.left.type === 'ObjectPattern') { + for (const prop of node.left.properties) { + if (prop.type === 'Property' && prop.value.type === 'MemberExpression') { + const ref = resolveExportTarget(prop.value, aliases, literals) + if (ref) { + addExport(ref, node) + } + } + } + } + } + + if (node.type === 'CallExpression') { + // Object.assign(exports, { foo: bar }) + if ( + node.callee.type === 'MemberExpression' && + node.callee.object.type === 'Identifier' && + node.callee.object.name === 'Object' && + node.callee.property.type === 'Identifier' && + node.callee.property.name === 'assign' && + node.arguments.length >= 2 + ) { + const targetArg = node.arguments[0] + const ref = resolveBase(targetArg as Node, aliases) + if (!ref) return + + for (let i = 1; i < node.arguments.length; i++) { + const arg = node.arguments[i] + if (arg.type === 'ObjectExpression') { + for (const prop of arg.properties) { + if (prop.type !== 'Property') continue + const keyName = literalPropName(prop.key, literals) + if (!keyName) continue + + let rhsIdent: SimpleIdentifier | undefined + if (prop.value.type === 'Identifier') { + rhsIdent = prop.value as SimpleIdentifier + } + + addExport({ key: keyName, via: ref }, node, rhsIdent) + } + } + } + } } }, }) diff --git a/test/fixtures/exportsAlias.cjs b/test/fixtures/exportsAlias.cjs new file mode 100644 index 0000000..7cf2e0d --- /dev/null +++ b/test/fixtures/exportsAlias.cjs @@ -0,0 +1,10 @@ +const e = exports +const m = module.exports + +e.foo = 1 +m.bar = 2 + +const later = e +later.baz = 3 + +exports diff --git a/test/fixtures/exportsAliasChain.cjs b/test/fixtures/exportsAliasChain.cjs new file mode 100644 index 0000000..e0c301a --- /dev/null +++ b/test/fixtures/exportsAliasChain.cjs @@ -0,0 +1,7 @@ +const a = exports +const b = a + +b.foo = 1 +module.exports.bar = 2 + +exports diff --git a/test/fixtures/exportsAssign.cjs b/test/fixtures/exportsAssign.cjs new file mode 100644 index 0000000..8fd3e23 --- /dev/null +++ b/test/fixtures/exportsAssign.cjs @@ -0,0 +1,6 @@ +module.exports = function main() { + return 'ok' +} +exports.extra = 'value' + +exports diff --git a/test/fixtures/exportsAugment.cjs b/test/fixtures/exportsAugment.cjs new file mode 100644 index 0000000..97912be --- /dev/null +++ b/test/fixtures/exportsAugment.cjs @@ -0,0 +1,6 @@ +module.exports = function main() { + return 'ok' +} + +module.exports.extra = 1 +exports diff --git a/test/fixtures/exportsComputed.cjs b/test/fixtures/exportsComputed.cjs new file mode 100644 index 0000000..7a7d1e7 --- /dev/null +++ b/test/fixtures/exportsComputed.cjs @@ -0,0 +1,8 @@ +const key = 'dyn' + +exports['foo'] = 'alpha' +exports[42] = 'num' +module.exports['bar'] = 'beta' +module.exports[key] = 'gamma' + +exports diff --git a/test/fixtures/exportsDestructure.cjs b/test/fixtures/exportsDestructure.cjs new file mode 100644 index 0000000..6a892f4 --- /dev/null +++ b/test/fixtures/exportsDestructure.cjs @@ -0,0 +1,7 @@ +;({ alpha: exports.alpha, beta: module.exports.beta } = { alpha: 'A', beta: 'B' }) + +const obj = { foo: 1, bar: 2 } +const ref = exports +;({ foo: ref.foo } = obj) + +exports diff --git a/test/fixtures/exportsDynamicComputed.cjs b/test/fixtures/exportsDynamicComputed.cjs new file mode 100644 index 0000000..c5ad802 --- /dev/null +++ b/test/fixtures/exportsDynamicComputed.cjs @@ -0,0 +1,6 @@ +const key = () => 'dyn' + +exports[key()] = 'value' +exports.static = 'ok' + +exports diff --git a/test/fixtures/exportsObjectAssign.cjs b/test/fixtures/exportsObjectAssign.cjs new file mode 100644 index 0000000..6d999fd --- /dev/null +++ b/test/fixtures/exportsObjectAssign.cjs @@ -0,0 +1,4 @@ +Object.assign(exports, { foo: 'x', bar: 'y' }) +Object.assign(module.exports, { baz: 'z' }) + +exports diff --git a/test/module.ts b/test/module.ts index d02a5a3..21d583d 100644 --- a/test/module.ts +++ b/test/module.ts @@ -94,6 +94,101 @@ describe('@knighted/module', () => { assert.equal(statusOut, 0) }) + const exportFixtures: Array<{ + name: string + file: string + expect?: Record + verify?: (mod: Record) => void + }> = [ + { + name: 'exportsComputed', + file: 'exportsComputed.cjs', + expect: { foo: 'alpha', '42': 'num', bar: 'beta', dyn: 'gamma' }, + }, + { + name: 'exportsDynamicComputed', + file: 'exportsDynamicComputed.cjs', + verify: mod => { + assert.equal(mod.static, 'ok') + assert.equal(Object.prototype.hasOwnProperty.call(mod, 'dyn'), false) + }, + }, + { + name: 'exportsAlias', + file: 'exportsAlias.cjs', + expect: { foo: 1, bar: 2, baz: 3 }, + }, + { + name: 'exportsAliasChain', + file: 'exportsAliasChain.cjs', + expect: { foo: 1, bar: 2 }, + }, + { + name: 'exportsAssign', + file: 'exportsAssign.cjs', + verify: mod => { + assert.equal(typeof mod.default, 'function') + assert.equal(mod.default(), 'ok') + assert.equal(mod.extra, 'value') + }, + }, + { + name: 'exportsAugment', + file: 'exportsAugment.cjs', + verify: mod => { + assert.equal(typeof mod.default, 'function') + assert.equal(mod.default(), 'ok') + assert.equal(mod.extra, 1) + }, + }, + { + name: 'exportsDestructure', + file: 'exportsDestructure.cjs', + expect: { alpha: 'A', beta: 'B', foo: 1 }, + }, + { + name: 'exportsObjectAssign', + file: 'exportsObjectAssign.cjs', + expect: { foo: 'x', bar: 'y', baz: 'z' }, + }, + ] + + exportFixtures.forEach(({ name, file, expect, verify }) => { + it(`transforms ${name}`, async t => { + const fixturePath = join(fixtures, file) + const result = await transform(fixturePath, { + target: 'module', + }) + const outFile = join(fixtures, `${file.replace('.cjs', '')}.mjs`) + const { status: statusIn } = spawnSync('node', [fixturePath], { + stdio: 'inherit', + }) + + t.after(() => { + rm(outFile, { force: true }) + }) + + assert.equal(statusIn, 0) + await writeFile(outFile, result) + + const { status: statusOut } = spawnSync('node', [outFile], { stdio: 'inherit' }) + assert.equal(statusOut, 0) + + const exportsObj = await import(outFile) + + if (verify) { + verify(exportsObj as any) + return + } + + if (expect) { + Object.entries(expect).forEach(([k, v]) => { + assert.equal((exportsObj as any)[k], v) + }) + } + }) + }) + it('transforms import.meta', async t => { const result = await transform(join(fixtures, 'import.meta.mjs'), { target: 'commonjs', From 04075a713838c3cfb9a1482f8a2f93c6c72fa3c3 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 10:42:53 -0600 Subject: [PATCH 4/7] feat: object.defineproperties exports getters and setters. --- docs/cjs-exports.md | 4 +- src/types.ts | 1 + src/utils/exports.ts | 107 ++++++++++++++++++++-- test/fixtures/exportsAlias.cjs | 2 - test/fixtures/exportsAliasChain.cjs | 2 - test/fixtures/exportsAssign.cjs | 2 - test/fixtures/exportsAugment.cjs | 1 - test/fixtures/exportsComputed.cjs | 2 - test/fixtures/exportsDefineGetter.cjs | 7 ++ test/fixtures/exportsDefineProperties.cjs | 9 ++ test/fixtures/exportsDefineProperty.cjs | 2 + test/fixtures/exportsDestructure.cjs | 2 - test/fixtures/exportsDynamicComputed.cjs | 2 - test/fixtures/exportsObjectAssign.cjs | 2 - test/module.ts | 15 +++ 15 files changed, 138 insertions(+), 22 deletions(-) create mode 100644 test/fixtures/exportsDefineGetter.cjs create mode 100644 test/fixtures/exportsDefineProperties.cjs create mode 100644 test/fixtures/exportsDefineProperty.cjs diff --git a/docs/cjs-exports.md b/docs/cjs-exports.md index d6260ce..41f2ef9 100644 --- a/docs/cjs-exports.md +++ b/docs/cjs-exports.md @@ -7,10 +7,12 @@ - Aliases to the export bag: `const e = exports; e.foo = 1`, `const m = module.exports; m.bar = 2` (alias chains are tracked). - Destructuring to properties: `({ value: exports.foo } = obj)`. - `Object.assign(exports, { foo, bar: baz })` and `Object.assign(module.exports, { ... })` with literal keys. +- `Object.defineProperty` / `Object.defineProperties` on exports/module.exports (aliases included) with literal keys; value and getter descriptors are collected. Getter-based exports are emitted via a proxy read (best-effort, not true ESM live bindings). ## Ignored or intentionally excluded - Non-literal computed keys (e.g., `exports[key()] = x`) are ignored for static export emission; they may still exist on the runtime bag but no ESM binding is generated. +- Symbol keys on exports/module.exports are ignored. - Patterns that do not resolve to `exports`/`module.exports` or aliases are ignored. ## Emission rules @@ -21,5 +23,5 @@ ## Testing strategy -- Fixtures cover each supported pattern: computed literals, alias chains, destructuring, Object.assign fan-out, and overwrite-then-augment sequences. Dynamic non-literal keys have a fixture to verify they remain un-exported. +- Fixtures cover each supported pattern: computed literals, alias chains, destructuring, Object.assign fan-out, overwrite-then-augment sequences, and defineProperty/defineProperties (value + getter). Dynamic non-literal keys have a fixture to verify they remain un-exported. - Each transformed fixture is executed with Node before assertions to catch runtime/syntax issues, then its exports are asserted via ESM import. diff --git a/src/types.ts b/src/types.ts index f4e586a..99fb341 100644 --- a/src/types.ts +++ b/src/types.ts @@ -47,6 +47,7 @@ export type CjsExport = { fromIdentifier?: string via: Set<'exports' | 'module.exports'> reassignments: SpannedNode[] + hasGetter?: boolean } export type IdentMeta = { diff --git a/src/utils/exports.ts b/src/utils/exports.ts index 291402c..eb29a19 100644 --- a/src/utils/exports.ts +++ b/src/utils/exports.ts @@ -95,7 +95,12 @@ const collectCjsExports = async (ast: Node) => { const aliases = new Map() const literals = new Map() - const addExport = (ref: ExportRef, node: Node, rhs?: SimpleIdentifier) => { + const addExport = ( + ref: ExportRef, + node: Node, + rhs?: SimpleIdentifier, + options?: { hasGetter?: boolean }, + ) => { const entry = exportsMap.get(ref.key) ?? { key: ref.key, writes: [], @@ -106,6 +111,10 @@ const collectCjsExports = async (ast: Node) => { entry.via.add(ref.via) entry.writes.push(node as any) + if (options?.hasGetter) { + entry.hasGetter = true + } + if (rhs) { entry.fromIdentifier ??= rhs.name const set = localToExport.get(rhs.name) ?? new Set() @@ -184,13 +193,15 @@ const collectCjsExports = async (ast: Node) => { } if (node.type === 'CallExpression') { + const callee = node.callee + // Object.assign(exports, { foo: bar }) if ( - node.callee.type === 'MemberExpression' && - node.callee.object.type === 'Identifier' && - node.callee.object.name === 'Object' && - node.callee.property.type === 'Identifier' && - node.callee.property.name === 'assign' && + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'Object' && + callee.property.type === 'Identifier' && + callee.property.name === 'assign' && node.arguments.length >= 2 ) { const targetArg = node.arguments[0] @@ -215,6 +226,90 @@ const collectCjsExports = async (ast: Node) => { } } } + + // Object.defineProperty(exports, 'foo', { value, get, set }) + if ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'Object' && + callee.property.type === 'Identifier' && + callee.property.name === 'defineProperty' && + node.arguments.length >= 3 + ) { + const target = resolveBase(node.arguments[0] as Node, aliases) + if (!target) return + + const keyName = literalPropName(node.arguments[1] as Node, literals) + if (!keyName) return + + const desc = node.arguments[2] + if (desc.type !== 'ObjectExpression') return + + let rhsIdent: SimpleIdentifier | undefined + let hasGetter = false + + for (const prop of desc.properties) { + if (prop.type !== 'Property') continue + if (prop.key.type !== 'Identifier') continue + + if (prop.key.name === 'value' && prop.value.type === 'Identifier') { + rhsIdent = prop.value as SimpleIdentifier + } + + if (prop.key.name === 'get' && prop.value.type === 'Identifier') { + hasGetter = true + } + + if (prop.key.name === 'set' && prop.value.type === 'Identifier') { + // Setter-only doesn’t create a readable export; ignore beyond marking write + } + } + + addExport({ key: keyName, via: target }, node, rhsIdent, { hasGetter }) + } + + // Object.defineProperties(exports, { foo: { value: ... }, bar: { get: ... } }) + if ( + callee.type === 'MemberExpression' && + callee.object.type === 'Identifier' && + callee.object.name === 'Object' && + callee.property.type === 'Identifier' && + callee.property.name === 'defineProperties' && + node.arguments.length >= 2 + ) { + const target = resolveBase(node.arguments[0] as Node, aliases) + if (!target) return + + const descMap = node.arguments[1] + if (descMap.type !== 'ObjectExpression') return + + for (const prop of descMap.properties) { + if (prop.type !== 'Property') continue + + const keyName = literalPropName(prop.key, literals) + if (!keyName) continue + + if (prop.value.type !== 'ObjectExpression') continue + + let rhsIdent: SimpleIdentifier | undefined + let hasGetter = false + + for (const descProp of prop.value.properties) { + if (descProp.type !== 'Property') continue + if (descProp.key.type !== 'Identifier') continue + + if (descProp.key.name === 'value' && descProp.value.type === 'Identifier') { + rhsIdent = descProp.value as SimpleIdentifier + } + + if (descProp.key.name === 'get' && descProp.value.type === 'Identifier') { + hasGetter = true + } + } + + addExport({ key: keyName, via: target }, node, rhsIdent, { hasGetter }) + } + } } }, }) diff --git a/test/fixtures/exportsAlias.cjs b/test/fixtures/exportsAlias.cjs index 7cf2e0d..22b35d0 100644 --- a/test/fixtures/exportsAlias.cjs +++ b/test/fixtures/exportsAlias.cjs @@ -6,5 +6,3 @@ m.bar = 2 const later = e later.baz = 3 - -exports diff --git a/test/fixtures/exportsAliasChain.cjs b/test/fixtures/exportsAliasChain.cjs index e0c301a..9a078ae 100644 --- a/test/fixtures/exportsAliasChain.cjs +++ b/test/fixtures/exportsAliasChain.cjs @@ -3,5 +3,3 @@ const b = a b.foo = 1 module.exports.bar = 2 - -exports diff --git a/test/fixtures/exportsAssign.cjs b/test/fixtures/exportsAssign.cjs index 8fd3e23..befdcea 100644 --- a/test/fixtures/exportsAssign.cjs +++ b/test/fixtures/exportsAssign.cjs @@ -2,5 +2,3 @@ module.exports = function main() { return 'ok' } exports.extra = 'value' - -exports diff --git a/test/fixtures/exportsAugment.cjs b/test/fixtures/exportsAugment.cjs index 97912be..59fbb9a 100644 --- a/test/fixtures/exportsAugment.cjs +++ b/test/fixtures/exportsAugment.cjs @@ -3,4 +3,3 @@ module.exports = function main() { } module.exports.extra = 1 -exports diff --git a/test/fixtures/exportsComputed.cjs b/test/fixtures/exportsComputed.cjs index 7a7d1e7..3c7eb5f 100644 --- a/test/fixtures/exportsComputed.cjs +++ b/test/fixtures/exportsComputed.cjs @@ -4,5 +4,3 @@ exports['foo'] = 'alpha' exports[42] = 'num' module.exports['bar'] = 'beta' module.exports[key] = 'gamma' - -exports diff --git a/test/fixtures/exportsDefineGetter.cjs b/test/fixtures/exportsDefineGetter.cjs new file mode 100644 index 0000000..9490cb9 --- /dev/null +++ b/test/fixtures/exportsDefineGetter.cjs @@ -0,0 +1,7 @@ +let count = 0 +Object.defineProperty(exports, 'next', { + get() { + count += 1 + return count + }, +}) diff --git a/test/fixtures/exportsDefineProperties.cjs b/test/fixtures/exportsDefineProperties.cjs new file mode 100644 index 0000000..edb8dd3 --- /dev/null +++ b/test/fixtures/exportsDefineProperties.cjs @@ -0,0 +1,9 @@ +const value = 'ok' +Object.defineProperties(module.exports, { + alpha: { value }, + beta: { + get() { + return value + '!' + }, + }, +}) diff --git a/test/fixtures/exportsDefineProperty.cjs b/test/fixtures/exportsDefineProperty.cjs new file mode 100644 index 0000000..7961cf5 --- /dev/null +++ b/test/fixtures/exportsDefineProperty.cjs @@ -0,0 +1,2 @@ +Object.defineProperty(exports, 'foo', { value: 'bar' }) +Object.defineProperty(module.exports, 'baz', { value: 2 }) diff --git a/test/fixtures/exportsDestructure.cjs b/test/fixtures/exportsDestructure.cjs index 6a892f4..82509c3 100644 --- a/test/fixtures/exportsDestructure.cjs +++ b/test/fixtures/exportsDestructure.cjs @@ -3,5 +3,3 @@ const obj = { foo: 1, bar: 2 } const ref = exports ;({ foo: ref.foo } = obj) - -exports diff --git a/test/fixtures/exportsDynamicComputed.cjs b/test/fixtures/exportsDynamicComputed.cjs index c5ad802..a93e48f 100644 --- a/test/fixtures/exportsDynamicComputed.cjs +++ b/test/fixtures/exportsDynamicComputed.cjs @@ -2,5 +2,3 @@ const key = () => 'dyn' exports[key()] = 'value' exports.static = 'ok' - -exports diff --git a/test/fixtures/exportsObjectAssign.cjs b/test/fixtures/exportsObjectAssign.cjs index 6d999fd..33cc504 100644 --- a/test/fixtures/exportsObjectAssign.cjs +++ b/test/fixtures/exportsObjectAssign.cjs @@ -1,4 +1,2 @@ Object.assign(exports, { foo: 'x', bar: 'y' }) Object.assign(module.exports, { baz: 'z' }) - -exports diff --git a/test/module.ts b/test/module.ts index 21d583d..3a5b40f 100644 --- a/test/module.ts +++ b/test/module.ts @@ -141,6 +141,21 @@ describe('@knighted/module', () => { assert.equal(mod.extra, 1) }, }, + { + name: 'exportsDefineProperty', + file: 'exportsDefineProperty.cjs', + expect: { foo: 'bar', baz: 2 }, + }, + { + name: 'exportsDefineGetter', + file: 'exportsDefineGetter.cjs', + expect: { next: 1 }, + }, + { + name: 'exportsDefineProperties', + file: 'exportsDefineProperties.cjs', + expect: { alpha: 'ok', beta: 'ok!' }, + }, { name: 'exportsDestructure', file: 'exportsDestructure.cjs', From 5c48515fa6d5c298c62926864d9a900ece538440 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 10:43:51 -0600 Subject: [PATCH 5/7] chore: bump version. --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 13044de..ddb8b53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@knighted/module", - "version": "1.0.0-beta.0", + "version": "1.0.0-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@knighted/module", - "version": "1.0.0-beta.0", + "version": "1.0.0-beta.1", "license": "MIT", "dependencies": { "@knighted/specifier": "^2.0.9", diff --git a/package.json b/package.json index ec25a6a..46c9634 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@knighted/module", - "version": "1.0.0-beta.0", + "version": "1.0.0-beta.1", "description": "Transforms differences between ES modules and CommonJS.", "type": "module", "main": "dist/module.js", From 3aae98fa3e62145cdad4fa51fa7bb541fe0f5b37 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 10:53:41 -0600 Subject: [PATCH 6/7] ci: adjust codecov. --- codecov.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codecov.yml b/codecov.yml index 23f1ed2..b6c81d9 100644 --- a/codecov.yml +++ b/codecov.yml @@ -7,4 +7,4 @@ coverage: patch: default: target: 80.0 - threshold: 5.0 + threshold: 20 From 5326d8c600b9acbb21e0c4d052c19ea0e07f5c22 Mon Sep 17 00:00:00 2001 From: KCM Date: Sat, 27 Dec 2025 10:55:36 -0600 Subject: [PATCH 7/7] fix: windows with pathToFileUrl. --- test/module.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/module.ts b/test/module.ts index 3a5b40f..4ec8ee8 100644 --- a/test/module.ts +++ b/test/module.ts @@ -2,6 +2,7 @@ import { describe, it } from 'node:test' import assert from 'node:assert/strict' import { spawnSync } from 'node:child_process' import { resolve, join } from 'node:path' +import { pathToFileURL } from 'node:url' import { rm, stat, writeFile } from 'node:fs/promises' import type { Stats } from 'node:fs' @@ -189,7 +190,7 @@ describe('@knighted/module', () => { const { status: statusOut } = spawnSync('node', [outFile], { stdio: 'inherit' }) assert.equal(statusOut, 0) - const exportsObj = await import(outFile) + const exportsObj = await import(pathToFileURL(outFile).href) if (verify) { verify(exportsObj as any)