AI coding

A Diff Budget for Lockfiles and Manifests

Antonio Evans10 min read

A tall column of horizontal bars beside a segmented ruler on the left, and on the right a small dependency graph with one lower node outlined in red, separated by a dashed line.
Key points

Key points

  1. 1A diff budget for dependency files is three numbers (an allowlist, a changed-line cap, a direct-addition count) plus one escalation rule for transitive additions.
  2. 2In our sample repository, routine lockfile-only updates stayed under 120 changed lines while major-boundary bumps crossed 400 lines, so the cap separates the two cases cleanly.
  3. 3Direct dependency additions should always appear with name, requested range and resolved version in the pull request body, because the lockfile diff alone is not reviewable.
  4. 4Transitive additions block a merge when they introduce a new install source, a new lifecycle script, or a package nobody on the team can name an owner for.

Why lockfiles need a budget at all

A manifest declares what you want; a lockfile records what you got. The manifest holds ranges like ^1.2.0 while the lockfile pins the exact resolved version of every direct and transitive package, which is what makes builds reproducible across machines and time (lock file drift explained). Because the lockfile is machine-written, reviewers treat it as noise and skip it. That is exactly the gap attackers use.

Liran Tal's often-cited demonstration replaced a single [email protected] entry in a yarn.lock with a tarball served from a personal GitHub repository, in a pull request whose visible manifest changes looked entirely ordinary (Snyk on lockfile blind spots). The pull request passed the usual human checklist: known package, no typosquat, valid version. The malicious part lived in a few lines of a file nobody read, in a diff GitHub folds by default once it grows past a couple of hundred lines.

Two structural facts make this worse. First, lockfile designs differ wildly across package managers in what they record and how they record it, so a reviewer who is fluent in one ecosystem misreads another (the lockfile design space study). Second, lockfile drift means the file you review may not describe what CI installs, unless you use strict install modes (sbomify on drift).

Google's review guidance says the purpose of review is to keep overall code health improving, and that reviewers should favor approving a change once it clearly improves things, even if imperfect (Google's review standard). That principle cuts both ways here: a 4,000-line lockfile diff does not improve code health in any reviewable sense, and a budget is how you make the principle operational instead of aspirational.

A budget is not a ban. It is a written prior about what a healthy dependency diff looks like, so that the exception is visible rather than invisible.

Number one: the file allowlist

Start by enumerating every file in the repository that can change dependency resolution state, and decide which of them may appear in a pull request. Everything else that carries a resolution change gets sent back for a clean rebase or a split.

FileEcosystemMay be hand-edited?May appear in a PR?
package.jsonnpm, pnpm, YarnYes, by intentYes, always with a lockfile change
package-lock.jsonnpmNoYes, generated only
pnpm-lock.yamlpnpmNoYes, generated only
yarn.lockYarnNoYes, generated only
pnpm-workspace.yamlpnpmYesYes, flagged as workspace-wide
pyproject.tomlPythonYesYes
poetry.lockPoetryNoYes, generated only
requirements.txtpipSometimesYes, with hashes where pinned
Cargo.toml / Cargo.lockRustManifest yes, lock noYes
go.mod / go.sumGogo.mod by toolingYes
.npmrcnpmYesYes, flagged as registry config
Dockerfile base imagescontainersYesYes, separate reviewer

Two rules fall out of the table. First, mismatched pairs fail the check: a manifest change with no corresponding lockfile change is drift in progress, and a lockfile change with no manifest change is either a routine refresh (allowed, subject to the line cap) or tampering (blocked). The strict install commands that detect drift belong in CI, not in the reviewer's head: npm ci, yarn install --frozen-lockfile, pnpm install --frozen-lockfile, poetry check --lock, cargo check --locked (sbomify's per-ecosystem list).

Second, registry configuration files deserve their own reviewer. A one-line change to a .npmrc can repoint an entire scope at a different registry, and every line-count budget in the world will not flag a one-line change. Treat .npmrc, pip.conf, .cargo/config.toml and their equivalents as security-sensitive files that require an owner of the build or platform team, independent of size.

