@vzn/vx-lockfile 0.0.635 → 0.0.637

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,9 +2,9 @@
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/): `pnpm()`, `bun()`, `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. 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/): `pnpm()`, `bun()`, `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.
6
6
 
7
- Without a plugin, core folds the whole lockfile into the workspace fingerprint that every cache key sees, so one install invalidates every task in the workspace.
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
 
9
9
  ## Usage
10
10
 
@@ -13,7 +13,7 @@ npm install -D @vzn/vx @vzn/vx-lockfile # or: pnpm add -D -w · yarn add -D (-
13
13
  ```
14
14
 
15
15
  ```ts
16
- // vx.workspace.ts
16
+ // .vx/workspace.ts
17
17
  import { defineWorkspace } from '@vzn/vx/config'
18
18
  import { pnpm } from '@vzn/vx-lockfile' // or bun, npm, yarn
19
19
 
@@ -22,56 +22,61 @@ export default defineWorkspace({
22
22
  })
23
23
  ```
24
24
 
25
- That is the whole setup. The plugin **claims** its lockfile (`VxPlugin.fingerprint`), so core leaves it out of the workspace fingerprint, and its `key` hook folds one digest per project. `vx why <task>` names the material as `plugin @vzn/vx-lockfile/pnpm` (or `/bun`, `/npm`, `/yarn`).
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).
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, as core folds it — the coarse key through the plugin, for a workspace not yet ready to trust the precision. |
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. |
30
30
 
31
31
  ## What a project's digest covers
32
32
 
33
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:
34
34
 
35
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, and a `file:` directory dependency folds its own closure (v9 keys it `name@file:…`, v5/v6 by the bare `file:…`); 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`): 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; 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** (`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; 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: a root `resolutions` override (to a `patch:`, or another range) leaves the original descriptor out of `yarn.lock`, and the entry installed in its place is reached through the package's name, so a patch edit 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. Yarn's builtin compat patches (`resolve`, `typescript`, `fsevents`) are reached too: the workspace asks for the plain descriptor, and the patched entry is the one Yarn installs. `.yarnrc.yml` itself is in core's workspace fingerprint, so a catalog edit re-keys every project, as a `pnpm-workspace.yaml` edit does. 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.
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; 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; 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: a root `resolutions` override (to a `patch:`, or another range) leaves the original descriptor out of `yarn.lock`, and the entry installed in its place is reached through the package's name, so a patch edit 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. Yarn's builtin compat patches (`resolve`, `typescript`, `fsevents`) are reached too: the workspace asks for the plain descriptor, and the patched entry is the one Yarn installs. `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).
39
39
 
40
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.
41
41
 
42
42
  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
43
 
44
- ## Cost
44
+ ## Install settings
45
45
 
46
- 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 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. 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.
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`:
47
47
 
48
- ## `--affected`
48
+ | Plugin | Settings |
49
+ | -------- | ------------------------------- |
50
+ | `pnpm()` | `pnpm-workspace.yaml`, `.npmrc` |
51
+ | `bun()` | `bunfig.toml`, `.npmrc` |
52
+ | `npm()` | `.npmrc` |
53
+ | `yarn()` | `.yarnrc.yml` |
49
54
 
50
- `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.
55
+ 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.
51
56
 
52
- ## `vx prune`
57
+ ## `package.json` as an input
53
58
 
54
- Declaring any of the four plugins adds `vx prune` (the `commands` seam): the workspace cut to some projects and their transitive workspace dependencies, for a Docker build that installs and builds only what it ships — `turbo prune`.
59
+ Every cached task of a project the lockfile lists gets its literal `package.json` appended to `cache.inputs.files` (the `project` stage), so an edit to a manifest re-keys that project's tasks and, through `^` keys, its dependants. A task with no `cache` block stays uncached; one whose inputs already name `package.json` is left alone. A task that rewrites its own `package.json` with the same bytes (tsdown) keeps its hits under `inputStability: { strategy: 'mutable' }`.
55
60
 
56
- ```sh
57
- vx prune <project...> [--out-dir <dir>] [--docker] [--production]
58
- ```
61
+ ## Project edges
59
62
 
