Editorial

Field guide · repository drift · edition v1

When your repository says pnpm but the change says npm

A practical guide to configuration drift: evidence, safe fixes, and the cases a checker should leave alone.

The whole problem, in one repository

package.json
  "packageManager": "pnpm@10.18.2"
pnpm-lock.yaml
package-lock.json
    ← someone ran npm install

Nothing is broken yet. The tests pass, the pull request looks normal, and the repository now holds two different answers to the question “how do we install this?”. Whichever answer your build system picks is the one that counts — and it may not be the one you picked.

Below are three real repositories. Two had this contradiction and their maintainers fixed it; their before-and-after commits are linked. The third looks like a conflict and is not — the correct result there is silence. Every scanner output on this page was recorded with CrossCheck 0.1.1 on 21 September 2026, and the evidence package re-runs it.

Published 21 September 2026 · scanner pinned to CrossCheck 0.1.1 · edition v1

Case 1 · fixed by the maintainer

The repository says pnpm; a second lockfile says npm

Every signal in this repository pointed at pnpm — the packageManager field, the pnpm lockfile, and both CI install steps. An npm lockfile was committed alongside them anyway.

Scope
repository root
Commit
d063962f6b97
Licence
MIT
Recorded example · not a live scan

Before — commit d063962

Configuration

package.json
  "packageManager": "pnpm@10.18.2"
pnpm-lock.yaml
package-lock.json      ← contradicts it
.github/workflows/workflow.yml
  :33  run: pnpm install
  :67  run: pnpm install

CrossCheck 0.1.1 output

CrossCheck repository model
(./sebibe-plataforma-educativa@d063962)

Packages evaluated: 14 (root + 13 nested)
Package manager:    CONFLICT (npm vs pnpm)
ORM:                none detected
Database:           not established
Authentication:     none detected
Agent instructions: none found

Contradictions: 1

1. Package manager conflict: npm vs pnpm
     - package-lock.json → npm (lockfile present)
     - package.json → pnpm ("packageManager": "pnpm@10.18.2")
     - pnpm-lock.yaml → pnpm (lockfile present)
     - .github/workflows/workflow.yml:33 → pnpm (run: pnpm install)
     - .github/workflows/workflow.yml:67 → pnpm (run: pnpm install)
     Fix: "packageManager" declares pnpm. Remove package-lock.json and reinstall with pnpm, or change "packageManager" if you are deliberately migrating.
Remove package-lock.json to fix Vercel pnpm build
The maintainer’s own commit message, 17 September 2026. We did not reproduce that build.

What this proves

The contradiction was decidable from files already in the repository. Because package.json declared a manager, the checker could name the specific file to remove rather than guess — and the maintainer's own fix, made independently and for their own reasons, removed exactly that file. What this does not prove is why the npm lockfile appeared: the records do not show who ran which installer.

What it does not

  • The Vercel build failure is the maintainer's own description in the commit message. We did not reproduce that build.
  • A configuration scan cannot show which dependency versions actually resolved differently.
  • This repository was selected because it had already been fixed. One case implies no rate.

Case 2 · fixed by the maintainer

Two conflicts, two different levels of confidence

The same repository produced two package-manager conflicts in two packages. The checker asserted the fix for one and deliberately refused to assert it for the other.

Scope
apps/mobile and apps/web
Commit
7c7fc9f1facf
Licence
no licence declared — cited by URL and commit only
Recorded example · not a live scan

Before — commit 7c7fc9f

Configuration

apps/mobile/
  package.json
    "packageManager": "pnpm@9.0.0"
  package-lock.json    ← contradicts it

apps/web/
  package-lock.json    ← two lockfiles,
  pnpm-lock.yaml         nothing declares
                         a winner

CrossCheck 0.1.1 output

CrossCheck repository model
(./dance-company-app@7c7fc9f)

Packages evaluated: 4 (root + 3 nested)
Package manager:    pnpm
ORM:                Drizzle (apps/web)
Database:           PostgreSQL (apps/web)
Authentication:     Better Auth (apps/mobile); Better Auth (apps/web)
Agent instructions: apps/mobile/AGENTS.md, apps/mobile/CLAUDE.md

Contradictions: 2

1. Package manager conflict in apps/mobile/: npm vs pnpm
     - apps/mobile/package-lock.json → npm (lockfile present)
     - apps/mobile/package.json → pnpm ("packageManager": "pnpm@9.0.0")
     Fix: "packageManager" declares pnpm. Remove apps/mobile/package-lock.json and reinstall with pnpm, or change "packageManager" if you are deliberately migrating.

