Skip to content

merttopuz/dex

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

1 Commit
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Dependency Explorer (dex)

One package name. Every project on your disk. Which copies are a problem.

dex reads every package.json and lockfile on your machine and answers the question no per-project tool can: where is this package, what version is each copy on, which ones have a CVE, and which ones aren't even used?

English | Türkçe


What it is

dex is a read-only inspector for the npm projects already sitting on your disk. It is not a package manager. It never installs anything, never resolves a tree, never edits a package.json and never writes a lockfile. It reads what pnpm, npm, yarn and bun have already written, and lets you ask one question across all of your projects at once.

That last part is the whole point:

  • npm and pnpm answer about the project you are standing in. npm audit tells you about this one. dex audit -g tells you about all seventy-five.
  • dex is organized around the package, not the project. "Where is express, on what versions, and what is wrong with them" is one command instead of seventy-five.

The problem

npm audit answers about one project. But express is not in one project - it is in twelve, on four different versions, and two of them are vulnerable. You find that out by cd-ing into each of them, one at a time, and you don't, so you never find out at all.

The three questions you actually have are the same three every time:

express
  ↓
which projects have it?
  ↓
which are on an old version · which have a CVE · which don't even use it

That's one command:

$ dex who express ~/Projects

  express    12 projects  ·  latest 5.2.1

  5.2.1    ██████████████████████    5
  4.22.1   ██████████████████████    5
  4.19.2   █████░░░░░░░░░░░░░░░░░    1   CVE-2024-43796 low
  4.17.1   █████░░░░░░░░░░░░░░░░░    1   CVE-2024-29041 +1 more

  VULNERABLE  2 projects
    acme/cms-develop      4.19.2   CVE-2024-43796  express vulnerable to XSS via response.re…  → 4.20.0
    globex/globex.com.tr  4.17.1   CVE-2024-29041  Express.js Open Redirect in malformed URLs  → 4.19.2

  0 declare it · 12 had it pulled in by something else
  pulled in by: @nestjs/platform-express (6), @nestjs/core (4), @nestjs/platform-fastify (4)

Reading it top to bottom:

  • the bars are your version spread. Four versions of one package, which is the normal state of a real disk.
  • VULNERABLE is the part you act on: the project, the version it's on, the advisory, and the version that fixes it from where that project is standing.
  • the last two lines are the ones people miss. Nobody chose express - NestJS did. You cannot fix a package you never asked for by editing your own package.json, so the answer has to name what pulled it in.

Install

Built with Rust, so you need a Rust toolchain (rustup.rs). From the repo root:

cargo install --path .        # installs `dex` into ~/.cargo/bin
# or, just build it:
cargo build --release         # binary at ./target/release/dex

Nothing else to set up. There is no config file, no daemon, no index to build.

Quick start

Everything defaults to where you are, like every other tool you own:

cd ~/Projects/api
dex unused                    # what can I delete from this project?
dex show .                    # every dependency here, and why each one counts as used

cd ~/Projects
dex ls                        # what do all of these projects depend on?
dex audit --direct            # every CVE under here, in packages I chose myself

Then set up -g once, because the flagship question is global:

export DEX_ROOTS=~/Projects   # colon-separated, like PATH. Put it in your shell rc.
dex who express -g            # every project on the disk, asked from anywhere

Recipes

You want to know Run
"That CVE is in the news - am I exposed?" dex who lodash -g
"What can I delete from this project?" dex unused
"Why is this package even installed?" dex show .
"What can I safely bump today?" dex up
"Which of my projects are behind?" dex outdated -g
"Give me every critical finding, for a report" dex audit -g --json | jq '.[] | select(.severity=="critical")'
"Fail the build if anything is vulnerable" dex audit (exits 1 on findings)

Commands

dex who <package>    which projects have it, what version, what's wrong with it
dex ls               every package you declared, by how many projects have it
dex audit            every known advisory                        (exits 1 on findings)
dex unused           declared and never referenced
dex outdated         direct dependencies behind their latest release
dex up               move them to the latest release             (prints; --yes runs)
dex show <project>   one project: every dependency, and why it counts as used

Everything is read-only except dex up --yes, which is the only command in the tool that changes anything, and only when you say so.

Options

Flag Where What it does
-g, --global all but show look at $DEX_ROOTS instead of here
--depth N all but show how many levels down to look for projects (default 4)
--json all but up machine-readable output
--offline who answer from the disk alone, ask nobody anything
--direct audit only packages you declared, skip the tree underneath
--all ls count the whole tree, not just what you declared
--limit N / --min N ls how many rows (default 40) / minimum projects (default 1)
--patch / --minor / --breaking up how far to move. --minor is the default
--breaking outdated only the bumps whose author reserved the right to break you
--yes up actually run the commands

Where it looks

