|
| 1 | +#!/usr/bin/env node |
| 2 | +/** |
| 3 | + * check-undeclared-deps.js |
| 4 | + * |
| 5 | + * Scans all runtime imports in src/ and verifies every bare-specifier package |
| 6 | + * is listed in package.json `dependencies`. Reports any packages that are |
| 7 | + * imported but not declared — catching issues like an accidentally-removed |
| 8 | + * entry or a package that only exists in a contributor's local node_modules. |
| 9 | + * |
| 10 | + * Usage: |
| 11 | + * node scripts/check-undeclared-deps.js |
| 12 | + * npm run check:deps |
| 13 | + * |
| 14 | + * Exit codes: |
| 15 | + * 0 — all imports are declared |
| 16 | + * 1 — one or more undeclared imports found |
| 17 | + */ |
| 18 | + |
| 19 | +import { readFileSync, readdirSync, statSync } from 'fs'; |
| 20 | +import { join, extname } from 'path'; |
| 21 | +import { fileURLToPath } from 'url'; |
| 22 | +import { createRequire } from 'module'; |
| 23 | + |
| 24 | +const require = createRequire(import.meta.url); |
| 25 | +const __dirname = fileURLToPath(new URL('.', import.meta.url)); |
| 26 | +const ROOT = join(__dirname, '..'); |
| 27 | +const SRC_DIR = join(ROOT, 'src'); |
| 28 | +const PKG_PATH = join(ROOT, 'package.json'); |
| 29 | + |
| 30 | +// ── Load declared dependencies ──────────────────────────────────────────────── |
| 31 | +const pkg = JSON.parse(readFileSync(PKG_PATH, 'utf8')); |
| 32 | +const declared = new Set([ |
| 33 | + ...Object.keys(pkg.dependencies ?? {}), |
| 34 | + // devDependencies intentionally excluded — runtime imports must be in dependencies |
| 35 | +]); |
| 36 | + |
| 37 | +// Node.js built-in modules — never need to be in package.json |
| 38 | +const BUILTINS = new Set([ |
| 39 | + 'assert', 'async_hooks', 'buffer', 'child_process', 'cluster', 'console', |
| 40 | + 'constants', 'crypto', 'dgram', 'diagnostics_channel', 'dns', 'domain', |
| 41 | + 'events', 'fs', 'http', 'http2', 'https', 'inspector', 'module', 'net', |
| 42 | + 'os', 'path', 'perf_hooks', 'process', 'punycode', 'querystring', |
| 43 | + 'readline', 'repl', 'stream', 'string_decoder', 'sys', 'timers', |
| 44 | + 'tls', 'trace_events', 'tty', 'url', 'util', 'v8', 'vm', 'worker_threads', |
| 45 | + 'zlib', |
| 46 | +]); |
| 47 | + |
| 48 | +// ── Collect all .ts source files under src/ ─────────────────────────────────── |
| 49 | +function collectFiles(dir, results = []) { |
| 50 | + for (const entry of readdirSync(dir)) { |
| 51 | + const full = join(dir, entry); |
| 52 | + const stat = statSync(full); |
| 53 | + if (stat.isDirectory()) { |
| 54 | + collectFiles(full, results); |
| 55 | + } else if (['.ts', '.tsx'].includes(extname(entry))) { |
| 56 | + results.push(full); |
| 57 | + } |
| 58 | + } |
| 59 | + return results; |
| 60 | +} |
| 61 | + |
| 62 | +// ── Extract bare-specifier imports from source text ─────────────────────────── |
| 63 | +// Matches: |
| 64 | +// import ... from 'pkg' |
| 65 | +// import ... from "pkg" |
| 66 | +// import('pkg') |
| 67 | +// require('pkg') |
| 68 | +// export ... from 'pkg' |
| 69 | +const IMPORT_RE = |
| 70 | + /(?:import|export)\s+(?:type\s+)?(?:[^'"]*from\s+)?['"]([^'"]+)['"]/g; |
| 71 | +const DYNAMIC_RE = /(?:import|require)\s*\(\s*['"]([^'"]+)['"]\s*\)/g; |
| 72 | + |
| 73 | +function extractPackageNames(source) { |
| 74 | + const names = new Set(); |
| 75 | + for (const re of [IMPORT_RE, DYNAMIC_RE]) { |
| 76 | + re.lastIndex = 0; |
| 77 | + let match; |
| 78 | + while ((match = re.exec(source)) !== null) { |
| 79 | + const specifier = match[1]; |
| 80 | + // Relative and absolute paths are not packages |
| 81 | + if (specifier.startsWith('.') || specifier.startsWith('/')) continue; |
| 82 | + // node: protocol builtins |
| 83 | + if (specifier.startsWith('node:')) continue; |
| 84 | + // Scoped package: @scope/name → root is @scope/name |
| 85 | + // Un-scoped package: pkg/sub/path → root is pkg |
| 86 | + const root = specifier.startsWith('@') |
| 87 | + ? specifier.split('/').slice(0, 2).join('/') |
| 88 | + : specifier.split('/')[0]; |
| 89 | + names.add(root); |
| 90 | + } |
| 91 | + } |
| 92 | + return names; |
| 93 | +} |
| 94 | + |
| 95 | +// ── Main ────────────────────────────────────────────────────────────────────── |
| 96 | +const files = collectFiles(SRC_DIR); |
| 97 | +const undeclared = new Set(); |
| 98 | + |
| 99 | +for (const file of files) { |
| 100 | + const source = readFileSync(file, 'utf8'); |
| 101 | + for (const pkg of extractPackageNames(source)) { |
| 102 | + if (!BUILTINS.has(pkg) && !declared.has(pkg)) { |
| 103 | + undeclared.add(pkg); |
| 104 | + console.error(` [UNDECLARED] "${pkg}" — imported in ${file.replace(ROOT + '/', '')}`); |
| 105 | + } |
| 106 | + } |
| 107 | +} |
| 108 | + |
| 109 | +if (undeclared.size > 0) { |
| 110 | + console.error( |
| 111 | + `\n✖ ${undeclared.size} undeclared package(s) found. Add them to package.json "dependencies" and re-run npm ci.\n` |
| 112 | + ); |
| 113 | + process.exit(1); |
| 114 | +} |
| 115 | + |
| 116 | +console.log(`✔ All imports match package.json dependencies (${files.length} files scanned).`); |
0 commit comments