60
- The out dir (default `out/`, relative to the cwd) gets the root `package.json` with `workspaces` rewritten to the subset's dirs (a glob character in a dir name bracketed, `a[1]` → `a[[]1]`, so the pattern matches that dir alone), `pnpm-workspace.yaml` with `packages` rewritten (every other key as written), each lockfile at the root **pruned** to what the subset installs, the install config (`.npmrc`, `.yarnrc.yml`, `.pnpmfile.cjs`, `bunfig.toml`, `.nvmrc`, `.node-version`, the patch files `patchedDependencies` names, Yarn's `yarnPath` and plugins), `vx.workspace.*`, the root `vx.config.*`, and each project directory less `node_modules`, `.git`, `.vx` and `.turbo`. A workspace package a copied vx config imports (a local plugin) comes along with its closure: the config loads before any task. A relative config import that leaves the subset is warned about. `--docker` writes `json/` — the root install files and each project's `package.json`, the cacheable install layer — and `full/`, everything:
63
+ `discover` gives vx each project's edges as the lockfile records them, read from the same memo as the digests:
61
64
 
62
- ```dockerfile
63
- COPY out/json/ .
64
- RUN bun install --frozen-lockfile
65
- COPY out/full/ .
66
- RUN bunx vx run app#build
67
- ```
65
+ - **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.
66
+ - **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.
67
+ - 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.
68
+ - A yarn classic (v1) lockfile records no workspaces: `yarn()` gives no edges and warns `yarn.lock: yarn classic (v1) records no workspaces, so yarn() gives vx no project edges — upgrade to Yarn 2+`.
68
69
 
69
- The pruned lockfile is the source with whole entries cut, in the package manager's own layout, so it installs with a frozen lockfile (`bun install --frozen-lockfile`, `pnpm install --frozen-lockfile`, `npm ci`, `yarn install --immutable`) and a line only another project reaches no longer busts the install layer. Kept: the root's and each kept workspace's entries and every package they reach — through Bun's and npm's hoisted walk (a package stays at the path it held; nothing is re-hoisted), pnpm's snapshots, Yarn's descriptors. Workspace-wide fields (catalogs, overrides, patches, settings, `trustedDependencies`) stay whole: the root manifest still names them. pnpm's env document stays whole. Yarn classic records no workspaces, so its walk starts at each kept manifest's dependencies.
70
+ 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.
70
71
 
71
- `--production` follows no `devDependencies` edge between workspace packages: a package only a dev dependency reaches is left out (`turbo prune --production`). Each kept manifest, the root's too, loses the `devDependencies` entries naming one, its field gone when it empties, and every lockfile drops the same entries: bun's and npm's workspace records, pnpm's importer (`{}` when it empties), Yarn berry's `workspace:` link in the workspace entry; Yarn classic walks the stripped manifests. A name another field also holds is installed in production and stays. Third-party dev dependencies stay in the lockfile: an install with `--production` / `--omit=dev` / `--prod` skips them. For the stage that runs a built app, not the one that builds it.
72
+ ## Cost
73
+
74
+ 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.
72
75
 
