|
| 1 | +#!/usr/bin/env bun |
| 2 | +/** |
| 3 | + * Import-specifier hygiene for the two shapes that break Turbopack dev while passing |
| 4 | + * the webpack production build — a divergence CI cannot see, because `next dev` runs |
| 5 | + * Turbopack and `next build` runs webpack. |
| 6 | + * |
| 7 | + * 1. `.js` extension specifiers in TypeScript source. |
| 8 | + * |
| 9 | + * webpack maps `./errors.js` -> `./errors.ts` via `resolve.extensionAlias`. |
| 10 | + * Turbopack has no equivalent (vercel/next.js#82945), so the same import is a hard |
| 11 | + * `Module not found: Can't resolve './errors.js'`. `packages/utils/src/index.ts` |
| 12 | + * shipped 12 of these; every route whose graph reached the `@sim/utils` barrel 500'd |
| 13 | + * in dev while CI stayed green. The repo is on `moduleResolution: "bundler"`, so the |
| 14 | + * extensions were never required in the first place. |
| 15 | + * |
| 16 | + * 2. Bare `@sim/<pkg>` barrel imports when the package publishes a subpath export. |
| 17 | + * |
| 18 | + * `@sim/utils/helpers` resolves straight to one module. `@sim/utils` pulls the barrel |
| 19 | + * and everything it re-exports — which is how a single `chunkArray` import reached |
| 20 | + * the broken specifiers above. Subpath imports are already the documented convention |
| 21 | + * (CLAUDE.md, "Common Utilities"); this makes the convention enforceable. |
| 22 | + * |
| 23 | + * Usage: |
| 24 | + * bun run scripts/check-import-specifiers.ts |
| 25 | + * bun run scripts/check-import-specifiers.ts --verbose |
| 26 | + */ |
| 27 | +import { readdirSync, readFileSync } from 'node:fs' |
| 28 | +import { dirname, join, relative, resolve } from 'node:path' |
| 29 | +import { fileURLToPath } from 'node:url' |
| 30 | + |
| 31 | +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) |
| 32 | +const ROOT = resolve(SCRIPT_DIR, '..') |
| 33 | +const SCAN_DIRS = ['apps/sim', 'apps/realtime', 'packages'] |
| 34 | +const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', 'generated', '.turbo']) |
| 35 | + |
| 36 | +/** Any specifier ending in `.js`/`.jsx`/`.mjs` — relative or aliased. */ |
| 37 | +const JS_SPECIFIER_RE = |
| 38 | + /(?:^|\n)\s*(?:import|export)[\s\S]*?from\s*['"]((?:\.|@\/|@sim\/)[^'"]*\.(?:js|jsx|mjs))['"]/g |
| 39 | +/** `import ... from '@sim/pkg'` with no subpath. */ |
| 40 | +const BARE_SIM_BARREL_RE = /(?:^|\n)\s*import[\s\S]*?from\s*['"](@sim\/[a-z0-9-]+)['"]/g |
| 41 | + |
| 42 | +/** |
| 43 | + * Packages that must be imported by subpath. Opt-in rather than opt-out: `@sim/emcn`, |
| 44 | + * `@sim/desktop-bridge` and friends are barrel-first by design, and flagging them would |
| 45 | + * bury the one rule that matters. `@sim/utils` is subpath-only by documented convention |
| 46 | + * (CLAUDE.md, "Common Utilities") and is the package whose barrel took prod routes down. |
| 47 | + */ |
| 48 | +const SUBPATH_REQUIRED = new Set(['@sim/utils']) |
| 49 | + |
| 50 | +/** |
| 51 | + * Only source a bundler will compile. Vitest and standalone `bun run` scripts resolve |
| 52 | + * `.js` -> `.ts` on their own, so flagging their specifiers is noise — and a check that |
| 53 | + * cries wolf is a check someone deletes. |
| 54 | + */ |
| 55 | +function isCompiledSource(full: string, name: string): boolean { |
| 56 | + if (!/\.(ts|tsx)$/.test(name) || name.endsWith('.d.ts')) return false |
| 57 | + if (/\.(test|spec)\.tsx?$/.test(name)) return false |
| 58 | + const rel = relative(ROOT, full) |
| 59 | + return !rel.startsWith('apps/sim/scripts/') && !rel.startsWith('apps/realtime/scripts/') |
| 60 | +} |
| 61 | + |
| 62 | +function walk(dir: string, acc: string[] = []): string[] { |
| 63 | + let entries |
| 64 | + try { |
| 65 | + entries = readdirSync(dir, { withFileTypes: true }) |
| 66 | + } catch { |
| 67 | + return acc |
| 68 | + } |
| 69 | + for (const e of entries) { |
| 70 | + if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue |
| 71 | + const full = join(dir, e.name) |
| 72 | + if (e.isDirectory()) walk(full, acc) |
| 73 | + else if (isCompiledSource(full, e.name)) acc.push(full) |
| 74 | + } |
| 75 | + return acc |
| 76 | +} |
| 77 | + |
| 78 | +/** Subpath exports a package publishes, e.g. `@sim/utils` -> Set{'id','helpers',...}. */ |
| 79 | +function subpathExports(pkg: string): Set<string> { |
| 80 | + const name = pkg.replace('@sim/', '') |
| 81 | + const out = new Set<string>() |
| 82 | + try { |
| 83 | + const json = JSON.parse(readFileSync(join(ROOT, 'packages', name, 'package.json'), 'utf8')) |
| 84 | + for (const key of Object.keys(json.exports ?? {})) { |
| 85 | + if (key.startsWith('./')) out.add(key.slice(2)) |
| 86 | + } |
| 87 | + } catch { |
| 88 | + /* not a workspace package we can inspect */ |
| 89 | + } |
| 90 | + return out |
| 91 | +} |
| 92 | + |
| 93 | +interface Violation { |
| 94 | + file: string |
| 95 | + line: number |
| 96 | + specifier: string |
| 97 | + kind: 'js-extension' | 'bare-barrel' |
| 98 | + hint: string |
| 99 | +} |
| 100 | + |
| 101 | +const files = SCAN_DIRS.flatMap((d) => walk(join(ROOT, d))) |
| 102 | +const violations: Violation[] = [] |
| 103 | +const subpathCache = new Map<string, Set<string>>() |
| 104 | + |
| 105 | +for (const file of files) { |
| 106 | + const src = readFileSync(file, 'utf8') |
| 107 | + const lineAt = (idx: number) => src.slice(0, idx).split('\n').length |
| 108 | + |
| 109 | + JS_SPECIFIER_RE.lastIndex = 0 |
| 110 | + let m = JS_SPECIFIER_RE.exec(src) |
| 111 | + while (m !== null) { |
| 112 | + violations.push({ |
| 113 | + file: relative(ROOT, file), |
| 114 | + line: lineAt(m.index), |
| 115 | + specifier: m[1], |
| 116 | + kind: 'js-extension', |
| 117 | + hint: `drop the extension: '${m[1].replace(/\.(js|jsx|mjs)$/, '')}'`, |
| 118 | + }) |
| 119 | + m = JS_SPECIFIER_RE.exec(src) |
| 120 | + } |
| 121 | + |
| 122 | + BARE_SIM_BARREL_RE.lastIndex = 0 |
| 123 | + m = BARE_SIM_BARREL_RE.exec(src) |
| 124 | + while (m !== null) { |
| 125 | + const pkg = m[1] |
| 126 | + if (SUBPATH_REQUIRED.has(pkg)) { |
| 127 | + if (!subpathCache.has(pkg)) subpathCache.set(pkg, subpathExports(pkg)) |
| 128 | + const subs = subpathCache.get(pkg) as Set<string> |
| 129 | + if (subs.size > 0) { |
| 130 | + violations.push({ |
| 131 | + file: relative(ROOT, file), |
| 132 | + line: lineAt(m.index), |
| 133 | + specifier: pkg, |
| 134 | + kind: 'bare-barrel', |
| 135 | + hint: `import from a subpath instead, e.g. '${pkg}/${[...subs].sort()[0]}'`, |
| 136 | + }) |
| 137 | + } |
| 138 | + } |
| 139 | + m = BARE_SIM_BARREL_RE.exec(src) |
| 140 | + } |
| 141 | +} |
| 142 | + |
| 143 | +const verbose = process.argv.includes('--verbose') |
| 144 | + |
| 145 | +if (violations.length === 0) { |
| 146 | + console.log(`✓ check-import-specifiers: ${files.length} files, no risky specifiers`) |
| 147 | + process.exit(0) |
| 148 | +} |
| 149 | + |
| 150 | +const byKind = { |
| 151 | + 'js-extension': violations.filter((v) => v.kind === 'js-extension'), |
| 152 | + 'bare-barrel': violations.filter((v) => v.kind === 'bare-barrel'), |
| 153 | +} |
| 154 | + |
| 155 | +if (byKind['js-extension'].length) { |
| 156 | + console.error(`\n✗ ${byKind['js-extension'].length} '.js' specifier(s) in TypeScript source:\n`) |
| 157 | + for (const v of byKind['js-extension']) { |
| 158 | + console.error(` ${v.file}:${v.line} '${v.specifier}'`) |
| 159 | + console.error(` ${v.hint}`) |
| 160 | + } |
| 161 | + console.error( |
| 162 | + "\n webpack rewrites '.js' -> '.ts' via resolve.extensionAlias; Turbopack does not\n" + |
| 163 | + " (vercel/next.js#82945). So these build green in CI ('next build' = webpack) and\n" + |
| 164 | + " hard-fail every dev server ('next dev' = Turbopack) with Module not found.\n" + |
| 165 | + ' This repo uses moduleResolution: "bundler" — the extension is never needed.\n' |
| 166 | + ) |
| 167 | +} |
| 168 | + |
| 169 | +if (byKind['bare-barrel'].length) { |
| 170 | + console.error(`\n✗ ${byKind['bare-barrel'].length} bare @sim/* barrel import(s):\n`) |
| 171 | + for (const v of byKind['bare-barrel']) { |
| 172 | + console.error(` ${v.file}:${v.line} '${v.specifier}'`) |
| 173 | + console.error(` ${v.hint}`) |
| 174 | + } |
| 175 | + console.error( |
| 176 | + '\n A barrel import pulls every module the barrel re-exports, so one helper drags in\n' + |
| 177 | + ' the whole package — and any single bad specifier inside it takes the importer down.\n' |
| 178 | + ) |
| 179 | +} |
| 180 | + |
| 181 | +if (verbose) console.error(`\nscanned ${files.length} files`) |
| 182 | +process.exit(1) |
0 commit comments