diff --git a/src/build.ts b/src/build.ts index 827ef25c7..5da9734a2 100644 --- a/src/build.ts +++ b/src/build.ts @@ -14,6 +14,7 @@ import { import { warnLegacyCJS } from './features/cjs.ts' import { cleanChunks, cleanOutDir } from './features/clean.ts' import { copy } from './features/copy.ts' +import { addDeclarationDiagnosticHints } from './features/declaration-diagnostics.ts' import { startDevtoolsUI } from './features/devtools.ts' import { isGlobEntry, toObjectEntry } from './features/entry.ts' import { buildExe } from './features/exe.ts' @@ -169,7 +170,10 @@ async function buildSingle( watcher = rolldownWatch(configs) handleWatcher(watcher) } else { - const outputs = await rolldownBuild(configs) + const outputs = await rolldownBuild(configs).catch((error: unknown) => { + addDeclarationDiagnosticHints(error, config) + throw error + }) for (const { output } of outputs) { chunks.push(...addOutDirToChunks(output, outDir)) } @@ -258,6 +262,7 @@ async function buildSingle( case 'ERROR': { await event.result.close() + addDeclarationDiagnosticHints(event.error, config) logger.error(event.error) hasError = true break diff --git a/src/features/declaration-diagnostics.test.ts b/src/features/declaration-diagnostics.test.ts new file mode 100644 index 000000000..397785509 --- /dev/null +++ b/src/features/declaration-diagnostics.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest' +import { + addDeclarationDiagnosticHints, + formatDevDependencyDeclarationHint, + type DeclarationDiagnosticContext, +} from './declaration-diagnostics.ts' +import type { PackageJson } from 'pkg-types' +import type { RolldownError } from 'rolldown' + +const hint = formatDevDependencyDeclarationHint('dev-only') + +describe('addDeclarationDiagnosticHints', () => { + it('adds devDependency hints to aggregate and child declaration errors', () => { + const diagnostic = createRolldownError({ + code: 'MISSING_EXPORT', + id: '/project/src/index.d.ts', + exporter: '/project/node_modules/dev-only/index.d.ts', + }) + const error = createBuildError(diagnostic) + + addDeclarationDiagnosticHints(error, createContext()) + + expect(error.message).toContain(hint) + expect(diagnostic.message).toContain(hint) + }) + + it('does not hint for production dependencies', () => { + const error = createBuildError( + createRolldownError({ + code: 'MISSING_EXPORT', + id: '/project/src/index.d.ts', + exporter: '/project/node_modules/dev-only/index.d.ts', + }), + ) + + addDeclarationDiagnosticHints( + error, + createContext({ + dependencies: { 'dev-only': '^1.0.0' }, + devDependencies: { 'dev-only': '^1.0.0' }, + }), + ) + + expect(error.message).not.toContain(hint) + }) + + it('does not hint for runtime missing exports', () => { + const error = createBuildError( + createRolldownError({ + code: 'MISSING_EXPORT', + id: '/project/src/index.js', + exporter: '/project/node_modules/dev-only/index.js', + }), + ) + + addDeclarationDiagnosticHints(error, createContext()) + + expect(error.message).not.toContain(hint) + }) + + it('does not hint for unrelated declaration diagnostics', () => { + const error = createBuildError( + createRolldownError({ + code: 'OTHER_ERROR', + id: '/project/src/index.d.ts', + exporter: '/project/node_modules/dev-only/index.d.ts', + }), + ) + + addDeclarationDiagnosticHints(error, createContext()) + + expect(error.message).not.toContain(hint) + }) + + it('does not infer package names from unstructured import undefined diagnostics', () => { + const error = createBuildError( + createRolldownError({ + code: 'IMPORT_IS_UNDEFINED', + id: '/project/src/index.d.ts', + }), + ) + + addDeclarationDiagnosticHints(error, createContext()) + + expect(error.message).not.toContain(hint) + }) +}) + +function createBuildError(...errors: Error[]): Error { + return Object.assign(new Error('Build failed'), { errors }) +} + +function createRolldownError( + options: Pick, +): Error { + return Object.assign(new Error('Diagnostic failed'), options) +} + +function createContext(pkg: PackageJson = defaultPkg()) { + return { pkg } satisfies DeclarationDiagnosticContext +} + +function defaultPkg(): PackageJson { + return { + name: 'test-pkg', + version: '1.0.0', + devDependencies: { 'dev-only': '^1.0.0' }, + } +} diff --git a/src/features/declaration-diagnostics.ts b/src/features/declaration-diagnostics.ts new file mode 100644 index 000000000..af2e34287 --- /dev/null +++ b/src/features/declaration-diagnostics.ts @@ -0,0 +1,67 @@ +import { getDevDependencyOnlyName } from './deps.ts' +import type { PackageJson } from 'pkg-types' +import type { RolldownError } from 'rolldown' + +const DECLARATION_DEPENDENCY_CODES = new Set(['MISSING_EXPORT']) +const RE_DTS_FILE = /\.d\.[cm]?ts$/ + +export interface DeclarationDiagnosticContext { + pkg?: PackageJson +} + +interface ErrorWithErrors extends Error { + errors?: unknown[] +} + +export function formatDevDependencyDeclarationHint(name: string): string { + return `Hint: ${name} is listed in devDependencies only. Move it to dependencies or peerDependencies if it is needed by published declarations.` +} + +export function addDeclarationDiagnosticHints( + error: unknown, + context: DeclarationDiagnosticContext, +): void { + if (!(error instanceof Error)) return + + const hints = new Set() + for (const diagnostic of getDiagnostics(error)) { + const hint = getDevDependencyDeclarationHint(diagnostic, context) + if (!hint) continue + appendHint(diagnostic, hint) + hints.add(hint) + } + + for (const hint of hints) { + appendHint(error, hint) + } +} + +function getDiagnostics(error: Error): Error[] { + const diagnostics = (error as ErrorWithErrors).errors + if (!Array.isArray(diagnostics)) return [error] + return diagnostics.filter((diagnostic): diagnostic is Error => { + return diagnostic instanceof Error + }) +} + +function getDevDependencyDeclarationHint( + diagnostic: Error, + context: DeclarationDiagnosticContext, +): string | undefined { + const { code, exporter, id, loc } = diagnostic as RolldownError + if (!code || !DECLARATION_DEPENDENCY_CODES.has(code) || !exporter) return + if (!isDeclarationDiagnostic(id, exporter, loc?.file)) return + + const name = getDevDependencyOnlyName(context.pkg, exporter) + return name ? formatDevDependencyDeclarationHint(name) : undefined +} + +function appendHint(error: Error, hint: string): void { + if (!error.message.includes(hint)) { + error.message += `\n${hint}` + } +} + +function isDeclarationDiagnostic(...ids: Array): boolean { + return ids.some((id) => id && RE_DTS_FILE.test(id)) +} diff --git a/src/features/deps.ts b/src/features/deps.ts index bf5a8eae5..63a7b0ef8 100644 --- a/src/features/deps.ts +++ b/src/features/deps.ts @@ -436,6 +436,27 @@ async function resolveDepSubpath(id: string, resolved: ResolvedId | null) { return result } +export function getDevDependencyOnlyName( + pkg: PackageJson | undefined, + id: string, +): string | undefined { + if (!pkg) return + const parsed = parseNodeModulesPath(id) + if (!parsed) return + const [name] = parsed + + if (!hasDependency(pkg.devDependencies, name)) return + if ( + hasDependency(pkg.dependencies, name) || + hasDependency(pkg.peerDependencies, name) || + hasDependency(pkg.optionalDependencies, name) + ) { + return + } + + return name +} + /* * Production deps should be excluded from the bundle */ @@ -446,3 +467,10 @@ function getProductionDeps(pkg: PackageJson): Set { ...Object.keys(pkg.optionalDependencies || {}), ]) } + +function hasDependency( + deps: PackageJson['dependencies'] | undefined, + name: string, +): boolean { + return !!deps && Object.hasOwn(deps, name) +} diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index 1c81047a7..b581f713d 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -3,6 +3,8 @@ import path from 'node:path' import { RE_NODE_MODULES } from 'rolldown-plugin-dts/internal' import { describe, expect, test, vi } from 'vitest' import { resolveConfig, type UserConfig } from '../src/config/index.ts' +import { formatDevDependencyDeclarationHint } from '../src/features/declaration-diagnostics.ts' +import { build } from '../src/index.ts' import { slash } from '../src/utils/general.ts' import { chdir, testBuild, writeFixtures } from './utils.ts' import type { Plugin } from 'rolldown' @@ -1068,6 +1070,44 @@ test('failOnWarn', async (context) => { ).rejects.toThrow('Module not found') }) +test('dts missing export hints when dependency is dev-only', async (context) => { + const { testDir } = await writeFixtures(context, { + 'index.ts': ` + import type { Missing } from 'dev-only' + export interface Box { + value: Missing + } + `, + 'node_modules/dev-only/index.d.ts': ` + export interface Present { + value: string + } + `, + 'node_modules/dev-only/package.json': JSON.stringify({ + name: 'dev-only', + version: '1.0.0', + types: 'index.d.ts', + }), + 'package.json': JSON.stringify({ + name: 'test-pkg', + version: '1.0.0', + devDependencies: { + 'dev-only': '^1.0.0', + }, + }), + }) + + await expect( + build({ + cwd: testDir, + config: false, + entry: 'index.ts', + dts: true, + logLevel: 'silent', + }), + ).rejects.toThrow(formatDevDependencyDeclarationHint('dev-only')) +}) + describe('resolve dep subpath without exports field', () => { test('dep/file should resolve to dep/file.js', async (context) => { const node_modules = {