@vzn/vx-lockfile 0.0.650 → 0.0.652
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 +15 -11
- package/package.json +2 -2
- package/src/bun.ts +3 -3
- package/src/index.ts +11 -1
- package/src/npm.ts +6 -0
- package/src/pnpm.ts +23 -4
- package/src/posix.ts +15 -0
- package/src/yarn-settings.ts +7 -3
- package/src/yarn.ts +70 -2
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ export default defineWorkspace({
|
|
|
22
22
|
})
|
|
23
23
|
```
|
|
24
24
|
|
|
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).
|
|
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 lock` records the digests in `.vx/dependencies.lock.json` and a run keys from that record, so after an install run `vx lock` and commit it: a run whose lockfile changed since refuses until you do. An edit in a `link:` or `file:` directory (or a patch) the lockfile names needs no `vx lock`: the run sees it moved since the record and digests anew. `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
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
28
|
|
|
@@ -34,11 +34,13 @@ With no lockfile at the workspace root (before the first install), it warns and
|
|
|
34
34
|
|
|
35
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:
|
|
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
|
|
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.
|
|
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
|
-
- **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.
|
|
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.
|
|
41
41
|
|
|
42
|
+
A `file:` or `link:` directory outside the repository (`"dep": "link:../../../vendor/dep"`) is hashed by its CONTENT too, under every manager: an edit in it re-keys the projects reaching it. Git holds no base for it, so `--affected` does not see that edit unless the lockfile changes.
|
|
43
|
+
|
|
42
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.
|
|
43
45
|
|
|
44
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.
|
|
@@ -47,20 +49,22 @@ A lockfile the parser cannot read **refuses the run**, naming the file, the reas
|
|
|
47
49
|
|
|
48
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`:
|
|
49
51
|
|
|
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 `
|
|
52
|
+
| Plugin | Settings |
|
|
53
|
+
| -------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
|
54
|
+
| `pnpm()` | `pnpm-workspace.yaml`, `.npmrc`, the root `package.json`'s `pnpm` field |
|
|
55
|
+
| `bun()` | `bunfig.toml`, `.npmrc` |
|
|
56
|
+
| `npm()` | `.npmrc` |
|
|
57
|
+
| `yarn()` | `.yarnrc.yml`, `.yarnrc` and `.npmrc` (yarn 1), the root `package.json`'s `workspaces.nohoist`, `installConfig` and `resolutions` |
|
|
56
58
|
|
|
57
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.
|
|
58
60
|
|
|
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
|
+
|
|
59
63
|
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.
|
|
60
64
|
|
|
61
65
|
## `package.json` as an input
|
|
62
66
|
|
|
63
|
-
Every task with `inputs` of a project the lockfile lists, cached or not, gets its literal `package.json` appended to `inputs.files` (the `project` stage), so an edit to a manifest re-keys that project's tasks and, through `^` keys, its dependants. A task with no `inputs` stays keyless; one whose inputs already name `package.json` is left alone. A task that rewrites its own `package.json` with the same bytes (tsdown) keeps its hits under `
|
|
67
|
+
Every task with `inputs` of a project the lockfile lists, cached or not, gets its literal `package.json` appended to `inputs.files` (the `project` stage), so an edit to a manifest re-keys that project's tasks and, through `^` keys, its dependants. A task with no `inputs` stays keyless; one whose inputs already name `package.json` is left alone. A task that rewrites its own `package.json` with the same bytes (tsdown) keeps its hits under `inputs.stability: { strategy: 'mutable' }`.
|
|
64
68
|
|
|
65
69
|
## Project edges
|
|
66
70
|
|
|
@@ -80,7 +84,7 @@ These edges are what was installed, so they differ from a guess over manifest ra
|
|
|
80
84
|
|
|
81
85
|
## Cost
|
|
82
86
|
|
|
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**:
|
|
87
|
+
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 run's key never parses the lockfile: it reads each project's digest from `.vx/dependencies.lock.json`, which core holds to the lockfile's hash (unless a file the lockfile names moved since, as above). The rest is parsed **once per content**: each importer's edges and the dependency names it records (what `discover` reads), and the digests `vx lock` asks for, are memoised in core's cache.db (`lockfile_memos`, one row per file and claimant) 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 row read — never a parse. A run's own parse (a fresh cache) leaves the digests out, most of a parse's cost. `--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.
|
|
84
88
|
|
|
85
89
|
## `--affected`
|
|
86
90
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-lockfile",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.652",
|
|
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.652"
|
|
39
39
|
},
|
|
40
40
|
"repository": {
|
|
41
41
|
"type": "git",
|
package/src/bun.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
import { reachDigests, type LockfileImporter } from '@vzn/vx'
|
|
14
14
|
import { depsOf, peerOnly, record, stable, type Json } from './json.js'
|
|
15
|
-
import { joinPosix } from './posix.js'
|
|
15
|
+
import { joinPosix, resolvePosix } from './posix.js'
|
|
16
16
|
|
|
17
17
|
export interface Lockfile {
|
|
18
18
|
readonly version: string
|
|
@@ -278,8 +278,8 @@ function fileDir(lock: Lockfile, id: string): string | undefined {
|
|
|
278
278
|
if (file !== -1) return id.slice(file + '@file:'.length)
|
|
279
279
|
const link = id.indexOf('@link:', 1)
|
|
280
280
|
if (link === -1) return undefined
|
|
281
|
-
const dir =
|
|
282
|
-
return
|
|
281
|
+
const dir = resolvePosix('.', id.slice(link + '@link:'.length))
|
|
282
|
+
return lock.workspaces.has(dir) ? undefined : dir
|
|
283
283
|
}
|
|
284
284
|
|
|
285
285
|
/**
|
package/src/index.ts
CHANGED
|
@@ -230,7 +230,12 @@ const MANAGERS = {
|
|
|
230
230
|
// changed one is a settings change, which selects every project.
|
|
231
231
|
const rc =
|
|
232
232
|
lock.generation === 'berry' ? readText(path.join(root, yarnLock().YARNRC)) : undefined
|
|
233
|
-
|
|
233
|
+
// Root `resolutions` Yarn 4 records on no entry: the edges they add.
|
|
234
|
+
const resolutions =
|
|
235
|
+
lock.generation === 'berry'
|
|
236
|
+
? yarnLock().resolutionsOf(readText(path.join(root, 'package.json')))
|
|
237
|
+
: []
|
|
238
|
+
return yarnLock().importerDigests(lock, files, yarnLock().packageExtensions(rc), resolutions)
|
|
234
239
|
},
|
|
235
240
|
importers: (t) => yarnLock().importerLinks(yarnParse(t)),
|
|
236
241
|
extraFiles: (t, root) => {
|
|
@@ -414,6 +419,11 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
414
419
|
const before = folded(change.before)
|
|
415
420
|
return before !== undefined && before === folded(change.after) ? [] : undefined
|
|
416
421
|
},
|
|
422
|
+
// A settings file folds into every key alike: nothing per project.
|
|
423
|
+
digests: (file, ctx) =>
|
|
424
|
+
claimsAt(ctx.workspaceRoot)
|
|
425
|
+
.find((c) => c.file === file)
|
|
426
|
+
?.claim.fingerprint.digests?.(file, ctx),
|
|
417
427
|
// The files the key hashes, so the key's own claim answers, from its
|
|
418
428
|
// memo when it holds these bytes: `--affected` asks about every
|
|
419
429
|
// changed path, and a classic `link:` to a workspace named without
|
package/src/npm.ts
CHANGED
|
@@ -47,6 +47,12 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
47
47
|
)
|
|
48
48
|
}
|
|
49
49
|
const raws = record(d!['packages']) ?? {}
|
|
50
|
+
// Read as no packages, a bump there moved no key.
|
|
51
|
+
if (Object.keys(raws).length === 0 && Object.keys(record(d!['dependencies']) ?? {}).length > 0) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`lockfileVersion ${version} lists its packages only in the legacy \`dependencies\` tree — npm 7+ writes them under \`packages\``,
|
|
54
|
+
)
|
|
55
|
+
}
|
|
50
56
|
const isMember = workspaceMatcher(raws[''])
|
|
51
57
|
const packages = new Map<string, Entry>()
|
|
52
58
|
for (const [p, raw] of Object.entries(raws)) {
|
package/src/pnpm.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { reachDigests, type LockfileImporter } from '@vzn/vx'
|
|
15
15
|
import { record, stable } from './json.js'
|
|
16
|
-
import { joinPosix } from './posix.js'
|
|
16
|
+
import { joinPosix, resolvePosix } from './posix.js'
|
|
17
17
|
|
|
18
18
|
export interface Lockfile {
|
|
19
19
|
readonly version: string
|
|
@@ -271,6 +271,26 @@ function packageName(lock: Lockfile, snapshot: string): string {
|
|
|
271
271
|
return slash === -1 ? s : s.slice(0, slash)
|
|
272
272
|
}
|
|
273
273
|
|
|
274
|
+
/**
|
|
275
|
+
* The patch pnpm applies to a snapshot, by its precedence: `name@version`,
|
|
276
|
+
* then a range key (`name@<=7.0.0`), then `name`. A range key matched
|
|
277
|
+
* nothing here, so a patch edit re-keyed no project (vercel/turborepo#13307).
|
|
278
|
+
*/
|
|
279
|
+
function patchOf(lock: Lockfile, snapshot: string): string {
|
|
280
|
+
const exact = lock.patches.get(packageKey(snapshot))
|
|
281
|
+
if (exact !== undefined) return exact
|
|
282
|
+
const name = packageName(lock, snapshot)
|
|
283
|
+
const id = packageKey(snapshot)
|
|
284
|
+
const version = id.slice(id.indexOf(name) + name.length + 1)
|
|
285
|
+
for (const [key, hash] of lock.patches) {
|
|
286
|
+
if (key.length > name.length + 1 && key.startsWith(`${name}@`)) {
|
|
287
|
+
const range = key.slice(name.length + 1)
|
|
288
|
+
if (Bun.semver.satisfies(version, range)) return hash
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return lock.patches.get(name) ?? ''
|
|
292
|
+
}
|
|
293
|
+
|
|
274
294
|
/**
|
|
275
295
|
* Every importer's digest: the lockfile as one graph (importers and
|
|
276
296
|
* snapshots alike), each node's material its identity, and core's
|
|
@@ -339,7 +359,7 @@ export function fileDeps(lock: Lockfile): string[] {
|
|
|
339
359
|
}
|
|
340
360
|
|
|
341
361
|
function linkTarget(lock: Lockfile, dir: string, version: string): string | undefined {
|
|
342
|
-
return version.startsWith('link:') ?
|
|
362
|
+
return version.startsWith('link:') ? resolvePosix(dir, version.slice('link:'.length)) : undefined
|
|
343
363
|
}
|
|
344
364
|
|
|
345
365
|
function buildGraph(lock: Lockfile, files: ReadonlyMap<string, string>): Graph {
|
|
@@ -358,8 +378,7 @@ function buildGraph(lock: Lockfile, files: ReadonlyMap<string, string>): Graph {
|
|
|
358
378
|
}
|
|
359
379
|
for (const key of lock.snapshots.keys()) {
|
|
360
380
|
const resolution = lock.resolutions.get(key) ?? lock.resolutions.get(packageKey(key)) ?? ''
|
|
361
|
-
const patch =
|
|
362
|
-
lock.patches.get(packageKey(key)) ?? lock.patches.get(packageName(lock, key)) ?? ''
|
|
381
|
+
const patch = patchOf(lock, key)
|
|
363
382
|
const dir = lock.directories.get(key) ?? lock.directories.get(packageKey(key))
|
|
364
383
|
const content = dir === undefined ? '' : `\0file\0${files.get(dir) ?? ''}`
|
|
365
384
|
node(key, `${key}\0${resolution}\0${patch}${content}`)
|
package/src/posix.ts
CHANGED
|
@@ -12,3 +12,18 @@ export function joinPosix(base: string, rel: string): string | undefined {
|
|
|
12
12
|
}
|
|
13
13
|
return parts.length === 0 ? '.' : parts.join('/')
|
|
14
14
|
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* `joinPosix`, but a path above the root keeps its leading `..`
|
|
18
|
+
* (`packages/a` + `../../../ext` → `../ext`): a local directory dependency
|
|
19
|
+
* beside the repository, whose content the claim hashes.
|
|
20
|
+
*/
|
|
21
|
+
export function resolvePosix(base: string, rel: string): string {
|
|
22
|
+
const parts: string[] = []
|
|
23
|
+
for (const seg of `${base}/${rel}`.split('/')) {
|
|
24
|
+
if (seg === '' || seg === '.') continue
|
|
25
|
+
if (seg === '..' && parts.length > 0 && parts.at(-1) !== '..') parts.pop()
|
|
26
|
+
else parts.push(seg)
|
|
27
|
+
}
|
|
28
|
+
return parts.length === 0 ? '.' : parts.join('/')
|
|
29
|
+
}
|
package/src/yarn-settings.ts
CHANGED
|
@@ -4,9 +4,12 @@ import { record, stable, type Json } from './json.js'
|
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* The root package.json as every task folds it: `workspaces.nohoist`
|
|
7
|
-
* (Yarn 1 lays node_modules out by it)
|
|
8
|
-
* yarn.lock records
|
|
9
|
-
*
|
|
7
|
+
* (Yarn 1 lays node_modules out by it), `installConfig` and
|
|
8
|
+
* `resolutions`. yarn.lock records none of them: one scoped to a parent
|
|
9
|
+
* (`"is-odd/is-number"`) leaves the parent's entry on the range it
|
|
10
|
+
* declares, so adding one whose range another package already keys
|
|
11
|
+
* changes the install with yarn.lock byte for byte (turborepo#14499).
|
|
12
|
+
* Its dependencies are the lockfile's to record. Undefined for a file that does not parse to a mapping, folded
|
|
10
13
|
* whole.
|
|
11
14
|
*/
|
|
12
15
|
export function packageSettings(text: string): string | undefined {
|
|
@@ -22,5 +25,6 @@ export function packageSettings(text: string): string | undefined {
|
|
|
22
25
|
const nohoist = record(map['workspaces'])?.['nohoist']
|
|
23
26
|
if (nohoist !== undefined) kept['nohoist'] = nohoist
|
|
24
27
|
if (map['installConfig'] !== undefined) kept['installConfig'] = map['installConfig']
|
|
28
|
+
if (map['resolutions'] !== undefined) kept['resolutions'] = map['resolutions']
|
|
25
29
|
return `yarn-package\0${stable(kept)}`
|
|
26
30
|
}
|
package/src/yarn.ts
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
|
|
14
14
|
import { reachDigests, type LockfileImporter } from '@vzn/vx'
|
|
15
15
|
import { depsOf, record, stable } from './json.js'
|
|
16
|
-
import { joinPosix } from './posix.js'
|
|
16
|
+
import { joinPosix, resolvePosix } from './posix.js'
|
|
17
17
|
import { parseSyml } from './syml.js'
|
|
18
18
|
|
|
19
19
|
export interface Lockfile {
|
|
@@ -412,7 +412,7 @@ function linkPath(dirOf: ReadonlyMap<string, string>, id: string): string | unde
|
|
|
412
412
|
return undefined
|
|
413
413
|
}
|
|
414
414
|
const from = dirOf.get(locator)
|
|
415
|
-
return from === undefined ? undefined :
|
|
415
|
+
return from === undefined ? undefined : resolvePosix(from, m[1]!)
|
|
416
416
|
}
|
|
417
417
|
|
|
418
418
|
/**
|
|
@@ -573,6 +573,72 @@ function extend(
|
|
|
573
573
|
if (orphans.length > 0) for (const r of roots) edges[r]!.push(...orphans)
|
|
574
574
|
}
|
|
575
575
|
|
|
576
|
+
/** A root `resolutions` entry: the dependency it overrides, of `parent` alone when scoped. */
|
|
577
|
+
export interface Resolution {
|
|
578
|
+
readonly parent: string | undefined
|
|
579
|
+
readonly name: string
|
|
580
|
+
readonly range: string
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* The root manifest's berry `resolutions` (`is-number`, `is-odd/is-number`,
|
|
585
|
+
* `is-odd@3/is-number`, `a/**\/b`), each as the dependency it overrides and
|
|
586
|
+
* the parent it is scoped to. A parent's range and a `**` are read as any
|
|
587
|
+
* parent: an edge too many re-keys once too often, never too seldom.
|
|
588
|
+
*/
|
|
589
|
+
export function resolutionsOf(manifest: string | undefined): Resolution[] {
|
|
590
|
+
let raw: unknown
|
|
591
|
+
try {
|
|
592
|
+
raw = manifest === undefined ? undefined : JSON.parse(manifest)
|
|
593
|
+
} catch {
|
|
594
|
+
return []
|
|
595
|
+
}
|
|
596
|
+
const out: Resolution[] = []
|
|
597
|
+
for (const [pattern, range] of depsOf((raw ?? {}) as Record<string, unknown>, ['resolutions'])) {
|
|
598
|
+
const parts = pattern.split('/')
|
|
599
|
+
const tokens: string[] = []
|
|
600
|
+
for (let i = 0; i < parts.length; i++) {
|
|
601
|
+
tokens.push(
|
|
602
|
+
parts[i]!.startsWith('@') && i + 1 < parts.length ? `${parts[i]}/${parts[++i]}` : parts[i]!,
|
|
603
|
+
)
|
|
604
|
+
}
|
|
605
|
+
const bare = (t: string) => {
|
|
606
|
+
const at = t.indexOf('@', 1)
|
|
607
|
+
return at === -1 ? t : t.slice(0, at)
|
|
608
|
+
}
|
|
609
|
+
const name = bare(tokens.at(-1) ?? '')
|
|
610
|
+
if (name === '' || name === '**') continue
|
|
611
|
+
const parent = tokens.length < 2 || tokens.includes('**') ? undefined : bare(tokens.at(-2)!)
|
|
612
|
+
out.push({ parent, name, range })
|
|
613
|
+
}
|
|
614
|
+
return out
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/**
|
|
618
|
+
* Each entry a root `resolutions` override reaches gets an edge to the
|
|
619
|
+
* entry its range names. Yarn 4 writes a parent-scoped override nowhere in
|
|
620
|
+
* the file: is-odd's entry keeps `is-number: "npm:^6.0.0"` while is-odd
|
|
621
|
+
* installs the ^7 entry another package keys, so a lockfile-only bump of
|
|
622
|
+
* that entry moved only the other package's key, and is-odd's dependents
|
|
623
|
+
* replayed a stale output. The override's own text is a settings fold
|
|
624
|
+
* (yarn-settings.ts); this is where it lands.
|
|
625
|
+
*/
|
|
626
|
+
function override(
|
|
627
|
+
lock: Lockfile,
|
|
628
|
+
index: ReadonlyMap<string, number>,
|
|
629
|
+
edges: number[][],
|
|
630
|
+
resolutions: readonly Resolution[],
|
|
631
|
+
): void {
|
|
632
|
+
for (const r of resolutions) {
|
|
633
|
+
for (const [id, e] of lock.entries) {
|
|
634
|
+
if (!e.deps.has(r.name) || (r.parent !== undefined && e.name !== r.parent)) continue
|
|
635
|
+
for (const target of resolveDescriptor(lock, id, r.name, r.range)) {
|
|
636
|
+
edges[index.get(id)!]!.push(index.get(target)!)
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
|
|
576
642
|
/**
|
|
577
643
|
* Every workspace's digest. Berry: one node per entry, workspaces among
|
|
578
644
|
* them, keyed by dir. Classic: the file has no workspace entries, so the
|
|
@@ -583,6 +649,7 @@ export function importerDigests(
|
|
|
583
649
|
lock: Lockfile,
|
|
584
650
|
files: ReadonlyMap<string, string> = new Map(),
|
|
585
651
|
extensions: readonly Extension[] = BUILTIN,
|
|
652
|
+
resolutions: readonly Resolution[] = [],
|
|
586
653
|
): ReadonlyMap<string, string> {
|
|
587
654
|
const index = new Map<string, number>()
|
|
588
655
|
const material: string[] = []
|
|
@@ -615,6 +682,7 @@ export function importerDigests(
|
|
|
615
682
|
if (target !== undefined)
|
|
616
683
|
edges[index.get(id)!]!.push(index.get(lock.workspaces.get(target)!)!)
|
|
617
684
|
}
|
|
685
|
+
override(lock, index, edges, resolutions)
|
|
618
686
|
extend(lock, index, edges, extensions)
|
|
619
687
|
}
|
|
620
688
|
const digests = reachDigests({ material, edges })
|