Use explicit ts specifiers for declarations
Restore explicit .ts relative specifiers in source and enable rewriteRelativeImportExtensions so emitted JS uses .js while declarations keep explicit .ts specifiers. Add a NodeNext declaration-consumer gate to prevent extensionless declaration regressions.
This commit is contained in:
160
scripts/verify-node-next-types.ts
Normal file
160
scripts/verify-node-next-types.ts
Normal file
@@ -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 extensionlessRelativeSpecifiers(): 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 = extensionlessRelativeSpecifiers()
|
||||
if (badSpecifiers.length > 0) {
|
||||
console.error('verify-node-next-types: declaration files still contain extensionless relative specifiers.')
|
||||
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:
|
||||
// extensionless relative specifiers 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)
|
||||
Reference in New Issue
Block a user