Skip to content

Dependency Graph ​

Two commands that look at the same import graph from different angles: unused-deps checks it against what package.json declares, circular-imports looks for cycles in it. Both are read-only — neither writes anything nor executes the source code they scan.

unused-deps ​

Finds package.json dependencies nothing imports — and the reverse: a "phantom" dependency, genuinely imported but never declared.

bash
devtoolz unused-deps [dir] [options]

The command reads a single package.json in the given directory (current directory by default) — the same path doubles as both the scan root and where package.json is looked up from, deliberately, so there's no way for "what's being scanned" and "whose package.json is being checked" to drift apart.

Real output — a package with one genuinely unused dependency and one phantom:

🧰 devtoolz, reporting for duty

Scanned 2 files.

2 problems found:
  phantom  dotenv    resolves from C:\tmp\unused-deps-doc\node_modules\dotenv — not declared in package.json
  unused   left-pad  (dependencies, not imported anywhere)

dotenv is genuinely imported in source but never shows up in package.json at all — it only works because something else pulled it in transitively. left-pad is declared but has no import anywhere.

What counts as usage besides a direct import ​

A naive "no import means unused" check would produce a huge number of false positives — the same package, but with vitest/happy-dom/@types/node in devDependencies, on a plain run:

🧰 devtoolz — chores, automated

Scanned 2 files.

2 problems found:
  phantom  dotenv    resolves from C:\tmp\unused-deps-doc\node_modules\dotenv — not declared in package.json
  unused   left-pad  (dependencies, not imported anywhere)

vitest and happy-dom don't show up — they're used, just not through an import:

  • invoked by its bin name from scripts (or from lint-staged in package.json, if it's there) — vitest is invoked via "test": "vitest run"; the check also considers a package's own declared bin keys (from its package.json in node_modules), not just its package name — that's how typescript invoked as tsc is caught too;
  • mentioned in a config file — happy-dom is never imported anywhere, but vitest.config.ts contains environment: 'happy-dom'. A fixed list of config files (vitest.config.*, .eslintrc*, .stylelintrc*, postcss.config.*, tsconfig.json, and a few others) is checked for the package name's presence — both quoted and as a bare identifier (postcss.config.js's plugins: { autoprefixer: {} }), with word-boundary checking so autoprefixer doesn't match as part of a different name like autoprefixer-wrapper.

Structurally undetectable packages ​

A separate, explicit category — packages that could never appear as text in an import, scripts, or any config, by design: @types/* (ambient, picked up by TypeScript from mere presence), typescript (near-universally legitimate, invoked transitively by other tools), @vitest/coverage-*/@vitest/ui (vitest itself constructs the package name from the coverage.provider/ui config value — the full package name never appears as text anywhere), @nuxt/schema (ambient type augmentation for a Nuxt module), postcss/sass/less/stylus (Vite auto-detects them by file extension and mere installation).

This category isn't checked by default at all. --strict includes it too — on the same example above, @types/node now shows up, while vitest/happy-dom still don't (that's genuine usage, not an exemption from the check):

🧰 devtoolz

Scanned 2 files.

3 problems found:
  phantom  dotenv       resolves from C:\tmp\unused-deps-doc\node_modules\dotenv — not declared in package.json
  unused   @types/node  (devDependencies, not imported anywhere)
  unused   left-pad     (dependencies, not imported anywhere)

Options ​

[dir] ​

Package directory to check — also the scan root (positional argument, default: .).

--strict ​

Also check the structurally undetectable category above — for a manual audit.

--ignore-package <name> ​

Exempt a specific dependency from the check in both directions (repeatable) — for cases no heuristic covers, e.g. a package invoked only from an external git hook.

--ext <list> ​

Comma-separated extensions to include. Default: .ts,.tsx,.js,.jsx,.cjs,.mjs.

--ignore <glob> ​

Extra ignore pattern (repeatable) on top of the built-in defaults.

--no-respect-gitignore ​

Don't also honor the project's .gitignore.

Deliberately no --fix — removing a dependency from package.json is too risky for automation, the decision is left to a human.

Example:

bash
devtoolz unused-deps                       # package.json in the current directory
devtoolz unused-deps path/to/package        # a specific directory
devtoolz unused-deps --strict               # the structurally undetectable category too
devtoolz unused-deps --ignore-package foo    # exempt a specific package

circular-imports ​

Finds import cycles (A → B → … → A) in your own project's code.

bash
devtoolz circular-imports [paths...] [options]

In ESM, an import cycle can sometimes produce undefined at runtime in the most unexpected place, and usually isn't found until it already broke. Only local (./) specifiers are checked — a cycle through a third-party package isn't visible and isn't this command's concern either way. Every cycle is found, not just the first; each finding is the full chain, not just a pair of files that happen to be in the same cycle.

Real output — two files importing each other directly:

🧰 devtoolz, reporting for duty

Scanned 2 files.

1 circular import found:

  src/a.ts
  → src/b.ts
  → src/a.ts

import type cycles are hidden by default ​

A cycle made entirely of import type isn't a runtime bug — types are erased at compile time. Since that's idiomatic modern style, showing these by default would be noisy on nearly any well-typed project:

🧰 devtoolz

Scanned 2 files.

Nothing found. Somebody already did their homework.

--include-types shows them too, clearly marked:

🧰 devtoolz — chores, automated

Scanned 2 files.

1 circular import found:

  src/a.ts
  → src/b.ts
  → src/a.ts
  (type-only — harmless at runtime, types are erased)

If a cycle has even one regular (non-type) import, it's treated as value-level as a whole and shown by default, even if the rest of its edges are type-only.

The tool doesn't try to prove a specific cycle is actually broken at runtime — that's generally undecidable statically (the reference could be deferred inside a function rather than sitting at the top level of the module). A finding says "here's the chain, judge for yourself," not "this is definitely a bug."

Options ​

--include-types ​

Also show cycles made entirely of import type edges (hidden by default).

--cwd <path> ​

Root paths are resolved against.

--ext <list> ​

Comma-separated extensions to include. Default: .ts,.tsx,.js,.jsx,.cjs,.mjs.

--ignore <glob> ​

Extra ignore pattern (repeatable) on top of the built-in defaults.

--no-respect-gitignore ​

Don't also honor the project's .gitignore.

Example:

bash
devtoolz circular-imports src                  # value-level cycles only
devtoolz circular-imports src --include-types  # type-only ones too