@vzn/vx-lockfile 0.0.646 → 0.0.649

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 folds the manager's install settings into every task. Zero dependencies: Bun's own YAML and JSONC parsers, and a classic-yarn reader.
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`).
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
 
@@ -22,39 +22,41 @@ export default defineWorkspace({
22
22
  })
23
23
  ```
24
24
 
25
- That is the whole setup. The plugin **claims** its lockfile and its manager's install settings (`VxPlugin.fingerprint`), and its `key` hook folds one digest per project plus each settings file present. `vx why <task>` names the material as `plugin @vzn/vx-lockfile/pnpm` (or `/bun`, `/npm`, `/yarn`) and `plugin @vzn/vx-lockfile/.npmrc` (or the settings file that moved).
25
+ That is the whole setup. The plugin **owns** its lockfile and its manager's install settings (`VxPlugin.fingerprint`), and its `key` hook hashes one digest per project plus each settings file present into the key. `vx why <task>` names the material as `plugin @vzn/vx-lockfile/pnpm` (or `/bun`, `/npm`, `/yarn`) and `plugin @vzn/vx-lockfile/.npmrc` (or the settings file that moved).
26
26
 
27
- | Option | Values | Meaning |
28
- | ------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
29
- | `scope` | `'project'` (default), `'workspace'` | `project`: each task folds its own project's closure. `workspace`: the whole file into every task — the coarse key, for a workspace not yet ready to trust the precision. |
27
+ With no lockfile at the workspace root (before the first install), it warns and keys no package's dependencies; a `^build` then has no edges to follow, and the run refuses it.
28
+
29
+ | Option | Values | Meaning |
30
+ | ------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
31
+ | `scope` | `'project'` (default), `'workspace'` | `project`: each task keys on its own project's closure. `workspace`: the whole file in every task's key — the coarse key, for a workspace not yet ready to trust the precision. |
30
32
 
31
33
  ## What a project's digest covers
32
34
 
33
- 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 folds — 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 folds 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. Per manager:
34
36
 
