mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-01 14:59:19 +08:00
de02bc6ad5
Review round: isGeneratedPath split the repo-relative path on '/', but
path.relative returns backslashes on Windows, so '.source' and 'node_modules'
never matched a segment and generated output was treated as source. The repo
does support Windows dev — scripts/setup branches on win32.
The finding named one site; there were three. isCompiledSource compared against
'apps/sim/scripts/' with the same assumption, and workspaceFor matched
`${w.dir}/`, which on Windows never matches an absolute path and would have
dropped every file out of its own workspace — silently disabling tsconfig paths
resolution rather than erroring.
Normalized behind a repoPath() helper, with workspaceFor using path.sep against
absolute paths. Reported paths now go through it too, so output is identical on
either platform. spec.split('/') is left alone: import specifiers are always
'/'-separated regardless of host.
Verified by simulating win32 separators through the same predicates, and posix
behaviour is unchanged at 37,437 specifiers.
406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
/**
|
|
* Resolves every first-party import specifier the way Turbopack does, failing on any that
|
|
* does not land on a real file.
|
|
*
|
|
* `next build` runs webpack and `next dev` runs Turbopack, and they do not resolve the same
|
|
* specifiers. webpack rewrites `./errors.js` -> `./errors.ts` via `resolve.extensionAlias`;
|
|
* Turbopack has no equivalent (vercel/next.js#82945). So that shape builds green in CI and
|
|
* 500s on every developer's machine — CI never runs the Turbopack graph.
|
|
*
|
|
* Running real resolution rather than matching that one mistake covers the whole
|
|
* "Module not found" class: bad extensions, typo'd paths, stale importers of moved files,
|
|
* dead `@/` aliases, and `@sim/*` subpaths a package does not export.
|
|
*
|
|
* Skipped: bare npm specifiers (node_modules' business, and flaky on install state),
|
|
* type-only imports (erased before resolution), and tests plus `apps/*/scripts/**`,
|
|
* which run under vitest and bun — both of which do resolve `.js` -> `.ts`.
|
|
*
|
|
* Usage: `bun run scripts/check-import-specifiers.ts [--verbose]`
|
|
*/
|
|
import { readdirSync, readFileSync, statSync } from 'node:fs'
|
|
import { dirname, join, relative, resolve, sep } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
|
|
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url))
|
|
const ROOT = resolve(SCRIPT_DIR, '..')
|
|
const SCAN_DIRS = ['apps/sim', 'apps/realtime', 'apps/docs', 'packages']
|
|
const SKIP_DIRS = new Set(['node_modules', '.next', 'dist', 'build', '.turbo'])
|
|
|
|
/**
|
|
* `.js` is listed because a real `foo.js` resolves fine. What does not happen is `./foo.js`
|
|
* falling back to `foo.ts` — that asymmetry is the bug this guard exists for.
|
|
*/
|
|
const EXTENSIONS = ['.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json']
|
|
|
|
/** Static value imports and re-exports. `import type` / `export type` are erased. */
|
|
const SPECIFIER_RE =
|
|
/(?:^|\n)\s*(?:import|export)\s+(?!type\s)(?:[\s\S]*?from\s*)?['"]([^'"]+)['"]/g
|
|
/** `import(...)` — resolved at call time, but the path still has to exist. */
|
|
const DYNAMIC_RE = /\bimport\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
/** Lazy `require()` is used here to break import cycles; those edges resolve like static ones. */
|
|
const REQUIRE_RE = /\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g
|
|
|
|
/**
|
|
* Subpath-only packages. Opt-in: `@sim/emcn` and `@sim/desktop-bridge` are barrel-first by
|
|
* design, so flagging them would bury the one rule that matters.
|
|
*/
|
|
const SUBPATH_REQUIRED = new Set(['@sim/utils'])
|
|
|
|
/** Repo-relative path, always `/`-separated — `relative()` yields `\` on Windows. */
|
|
function repoPath(absolute: string): string {
|
|
return relative(ROOT, absolute).replaceAll('\\', '/')
|
|
}
|
|
|
|
/** Only source a bundler compiles — see the "Deliberately NOT checked" note above. */
|
|
function isCompiledSource(full: string, name: string): boolean {
|
|
if (!/\.(ts|tsx)$/.test(name) || name.endsWith('.d.ts')) return false
|
|
if (/\.(test|spec)\.tsx?$/.test(name)) return false
|
|
const rel = repoPath(full)
|
|
return !rel.startsWith('apps/sim/scripts/') && !rel.startsWith('apps/realtime/scripts/')
|
|
}
|
|
|
|
function walk(dir: string, acc: string[] = []): string[] {
|
|
let entries
|
|
try {
|
|
entries = readdirSync(dir, { withFileTypes: true })
|
|
} catch {
|
|
return acc
|
|
}
|
|
for (const e of entries) {
|
|
if (e.name.startsWith('.') || SKIP_DIRS.has(e.name)) continue
|
|
const full = join(dir, e.name)
|
|
if (e.isDirectory()) walk(full, acc)
|
|
else if (isCompiledSource(full, e.name)) acc.push(full)
|
|
}
|
|
return acc
|
|
}
|
|
|
|
/**
|
|
* Build output the scanner will not read as source, so it cannot assert on its presence
|
|
* either — `apps/docs/.source` is generated by fumadocs-mdx and absent from a fresh checkout.
|
|
*
|
|
* Only the repo-relative portion is inspected: a git worktree lives under `.claude/`, which
|
|
* would otherwise make every specifier in the repo look generated.
|
|
*/
|
|
function isGeneratedPath(absolute: string): boolean {
|
|
const rel = repoPath(absolute)
|
|
if (rel.startsWith('..')) return true
|
|
return rel.split('/').some((segment) => segment.startsWith('.') || SKIP_DIRS.has(segment))
|
|
}
|
|
|
|
function isFile(p: string): boolean {
|
|
try {
|
|
return statSync(p).isFile()
|
|
} catch {
|
|
return false
|
|
}
|
|
}
|
|
|
|
/** `<base>`, `<base><ext>`, or `<base>/index<ext>`. */
|
|
function probe(base: string): string | null {
|
|
if (isFile(base)) return base
|
|
for (const ext of EXTENSIONS) {
|
|
if (isFile(base + ext)) return base + ext
|
|
}
|
|
for (const ext of EXTENSIONS) {
|
|
const idx = join(base, `index${ext}`)
|
|
if (isFile(idx)) return idx
|
|
}
|
|
return null
|
|
}
|
|
|
|
/**
|
|
* `paths` from the workspace owning a file. Per-workspace, not global: `@/*` differs between
|
|
* apps/sim and apps/realtime, and apps/sim maps `@sim/db/*` straight at the package directory,
|
|
* bypassing its `exports` map.
|
|
*/
|
|
interface PathRule {
|
|
prefix: string
|
|
suffix: string
|
|
wildcard: boolean
|
|
/**
|
|
* Absolute targets, `*` substituted at match time with `replaceAll` — Node's `exports`
|
|
* resolver uses a global regex, so a target with two wildcards fills both.
|
|
*/
|
|
targets: string[]
|
|
}
|
|
|
|
interface Workspace {
|
|
dir: string
|
|
paths: PathRule[]
|
|
}
|
|
|
|
const workspaces: Workspace[] = []
|
|
for (const group of ['apps', 'packages']) {
|
|
let names: string[]
|
|
try {
|
|
names = readdirSync(join(ROOT, group))
|
|
} catch {
|
|
continue
|
|
}
|
|
for (const name of names) {
|
|
const dir = join(ROOT, group, name)
|
|
const tsconfig = join(dir, 'tsconfig.json')
|
|
if (!isFile(tsconfig)) continue
|
|
try {
|
|
const raw = readFileSync(tsconfig, 'utf8').replace(/^\s*\/\/.*$/gm, '')
|
|
const paths = JSON.parse(raw)?.compilerOptions?.paths ?? {}
|
|
const entries: PathRule[] = Object.entries<string[]>(paths).map(([pattern, targets]) => {
|
|
const [prefix, suffix = ''] = pattern.split('*')
|
|
return {
|
|
prefix,
|
|
suffix,
|
|
wildcard: pattern.includes('*'),
|
|
targets: targets.map((t) => resolve(dir, t)),
|
|
}
|
|
})
|
|
// Longest prefix wins, matching TypeScript's own precedence.
|
|
entries.sort((a, b) => b.prefix.length - a.prefix.length)
|
|
workspaces.push({ dir, paths: entries })
|
|
} catch {
|
|
/* unparseable tsconfig — skip rather than fail the whole run */
|
|
}
|
|
}
|
|
}
|
|
workspaces.sort((a, b) => b.dir.length - a.dir.length)
|
|
|
|
function workspaceFor(file: string): Workspace | undefined {
|
|
return workspaces.find((w) => file.startsWith(w.dir + sep))
|
|
}
|
|
|
|
/** Matched a tsconfig path, but every target is generated — distinct from missing (`null`). */
|
|
const GENERATED = Symbol('generated')
|
|
|
|
/** Resolve through the owning workspace's tsconfig `paths`. */
|
|
function resolveViaPaths(
|
|
spec: string,
|
|
importer: string
|
|
): string | null | undefined | typeof GENERATED {
|
|
const ws = workspaceFor(importer)
|
|
if (!ws) return undefined
|
|
for (const { prefix, suffix, wildcard, targets } of ws.paths) {
|
|
if (!spec.startsWith(prefix)) continue
|
|
if (!wildcard) {
|
|
if (spec !== prefix) continue
|
|
if (targets.every(isGeneratedPath)) return GENERATED
|
|
for (const t of targets) {
|
|
const hit = probe(t)
|
|
if (hit) return hit
|
|
}
|
|
return null
|
|
}
|
|
if (suffix && !spec.endsWith(suffix)) continue
|
|
const middle = spec.slice(prefix.length, suffix ? spec.length - suffix.length : undefined)
|
|
const filled = targets.map((t) => t.replaceAll('*', middle))
|
|
if (filled.every(isGeneratedPath)) return GENERATED
|
|
for (const t of filled) {
|
|
const hit = probe(t)
|
|
if (hit) return hit
|
|
}
|
|
return null
|
|
}
|
|
return undefined
|
|
}
|
|
|
|
/** Subpath -> target file, read from a workspace package's `exports` map. */
|
|
const pkgExportCache = new Map<string, Map<string, string> | null>()
|
|
function packageExports(pkg: string): Map<string, string> | null {
|
|
if (pkgExportCache.has(pkg)) return pkgExportCache.get(pkg) as Map<string, string> | null
|
|
const dir = join(ROOT, 'packages', pkg.replace('@sim/', ''))
|
|
let map: Map<string, string> | null = null
|
|
try {
|
|
const json = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8'))
|
|
if (json.name === pkg && json.exports) {
|
|
map = new Map()
|
|
for (const [key, val] of Object.entries<any>(json.exports)) {
|
|
const target = typeof val === 'string' ? val : (val?.default ?? val?.types)
|
|
if (typeof target === 'string') map.set(key, join(dir, target))
|
|
}
|
|
}
|
|
} catch {
|
|
/* not a workspace package, or unreadable */
|
|
}
|
|
pkgExportCache.set(pkg, map)
|
|
return map
|
|
}
|
|
|
|
type Outcome = { ok: true } | { ok: false; reason: string }
|
|
|
|
function resolveSpecifier(spec: string, importer: string): Outcome | null {
|
|
if (spec.startsWith('.')) {
|
|
const base = resolve(dirname(importer), spec)
|
|
if (isGeneratedPath(base)) return null
|
|
return probe(base) ? { ok: true } : { ok: false, reason: 'no file at that path' }
|
|
}
|
|
|
|
// tsconfig `paths` first — it legitimately overrides a package's exports map.
|
|
const viaPaths = resolveViaPaths(spec, importer)
|
|
if (viaPaths === GENERATED) return null
|
|
if (viaPaths) return { ok: true }
|
|
if (viaPaths === null) {
|
|
return {
|
|
ok: false,
|
|
reason: spec.startsWith('@/')
|
|
? "'@/' alias matches a tsconfig path but nothing is there"
|
|
: 'matches a tsconfig path but nothing is there',
|
|
}
|
|
}
|
|
|
|
if (spec.startsWith('@sim/')) {
|
|
const [, name, ...rest] = spec.split('/')
|
|
const pkg = `@sim/${name}`
|
|
const exports = packageExports(pkg)
|
|
if (!exports) return null // package not in packages/, or has no exports map
|
|
const key = rest.length ? `./${rest.join('/')}` : '.'
|
|
|
|
const exact = exports.get(key)
|
|
if (exact) {
|
|
if (isGeneratedPath(exact)) return null
|
|
return probe(exact) ? { ok: true } : { ok: false, reason: `${key} points at a missing file` }
|
|
}
|
|
|
|
// Wildcard subpaths, e.g. `"./*": "./src/*"` on @sim/emcn.
|
|
for (const [pattern, target] of exports) {
|
|
const star = pattern.indexOf('*')
|
|
if (star === -1) continue
|
|
const head = pattern.slice(0, star)
|
|
const tail = pattern.slice(star + 1)
|
|
if (!key.startsWith(head) || !key.endsWith(tail)) continue
|
|
const middle = key.slice(head.length, key.length - tail.length)
|
|
const filled = target.replaceAll('*', middle)
|
|
if (isGeneratedPath(filled)) return null
|
|
if (probe(filled)) return { ok: true }
|
|
return { ok: false, reason: `${pkg}'s '${pattern}' export has no file for '${key}'` }
|
|
}
|
|
|
|
return { ok: false, reason: `${pkg} does not export '${key}'` }
|
|
}
|
|
|
|
return null // bare npm specifier — not ours to verify
|
|
}
|
|
|
|
interface Violation {
|
|
file: string
|
|
line: number
|
|
specifier: string
|
|
kind: 'unresolved' | 'bare-barrel'
|
|
reason: string
|
|
}
|
|
|
|
const files = SCAN_DIRS.flatMap((d) => walk(join(ROOT, d)))
|
|
const violations: Violation[] = []
|
|
let checked = 0
|
|
|
|
/**
|
|
* Blank comments in place, preserving byte offsets so line numbers stay exact. TSDoc carries
|
|
* example imports that are not real edges — `packages/db/triggers.ts` documents a subpath the
|
|
* package deliberately does not export.
|
|
*/
|
|
function blankComments(src: string): string {
|
|
return src
|
|
.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '))
|
|
.replace(/(^|[^:])\/\/[^\n]*/g, (m, lead) => lead + ' '.repeat(m.length - lead.length))
|
|
}
|
|
|
|
for (const file of files) {
|
|
const raw = readFileSync(file, 'utf8')
|
|
const src = blankComments(raw)
|
|
let lineStarts: number[] | null = null
|
|
const lineAt = (idx: number) => {
|
|
if (!lineStarts) {
|
|
lineStarts = [0]
|
|
for (let i = 0; i < src.length; i++) if (src[i] === '\n') lineStarts.push(i + 1)
|
|
}
|
|
let lo = 0
|
|
let hi = lineStarts.length - 1
|
|
while (lo < hi) {
|
|
const mid = (lo + hi + 1) >> 1
|
|
if (lineStarts[mid] <= idx) lo = mid
|
|
else hi = mid - 1
|
|
}
|
|
return lo + 1
|
|
}
|
|
|
|
for (const pattern of [SPECIFIER_RE, DYNAMIC_RE, REQUIRE_RE]) {
|
|
pattern.lastIndex = 0
|
|
let m = pattern.exec(src)
|
|
while (m !== null) {
|
|
const spec = m[1]
|
|
// `m.index` is the newline ending the previous line; the specifier's offset is exact.
|
|
const at = m.index + m[0].lastIndexOf(spec)
|
|
const outcome = resolveSpecifier(spec, file)
|
|
if (outcome) {
|
|
checked++
|
|
if (!outcome.ok) {
|
|
violations.push({
|
|
file: repoPath(file),
|
|
line: lineAt(at),
|
|
specifier: spec,
|
|
kind: 'unresolved',
|
|
reason: outcome.reason,
|
|
})
|
|
}
|
|
}
|
|
if (pattern === SPECIFIER_RE && SUBPATH_REQUIRED.has(spec)) {
|
|
const subs = packageExports(spec)
|
|
const example = subs ? [...subs.keys()].find((k) => k !== '.') : undefined
|
|
violations.push({
|
|
file: repoPath(file),
|
|
line: lineAt(at),
|
|
specifier: spec,
|
|
kind: 'bare-barrel',
|
|
reason: example
|
|
? `import from a subpath instead, e.g. '${spec}${example.slice(1)}'`
|
|
: 'import from a subpath instead',
|
|
})
|
|
}
|
|
m = pattern.exec(src)
|
|
}
|
|
}
|
|
}
|
|
|
|
const verbose = process.argv.includes('--verbose')
|
|
|
|
if (violations.length === 0) {
|
|
console.log(
|
|
`✓ check-import-specifiers: ${checked} first-party specifiers across ${files.length} files all resolve`
|
|
)
|
|
process.exit(0)
|
|
}
|
|
|
|
const unresolved = violations.filter((v) => v.kind === 'unresolved')
|
|
const barrels = violations.filter((v) => v.kind === 'bare-barrel')
|
|
|
|
if (unresolved.length) {
|
|
console.error(`\n✗ ${unresolved.length} specifier(s) do not resolve:\n`)
|
|
for (const v of unresolved) {
|
|
console.error(` ${v.file}:${v.line}`)
|
|
console.error(` '${v.specifier}' — ${v.reason}`)
|
|
if (/\.(js|jsx|mjs)$/.test(v.specifier)) {
|
|
console.error(` drop the extension: '${v.specifier.replace(/\.\w+$/, '')}'`)
|
|
}
|
|
}
|
|
console.error(
|
|
"\n These are 'Module not found' at dev time. A '.js' specifier pointing at a '.ts'\n" +
|
|
' file is the common case: webpack rewrites it via resolve.extensionAlias, Turbopack\n' +
|
|
' does not (vercel/next.js#82945). CI builds with webpack and every developer runs\n' +
|
|
" Turbopack, so this class of break is invisible to CI. moduleResolution is 'bundler'\n" +
|
|
' here — extensions are never required.\n'
|
|
)
|
|
}
|
|
|
|
if (barrels.length) {
|
|
console.error(`\n✗ ${barrels.length} bare barrel import(s) of a subpath-only package:\n`)
|
|
for (const v of barrels) {
|
|
console.error(` ${v.file}:${v.line} '${v.specifier}'`)
|
|
console.error(` ${v.reason}`)
|
|
}
|
|
console.error(
|
|
'\n A barrel import pulls every module the barrel re-exports, so one helper drags in\n' +
|
|
' the whole package — and one bad specifier anywhere inside it takes the importer down.\n'
|
|
)
|
|
}
|
|
|
|
if (verbose) console.error(`\nscanned ${files.length} files, ${checked} first-party specifiers`)
|
|
process.exit(1)
|