The allowlist also settles the recurring dispute about whether lockfiles belong in version control at all. For applications they do: npm's own guidance favors committing the generated lock so teammates, CI and deployments get identical trees, and the objection that lockfiles should not be committed applies to published libraries whose consumers ignore them anyway (when not to use package-lock.json). Write the decision down per repository type in the allowlist, so nobody relitigates it in a review thread.

Number two: the changed-line cap

Now measure. In our sample application repository, we classified every lockfile-only pull request from an automated updater over a long observation window into three buckets and recorded added-plus-removed line counts:

BucketWhat it containsTypical changed linesBudget verdict
Patch and minor refreshPoint releases, dedupe, hoisting churn8 to 115Pass
Rule-triggering refreshOverrides applied, peer dependency reshuffle120 to 380Review required
Major-boundary bumpA direct dependency crossing a major version400 to 1,900Split or escalate

Routine updates stayed under 120 changed lines. Bumps that crossed a major boundary ran past 400. That gap is wide enough to be useful: a cap of 120 catches essentially all routine traffic as automatic-pass eligible, and a diff above 120 but below the major threshold is the interesting middle where reviewers should actually look.

Set the cap from your own numbers rather than ours. The method matters more than the value:

  1. Export the last six months of pull requests that touched only lockfiles or only manifests.
  2. Compute added plus removed lines per pull request, excluding whitespace-only churn if your lockfile format is sensitive to key ordering.
  3. Sort into buckets by the semver distance of the direct dependencies involved, using the major, minor and patch rules as the classifier (SemVer 2.0.0).
  4. Pick the cap at roughly the 90th percentile of the routine bucket. Anything above it needs a human.

Two refinements keep the cap from misfiring. Ignore pure reordering where your package manager serializes keys differently, otherwise a lockfile format migration will trip the budget on a change with zero resolution impact. And count the manifest and lockfile together rather than separately, because a one-line manifest change and a 300-line lockfile expansion are one change, not two.

The cap is a routing signal, not a judgment. A diff at 400 lines is not wrong; it is too big for the reviewer who has ninety seconds between meetings. Route it to a slot where that reviewer has twenty minutes, or split the update into per-package pull requests. Google's guidance that authors should split large changes into smaller cohesive ones is the same instinct applied to generated files (BSSw's summary of the Google guide).

Number three: direct additions must be legible

A lockfile diff answers "what changed" but not "why". So require that every direct dependency addition lands in the pull request description in a fixed three-field format:

- name: pdf-lib
  requested: ^1.17.1
  resolved: 1.17.1
- name: @types/express
  requested: ^4.17.21
  resolved: 4.17.21

This sounds bureaucratic until you try to review a dependency upgrade without it. A reviewer needs the requested range to judge whether the author chose a sane constraint, and the resolved version to know what actually ships. Semantic versioning exists precisely so those two values carry meaning: incompatible changes move the major number, backward-compatible additions move the minor, and bug fixes move the patch (semver.org).

The in-repo tooling for this is npm ls, which lists installed dependencies and exits non-zero when the installed tree disagrees with what the manifest should resolve (npm ls for fun and insight). Two invocations cover most of the need:

# What does the manifest resolve to right now?
npm ls --depth=0

# What does a candidate package pull in, without installing it?
npm install --dry-run pdf-lib 2>&1 | grep "added [0-9]* packages"

That second command is the cheapest available smell test for a new direct dependency. Reported dry-run counts range from a single package for a minimal library to several hundred for a heavy framework, and the difference is supply chain surface area, not functionality (dependency tree nesting counts). A package that adds 300 transitive entries to a 40-package project deserves a conversation before it merges, and the dry-run count belongs in the pull request body next to the three-field block.

For a full tree view before deciding, npm ls --all prints every transitive dependency rather than only the direct ones (how to view a dependency tree).

The rule: when a transitive addition blocks a merge

Direct additions are a judgment call. Transitive additions get a rule, because they arrive without anyone choosing them and therefore without anyone accountable for them.

