@vzn/vx-lockfile 0.0.651 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Website: <https://vznjs.github.io/vx/> · this plugin: <https://vznjs.github.io/vx/plugins/vx-lockfile/> · source: <https://github.com/vznjs/vx>
4
4
 
5
- Lockfile plugins for [`@vzn/vx`](https://vznjs.github.io/vx/): `bun()`, `pnpm()`, `npm()` and `yarn()`. Each keys every task on its **own project's resolved dependency closure** from the package manager's lockfile, not on the whole file. `pnpm update foo` (or `bun add`, `npm install`, `yarn up`) re-keys exactly the projects that reach `foo` — through their dependencies, transitively, and through workspace links — and `vx run … --affected` selects the same projects. Each also gives vx the **projects, names and edges the lockfile records** (`discover`), adds each package's `package.json` to its cached tasks' inputs, and hashes the manager's install settings into every task. Zero dependencies: Bun's own YAML and JSONC parsers, and yarn's own readers (classic, and berry's, which reads every value as its text: an unquoted range `0.10` is not the number `0.1`).
5
+ Lockfile plugins for [`@vzn/vx`](https://vznjs.github.io/vx/): `bun()`, `pnpm()`, `npm()` and `yarn()`. Each keys every task on its **own project's resolved dependency closure** from the package manager's lockfile, not on the whole file. `pnpm update foo` (or `bun add`, `npm install`, `yarn up`) re-keys exactly the projects that reach `foo` — through their dependencies, transitively, and through workspace links — and `vx run … --affected` selects the same projects. Each also gives vx the **projects, names and edges the lockfile records** (`discover`), adds each package's manifest (`package.json`; under `pnpm()` a `package.json5` or `package.yaml` where it has none, as pnpm reads them) to its cached tasks' inputs, and hashes the manager's install settings into every task. Zero dependencies: Bun's own YAML and JSONC parsers, and yarn's own readers (classic, and berry's, which reads every value as its text: an unquoted range `0.10` is not the number `0.1`).
6
6
 
7
7
  Core keys no package file: without a plugin no lockfile, install setting or `package.json` reaches any cache key, so an install re-keys nothing and a build made against the old `node_modules` replays. A JS workspace declares one.
8
8
 
@@ -32,9 +32,9 @@ With no lockfile at the workspace root (before the first install), it warns and
32
32
 
33
33
  ## What a project's digest covers
34
34
 
35
- Every package the project can reach, by resolved identity (name, version, integrity), and under bun and npm by the name it installs under, not where it sits, so a re-hoist of one version re-keys nothing, plus the install-wide material every project hashes — and **the root package's own closure**. The root's `dependencies` and `devDependencies` reach every task: their bins run from the root `node_modules/.bin`, which vx puts on every task's PATH, and Node's resolution walks up to the root `node_modules` (`@types/*`, a plugin a root tool's config names). So a root devDependency bump (`pnpm add -Dw oxlint@next`) re-keys every project and `--affected` selects every project; a package only project A reaches still moves only A. A root `workspace:` dependency hashes that package's closure into every project too. Per manager:
35
+ Every package the project can reach, by resolved identity (name, version, integrity), and under bun and npm by the name it installs under, not where it sits, so a re-hoist of one version re-keys nothing, plus the install-wide material every project hashes — and **the root package's own closure**. The root's `dependencies` and `devDependencies` reach every task: their bins run from the root `node_modules/.bin`, which vx puts on every task's PATH, and Node's resolution walks up to the root `node_modules` (`@types/*`, a plugin a root tool's config names). So a root devDependency bump (`pnpm add -Dw oxlint@next`) re-keys every project and `--affected` selects every project; a package only project A reaches still moves only A. A root `workspace:` dependency hashes that package's closure into every project too. Which field the root's and each workspace's dependency sits in (`dependencies`, `devDependencies`, `optionalDependencies`) is in its digest as well: an install that leaves dev or optional packages out (npm `omit=dev`, `pnpm install --prod`, `bun install --production`) lays `node_modules` out by it, so moving a root dependency to `devDependencies` re-keys every project and `--affected` selects them: a build that would now fail "Cannot find module" does not replay its old success. yarn.lock records no field, so `yarn()` reads the root `package.json`'s (below). Per manager:
36
36
 
37
- - **pnpm** (`pnpm-lock.yaml`, lockfile v5, v6, v9): importers → snapshots, transitively, each dependency by the name it is installed under as well as what it resolves to (two aliases swapping targets re-key); each package by name, version **and resolved peers** (`foo@1(react@18)` is not `foo@1(react@19)`), resolution and the `patchedDependencies` entry pnpm applies to it (keyed `name@version`, a version range such as `name@<=7.0.0`, or `name`, in that precedence); `link:` hashes the linked importer's reach, or the CONTENT of a linked directory that is no importer (an importer's or, under an override, a package's), and a `file:` directory dependency hashes its own closure (v9 keys it `name@file:…`, v5/v6 by the bare `file:…`) and the CONTENT of the directory (pnpm copies it at install and records only its path; its `node_modules` and `.git` aside), so an edit in it re-keys the projects reaching it; every other top-level field but `overrides` and the catalogs, whose effect is the snapshot an importer reaches (`settings`, `packageExtensionsChecksum`, `pnpmfileChecksum`, `ignoredOptionalDependencies`, v6's `onlyBuiltDependencies`, and any field pnpm adds later) and the lockfile version into every project. A multi-document lockfile (pnpm 10.x and 11 write the env lockfile — `configDependencies`, the package manager's own install — ahead of the project's) reads its last document as the lockfile and hashes the ones before it into every project. A CRLF file (a Windows checkout) reads as its LF form, `settings` and `ignoredOptionalDependencies` included.
37
+ - **pnpm** (`pnpm-lock.yaml`, lockfile v5, v6, v9): importers → snapshots, transitively, each dependency by the name it is installed under as well as what it resolves to (two aliases swapping targets re-key); each package by name, version **and resolved peers** (`foo@1(react@18)` is not `foo@1(react@19)`), resolution and the `patchedDependencies` entry pnpm applies to it (keyed `name@version`, a version range such as `name@<=7.0.0`, or `name`, in that precedence); `link:` hashes the linked importer's reach, or the CONTENT of a linked directory that is no importer (an importer's or, under an override, a package's), and a `file:` directory dependency hashes its own closure (v9 keys it `name@file:…`, v5/v6 by the bare `file:…`) and the CONTENT of the directory (pnpm copies it at install and records only its path; its `node_modules` and `.git` aside), so an edit in it re-keys the projects reaching it, unless the directory is an importer's (an injected workspace package is an edge, below, so its build output re-keys nothing); every other top-level field but `overrides` and the catalogs, whose effect is the snapshot an importer reaches (`settings`, `packageExtensionsChecksum`, `pnpmfileChecksum`, `ignoredOptionalDependencies`, v6's `onlyBuiltDependencies`, and any field pnpm adds later) and the lockfile version into every project. A multi-document lockfile (pnpm 10.x and 11 write the env lockfile — `configDependencies`, the package manager's own install — ahead of the project's) reads its last document as the lockfile and hashes the ones before it into every project. A CRLF file (a Windows checkout) reads as its LF form, `settings` and `ignoredOptionalDependencies` included.
38
38
  - **bun** (`bun.lock`; a `bun.lockb` alone is refused — it is binary — naming `bun install --save-text-lockfile`, which writes `bun.lock`): Bun's hoisted layout — a dependency `d` of the package at path `p` is `p/d` when that key exists, else the nearest ancestor's, else the root's, so a nested version counts for the package it is nested under and no other, and a workspace resolves from its own name, or from `./<name>` when a root package holds that name (Bun nests that workspace's own dependencies there); `workspace:` entries, the root package's `name@root:` and a `link:` to a workspace's directory (`b@link:./packages/b`, which symlinks the workspace) hash the linked package's reach and are its project edge; each `patchedDependencies` entry and the CONTENT of its patch file (bun.lock records only its path) with the package it patches, so a patch edit re-keys only the projects reaching it; a `file:` directory dependency, and a `link:` to a directory that is no workspace, with the CONTENT of the directory (Bun copies the first and links the second, recording only the path), as under pnpm; every top-level field but `workspaces`, `packages`, the catalogs, `overrides` and `patchedDependencies` (`trustedDependencies`, and any field Bun adds later, keys sorted so the order Bun writes them in moves nothing), and any patch naming no entry, into every project — a catalog bump reaches only the projects whose `catalog:` dependency resolves anew, through the `packages` entry it installs.
39
39
  - **npm** (`npm-shrinkwrap.json` when present, as npm reads it, else `package-lock.json`; lockfileVersion 2 and 3): the `packages` map — `p/node_modules/d`, then each ancestor directory's (a workspace nested in another's directory resolves through the outer one's `node_modules`, as Node does), then `node_modules/d`; a `link: true` entry hashes its target workspace's reach, and one to a `file:` directory (an entry outside the root's `workspaces` globs, `!` exclusions honoured, or any entry outside `node_modules` when the root names none) the CONTENT of the directory, as under pnpm, since npm links it, and so does an entry npm copied from one (`install-links=true`: `resolved: "file:…"` with no integrity); root `overrides` reach a project through the entries they force (npm 10 does not write them to the lockfile); the root's and a workspace's own `version` is its package.json's, not an install, and is not in the key. Version 1 (npm 6) has no `packages` map and is refused, as is a later version whose packages are only in the legacy `dependencies` tree.
40
40
  - **yarn** (`yarn.lock`): berry (yarn 2+) resolves `name@npm:range` descriptors to entries, workspaces included, each entry hashing every field, its dependency lists included (so the root workspace's `dependenciesMeta` reaches every project, and two aliases swapping targets re-key), `__metadata`'s `version` and `cacheKey` into every project. A Yarn 4 catalog dependency (`catalog:`, `catalog:<name>`) is recorded as that literal and its range lives in `.yarnrc.yml`, so it reaches every entry of its package: a bump of any of them re-keys the workspace. So does a descriptor the file does not key, as when a root `resolutions` override rewrote it to another range: the entry installed in its place is reached through the package's name, so changing it re-keys the workspaces that depend on it. A path range (`portal:`, `file:`, `link:`) reaches the entry keyed bound to the package naming it (`::locator=…`), not another workspace's of the same name; a `portal:` or `link:` to a workspace reaches that workspace's closure, as `workspace:` does (Yarn symlinks the workspace itself, and its entry lists no dependencies); a `portal:` or `link:` to a directory that is no workspace also hashes the CONTENT of the directory (Yarn symlinks it and records no hash; berry's `file:` carries its own `::hash=`). Every `patch:` entry is reached by the plain descriptor it patches: Yarn's builtin compat patches (`resolve`, `typescript`, `fsevents`), in Yarn 2/3's spelling (`resolve@^1.22.0`) as in Yarn 4's (`resolve@npm%3A^1.22.0`), and a root `resolutions` patch over a descriptor the file still keys (what `yarn patch-commit -s` writes). The workspace asks for the plain descriptor, and the patched entry is the one Yarn installs, so adding or editing the patch re-keys it. A `packageExtensions` dependency (`.yarnrc.yml`'s, and Yarn's builtin `@babel/parser` → `@babel/types`) is installed for each matching package that does not declare it, yet its lockfile entry lists none: `yarn()` adds the edge, so a lockfile-only bump of the added package re-keys the packages that reach the extended one, and an entry no workspace reaches (an extension it cannot read) re-keys every project. `yarn()` owns `.yarnrc.yml` and hashes it into every task, so a catalog edit re-keys every project (pnpm-lock.yaml records a catalog's resolution, so under `pnpm()` it re-keys only the projects naming it). Classic (yarn 1) records no workspaces, so it yields one digest for the root that every project hashes — coarse, and honest about what the file records — and no project edges (below); a `file:` directory, which classic copies with no hash, and a `link:` one, which it symlinks, hash their CONTENT into that digest, but a `link:` to a workspace the root `workspaces` globs name (a `!` glob aside: Yarn 1 takes none and links the directory anyway): Yarn 1 links that workspace as it does by its name.
@@ -43,28 +43,30 @@ A `file:` or `link:` directory outside the repository (`"dep": "link:../../../ve
43
43
 
44
44
  A project the lockfile has no entry for hashes the digest of the nearest package that encloses it, with the root's (`packages/a/e2e` resolves from `packages/a/node_modules` first), or the root's alone outside every package — the only `node_modules` it can resolve from. A phantom dependency (imported, never declared by the project or the root — a sibling's package hoisted to the root) is not in any closure; declare it.
45
45
 
46
- A lockfile the parser cannot read **refuses the run**, naming the file, the reason and the install that regenerates it. That is deliberate: the alternative to reading the lockfile is keying on nothing, and a key that is missing material is a stale hit waiting to happen. Under `--affected` the refusal also says which side could not be read — the working tree's copy, or the one at the base ref (a lockfile-migration commit hits the second). A lockfile holding git merge conflict markers is refused the same way: it names two installs at once, and yarn classic's format parsed one side of it without a word.
46
+ A lockfile the parser cannot read **refuses the run**, naming the file, the reason and the install that regenerates it. That is deliberate: the alternative to reading the lockfile is keying on nothing, and a key that is missing material is a stale hit waiting to happen. Under `--affected` the refusal also says which side could not be read — the working tree's copy, or the one at the base ref (a lockfile-migration commit hits the second). A lockfile holding git merge conflict markers is refused the same way: it names two installs at once, and yarn classic's format parsed one side of it without a word. So is a `pnpm-lock.yaml` whose importer or package names a dependency it has no entry for (`snapshots` in v9, `packages` in v5/v6; a `link:` needs none), as pnpm refuses it ("Broken lockfile"): read as a leaf, that package's own dependencies were in no key.
47
47
 
48
48
  ## Install settings
49
49
 
50
50
  Files no lockfile records lay `node_modules` out anew under a byte-identical lockfile (pnpm's `node-linker`, bun's `linker = "isolated"`, a registry in `.npmrc`, Yarn 4's catalogs). Each plugin owns its manager's and hashes each present file whole into every task; a change to one selects every project under `--affected`:
51
51
 
52
- | Plugin | Settings |
53
- | -------- | --------------------------------------------------------------------------------------------------------------------------------- |
54
- | `pnpm()` | `pnpm-workspace.yaml`, `.npmrc`, the root `package.json`'s `pnpm` field |
55
- | `bun()` | `bunfig.toml`, `.npmrc` |
56
- | `npm()` | `.npmrc` |
57
- | `yarn()` | `.yarnrc.yml`, `.yarnrc` and `.npmrc` (yarn 1), the root `package.json`'s `workspaces.nohoist`, `installConfig` and `resolutions` |
52
+ | Plugin | Settings |
53
+ | -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
54
+ | `pnpm()` | `pnpm-workspace.yaml`, `.npmrc`, the root manifest's `pnpm` field (`package.json`, `package.json5` or `package.yaml`) |
55
+ | `bun()` | `bunfig.toml`, `.npmrc` |
56
+ | `npm()` | `.npmrc` |
57
+ | `yarn()` | `.yarnrc.yml`, `.yarnrc` and `.npmrc` (yarn 1), the root `package.json`'s `workspaces.nohoist`, `installConfig`, `resolutions`, `dependencies`, `devDependencies` and `optionalDependencies` |
58
58
 
59
- `pnpm()` hashes `pnpm-workspace.yaml` without `patchedDependencies`, `overrides`, `catalog` and `catalogs`, which pnpm 10+ keeps there: the lockfile records what each one did, per project, so a patch, an override or a catalog bump re-keys only the projects that reach the package it changes, and `--affected` selects them through the lockfile. Any other setting in the file re-keys every project, and a file that does not parse is hashed whole. The root `package.json` adds only its `pnpm` field (`onlyBuiltDependencies` and the rest pnpm reads there and the lockfile does not record), its `overrides` and `patchedDependencies` aside: a root dependency edit is the lockfile's.
59
+ `pnpm()` hashes `pnpm-workspace.yaml` without `patchedDependencies`, `overrides`, `catalog` and `catalogs`, which pnpm 10+ keeps there: the lockfile records what each one did, per project, so a patch, an override or a catalog bump re-keys only the projects that reach the package it changes, and `--affected` selects them through the lockfile. Any other setting in the file re-keys every project, and a file that does not parse is hashed whole. The root manifest (`package.json`, `package.json5` or `package.yaml`) adds only its `pnpm` field (`onlyBuiltDependencies` and the rest pnpm reads there and the lockfile does not record), its `overrides` and `patchedDependencies` aside: a root dependency edit is the lockfile's.
60
60
 
61
61
  `yarn()` reads the root `package.json`'s `resolutions` because yarn.lock does not record where one applies: a parent-scoped one (`"is-odd/is-number"`) leaves the parent's entry on the range it declares, so adding one whose range another package already keys changes the install under a byte-identical yarn.lock. Berry's overrides also become edges: each entry a `resolutions` key reaches (any entry depending on the name, or only the named parent's for a scoped one; a parent's range or a `**` counts as any parent) gets an edge to the entry the override's range names, so a lockfile-only bump of that entry (`yarn up -R is-number`) re-keys the parent's workspaces too, not only the package that keys the range itself. An unreadable scope over-keys, never under-keys.
62
62
 
63
+ It reads the root's `dependencies`, `devDependencies` and `optionalDependencies` because yarn 1's lockfile records no importer, only the ranges asked: moving a range between the root and a workspace (root `is-number` `^6` → `^7`, `packages/a` `^7` → `^6`) leaves yarn.lock byte-identical and moves the root `node_modules` copy every project without its own resolves. Any change to them re-keys every project and selects every project under `--affected`; berry records them on the root workspace's entry, which every project's digest already includes.
64
+
63
65
  A file has one owner, so two of these plugins that share a settings file (`pnpm()` and `bun()` both own `.npmrc`) cannot be declared together.
64
66
 
65
- ## `package.json` as an input
67
+ ## The manifest as an input
66
68
 
67
- Every task with `inputs` of a project the lockfile lists, cached or not, gets its literal `package.json` appended to `inputs.files` (the `project` stage), so an edit to a manifest re-keys that project's tasks and, through `^` keys, its dependants. A task with no `inputs` stays keyless; one whose inputs already name `package.json` is left alone. A task that rewrites its own `package.json` with the same bytes (tsdown) keeps its hits under `inputStability: { strategy: 'mutable' }`.
69
+ Every task with `inputs` of a project the lockfile lists, cached or not, gets its literal manifest appended to `inputs.files` (the `project` stage), so an edit to a manifest re-keys that project's tasks and, through `^` keys, its dependants. The manifest is `package.json`; under `pnpm()` it is `package.json5` or `package.yaml` where the package has no `package.json`, as pnpm reads them (one stat per project a run). A task with no `inputs` stays keyless; one whose inputs already name its manifest is left alone. A task that rewrites its own `package.json` with the same bytes (tsdown) keeps its hits under `inputs.stability: { strategy: 'mutable' }`.
68
70
 
69
71
  ## Project edges
70
72
 
@@ -72,13 +74,14 @@ Every task with `inputs` of a project the lockfile lists, cached or not, gets it
72
74
 
73
75
  - **deps** (order `^task`, reach `--filter …` and `--affected`): a dependency the lockfile resolves to a workspace package — pnpm `link:` (under `excludeLinksFromLockfile`, which leaves every `link:` dependency out of the file, a `package.json` `link:` to a workspace package; the file then no longer says who links whom, so every project's key holds every importer's reach, and a `link:` it leaves out is no stale lockfile), and `file:` to a workspace package's directory (an injected one: `dependenciesMeta.<name>.injected`, or `injectWorkspacePackages` with `dedupeInjectedDeps: false`; pnpm hard-links that copy and re-syncs it after the package's scripts, so it waits for the package's build), bun's `workspace:` entries (a `workspace:` path or alias, and an `npm:` alias Bun links to the workspace package, by the spec it records) and `link:` entries to a workspace's directory, npm's `link: true` entries, yarn berry's workspace entries and `link:` / `portal:` paths.
74
76
  - **peers**: a peer with no installed entry of the same name that resolves to a workspace package orders the build too, unless it would close a cycle through the order edges; then it is reach only (in project-name order, so the edge kept is stable). pnpm records an auto-installed peer under `dependencies`, so `pnpm()` asks the project's `package.json` which of those entries are peers; yarn berry resolves a peer to the workspace of its name, which Yarn hoists.
77
+ - **workspace packages installed through a package**: a registry package's dependency the install resolved to a workspace package (npm and bun link `node_modules/is-number` to `packages/b` when the workspace satisfies `is-odd`'s range, Yarn's transparent workspaces do the same, a pnpm `overrides` to `workspace:*` writes `link:` into the package's snapshot). The project loads that workspace package as surely as its own dependency, so it builds after it and its key holds that package's, as a peer's would: unless that would close a cycle through the order edges, then it is reach only. Read from each project's own dependencies alone, an edit to such a package re-keyed only that package, `--affected` left out the projects reaching it, and they hit with its old build. Under yarn, only descriptors the file keys count, as for a direct link.
75
78
  - **the root's workspace packages**: a workspace package the root depends on is installed at the root `node_modules`, so every project resolves it whatever its own `package.json` declares. Every other project builds after it and its key holds that package's, as a peer's would: unless that would close a cycle through the order edges (the package's own dependencies reaching it), then it is reach only. Without it, an edit to such a package re-keyed only that package, `--affected` left out the projects that load it through the root, and they hit with its old build. Turbo makes these dependencies of every package too (#8364, #8465).
76
- - Each importer with a `package.json` is a project: one with no vx config joins as a project a plugin added (its tasks come from the `project` stage, else the default `build`). The root importer is the exception: it is a project only by its own vx config. An importer with no `package.json`, and an edge to one, is skipped. A project is named by its `package.json` `name` unless its config names it, or another importer's `package.json` shares the name; then its path names it.
79
+ - Each importer with a manifest (`package.json`, or under `pnpm()` a `package.json5` or `package.yaml`, which pnpm reads and records like any other) is a project: one with no vx config joins as a project a plugin added (its tasks come from the `project` stage, else the default `build`). The root importer is the exception: it is a project only by its own vx config. An importer with no manifest, and an edge to one, is skipped. A project is named by its manifest's `name` unless its config names it, or another importer's manifest shares the name; then its path names it. The workspace globs match a directory holding any of the manifests the manager reads.
77
80
  - A yarn classic (v1) lockfile records no workspaces, so `yarn()` reads them as Yarn 1 does: the directories the root `package.json` `workspaces` globs (an array or `{ packages }`) name. A dependency on a workspace's name is a link unless the lockfile keys its descriptor, which Yarn 1 writes only for the registry copy it fetched because the workspace's version missed the range. A `link:` to a workspace's directory is a link whatever name it is under. A root `resolutions` entry that names the package everywhere (`ms`, `**/ms`) replaces the range first, as Yarn 1 does.
78
81
  - A lockfile older than the manifests **refuses `vx lock`**, naming the install that refreshes it: a workspace the globs name (the root `package.json` `workspaces`, or `pnpm-workspace.yaml` under `pnpm()`, `!` exclusions honoured as the manager reads them, a trailing `**` matching zero directories: `!**/fixtures/**` leaves out `packages/fixtures`) that the file lists no importer for, such as a renamed folder, or a `dependencies`, `devDependencies` or `optionalDependencies` entry naming a workspace package that the file does not record for that importer (not one pnpm leaves out by design: an optional dependency its `ignoredOptionalDependencies` names). Either would lose edges: the package would build beside what it depends on and hit with old content. Yarn classic is exempt: its edges come from the manifests. Under `bun()` a `package.json` with no `name` (build output's `{"type":"module"}`) is no workspace, as Bun leaves it out of `bun.lock`.
79
82
  - `pnpm()` reads the root `pnpm-lock.yaml` only. With `shared-workspace-lockfile` false (`sharedWorkspaceLockfile: false` in `pnpm-workspace.yaml`, which pnpm 10.8+ reads first, or `.npmrc`), pnpm writes a lockfile per package and the root's lists none of them, so `vx lock` refuses naming the setting: an install would not fix it.
80
83
 
81
- A workspace `package.json` that does not parse (git merge conflict markers, a syntax error, or JSON that is not an object) **refuses `vx lock`**, naming it, under every manager: read as no manifest, it lost its dependencies and its dependents hit with old bytes. Turbo and Nx refuse it too. `bun()` reads a manifest as Bun does, so a trailing comma or a comment is no syntax error there; npm, pnpm and yarn refuse both, and so do their plugins.
84
+ A workspace manifest that does not parse (git merge conflict markers, a syntax error, or JSON, JSON5 or YAML that is not an object) **refuses `vx lock`**, naming it, under every manager, and so does a directory holding two (a `package.json` and a `package.yaml`: pnpm 10.28 lists the package by one and installs the other's dependencies): read as no manifest, it lost its dependencies and its dependents hit with old bytes. Turbo and Nx refuse it too. `bun()` reads a manifest as Bun does, so a trailing comma or a comment is no syntax error there; npm, pnpm and yarn refuse both, and so do their plugins.
82
85
 
83
86
  These edges are what was installed, so they differ from a guess over manifest ranges where the manager decided otherwise: pnpm installs a plain range (`^2.0.0`, `*`, `catalog:`) from the registry unless it is `workspace:`, yarn packs a `file:` directory as a copy (no edge: Yarn neither links nor re-syncs it), bun installs `dependencies` over a devDependency of the same key. They are the only package edges vx has: core reads none from a `package.json`, so a workspace whose tasks use `^build` declares one of these plugins (or another that returns `deps`), and without one vx refuses the `^` naming the fix. A dependency an install adds or removes reaches `--affected` as this lockfile's change.
84
87
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-lockfile",
3
- "version": "0.0.651",
3
+ "version": "0.0.653",
4
4
  "license": "MIT",
5
5
  "description": "Lockfile plugins for vx — pnpm(), bun(), npm(), yarn(): each keys every task on its project's own resolved dependency closure from the package manager's lockfile, so a lockfile change re-keys only the projects it reaches; --affected follows the same answer.",
6
6
  "keywords": [
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "peerDependencies": {
38
- "@vzn/vx": "^0.0.651"
38
+ "@vzn/vx": "^0.0.653"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
package/src/bun.ts CHANGED
@@ -11,13 +11,20 @@
11
11
  // Bun.JSONC is the parser: no dependency, and the file is Bun's own.
12
12
 
13
13
  import { reachDigests, type LockfileImporter } from '@vzn/vx'
14
- import { depsOf, peerOnly, record, stable, type Json } from './json.js'
14
+ import { reachedImporters, withTransitive } from './graph.js'
15
+ import { depsOf, peerOnly, record, sectionsOf, stable, type Json } from './json.js'
15
16
  import { joinPosix, resolvePosix } from './posix.js'
16
17
 
18
+ // The graph the digest built from this parse, for `importerLinks`: `vx
19
+ // lock` asks for both, and the edges do not depend on `files`.
20
+ const built = new WeakMap<Lockfile, ReturnType<typeof buildGraph>>()
21
+
17
22
  export interface Lockfile {
18
23
  readonly version: string
19
24
  /** root-relative dir (`.` for the root) → dependency name → specifier */
20
25
  readonly workspaces: ReadonlyMap<string, ReadonlyMap<string, string>>
26
+ /** root-relative dir → which field each dependency sits in (`sectionsOf`) */
27
+ readonly workspaceSections: ReadonlyMap<string, string>
21
28
  /** workspace package name → its dir */
22
29
  readonly workspaceDirs: ReadonlyMap<string, string>
23
30
  /** root-relative dir → its installed dependencies (`dependencies` win, as Bun 1.4 installs them) */
@@ -67,6 +74,7 @@ export function parseLockfile(text: string): Lockfile {
67
74
  throw new Error('bun.lock: not a Bun text lockfile (no lockfileVersion)')
68
75
  }
69
76
  const workspaces = new Map<string, ReadonlyMap<string, string>>()
77
+ const workspaceSections = new Map<string, string>()
70
78
  const workspaceDirs = new Map<string, string>()
71
79
  const workspaceInstalled = new Map<string, ReadonlyMap<string, string>>()
72
80
  const workspacePeers = new Map<string, ReadonlySet<string>>()
@@ -74,6 +82,7 @@ export function parseLockfile(text: string): Lockfile {
74
82
  const m = record(manifest) ?? {}
75
83
  const key = dir === '' ? '.' : dir
76
84
  workspaces.set(key, depsOf(m))
85
+ workspaceSections.set(key, sectionsOf(m))
77
86
  workspaceInstalled.set(
78
87
  key,
79
88
  depsOf(m, ['devDependencies', 'optionalDependencies', 'dependencies']),
@@ -108,6 +117,7 @@ export function parseLockfile(text: string): Lockfile {
108
117
  return {
109
118
  version: String(d['lockfileVersion']),
110
119
  workspaces,
120
+ workspaceSections,
111
121
  workspaceDirs,
112
122
  workspaceInstalled,
113
123
  workspacePeers,
@@ -155,10 +165,19 @@ function packageLevels(path: string): string[] {
155
165
  * its target, which Bun links though `packages` holds no nested entry for
156
166
  * it, and so does an `npm:` alias no entry installs under its own name (a
157
167
  * satisfied one: Bun links the workspace package); anything else resolves
158
- * through Bun's layout.
168
+ * through Bun's layout. A workspace a package it installs resolves to is
169
+ * `transitive`.
159
170
  */
160
171
  export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
161
172
  const out = new Map<string, LockfileImporter>()
173
+ const g = built.get(lock) ?? buildGraph(lock, new Map())
174
+ const nodes = new Map<number, string>()
175
+ for (const [dir, i] of g.importers) nodes.set(i, dir)
176
+ for (const [p, e] of lock.packages) {
177
+ const dir = workspaceOf(lock, e.id)
178
+ if (dir !== undefined && g.importers.has(dir)) nodes.set(g.index.get(p)!, dir)
179
+ }
180
+ const reached = reachedImporters(g.edges, nodes)
162
181
  const viaLayout = (from: string, name: string): string | undefined => {
163
182
  const key = resolve(lock, from, name)
164
183
  return key === undefined ? undefined : workspaceOf(lock, lock.packages.get(key)!.id)
@@ -175,7 +194,7 @@ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImpor
175
194
  add('deps', name, workspaceSpecTarget(lock, dir, from, name, spec) ?? viaLayout(from, name))
176
195
  }
177
196
  for (const name of lock.workspacePeers.get(dir)!) add('peers', name, viaLayout(from, name))
178
- out.set(dir, links)
197
+ out.set(dir, withTransitive(links, reached.get(dir)))
179
198
  }
180
199
  return out
181
200
  }
@@ -293,6 +312,33 @@ export function importerDigests(
293
312
  lock: Lockfile,
294
313
  files: ReadonlyMap<string, string> = new Map(),
295
314
  ): ReadonlyMap<string, string> {
315
+ const g = buildGraph(lock, files)
316
+ built.set(lock, g)
317
+ const { material, edges, importers, loose } = g
318
+ const digests = reachDigests({ material, edges })
319
+ const out = new Map<string, string>()
320
+ // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
321
+ // bits of a seed, so two lockfiles' globals could share one (item 682).
322
+ const globalMaterial =
323
+ loose.length === 0 ? lock.global : `${lock.global}\0${JSON.stringify(loose)}`
324
+ const global = Bun.hash.xxHash3(globalMaterial).toString(16).padStart(16, '0')
325
+ for (const [dir, i] of importers) {
326
+ out.set(dir, Bun.hash.xxHash3(`${global}\0${digests[i]!}`).toString(16).padStart(16, '0'))
327
+ }
328
+ return out
329
+ }
330
+
331
+ /** The lockfile as one graph, each importer's node by dir, and the patches naming no entry. */
332
+ function buildGraph(
333
+ lock: Lockfile,
334
+ files: ReadonlyMap<string, string>,
335
+ ): {
336
+ index: Map<string, number>
337
+ material: string[]
338
+ edges: number[][]
339
+ importers: Map<string, number>
340
+ loose: [string, string, string][]
341
+ } {
296
342
  const index = new Map<string, number>()
297
343
  const material: string[] = []
298
344
  const edges: number[][] = []
@@ -342,7 +388,10 @@ export function importerDigests(
342
388
  const importers = new Map<string, number>()
343
389
  for (const [dir, deps] of lock.workspaces) {
344
390
  // Its dir, as pnpm's importers fold theirs (item 1073).
345
- const own = node(`workspace\0${dir}`, `workspace\0${dir}`)
391
+ const own = node(
392
+ `workspace\0${dir}`,
393
+ `workspace\0${dir}\nsections\0${lock.workspaceSections.get(dir)}`,
394
+ )
346
395
  importers.set(dir, own)
347
396
  const from = importerFrom(lock, dir)
348
397
  for (const [name, spec] of deps) {
@@ -359,17 +408,7 @@ export function importerDigests(
359
408
  const target = dir === undefined ? undefined : importers.get(dir)
360
409
  if (target !== undefined) edges[index.get(p)!]!.push(target)
361
410
  }
362
- const digests = reachDigests({ material, edges })
363
- const out = new Map<string, string>()
364
- // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
365
- // bits of a seed, so two lockfiles' globals could share one (item 682).
366
- const globalMaterial =
367
- loose.length === 0 ? lock.global : `${lock.global}\0${JSON.stringify(loose)}`
368
- const global = Bun.hash.xxHash3(globalMaterial).toString(16).padStart(16, '0')
369
- for (const [dir, i] of importers) {
370
- out.set(dir, Bun.hash.xxHash3(`${global}\0${digests[i]!}`).toString(16).padStart(16, '0'))
371
- }
372
- return out
411
+ return { index, material, edges, importers, loose }
373
412
  }
374
413
 
375
414
  /** The name a package key installs under: its last segment, scope included. */
package/src/graph.ts ADDED
@@ -0,0 +1,73 @@
1
+ // The importers each importer reaches through installed packages, from the
2
+ // graph its digest folds: a registry package's dependency the install
3
+ // resolved to a workspace package (npm and bun link it when the workspace
4
+ // satisfies the range, Yarn's transparent workspaces, a pnpm override to
5
+ // `workspace:`). Read from each importer's own dependencies alone, such a
6
+ // workspace was no edge, so the dependent's key folded none of its task's
7
+ // key, and an edit to it replayed the dependent's old output.
8
+
9
+ /**
10
+ * Each importer's other importers it reaches by a path through one or more
11
+ * package nodes, sorted; one with none is left out. `nodes` maps each node
12
+ * that stands for an importer to its dir: the importer's own, and an entry
13
+ * that only links it (npm's `link: true`, bun's `workspace:` package, a
14
+ * yarn `link:`), so a direct link is no path through a package. Walked
15
+ * backwards from each package node with an edge into an importer, through
16
+ * package nodes alone: a lockfile with no such path costs a pass over the
17
+ * edges.
18
+ */
19
+ export function reachedImporters(
20
+ edges: readonly (readonly number[])[],
21
+ nodes: ReadonlyMap<number, string>,
22
+ ): Map<string, string[]> {
23
+ const into: number[][] = Array.from({ length: edges.length }, () => [])
24
+ for (let v = 0; v < edges.length; v++) for (const w of edges[v]!) into[w]!.push(v)
25
+ const targets = new Map<string, number[]>()
26
+ for (const [i, dir] of nodes) {
27
+ const list = targets.get(dir)
28
+ if (list === undefined) targets.set(dir, [i])
29
+ else list.push(i)
30
+ }
31
+ const found = new Map<string, Set<string>>()
32
+ // Which target last visited a node: no set to clear per target.
33
+ const seen = new Int32Array(edges.length)
34
+ let stamp = 0
35
+ for (const [target, own] of targets) {
36
+ stamp++
37
+ const stack: number[] = []
38
+ const visit = (u: number): void => {
39
+ const dir = nodes.get(u)
40
+ if (dir === undefined) {
41
+ if (seen[u] !== stamp) {
42
+ seen[u] = stamp
43
+ stack.push(u)
44
+ }
45
+ } else if (dir !== target) {
46
+ let set = found.get(dir)
47
+ if (set === undefined) found.set(dir, (set = new Set()))
48
+ set.add(target)
49
+ }
50
+ }
51
+ for (const t of own) for (const u of into[t]!) if (!nodes.has(u)) visit(u)
52
+ while (stack.length > 0) for (const v of into[stack.pop()!]!) visit(v)
53
+ }
54
+ return new Map([...found].map(([dir, set]) => [dir, [...set].sort()]))
55
+ }
56
+
57
+ /**
58
+ * `links` with `transitive` set to what `reached` names beyond its deps and
59
+ * peers; unchanged when that is nothing, so a lockfile with no such path
60
+ * memoises what it did.
61
+ */
62
+ export function withTransitive<T extends { deps: object; peers: object }>(
63
+ links: T,
64
+ reached: readonly string[] | undefined,
65
+ ): T & { transitive?: readonly string[] } {
66
+ if (reached === undefined) return links
67
+ const direct = new Set<string>([
68
+ ...(Object.values(links.deps) as string[]),
69
+ ...(Object.values(links.peers) as string[]),
70
+ ])
71
+ const transitive = reached.filter((d) => !direct.has(d))
72
+ return transitive.length === 0 ? links : { ...links, transitive }
73
+ }
package/src/index.ts CHANGED
@@ -17,7 +17,7 @@
17
17
  // each importer's `package.json` to its tasks' inputs.
18
18
  //
19
19
  // Imports core only through the public `@vzn/vx` specifier.
20
- import { readdirSync, readFileSync } from 'node:fs'
20
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
21
21
  import { createRequire } from 'node:module'
22
22
  import path from 'node:path'
23
23
  import {
@@ -58,7 +58,7 @@ export interface LockfileOptions {
58
58
  }
59
59
 
60
60
  /** Bumps when a digest folds differently or the memo holds more (its identity). */
61
- const DIGEST_VERSION = 23
61
+ const DIGEST_VERSION = 24
62
62
 
63
63
  let memoVersion: number | undefined
64
64
  /**
@@ -133,6 +133,8 @@ interface Manager {
133
133
  readonly unrecorded?: (text: string) => (name: string, pkg: Record<string, unknown>) => boolean
134
134
  /** The manager reads package.json as JSONC (trailing commas, comments): bun does. */
135
135
  readonly jsoncManifests?: true
136
+ /** The file names a manifest may have (default `package.json` alone). */
137
+ readonly manifests?: readonly [string, ...string[]]
136
138
  /**
137
139
  * Importers a format leaves out of the lockfile (yarn classic), from the
138
140
  * workspace's manifests through `manifest`; undefined where the file
@@ -172,15 +174,18 @@ const MANAGERS = {
172
174
  recorded: (t) => pnpmLock().recordedImporters(t),
173
175
  // pnpm's walk skips bower_components as it does node_modules (pnpm 10).
174
176
  skippedDirs: ['node_modules', 'bower_components'],
175
- // The root package.json for its `pnpm` field: pnpm reads install
177
+ // The root manifest for its `pnpm` field: pnpm reads install
176
178
  // settings there that the lockfile does not record.
177
- settings: ['pnpm-workspace.yaml', '.npmrc', 'package.json'],
179
+ settings: ['pnpm-workspace.yaml', '.npmrc', 'package.json', 'package.json5', 'package.yaml'],
178
180
  settingsText: (file, text) =>
179
181
  file === 'pnpm-workspace.yaml'
180
182
  ? pnpmSettings().workspaceSettings(text)
181
- : file === 'package.json'
182
- ? pnpmSettings().packageSettings(text)
183
+ : file.startsWith('package.')
184
+ ? pnpmSettings().packageSettings(text, file)
183
185
  : undefined,
186
+ // pnpm reads a package.json5 or package.yaml where there is no
187
+ // package.json, and records its dependencies like any other's.
188
+ manifests: ['package.json', 'package.json5', 'package.yaml'],
184
189
  peersInManifest: true,
185
190
  // Read from their blocks alone: a whole parse here was on every warm run.
186
191
  linksUnrecorded: (t) => pnpmLock().excludesLinks(t),
@@ -263,8 +268,9 @@ const MANAGERS = {
263
268
  // under an unchanged yarn.lock (probed, yarn 1.22). Yarn 1 reads an
264
269
  // option `.yarnrc` leaves unset from `.npmrc`: `production=true` there
265
270
  // dropped every devDependency (probed, yarn 1.22).
266
- // The root package.json for `workspaces.nohoist` and `installConfig`,
267
- // which lay the install out and the lockfile does not record.
271
+ // The root package.json for `workspaces.nohoist`, `installConfig`,
272
+ // `resolutions` and its dependency ranges, which lay the install out
273
+ // and the lockfile does not record (yarn-settings.ts).
268
274
  settings: ['.yarnrc.yml', '.yarnrc', '.npmrc', 'package.json'],
269
275
  settingsText: (file, text) =>
270
276
  file === 'package.json' ? yarnSettings().packageSettings(text) : undefined,
@@ -295,6 +301,7 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
295
301
  )
296
302
  }
297
303
  const extraFiles = manager.extraFiles
304
+ const manifests = manager.manifests ?? ['package.json']
298
305
  const guardedFor = (file: string) => {
299
306
  // The parsers name their own file, so prefixing unconditionally said it
300
307
  // twice — "bun.lock: bun.lock: Failed to parse JSONC" (2026-09-20).
@@ -458,10 +465,10 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
458
465
  : { ...deps, ...folded }
459
466
  },
460
467
  async discover(ctx) {
461
- const manifest = manifestReader(ctx.workspaceRoot, manager.jsoncManifests === true)
468
+ const manifest = manifestReader(ctx.workspaceRoot, manifests, manager.jsoncManifests === true)
462
469
  // The staleness check's walk overlaps the lockfile read.
463
470
  const dirs = workspaceGlobs(ctx.workspaceRoot, manager.name, manifest).then((globs) =>
464
- workspaceDirs(ctx.workspaceRoot, globs, manager.skippedDirs),
471
+ workspaceDirs(ctx.workspaceRoot, globs, manager.skippedDirs, manifests),
465
472
  )
466
473
  dirs.catch(() => {})
467
474
  const read = await readImporters(ctx, manifest)
@@ -535,19 +542,24 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
535
542
  if (importers === undefined) {
536
543
  importers = importersOf(
537
544
  ctx,
538
- manifestReader(ctx.workspaceRoot, manager.jsoncManifests === true),
545
+ manifestReader(ctx.workspaceRoot, manifests, manager.jsoncManifests === true),
539
546
  )
540
547
  perRun.set(ctx.projects, importers)
541
548
  }
542
549
  if ((await importers)?.has(importerOf(ctx.workspaceRoot, ctx.dir)) !== true) return
550
+ // A stat per project, and only where a manifest may have another name.
551
+ const file =
552
+ manifests.length === 1
553
+ ? manifests[0]
554
+ : (manifests.find((f) => existsSync(path.join(ctx.dir, f))) ?? manifests[0])
543
555
  const tasks = config.tasks ?? {}
544
556
  for (const [name, task] of Object.entries(tasks)) {
545
557
  const inputs = task.inputs
546
- if (inputs === undefined || inputs.files.includes('package.json')) continue
558
+ if (inputs === undefined || inputs.files.includes(file)) continue
547
559
  // A copy: a loaded config may be shared or frozen.
548
560
  tasks[name] = {
549
561
  ...task,
550
- inputs: { ...inputs, files: [...inputs.files, 'package.json'] },
562
+ inputs: { ...inputs, files: [...inputs.files, file] },
551
563
  }
552
564
  }
553
565
  },
@@ -603,40 +615,62 @@ function importerOf(workspaceRoot: string, dir: string): string {
603
615
  }
604
616
 
605
617
  /**
606
- * An importer's `package.json`, or null where it has none. One that does
607
- * not parse refuses, named: read as none, a conflicted manifest lost its
608
- * dependencies and its dependents hit with old bytes (Turbo and Nx refuse).
618
+ * An importer's manifest (the one of `names` present), or null where it
619
+ * has none. One that does not parse refuses, named: read as none, a
620
+ * conflicted manifest lost its dependencies and its dependents hit with
621
+ * old bytes (Turbo and Nx refuse). So do two in one directory: pnpm 10.28
622
+ * lists such a package by its package.json and installs the package.yaml's
623
+ * dependencies.
609
624
  */
610
625
  async function manifestOf(
611
626
  root: string,
612
627
  dir: string,
628
+ names: readonly string[],
613
629
  jsonc: boolean,
614
630
  ): Promise<Record<string, unknown> | null> {
615
- const file = path.join(dir, 'package.json')
616
- const name = path.relative(root, file).split(path.sep).join('/')
617
- let text: string
618
- try {
619
- text = await Bun.file(file).text()
620
- } catch (err) {
621
- // As readSettings: one there but unreadable read as none, and its
622
- // workspace globs and names dropped out of the staleness check. A
623
- // lockfile importer whose directory is now a file has none either.
624
- const code = (err as NodeJS.ErrnoException).code
625
- if (code === 'ENOENT' || code === 'ENOTDIR') return null
631
+ const found: { name: string; text: string }[] = []
632
+ for (const base of names) {
633
+ const file = path.join(dir, base)
634
+ const name = path.relative(root, file).split(path.sep).join('/')
635
+ try {
636
+ found.push({ name, text: await Bun.file(file).text() })
637
+ } catch (err) {
638
+ // As readSettings: one there but unreadable read as none, and its
639
+ // workspace globs and names dropped out of the staleness check. A
640
+ // lockfile importer whose directory is now a file has none either.
641
+ const code = (err as NodeJS.ErrnoException).code
642
+ if (code === 'ENOENT' || code === 'ENOTDIR') continue
643
+ throw new UserError(
644
+ `${name}: cannot be read (${err instanceof Error ? err.message : String(err)})`,
645
+ )
646
+ }
647
+ }
648
+ if (found.length === 0) return null
649
+ if (found.length > 1) {
650
+ const rel = path.relative(root, dir).split(path.sep).join('/') || '.'
651
+ const bases = found.map((f) => path.posix.basename(f.name))
626
652
  throw new UserError(
627
- `${name}: cannot be read (${err instanceof Error ? err.message : String(err)})`,
653
+ `${rel}: holds ${bases.slice(0, -1).join(', ')} and ${bases.at(-1)}, two manifests — keep one`,
628
654
  )
629
655
  }
656
+ const { name, text } = found[0]!
630
657
  if (CONFLICT.test(text))
631
658
  throw new UserError(`${name}: holds git merge conflict markers — resolve them`)
659
+ const [kind, parse]: [string, (t: string) => unknown] = name.endsWith('.yaml')
660
+ ? ['YAML', (t) => Bun.YAML.parse(t)]
661
+ : name.endsWith('.json5')
662
+ ? ['JSON5', (t) => Bun.JSON5.parse(t)]
663
+ : ['JSON', (t) => (jsonc ? Bun.JSONC.parse(t) : JSON.parse(t))]
632
664
  let pkg: unknown
633
665
  try {
634
- pkg = jsonc ? Bun.JSONC.parse(text) : JSON.parse(text)
666
+ pkg = parse(text)
635
667
  } catch (err) {
636
- throw new UserError(`${name}: not JSON (${err instanceof Error ? err.message : String(err)})`)
668
+ throw new UserError(
669
+ `${name}: not ${kind} (${err instanceof Error ? err.message : String(err)})`,
670
+ )
637
671
  }
638
672
  if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
639
- throw new UserError(`${name}: not a JSON object`)
673
+ throw new UserError(`${name}: not a ${kind === 'YAML' ? 'YAML mapping' : `${kind} object`}`)
640
674
  }
641
675
  return pkg as Record<string, unknown>
642
676
  }
@@ -644,13 +678,14 @@ async function manifestOf(
644
678
  /** `manifestOf`, each directory read once. */
645
679
  function manifestReader(
646
680
  root: string,
681
+ names: readonly string[],
647
682
  jsonc: boolean,
648
683
  ): (dir: string) => Promise<Record<string, unknown> | null> {
649
684
  const read = new Map<string, Promise<Record<string, unknown> | null>>()
650
685
  return (dir) => {
651
686
  let pkg = read.get(dir)
652
687
  if (pkg === undefined) {
653
- pkg = manifestOf(root, dir, jsonc)
688
+ pkg = manifestOf(root, dir, names, jsonc)
654
689
  read.set(dir, pkg)
655
690
  }
656
691
  return pkg
@@ -725,14 +760,17 @@ async function workspaceGlobs(
725
760
  }
726
761
 
727
762
  /**
728
- * The directories the globs name that hold a `package.json`, root-relative
729
- * POSIX, sorted; none a `!` glob excludes, none under a `skipped` dir.
763
+ * The directories the globs name that hold a manifest (one of `names`),
764
+ * root-relative POSIX, sorted; none a `!` glob excludes, none under a
765
+ * `skipped` dir.
730
766
  */
731
767
  async function workspaceDirs(
732
768
  root: string,
733
769
  globs: readonly unknown[],
734
770
  skipped: readonly string[] = ['node_modules'],
771
+ names: readonly string[] = ['package.json'],
735
772
  ): Promise<string[]> {
773
+ const manifest = names.length === 1 ? names[0] : `{${names.join(',')}}`
736
774
  const strip = (p: string): string => p.replace(/^\.\//, '').replace(/\/+$/, '')
737
775
  const excluded = globs
738
776
  .filter((p): p is string => typeof p === 'string' && p.startsWith('!'))
@@ -743,7 +781,7 @@ async function workspaceDirs(
743
781
  const dirs = new Set<string>()
744
782
  for (const pattern of globs) {
745
783
  if (typeof pattern !== 'string' || pattern.startsWith('!')) continue
746
- const glob = new Bun.Glob(`${strip(pattern)}/package.json`)
784
+ const glob = new Bun.Glob(`${strip(pattern)}/${manifest}`)
747
785
  for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
748
786
  const dir = path.dirname(file).split(path.sep).join('/')
749
787
  if (dir === '.' || dir.split('/').some((seg) => skipped.includes(seg))) continue
@@ -841,10 +879,11 @@ async function withManifestLinks(
841
879
  * (D-39). Its name is the
842
880
  * manifest's `name` when no other importer shares it (core refuses two
843
881
  * projects of one name, so a shared one names neither); its `deps` the
844
- * importers it installs, its peers on importers, and the importers the root
845
- * installs. A peer or root-installed importer orders a build unless it
846
- * would close a cycle through the order edges, then it is `reach` only, in
847
- * name order so the edge kept is stable.
882
+ * importers it installs, its peers on importers, the importers its packages
883
+ * install, and the importers the root installs. A peer, package-installed
884
+ * or root-installed importer orders a build unless it would close a cycle
885
+ * through the order edges, then it is `reach` only, in name order so the
886
+ * edge kept is stable.
848
887
  */
849
888
  async function discovered(
850
889
  root: string,
@@ -892,6 +931,10 @@ async function discovered(
892
931
  if (manifests.has(target)) (manifestPeers?.has(name) === true ? peer : own).add(target)
893
932
  }
894
933
  for (const target of Object.values(links.peers)) if (manifests.has(target)) peer.add(target)
934
+ // Installed only through a package (a registry package's dependency on
935
+ // a workspace): it orders like a peer, as nothing the importer's own
936
+ // manifest declares puts the edge there.
937
+ for (const target of links.transitive ?? []) if (manifests.has(target)) peer.add(target)
895
938
  order.set(i, own)
896
939
  peers.set(i, peer)
897
940
  }
package/src/json.ts CHANGED
@@ -20,6 +20,20 @@ export function depsOf(
20
20
  return out
21
21
  }
22
22
 
23
+ /**
24
+ * Which field each dependency sits in, as `field\0name` lines sorted: an
25
+ * install that omits dev or optional packages (npm `omit=dev`, pnpm
26
+ * `--prod`, bun `--production`) lays out what it says, and a merged
27
+ * name → version map does not.
28
+ */
29
+ export function sectionsOf(e: Json, fields: readonly string[] = DEP_FIELDS): string {
30
+ const out: string[] = []
31
+ for (const field of fields) {
32
+ for (const name of Object.keys(record(e[field]) ?? {})) out.push(`${field}\0${name}`)
33
+ }
34
+ return out.sort().join('\n')
35
+ }
36
+
23
37
  /** The `peerDependencies` names an entry installs under no other field. */
24
38
  export function peerOnly(e: Json): ReadonlySet<string> {
25
39
  const installed = depsOf(e, DEP_FIELDS.slice(0, 3))
package/src/npm.ts CHANGED
@@ -8,7 +8,12 @@
8
8
  // ancestor directory's, else `node_modules/d` — Node's own walk.
9
9
 
10
10
  import { reachDigests, type LockfileImporter } from '@vzn/vx'
11
- import { depsOf, peerOnly, record } from './json.js'
11
+ import { reachedImporters, withTransitive } from './graph.js'
12
+ import { depsOf, peerOnly, record, sectionsOf } from './json.js'
13
+
14
+ // The graph the digest built from this parse, for `importerLinks`: `vx
15
+ // lock` asks for both, and the edges do not depend on `files`.
16
+ const built = new WeakMap<Lockfile, ReturnType<typeof buildGraph>>()
12
17
 
13
18
  export interface Lockfile {
14
19
  readonly version: number
@@ -22,6 +27,8 @@ export interface Lockfile {
22
27
 
23
28
  export interface Entry {
24
29
  readonly deps: ReadonlyMap<string, string>
30
+ /** the root's and a workspace's `sectionsOf`; '' for any other entry */
31
+ readonly sections: string
25
32
  /** version + resolved + integrity — whatever pins the bytes; '' for a link, the root, a workspace */
26
33
  readonly resolution: string
27
34
  /** the path a `link: true` entry points at */
@@ -72,6 +79,7 @@ export function parseLockfile(text: string): Lockfile {
72
79
  p !== '' && !p.startsWith('node_modules/') && !p.includes('/node_modules/') && isMember(p)
73
80
  packages.set(p, {
74
81
  deps: depsOf(e),
82
+ sections: p === '' || isWorkspace ? sectionsOf(e) : '',
75
83
  // The root's and a workspace's `version` is their own package.json's,
76
84
  // not what npm installed: a release bump of the root re-keyed every
77
85
  // task with node_modules unchanged.
@@ -156,6 +164,32 @@ export function importerDigests(
156
164
  lock: Lockfile,
157
165
  files: ReadonlyMap<string, string> = new Map(),
158
166
  ): ReadonlyMap<string, string> {
167
+ const g = buildGraph(lock, files)
168
+ built.set(lock, g)
169
+ const { index, material, edges } = g
170
+ const digests = reachDigests({ material, edges })
171
+ const out = new Map<string, string>()
172
+ // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
173
+ // bits of a seed, so two lockfiles' globals could share one (item 682).
174
+ const global = Bun.hash.xxHash3(lock.global).toString(16).padStart(16, '0')
175
+ for (const [p, e] of lock.packages) {
176
+ if (p !== '' && !e.isWorkspace) continue
177
+ out.set(
178
+ p === '' ? '.' : p,
179
+ Bun.hash
180
+ .xxHash3(`${global}\0${digests[index.get(p)!]!}`)
181
+ .toString(16)
182
+ .padStart(16, '0'),
183
+ )
184
+ }
185
+ return out
186
+ }
187
+
188
+ /** The lockfile as one graph: a node per path, by path. */
189
+ function buildGraph(
190
+ lock: Lockfile,
191
+ files: ReadonlyMap<string, string>,
192
+ ): { index: Map<string, number>; material: string[]; edges: number[][] } {
159
193
  const index = new Map<string, number>()
160
194
  const material: string[] = []
161
195
  const edges: number[][] = []
@@ -165,7 +199,11 @@ export function importerDigests(
165
199
  // it sits: a re-hoist of one version re-keyed every project reaching it
166
200
  // (D-140). Where it sits decides what it resolves; that is the edges.
167
201
  // The root and a workspace keep their path, which is who they are.
168
- material.push(`${p === '' || e.isWorkspace ? p : installName(p)}\0${e.resolution}`)
202
+ material.push(
203
+ p === '' || e.isWorkspace
204
+ ? `${p}\0${e.resolution}\nsections\0${e.sections}`
205
+ : `${installName(p)}\0${e.resolution}`,
206
+ )
169
207
  edges.push([])
170
208
  }
171
209
  // npm links a `file:` directory, or with `install-links=true` copies it,
@@ -193,31 +231,25 @@ export function importerDigests(
193
231
  else material[from] += `\nunresolved\0${name}\0${spec}`
194
232
  }
195
233
  }
196
- const digests = reachDigests({ material, edges })
197
- const out = new Map<string, string>()
198
- // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
199
- // bits of a seed, so two lockfiles' globals could share one (item 682).
200
- const global = Bun.hash.xxHash3(lock.global).toString(16).padStart(16, '0')
201
- for (const [p, e] of lock.packages) {
202
- if (p !== '' && !e.isWorkspace) continue
203
- out.set(
204
- p === '' ? '.' : p,
205
- Bun.hash
206
- .xxHash3(`${global}\0${digests[index.get(p)!]!}`)
207
- .toString(16)
208
- .padStart(16, '0'),
209
- )
210
- }
211
- return out
234
+ return { index, material, edges }
212
235
  }
213
236
 
214
237
  /**
215
238
  * The root's and each workspace's dependencies that resolve, by Node's
216
239
  * walk, to a `link: true` entry pointing at the root or a workspace: an
217
- * installed entry is a dep, a peer with none of the same name a peer.
240
+ * installed entry is a dep, a peer with none of the same name a peer;
241
+ * and the ones a package it installs resolves to (`transitive`).
218
242
  */
219
243
  export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
220
244
  const out = new Map<string, LockfileImporter>()
245
+ const g = built.get(lock) ?? buildGraph(lock, new Map())
246
+ const nodes = new Map<number, string>()
247
+ for (const [p, e] of lock.packages) {
248
+ const at = p === '' || e.isWorkspace ? p : e.link
249
+ if (at === undefined || (at !== '' && lock.packages.get(at)?.isWorkspace !== true)) continue
250
+ nodes.set(g.index.get(p)!, at === '' ? '.' : at)
251
+ }
252
+ const reached = reachedImporters(g.edges, nodes)
221
253
  for (const [p, e] of lock.packages) {
222
254
  if (p !== '' && !e.isWorkspace) continue
223
255
  const dir = p === '' ? '.' : p
@@ -231,7 +263,7 @@ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImpor
231
263
  const targetDir = link === '' ? '.' : link
232
264
  if (targetDir !== dir) links[e.peers.has(name) ? 'peers' : 'deps'][name] = targetDir
233
265
  }
234
- out.set(dir, links)
266
+ out.set(dir, withTransitive(links, reached.get(dir)))
235
267
  }
236
268
  return out
237
269
  }
@@ -47,17 +47,22 @@ export function sharedWorkspaceLockfile(
47
47
  }
48
48
 
49
49
  /**
50
- * The root package.json as every task folds it: only its `pnpm` field,
50
+ * The root manifest (`file`: package.json, package.json5 or package.yaml)
51
+ * as every task folds it: only its `pnpm` field,
51
52
  * which pnpm reads install settings from (`onlyBuiltDependencies`,
52
53
  * `supportedArchitectures`, …) and the lockfile records none of, without
53
54
  * the patches and overrides it does record. Its dependencies are the
54
55
  * lockfile's to record. Undefined for a file that does not parse to a
55
56
  * mapping, folded whole.
56
57
  */
57
- export function packageSettings(text: string): string | undefined {
58
+ export function packageSettings(text: string, file: string): string | undefined {
58
59
  let doc: unknown
59
60
  try {
60
- doc = JSON.parse(text)
61
+ doc = file.endsWith('.yaml')
62
+ ? Bun.YAML.parse(text)
63
+ : file.endsWith('.json5')
64
+ ? Bun.JSON5.parse(text)
65
+ : JSON.parse(text)
61
66
  } catch {
62
67
  return undefined
63
68
  }
package/src/pnpm.ts CHANGED
@@ -12,13 +12,20 @@
12
12
  // Bun.YAML is the parser: no dependency, and the file is plain YAML.
13
13
 
14
14
  import { reachDigests, type LockfileImporter } from '@vzn/vx'
15
- import { record, stable } from './json.js'
15
+ import { reachedImporters, withTransitive } from './graph.js'
16
+ import { record, sectionsOf, stable } from './json.js'
16
17
  import { joinPosix, resolvePosix } from './posix.js'
17
18
 
19
+ // The graph the digest built from this parse, for `importerLinks`: `vx
20
+ // lock` asks for both, and the edges do not depend on `files`.
21
+ const built = new WeakMap<Lockfile, ReturnType<typeof buildGraph>>()
22
+
18
23
  export interface Lockfile {
19
24
  readonly version: string
20
25
  /** importer path (`.`, `packages/a`) → dependency name → version or `link:…` */
21
26
  readonly importers: ReadonlyMap<string, ReadonlyMap<string, string>>
27
+ /** importer path → which field each dependency sits in (`sectionsOf`) */
28
+ readonly sections: ReadonlyMap<string, string>
22
29
  /** snapshot key → dependency name → version (v9 `snapshots`; v5/v6 `packages`) */
23
30
  readonly snapshots: ReadonlyMap<string, ReadonlyMap<string, string>>
24
31
  /** package key (v9: `name@version`; v5/v6: the snapshot key) → resolution digest */
@@ -73,12 +80,17 @@ export function parseLockfile(text: string): Lockfile {
73
80
  }
74
81
 
75
82
  const importers = new Map<string, ReadonlyMap<string, string>>()
83
+ const sections = new Map<string, string>()
76
84
  const rawImporters = record(doc['importers'])
77
85
  if (rawImporters !== undefined) {
78
- for (const [dir, entry] of Object.entries(rawImporters)) importers.set(dir, depsOf(entry))
86
+ for (const [dir, entry] of Object.entries(rawImporters)) {
87
+ importers.set(dir, depsOf(entry))
88
+ sections.set(dir, sectionsOf(record(entry) ?? {}, DEP_FIELDS))
89
+ }
79
90
  } else if (DEP_FIELDS.some((f) => doc[f] !== undefined)) {
80
91
  // A single-package lockfile keeps its dependencies at the top level.
81
92
  importers.set('.', depsOf(doc))
93
+ sections.set('.', sectionsOf(doc, DEP_FIELDS))
82
94
  }
83
95
 
84
96
  const snapshots = new Map<string, ReadonlyMap<string, string>>()
@@ -122,7 +134,17 @@ export function parseLockfile(text: string): Lockfile {
122
134
  : { lockfileVersion: version, rest, leading },
123
135
  )
124
136
  const excludesLinks = record(doc['settings'])?.['excludeLinksFromLockfile'] === true
125
- return { version, importers, snapshots, resolutions, patches, directories, global, excludesLinks }
137
+ return {
138
+ version,
139
+ importers,
140
+ sections,
141
+ snapshots,
142
+ resolutions,
143
+ patches,
144
+ directories,
145
+ global,
146
+ excludesLinks,
147
+ }
126
148
  }
127
149
 
128
150
  /**
@@ -304,6 +326,7 @@ export function importerDigests(
304
326
  files: ReadonlyMap<string, string> = new Map(),
305
327
  ): ReadonlyMap<string, string> {
306
328
  const g = buildGraph(lock, files)
329
+ built.set(lock, g)
307
330
  const hashes = reachDigests(g)
308
331
  const out = new Map<string, string>()
309
332
  // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
@@ -340,10 +363,12 @@ function importerNode(dir: string): string {
340
363
  * The directories `file:` dependencies install from, and those a `link:`
341
364
  * names that is no importer. pnpm copies the first at install and links
342
365
  * the second, and records only their path, so an edit in one moved no key
343
- * (Growth, file-dir-dep-stale.sh): the claim hashes its content.
366
+ * (Growth, file-dir-dep-stale.sh): the claim hashes its content. A
367
+ * `file:` to an importer (an injected workspace package) is an edge
368
+ * (`importerLinks`), not content: its build output re-keyed its dependants.
344
369
  */
345
370
  export function fileDeps(lock: Lockfile): string[] {
346
- const out = new Set(lock.directories.values())
371
+ const out = new Set([...lock.directories.values()].filter((dir) => !lock.importers.has(dir)))
347
372
  const add = (dir: string, deps: ReadonlyMap<string, string>): void => {
348
373
  for (const version of deps.values()) {
349
374
  const target = linkTarget(lock, dir, version)
@@ -387,8 +412,10 @@ function buildGraph(lock: Lockfile, files: ReadonlyMap<string, string>): Graph {
387
412
  // importers that link each other were told apart by their edges alone,
388
413
  // and swapping which reaches which version gave the same lines, so a
389
414
  // lockfile that moved both installs keyed neither (item 1073).
390
- for (const dir of lock.importers.keys()) node(importerNode(dir), importerNode(dir))
391
- const link = (from: number, dir: string, name: string, version: string): void => {
415
+ for (const dir of lock.importers.keys()) {
416
+ node(importerNode(dir), `${importerNode(dir)}\nsections\0${lock.sections.get(dir)}`)
417
+ }
418
+ const link = (from: number, by: string, dir: string, name: string, version: string): void => {
392
419
  if (version.startsWith('link:')) {
393
420
  const target = linkTarget(lock, dir, version)
394
421
  if (target !== undefined && lock.importers.has(target))
@@ -400,9 +427,12 @@ function buildGraph(lock: Lockfile, files: ReadonlyMap<string, string>): Graph {
400
427
  return
401
428
  }
402
429
  const key = snapshotKey(lock, name, version)
403
- // A dependency with no snapshot (a leaf the file lists only by version)
404
- // is a node of its own, so its version still counts.
405
- edges[from]!.push(node(key, `${key}\0${lock.resolutions.get(packageKey(key)) ?? ''}\0`))
430
+ // pnpm refuses a dependency with no entry ("Broken lockfile",
431
+ // ERR_PNPM_LOCKFILE_MISSING_DEPENDENCY); made a leaf, a file cut after
432
+ // `snapshots:` keyed every project on versions alone.
433
+ const i = index.get(key)
434
+ if (i === undefined) throw new Error(`pnpm-lock.yaml: no entry for ${key}, which ${by} names`)
435
+ edges[from]!.push(i)
406
436
  }
407
437
  // A node folds the names it asks for as well as where they land: by
408
438
  // its edges alone, two aliases swapping targets (`foo: npm:x`, `bar:
@@ -417,12 +447,12 @@ function buildGraph(lock: Lockfile, files: ReadonlyMap<string, string>): Graph {
417
447
  for (const [dir, deps] of lock.importers) {
418
448
  const from = index.get(importerNode(dir))!
419
449
  named(from, deps)
420
- for (const [name, version] of deps) link(from, dir, name, version)
450
+ for (const [name, version] of deps) link(from, `importer ${dir}`, dir, name, version)
421
451
  }
422
452
  for (const [key, deps] of lock.snapshots) {
423
453
  const from = index.get(key)!
424
454
  named(from, deps)
425
- for (const [name, version] of deps) link(from, '.', name, version)
455
+ for (const [name, version] of deps) link(from, key, '.', name, version)
426
456
  }
427
457
  return { index, material, edges }
428
458
  }
@@ -452,10 +482,16 @@ function fileTarget(lock: Lockfile, version: string): string | undefined {
452
482
  * packed `file:` copy is not. pnpm records
453
483
  * an auto-installed peer (`autoInstallPeers`, the default) under
454
484
  * `dependencies` like any other, so `peers` is empty here: the plugin
455
- * asks the importer's manifest which of these are peers.
485
+ * asks the importer's manifest which of these are peers. An importer a
486
+ * snapshot it reaches links (an override to `workspace:`) is `transitive`.
456
487
  */
457
488
  export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
458
489
  const out = new Map<string, LockfileImporter>()
490
+ const g = built.get(lock) ?? buildGraph(lock, new Map())
491
+ const reached = reachedImporters(
492
+ g.edges,
493
+ new Map([...lock.importers.keys()].map((dir) => [g.index.get(importerNode(dir))!, dir])),
494
+ )
459
495
  for (const [dir, deps] of lock.importers) {
460
496
  const links: Record<string, string> = {}
461
497
  for (const [name, version] of deps) {
@@ -468,7 +504,7 @@ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImpor
468
504
  links[name] = target
469
505
  }
470
506
  }
471
- out.set(dir, { deps: links, peers: {} })
507
+ out.set(dir, withTransitive({ deps: links, peers: {} }, reached.get(dir)))
472
508
  }
473
509
  return out
474
510
  }
@@ -9,8 +9,14 @@ import { record, stable, type Json } from './json.js'
9
9
  * (`"is-odd/is-number"`) leaves the parent's entry on the range it
10
10
  * declares, so adding one whose range another package already keys
11
11
  * changes the install with yarn.lock byte for byte (turborepo#14499).
12
- * Its dependencies are the lockfile's to record. Undefined for a file that does not parse to a mapping, folded
13
- * whole.
12
+ * Its `dependencies`, `devDependencies` and `optionalDependencies` too:
13
+ * Yarn 1 records no importer, only the ranges asked, so moving a range
14
+ * between the root and a workspace (root is-number ^6 → ^7, a's ^7 → ^6)
15
+ * leaves yarn.lock byte for byte and moves the root node_modules copy
16
+ * every project without its own resolves (probed, yarn 1.22). Berry
17
+ * records them on the root workspace's entry, whose digest reaches every
18
+ * task already, so folding them there costs no extra re-key. Undefined
19
+ * for a file that does not parse to a mapping, folded whole.
14
20
  */
15
21
  export function packageSettings(text: string): string | undefined {
16
22
  let doc: unknown
@@ -25,6 +31,8 @@ export function packageSettings(text: string): string | undefined {
25
31
  const nohoist = record(map['workspaces'])?.['nohoist']
26
32
  if (nohoist !== undefined) kept['nohoist'] = nohoist
27
33
  if (map['installConfig'] !== undefined) kept['installConfig'] = map['installConfig']
28
- if (map['resolutions'] !== undefined) kept['resolutions'] = map['resolutions']
34
+ for (const field of ['resolutions', 'dependencies', 'devDependencies', 'optionalDependencies']) {
35
+ if (map[field] !== undefined) kept[field] = map[field]
36
+ }
29
37
  return `yarn-package\0${stable(kept)}`
30
38
  }
package/src/yarn.ts CHANGED
@@ -12,6 +12,7 @@
12
12
  // one digest is the root's reach through every entry the file resolves.
13
13
 
14
14
  import { reachDigests, type LockfileImporter } from '@vzn/vx'
15
+ import { reachedImporters, withTransitive } from './graph.js'
15
16
  import { depsOf, record, stable } from './json.js'
16
17
  import { joinPosix, resolvePosix } from './posix.js'
17
18
  import { parseSyml } from './syml.js'
@@ -349,8 +350,10 @@ function resolveDescriptor(
349
350
  * transparent workspaces satisfied with one), and its peers on a
350
351
  * workspace of that name, which Yarn hoists whatever the range. Only a
351
352
  * descriptor the file keys counts: the name fallbacks the digest takes
352
- * (a catalog, an override) name no link. Classic records no workspaces,
353
- * so it has none here (`classicLinks`).
353
+ * (a catalog, an override) name no link. A workspace an entry it reaches
354
+ * so resolves to (transparent workspaces satisfying a registry package's
355
+ * range) is `transitive`. Classic records no workspaces, so it has none
356
+ * here (`classicLinks`).
354
357
  */
355
358
  export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
356
359
  const out = new Map<string, LockfileImporter>()
@@ -360,6 +363,23 @@ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImpor
360
363
  dirOf.set(id, dir)
361
364
  byName.set(id.slice(0, id.indexOf('@workspace:')), dir)
362
365
  }
366
+ // The digest's graph by the descriptors the file keys alone, as above.
367
+ const index = new Map<string, number>()
368
+ for (const id of lock.entries.keys()) index.set(id, index.size)
369
+ // A `link:` entry to a workspace stands for it: no package between.
370
+ const edges: number[][] = []
371
+ const nodes = new Map<number, string>()
372
+ for (const [id, e] of lock.entries) {
373
+ const own: number[] = []
374
+ for (const [name, range] of e.deps) {
375
+ const keyed = keyedDescriptor(lock, id, name, range)
376
+ if (keyed !== undefined) own.push(index.get(keyed)!)
377
+ }
378
+ const at = dirOf.get(id) ?? linkedDir(lock, dirOf, id)
379
+ if (at !== undefined) nodes.set(index.get(id)!, at)
380
+ edges.push(own)
381
+ }
382
+ const reached = reachedImporters(edges, nodes)
363
383
  for (const [dir, id] of lock.workspaces) {
364
384
  const entry = lock.entries.get(id)!
365
385
  const links = { deps: {} as Record<string, string>, peers: {} as Record<string, string> }
@@ -381,7 +401,7 @@ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImpor
381
401
  for (const name of entry.deps.keys()) {
382
402
  if (!entry.installed.has(name)) add('peers', name, byName.get(name))
383
403
  }
384
- out.set(dir, links)
404
+ out.set(dir, withTransitive(links, reached.get(dir)))
385
405
  }
386
406
  return out
387
407
  }