@vzn/vx-lockfile 0.0.644 → 0.0.646
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/LICENSE +1 -1
- package/README.md +2 -2
- package/package.json +2 -2
- package/src/index.ts +54 -9
- package/src/json.ts +9 -0
- package/src/pnpm-settings.ts +26 -0
- package/src/pnpm.ts +29 -32
- package/src/yarn.ts +1 -2
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -2,14 +2,14 @@
|
|
|
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/): `
|
|
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.
|
|
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
|
|
|
9
9
|
## Usage
|
|
10
10
|
|
|
11
11
|
```sh
|
|
12
|
-
|
|
12
|
+
bun add -d @vzn/vx @vzn/vx-lockfile
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
```ts
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-lockfile",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.646",
|
|
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.646"
|
|
39
39
|
},
|
|
40
40
|
"repository": {
|
|
41
41
|
"type": "git",
|
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
// each importer's `package.json` to its tasks' inputs.
|
|
18
18
|
//
|
|
19
19
|
// Imports core only through the public `@vzn/vx` specifier.
|
|
20
|
+
import { mkdir, rename } from 'node:fs/promises'
|
|
20
21
|
import { createRequire } from 'node:module'
|
|
21
22
|
import path from 'node:path'
|
|
22
23
|
import {
|
|
@@ -41,6 +42,7 @@ const pnpmLock = (): typeof import('./pnpm.js') => load('./pnpm.js')
|
|
|
41
42
|
const bunLock = (): typeof import('./bun.js') => load('./bun.js')
|
|
42
43
|
const npmLock = (): typeof import('./npm.js') => load('./npm.js')
|
|
43
44
|
const yarnLock = (): typeof import('./yarn.js') => load('./yarn.js')
|
|
45
|
+
const pnpmSettings = (): typeof import('./pnpm-settings.js') => load('./pnpm-settings.js')
|
|
44
46
|
|
|
45
47
|
export interface LockfileOptions {
|
|
46
48
|
/**
|
|
@@ -112,10 +114,10 @@ const MANAGERS = {
|
|
|
112
114
|
digest: (t, files) => pnpmLock().importerDigests(pnpmParse(t), files),
|
|
113
115
|
importers: (t) => pnpmLock().importerLinks(pnpmParse(t)),
|
|
114
116
|
extraFiles: (t) => pnpmLock().fileDeps(pnpmParse(t)),
|
|
115
|
-
recorded: (t) =>
|
|
117
|
+
recorded: (t) => pnpmLock().recordedImporters(t),
|
|
116
118
|
settings: ['pnpm-workspace.yaml', '.npmrc'],
|
|
117
119
|
settingsText: (file, text) =>
|
|
118
|
-
file === 'pnpm-workspace.yaml' ?
|
|
120
|
+
file === 'pnpm-workspace.yaml' ? pnpmSettings().workspaceSettings(text) : undefined,
|
|
119
121
|
peersInManifest: true,
|
|
120
122
|
},
|
|
121
123
|
bun: {
|
|
@@ -317,6 +319,11 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
317
319
|
},
|
|
318
320
|
async discover(ctx) {
|
|
319
321
|
const manifest = manifestReader(ctx.workspaceRoot)
|
|
322
|
+
// The staleness check's walk overlaps the lockfile read.
|
|
323
|
+
const dirs = workspaceGlobs(ctx.workspaceRoot, manager.name, manifest).then((globs) =>
|
|
324
|
+
workspaceDirs(ctx.workspaceRoot, globs),
|
|
325
|
+
)
|
|
326
|
+
dirs.catch(() => {})
|
|
320
327
|
const read = await readImporters(ctx, manifest)
|
|
321
328
|
if (read === undefined) return []
|
|
322
329
|
const { importers, file } = read
|
|
@@ -332,11 +339,8 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
332
339
|
const missing = await staleness(
|
|
333
340
|
ctx.workspaceRoot,
|
|
334
341
|
importers,
|
|
335
|
-
manager.recorded
|
|
336
|
-
await
|
|
337
|
-
ctx.workspaceRoot,
|
|
338
|
-
await workspaceGlobs(ctx.workspaceRoot, manager.name, manifest),
|
|
339
|
-
),
|
|
342
|
+
await recordedOnce(ctx.cacheDir, file, text, manager.recorded),
|
|
343
|
+
await dirs,
|
|
340
344
|
manifest,
|
|
341
345
|
)
|
|
342
346
|
if (missing.length > 0) {
|
|
@@ -375,6 +379,43 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
375
379
|
})
|
|
376
380
|
}
|
|
377
381
|
|
|
382
|
+
/**
|
|
383
|
+
* The dependency names each importer records, kept per lockfile content:
|
|
384
|
+
* parsing them (pnpm's importers block, ~2 ms on solid, and the parser's
|
|
385
|
+
* load) was on every run, and only the lockfile text decides them.
|
|
386
|
+
*/
|
|
387
|
+
async function recordedOnce(
|
|
388
|
+
cacheDir: string,
|
|
389
|
+
file: string,
|
|
390
|
+
text: string,
|
|
391
|
+
recorded: (text: string) => ReadonlyMap<string, ReadonlyMap<string, unknown>>,
|
|
392
|
+
): Promise<ReadonlyMap<string, ReadonlySet<string>>> {
|
|
393
|
+
const hash = `${RECORDED_VERSION}:${Bun.hash.xxHash3(text).toString(16)}`
|
|
394
|
+
const memo = path.join(cacheDir, 'lockfile-claims', `${file}.recorded.json`)
|
|
395
|
+
try {
|
|
396
|
+
const kept = (await Bun.file(memo).json()) as { hash: string; names: Record<string, string[]> }
|
|
397
|
+
if (kept.hash === hash) {
|
|
398
|
+
return new Map(Object.entries(kept.names).map(([i, names]) => [i, new Set(names)]))
|
|
399
|
+
}
|
|
400
|
+
} catch {
|
|
401
|
+
// None kept, or unreadable: parsed below.
|
|
402
|
+
}
|
|
403
|
+
const out = new Map([...recorded(text)].map(([i, deps]) => [i, new Set(deps.keys())]))
|
|
404
|
+
const names = Object.fromEntries([...out].map(([i, set]) => [i, [...set]]))
|
|
405
|
+
// Write-then-rename, so a concurrent run reads a whole memo or none. The
|
|
406
|
+
// memo is a speed-up: a failed write costs the next run a parse.
|
|
407
|
+
const tmp = `${memo}.tmp-${process.pid}-${Date.now()}`
|
|
408
|
+
try {
|
|
409
|
+
await mkdir(path.dirname(memo), { recursive: true })
|
|
410
|
+
await Bun.write(tmp, JSON.stringify({ hash, names }))
|
|
411
|
+
await rename(tmp, memo)
|
|
412
|
+
} catch {}
|
|
413
|
+
return out
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/** Bumped when what `recorded` returns for the same text changes. */
|
|
417
|
+
const RECORDED_VERSION = 1
|
|
418
|
+
|
|
378
419
|
/** Up to five names, then a count of the rest. */
|
|
379
420
|
function listed(names: readonly string[]): string {
|
|
380
421
|
return names.length <= 5
|
|
@@ -513,14 +554,18 @@ async function workspaceDirs(root: string, globs: readonly unknown[]): Promise<s
|
|
|
513
554
|
async function staleness(
|
|
514
555
|
root: string,
|
|
515
556
|
importers: ReadonlyMap<string, LockfileImporter>,
|
|
516
|
-
recorded: ReadonlyMap<string,
|
|
557
|
+
recorded: ReadonlyMap<string, ReadonlySet<string>>,
|
|
517
558
|
dirs: readonly string[],
|
|
518
559
|
manifest: (dir: string) => Promise<Record<string, unknown> | null>,
|
|
519
560
|
): Promise<string[]> {
|
|
520
561
|
const dirOf = (importer: string): string => (importer === '.' ? root : path.join(root, importer))
|
|
521
562
|
const missing = dirs.filter((d) => !importers.has(d)).map((d) => `the workspace ${d}`)
|
|
522
563
|
const names = new Set<string>()
|
|
523
|
-
|
|
564
|
+
const all = new Set([...importers.keys(), ...dirs])
|
|
565
|
+
// Read at once, then visited in order: the first unparsable one still
|
|
566
|
+
// names the refusal.
|
|
567
|
+
await Promise.allSettled([...all].map((d) => manifest(dirOf(d))))
|
|
568
|
+
for (const d of all) {
|
|
524
569
|
const n = (await manifest(dirOf(d)))?.['name']
|
|
525
570
|
if (typeof n === 'string') names.add(n)
|
|
526
571
|
}
|
package/src/json.ts
CHANGED
|
@@ -29,3 +29,12 @@ export function peerOnly(e: Json): ReadonlySet<string> {
|
|
|
29
29
|
export function record(v: unknown): Json | undefined {
|
|
30
30
|
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : undefined
|
|
31
31
|
}
|
|
32
|
+
|
|
33
|
+
/** JSON with sorted keys, so YAML key order cannot move a digest. */
|
|
34
|
+
export function stable(v: unknown): string {
|
|
35
|
+
return JSON.stringify(v, (_k, val: unknown) =>
|
|
36
|
+
val !== null && typeof val === 'object' && !Array.isArray(val)
|
|
37
|
+
? Object.fromEntries(Object.entries(val as Json).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
38
|
+
: val,
|
|
39
|
+
)
|
|
40
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// What every task folds from pnpm-workspace.yaml, apart from the lockfile
|
|
2
|
+
// parser: a warm run reads the settings on every key, and loading pnpm.ts
|
|
3
|
+
// for them cost ~1.5 ms a run.
|
|
4
|
+
import { record, stable, type Json } from './json.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* pnpm-workspace.yaml as every task folds it: without `patchedDependencies`
|
|
8
|
+
* and `overrides`, whose effect the lockfile records per project (pnpm 10+
|
|
9
|
+
* keeps them here). Undefined for a file that does not parse to a mapping,
|
|
10
|
+
* folded whole.
|
|
11
|
+
*/
|
|
12
|
+
export function workspaceSettings(text: string): string | undefined {
|
|
13
|
+
let doc: unknown
|
|
14
|
+
try {
|
|
15
|
+
doc = Bun.YAML.parse(text)
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
const map = record(doc)
|
|
20
|
+
if (map === undefined) return undefined
|
|
21
|
+
const rest: Json = {}
|
|
22
|
+
for (const [k, v] of Object.entries(map)) {
|
|
23
|
+
if (k !== 'patchedDependencies' && k !== 'overrides') rest[k] = v
|
|
24
|
+
}
|
|
25
|
+
return `pnpm-workspace\0${stable(rest)}`
|
|
26
|
+
}
|
package/src/pnpm.ts
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
// Bun.YAML is the parser: no dependency, and the file is plain YAML.
|
|
13
13
|
|
|
14
14
|
import { reachDigests, type LockfileImporter } from '@vzn/vx'
|
|
15
|
-
import { record } from './json.js'
|
|
15
|
+
import { record, stable } from './json.js'
|
|
16
16
|
import { joinPosix } from './posix.js'
|
|
17
17
|
|
|
18
18
|
export interface Lockfile {
|
|
@@ -122,6 +122,34 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
122
122
|
return { version, importers, snapshots, resolutions, patches, directories, global }
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
/**
|
|
126
|
+
* The importers' dependencies alone, as `parseLockfile` reads them. The
|
|
127
|
+
* staleness check asks for them on every run, after the digest memo spared
|
|
128
|
+
* the parse: solid's 6,900-line lockfile took 8 ms to parse whole and
|
|
129
|
+
* 0.4 ms for its `importers` block. Sliced from the last document to the
|
|
130
|
+
* next top-level key; any other shape is parsed whole.
|
|
131
|
+
*/
|
|
132
|
+
export function recordedImporters(text: string): ReadonlyMap<string, ReadonlyMap<string, string>> {
|
|
133
|
+
const doc = Math.max(text.lastIndexOf('\n---'), 0)
|
|
134
|
+
const at = text.indexOf('\nimporters:\n', doc)
|
|
135
|
+
const block = at < 0 ? undefined : text.slice(at + 1)
|
|
136
|
+
const end = block === undefined ? null : /\n\S/.exec(block.slice('importers:\n'.length))
|
|
137
|
+
const rawImporters =
|
|
138
|
+
block === undefined
|
|
139
|
+
? undefined
|
|
140
|
+
: record(
|
|
141
|
+
record(
|
|
142
|
+
Bun.YAML.parse(
|
|
143
|
+
end === null ? block : block.slice(0, 'importers:\n'.length + end.index + 1),
|
|
144
|
+
),
|
|
145
|
+
)?.['importers'],
|
|
146
|
+
)
|
|
147
|
+
if (rawImporters === undefined) return parseLockfile(text).importers
|
|
148
|
+
const importers = new Map<string, ReadonlyMap<string, string>>()
|
|
149
|
+
for (const [dir, entry] of Object.entries(rawImporters)) importers.set(dir, depsOf(entry))
|
|
150
|
+
return importers
|
|
151
|
+
}
|
|
152
|
+
|
|
125
153
|
/** `dependencies` + `devDependencies` + `optionalDependencies` of an entry, as name → version. */
|
|
126
154
|
function depsOf(entry: unknown): ReadonlyMap<string, string> {
|
|
127
155
|
const out = new Map<string, string>()
|
|
@@ -153,37 +181,6 @@ function scalar(v: unknown): string {
|
|
|
153
181
|
: ''
|
|
154
182
|
}
|
|
155
183
|
|
|
156
|
-
/**
|
|
157
|
-
* pnpm-workspace.yaml as every task folds it: without `patchedDependencies`
|
|
158
|
-
* and `overrides`, whose effect the lockfile records per project (pnpm 10+
|
|
159
|
-
* keeps them here). Undefined for a file that does not parse to a mapping,
|
|
160
|
-
* folded whole.
|
|
161
|
-
*/
|
|
162
|
-
export function workspaceSettings(text: string): string | undefined {
|
|
163
|
-
let doc: unknown
|
|
164
|
-
try {
|
|
165
|
-
doc = Bun.YAML.parse(text)
|
|
166
|
-
} catch {
|
|
167
|
-
return undefined
|
|
168
|
-
}
|
|
169
|
-
const map = record(doc)
|
|
170
|
-
if (map === undefined) return undefined
|
|
171
|
-
const rest: Yaml = {}
|
|
172
|
-
for (const [k, v] of Object.entries(map)) {
|
|
173
|
-
if (k !== 'patchedDependencies' && k !== 'overrides') rest[k] = v
|
|
174
|
-
}
|
|
175
|
-
return `pnpm-workspace\0${stable(rest)}`
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
/** JSON with sorted keys, so YAML key order cannot move a digest. */
|
|
179
|
-
export function stable(v: unknown): string {
|
|
180
|
-
return JSON.stringify(v, (_k, val: unknown) =>
|
|
181
|
-
val !== null && typeof val === 'object' && !Array.isArray(val)
|
|
182
|
-
? Object.fromEntries(Object.entries(val as Yaml).sort(([a], [b]) => (a < b ? -1 : 1)))
|
|
183
|
-
: val,
|
|
184
|
-
)
|
|
185
|
-
}
|
|
186
|
-
|
|
187
184
|
/**
|
|
188
185
|
* The snapshot key a dependency `name@version` resolves to, per lockfile
|
|
189
186
|
* generation: `name@1.0.0(peer@2)` (v9), `/name@1.0.0(peer@2)` (v6),
|
package/src/yarn.ts
CHANGED
|
@@ -12,8 +12,7 @@
|
|
|
12
12
|
// one digest is the root's reach through every entry the file resolves.
|
|
13
13
|
|
|
14
14
|
import { reachDigests, type LockfileImporter } from '@vzn/vx'
|
|
15
|
-
import { stable } from './
|
|
16
|
-
import { depsOf, record } from './json.js'
|
|
15
|
+
import { depsOf, record, stable } from './json.js'
|
|
17
16
|
import { joinPosix } from './posix.js'
|
|
18
17
|
|
|
19
18
|
export interface Lockfile {
|