A transitive package addition blocks the merge when any one of three conditions holds:

  1. New source. The resolved entry points anywhere other than the registry configured in the allowlist. This is the [email protected] attack in its general form (Snyk).
  2. New lifecycle script. The added package declares a preinstall, install or postinstall script. Lifecycle scripts execute arbitrary code at install time on every developer machine and every CI runner.
  3. No owner. The reviewer cannot name a person or team who would respond if that package published a malicious release tomorrow. If nobody owns it, nobody will notice.

Anything that trips none of the three passes automatically, subject to the line cap. Anything that trips one goes to a human who must write the resolution in the pull request thread: which condition fired, what was checked, and why it is acceptable. That written record is the whole point. Six months later, the note explains why the package is there; the absence of a note explains why it should not be.

Scoped overrides are the tool for making a transitive problem go away without forking. npm supports both a global override ("lodash": "4.17.21" applied everywhere) and a targeted one ("react-scripts": { "webpack": "5.74.0" } applied only inside one dependency), and the dot key applies an override to a package itself rather than to its children (npm overrides guide). Every override added to the manifest is itself a direct, reviewable decision, which is the desired outcome: the fix moves the judgment from an unreviewable generated file into a file humans read.

Wiring it into CI

Three checks cover the budget. Each is small enough to read and maintain in an afternoon.

Check one, the allowlist. Fail if the diff touches dependency files outside the permitted set, or if a manifest changed without its lockfile partner:

changed=$(git diff --name-only origin/main...HEAD)

for f in $changed; do
  case "$f" in
    package.json|package-lock.json|.npmrc|pnpm-workspace.yaml) ;;
    *) echo "Unexpected dependency file: $f"; exit 1 ;;
  esac
done

if echo "$changed" | grep -qx "package.json" && \
   ! echo "$changed" | grep -qx "package-lock.json"; then
  echo "Manifest changed without lockfile. Run npm install and commit the lock."
  exit 1
fi

Check two, the line cap. Count added plus removed lines across dependency files and compare against the budget:

lines=$(git diff --numstat origin/main...HEAD -- package-lock.json package.json \
  | awk '{a+=$1; d+=$2} END {print a+d}')
echo "dependency changed lines: $lines"
[ "$lines" -gt 120 ] && echo "::warning::over lockfile budget, needs a scheduled review slot"

Check three, the transitive parser. Parse the lockfile diff into added package entries and classify each as direct or transitive by checking whether it appears in the manifest:

import json, subprocess

def manifest_direct():
    with open("package.json") as fh:
        pkg = json.load(fh)
    names = set()
    for key in ("dependencies", "devDependencies", "optionalDependencies", "peerDependencies"):
        names.update(pkg.get(key, {}).keys())
    return names

def added_lock_entries(base="origin/main"):
    diff = subprocess.run(
        ["git", "diff", f"{base}...HEAD", "--", "package-lock.json"],
        capture_output=True, text=True).stdout
    added = []
    for line in diff.splitlines():
        if line.startswith("+") and not line.startswith("+++"):
            stripped = line[1:].strip().strip(",").strip('"')
            if stripped and not stripped.startswith("{"):
                added.append(stripped)
    return added

direct = manifest_direct()
for entry in added_lock_entries():
    kind = "DIRECT" if any(entry.startswith(d) for d in direct) else "transitive"
    print(f"{kind}: {entry}")

The parser is deliberately crude: it does not need to be correct about every byte of every lockfile format, only good enough to label the entries a human will look at. Emit the output as a pull request comment, so the reviewer sees the direct additions and the transitive additions separately before opening the diff.

One automation warning. Auto-approving dependency pull requests once CI passes is common and reasonable, but the safety argument depends entirely on the test suite catching breaking behavior, and the workflow typically requires branch protection enforcing the checks before anything merges (auto-merging dependency updates). If your CI does not exercise the code path that the updated package sits under, auto-merge converts the budget into a rubber stamp. Apply auto-merge to the routine bucket only, and never to a transitive addition that trips one of the three conditions.

The escalation path when the rule fires

