Skip to content

Commit 731cfca

Browse files
committed
feat(build): add ESM-compatible named exports validation
Add validation to ensure all dist/ files export named exports compatible with ESM imports (module.exports = { foo, bar } pattern). This allows ESM code to use: import { foo, bar } from '#socketsecurity/lib/module' Also revert validate-dist-exports.mjs to use ESM imports now that we're ensuring proper named exports.
1 parent a1a3ddb commit 731cfca

File tree

3 files changed

+179
-5
lines changed

3 files changed

+179
-5
lines changed

scripts/fix-build.mjs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ async function main() {
4444
args: ['scripts/fix-commonjs-exports.mjs', ...fixArgs],
4545
command: 'node',
4646
},
47+
{
48+
args: ['scripts/validate-esm-named-exports.mjs', ...fixArgs],
49+
command: 'node',
50+
},
4751
{
4852
args: ['scripts/validate-dist-exports.mjs', ...fixArgs],
4953
command: 'node',

scripts/validate-dist-exports.mjs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,14 @@ import { fileURLToPath } from 'node:url'
1010

1111
import colors from 'yoctocolors-cjs'
1212

13+
import { isQuiet } from '#socketsecurity/lib/argv/flags'
14+
import { normalizePath } from '#socketsecurity/lib/paths'
15+
import { pluralize } from '#socketsecurity/lib/words'
16+
1317
const __dirname = path.dirname(fileURLToPath(import.meta.url))
1418
const distDir = path.resolve(__dirname, '..', 'dist')
1519
const require = createRequire(import.meta.url)
1620

17-
// Import CommonJS modules
18-
const { isQuiet } = require('#socketsecurity/lib/argv/flags')
19-
const { normalizePath } = require('#socketsecurity/lib/paths')
20-
const { pluralize } = require('#socketsecurity/lib/words')
21-
2221
/**
2322
* Get all .js files in a directory recursively.
2423
*/
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
/**
2+
* @fileoverview Validate that dist/* files export named exports compatible with ESM imports
3+
* Ensures that module.exports = { foo, bar } pattern is used (not module.exports.default)
4+
* so that ESM code can do: import { foo, bar } from '#socketsecurity/lib/module'
5+
*/
6+
7+
import { createRequire } from 'node:module'
8+
import { readFileSync, readdirSync } from 'node:fs'
9+
import path from 'node:path'
10+
import { fileURLToPath } from 'node:url'
11+
12+
import colors from 'yoctocolors-cjs'
13+
14+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
15+
const distDir = path.resolve(__dirname, '..', 'dist')
16+
const require = createRequire(import.meta.url)
17+
18+
// Import CommonJS modules
19+
const { isQuiet } = require('#socketsecurity/lib/argv/flags')
20+
const { normalizePath } = require('#socketsecurity/lib/paths')
21+
const { pluralize } = require('#socketsecurity/lib/words')
22+
23+
/**
24+
* Get all .js files in a directory recursively.
25+
*/
26+
function getJsFiles(dir, files = []) {
27+
const entries = readdirSync(dir, { withFileTypes: true })
28+
29+
for (const entry of entries) {
30+
const fullPath = path.join(dir, entry.name)
31+
32+
if (entry.isDirectory()) {
33+
getJsFiles(fullPath, files)
34+
} else if (entry.isFile() && entry.name.endsWith('.js')) {
35+
files.push(fullPath)
36+
}
37+
}
38+
39+
return files
40+
}
41+
42+
/**
43+
* Check if a module exports named exports in an ESM-compatible way.
44+
* Good: module.exports = { foo, bar, baz }
45+
* Bad: module.exports = value or module.exports.default = value
46+
*/
47+
function checkEsmNamedExports(filePath) {
48+
// Skip external packages - they are bundled dependencies
49+
const relativePath = path.relative(distDir, filePath)
50+
const normalizedPath = normalizePath(relativePath)
51+
if (normalizedPath.startsWith('external/')) {
52+
return { path: filePath, ok: true, skipped: true }
53+
}
54+
55+
try {
56+
// Read the file source to check export pattern
57+
const source = readFileSync(filePath, 'utf-8')
58+
59+
// Check for problematic patterns
60+
const hasDefaultExport =
61+
/module\.exports\s*=\s*\w+\s*;?\s*$/.test(source) ||
62+
/module\.exports\.default\s*=/.test(source)
63+
64+
// Check for proper named exports pattern
65+
const hasNamedExportsObject = /module\.exports\s*=\s*{/.test(source)
66+
67+
// Also check by actually requiring the module
68+
const mod = require(filePath)
69+
70+
// If it's a primitive, it can't have named exports
71+
if (typeof mod !== 'object' || mod === null) {
72+
return {
73+
path: filePath,
74+
ok: false,
75+
reason:
76+
'Module exports a primitive value instead of an object with named exports',
77+
}
78+
}
79+
80+
// If module only has 'default' key, it's not ESM-compatible
81+
const keys = Object.keys(mod)
82+
if (keys.length === 1 && keys[0] === 'default') {
83+
return {
84+
path: filePath,
85+
ok: false,
86+
reason:
87+
'Module only exports { default: value } - should export named exports directly',
88+
}
89+
}
90+
91+
// If we have suspicious patterns and no proper object exports
92+
if (hasDefaultExport && !hasNamedExportsObject) {
93+
// But let's be lenient if the module does have named exports when required
94+
if (keys.length > 0 && !keys.includes('default')) {
95+
// It's fine - esbuild generated proper interop
96+
return { path: filePath, ok: true }
97+
}
98+
99+
return {
100+
path: filePath,
101+
ok: false,
102+
reason:
103+
'Module uses default export pattern instead of named exports object',
104+
}
105+
}
106+
107+
// If we have an empty object
108+
if (keys.length === 0) {
109+
return {
110+
path: filePath,
111+
ok: false,
112+
reason: 'Module exports an empty object with no named exports',
113+
}
114+
}
115+
116+
return { path: filePath, ok: true }
117+
} catch (error) {
118+
return {
119+
path: filePath,
120+
ok: false,
121+
reason: `Failed to analyze: ${error.message}`,
122+
}
123+
}
124+
}
125+
126+
async function main() {
127+
const quiet = isQuiet()
128+
const verbose = process.argv.includes('--verbose')
129+
130+
if (!quiet && verbose) {
131+
console.log(`${colors.cyan('→')} Validating ESM-compatible named exports`)
132+
}
133+
134+
const files = getJsFiles(distDir)
135+
const results = files.map(checkEsmNamedExports)
136+
const failures = results.filter(r => !r.ok)
137+
138+
const checked = results.filter(r => !r.skipped)
139+
140+
if (failures.length > 0) {
141+
if (!quiet) {
142+
console.error(
143+
colors.red('✗') +
144+
` Found ${failures.length} ${pluralize('file', { count: failures.length })} without ESM-compatible named exports:`,
145+
)
146+
for (const failure of failures) {
147+
const relativePath = path.relative(distDir, failure.path)
148+
console.error(` ${colors.red('✗')} ${relativePath}`)
149+
console.error(` ${failure.reason}`)
150+
}
151+
console.error(
152+
'\n' +
153+
colors.yellow('Hint:') +
154+
' Use module.exports = { foo, bar } pattern for ESM compatibility',
155+
)
156+
}
157+
process.exitCode = 1
158+
} else {
159+
if (!quiet) {
160+
console.log(
161+
colors.green('✓') +
162+
` Validated ${checked.length} ${pluralize('file', { count: checked.length })} - all have ESM-compatible named exports`,
163+
)
164+
}
165+
}
166+
}
167+
168+
main().catch(error => {
169+
console.error(`${colors.red('✗')} Validation failed:`, error.message)
170+
process.exitCode = 1
171+
})

0 commit comments

Comments
 (0)