explicit paths   >   -g   >   the current directory
  • dex unused - here, and everything below it.
  • dex unused ~/work ~/side - those.
  • dex unused -g - everywhere, meaning $DEX_ROOTS.

$DEX_ROOTS is something you ask for, never something that happens to you. It used to outrank the working directory, which meant that once you set it, dex unused inside one project silently scanned all seventy-five of them and there was no way to ask about the one you were standing in. A tool that ignores your location to go read the whole disk is one you stop trusting to be looking at what you think it is.

Upgrading

$ dex up                      # patches and minors: the set that cannot break you

  acme/backend  pnpm · prod
    pnpm add axios@^1.18.1 fastify@^5.10.0 @nestjs/core@^11.1.28 …

  acme/backend  pnpm · dev
    pnpm add -D prettier@^3.9.5 jest@^30.4.2 …

  32 packages in 1 project  ·  nothing was run

  1 of these has uncommitted changes - if the upgrade goes wrong, `git
  checkout` takes your own work with it:
    acme/backend

  to run it:  dex up --minor --yes
dex up patches and minors - the default, and the set that cannot break you
dex up --patch patches only
dex up --breaking everything, including the bumps whose author reserved the right to break you
dex up --yes actually run it

Two things to know before you use it.

It writes the commands, not the change. The output is pnpm add …, run in your project by the tool that owns your lockfile. Nothing here edits a package.json or touches a lockfile. (Why?)

Peer dependencies are never moved. Bumping one is an API change to your package - it belongs in a deliberate release, not a bulk upgrade.

Below 1.0, "minor" means breaking

class-validator   0.14.2  → 0.15.1   breaking
sharp             0.34.5  → 0.35.3   breaking
axios             1.13.2  → 1.18.1   minor

^0.34.5 means >=0.34.5 <0.35.0 to npm: the resolver refuses to cross that line itself, because the spec says anything may change at any time in 0.y.z. Reading 0.34.5 → 0.35.3 as "minor" is arithmetic, not meaning - and it is the reading that gets somebody hurt, because dex up promises the set that cannot break you. Both of those are sitting on a real disk at 0.x, both a "minor" away, and both would have been moved without asking.

In CI and scripts

dex audit exits 1 when it finds anything, which is what makes it usable in a pre-commit hook or a CI step:

dex audit                                                   # fails the step on any finding
dex audit ~/Projects --json | jq '.[] | select(.severity=="critical")'

Every command except dex up takes --json.

How unused decides