73
- Refused, with the reason: an out dir that is or contains the root, one inside a copied project, one that already has content; a project name the workspace lacks (the root is always kept); `bun.lockb` without `bun.lock` (binary — `bun install --save-text-lockfile`). A `file:` or `link:` dependency outside every copied project is not copied. One plugin or four, there is one `prune`: it prunes every lockfile at the root.
76
+ ## `--affected`
77
+
78
+ `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.
74
79
 
75
80
  ## Testing
76
81
 
77
- `bun test` covers each parser's digests (transitive bumps, nested versions, workspace links, peer suffixes, patches, install-wide knobs, cycles, key order, aliases, refusals) and `vx run` / `vx why` / `--affected` end to end. `vx prune` is pinned on lockfiles each package manager wrote (bun 1.4, pnpm 10, npm 10, yarn 1 and 4) and end to end: the pruned lockfile is installed frozen and offline by bun always, and by pnpm, npm and yarn 1 where they are on PATH (yarn 1's `--frozen-lockfile` passes a lockfile missing entries, so its check is an install that leaves the file byte-identical). vx's own repository declares `bun()`.
82
+ `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()`.
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@vzn/vx-lockfile",
3
- "version": "0.0.635",
3
+ "version": "0.0.637",
4
4
  "license": "MIT",
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; `vx prune` cuts the workspace and the lockfile to a Docker subset.",
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": [
7
7
  "vx",
8
8
  "vx-plugin",
@@ -11,9 +11,7 @@
11
11
  "pnpm",
12
12
  "bun",
13
13
  "npm",
14
- "yarn",
15
- "prune",
16
- "docker"
14
+ "yarn"
17
15
  ],
18
16
  "type": "module",
19
17
  "main": "./src/index.ts",
@@ -37,7 +35,7 @@
37
35
  "access": "public"
38
36
  },
39
37
  "peerDependencies": {
40
- "@vzn/vx": "^0.0.635"
38
+ "@vzn/vx": "^0.0.637"
41
39
  },
42
40
  "repository": {
43
41
  "type": "git",
package/src/bun.ts CHANGED
@@ -10,9 +10,9 @@
10
10
  //
11
11
  // Bun.JSONC is the parser: no dependency, and the file is Bun's own.
12
12
 
13
- import { reachDigests } from '@vzn/vx'
14
- import { depsOf, devOnly, dropDev, record, type Json } from './json.js'
15
- import type { PruneScope } from './scope.js'
13
+ import { reachDigests, type LockfileImporter } from '@vzn/vx'
14
+ import { depsOf, peerOnly, record, type Json } from './json.js'
15
+ import { joinPosix } from './posix.js'
16
16
 
17
17
  export interface Lockfile {
18
18
  readonly version: string
@@ -20,6 +20,10 @@ export interface Lockfile {
20
20
  readonly workspaces: ReadonlyMap<string, ReadonlyMap<string, string>>
21
21
  /** workspace package name → its dir */
22
22
  readonly workspaceDirs: ReadonlyMap<string, string>
23
+ /** root-relative dir → its installed dependencies (`dependencies` win, as Bun 1.4 installs them) */
24
+ readonly workspaceInstalled: ReadonlyMap<string, ReadonlyMap<string, string>>
25
+ /** root-relative dir → the peers it installs no entry for */
26
+ readonly workspacePeers: ReadonlyMap<string, ReadonlySet<string>>
23
27
  /** node_modules path → the package there */
24
28
  readonly packages: ReadonlyMap<string, Entry>
25
29
  /** `patchedDependencies`: `name@version` (or `name`) → the patch file's path */
@@ -64,10 +68,17 @@ export function parseLockfile(text: string): Lockfile {
64
68
  }
65
69
  const workspaces = new Map<string, ReadonlyMap<string, string>>()
66
70
  const workspaceDirs = new Map<string, string>()
71
+ const workspaceInstalled = new Map<string, ReadonlyMap<string, string>>()
72
+ const workspacePeers = new Map<string, ReadonlySet<string>>()
67
73
  for (const [dir, manifest] of Object.entries(record(d['workspaces']) ?? {})) {
68
74
  const m = record(manifest) ?? {}
69
75
  const key = dir === '' ? '.' : dir
70
76
  workspaces.set(key, depsOf(m))
77
+ workspaceInstalled.set(
78
+ key,
79
+ depsOf(m, ['devDependencies', 'optionalDependencies', 'dependencies']),
80
+ )
81
+ workspacePeers.set(key, peerOnly(m))
71
82
  if (typeof m['name'] === 'string') workspaceDirs.set(m['name'], key)
72
83
  }
73
84
  const packages = new Map<string, Entry>()
@@ -97,6 +108,8 @@ export function parseLockfile(text: string): Lockfile {
97
108
  version: String(d['lockfileVersion']),
98
109
  workspaces,
99
110
  workspaceDirs,
111
+ workspaceInstalled,
112
+ workspacePeers,
100
113
  packages,
101
114
  patches,
102
115
  global,
@@ -134,6 +147,77 @@ function packageLevels(path: string): string[] {
134
147
  return out
135
148
  }
136
149
 
150
+ /**
151
+ * Each workspace's dependencies that resolve to a `workspace:` package: an
152
+ * installed entry is a dep, a peer with none of the same name a peer. A
153
+ * `workspace:` path or alias (`workspace:../b`, `workspace:@s/b@^1`) names
154
+ * its target, which Bun links though `packages` holds no nested entry for
155
+ * it, and so does an `npm:` alias no entry installs under its own name (a
156
+ * satisfied one: Bun links the workspace package); anything else resolves
157
+ * through Bun's layout.
158
+ */
159
+ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
160
+ const out = new Map<string, LockfileImporter>()
161
+ const viaLayout = (from: string, name: string): string | undefined => {
162
+ 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) || '.'
166
+ }
167
+ for (const dir of lock.workspaces.keys()) {
168
+ const from = importerFrom(lock, dir)
169
+ const links = { deps: {} as Record<string, string>, peers: {} as Record<string, string> }
170
+ const add = (kind: 'deps' | 'peers', name: string, target: string | undefined): void => {
171
+ if (target !== undefined && target !== dir && lock.workspaces.has(target)) {
172
+ links[kind][name] = target
173
+ }
174
+ }
175
+ for (const [name, spec] of lock.workspaceInstalled.get(dir)!) {
176
+ add('deps', name, workspaceSpecTarget(lock, dir, from, name, spec) ?? viaLayout(from, name))
177
+ }
178
+ for (const name of lock.workspacePeers.get(dir)!) add('peers', name, viaLayout(from, name))
179
+ out.set(dir, links)
180
+ }
181
+ return out
182
+ }
183
+
184
+ const ALIAS = /^(@[^@/]+\/[^@/]+|[^@/.][^@/]*)(?:@.*)?$/
185
+
186
+ /**
187
+ * The workspace dir a spec names past Bun's layout: `workspace:<path>`,
188
+ * `workspace:<name>@<range>`, or `npm:<name>@<range>` when the entry the
189
+ * dependency resolves to is not a `<name>` package.
190
+ */
191
+ function workspaceSpecTarget(
192
+ lock: Lockfile,
193
+ dir: string,
194
+ from: string,
195
+ key: string,
196
+ spec: string,
197
+ ): string | undefined {
198
+ if (spec.startsWith('npm:')) {
199
+ const alias = ALIAS.exec(spec.slice('npm:'.length))?.[1]
200
+ const at = resolve(lock, from, key)
201
+ if (alias === undefined || lock.packages.get(at ?? '')?.id.startsWith(`${alias}@`)) {
202
+ return undefined
203
+ }
204
+ return lock.workspaceDirs.get(alias)
205
+ }
206
+ if (!spec.startsWith('workspace:')) return undefined
207
+ const rest = spec.slice('workspace:'.length)
208
+ if (rest.startsWith('/')) return undefined
209
+ if (rest.startsWith('.')) return joinPosix(dir, rest)
210
+ const alias = ALIAS.exec(rest)
211
+ return alias === null ? undefined : lock.workspaceDirs.get(alias[1]!)
212
+ }
213
+
214
+ /** The node_modules path a workspace resolves from: its own name, or the root's ''. */
215
+ function importerFrom(lock: Lockfile, dir: string): string {
216
+ if (dir === '.') return ''
217
+ for (const [name, wsDir] of lock.workspaceDirs) if (wsDir === dir) return name
218
+ return ''
219
+ }
220
+
137
221
  /**
138
222
  * The patch files `patchedDependencies` names. bun.lock records a patch by
139
223
  * PATH, never by a hash of its content, so an edited patch left the lockfile
@@ -205,8 +289,7 @@ export function importerDigests(
205
289
  // Its dir, as pnpm's importers fold theirs (item 1073).
206
290
  const own = node(`workspace\0${dir}`, `workspace\0${dir}`)
207
291
  importers.set(dir, own)
208
- let from = ''
209
- for (const [name, wsDir] of lock.workspaceDirs) if (wsDir === dir && dir !== '.') from = name
292
+ const from = importerFrom(lock, dir)
210
293
  for (const [name, spec] of deps) {
211
294
  const target = resolve(lock, from, name)
212
295
  if (target !== undefined) edges[own]!.push(index.get(target)!)
@@ -235,93 +318,6 @@ export function importerDigests(
235
318
  return out
236
319
  }
237
320
 
238
- /**
239
- * The lockfile cut to the workspaces at `dirs` (`.` the root) and the
240
- * packages they reach through Bun's hoisted walk, each where it sat: Bun
241
- * installs a nested key whose root counterpart is gone as written
242
- * (`--frozen-lockfile` passes), so nothing is re-hoisted. Every other
243
- * field as written — catalogs, overrides, patches and trusted
244
- * dependencies are the workspace's, and the root manifest still names them.
245
- */
246
- export function pruneLockfile(text: string, { dirs, dropped }: PruneScope): string {
247
- const lock = parseLockfile(text)
248
- const doc = Bun.JSONC.parse(text) as Json
249
- const reached = new Set<string>()
250
- const visit = (key: string | undefined): void => {
251
- if (key === undefined || reached.has(key)) return
252
- reached.add(key)
253
- const entry = lock.packages.get(key)!
254
- const at = entry.id.indexOf('@workspace:')
255
- if (at !== -1) {
256
- const dir = entry.id.slice(at + '@workspace:'.length) || '.'
257
- if (!dirs.has(dir))
258
- throw new Error(`bun.lock: ${key} is workspace ${dir}, which the subset leaves out`)
259
- return
260
- }
261
- for (const name of entry.deps.keys()) visit(resolve(lock, key, name))
262
- }
263
- const workspaces = record(doc['workspaces']) ?? {}
264
- const skip = new Map<string, Set<string>>()
265
- for (const [dir, manifest] of Object.entries(workspaces)) {
266
- const key = dir === '' ? '.' : dir
267
- if (!dirs.has(key)) {
268
- delete workspaces[dir]
269
- continue
270
- }
271
- const names = devOnly(record(manifest), dropped)
272
- skip.set(key, names)
273
- dropDev(record(manifest), names)
274
- }
275
- for (const [dir, deps] of lock.workspaces) {
276
- if (!dirs.has(dir)) continue
277
- let from = ''
278
- for (const [name, wsDir] of lock.workspaceDirs) if (wsDir === dir && dir !== '.') from = name
279
- if (from !== '') visit(from)
280
- const gone = skip.get(dir)
281
- for (const name of deps.keys()) if (gone?.has(name) !== true) visit(resolve(lock, from, name))
282
- }
283
- const packages = record(doc['packages']) ?? {}
284
- for (const key of Object.keys(packages)) if (!reached.has(key)) delete packages[key]
285
- return writeLockfile(doc)
286
- }
287
-
288
- /**
289
- * Bun's own text layout, so a pruned file diffs against the source as
290
- * removals: two-space indent, a trailing comma after every member but the
291
- * document's last, each `packages` entry on one line with a blank line
292
- * between entries.
293
- */
294
- function writeLockfile(doc: Json): string {
295
- const inline = (v: unknown): string => {
296
- if (Array.isArray(v)) return `[${v.map(inline).join(', ')}]`
297
- const r = record(v)
298
- if (r === undefined) return JSON.stringify(v)
299
- const members = Object.entries(r).map(([k, x]) => `${JSON.stringify(k)}: ${inline(x)}`)
300
- return members.length === 0 ? '{}' : `{ ${members.join(', ')} }`
301
- }
302
- const block = (v: unknown, depth: number): string => {
303
- const pad = ' '.repeat(depth + 1)
304
- const end = ' '.repeat(depth)
305
- if (Array.isArray(v)) {
306
- return v.length === 0
307
- ? '[]'
308
- : `[\n${v.map((x) => `${pad}${block(x, depth + 1)},\n`).join('')}${end}]`
309
- }
310
- const r = record(v)
311
- if (r === undefined) return JSON.stringify(v)
312
- const members = Object.entries(r)
313
- if (members.length === 0) return '{}'
314
- return `{\n${members.map(([k, x]) => `${pad}${JSON.stringify(k)}: ${block(x, depth + 1)},\n`).join('')}${end}}`
315
- }
316
- const members = Object.entries(doc).map(([k, v]) => {
317
- if (k !== 'packages') return ` ${JSON.stringify(k)}: ${block(v, 1)}`
318
- const entries = Object.entries(record(v) ?? {})
319
- if (entries.length === 0) return ` "packages": {}`
320
- return ` "packages": {\n${entries.map(([p, t]) => ` ${JSON.stringify(p)}: ${inline(t)},\n`).join('\n')} }`
321
- })
322
- return `{\n${members.join(',\n')}\n}\n`
323
- }
324
-
325
321
  /** The name a package key installs under: its last segment, scope included. */
326
322
  function installName(key: string): string {
327
323
  const parts = key.split('/')