Skip to content

Commit 3dcb0b3

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. Use inline normalizePath to avoid circular dependency during build. Temporarily disable validation until export patterns are fixed.
1 parent 728d222 commit 3dcb0b3

File tree

3 files changed

+374
-0
lines changed

3 files changed

+374
-0
lines changed

scripts/fix/main.mjs

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
/**
2+
* @fileoverview Orchestrates all post-build fix scripts.
3+
* Runs generate-package-exports and fix-external-imports in sequence.
4+
*/
5+
6+
import { isQuiet } from '#socketsecurity/lib/argv/flags'
7+
import { getDefaultLogger } from '#socketsecurity/lib/logger'
8+
import { printFooter, printHeader } from '#socketsecurity/lib/stdio/header'
9+
10+
import { runSequence } from '../utils/run-command.mjs'
11+
12+
const logger = getDefaultLogger()
13+
14+
async function main() {
15+
const verbose = process.argv.includes('--verbose')
16+
const quiet = isQuiet()
17+
18+
if (!quiet) {
19+
printHeader('Fixing Build Output')
20+
}
21+
22+
const fixArgs = []
23+
if (quiet) {
24+
fixArgs.push('--quiet')
25+
}
26+
if (verbose) {
27+
fixArgs.push('--verbose')
28+
}
29+
30+
const exitCode = await runSequence([
31+
{
32+
args: ['scripts/fix/generate-package-exports.mjs', ...fixArgs],
33+
command: 'node',
34+
},
35+
{
36+
args: ['scripts/fix/path-aliases.mjs', ...fixArgs],
37+
command: 'node',
38+
},
39+
{
40+
args: ['scripts/fix/external-imports.mjs', ...fixArgs],
41+
command: 'node',
42+
},
43+
{
44+
args: ['scripts/fix/commonjs-exports.mjs', ...fixArgs],
45+
command: 'node',
46+
},
47+
// TEMP: Re-enable once export patterns are fixed
48+
// {
49+
// args: ['scripts/validate/esm-named-exports.mjs', ...fixArgs],
50+
// command: 'node',
51+
// },
52+
// {
53+
// args: ['scripts/validate/dist-exports.mjs', ...fixArgs],
54+
// command: 'node',
55+
// },
56+
])
57+
58+
if (!quiet) {
59+
printFooter()
60+
}
61+
62+
if (exitCode !== 0) {
63+
logger.error('Build fixing failed')
64+
process.exitCode = exitCode
65+
}
66+
}
67+
68+
main().catch(error => {
69+
logger.error(`Build fixing failed: ${error.message || error}`)
70+
process.exitCode = 1
71+
})

scripts/validate/dist-exports.mjs

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

0 commit comments

Comments
 (0)