Concepts
The bug class
Section titled “The bug class”When you publish a package, the artifact consumers install is not your source tree — it is the built dist/ (or build/) plus your package.json. Inside that artifact, two kinds of files reference other packages by bare specifier (react, lodash/fp, node:fs):
- emitted type declarations (
.d.ts) — e.g.export type Props = import('react').ComponentProps<'div'>, or/// <reference types="node" />; - emitted runtime JS — e.g.
import x from 'lodash',require('debug'),createRequire(import.meta.url)('./native.node').
On the author’s machine, those specifiers resolve because the dependency happens to be installed — hoisted into a shared node_modules, or present as a transitive dependency, or available as a dev dependency. But a consumer installing only your published package gets only the dependencies your manifest declares. If a reachable import is not declared (or is declared but ships no types, or points at a subpath the dependency does not export), the consumer’s build breaks — with an error that points into your package, not theirs.
The canonical case: an emitted declaration does import('react'), but @types/react was never declared. Your build is fine (you have React installed); your consumer’s tsc fails to resolve the type. dependency-audit catches this class statically, before you publish.
This is not hypothetical: the tool grew out of exactly this problem recurring across Gutenberg’s 100+ published packages, where @types/react / csstype and others resolved at build time (via root hoisting, a devDependency, or a workspace link) but were not declared where consumers could resolve them, and so broke for npm consumers (#74655, #74310, #78882). An isolated install layout (pnpm / npm install-strategy=linked) catches the fully-undeclared cases but not a dependency in the wrong section (a devDependency whose types leak into the published .d.ts). See Why this exists for the full story.
What it checks
Section titled “What it checks”For a target package, the tool verifies the invariant:
Every external bare specifier reachable in the released artifact resolves through a dependency the manifest declares.
It checks this independently on two surfaces:
- Type surface (
.d.ts) — resolved the way a consumer’s TypeScript would, soreactcorrectly falls back to@types/react. - Runtime surface (JS) — resolved the way Node would at run time, honoring
exports/mainand the call form (importvsrequire).
A specifier that fails the invariant becomes a finding. A surface that has nothing to check (e.g. a package whose types were never built) becomes a notice, so “clean” is never confused with “nothing audited”.
The two surfaces
Section titled “The two surfaces”Type surface
Section titled “Type surface”Entry points are discovered from the manifest, in priority order:
- If
exportsis present, it encapsulates the package: only the type targets selected by the active conditions (typesfirst, then the ESM profile) are entry points. Legacytypes/typingsandtypesVersionsare ignored whenexportsis present (TypeScript does.js→.d.tsextension substitution from the JS target). - If there is no
exportsbut atypesVersions"*"catch-all, its mapped targets are the surface. - Otherwise, every
.d.tsin the tarball is deep-importable, so the surface is all of them, plustypes/typings/module/mainsubstitution and anytypesVersionstargets.
From each entry, the tool follows relative imports across .d.ts files (the intra-package graph) and records every external specifier — including import('x') type-only imports, /// <reference types="x" /> directives, and declare module "x" augmentations of real (non-pattern) module names.
Runtime surface
Section titled “Runtime surface”Entry points are discovered from the manifest:
exportsruntime targets for both theimportandrequireprofiles (a dual package can expose different specifiers under each).- Legacy
mainandmodule. binscripts — always executable regardless ofexportsencapsulation, including extensionless files with a#!/usr/bin/env nodeshebang.
From each entry, the tool follows the relative JS import graph and records every external specifier, tagged by call form (import or require), so each is resolved under the right condition set. It understands static import/export … from, import x = require(...), dynamic import('x') (literal only), require('x'), require.resolve('x'), createRequire(...)('x'), and createRequire(...).resolve('x'). Non-literal/dynamic specifiers are surfaced as unchecked, never silently dropped.
The resolution model
Section titled “The resolution model”The point of the tool is to resolve against the package’s declared dependencies, never the author’s ambient node_modules. So before resolving, it materializes every declared dependency (production + peer + optional; never dev) at its declared range into one fresh, throwaway tree:
- registry ranges are fetched from npm (reusing npm’s cache/auth) and extracted;
- monorepo-local deps —
file:../sibling,link:../sibling, orworkspace:*resolved by name throughpnpm-workspace.yaml/package.json#workspaces— are linked to the already-built local sibling (no rebuild, fully static).
Then each specifier is resolved against that tree:
- type specifiers go through the bundled
typescript(ts.resolveModuleName/ts.resolveTypeReferenceDirective) under NodeNext, so@types/*fallback works exactly as a consumer’s checker would; - runtime specifiers go through the dependency’s own
exports/mainfor the matching call form, with lenient extension/index probing on the legacy (no-exports) fallback (see Resolution model).
Node builtins (node:fs, fs, …) need no declaration at run time; on the type surface they imply @types/node.
See Resolution model for the condition sets, profiles, and local-protocol details, and Security for the guarantees around extraction and execution.