Mapping Every Dependency to the Features That Break If It Disappears Tomorrow
Run npm ls --all on a mid-sized web app and you get a tree that scrolls for pages. It’s accurate and complete, and on the morning a maintainer deletes a package it tells you almost nothing. You can see that tiny-util sits three levels down. You can’t see whether losing it breaks the checkout page or a script nobody has run since spring.
A dependency tree answers “what do I depend on”. The question that matters is “what breaks, and for whom, if this package vanishes or turns hostile”. They need different data. The tree comes from a lockfile. The answer comes from your own source: which functions call the package, which routes, commands and jobs those functions serve, and how much code would change to replace it. A tool that produced that for every package would turn dependency management from an inventory into something a product owner can read.
Every Existing Tool Covers a Slice
The incidents that made people care were four different failures. In March 2016 the author unpublished left-pad, and builds broke because the package simply wasn’t there. In 2018 event-stream changed hands, and the new maintainer added a dependency carrying a malicious payload, so most victims were running code they had never chosen. In January 2022 the author of colors and faker sabotaged both packages, which meant the code was present and hostile. In March 2024 the xz backdoor (CVE-2024-3094) came to light; its author had spent time earning the maintainers’ trust. The first question was the same every time: where do we use this, and what would we lose?
Existing tools cover slices of the answer. Socket analyzes what a package does, such as running install scripts or opening network connections. OpenSSF Scorecard grades the practices of the project behind a package, and deps.dev lays out versions and dependency graphs across ecosystems. Snyk, Endor Labs and Semgrep offer reachability analysis, which asks whether your code calls the vulnerable function instead of merely installing the package. Go’s govulncheck is the cleanest example: it reports only vulnerabilities in functions your code actually calls.
That last one is closest to the idea here, and it still stops short. Every tool on the list starts from a risk signal (a CVE, a suspicious behavior, a weak score) and ends at the package or the function. None of them climbs from the call site to the route, the command or the job a customer touches. None has anything to say about a package with no signal at all. A maintainer who gets bored doesn’t generate an advisory.
The hidden costs of third-party API dependencies asks the same question about services you call over HTTP; this is the code-level version.
From Imports Up to Routes
The build starts with an import graph. Tree-sitter gives you a syntax tree for most languages, so parsing is cheap; the language-specific work is module resolution. In JavaScript that means reading package.json, following node_modules and handling exports maps. In Python it means mapping import names to installed distributions, because import yaml comes from PyYAML and import PIL from Pillow, and the metadata of what’s installed is the only reliable map.
Next, find every use of each imported name: calls, instantiations, decorators, subclasses, and type names that leak into your own function signatures. Record the distinct symbols, not just the count of call sites. Forty call sites through two functions is a smaller commitment than six call sites through thirty symbols.
Then build an approximate call graph inside your own code and walk callers upward until you reach an entry point: an HTTP route, a CLI command, a queue consumer, a cron job. Detecting entry points is a table of framework patterns (an app.get(path, handler) call, a bin entry in package.json, a file under app/api/ in a file-routed framework). Cover three frameworks properly rather than twenty badly.
For a hypothetical invoicing service and a made-up PDF library, a per-package report could look like this (the command name is a placeholder):
$ depimpact report pdf-stamper
pdf-stamper 2.4.1 (direct)
entry points 5 GET /invoices/:id/pdf, POST /statements,
cli export-ledger, job nightly-statements, job dunning-emails
call sites 23 in 9 files, 4 distinct symbols (stamp, merge, embedFont, toBuffer)
wrapper no (9 files import it directly; PdfDoc appears in 3 exported signatures)
release last release 14 months ago, 1 publisher
replacement moderate
+ narrow API, only 4 symbols
- no wrapper, so the change touches 9 files
- PdfDoc leaks into your own interfaces
unreached 2 call sites reachable from no known entry point
Invert the same data and each route gets a bill of materials: a checkout route might touch nine packages, two of them with a single publisher. That second view is the one a product owner can use, because routes mean something to them and package names don’t. Routes are only a proxy for features, though: “checkout” is a feature, and POST /cart/confirm is just where it starts. A small file lets people name them:
checkout:
entry: ["POST /cart/confirm", "job retry-failed-payments"]
reporting:
entry: ["cli export-ledger", "job nightly-statements"]
The wrapper line deserves attention. When every call site sits behind one module you own, replacing the package is a one-file job. Whether that wrapper exists is an architecture decision somebody made or skipped, and architectural coherence is the discipline that keeps it made. The best output of the whole exercise may be a short list of packages worth wrapping this week.
Where Static Analysis Runs Out
Dynamic imports and reflection come first. require(name) with a computed name, importlib.import_module, plugin loaders and decorators that register themselves at import time all defeat a syntax-level walk. The tool should say “unresolved dynamic use” next to the file where it happens and never guess.
Transitive dependencies are the next problem. The risky package is often three levels down, pulled in by something you chose, so your code never imports it and call-site analysis has nothing to look at. Code written with an assistant adds to the pile, because its imports arrive already picked, and vibe coding works until you have to read the code. The workable answer is propagation. A transitive package inherits the impact of every direct dependency above it, because if it vanishes, whatever needs it can’t install or run. That’s an upper bound, since the parent may never touch the broken path. A tighter answer means running the same analysis on the dependency’s own source, which sits in node_modules, but the cost grows with each level.
Call-site count is a poor proxy for importance. A logging library appears in every file and could be swapped in an afternoon because its API is narrow. A JWT verification library appears once, in a middleware, and every authenticated route stands on it. Keep impact (how many entry points) and difficulty (how hard to replace) as separate columns. A single risk score hides the case you care about, which is broad impact with high difficulty. No analysis will notice that your sessions use a format the auth library defined; a built-in table of categories (auth, ORM, crypto, serialization) can at least raise a flag.
Monorepos multiply all of this. A shared internal package that wraps an HTTP client is a wrapper for twelve services and a single point of replacement for all of them, so workspace packages have to be transparent nodes in the graph, and each service needs its own entry-point roots.
Speed decides whether the tool survives in CI. Cache per-file facts keyed by content hash, reparse only what changed, and run on pull requests that touch a manifest or lockfile plus a nightly schedule. Call sites that no entry point reaches are either dead code or a hole in entry-point detection, which makes them the same question as finding what nobody uses. The tool also shares a spine with a cross-file config linter: parse everything, link definitions to uses, report where the graph looks wrong. A dependency analyzer that needs a thousand packages of its own would be embarrassing, so ship it as one binary.
What Version 0.1 Does and Refuses
Version 0.1 handles JavaScript and TypeScript on npm first, because three of the four incidents above were npm packages, with Python second. It covers direct dependencies only and prints the “pulled in by” chain for the rest. Entry points are HTTP routes in a few frameworks plus CLI commands. Output is Markdown for people and JSON for CI. A run is a command, not a service.
It refuses two things. It won’t scan for vulnerabilities, because Snyk, Semgrep, govulncheck and others do that against databases a side project can’t match. It won’t replace anything automatically either; rewriting imports to another library is a separate, riskier product.
Compute the answer before anyone asks.