@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2026 vx contributors
3
+ Copyright (c) 2026 VZN
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
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/): `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.
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
- npm install -D @vzn/vx @vzn/vx-lockfile # or: pnpm add -D -w · yarn add -D (-W on Yarn 1) · bun add -d
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.644",
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.644"
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) => pnpmParse(t).importers,
117
+ recorded: (t) => pnpmLock().recordedImporters(t),
116
118
  settings: ['pnpm-workspace.yaml', '.npmrc'],
117
119
  settingsText: (file, text) =>
118
- file === 'pnpm-workspace.yaml' ? pnpmLock().workspaceSettings(text) : undefined,
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(text),
336
- await workspaceDirs(
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, ReadonlyMap<string, unknown>>,
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
- for (const d of new Set([...importers.keys(), ...dirs])) {
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 './pnpm.js'
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 {