35
- - **pnpm** (`pnpm-lock.yaml`, lockfile v5, v6, v9): importers → snapshots, transitively; each package by name, version **and resolved peers** (`foo@1(react@18)` is not `foo@1(react@19)`), resolution and any `patchedDependencies` entry; `link:` folds the linked importer's reach, or the CONTENT of a linked directory that is no importer, and a `file:` directory dependency folds 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 folds the ones before it into every project.
36
- - **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; `workspace:` entries fold the linked package's reach; 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 with the CONTENT of the directory, as under pnpm; every top-level field but `workspaces`, `packages`, the catalogs, `overrides` and `patchedDependencies` (`trustedDependencies`, and any field Bun adds later), 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.
37
- - **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 folds its target workspace's reach, and one to a `file:` directory (an entry outside the root's `workspaces` globs) the CONTENT of the directory, as under pnpm, since npm links it; root `overrides` reach a project through the entries they force (npm 10 does not write them to the lockfile). Version 1 (npm 6) has no `packages` map and is refused.
38
- - **yarn** (`yarn.lock`): berry (yarn 2+) resolves `name@npm:range` descriptors to entries, workspaces included, each entry folding every field but its dependency lists (so the root workspace's `dependenciesMeta` reaches every project), `__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 directory that is no workspace also folds 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`) 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. `yarn()` claims `.yarnrc.yml` and folds it into every task, so a catalog edit re-keys every project, as a `pnpm-workspace.yaml` edit does under `pnpm()`. Classic (yarn 1) records no workspaces, so it yields one digest for the root that every project folds — 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, fold their CONTENT into that digest.
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 any `patchedDependencies` entry; `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.
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
+ - **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.
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.
39
41
 
40
- A project the lockfile has no entry for folds the root's digest alone — 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.
42
+ 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.
41
43
 
42
44
  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.
43
45
 
44
46
  ## Install settings
45
47
 
46
- 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 claims its manager's and folds each present file whole into every task; a change to one selects every project under `--affected`:
48
+ 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`:
47
49
 
48
- | Plugin | Settings |
49
- | -------- | ------------------------------- |
50
- | `pnpm()` | `pnpm-workspace.yaml`, `.npmrc` |
51
- | `bun()` | `bunfig.toml`, `.npmrc` |
52
- | `npm()` | `.npmrc` |
53
- | `yarn()` | `.yarnrc.yml` |
50
+ | Plugin | Settings |
51
+ | -------- | ------------------------------------------------------------------------------------------------------------------ |
52
+ | `pnpm()` | `pnpm-workspace.yaml`, `.npmrc`, the root `package.json`'s `pnpm` field |
53
+ | `bun()` | `bunfig.toml`, `.npmrc` |
54
+ | `npm()` | `.npmrc` |
55
+ | `yarn()` | `.yarnrc.yml`, `.yarnrc` and `.npmrc` (yarn 1), the root `package.json`'s `workspaces.nohoist` and `installConfig` |
54
56
 
55
- `pnpm()` folds `pnpm-workspace.yaml` without `patchedDependencies` and `overrides`, which pnpm 10+ keeps there: the lockfile records what each one did, per project, so a patch or an override 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 folds whole.
57
+ `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.
56
58
 
57
- A file has one claimant, so two of these plugins that share a settings file (`pnpm()` and `bun()` both claim `.npmrc`) cannot be declared together.
59
+ 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.
58
60
 
59
61
  ## `package.json` as an input
60
62
 
@@ -64,24 +66,26 @@ Every task with `inputs` of a project the lockfile lists, cached or not, gets it
64
66
 
65
67
  `discover` gives vx each project's edges as the lockfile records them, read from the same memo as the digests:
66
68
 
67
- - **deps** (order `^task`, reach `--filter …` and `--affected`): a dependency the lockfile resolves to a workspace package — pnpm `link:`, bun's `workspace:` entries (a `workspace:` path or alias, and an `npm:` alias Bun links to the workspace package, by the spec it records), npm's `link: true` entries, yarn berry's workspace entries and `link:` / `portal:` paths.
69
+ - **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.
68
70
  - **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.
71
+ - **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).
69
72
  - 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.
70
- - 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.
71
- - 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) 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. 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.
73
+ - 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.
74
+ - 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`.
75
+ - `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.
72
76
 
73
- 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.
77
+ 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.
74
78
 
75
- 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, 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.
79
+ 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.
76
80
 
77
81
  ## Cost
78
82
 
79
- The claim, the per-project key, the memo and the `--affected` diff are core's `lockfileClaim`; this package is the parsers, and they are internal: it exports `pnpm`, `bun`, `npm`, `yarn` and `LockfileOptions`. A lockfile is parsed **once per content**: the digests and each importer's edges are memoised under the cache dir (`lockfile-claims/<file>.json`) by the file's xxh3 and the claimant, so a warm run pays one read, one hash and one small JSON read — never a parse — and the read happens once per run, not per task. Under `scope: 'workspace'` the file is parsed for its edges alone, once per content, so an unreadable lockfile is refused there too. The settings files are read once per run. The digest is one hash per strongly connected component of the dependency graph (lockfiles carry cycles), children first, so a 1000-importer / 3000-package lockfile digests in ~20 ms when it does change.
83
+ Owning the file, the per-project key, the memo and the `--affected` diff are core's `lockfileClaim`; this package is the parsers, and they are internal: it exports `pnpm`, `bun`, `npm`, `yarn` and `LockfileOptions`. A lockfile is parsed **once per content**: the digests and each importer's edges are memoised under the cache dir (`lockfile-claims/<file>.json`) by the file's xxh3 and the plugin, its parsers' source and what core's digest step answers on a fixed probe graph included (an upgrade that changes how a digest is built never replays an old digest), so a warm run pays one read, one hash and one small JSON read — never a parse — and the read happens once per run, not per task. `--affected` reads which files the lockfile names from the same memo. Under `scope: 'workspace'` the file is parsed for its edges alone, once per content, so an unreadable lockfile is refused there too. The settings files are read once per run. The digest is one hash per strongly connected component of the dependency graph (lockfiles carry cycles), children first, so a 1000-importer / 3000-package lockfile digests in ~20 ms when it does change.
80
84
 
81
85
  ## `--affected`
82
86
 
83
- `vx run test --affected=origin/main` after a lockfile change used to select every project. With a plugin declared at `scope: 'project'`, core hands it the lockfile at the base ref and in the working tree; it digests both and names the projects whose digest moved. At `scope: 'workspace'` a change still selects every project. A lockfile that appeared or was deleted still selects everything — every project's `node_modules` is in question.
87
+ `vx run test --affected=origin/main` after a lockfile change used to select every project. With a plugin declared at `scope: 'project'`, core hands it the lockfile at the base ref and in the working tree; it digests both and names the projects whose digest moved. At `scope: 'workspace'` a change still selects every project. A lockfile that appeared or was deleted still selects everything — every project's `node_modules` is in question. A change to a file the lockfile names but does not pin (a `file:`, `link:` or `portal:` directory, a patch) selects the projects reaching it, even when it sits inside another project's directory.
84
88
 
85
89
  ## Testing
86
90
 
87
- `bun test` covers each parser's digests (transitive bumps, nested versions, workspace links, peer suffixes, patches, install-wide knobs, cycles, key order, aliases, refusals), `discover`'s edges per manager (`discover.test.ts`) and the edges core's manifest graph read over the same real installs, recorded when core stopped reading them (`discover-parity.test.ts`), the settings claims, and `vx run` / `vx why` / `--affected` end to end. vx's own repository declares `bun()`.
91
+ `bun test` covers each parser's digests (transitive bumps, nested versions, workspace links, peer suffixes, patches, install-wide knobs, cycles, key order, aliases, refusals), `discover`'s edges per manager (`discover.test.ts`) and the edges core's manifest graph read over the same real installs, recorded when core stopped reading them (`discover-parity.test.ts`), the settings files each plugin owns, and `vx run` / `vx why` / `--affected` end to end. vx's own repository declares `bun()`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-lockfile",
3
- "version": "0.0.646",
3
+ "version": "0.0.649",
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": [
@@ -29,13 +29,13 @@
29
29
  "LICENSE"
30
30
  ],
31
31
  "engines": {
32
- "bun": ">=1.4"
32
+ "bun": ">=1.4.4"
33
33
  },
34
34
  "publishConfig": {
35
35
  "access": "public"
36
36
  },
37
37
  "peerDependencies": {
38
- "@vzn/vx": "^0.0.646"
38
+ "@vzn/vx": "^0.0.649"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
package/src/bun.ts CHANGED
@@ -11,7 +11,7 @@
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, type Json } from './json.js'
14
+ import { depsOf, peerOnly, record, stable, type Json } from './json.js'
15
15
  import { joinPosix } from './posix.js'
16
16
 
17
17
  export interface Lockfile {
@@ -103,7 +103,8 @@ export function parseLockfile(text: string): Lockfile {
103
103
  for (const [k, v] of Object.entries(record(d['patchedDependencies']) ?? {})) {
104
104
  patches.set(k, typeof v === 'string' ? v : JSON.stringify(v))
105
105
  }
106
- const global = JSON.stringify(rest)
106
+ // Sorted: the order Bun writes the fields in says nothing of the install.
107
+ const global = stable(rest)
107
108
  return {
108
109
  version: String(d['lockfileVersion']),
109
110
  workspaces,
@@ -160,9 +161,7 @@ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImpor
160
161
  const out = new Map<string, LockfileImporter>()
161
162
  const viaLayout = (from: string, name: string): string | undefined => {
162
163
  const key = resolve(lock, from, name)
163
- const id = key === undefined ? '' : lock.packages.get(key)!.id
164
- const at = id.indexOf('@workspace:')
165
- return at === -1 ? undefined : id.slice(at + '@workspace:'.length) || '.'
164
+ return key === undefined ? undefined : workspaceOf(lock, lock.packages.get(key)!.id)
166
165
  }
167
166
  for (const dir of lock.workspaces.keys()) {
168
167
  const from = importerFrom(lock, dir)
@@ -211,10 +210,35 @@ function workspaceSpecTarget(
211
210
  return alias === null ? undefined : lock.workspaceDirs.get(alias[1]!)
212
211
  }
213
212
 
214
- /** The node_modules path a workspace resolves from: its own name, or the root's ''. */
213
+ /**
214
+ * The workspace dir a package id installs: `b@workspace:packages/b`, the
215
+ * root, which Bun 1.4 writes `root@root:`, or a `link:` to a workspace's
216
+ * directory (`b@link:./packages/b`), which symlinks the workspace itself:
217
+ * read as a leaf, b's bumps moved no key of the project linking it.
218
+ */
219
+ function workspaceOf(lock: Lockfile, id: string): string | undefined {
220
+ const at = id.indexOf('@workspace:', 1)
221
+ if (at !== -1) return id.slice(at + '@workspace:'.length) || '.'
222
+ if (id.endsWith('@root:')) return '.'
223
+ const link = id.indexOf('@link:', 1)
224
+ if (link === -1) return undefined
225
+ const dir = joinPosix('.', id.slice(link + '@link:'.length))
226
+ return dir !== undefined && lock.workspaces.has(dir) ? dir : undefined
227
+ }
228
+
229
+ /**
230
+ * The node_modules path a workspace resolves from: its own name, `./<name>`
231
+ * when a root package holds that name, or the root's ''. Bun 1.4 writes no
232
+ * root entry for such a workspace and nests its own dependencies under
233
+ * `./<name>/`: resolved from `<name>`, they were the root package's.
234
+ */
215
235
  function importerFrom(lock: Lockfile, dir: string): string {
216
236
  if (dir === '.') return ''
217
- for (const [name, wsDir] of lock.workspaceDirs) if (wsDir === dir) return name
237
+ for (const [name, wsDir] of lock.workspaceDirs) {
238
+ if (wsDir !== dir) continue
239
+ const held = lock.packages.get(name)
240
+ return held === undefined || held.id === `${name}@workspace:${dir}` ? name : `./${name}`
241
+ }
218
242
  return ''
219
243
  }
220
244
 
@@ -232,24 +256,30 @@ export function patchFiles(text: string): string[] {
232
256
  }
233
257
 
234
258
  /**
235
- * The root-relative paths of `file:` dependencies (`dep@file:vendor/dep`).
236
- * Bun copies the directory at install and records only its path, so an
237
- * edit in it moved no key (Growth, file-dir-dep-stale.sh): the claim hashes
238
- * its content like a patch's.
259
+ * The root-relative paths of `file:` and `link:` dependencies
260
+ * (`dep@file:vendor/dep`, `dep@link:./vendor/dep`). Bun copies the first
261
+ * at install and links the second, recording only the path, so an edit in
262
+ * either moved no key (Growth, file-dir-dep-stale.sh): the claim hashes
263
+ * its content like a patch's. A link to a workspace is that workspace's
264
+ * node, not its files.
239
265
  */
240
266
  export function fileDeps(lock: Lockfile): string[] {
241
267
  const out = new Set<string>()
242
268
  for (const e of lock.packages.values()) {
243
- const dir = fileDir(e.id)
269
+ const dir = fileDir(lock, e.id)
244
270
  if (dir !== undefined) out.add(dir)
245
271
  }
246
272
  return [...out].sort()
247
273
  }
248
274
 
249
- /** `dep@file:vendor/dep` → `vendor/dep`. */
250
- function fileDir(id: string): string | undefined {
251
- const at = id.indexOf('@file:', 1)
252
- return at === -1 ? undefined : id.slice(at + '@file:'.length)
275
+ /** `dep@file:vendor/dep` → `vendor/dep`; `dep@link:./vendor/dep` → `vendor/dep` unless a workspace. */
276
+ function fileDir(lock: Lockfile, id: string): string | undefined {
277
+ const file = id.indexOf('@file:', 1)
278
+ if (file !== -1) return id.slice(file + '@file:'.length)
279
+ const link = id.indexOf('@link:', 1)
280
+ if (link === -1) return undefined
281
+ const dir = joinPosix('.', id.slice(link + '@link:'.length))
282
+ return dir === undefined || lock.workspaces.has(dir) ? undefined : dir
253
283
  }
254
284
 
255
285
  /**
@@ -282,7 +312,7 @@ export function importerDigests(
282
312
  // it sits still decides what it resolves; that is the edges.
283
313
  for (const [p, e] of lock.packages) node(p, `${installName(p)}\0${e.id}\0${e.resolution}`)
284
314
  for (const [p, e] of lock.packages) {
285
- const dir = fileDir(e.id)
315
+ const dir = fileDir(lock, e.id)
286
316
  if (dir !== undefined) material[index.get(p)!] += `\nfile\0${files.get(dir) ?? ''}`
287
317
  }
288
318
  // A patch is part of the package it patches: an edit to one only `b`
@@ -325,9 +355,8 @@ export function importerDigests(
325
355
  // live on its importer — so the node points at the importer: what a
326
356
  // project can import through workspace package B is B's whole reach.
327
357
  for (const [p, e] of lock.packages) {
328
- const at = e.id.indexOf('@workspace:')
329
- if (at === -1) continue
330
- const target = importers.get(e.id.slice(at + '@workspace:'.length) || '.')
358
+ const dir = workspaceOf(lock, e.id)
359
+ const target = dir === undefined ? undefined : importers.get(dir)
331
360
  if (target !== undefined) edges[index.get(p)!]!.push(target)
332
361
  }
333
362
  const digests = reachDigests({ material, edges })