Skip to content

Commit f95d2a9

Browse files
committed
refactor(scripts): organize validation scripts into subdirectory
Move validation scripts from scripts/validate-*.mjs to scripts/validate/*.mjs for better organization. Remove validate- prefix from filenames for consistency with socket-registry and socket-lib.
1 parent 40d1abe commit f95d2a9

File tree

7 files changed

+1417
-0
lines changed

7 files changed

+1417
-0
lines changed

scripts/validate/bundle-deps.mjs

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
/**
2+
* @fileoverview Validates that bundled vs external dependencies are correctly declared in package.json.
3+
*
4+
* Rules:
5+
* - Bundled packages (code copied into dist/) should be in devDependencies
6+
* - External packages (require() calls in dist/) should be in dependencies or peerDependencies
7+
* - Packages used only for building should be in devDependencies
8+
*
9+
* This ensures consumers install only what they need.
10+
*/
11+
12+
import { promises as fs } from 'node:fs'
13+
import { builtinModules } from 'node:module'
14+
import path from 'node:path'
15+
import { fileURLToPath } from 'node:url'
16+
17+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
18+
const rootPath = path.join(__dirname, '..')
19+
20+
// Node.js builtins to ignore (including node: prefix variants)
21+
const BUILTIN_MODULES = new Set([
22+
...builtinModules,
23+
...builtinModules.map(m => `node:${m}`),
24+
])
25+
26+
/**
27+
* Find all JavaScript files in dist directory.
28+
*/
29+
async function findDistFiles(distPath) {
30+
const files = []
31+
32+
try {
33+
const entries = await fs.readdir(distPath, { withFileTypes: true })
34+
35+
for (const entry of entries) {
36+
const fullPath = path.join(distPath, entry.name)
37+
38+
if (entry.isDirectory()) {
39+
files.push(...(await findDistFiles(fullPath)))
40+
} else if (
41+
entry.name.endsWith('.js') ||
42+
entry.name.endsWith('.mjs') ||
43+
entry.name.endsWith('.cjs')
44+
) {
45+
files.push(fullPath)
46+
}
47+
}
48+
} catch {
49+
// Directory doesn't exist or can't be read
50+
return []
51+
}
52+
53+
return files
54+
}
55+
56+
/**
57+
* Check if a string is a valid package specifier.
58+
*/
59+
function isValidPackageSpecifier(specifier) {
60+
// Relative imports
61+
if (specifier.startsWith('.') || specifier.startsWith('/')) {
62+
return false
63+
}
64+
65+
// Subpath imports (Node.js internal imports starting with #)
66+
if (specifier.startsWith('#')) {
67+
return false
68+
}
69+
70+
// Filter out invalid patterns
71+
if (
72+
specifier.includes('${') ||
73+
specifier.includes('"}') ||
74+
specifier.includes('`') ||
75+
specifier === 'true' ||
76+
specifier === 'false' ||
77+
specifier === 'null' ||
78+
specifier === 'undefined' ||
79+
specifier === 'name' ||
80+
specifier === 'dependencies' ||
81+
specifier === 'devDependencies' ||
82+
specifier === 'peerDependencies' ||
83+
specifier === 'version' ||
84+
specifier === 'description' ||
85+
specifier.length === 0 ||
86+
// Filter out strings that look like code fragments
87+
specifier.includes('\n') ||
88+
specifier.includes(';') ||
89+
specifier.includes('function') ||
90+
specifier.includes('const ') ||
91+
specifier.includes('let ') ||
92+
specifier.includes('var ')
93+
) {
94+
return false
95+
}
96+
97+
return true
98+
}
99+
100+
/**
101+
* Extract external package names from require() and import statements in built files.
102+
*/
103+
async function extractExternalPackages(filePath) {
104+
const content = await fs.readFile(filePath, 'utf8')
105+
const externals = new Set()
106+
107+
// Match require('package') or require("package")
108+
const requirePattern = /require\s*\(\s*['"]([^'"]+)['"]\s*\)/g
109+
// Match import from 'package' or import from "package"
110+
const importPattern = /(?:from|import)\s+['"]([^'"]+)['"]/g
111+
// Match dynamic import() calls
112+
const dynamicImportPattern = /import\s*\(\s*['"]([^'"]+)['"]\s*\)/g
113+
114+
let match
115+
116+
// Extract from require()
117+
while ((match = requirePattern.exec(content)) !== null) {
118+
const specifier = match[1]
119+
// Skip internal src/external/ wrapper paths (used by socket-lib pattern)
120+
if (specifier.includes('/external/')) {
121+
continue
122+
}
123+
if (isValidPackageSpecifier(specifier)) {
124+
externals.add(specifier)
125+
}
126+
}
127+
128+
// Extract from import statements
129+
while ((match = importPattern.exec(content)) !== null) {
130+
const specifier = match[1]
131+
// Skip internal src/external/ wrapper paths (used by socket-lib pattern)
132+
if (specifier.includes('/external/')) {
133+
continue
134+
}
135+
if (isValidPackageSpecifier(specifier)) {
136+
externals.add(specifier)
137+
}
138+
}
139+
140+
// Extract from dynamic import()
141+
while ((match = dynamicImportPattern.exec(content)) !== null) {
142+
const specifier = match[1]
143+
// Skip internal src/external/ wrapper paths (used by socket-lib pattern)
144+
if (specifier.includes('/external/')) {
145+
continue
146+
}
147+
if (isValidPackageSpecifier(specifier)) {
148+
externals.add(specifier)
149+
}
150+
}
151+
152+
return externals
153+
}
154+
155+
/**
156+
* Extract bundled package names from node_modules paths in comments and code.
157+
*/
158+
async function extractBundledPackages(filePath) {
159+
const content = await fs.readFile(filePath, 'utf8')
160+
const bundled = new Set()
161+
162+
// Match node_modules paths in comments: node_modules/.pnpm/@scope+package@version/...
163+
// or node_modules/@scope/package/...
164+
// or node_modules/package/...
165+
const nodeModulesPattern =
166+
/node_modules\/(?:\.pnpm\/)?(@[^/]+\+[^@/]+|@[^/]+\/[^/]+|[^/@]+)/g
167+
168+
let match
169+
while ((match = nodeModulesPattern.exec(content)) !== null) {
170+
let packageName = match[1]
171+
172+
// Handle pnpm path format: @scope+package -> @scope/package
173+
if (packageName.includes('+')) {
174+
packageName = packageName.replace('+', '/')
175+
}
176+
177+
// Filter out invalid package names (contains special chars, code fragments, etc.)
178+
if (
179+
packageName.includes('"') ||
180+
packageName.includes("'") ||
181+
packageName.includes('`') ||
182+
packageName.includes('${') ||
183+
packageName.includes('\\') ||
184+
packageName.includes(';') ||
185+
packageName.includes('\n') ||
186+
packageName.includes('function') ||
187+
packageName.includes('const') ||
188+
packageName.includes('let') ||
189+
packageName.includes('var') ||
190+
packageName.includes('=') ||
191+
packageName.includes('{') ||
192+
packageName.includes('}') ||
193+
packageName.includes('[') ||
194+
packageName.includes(']') ||
195+
packageName.includes('(') ||
196+
packageName.includes(')') ||
197+
// Filter out common false positives (strings that appear in code but aren't packages)
198+
packageName === 'bin' ||
199+
packageName === '.bin' ||
200+
packageName === 'npm' ||
201+
packageName === 'node' ||
202+
packageName === 'pnpm' ||
203+
packageName === 'yarn' ||
204+
packageName.length === 0 ||
205+
// npm package name max length
206+
packageName.length > 214
207+
) {
208+
continue
209+
}
210+
211+
bundled.add(packageName)
212+
}
213+
214+
return bundled
215+
}
216+
217+
/**
218+
* Get package name from a module specifier (strip subpaths).
219+
*/
220+
function getPackageName(specifier) {
221+
// Relative imports are not packages
222+
if (specifier.startsWith('.') || specifier.startsWith('/')) {
223+
return null
224+
}
225+
226+
// Subpath imports (Node.js internal imports starting with #)
227+
if (specifier.startsWith('#')) {
228+
return null
229+
}
230+
231+
// Filter out template strings, boolean strings, and other non-package patterns
232+
if (
233+
specifier.includes('${') ||
234+
specifier.includes('"}') ||
235+
specifier.includes('`') ||
236+
specifier === 'true' ||
237+
specifier === 'false' ||
238+
specifier === 'null' ||
239+
specifier === 'undefined' ||
240+
specifier.length === 0 ||
241+
// Filter out strings that look like code fragments
242+
specifier.includes('\n') ||
243+
specifier.includes(';') ||
244+
specifier.includes('function') ||
245+
specifier.includes('const ') ||
246+
specifier.includes('let ') ||
247+
specifier.includes('var ') ||
248+
// Filter out common non-package strings
249+
specifier.includes('"') ||
250+
specifier.includes("'") ||
251+
specifier.includes('\\')
252+
) {
253+
return null
254+
}
255+
256+
// Scoped package: @scope/package or @scope/package/subpath
257+
if (specifier.startsWith('@')) {
258+
const parts = specifier.split('/')
259+
if (parts.length >= 2) {
260+
return `${parts[0]}/${parts[1]}`
261+
}
262+
return null
263+
}
264+
265+
// Regular package: package or package/subpath
266+
const parts = specifier.split('/')
267+
return parts[0]
268+
}
269+
270+
/**
271+
* Read and parse package.json.
272+
*/
273+
async function readPackageJson() {
274+
const packageJsonPath = path.join(rootPath, 'package.json')
275+
const content = await fs.readFile(packageJsonPath, 'utf8')
276+
return JSON.parse(content)
277+
}
278+
279+
/**
280+
* Validate bundle dependencies.
281+
*/
282+
async function validateBundleDeps() {
283+
const distPath = path.join(rootPath, 'dist')
284+
const pkg = await readPackageJson()
285+
286+
const dependencies = new Set(Object.keys(pkg.dependencies || {}))
287+
const devDependencies = new Set(Object.keys(pkg.devDependencies || {}))
288+
const peerDependencies = new Set(Object.keys(pkg.peerDependencies || {}))
289+
290+
// Find all dist files
291+
const distFiles = await findDistFiles(distPath)
292+
293+
if (distFiles.length === 0) {
294+
console.log('ℹ No dist files found - run build first')
295+
return { violations: [], warnings: [] }
296+
}
297+
298+
// Collect all external and bundled packages
299+
const allExternals = new Set()
300+
const allBundled = new Set()
301+
302+
for (const file of distFiles) {
303+
const externals = await extractExternalPackages(file)
304+
const bundled = await extractBundledPackages(file)
305+
306+
for (const ext of externals) {
307+
const packageName = getPackageName(ext)
308+
if (packageName && !BUILTIN_MODULES.has(packageName)) {
309+
allExternals.add(packageName)
310+
}
311+
}
312+
313+
for (const bun of bundled) {
314+
allBundled.add(bun)
315+
}
316+
}
317+
318+
const violations = []
319+
const warnings = []
320+
321+
// Validate external packages are in dependencies or peerDependencies
322+
for (const packageName of allExternals) {
323+
if (!dependencies.has(packageName) && !peerDependencies.has(packageName)) {
324+
violations.push({
325+
type: 'external-not-in-deps',
326+
package: packageName,
327+
message: `External package "${packageName}" is marked external but not in dependencies`,
328+
fix: devDependencies.has(packageName)
329+
? `RECOMMENDED: Remove "${packageName}" from esbuild's "external" array to bundle it (keep in devDependencies)\n OR: Move "${packageName}" to dependencies if it must stay external`
330+
: `RECOMMENDED: Remove "${packageName}" from esbuild's "external" array to bundle it\n OR: Add "${packageName}" to dependencies if it must stay external`,
331+
})
332+
}
333+
}
334+
335+
// Validate bundled packages are in devDependencies (not dependencies)
336+
for (const packageName of allBundled) {
337+
if (dependencies.has(packageName)) {
338+
violations.push({
339+
type: 'bundled-in-deps',
340+
package: packageName,
341+
message: `Bundled package "${packageName}" should be in devDependencies, not dependencies`,
342+
fix: `Move "${packageName}" from dependencies to devDependencies (code is bundled into dist/)`,
343+
})
344+
}
345+
346+
if (!devDependencies.has(packageName) && !dependencies.has(packageName)) {
347+
warnings.push({
348+
type: 'bundled-not-declared',
349+
package: packageName,
350+
message: `Bundled package "${packageName}" is not declared in devDependencies`,
351+
fix: `Add "${packageName}" to devDependencies`,
352+
})
353+
}
354+
}
355+
356+
return { violations, warnings }
357+
}
358+
359+
async function main() {
360+
try {
361+
const { violations, warnings } = await validateBundleDeps()
362+
363+
if (violations.length === 0 && warnings.length === 0) {
364+
console.log('✓ Bundle dependencies validation passed')
365+
process.exitCode = 0
366+
return
367+
}
368+
369+
if (violations.length > 0) {
370+
console.error('❌ Bundle dependencies validation failed\n')
371+
372+
for (const violation of violations) {
373+
console.error(` ${violation.message}`)
374+
console.error(` ${violation.fix}`)
375+
console.error('')
376+
}
377+
}
378+
379+
if (warnings.length > 0) {
380+
console.log('⚠ Warnings:\n')
381+
382+
for (const warning of warnings) {
383+
console.log(` ${warning.message}`)
384+
console.log(` ${warning.fix}\n`)
385+
}
386+
}
387+
388+
// Only fail on violations, not warnings
389+
process.exitCode = violations.length > 0 ? 1 : 0
390+
} catch (error) {
391+
console.error('Validation failed:', error.message)
392+
process.exitCode = 1
393+
}
394+
}
395+
396+
main()

0 commit comments

Comments
 (0)