---
title: 'Dependency Risk'
source: 'https://academia.sh/en/courses/javascript-ecosystem/dependency-risk'
course: 'Modules, Tooling and the Ecosystem'
language: en
updated: '2026-08-17T18:09:45+00:00'
license: 'CC BY-SA 4.0'
---

# Dependency Risk

Measuring the transitive dependency mass, what code running at install time means, integrity verification, locking discipline, package selection criteria, and managing abandoned packages.

The previous lessons treated dependencies as a technical resolution problem: which
version gets chosen, where it gets installed, who sees it. This lesson asks a
different question — how much is the installed code as a whole trusted, and how is
that trust narrowed?

The question is a management one, but the answer is entirely technical, starting with
a measurable quantity: how many packages a project actually runs.

## The Transitive Mass

The packages written in the manifest are a small fraction of the installed packages.
Every dependency brings its own dependencies; this relationship continues until it
closes. The entire set is called the **dependency closure**.

The closure is computed with a breadth-first search over the manifests:

```javascript
// file: closure.mjs -- reads registry/<name>/package.json files
import { readFile } from 'node:fs/promises';

async function manifest(name) {
  return JSON.parse(await readFile(`registry/${name}/package.json`, 'utf8'));
}

const root = await manifest('text-measurer');
const direct = Object.keys(root.dependencies);
const closure = new Map();
const queue = [...direct];

while (queue.length > 0) {
  const name = queue.shift();
  if (closure.has(name)) continue;
  const pkg = await manifest(name);
  closure.set(name, pkg.version);
  queue.push(...Object.keys(pkg.dependencies ?? {}));
}

console.log('direct dependencies:', direct.length, '->', direct.join(', '));
console.log('transitive closure:', closure.size);
for (const [name, version] of closure) console.log(`  ${name}@${version}`);
```

In a small sample registry — a six-package directory — the result is this:

```
$ node closure.mjs
direct dependencies: 2 -> format-helper, string-tool
transitive closure: 5
  format-helper@1.4.0
  string-tool@2.1.3
  number-core@1.0.7
  unicode-table@3.2.0
  data-package@1.2.9
```

Two deliberate choices brought in five packages. The ratio is not due to the example's
small size: there is a multiplier effect at every level, and in real projects a
double-digit count of direct dependencies can produce four-digit closures.

What this number means: the code of every package in the closure runs inside your
program, with your program's privileges. You made the decision for the packages you
chose directly; for the rest, you trusted indirectly through the packages that chose
them. Closure size is the measure of that indirect trust.

## Code That Runs at Install Time

The trust boundary does not begin the moment you call the library. The manifest file
can define scripts to be run during installation:

```json
{
  "name": "sample-package",
  "version": "1.0.0",
  "scripts": {
    "postinstall": "node setup/prepare.mjs"
  }
}
```

This field exists for legitimate work: local compilation, cache preparation,
compatibility checks. The consequence, though — installing a package is equivalent to
**running** that package's code. Without ever importing the package, just by running
the install command, execution rights go to the install script of every package in the
closure. Scripts run with the user's privileges and can reach the filesystem and the
network.

The defense is three steps:

**Turn off install scripts by default.** Installation tools offer this as an option.
With them off, packages whose installation fails are examined one by one, and an
exception is granted only if genuinely necessary.

**Install in an isolated environment.** Installing not on the developer's machine but
in a limited-privilege, discard-afterward environment narrows the area a script can
reach — the principle of least privilege from the Introduction to Linux course applies
directly here.

**Review changes that update the lock file.** A changed lock line means the archive to
be installed has changed, and should be reviewed as carefully as source code.

## Integrity Verification

The third piece of information the lock file records — the integrity digest — answers
a specific question: is the downloaded archive identical to the archive at the moment
it was locked?

The digest is a cryptographic checksum computed over the archive's bytes. Changing a
single byte changes the digest entirely:

```javascript
// file: digest.mjs
import { createHash } from 'node:crypto';
import { readFile } from 'node:fs/promises';

const content = await readFile(process.argv[2]);
const digest = createHash('sha512').update(content).digest('base64');
console.log(`bytes: ${content.length}`);
console.log(`sha512-${digest.slice(0, 44)}...`);
```