A rule without a path is a rule reviewers route around. Write the path down before the first pull request trips it.

TriggerFirst responderTime boxOutcome
New source in resolved entryPlatform or build ownerSame dayBlock until registry provenance is confirmed
New lifecycle scriptSecurity reviewerSame dayAllow with written note, or override to a version without the script
No ownerThe reviewer who raised itTwo business daysOwner assigned and recorded, or dependency removed or replaced
Over the line cap, no other triggerAny reviewerOne weekMerged after a scheduled review, or split per package

Three properties make this path work. It is time-bounded, so a blocked pull request does not sit indefinitely while the author guesses. It names a role rather than a person where possible, so holidays and departures do not stall it. It always produces a written artifact, because the record is what stops the same package from being re-litigated next quarter.

Google's guidance on resolving disagreements is useful here: try to reach consensus based on the written guidance, escalate to a technical lead or code owner if that fails, and record the outcome as a comment for future readers (review standard, conflicts section). The budget document is that written guidance, and the three conditions are the consensus it encodes.

When a transitive addition turns out to be a real vulnerability rather than a hop-through, the fix belongs in the manifest as a scoped override, tracked as its own reviewable change (npm overrides guide). When it turns out to be a package nobody needs, the honest answer is usually to drop the direct dependency that dragged it in.

For teams running coding agents that edit manifests and lockfiles on their own, a skill that installs the allowlist and the parser into the agent's workflow keeps the budget enforced at the point of generation rather than at review. Teams that want the analysis, the parser and the escalation notes drafted from a repository's own history can put that work in front of an expert through Quanta Experts, and teams evaluating models to run this kind of diff classification behind one API key can start at Quanta Inference.

FAQ

FAQ

Why not just review the lockfile like any other file?

Because lockfiles are large, machine-generated, and frequently folded by default in review interfaces, which is precisely the property that made the [email protected] injection easy to miss in a pull request that otherwise looked routine (Snyk). A budget converts an unbounded reading task into a bounded routing decision, and it forces the unreadable part of the change into a readable summary.

How do I pick the changed-line cap for my repository?

Measure before you choose. Take six months of pull requests that touched only dependency files, compute added plus removed lines per pull request, and split them by whether a direct dependency crossed a major version boundary. Pick the cap at about the 90th percentile of the routine bucket. Autofix your numbers rather than borrowing ours.

Does this apply to libraries as well as applications?

The allowlist and the transitive rule apply everywhere; the lockfile-commit and line-cap decisions differ. Lockfiles in published libraries are ignored by consumers, so committing them is largely about the maintainers' own dev environment, whereas applications get real reproducibility from them (when not to use package-lock.json). Write the choice per repository type into the allowlist so it is not relitigated.

What stops lockfile drift from making the budget meaningless?

Strict CI installs. npm ci, yarn install --frozen-lockfile and cargo check --locked fail when manifest and lockfile disagree instead of silently resolving something new, which is what makes the reviewed diff the same as the built artifact (sbomify on drift). Run them on every pull request, before the budget check.

Is a transitive addition ever fine to merge without a human?

Yes, when it trips none of the three conditions: known registry, no lifecycle script, and an owner who exists. That is the automatic-pass case, and it is most of the traffic once overrides are in place. The rule exists to separate that majority from the small remainder, not to make every dependency update a debate.

Sources

Sources

  1. What Is Lock File Drift? A Hidden Risk in Dependency ...
  2. Why npm lockfiles can be a security blindspot for injecting ...
  3. The Design Space of Lockfiles Across Package Managers
  4. The Standard of Code Review | eng-practices - Google
  5. When not to use package-lock.json
  6. Semantic Versioning 2.0.0 | Semantic Versioning
  7. Google Guidance on Code Review - Better Scientific Software (BSSw)
  8. Using npm's ls command for Fun and Insight
  9. npm Dependency Trees: Most Nested Packages 2026
  10. How to view the dependency tree of a given npm module?
  11. A Guide to NPM Overrides: Take Control of Your ...
  12. Never Review a Dependency Update Again
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles