| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164 |
- /**
- * Verify that built package declarations are consumable by a standard external
- * TypeScript ESM project using NodeNext resolution.
- *
- * Run after `pnpm run build` has emitted declaration files under package
- * `lib/types` directories.
- */
- import { execFileSync } from 'node:child_process'
- import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
- import { dirname, resolve } from 'node:path'
- const root = resolve(import.meta.dirname, '..')
- interface ExportTarget {
- types?: string
- }
- interface PackageManifest {
- name?: string
- types?: string
- exports?: Record<string, ExportTarget | string | null>
- }
- interface WorkspacePackage {
- dir: string
- name: string
- manifest: PackageManifest
- }
- function readPackage(path: string): WorkspacePackage | null {
- const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
- if (!manifest.name) return null
- return { dir: dirname(path), name: manifest.name, manifest }
- }
- function workspacePackages(): WorkspacePackage[] {
- return [
- ...globSync('vendor/*/package.json', { cwd: root }),
- ...globSync('packages/*/*/package.json', { cwd: root }),
- ]
- .map(path => readPackage(resolve(root, path)))
- .filter(pkg => pkg !== null)
- .sort((a, b) => a.name.localeCompare(b.name))
- }
- const declarationSpecifierPattern = /(?:from\s*|import\s*\(\s*|import\s+|declare\s+module\s*)["'](\.{0,2}(?:\/[^"']*)?)["']/g
- const hasExtension = /\.[^/.]+$/
- function relativeSpecifiersMissingExtensions(): string[] {
- const errors: string[] = []
- const files = [
- ...globSync('vendor/*/lib/types/**/*.d.ts', { cwd: root }),
- ...globSync('packages/*/*/lib/types/**/*.d.ts', { cwd: root }),
- ].sort()
- for (const file of files) {
- const text = readFileSync(resolve(root, file), 'utf8')
- for (const match of text.matchAll(declarationSpecifierPattern)) {
- const specifier = match[1]
- if (!specifier) continue
- const isRelative = specifier === '.' || specifier.startsWith('./') || specifier.startsWith('../')
- if (isRelative && !hasExtension.test(specifier)) errors.push(`${file}: ${specifier}`)
- }
- }
- return errors
- }
- function publicSpecifiers(pkg: WorkspacePackage): string[] {
- const specifiers = new Set<string>()
- if (pkg.manifest.types) specifiers.add(pkg.name)
- for (const [key, target] of Object.entries(pkg.manifest.exports ?? {})) {
- if (key.includes('*') || key === './package.json') continue
- if (typeof target !== 'object' || target === null || !target.types) continue
- specifiers.add(key === '.' ? pkg.name : `${pkg.name}/${key.slice(2)}`)
- }
- return [...specifiers].sort()
- }
- function linkPackage(pkg: WorkspacePackage, nodeModules: string): void {
- const parts = pkg.name.split('/')
- const link = resolve(nodeModules, ...parts)
- mkdirSync(dirname(link), { recursive: true })
- symlinkSync(pkg.dir, link, 'dir')
- }
- const packages = workspacePackages()
- const badSpecifiers = relativeSpecifiersMissingExtensions()
- if (badSpecifiers.length > 0) {
- console.error('verify-node-next-types: declaration files still contain relative specifiers without file extensions.')
- console.error(badSpecifiers.join('\n'))
- process.exit(1)
- }
- const missingOutputs = packages
- .filter(pkg => pkg.manifest.types && !existsSync(resolve(pkg.dir, pkg.manifest.types)))
- .map(pkg => `${pkg.name}: missing ${pkg.manifest.types}`)
- if (missingOutputs.length > 0) {
- console.error('verify-node-next-types: build outputs are missing; run `pnpm run build` first.')
- console.error(missingOutputs.join('\n'))
- process.exit(1)
- }
- const tmp = mkdtempSync(resolve(root, '.node-next-types-'))
- let failed = false
- try {
- const nodeModules = resolve(tmp, 'node_modules')
- mkdirSync(nodeModules, { recursive: true })
- for (const pkg of packages) linkPackage(pkg, nodeModules)
- const rootTypes = resolve(root, 'node_modules/@types/node')
- if (existsSync(rootTypes)) {
- const typesDir = resolve(nodeModules, '@types')
- mkdirSync(typesDir, { recursive: true })
- symlinkSync(rootTypes, resolve(typesDir, 'node'), 'dir')
- }
- writeFileSync(resolve(tmp, 'package.json'), `${JSON.stringify({ type: 'module', private: true }, null, 2)}\n`)
- writeFileSync(resolve(tmp, 'tsconfig.json'), `${JSON.stringify({
- compilerOptions: {
- target: 'es2024',
- module: 'NodeNext',
- moduleResolution: 'NodeNext',
- strict: true,
- // Third-party SDK declarations can have their own lib-check noise under a
- // symlinked temp install. The explicit scan above owns our regression:
- // relative specifiers without file extensions in built declarations.
- skipLibCheck: true,
- preserveSymlinks: true,
- noEmit: true,
- types: ['node'],
- },
- include: ['index.ts'],
- }, null, 2)}\n`)
- const imports = packages.flatMap(publicSpecifiers)
- .map((specifier, index) => `import * as mod${index} from ${JSON.stringify(specifier)};\nvoid mod${index};`)
- .join('\n')
- writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`)
- // tsc's JS entry via the current node, not the .bin shim: the extensionless
- // shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd variant needs
- // shell:true, which space-joins args UNESCAPED (DEP0190) — a hazard for the
- // temp tsconfig path. The JS entry behaves identically on every platform.
- execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], {
- cwd: root,
- stdio: 'pipe',
- })
- console.log(`verify-node-next-types: ${packages.length} workspace package declaration API(s) compile under NodeNext.`)
- } catch (error: unknown) {
- failed = true
- const output = error as { stdout?: Buffer; stderr?: Buffer }
- console.error('verify-node-next-types: NodeNext consumer typecheck failed.\n')
- console.error(`${output.stdout?.toString() ?? ''}${output.stderr?.toString() ?? ''}`)
- } finally {
- rmSync(tmp, { recursive: true, force: true })
- }
- if (failed) process.exit(1)
|