A package.json says two things about every entry: that the project needs it, and when. norefs checks both.
Nothing uses it. An entry is reported dead when nothing in the project uses it, and five things count as using it.
An import, first — require('pkg') included, in a TypeScript file as much as a JavaScript one, and require.resolve('pkg') too, which loads nothing and still says the package has to be installed. A /// <reference types="bun-types" /> directive counts the same way: it is the file saying it needs that package, written in the one place the import graph never looks — and so does a @jest-environment @edge-runtime/jest-environment docblock, which says what the test run loads. Only the prologue is read, because past the first token neither one is honoured and the same words are prose. Then a script: "build": "tsc -p tsconfig.json" is TypeScript being used, and no import will ever say so — norefs reads each script’s tokens and matches them against the packages you listed, by name and by the binaries each installed package declares in its own bin field. A token gives up its quotes and the name in front of its value first, so NODE_OPTIONS='--import=tsx/esm' ava is read as loading tsx. Then a tool config: an ESLint config imports its plugins from a file the TypeScript program never holds, and environment: 'jsdom' loads jsdom without naming a file at all, so a listed package written anywhere in a *.config.* — data extensions included, since stryker.config.json configures Stryker the same way — or an .eslintrc-style rc file counts as used. A directory a tool owns is read the same way, because a tool is as likely to take a directory as a file: .husky/, .changeset/, .github/workflows/ and .github/actions/ at the package root, and .storybook/ and .vitepress/ wherever they sit — vitepress’s own scaffolder writes docs/.vitepress/ with no manifest beside it. The rest of .github is repository paperwork, and dependabot names a package to say not to touch it. The name of a config counts as well as what it writes: postcss.config.js is postcss being used and tailwind.config.js is tailwindcss, and a config is often the one place a project writes the tool down. Short names are expanded on the way through: plugins: [import], plugin:unicorn/recommended and import/no-cycle all name a package spelled eslint-plugin-…, 'import/resolver': { typescript: true } names eslint-import-resolver-typescript even though it writes it as a bare key, testEnvironment: 'jsdom' names jest-environment-jsdom, ~icons/logos/github-icon names @iconify-json/logos, and every key of a PostCSS config’s plugins block — plugins: { tailwindcss: {}, autoprefixer: {} } — is a package loaded by that name. In a config written as code, a bare word is a short name only where it is an object key — the other words of a code file are the code, and import js from '@eslint/js' is not the import plugin. Three blocks of a legacy ESLint config are left out of that: the keys of env, globals and settings name environments, variables and a plugin’s settings, and env: { node: true, jest: true } loads no plugin at all. And last, a host: bufferutil runs behind ws and jsdom behind a test environment, and each is a peer dependency of a package this project does use — which is how the ecosystem writes down “that one loads me”. A host only a test outside the tsconfig imports is a host in use all the same, and answers for its peers. A plugin can be the one saying it instead: @vitest/coverage-v8 runs behind --coverage, vitest’s peer list has never named it, and its own list names vitest. That counts where the scope agrees — the package is published under the host’s own name and declares that host as its peer.
A tool’s config is not always a file. "lint-staged": { … } and "ava": { … } sit in package.json, and the key is the tool’s name — so a top-level key that names a package the manifest lists is that package being configured, and the strings inside the block are read the way a config file’s are. A value written as a command-line argument is split the way a script is, which is how ava’s nodeArguments: ["--import=tsx/esm"] says it loads tsx. Every other manifest key is npm’s own and goes unread: "keywords": ["react"] is a word about the package, not a package in use.
The tsconfig is read for five more. extends: "@sindresorhus/tsconfig" is a package the build needs; types: ["node", "@withfig/autocomplete-types"] and plugins: [{ name: "@effect/language-service" }] are the compiler being handed packages to load, the same statement a /// <reference types> makes; jsxImportSource: "@emotion/react" is the package every file with JSX in it comes out importing its runtime from, with react standing in under plain react-jsx; and importHelpers puts a require('tslib') in the emitted output, which is not a config naming a tool but the built code importing a package — so it answers the section question the way an import would.
Nothing here guesses which tool owns which command, or which plugin. tsc maps to typescript, and bufferutil to ws, because those packages’ own manifests say so.
The imports come from every source file the project keeps, not only the ones the tsconfig holds. A package whose only importer sits in a file the config excluded — lib/*.js beside a files: ["index.d.ts"], a test/ directory outside include, a scripts/ nobody compiles — used to read as dead, and the import was one directory listing away. Those files are read as text and count for one thing: the package is used. They can never say a name is missing from the manifest, or which section it belongs in — nothing in them was analyzed, so a run that read only them holds both claims back and reports neither. A config that holds no files of its own is read this way too: a solution-style tsconfig with files: [] and a list of references holds no program, and the sources beside it are still there to read.
A workspace sibling is a different case, and gets a narrower answer. A run pointed at one package reads the other packages for what they take from it, and never for what they name: a sibling importing lodash says nothing about this package’s manifest, and a claim built on a file nobody analyzed, in a package nobody asked about, would be two guesses deep.
A workspace under one tsconfig is the opposite case. include: ["./"] at the root holds every package’s files, and the root manifest is private and lists nothing — so read alone, it would call every package’s dependency unlisted and every package’s entry dead. The declaration the package manager reads says where the other manifests are, and each declared package whose files the run holds answers for its own package.json: its entries, its dependencies, and where a name is missing. A declared package the tsconfig leaves out was never analyzed, and its manifest is left alone.
That is also the limit. A package that is not installed has no binaries to read, so norefs will not call a devDependency unused — it cannot see what a script might be running. Install first, or the claim goes unmade. And a file no scanner reads hides its imports whatever the config says: a .vue or .svelte single-file component, a template, anything but the JavaScript and TypeScript extensions.
It is in the wrong section. Where an entry sits is a claim about when it is needed, and getting it wrong breaks something either way:
package.json
9:5 `only-in-tests` is in dependencies: only test, spec, story, bench, and config files use it, so it ships for nothing
15:5 `zod` is in devDependencies: production code imports it, so an install without dev dependencies is missing it
The second one is the expensive one — npm install --omit=dev and the package is gone at runtime.
Neither claim is made about a "private": true package. Nobody installs it and nobody downloads its weight, so both halves of the section question are about an install that never happens. A dependency nothing uses is still worth saying, and still said.
Only an import that survives compilation counts here. import type { Recipe } from 'shapes' is erased before anything runs, so a devDependency the shipping code reads for types alone is already in the right section — moving it would ship a package the output never loads. The import still counts as the package being used, so nothing calls it dead. A type query is read the same way: import('shapes').Recipe, written where a type goes, is a dynamic import’s words and import type’s meaning. Where a value goes, the same words load the module, and that one is needed at run time.
A bundled package carries its dependencies inside its output file, and then no install can be missing them. norefs reads the external list out of the build files at the package root — external: ['esbuild', 'drizzle-orm'] is what the bundler is told to leave for the run time — and holds back the misplaced claim for every name the list omits. Several outputs are added up: a name any one bundle keeps external is a name the install still needs. A bundler that inlines by default declares a list without writing one — tsup, tsdown, bunchee, unbuild and obuild all leave dependencies and peerDependencies external and compile everything else in — so a devDependency such a package imports ships inside the output, and no claim is made about it. The package says which bundler builds it in one of two places, and both are read: a tsup.config.*, tsdown.config.* or build.config.* at its root, or the script that runs the build, so "build": "tsup src/index.ts --format esm,cjs" counts with no config at all. A list built out of something the reader cannot follow, a call or an object, makes the answer unknown, and the check reports as it did before.
A peer dependency listed in devDependencies as well is where it belongs, and it is not dead either. That pairing is how a package builds and tests against its own peer — typeorm lists seven database drivers in both sections — and whoever installs the package brings them. The listing is the reason the line is there, so no import has to be.
One package shape is read differently: a module the environment provides. import { app } from 'electron' reads a declare module 'electron' block in electron’s own types. That is what an API the host supplies looks like — the binary that loads the code brings the module with it, and no file in node_modules is what the import lands on at run time. Which section such a package belongs in is decided by whatever packages the app; electron-builder wants electron in devDependencies and reads it from there to pick the runtime it bundles. So an install without dev dependencies is not what would be missing it, and norefs does not make a claim it cannot ground.
Nor does it ask the other direction. declare module is also how a library older than ES modules ships its types — @xterm/headless, node-pty and toml all write it, and all three are ordinary packages a product installs and ships. The signal is strong enough to hold a claim back and far too weak to make one, so it is read in the direction that reports nothing.
A config file is a build’s file, not the product’s, so what it imports is never production usage. That holds for a second target’s config: vite.config.server.ts beside vite.config.ts is read as a config too. Only at the package root, though — the extra segment is also how ordinary code gets named, and src/form.config.schema.ts is a schema, not a build.
Neither is a file no chain of imports from an entry point reaches. A name tells you a test — card.test.ts — and reachability tells you the helper directory beside it: valibot’s src/vitest/ wraps the test framework, is imported by tests alone, and is named after the tool rather than after the harness. The report used to read that as production code and advise shipping a test framework in dependencies. Where the run resolves no entry point there is no reachability to read, so the question goes unanswered rather than answered wrongly, and the name is all that decides.
Four specifier shapes are never packages. A scheme names the host rather than a registry — node:fs, bun:sqlite, cloudflare:workers — and npm resolves none of them, so asking for a manifest entry would ask for a line nobody can write. A ~ opens a bundler’s alias — ~/lib, ~icons/logos/… — and no package name can start with one. uno.css is a module unocss makes up, the way virtual:icons/… is one unplugin-icons makes up; the scheme form is read as a scheme and the bare name is known by name. And a module a listed package’s own types declare into the program — import { serve } from 'bun', which bun-types answers with declare module "bun" — is brought by the run time, not by an install: the types entry of every listed package is read, along with the files its /// <reference path> directives pull in, and a name declared that way is never asked to be listed. Only a script file’s declaration counts; a module file writes declare module 'vue' to augment a package that exists, and says nothing about whether it is installed. And a bare specifier that lands on project code is read as the package it names when a manifest lists it — a workspace dependency linked by path ("seedbox": "workspace:../seedbox/dist") resolves straight back into the repo — while one no manifest lists is a baseUrl import and never reported unlisted. A specifier that matches a tsconfig paths pattern is read the same way: project code, unless a manifest lists the name, in which case the import says that package is used.
Fixing them. --fix-unsafe removes an unused entry and moves a misplaced one, editing package.json as text so the key order and the indentation survive. It needs --fix-unsafe rather than --fix for an honest reason: the type checker does not read a dependency list, so the probe that guards every other fix has nothing to say here. --verify-command is the one that can judge these, and when it fails the manifest edits are held back on their own — the source fixes it did verify still land.
Use the ignoreDependencies config key for a dependency norefs still cannot see: a binary invoked from somewhere other than a script, a package a runtime injects by a name nothing writes down.