You need one small function, such as padding a string or escaping a regular expression, and
whoever types npm install first decides how it arrives. Months later the lockfile holds packages
the runtime has replaced. A helper copied out of a package keeps a bug that the package fixed,
because no scanner knows the copy exists.
What you get
You will end up with a script that lists each direct dependency’s size, transitive packages,
license, and built-in replacement, and a checklist that turns those columns into a verdict. This is
for you if you maintain a library’s dependencies list.
Short answer
Decide each small dependency with a four-question checklist, in order, stopping at the first answer that settles it. Use a runtime built-in when one exists on your oldest supported runtime. Depend on the package when it handles an edge you would get wrong; glob syntax and random ids are two such edges. Write a one-expression replacement yourself, with a test. Copy small, finished, permissively licensed code, keeping its license notice. Depend on anything left.
You will need
A Node.js or TypeScript project with a package.json and a lockfile. Verified 2026-10-21 against
Node 22.22.0, module-replacements 3.4.0, and the six packages the sample audits.
The sample is the project under audit. Its package.json lists six small dependencies, and the
scripts read them from node_modules and package-lock.json with no network call. It leaves out
maintenance activity, which needs the registry: npm view
prints a package’s publish history, and services such as Socket
score it.
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| Copy the function into your repo | A small, finished function with a permissive license and no built-in on your runtime floor | Every later fix is yours to notice and apply, because npm audit cannot see a copied file | The function sits on a security edge, or still changes upstream |
| Depend via npm | The subject has edges you would get wrong, such as globs, dates, or parsers | Every transitive package, its maintainers, and its install scripts join your supply chain | A built-in covers it, or the code is one expression |
| Use a runtime built-in | The runtime ships the same behavior on the oldest version you support | It raises your runtime floor, and its options and output can differ from the package’s | You support a runtime that predates it |
| Write your own | One expression or a few lines, where every edge case fits in one test | Every edge case the package handled becomes a test you have to think of yourself | The edges are not obvious, as with randomness or glob syntax |
A dependency brings in someone else’s fixes on their schedule. Their packages and maintainers come
with it. A copy freezes the code at the version you took, so a fix upstream never reaches you
unless you go and look. A built-in costs a runtime floor, which is a decision about your users. Your
own code costs a test for every edge, and the is-odd case below shows how easy one is to miss.
List what each dependency brings
audit.ts reads three places in the project: package.json for the direct dependencies,
node_modules for each package’s code and license, and package-lock.json for what each one pulls
in and any deprecation notice. The last column comes from the
module-replacements list that the e18e community
maintains, which maps packages to built-ins, one-line snippets, and lighter packages.
export function audit(dir: string): Dependency[] {
const pkg = readJson(join(dir, 'package.json'))
const lock: Lock = readJson(join(dir, 'package-lock.json')).packages
return Object.keys(pkg.dependencies ?? {}).sort().map((name) => {
const root = join(dir, 'node_modules', name)
const own = readJson(join(root, 'package.json'))
const sources = jsFiles(root)
return {
name,
version: own.version,
files: sources.length,
lines: sources.reduce((sum, file) => sum + lineCount(file), 0),
pullsIn: pulledIn(lock, `node_modules/${name}`),
license: own.license ?? 'none',
deprecated: lock[`node_modules/${name}`]?.deprecated,
replacement: replacementFor(name),
}
})
}
Only dependencies counts, because that is what your users install. Run the report:
node --experimental-strip-types report.ts
dependency version files lines pulls in license e18e replacement
escape-string-regexp 5.0.0 1 11 - MIT RegExp.escape (native, Node 24.0.0)
is-odd 3.0.1 1 25 is-number MIT snippet: (n % 2) === 1
left-pad 1.3.0 5 210 - WTFPL String.prototype.padStart (native, Node 8.0.0)
minimist 1.2.8 17 1169 - MIT util.parseArgs (native, Node 16.17.0)
picomatch 4.0.7 7 2465 - MIT -
uuid 14.0.3 44 1087 - MIT crypto.randomUUID (native, Node 16.7.0)
deprecated on the registry
left-pad: use String.prototype.padStart()
The file and line counts cover all the JavaScript that installs, so tests and duplicate builds count.
uuid ships 44 JavaScript files for a job that crypto.randomUUID does when version 4 ids are all
you need. is-odd pulls in
is-number for a remainder check. left-pad carries a deprecation notice that names its own
replacement.1
Turn the columns into a verdict
The checklist is four questions asked in order, and the first one that settles a dependency
decides it. Three of them read the audit. The fourth, whether the package handles an edge you would
get wrong, has no data source, so its answers live in edges.json where a reviewer sees them.
export function decide(dep: Dependency, floor: string, edges: Record<string, string>) {
const r = dep.replacement
if (r?.type === 'native' && r.node && atLeast(floor, r.node)) {
return { verdict: 'built-in' as Verdict, reason: r.id }
}
if (Object.hasOwn(edges, dep.name)) return { verdict: 'depend' as Verdict, reason: edges[dep.name] }
if (r?.type === 'simple') return { verdict: 'write' as Verdict, reason: r.example ?? r.id }
if (dep.lines <= 50 && dep.pullsIn.length === 0 && PERMISSIVE.has(dep.license)) {
const until = r?.type === 'native' ? `, until Node ${r.node} brings ${r.id}` : ''
return { verdict: 'copy' as Verdict, reason: `${dep.lines} lines, ${dep.license}, pulls in nothing${until}` }
}
return { verdict: 'depend' as Verdict, reason: 'no built-in, and too large to own' }
}
The floor comes from engines.node in package.json, here Node 22. The report stops without that
field, because the Node that runs it says nothing about your users’ oldest runtime. The edge lookup
uses Object.hasOwn, so a package named constructor does not match the property every object
inherits. The same command prints the verdicts after the table:
node --experimental-strip-types report.ts
verdicts for a Node 22 floor
escape-string-regexp copy 11 lines, MIT, pulls in nothing, until Node 24.0.0 brings RegExp.escape
is-odd write (n % 2) === 1
left-pad built-in String.prototype.padStart
minimist built-in util.parseArgs
picomatch depend glob syntax: globstars, braces, dotfiles, and escaping
uuid built-in crypto.randomUUID
escape-string-regexp lands on copy only because of the floor. Its built-in,
RegExp.escape,
arrived in Node 24,2 so the sample keeps the eleven-line function in copied/, with its MIT
notice and a comment saying when to delete it. uuid has an edge too, randomness, but the built-in handles
that properly, so the question order lets it win. The write verdict quotes the list’s snippet for
is-odd, which the failures section shows to be wrong for negative numbers.
Prove the replacement on the calls you make
A built-in that covers a package’s purpose can still differ on your inputs. Test the two side by
side before the package goes. minimist turns 8080 into a number, and
util.parseArgs leaves
it a string:
test('util.parseArgs reads the flags minimist read, with the port as a string', () => {
const argv = ['--port', '8080', '--verbose', 'src']
const before = minimist(argv, { boolean: ['verbose'] })
const after = parseArgs({
args: argv,
options: { port: { type: 'string' }, verbose: { type: 'boolean' } },
allowPositionals: true,
})
assert.equal(before.port, 8080)
assert.equal(after.values.port, '8080')
assert.equal(after.values.verbose, before.verbose)
assert.deepEqual(after.positionals, before._)
})
The swap therefore needs a Number() at the call site, the kind of difference a test catches
before a reviewer would.
Check it worked
Ten tests cover the verdicts, a Node 24 floor turning the copy into a built-in, a missing floor, a name every object inherits, and each replacement against the package it replaces.
node --experimental-strip-types --test deps.test.ts
1..10
# tests 10
# suites 0
# pass 10
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 301.155456
With # pass 10, each package can go in the order the verdicts give, one commit per package, so a
regression points at a single swap.
When it goes wrong
The function you wrote disagrees with the package on negative numbers. The list’s snippet for
is-odd, (n % 2) === 1, returns false for -3, because JavaScript’s remainder
takes the sign of the dividend.3
Compare with zero, as own.ts does, and test negative inputs.
A host allowlist written as a glob admits a host you meant to block. Turning *.example.com into a
regular expression without escaping leaves each dot matching any character, so
attackerexample.com passes. Escape the literal parts with the copied function, or depend on a glob
library.
node --experimental-strip-types pitfall.ts
n is-odd (n % 2) === 1 own isOdd
3 true true true
-3 true false true
4 false false false
-4 false false false
allow *.example.com
host unescaped escaped picomatch
api.example.com true true true
attackerexample.com true false false
api.example.com.evil.net false false false
An id you wrote is guessable. Math.random is
not a cryptographically secure source,
so an id built from it can be predicted. Use crypto.randomUUID, which is why edges.json names
uuid at all.
When not to do this
Do not copy code that sits on a security edge. minimist needed two prototype pollution fixes,
CVE-2020-7598 and
CVE-2021-44906. A project that depended on it
received both as version bumps that npm audit reported. A project that copied an older version
carries the flaws until someone reads the advisory and patches the copy by hand.
Do not swap a package for a built-in the day the built-in ships. Your users’ oldest runtime sets the
floor, and the floor is a promise in engines, so raise it in a release that says so.
Do not run the checklist on transitive packages you do not choose. Change or drop the direct dependency that pulls them in, because a removed parent takes its children with it.
Do not take the replacement list as a verdict. It is a list of suggestions, and its snippet for
is-odd is wrong for every negative odd number.
Related how-tos
Last verified
Verified 2026-10-21 against Node 22.22.0, module-replacements 3.4.0, escape-string-regexp
5.0.0, is-odd 3.0.1, left-pad 1.3.0, minimist 1.2.8, picomatch 4.0.7, and uuid 14.0.3.
Every output block is what the command preceding it printed, run in the page’s code directory after
npm ci. No registry or scoring service was queried. The advisory facts are read from OSV, and the
policy facts from npm’s documentation.
Footnotes
-
The package left the registry on March 22, 2016, with 272 others its author unpublished at once. According to npm’s own account, dependent builds failed at hundreds a minute for two and a half hours. A replacement went up within ten minutes, but babel reached left-pad through
line-numbers, which asked for version 0.0.3 exactly, so npm restored that version from a backup. Itsunpublishpolicy refuses to remove a package that others depend on and offers deprecation instead, the state the package is in. ↩︎ Back to text -
The request is older than every package the sample installs. A message to es-discuss on June 13, 2010, proposed standardizing
RegExp.escapeand called it trivial to implement. The proposal that shipped in Node 24.0.0, on May 6, 2025, escapes anything that could cause what its README calls a context escape. Its output therefore escapes more thanescape-string-regexpdoes, including a letter at the start of the string. ↩︎ Back to text -
Python made the other choice. Its reference says the modulo operator always yields a result with the same sign as its second operand. In Python, -3 % 2 is 1, so the same test, written
n % 2 == 1, holds for every integer. ↩︎ Back to text