Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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))
}
Expand Down Expand Up @@ -258,6 +262,7 @@ async function buildSingle(

case 'ERROR': {
await event.result.close()
addDeclarationDiagnosticHints(event.error, config)
logger.error(event.error)
hasError = true
break
Expand Down
109 changes: 109 additions & 0 deletions src/features/declaration-diagnostics.test.ts
Original file line number Diff line number Diff line change
@@ -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<RolldownError, 'code' | 'exporter' | 'id'>,
): 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' },
}
}
67 changes: 67 additions & 0 deletions src/features/declaration-diagnostics.ts
Original file line number Diff line number Diff line change
@@ -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<string>()
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<string | undefined>): boolean {
return ids.some((id) => id && RE_DTS_FILE.test(id))
}
28 changes: 28 additions & 0 deletions src/features/deps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand All @@ -446,3 +467,10 @@ function getProductionDeps(pkg: PackageJson): Set<string> {
...Object.keys(pkg.optionalDependencies || {}),
])
}

function hasDependency(
deps: PackageJson['dependencies'] | undefined,
name: string,
): boolean {
return !!deps && Object.hasOwn(deps, name)
}
40 changes: 40 additions & 0 deletions tests/e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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 = {
Expand Down
Loading