Package matching

How we resolve a package name to a GitHub repository, and what happens when we can't.

For each package in your lockfile we follow a deterministic lookup path to find the GitHub repo that publishes it. The path varies by ecosystem (see Supported formats for the per-ecosystem registry hop), but the shape is the same: parse → registry lookup → extract the repository URL → confirm it's a public GitHub repo. When an npm, PyPI, Cargo, or Go package doesn't resolve to a GitHub repo this way (the registry metadata lists no GitHub link, or, for Go, the module path isn't a github.com path), we take one more hop before giving up: asking Google's deps.dev for the package's upstream source repo, so a well-known package whose latest release simply forgot to publish its URL still resolves.

When a match works #

Most popular packages publish a repository URL in their registry metadata that points at GitHub. When that URL is present, the match is one-shot and the resolved source becomes a tracked source automatically.

Matches are resolved once, for everyone #

Which repo publishes a package is a fact about the package, not about you, and it changes at most a handful of times in a package's life. So the answer is cached platform-wide: the first time anyone's lockfile resolves left-pad, that mapping is stored and every later lookup, yours or anyone else's, reads it instead of asking the registry again. The same applies one step further down: a repo we've already confirmed is in the shared source catalog, so re-uploading a lockfile whose packages are already known skips the GitHub confirmation too.

In practice that means the first upload of a large lockfile does the slow work, and a re-upload (or a teammate uploading the same file) matches near-instantly. It's also why we're not hammering npm, PyPI, crates.io, or GitHub once per user per package: matching cost scales with the number of distinct packages we've ever seen, not with how many people depend on them.

Cached matches are re-checked periodically (roughly monthly for a resolved repo, every few days for a package we couldn't match) so a maintainer newly adding a repository URL is picked up without you doing anything. Package names our registries don't recognise at all are never cached platform-wide. See the privacy note below.

When a match fails #

A package can fail to match for several reasons:

  • No repository field anywhere we look. The registry metadata didn't set one and deps.dev has no GitHub source repo for it either. (deps.dev covers npm, PyPI, Cargo, and Go; Composer packages rely on registry metadata alone.)
  • Non-GitHub host. Sources hosted on GitLab, Bitbucket, Heptapod, Launchpad, Codeberg, or internal registries are not yet supported. When we recognise the host, the unmatched entry names it ("Links to Heptapod, not GitHub") so you can tell an unsupported-host package apart from one that's simply missing a link. Public GitHub mirrors do work if the package metadata points at them.
  • Renamed or unpublished package. The registry returns 404.

When a package can't be matched (neither the registry nor deps.dev linked a public GitHub repo), it shows up in the upload result summary as "unmatched": no failure, just an honest "we couldn't find a public GitHub repo for this." You can come back later and add the source manually if you find the URL another way.

On a lockfile's detail page, any package whose discovery failed keeps a persistent Discovery failed badge in the package table, even after you deselect it, so the fact that it couldn't be resolved never quietly disappears from view. Hover the badge for the specific reason.

A matched source remembers where it came from: on the dashboard card and the detail page, each source's Used in row links back to the lockfile(s) it was discovered from, each one labeled by its connected repo (owner/name) (see Sources). When the same package is in several of your lockfiles, the source lists each one, and those links repeat inside each release card, so you can jump from a risky release straight to the repos that depend on it. Each entry links to that lockfile's detail page; for GitHub-connected lockfiles it also links straight to the repo on GitHub. Sources added manually or via starred-repo sync have no originating lockfile, so they don't show the row.

When the discovered repo isn't really the package #

Registry metadata sometimes points a package at a monorepo whose releases belong to a different (usually the flagship) project. client-only points at the React monorepo, so the repo's v19.x tags say nothing about client-only's own versions. Discovery still tracks the repo (its releases are often relevant context), but the upgrade pipeline validates every offered version against the package's own registry before claiming it: tags that name versions the package never published are discarded, and if none survive, the package simply produces no upgrade. See Available upgrades for the full computation.

Privacy of cached matches

The platform-wide match cache above is shared, so we're deliberate about what goes in it. A cached row holds a public package name, its ecosystem, and what the public registry said about it, and no link to any account. We only write a row when the registry confirms the package exists there, which makes the name public information by definition.

The one case we refuse to cache is a package name the public registry doesn't recognise. That's the shape an internal package from a private registry takes, and internal package names describe your project's structure, so they stay out of the shared table entirely (they're re-checked per upload instead, which costs us a little speed and is the right trade). Your own lockfile's full package list continues to live only in your own account's data, as described in Account → Privacy.

Transitive dependencies #

Lockfiles include transitive dependencies (the dependencies of your dependencies), and we match all of them. This is the point: the risky release is rarely the library you remember installing; it's something five hops down.

For formats that carry a dependency graph (package-lock.json v2/v3, uv.lock) we also record who requires what, with which version ranges. That's what lets an available upgrade say it's direct vs. transitive, and, when a parent's constraint excludes the newer version, mark it blocked by parent instead of pretending it's independently takeable.

When the same package is pinned at more than one version in a single lockfile (common with npm, which hoists one copy to the top level and nests conflicting copies under their parents) we report the most-hoisted (shallowest) copy, and break ties on the higher version. That's the copy most of your tree resolves against, and (unlike whichever nested copy happened to sort last) it's stable across re-installs, so version diffs don't flip-flop between syncs. Individual transitive dependencies may still resolve their own nested copy at a different version; we surface the one representative version per package rather than every nested pin.

Shadowed duplicate versions #

Because monitoring, available upgrades, and security advisories all key off that one representative version, a nested copy pinned at a different version isn't upgrade- or security-scanned. If you monitor js-yaml@4.x at the top level, an advisory that only affects the js-yaml@3.x copy nested under gray-matter won't surface here. That second copy is shadowed by the one we track.

We now count these shadowed duplicates and surface them in the get_upgrades and get_security_issues rollup (a "shadowed duplicate version(s)" line), because a gap that isn't named reads as "all clear." To cover them, run your package manager's full-tree audit: npm audit, yarn audit (Yarn Classic) or yarn npm audit (Yarn Berry), or pnpm audit, which walks every installed copy in the tree, not just the resolved representative. That's the same complement the audit playbook already pairs us with; the count just tells you when it matters. (Detected for package-lock.json, yarn.lock, and pnpm-lock.yaml: the ecosystems that install more than one version of a package side by side.)

On the Hobbyist tier you pick up to 1,000 packages to track across your lockfiles: enough to monitor a full repo's frontend and backend lockfile together. A good rule: pick the high-traffic ones at the top of your dependency tree plus any libraries with a history of breaking changes. Re-evaluate as the project grows.