```javascript
// file: package.mjs
export function decimal(value, digits = 2) {
  return value.toFixed(digits);
}
```

```
$ node digest.mjs package.mjs
bytes: 100
sha512-+J2oBbbq0tFWSv0c1SoceVACG6lMhXEwdJ/46hX89Uru...
```

With a single character in the file — the default digit count — changed to `3` and the
same command run again:

```
$ node digest.mjs package.mjs
bytes: 100
sha512-AuXc8NVQcn4vfnfQvKDNnEHicRcsEPDkRoSygM0ma2DA...
```

The size stayed the same, the digest changed completely. The script prints the digest
truncated; the real digest is longer and is stored in full in the lock file.

The surface it closes is clear: the same name and version served with different
content. The surface it does not close is equally clear: publishing a **new version**
changes the digest anyway. The integrity digest proves the content has not changed; it
does not prove the content is safe.

## Upgrade Discipline

In a project with a lock file, installation installs exactly the tree written in the
lock. This turns upgrading from an accident into a deliberate process. A working
routine includes these steps:

1. An upgrade is made as a separate change; it is not merged into the same commit as
   another edit.
2. The changed lock lines are reviewed: which packages, from which version to which.
3. The tests are run.
4. The reason for the upgrade is recorded.

Delaying the upgrade is a risk too: staying on an old version means continuing to
carry fixed defects. The balance is regular, small upgrades; accumulated upgrades done
all at once make review impractical and, on failure, make it hard to tell which
package is responsible.

## Package Selection Criteria

Adding a dependency is a decision, and it can be evaluated with criteria that do not
depend on time:

**Closure size.** How many packages are in the package's own dependency closure? One
that brings a large closure for a small function is expensive.

**Interface surface.** How many of the package's functions will actually be used? A
large library pulled in for a single function makes both tree shaking and review
harder.

**Replaceability.** What replaces the package if it is dropped? One with a narrow,
standard interface is replaceable; one that imposes its own concepts on the project is
not.

**Maintenance signals.** Are reported defects answered, are there tests, how many
contributors? Single-person maintenance ends when that person loses interest.

**License.** Do the usage and distribution terms fit the project?

**The cost of writing it yourself.** If the function can be written in a few lines, the
dependency's installation, update, and trust cost may not be worth it. The same measure
applies in reverse too: in high-detail domains like cryptography, date computation, or
text encoding, writing your own implementation is riskier than using a mature package.

## Name Verification and Abandoned Packages

Package names are unique within a registry, but similarity is unrestricted. A name
with a letter added to a popular package, or a hyphen moved, can be picked unnoticed
when typed by hand. The defense is spelling discipline: the name is copied from
documentation or the manifest, never typed from memory; after adding it, the manifest
line and the package's source repository link are checked.

An abandoned package is a separate problem, diagnosed by observation: defects have
piled up, no releases are being made, its own transitive dependencies are unmaintained
too. The options:

**Replace it.** Swap in a maintained package doing the same job. Workable if the
interface surface is narrow.

**Vendor it.** Copy the source into the project and take over maintenance. Suitable if
the code is small and can be considered frozen; its closure has to be carried along
too.

**Take over maintenance.** Fork it and publish under your own name. Responsibility
increases; in exchange, control is entirely yours.

In all three, the first step is the same: asking again whether the package is really
needed. The safest dependency is the one removed.

## Summary

- The dependency closure is far larger than the packages chosen directly; every
  package in the closure runs with the program's privileges.
- Install scripts run code without the package ever being imported; the defenses are
  turning scripts off, installing in an isolated environment, and reviewing lock
  changes.
- The integrity digest catches the same name and version being served with different
  content; it does not prove the content is safe.
- An upgrade should be a separate, regular action; delaying it is a risk too.
- Package selection is evaluated by closure size, interface surface,
  replaceability, maintenance signals, and license.
- There are three paths for an abandoned package — replace it, vendor it, take over
  maintenance — and before all three, the package's necessity is questioned.

## Next Step

Dependencies have been resolved, locked, and audited. What remains is how these
packages and your own modules get delivered to the target environment. The next topic
covers the class of tools that take the module graph as input and produce a runnable
output; its first lesson shows why this production is needed and how it works with a
running example.