A package is in use if any of these is true, and each answer says which one:

  • something imports or requires it (including subpaths: tailwindcss/plugin)
  • a package.json script runs its binary ("build": "tsc -p ."typescript)
  • anything else runs its binary: a .husky hook, a CI job, a Makefile
  • a config file names it - including eslint's shorthand, where extends: ["airbnb"] means eslint-config-airbnb
  • another package peer-requires it - @nestjs/core demands reflect-metadata, @react-email/render demands react-dom
  • it is @types/*, which tsc picks up off the filesystem and nobody ever imports
  • it is on the short list of packages a toolchain loads invisibly (tslib, core-js, @babel/runtime…)

Only when all of that comes back empty is a package reported unused. On a real disk that leaves findings like a NestJS scaffold's nodemon, supertest, ts-loader and tsconfig-paths - declared years ago, referenced nowhere since.

The best finding this tool has is the intersection: a package you never imported, that has a CVE. audit calls those out on their own line, because the fix is deleting one line.

What it reads

Package manager Lockfile Status
pnpm pnpm-lock.yaml v9, v6 and v5 - all three key spellings
npm package-lock.json v2/v3 fully, v1 without the graph
yarn yarn.lock berry and classic
bun bun.lock text format, untested against a real file
bun bun.lockb cannot - binary, no parser outside bun

Only npm packages, for now.

The network

Two of the questions have answers that are not on your disk, so exactly two things reach the network:

What Where from Which commands
Known vulnerabilities OSV.dev audit, who
Latest version registry.npmjs.org outdated, who

ls, unused and show never touch it, and dex who --offline answers from the disk alone. Nothing is uploaded: OSV is asked about (name, version) pairs, which is the same thing npm audit sends. Your code never leaves the machine, because dex never sends it anywhere.

The three rules it will not break

1. A range is not a version. "express": "^4.18.0" is a wish. The lockfile is the only file that records what a resolver actually chose, and asking OSV about a range is not a weaker answer - it is a wrong one. A project with no lockfile reports ?, never a guess, and it is counted as unexamined rather than clean.

2. "No advisories" from a tool that couldn't read your lockfile is not a clean bill of health. Every gap - a missing lockfile, a bun.lockb, a parse failure - is printed under every audit, including the clean ones. Especially the clean ones.

3. Nothing is called unused until every way it could be in use has been checked. Calling a used package unused costs you a broken build; calling an unused package used costs 40 KB. So the checks run in one direction, and anything unreadable is Unknown - never Unused.

FAQ

Can it break my projects?

Only dex up --yes changes anything, and what it changes is whatever pnpm add would change. Every other command opens files for reading and prints.

Why not just use npm audit?

Use both. npm audit is about the project you are in and dex is about all of them. The things dex can answer that a per-project tool structurally cannot: which of your projects share a vulnerable version, which package pulled it in, and which vulnerable packages you never even imported.

Why is there no dex index?

75 projects and 40,000 packages take about 0.8 seconds. There is no cached index and no indexing step: every answer is computed from what is on the disk at the moment you ask.

That is a deliberate trade. A cache would make who instant and would also, one day, report a CVE you fixed last week or miss one you installed this morning. A security tool that is sometimes lying is worse than a slow one, and 0.8 seconds is not slow.

Why doesn't dex up just edit my package.json?

Because that isn't the hard part, and doing it halfway is worse than not doing it. A lockfile is a resolved graph, not a list of versions: bumping axios means re-resolving its whole subtree, re-checking every peer constraint, and rewriting integrity hashes. pnpm does that correctly and this tool never could. Hand-editing a lockfile produces a file that looks right and installs a tree nobody has ever tested. So dex writes the command and your package manager writes the change.

Why does it say "why it's used" instead of just listing packages?

Because a tool that says "delete this" without saying how it knows is a tool you have to double-check by hand, which is the work you were trying to avoid. dex show . names the signal for every package: this import, that script, this peer requirement.

Why does it say who pulled a package in?

Most CVEs are not in the fifteen packages you chose. They are in the nine hundred those fifteen dragged in - and you cannot bump a transitive package, so naming it alone is useless:

  cookie   0.4.1  →  CVE-2024-47764   moderate   ·   fixed in 0.7.0
              acme/backend      via express, @nestjs/platform-express

"cookie is vulnerable" is a fact you can do nothing with. "cookie is vulnerable, and express pulls it in" tells you what to bump.

Why does pnpm name the same package three ways?

Because it changed its mind twice, and all three are still on real disks: express@4.18.2 (v9), /express@4.18.2 (v6), /express/4.18.2 (v5). The last is the awkward one - it separates name from version with the same / a scope uses, and appends peers with _, so /express/4.18.2_react@18.0.0 read with v9's rules gives you a package called express/4.18.2_react. Both readings are tried and the one whose name could actually be a package name wins. You will never see any of this; it is here so you know it was handled.

Every rule here exists because it got a real project wrong

The test suite (cargo test) is entirely accuracy cases, each one locking in a mistake this tool must never make again. Five of them, found on real projects on a real disk:

  • pnpm writes the peer context into the version. 11.1.24(rxjs@7.8.2) is not a version - npm never coined it and OSV has never heard of it. Left on, every pnpm project reports "no CVEs" forever.
  • reflect-metadata and react-dom are not unused. No source imports either. @nestjs/core and @react-email/render peer-require them. Deleting either breaks the app on boot, and only the lockfile knows.
  • @commitlint/cli is not unused. Nothing imports it, no script runs it, and its own config names a different package. What runs it is a one-line git hook.
  • "Fixed in" is a question about where you are standing. An advisory spanning three major lines patches each one separately. Taking the lowest fix told ten projects on Next.js 16 that the fix was 15.5.16 - not a fix, a downgrade into a different set of advisories.
  • The npm registry 406s scoped packages when you ask for the abbreviated document on /latest. Unscoped names are served normally, so it looks like it works - while every @scope/* package silently has no latest version.
  • outdated printed "everything is up to date" when npm rate-limited it. Failed lookups were swallowed, a failed lookup produced no row, and no rows read as good news. Rule 2 above, broken by the tool that states it. A registry that cannot be reached now says so, and is kept apart from a package that is merely private - one is news, the other 404s forever and is not.

Two more the tests hold that were never shipped: pnpm v5 keys (/express/4.18.2_react@18.0.0) read with v9's rules invent a package called express/4.18.2_react, and stripping that _ from the whole key instead of just the version turns the real, installed string_decoder@1.3.0 into string.

Known limits

  • npm ecosystem only. No cargo, pip or go yet.
  • bun.lockb cannot be read at all. It is binary and there is no parser outside bun itself. It is reported as a gap, never as clean.
  • bun.lock has never been tested against a real file. The parser is written; nobody has handed it one.
  • package-lock.json v1 has no graph, so "who pulled this in" is unanswerable for those projects.
  • A project with no lockfile is ?, not a guess. See rule 1.

Roadmap

  • cargo, pip and go: the same three questions, other ecosystems
  • dex why <package> --in <project> - the full path from your manifest to a transitive package
  • version drift against your own newest, so it works with no network at all
  • a real bun.lock to test the bun parser against - it is written and has never seen one

License

MIT

About

Dependency Explorer

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages