Skip to content

Commit 6f1a17b

Browse files
committed
feat(validation): add comprehensive ESM/CJS validator for external modules
- Create external-esm-cjs.mjs validator (322 lines) - Tests both CJS require() and ESM import for all 38 external modules - Validates ESM/CJS interop and export accessibility - Checks @InQuirer modules for correct export patterns - Verifies Separator is accessible from both CJS and ESM - Provides detailed statistics in verbose mode - Integrated into build pipeline after external-exports validator Results: - 38 modules tested (177 CJS exports, 38 ESM defaults) - All modules pass ESM/CJS interop tests - @InQuirer modules correctly export functions and Separator
1 parent adfc844 commit 6f1a17b

File tree

2 files changed

+326
-0
lines changed

2 files changed

+326
-0
lines changed

scripts/fix/main.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,10 @@ async function main() {
5656
args: ['scripts/validate/external-exports.mjs', ...fixArgs],
5757
command: 'node',
5858
},
59+
{
60+
args: ['scripts/validate/external-esm-cjs.mjs', ...fixArgs],
61+
command: 'node',
62+
},
5963
])
6064

6165
if (!quiet) {
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
/**
2+
* @fileoverview Comprehensive ESM/CJS validator for dist/external/* exports
3+
* Validates that bundled dependencies work correctly with both CommonJS require()
4+
* and ESM import, including proper handling of default exports and named exports.
5+
*
6+
* Key validations:
7+
* - CJS require() returns usable values without .default wrappers
8+
* - ESM default imports work correctly
9+
* - Named exports (like Separator) are accessible from both CJS and ESM
10+
* - Function exports are directly callable without .default
11+
* - Object exports preserve all named properties
12+
*/
13+
14+
import { createRequire } from 'node:module'
15+
import { readdirSync, statSync } from 'node:fs'
16+
import path from 'node:path'
17+
import { fileURLToPath } from 'node:url'
18+
import { pathToFileURL } from 'node:url'
19+
20+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
21+
const externalDir = path.resolve(__dirname, '..', '..', 'dist', 'external')
22+
const require = createRequire(import.meta.url)
23+
24+
// Normalize path for cross-platform (converts backslashes to forward slashes)
25+
const normalizePath = p => p.split(path.sep).join('/')
26+
27+
// Import CommonJS modules using require
28+
const { isQuiet } = require('#socketsecurity/lib/argv/flags')
29+
const { getDefaultLogger } = require('#socketsecurity/lib/logger')
30+
const { pluralize } = require('#socketsecurity/lib/words')
31+
32+
const logger = getDefaultLogger()
33+
34+
/**
35+
* Get all .js files recursively in a directory.
36+
*/
37+
function getJsFilesRecursive(dir, files = []) {
38+
try {
39+
const entries = readdirSync(dir, { withFileTypes: true })
40+
41+
for (const entry of entries) {
42+
const fullPath = path.join(dir, entry.name)
43+
44+
if (entry.isFile() && entry.name.endsWith('.js')) {
45+
files.push(fullPath)
46+
} else if (entry.isDirectory()) {
47+
// Recursively scan all subdirectories
48+
getJsFilesRecursive(fullPath, files)
49+
}
50+
}
51+
} catch (_error) {
52+
// Directory might not be accessible
53+
}
54+
55+
return files
56+
}
57+
58+
/**
59+
* Get all .js files and directories in the external directory.
60+
*/
61+
function getExternalModules(dir) {
62+
return getJsFilesRecursive(dir).filter(file => {
63+
// Ensure the file is actually in the external directory
64+
// (not some symlink or weird path)
65+
return file.startsWith(dir)
66+
})
67+
}
68+
69+
/**
70+
* Check if module exports work correctly for both CJS and ESM.
71+
*/
72+
async function checkModuleExports(filePath) {
73+
const relativePath = path.relative(externalDir, filePath)
74+
const normalizedPath = normalizePath(relativePath)
75+
const issues = []
76+
77+
// Test 1: CJS require() - should work without .default
78+
let cjsModule
79+
try {
80+
cjsModule = require(filePath)
81+
} catch (error) {
82+
return {
83+
path: normalizedPath,
84+
ok: false,
85+
issues: [`CJS require() failed: ${error.message}`],
86+
}
87+
}
88+
89+
// Validate CJS export structure
90+
const cjsType = typeof cjsModule
91+
const cjsKeys = cjsType === 'object' && cjsModule !== null ? Object.keys(cjsModule) : []
92+
93+
// Check for problematic CJS patterns
94+
if (cjsType === 'object' && cjsModule !== null) {
95+
// If only key is 'default', it's wrapped incorrectly
96+
if (cjsKeys.length === 1 && cjsKeys[0] === 'default') {
97+
issues.push(
98+
'CJS: Module only exports { default: value } - internal code would need .default accessor',
99+
)
100+
}
101+
102+
// Empty object is suspicious
103+
if (cjsKeys.length === 0) {
104+
issues.push('CJS: Module exports empty object - may indicate bundling issue')
105+
}
106+
107+
// Check if .default shadows the main export
108+
if ('default' in cjsModule && cjsModule.default !== undefined) {
109+
// If .default is a circular reference (module.default === module), that's okay
110+
if (cjsModule.default !== cjsModule) {
111+
const nonDefaultKeys = cjsKeys.filter(k => k !== 'default')
112+
// If there are other exports, this might be intentional (like @inquirer modules)
113+
// We'll check ESM compatibility below
114+
if (nonDefaultKeys.length === 0) {
115+
issues.push('CJS: Module has .default but no other exports - may be wrapped')
116+
}
117+
}
118+
}
119+
}
120+
121+
// Test 2: ESM import - should work correctly
122+
let esmModule
123+
try {
124+
// Dynamic import to test ESM interop
125+
const moduleUrl = pathToFileURL(filePath).href
126+
esmModule = await import(moduleUrl)
127+
} catch (error) {
128+
issues.push(`ESM import failed: ${error.message}`)
129+
return {
130+
path: normalizedPath,
131+
ok: false,
132+
issues,
133+
}
134+
}
135+
136+
// Validate ESM export structure
137+
const esmDefault = esmModule.default
138+
const esmKeys = Object.keys(esmModule).filter(k => k !== 'default')
139+
140+
// Test 3: ESM/CJS interop validation
141+
if (cjsType === 'function') {
142+
// Functions should be importable as default export in ESM
143+
if (typeof esmDefault !== 'function') {
144+
issues.push(
145+
`ESM: Default export should be a function (got ${typeof esmDefault}), but CJS exports function directly`,
146+
)
147+
}
148+
} else if (cjsType === 'object' && cjsModule !== null) {
149+
// For objects with both default and named exports (like @inquirer modules)
150+
if ('default' in cjsModule && cjsModule.default !== cjsModule) {
151+
// ESM should have the default export
152+
if (esmDefault === undefined) {
153+
issues.push('ESM: Missing default export, but CJS has .default property')
154+
}
155+
156+
// Named exports should be accessible in ESM's default import
157+
const nonDefaultCjsKeys = cjsKeys.filter(k => k !== 'default')
158+
for (const key of nonDefaultCjsKeys) {
159+
// In ESM, named exports appear as properties of the default import
160+
// when importing a CJS module
161+
if (!(key in esmModule) && !(key in (esmDefault || {}))) {
162+
issues.push(
163+
`ESM: Named export '${key}' not accessible (not in module or default object)`,
164+
)
165+
}
166+
}
167+
} else {
168+
// Regular object exports - all CJS keys should be in ESM default
169+
if (esmDefault && typeof esmDefault === 'object') {
170+
const esmDefaultKeys = Object.keys(esmDefault)
171+
for (const key of cjsKeys) {
172+
if (!esmDefaultKeys.includes(key)) {
173+
issues.push(`ESM: Named export '${key}' missing from default object`)
174+
}
175+
}
176+
}
177+
}
178+
}
179+
180+
// Test 4: Specific checks for @inquirer modules
181+
if (normalizedPath.startsWith('@inquirer/')) {
182+
const moduleName = normalizedPath.split('/')[1]
183+
184+
// confirm, input, password should export functions directly
185+
if (['confirm', 'input', 'password'].includes(moduleName)) {
186+
if (cjsType !== 'function') {
187+
issues.push(
188+
`@inquirer/${moduleName}: Should export function directly for CJS (got ${cjsType})`,
189+
)
190+
}
191+
if (typeof esmDefault !== 'function') {
192+
issues.push(
193+
`@inquirer/${moduleName}: Should export function as default for ESM (got ${typeof esmDefault})`,
194+
)
195+
}
196+
}
197+
198+
// select, checkbox, search should have both default function and Separator
199+
if (['select', 'checkbox', 'search'].includes(moduleName)) {
200+
if (!cjsKeys.includes('Separator')) {
201+
issues.push(`@inquirer/${moduleName}: Missing Separator export in CJS`)
202+
}
203+
if (!('default' in cjsModule)) {
204+
issues.push(`@inquirer/${moduleName}: Missing default export in CJS`)
205+
}
206+
if (typeof cjsModule.default !== 'function') {
207+
issues.push(
208+
`@inquirer/${moduleName}: default should be a function (got ${typeof cjsModule.default})`,
209+
)
210+
}
211+
// Check ESM access to Separator
212+
if (!('Separator' in esmModule)) {
213+
issues.push(`@inquirer/${moduleName}: Separator not accessible in ESM`)
214+
}
215+
}
216+
}
217+
218+
return {
219+
path: normalizedPath,
220+
ok: issues.length === 0,
221+
issues,
222+
cjsKeys: cjsKeys.length,
223+
esmKeys: esmKeys.length,
224+
cjsType,
225+
hasEsmDefault: esmDefault !== undefined,
226+
}
227+
}
228+
229+
async function main() {
230+
const quiet = isQuiet()
231+
const verbose = process.argv.includes('--verbose')
232+
233+
if (!quiet && verbose) {
234+
logger.step('Validating dist/external ESM/CJS exports')
235+
}
236+
237+
const modules = getExternalModules(externalDir)
238+
239+
if (modules.length === 0) {
240+
if (!quiet) {
241+
logger.warn('No external modules found to validate')
242+
}
243+
return
244+
}
245+
246+
// Check all modules
247+
const results = await Promise.all(modules.map(checkModuleExports))
248+
const failures = results.filter(r => !r.ok)
249+
const successes = results.filter(r => r.ok)
250+
251+
if (failures.length > 0) {
252+
if (!quiet) {
253+
logger.error(
254+
`Found ${failures.length} external ${pluralize('module', { count: failures.length })} with ESM/CJS export issues:`,
255+
)
256+
for (const failure of failures) {
257+
logger.error(` ${failure.path}`)
258+
for (const issue of failure.issues) {
259+
logger.substep(issue)
260+
}
261+
}
262+
logger.log('')
263+
logger.warn('Recommended fixes:')
264+
logger.substep(
265+
'Ensure esbuild configuration preserves proper export structure',
266+
)
267+
logger.substep('Check that external bundles use correct format settings')
268+
logger.substep(
269+
'For function exports: Use format that exports function directly',
270+
)
271+
logger.substep(
272+
'For named exports: Ensure all names are accessible from both CJS and ESM',
273+
)
274+
}
275+
process.exitCode = 1
276+
} else {
277+
if (!quiet) {
278+
// Summary statistics
279+
const totalCjsKeys = successes.reduce((sum, r) => sum + r.cjsKeys, 0)
280+
const modulesWithDefault = successes.filter(r => r.hasEsmDefault).length
281+
const functionExports = successes.filter(r => r.cjsType === 'function').length
282+
const objectExports = successes.filter(r => r.cjsType === 'object').length
283+
284+
logger.success(
285+
`Validated ${results.length} external ${pluralize('module', { count: results.length })} - all ESM/CJS interop working correctly`,
286+
)
287+
if (verbose) {
288+
logger.substep(`${totalCjsKeys} total CJS exports`)
289+
logger.substep(`${modulesWithDefault} modules with ESM default export`)
290+
logger.substep(`${functionExports} function exports`)
291+
logger.substep(`${objectExports} object exports`)
292+
293+
// Check @inquirer modules specifically
294+
const inquirerResults = results.filter(r =>
295+
r.path.startsWith('@inquirer/'),
296+
)
297+
if (inquirerResults.length > 0) {
298+
logger.log('')
299+
logger.success(
300+
`Verified ${inquirerResults.length} @inquirer ${pluralize('module', { count: inquirerResults.length })}:`,
301+
)
302+
for (const result of inquirerResults) {
303+
const hasDefault = result.hasEsmDefault ? '✓ default' : ''
304+
const hasSeparator =
305+
result.cjsKeys > 0 ? `✓ ${result.cjsKeys} exports` : ''
306+
logger.substep(
307+
`${result.path}: ${[hasDefault, hasSeparator].filter(Boolean).join(', ')}`,
308+
)
309+
}
310+
}
311+
}
312+
}
313+
}
314+
}
315+
316+
main().catch(error => {
317+
logger.error(`Validation failed: ${error.message}`)
318+
if (!isQuiet() && error.stack) {
319+
logger.log(error.stack)
320+
}
321+
process.exitCode = 1
322+
})

0 commit comments

Comments
 (0)