DEV Community

Avinash Gehi
Avinash Gehi

Posted on

I wrote a dependency scanner with zero dependencies. The parser was easy; the terminal was not.

The premise was too neat to pass up: a tool that finds dependency problems should not have dependencies.

So depx reads a repository's source, extracts every import, resolves each one against the manifest and the install tree, and reports where the two disagree. Twelve languages. Fully offline. "dependencies": {} and no node_modules.

Built in 72 hours for Zero Dependency 2026, run by Hackathon Raptors, where the single rule is that your shipped manifest ships empty.

The interesting part was not "can you avoid npm." You can. The interesting part was discovering which of my reflexes were load-bearing and which were just habit.


Why this tool, specifically

A 2025 USENIX Security study fed 576,000 AI-generated code samples through a checker: 19.7% of the packages the models recommended did not exist.

The inventions repeat. Ask ten models for an HTTP retry helper and a good number reach for the same plausible name. So an attacker doesn't guess — they read what the models suggest, register those names first, and wait. You run npm install, the name resolves, and you're compromised having typed everything correctly. The industry calls it slopsquatting.

It's statically visible. Your code imports a name; your manifest doesn't have it; nothing on disk provides it. That's the whole detection.


What I reimplemented

Sixteen substitutions. Three worth talking about.

The JavaScript parser → a masking lexer

Every tool in this category — depcheck, dependency-cruiser, madge, every bundler — installs a full JavaScript parser to answer one question: which strings are import specifiers?

You don't need a syntax tree for that. You need to know which string literals sit in import position. So I mask the source: blank out comments, string bodies, template literals and regex literals while preserving byte offsets, then test what precedes each surviving string literal.

// after masking, every remaining string literal is real code
const before = masked.slice(0, start).trimEnd();
if (/\bfrom$/.test(before) || /\bimport$/.test(before)) { /* … */ }
Enter fullscreen mode Exit fullscreen mode

About 150 lines against a 100 KB dependency. It can't be fooled by // import 'fake' or const s = "require('fake')", both of which are in the test suite.

Preserving offsets is the trick that makes it work. The masked text is the same length as the original, so an offset into one is an offset into the other, and you can report src/app.js:5:34 from a lexer that never built a node.

The terminal UI → node:readline and a dozen escape codes

This is the one that surprised me.

depx has a full-screen interface — alternate screen, arrow keys, live search, $EDITOR on Enter. The reflex here is ink: React, plus a reconciler, plus the Yoga layout engine compiled to WebAssembly. To draw a list you can arrow through in a terminal.

Every piece is already in Node:

