ArchSetu
ArchSetu
Back to blog
2026-08-025 min read

Undeclared Dependencies: The Bug That Hides Until a Lockfile Changes

Why an import that 'just happens to work' is a ticking time bomb, and how ArchSetu finds every one in a repo - plus a real bug that taught us this check can't blindly apply to every language.

Here's a failure mode almost every JavaScript/TypeScript developer has hit at least once: your code imports a package, it works fine locally, tests pass, CI is green - and then weeks later, a completely unrelated dependency bump changes the lockfile's resolution tree, and suddenly a build fails with Cannot find module 'X'. The package was never actually declared in package.json. It only ever worked because some other dependency happened to pull it in as a transitive install, and node's module resolution doesn't care who declared what - it just resolves whatever's physically present in node_modules.

That's an undeclared dependency: a real, structural gap between what your code assumes exists and what your manifest actually promises. It's invisible until the exact transitive chain that's been quietly propping it up shifts.

Why this check is one-directional

ArchSetu's dependency hygiene check only flags one direction: imported, but not declared. It deliberately doesn't attempt the opposite - "this declared dependency looks unused" - because that requires the same class of reference-detection heuristics that already caused real false positives in the dead-code detector. A dependency can be genuinely used without ever appearing in an import statement: a dynamic require(), a build-tool config file, a peer-dependency contract satisfied implicitly. An import pointing at something undeclared, by contrast, is a verifiable structural fact - either package.json lists it somewhere in the workspace, or it doesn't. There's no guessing involved.

Monorepo-aware resolution

A single-package repo is the easy case. A monorepo is where this gets genuinely tricky: a dependency can be correctly declared in any workspace package's package.json, not necessarily the one importing it. ArchSetu resolves each import against the nearest package.json first (longest matching directory prefix), but only actually flags something as undeclared if it's missing from both the nearest one and every other package.json in the workspace:

// packages/core/src/foo.ts imports 'lodash'
// -> checked against packages/core/package.json first (nearest)
// -> if missing there, checked against every OTHER workspace package.json
// -> only flagged if it's missing from ALL of them

That mirrors how hoisting actually works in a real workspace - a dependency declared once at the root, or in a sibling package under a shared install, is a genuinely valid declaration even though it's not local to the file that uses it.

A real bug: this check assumes JavaScript's import shape

This feature was built and tested against JS/TS codebases, where a bare import specifier (lodash, @archsetu/core) unambiguously means "an installable npm package." That assumption broke completely the first time it ran against a Rust codebase.

Rust's own import syntax uses :: as a path separator - std::collections::HashMap, for example. Run against rust-lang/rust itself, every one of those became a single "package name" with no npm-style separator to split on, and every genuine standard-library import got flagged as undeclared, since nothing in the check knows Rust's standard library isn't an npm package that needed listing anywhere.

The result: 15,886 meaningless findings on a single repo, all of them noise, none of them a real undeclared dependency.

The fix wasn't a Rust-specific import parser - it was a language gate. The check now runs exclusively for javascript and typescript files, full stop. Every other supported language has its own, completely unrelated dependency-declaration mechanism - Python's requirements.txt, Go's go.mod, Java's pom.xml, Rust's Cargo.toml - and this specific check has no model of any of them. Rather than build (and maintain) five separate import-parsing strategies for a first version, the honest move was to scope the feature to where it's actually reliable, and say nothing at all everywhere else.

The general lesson

A heuristic that's correct for one ecosystem's syntax isn't automatically safe to apply broadly just because the underlying idea - "you're using something you never declared" - is language- agnostic in principle. The failure mode here wasn't a logic bug; the logic was completely correct for JavaScript. It was a scope bug: assuming a language-specific parsing shape held everywhere, without checking. The fix that actually held up wasn't cleverer parsing - it was knowing exactly where to stop.