verify-node-next-types.ts 5.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164
  1. /**
  2. * Verify that built package declarations are consumable by a standard external
  3. * TypeScript ESM project using NodeNext resolution.
  4. *
  5. * Run after `pnpm run build` has emitted declaration files under package
  6. * `lib/types` directories.
  7. */
  8. import { execFileSync } from 'node:child_process'
  9. import { existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
  10. import { dirname, resolve } from 'node:path'
  11. const root = resolve(import.meta.dirname, '..')
  12. interface ExportTarget {
  13. types?: string
  14. }
  15. interface PackageManifest {
  16. name?: string
  17. types?: string
  18. exports?: Record<string, ExportTarget | string | null>
  19. }
  20. interface WorkspacePackage {
  21. dir: string
  22. name: string
  23. manifest: PackageManifest
  24. }
  25. function readPackage(path: string): WorkspacePackage | null {
  26. const manifest = JSON.parse(readFileSync(path, 'utf8')) as PackageManifest
  27. if (!manifest.name) return null
  28. return { dir: dirname(path), name: manifest.name, manifest }
  29. }
  30. function workspacePackages(): WorkspacePackage[] {
  31. return [
  32. ...globSync('vendor/*/package.json', { cwd: root }),
  33. ...globSync('packages/*/*/package.json', { cwd: root }),
  34. ]
  35. .map(path => readPackage(resolve(root, path)))
  36. .filter(pkg => pkg !== null)
  37. .sort((a, b) => a.name.localeCompare(b.name))
  38. }
  39. const declarationSpecifierPattern = /(?:from\s*|import\s*\(\s*|import\s+|declare\s+module\s*)["'](\.{0,2}(?:\/[^"']*)?)["']/g
  40. const hasExtension = /\.[^/.]+$/
  41. function relativeSpecifiersMissingExtensions(): string[] {
  42. const errors: string[] = []
  43. const files = [
  44. ...globSync('vendor/*/lib/types/**/*.d.ts', { cwd: root }),
  45. ...globSync('packages/*/*/lib/types/**/*.d.ts', { cwd: root }),
  46. ].sort()
  47. for (const file of files) {
  48. const text = readFileSync(resolve(root, file), 'utf8')
  49. for (const match of text.matchAll(declarationSpecifierPattern)) {
  50. const specifier = match[1]
  51. if (!specifier) continue
  52. const isRelative = specifier === '.' || specifier.startsWith('./') || specifier.startsWith('../')
  53. if (isRelative && !hasExtension.test(specifier)) errors.push(`${file}: ${specifier}`)
  54. }
  55. }
  56. return errors
  57. }
  58. function publicSpecifiers(pkg: WorkspacePackage): string[] {
  59. const specifiers = new Set<string>()
  60. if (pkg.manifest.types) specifiers.add(pkg.name)
  61. for (const [key, target] of Object.entries(pkg.manifest.exports ?? {})) {
  62. if (key.includes('*') || key === './package.json') continue
  63. if (typeof target !== 'object' || target === null || !target.types) continue
  64. specifiers.add(key === '.' ? pkg.name : `${pkg.name}/${key.slice(2)}`)
  65. }
  66. return [...specifiers].sort()
  67. }
  68. function linkPackage(pkg: WorkspacePackage, nodeModules: string): void {
  69. const parts = pkg.name.split('/')
  70. const link = resolve(nodeModules, ...parts)
  71. mkdirSync(dirname(link), { recursive: true })
  72. symlinkSync(pkg.dir, link, 'dir')
  73. }
  74. const packages = workspacePackages()
  75. const badSpecifiers = relativeSpecifiersMissingExtensions()
  76. if (badSpecifiers.length > 0) {
  77. console.error('verify-node-next-types: declaration files still contain relative specifiers without file extensions.')
  78. console.error(badSpecifiers.join('\n'))
  79. process.exit(1)
  80. }
  81. const missingOutputs = packages
  82. .filter(pkg => pkg.manifest.types && !existsSync(resolve(pkg.dir, pkg.manifest.types)))
  83. .map(pkg => `${pkg.name}: missing ${pkg.manifest.types}`)
  84. if (missingOutputs.length > 0) {
  85. console.error('verify-node-next-types: build outputs are missing; run `pnpm run build` first.')
  86. console.error(missingOutputs.join('\n'))
  87. process.exit(1)
  88. }
  89. const tmp = mkdtempSync(resolve(root, '.node-next-types-'))
  90. let failed = false
  91. try {
  92. const nodeModules = resolve(tmp, 'node_modules')
  93. mkdirSync(nodeModules, { recursive: true })
  94. for (const pkg of packages) linkPackage(pkg, nodeModules)
  95. const rootTypes = resolve(root, 'node_modules/@types/node')
  96. if (existsSync(rootTypes)) {
  97. const typesDir = resolve(nodeModules, '@types')
  98. mkdirSync(typesDir, { recursive: true })
  99. symlinkSync(rootTypes, resolve(typesDir, 'node'), 'dir')
  100. }
  101. writeFileSync(resolve(tmp, 'package.json'), `${JSON.stringify({ type: 'module', private: true }, null, 2)}\n`)
  102. writeFileSync(resolve(tmp, 'tsconfig.json'), `${JSON.stringify({
  103. compilerOptions: {
  104. target: 'es2024',
  105. module: 'NodeNext',
  106. moduleResolution: 'NodeNext',
  107. strict: true,
  108. // Third-party SDK declarations can have their own lib-check noise under a
  109. // symlinked temp install. The explicit scan above owns our regression:
  110. // relative specifiers without file extensions in built declarations.
  111. skipLibCheck: true,
  112. preserveSymlinks: true,
  113. noEmit: true,
  114. types: ['node'],
  115. },
  116. include: ['index.ts'],
  117. }, null, 2)}\n`)
  118. const imports = packages.flatMap(publicSpecifiers)
  119. .map((specifier, index) => `import * as mod${index} from ${JSON.stringify(specifier)};\nvoid mod${index};`)
  120. .join('\n')
  121. writeFileSync(resolve(tmp, 'index.ts'), `${imports}\n`)
  122. // tsc's JS entry via the current node, not the .bin shim: the extensionless
  123. // shim isn't spawnable on Windows (CVE-2024-27980) and the .cmd variant needs
  124. // shell:true, which space-joins args UNESCAPED (DEP0190) — a hazard for the
  125. // temp tsconfig path. The JS entry behaves identically on every platform.
  126. execFileSync(process.execPath, ['node_modules/typescript/bin/tsc', '-p', resolve(tmp, 'tsconfig.json'), '--pretty', 'false'], {
  127. cwd: root,
  128. stdio: 'pipe',
  129. })
  130. console.log(`verify-node-next-types: ${packages.length} workspace package declaration API(s) compile under NodeNext.`)
  131. } catch (error: unknown) {
  132. failed = true
  133. const output = error as { stdout?: Buffer; stderr?: Buffer }
  134. console.error('verify-node-next-types: NodeNext consumer typecheck failed.\n')
  135. console.error(`${output.stdout?.toString() ?? ''}${output.stderr?.toString() ?? ''}`)
  136. } finally {
  137. rmSync(tmp, { recursive: true, force: true })
  138. }
  139. if (failed) process.exit(1)