@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 +18 -15
- package/package.json +2 -2
- package/src/bun.ts +54 -15
- package/src/graph.ts +73 -0
- package/src/index.ts +82 -39
- package/src/json.ts +14 -0
- package/src/npm.ts +52 -20
- package/src/pnpm-settings.ts +8 -3
- package/src/pnpm.ts +50 -14
- package/src/yarn-settings.ts +11 -3
- package/src/yarn.ts +23 -3
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`
|
|
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 `
|
|
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
|
-
##
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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.
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
|
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
|
|
267
|
-
// which lay the install out
|
|
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(
|
|
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,
|
|
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 `
|
|
607
|
-
* not parse refuses, named: read as none, a
|
|
608
|
-
* dependencies and its dependents hit with
|
|
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
|
|
616
|
-
const
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
`${
|
|
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 =
|
|
666
|
+
pkg = parse(text)
|
|
635
667
|
} catch (err) {
|
|
636
|
-
throw new UserError(
|
|
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
|
|
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 `
|
|
729
|
-
* POSIX, sorted; none a `!` glob excludes, none under a
|
|
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)}
|
|
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,
|
|
845
|
-
* installs. A peer
|
|
846
|
-
*
|
|
847
|
-
*
|
|
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 {
|
|
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(
|
|
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
|
-
|
|
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
|
}
|
package/src/pnpm-settings.ts
CHANGED
|
@@ -47,17 +47,22 @@ export function sharedWorkspaceLockfile(
|
|
|
47
47
|
}
|
|
48
48
|
|
|
49
49
|
/**
|
|
50
|
-
* The root package.json
|
|
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 =
|
|
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 {
|
|
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))
|
|
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 {
|
|
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())
|
|
391
|
-
|
|
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
|
-
//
|
|
404
|
-
//
|
|
405
|
-
|
|
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
|
}
|
package/src/yarn-settings.ts
CHANGED
|
@@ -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
|
|
13
|
-
*
|
|
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
|
-
|
|
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.
|
|
353
|
-
* so
|
|
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
|
}
|