Need What you'd install What's already there
Decode arrow keys keypress, ink readline.emitKeypressEvents()
Read keys unbuffered blessed, enquirer stdin.setRawMode(true)
Alternate screen, hide cursor blessed, cli-cursor, ora \x1b[?1049h, \x1b[?25l
Terminal size + resize term-size stdout.columns, the resize event
Two-pane layout, text wrap ink + Yoga (WASM) ~30 lines of arithmetic
Highlight a row chalk util.styleText('inverse')

If you take one thing from this post, take the design decision underneath it: the state machine and the frame renderer are pure functions of (state, size).

reduce(state, key)              // → { state, action }
renderFrame(state, {cols, rows}) // → exactly `rows` strings of exactly `cols` width
Enter fullscreen mode Exit fullscreen mode

Only runTui() touches stdin, stdout or the process. Which means 47 tests drive the entire interface without a terminal, asserting on frames as plain strings. Testing an ink app normally means installing ink-testing-library on top of ink.

That invariant — every frame is exactly rows lines of exactly cols display width — turned out to be the highest-leverage assertion in the project. One property, three bugs I would never have caught by eye:

  • a footer emitted without padding, invisible because the draw loop's \x1b[K was cleaning up after it
  • a chrome constant that assumed the detail panel always existed, leaving a stray row on every empty frame
  • three rows in the empty state emitted as '' instead of a full-width run — two columns wide instead of seventy-eight

None of them looked wrong on screen. All of them are obvious to displayWidth(line) === cols.

chalk, minimist, string-width, cli-table3, globby, jest, esbuild

Mostly one-liners now:

import { styleText, parseArgs } from 'node:util';   // chalk, minimist
import { test } from 'node:test';                    // jest
import assert from 'node:assert/strict';
Enter fullscreen mode Exit fullscreen mode

util.styleText even honours NO_COLOR and does TTY detection for you, which is most of why people install chalk in the first place.


What the standard library made painful

Three things, in ascending order of annoyance.

1. Nothing tells you how wide a string is.

'日本語'.length is 3. It occupies 6 terminal columns. That gap is the entire reason string-width exists, and if you're aligning columns you cannot ignore it. So: strip ANSI, iterate code points, skip control characters and combining marks, and count East Asian Wide and emoji ranges as two.

if (code < 0x20 || (code >= 0x7f && code < 0xa0)) continue; // control
if (code >= 0x0300 && code <= 0x036f) continue;             // combining mark
width += isWide(code) ? 2 : 1;
Enter fullscreen mode Exit fullscreen mode

About 40 lines. Not hard, just genuinely absent.

2. Regex or division?

A lexer that masks regex literals has to decide what / means. a / b is division; a = /b/ is a literal. There is no stdlib help; you use the same preceding-token heuristic real lexers use — a set of characters and keywords after which a / can only start a regex.

const REGEX_PRECEDERS = new Set(['(', ',', '=', ':', '[', '!', '&', '|', '?', /* … */]);
const REGEX_KEYWORDS = /\b(return|typeof|instanceof|in|of|new|delete|void|yield|await)\s*$/;
Enter fullscreen mode Exit fullscreen mode

Not infallible. Documented as such in the README, because a limitation you name is a limitation and one you hide is a bug.

3. Node ships no TOML support. At any version.

This was the only true gap — the one place the constraint forced original work rather than a smaller reimplementation. Cargo.toml and pyproject.toml are not optional if you claim Rust and Python support.

So: a subset reader for what a dependency manifest can actually contain — tables, dotted keys, strings, numbers, booleans, inline tables, inline arrays. Arrays-of-tables and datetimes are deliberately not implemented.

The part I'd flag to anyone hand-rolling one: your comment stripper and your key/value split both have to be quote-aware.

key = "a = b # c"
Enter fullscreen mode Exit fullscreen mode

A naive split('=') and a naive split('#') each corrupt that line, and most quick TOML readers I've read do exactly one of the two.

(Bun 1.4 ships Bun.TOML. I was targeting Node on a machine with Bun 1.3.14, so it wasn't an option — but a Bun submission could legitimately delete this whole file.)


The afternoon that vanished

The interface worked. Arrow keys moved, search filtered, q restored my prompt.

And then the process just… sat there. Prompt back, terminal clean, shell unresponsive. ctrl-c to get out, every time.

The quit path looked correct:

stdin.off('keypress', onKey);
stdin.setRawMode(false);
stdin.pause();
stdout.write(ALT_OFF);
Enter fullscreen mode Exit fullscreen mode

The listener is gone. Raw mode is off. The stream is paused. The alternate screen is closed. What is holding the event loop open?

pause() is not the same as releasing the handle. readline.emitKeypressEvents() leaves a reader attached to the TTY, and a referenced TTY handle keeps libuv's loop alive whether or not you're reading from it. The fix is one line:

stdin.unref?.();
Enter fullscreen mode Exit fullscreen mode

What cost me the afternoon wasn't the fix — it was that every symptom pointed away from the cause. The terminal was restored, so the teardown "worked." The prompt was back, so the program "exited." I went looking for a stray setInterval and an unresolved promise before I thought to question pause().

The lesson I'd generalise: when a Node process won't exit, stop reading your cleanup code and start asking which handles are still referenced. Those are different questions, and only the second one matters.

It's now a regression test that injects fake streams and asserts unref was called — no terminal required, because of the pure-function split above.



Two things about terminal UIs that have nothing to do with dependencies

Your success state is a UI state.

Running depx on a healthy repository opened onto a header, a footer, and twenty blank rows. It was behaving correctly — there are no findings, so there is nothing to browse — but a mostly-empty screen reads as a program that failed to load. It was happening on exactly the repositories the tool should be reassuring about.

The fix wasn't code, it was noticing. Now the no-findings case gets a composed panel: a mark, a headline, what was actually scanned, and the nested projects that were skipped along with why. And the three ways of having nothing to show stay distinct, because a repository whose findings are all suppressed by config is configured, not clean, and one with no source files at all was never really analysed.

If your empty state is a blank screen, you didn't design an empty state.

I nearly shipped a lie, and it would have demoed better.

The analysis is fast: four files in 40ms, six thousand in 0.70s. So when I added a scanning screen — real file counts, streamed from the walker through an onProgress callback — it flickered past faster than you can perceive it.

The obvious fix was a minimum display time. Five hundred milliseconds and the demo looks great. Everyone does this.

I didn't, and I think the reason generalises. A floor on your loading indicator means shipping a slower tool so that it looks busier — trading a real strength (it's fast) for a manufactured one (it looks like it's working hard). The screen is now up for exactly as long as the walk takes: a flicker on a small project, about 0.4 seconds on four thousand files, and genuinely useful on a monorepo.

If your progress indicator needs a minimum duration to be visible, you don't need a progress indicator. You need to print the number.


The bug report that was really a design review

Someone watching me demo it asked the question that reframed the whole thing:

"Why do I have to quit the UI, run another command, and open it again? Why the hell am I bouncing between the two?"

They were right, and I'd been blind to it because I built the pieces in the order the code wanted rather than the order a person uses them. The interface was a view onto one subcommand — check. The zero-dependency rule check and the vendoring scan were separate commands, so answering three questions about the same repository meant three round trips through a shell.

That's not a missing feature. That's having modelled the tool as a set of commands with a UI bolted onto one of them, instead of as a thing you point at a repository.

Three keys now — f findings, z rule, v copied source — switch view from anywhere. Both analyses run behind the single scan screen, so switching is instant. It turned out to be two passes and not three, because verifyZeroDep() already returned the vendoring scan; the data had been sitting there the whole time, reachable only by exiting and typing a different word.

The change also caught a bug it had just introduced. The fuller footer overflowed 76 columns, and my all-or-nothing fallback silently swapped in a shorter hint line — one that didn't mention the new views at all. So the feature existed and was undiscoverable at exactly the widths most people use. The footer is tiered now, and drops the movement hints before the view list:

A key you cannot discover is a feature that does not exist.


The rule I wish I'd started with

depx opened the interface when you ran it bare, and printed a report when you gave it a path. Two behaviours, and the boundary was "did you pass an argument" — which is a fact about your typing, not about your intent.

It's one rule now:

No subcommand, in a terminal → the interface. Everything else → the report.

Naming a subcommand is the signal that you want that command's output. So depx check . is text and always was. So is a pipe, a redirect, a CI job, --json, --quiet. And depx and depx ./some/project both mean "show me this project", where how it's shown depends on whether a person is looking.

The property that makes a rule this broad safe: every scripted invocation either names a subcommand or isn't attached to a terminal. So no script's behaviour can change, which is the actual thing you're protecting. Two tests pin it — a bare path through a pipe is still the report and still exits 1, and a named subcommand is never the interface.

I'd been treating "adapt to the terminal" as the risky thing. It isn't. The risky thing is adapting on a signal a script can accidentally produce.

The insight I actually care about

Replacing packages was the fun part. The hard part was deciding what the tool is allowed to claim.

depx is offline. It can prove an import resolves to nothing in the project in front of it. It cannot prove a package doesn't exist anywhere — that needs a copy of the registry index, which is precisely the kind of dependency this tool exists without.

So there are two findings where a lazier tool would have one:

  • ghost — imported, and nothing here provides it. High confidence, because the full resolution universe was visible: either the language's manifest is authoritative (Go won't compile an import absent from go.mod) or an install tree was on disk to check against.
  • undeclared — imported, not declared, and no evidence available to judge further. Deliberately weak.

That distinction exists because a false ghost is the most damaging error this tool can make. It sends someone hunting a supply-chain compromise that isn't there. So the design leans away from it everywhere: modules the repo defines, a package importing itself, subpath imports, bundler aliases, #internal/x, $lib/w, Python's __future__, Ruby's English — all excluded before judgement.

An empty .venv directory does not promote anything to a ghost either. A directory is not evidence. It has to contain something.

I validated against eighteen real repositories — every public submission I could find, plus working Go, Rust, Python and TypeScript projects with trees installed. That produced twenty-one defects, mostly false positives, each now a regression test named for the case that caused it:

Rust: a bin target importing its own crate is not a ghost
Go: an // indirect requirement is not reported as dead
Python: a URL requirement line is not a package named "git"
Java: a Maven coordinate is never reported as dead

That last one is a whole tier of the design. In Java, import org.apache.commons.lang3.StringUtils is satisfied by the artifact org.apache.commons:commons-lang3. The namespace and the coordinate are unrelated strings, connected only by a mapping that lives on Maven Central. Resolving that offline means shipping the registry index.

So Java, C#, PHP and C/C++ are tier 3: ghost detection off by design. They report file inventory and declared dependencies and stay silent on ghosts and dead code.

Shipping a feature that says "I can't answer this" is less satisfying than shipping one that guesses. It's also the only honest option, and I think it's the thing in this project I'd defend hardest.


Numbers

  • 237 tests, 55 suites, node:test, no config file
  • 16 stdlib substitutions, each documented with reasoning
  • 12 languages across 9 adapters
  • 6,000 files scanned in 0.70s
  • Scan screen driven by real file counts, with no minimum display time
  • Single-file build that's byte-identical across runs — and the verify step diffs the bundle's behaviour against the source tree, so a build that changed behaviour fails even if it hashed the same
  • dependencies: {}, no node_modules, and the tool verifies that claim about itself

Would I do it again

For a scanner? Yes, and I'd keep it. The dependency count of a tool that audits dependencies is not a gimmick, it's the argument.

For a product with a deadline? No. I'd install ink.

But I know what's inside ink now, and that's worth something. The uncomfortable finding of the weekend is how few of my reflexes survived contact with the standard library. chalk became one import. minimist became one import. jest became one import. The genuinely irreplaceable thing turned out to be TOML — a text format from 2013.

We didn't outsource the hard parts. We outsourced the parts we stopped looking at.


Built for Zero Dependency 2026 by Hackathon Raptors. Source: github.com/Avi36005/ZeroDependency_Team_Kryptonite — MIT.

Top comments (0)