2. Package manager conflict in apps/web/: npm vs pnpm
     - apps/web/package-lock.json → npm (lockfile present)
     - apps/web/pnpm-lock.yaml → pnpm (lockfile present)
     Fix: Keep one lockfile in apps/web/: delete the one for the manager you do not use and reinstall.
Remove package-lock.json files that caused EAS to use npm instead of pnpm
The maintainer’s own commit message, 18 September 2026. We did not reproduce that build.

What this proves

Read the two fix lines against each other. In apps/mobile the package declared pnpm, so the file to remove followed from the repository itself. In apps/web two lockfiles disagreed and nothing declared a winner, so the checker described the choice instead of making it. Which lockfile to delete is a maintainer decision, and a checker that guesses it will eventually guess wrong.

What it does not

  • The EAS build behaviour is the maintainer's own description in the commit message. We did not run EAS.
  • This repository contains AGENTS.md and CLAUDE.md. That is a fact about the repository, not evidence that an AI tool caused the conflict.
  • The repository declares no licence, so its configuration files are not redistributed in the evidence package.

Case 3 · nothing to fix

A mixed-manager monorepo where silence is the right answer

Yarn at the root, a pnpm workspace inside an end-to-end test application, AGENTS.md and CLAUDE.md both present. A naive repository-wide check would raise an alarm here. It would be wrong.

Scope
root + 18 nested packages
Commit
8dd7649f83b4
Licence
see repository
ObservedRecorded example · not a live scan

Observed — commit 8dd7649

Configuration

yarn.lock              ← root: Yarn

e2e-tests/test-applications/
  pnpm-workspace-test-app/
    pnpm-lock.yaml     ← pnpm on purpose
  cloudflare-wrangler-.../
    yarn.lock

AGENTS.md
CLAUDE.md

CrossCheck 0.1.1 output

CrossCheck repository model
(./sentry-wizard@8dd7649)

Packages evaluated: 19 (root + 18 nested)
Package manager:    Yarn
ORM:                none detected
Database:           not established
Authentication:     none detected
Agent instructions: AGENTS.md, CLAUDE.md

Contradictions: none
CrossCheck reads only lockfiles, manifests, ORM/datasource config, CI install steps and agent instruction files. Nothing left this machine.

What this proves

A tool that installs different package managers on purpose, in order to test them, is behaving correctly. The conflict rule is evaluated per package scope, not per repository, so a pnpm fixture beside a Yarn root produces nothing. This case is also the clearest answer to a tempting inference: agent instruction files are present and the repository is clean, so their presence is not a signal of drift.

What it does not

  • Rebuilt from a frozen evaluation manifest in which lockfiles were recorded as present without retaining contents; they are recreated as empty files. Re-running the same scan against a fresh fetch of the same commit produces byte-identical output.
  • A clean result means no contradiction in the classes this checker covers. It does not mean the repository is correct.
  • This is one commit. The repository may have changed since.

Do it by hand

A six-step inspection that needs no tooling at all

This is the whole method. It is what the checker automates, and it is worth doing once by hand on a repository you care about, because the judgement in steps two and five is the part no tool should make for you.

  1. 1

    Find every lockfile, then group them by directory

    Two lockfiles in one directory is the signal. Two lockfiles in different packages usually is not. Run `git ls-files | grep -E '(package-lock\.json|pnpm-lock\.yaml|yarn\.lock|bun\.lock)$'` and read the paths, not the count.

  2. 2

    Check whether anything actually declares a winner

    Look for `"packageManager"` in the nearest package.json. If it exists, the lockfile that disagrees with it is the odd one out. If it does not, you have a decision to make, not a bug to fix.

  3. 3

    Read the install steps that really run

    CI workflows, Dockerfiles and deploy configuration. Mind the working directory and any `if:` conditions — a step that installs a different package, or never runs, proves nothing.

  4. 4

    Ignore the commands that are not installs

    `npm ci --dry-run` and `pnpm install --lockfile-only` do not establish a package manager. Treating them as installs is a known way to manufacture a false conflict.

  5. 5

    Compare the lockfile dates against the fix history

    If both lockfiles are updated in the same commits, the repository may be maintaining both on purpose. If one is months stale, it is probably an artefact nobody meant to commit.

  6. 6

    Decide, then make the repository say so

    Delete the losing lockfile, set `"packageManager"`, and make every install step agree. The point is that the next person — or the next agent — can read the decision instead of inferring it.

