|
|
@@ -0,0 +1,160 @@
|
|
|
+/**
|
|
|
+ * 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`)
|
|
|
+
|
|
|
+ execFileSync(resolve(root, 'node_modules/.bin/tsc'), ['-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], {
|
|
|
+ cwd: root,
|
|
|
+ stdio: 'pipe',
|
|
|
+ })
|
|
|
+ console.log(`verify-node-next-types: ${packages.length} workspace package declaration surface(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)
|