Reproduce it

Every output above, from a clean directory

The evidence package downloads only configuration files at the pinned commits, runs the pinned CrossCheck 0.1.1 CLI against them, and compares the result with the recorded output on this page. It never executes third-party project code.

1 — get the pinned scanner
npm pack @zfinia/crosscheck@0.1.1 && tar -xzf zfinia-crosscheck-0.1.1.tgz
2 — verify every recorded output
node reproduce.mjs

The evaluation behind the rule

Four holdout samples, and the difference between a finding and a repository

These are the package’s own published evaluation results, not an independent audit. For this edition each figure was checked back against the underlying frozen manifests and run outputs where those records were available; the table says which.

FigureValueFor this edition
Holdout samples never used for tuningFour frozen snapshot sets, each fixed before the engine ran.4Re-derived
Repositories across those four holdoutsFrozen manifest counts: 180 + 200 + 220 + 347.947Re-derived
Default package-manager findings raisedCounted from the four recorded run outputs.47Re-derived
Distinct repositories those findings came fromFindings are per package scope, so they outnumber repositories. Not published in the methodology; added here.40Re-derived
Of the 47 findings, confirmed correct by handThe count was re-derived; the manual confirmation of each finding is recorded in the methodology.47Source-reported
Historical maintainer fix commitsReported in the methodology. The commit list was not in the records available for this edition.35Source-reported
Control commits flaggedReported in the methodology.0 of 36Source-reported

Full method: METHODOLOGY.md at v0.1.1.

Four ways to misread that table

“47 out of 47 — it is 100% accurate.”

47 of 47 is precision on findings that were raised. It says nothing about conflicts the checker never raised, which is recall, and recall was not measured this way.

“47 repositories had a conflict.”

The 47 findings came from 40 repositories. A monorepo can contribute more than one finding, because each package is evaluated separately.

“About 4% of repositories have this problem.”

The samples were built from repositories that contain AI-agent instruction files. That is a selection criterion, not a population, so no prevalence rate can be read off it.

“AI agents caused these conflicts.”

None of these records show who ran which installer. Case 3 has both AGENTS.md and CLAUDE.md and no conflict at all.

Check your own repository

The checker used throughout this guide is free and runs in your own repository

CrossCheck is an MIT-licensed command-line tool and GitHub Action. It reads configuration files, names the contradicting file, and stays advisory unless you choose to fail on new conflicts. It does not upload your source code. A hosted single-repository scan and continuous monitoring are separate, and the product page explains which is which.

This guide tested CrossCheck 0.1.1. Later releases may behave differently; this edition is not updated silently when one ships.

Methods and limits

How this was produced, and what it cannot tell you

Cases 1 and 2 were located by searching public commit history for maintainers who had already removed a competing lockfile, so that every repository named here is one whose maintainers resolved the issue themselves. Case 3 was drawn from a frozen evaluation manifest. For each case, only configuration files were downloaded at the immutable commit and the pinned CrossCheck 0.1.1 CLI was run against them in isolation. No project code, lifecycle script or build was executed, and no credentials were available to any of it.

Naming a repository here means a configuration state was observed at a specific commit. It is not a customer reference, not an endorsement in either direction, and no maintainer was contacted about this guide.

  • This edition pins CrossCheck 0.1.1. A later release may report differently; this page is not silently upgraded when one ships.
  • The checker reads configuration files. It does not install anything, run project code, or inspect application source.
  • By default it covers one class of contradiction. A clean result is not a statement that a repository is consistent.
  • The build and deployment consequences in cases 1 and 2 are reported by maintainers in their own commit messages and were not reproduced here.
  • These figures are the project's own evaluation results, re-derived where the underlying records allowed. They are verified by tests and by hand, which is not the same as independently reviewed by a third party.

Published by

zFinia, publisher of CrossCheck. This guide is about a problem class; the checker used to evidence it is our own product, which is disclosed rather than hidden.

Corrections

Found an error, or maintain a repository named here? support@zfinia.com. Corrections are published on this page, and the evidence package is re-versioned rather than edited in place.

Cite as: zFinia, “When your repository says pnpm but the change says npm — the Repository Drift Field Guide”, edition v1, 21 September 2026. https://www.zfinia.com/research/repository-drift-field-guide

Related