@vzn/vx-lockfile 0.0.639 → 0.0.640

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
@@ -56,7 +56,7 @@ A file has one claimant, so two of these plugins that share a settings file (`pn
56
56
 
57
57
  ## `package.json` as an input
58
58
 
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' }`.
59
+ 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 `inputStability: { strategy: 'mutable' }`.
60
60
 
61
61
  ## Project edges
62
62
 
@@ -65,7 +65,8 @@ Every cached task of a project the lockfile lists gets its literal `package.json
65
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
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
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
+ - A yarn classic (v1) lockfile records no workspaces, so `yarn()` reads them as Yarn 1 does: the directories the root `package.json` `workspaces` globs (an array or `{ packages }`) name. A dependency on a workspace's name is a link unless the lockfile keys its descriptor, which Yarn 1 writes only for the registry copy it fetched because the workspace's version missed the range.
69
+ - A workspace the root `package.json` `workspaces` names that `bun()`, `npm()` or berry `yarn()` finds no importer for (added after the last install) is a warning at `vx lock` naming the install that adds it: until then core names it by its path, so a `dependsOn` naming it finds no project.
69
70
 
70
71
  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.
71
72
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-lockfile",
3
- "version": "0.0.639",
3
+ "version": "0.0.640",
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.639"
38
+ "@vzn/vx": "^0.0.640"
39
39
  },
40
40
  "repository": {
41
41
  "type": "git",
package/src/index.ts CHANGED
@@ -69,8 +69,16 @@ interface Manager {
69
69
  readonly settings: readonly string[]
70
70
  /** The lockfile files a peer under `dependencies`: the manifest says which entries are peers. */
71
71
  readonly peersInManifest?: true
72
- /** Warned when the file records no importer at all, which only this format can. */
73
- readonly noImporters?: () => string
72
+ /**
73
+ * Importers a format leaves out of the lockfile (yarn classic), from the
74
+ * workspace's manifests through `manifest`; undefined where the file
75
+ * records them itself.
76
+ */
77
+ readonly manifestImporters?: (
78
+ text: string,
79
+ workspaceRoot: string,
80
+ manifest: (dir: string) => Promise<Record<string, unknown> | null>,
81
+ ) => Promise<ReadonlyMap<string, LockfileImporter> | undefined>
74
82
  }
75
83
 
76
84
  /**
@@ -127,7 +135,18 @@ const MANAGERS = {
127
135
  digest: (t) => yarnLock().importerDigests(yarnParse(t)),
128
136
  importers: (t) => yarnLock().importerLinks(yarnParse(t)),
129
137
  settings: ['.yarnrc.yml'],
130
- noImporters: () => `yarn.lock${yarnLock().CLASSIC_EDGES}`,
138
+ async manifestImporters(t, root, manifest) {
139
+ const lock = yarnParse(t)
140
+ if (lock.generation !== 'classic') return undefined
141
+ const pkg = await manifest(root)
142
+ const manifests = new Map<string, Record<string, unknown>>()
143
+ if (pkg !== null) manifests.set('.', pkg)
144
+ for (const dir of await workspaceDirs(root, pkg)) {
145
+ const m = await manifest(path.join(root, dir))
146
+ if (m !== null) manifests.set(dir, m)
147
+ }
148
+ return yarnLock().classicLinks(lock, manifests)
149
+ },
131
150
  },
132
151
  } satisfies Record<string, Manager>
133
152
 
@@ -196,13 +215,16 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
196
215
  `${unreadable.file}: ${manager.name}() reads ${manager.files.join(' or ')} and finds none — ${unreadable.fix}`,
197
216
  )
198
217
  }
199
- const importersOf = async (ctx: {
200
- readonly workspaceRoot: string
201
- readonly cacheDir: string
202
- }): Promise<ReadonlyMap<string, LockfileImporter> | undefined> => {
203
- for (const { claim } of claims) {
218
+ const importersOf = async (
219
+ ctx: { readonly workspaceRoot: string; readonly cacheDir: string },
220
+ manifest: (dir: string) => Promise<Record<string, unknown> | null>,
221
+ ): Promise<ReadonlyMap<string, LockfileImporter> | undefined> => {
222
+ for (const { file, claim } of claims) {
204
223
  const importers = await claim.importers(ctx)
205
- if (importers !== undefined) return importers
224
+ if (importers === undefined) continue
225
+ if (importers.size > 0 || manager.manifestImporters === undefined) return importers
226
+ const text = await Bun.file(path.join(ctx.workspaceRoot, file)).text()
227
+ return (await manager.manifestImporters(text, ctx.workspaceRoot, manifest)) ?? importers
206
228
  }
207
229
  await refuseUnreadable(ctx.workspaceRoot)
208
230
  return undefined
@@ -246,41 +268,66 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
246
268
  : { ...deps, ...folded }
247
269
  },
248
270
  async discover(ctx) {
249
- const importers = await importersOf(ctx)
271
+ const manifest = manifestReader()
272
+ const importers = await importersOf(ctx, manifest)
250
273
  if (importers === undefined) return []
251
- if (importers.size === 0 && manager.noImporters !== undefined) ctx.warn(manager.noImporters())
274
+ // A workspace the root manifest names and the lockfile lacks is no
275
+ // project until an install adds it: lerna's 31 added Nx projects
276
+ // failed \`vx lock\` as "no such project" and nothing said why
277
+ // (Growth, 2026-10-16). pnpm reads pnpm-workspace.yaml instead.
278
+ if (manager.name !== 'pnpm') {
279
+ const missing = (
280
+ await workspaceDirs(ctx.workspaceRoot, await manifest(ctx.workspaceRoot))
281
+ ).filter((d) => !importers.has(d))
282
+ if (missing.length > 0) {
283
+ let file = manager.files[0]!
284
+ for (const f of manager.files)
285
+ if (await Bun.file(path.join(ctx.workspaceRoot, f)).exists()) {
286
+ file = f
287
+ break
288
+ }
289
+ ctx.warn(
290
+ `${file} does not list the workspace${missing.length > 1 ? 's' : ''} ${listed(missing)} that package.json names — run \`${manager.name} install\` to add ${missing.length > 1 ? 'them; until then each is' : 'it; until then it is'} named by its path, and a dependsOn naming it finds no project`,
291
+ )
292
+ }
293
+ }
252
294
  const rootIsProject = ctx.projects.some((p) => importerOf(ctx.workspaceRoot, p.dir) === '.')
253
295
  return await discovered(
254
296
  ctx.workspaceRoot,
255
297
  importers,
256
298
  manager.peersInManifest === true,
257
299
  rootIsProject,
300
+ manifest,
258
301
  )
259
302
  },
260
303
  async project(config, ctx) {
261
304
  let importers = perRun.get(ctx.projects)
262
305
  if (importers === undefined) {
263
- importers = importersOf(ctx)
306
+ importers = importersOf(ctx, manifestReader())
264
307
  perRun.set(ctx.projects, importers)
265
308
  }
266
309
  if ((await importers)?.has(importerOf(ctx.workspaceRoot, ctx.dir)) !== true) return
267
310
  const tasks = config.tasks ?? {}
268
311
  for (const [name, task] of Object.entries(tasks)) {
269
- const inputs = task.cache?.inputs
312
+ const inputs = task.inputs
270
313
  if (inputs === undefined || inputs.files.includes('package.json')) continue
271
314
  // A copy: a loaded config may be shared or frozen.
272
315
  tasks[name] = {
273
316
  ...task,
274
- cache: {
275
- ...task.cache!,
276
- inputs: { ...inputs, files: [...inputs.files, 'package.json'] },
277
- },
317
+ inputs: { ...inputs, files: [...inputs.files, 'package.json'] },
278
318
  }
279
319
  }
280
320
  },
281
321
  })
282
322
  }
283
323
 
324
+ /** Up to five names, then a count of the rest. */
325
+ function listed(names: readonly string[]): string {
326
+ return names.length <= 5
327
+ ? names.join(', ')
328
+ : `${names.slice(0, 5).join(', ')} and ${names.length - 5} more`
329
+ }
330
+
284
331
  /** Each present settings file's content hash, by name. */
285
332
  async function readSettings(
286
333
  workspaceRoot: string,
@@ -316,6 +363,44 @@ async function manifestOf(dir: string): Promise<Record<string, unknown> | null>
316
363
  }
317
364
  }
318
365
 
366
+ /** `manifestOf`, each directory read once. */
367
+ function manifestReader(): (dir: string) => Promise<Record<string, unknown> | null> {
368
+ const read = new Map<string, Promise<Record<string, unknown> | null>>()
369
+ return (dir) => {
370
+ let pkg = read.get(dir)
371
+ if (pkg === undefined) {
372
+ pkg = manifestOf(dir)
373
+ read.set(dir, pkg)
374
+ }
375
+ return pkg
376
+ }
377
+ }
378
+
379
+ /**
380
+ * The directories a root manifest's `workspaces` globs (an array, or
381
+ * Yarn 1's `{ packages }`) name that hold a `package.json`, root-relative
382
+ * POSIX, sorted; none under `node_modules`.
383
+ */
384
+ async function workspaceDirs(root: string, pkg: Record<string, unknown> | null): Promise<string[]> {
385
+ const field = pkg?.['workspaces']
386
+ const list = Array.isArray(field)
387
+ ? field
388
+ : field !== null && typeof field === 'object'
389
+ ? (field as Record<string, unknown>)['packages']
390
+ : undefined
391
+ if (!Array.isArray(list)) return []
392
+ const dirs = new Set<string>()
393
+ for (const pattern of list) {
394
+ if (typeof pattern !== 'string' || pattern.startsWith('!')) continue
395
+ const glob = new Bun.Glob(`${pattern.replace(/^\.\//, '').replace(/\/+$/, '')}/package.json`)
396
+ for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
397
+ const dir = path.dirname(file).split(path.sep).join('/')
398
+ if (dir !== '.' && !dir.split('/').includes('node_modules')) dirs.add(dir)
399
+ }
400
+ }
401
+ return [...dirs].sort()
402
+ }
403
+
319
404
  /**
320
405
  * One entry per importer with a `package.json` (core reads none): a
321
406
  * directory that is no project yet becomes one, the root aside, which is
@@ -332,12 +417,13 @@ async function discovered(
332
417
  importers: ReadonlyMap<string, LockfileImporter>,
333
418
  peersInManifest: boolean,
334
419
  rootIsProject: boolean,
420
+ manifest: (dir: string) => Promise<Record<string, unknown> | null>,
335
421
  ): Promise<DiscoveredProject[]> {
336
422
  const dirOf = (importer: string): string => (importer === '.' ? root : path.join(root, importer))
337
423
  const read = await Promise.all(
338
424
  [...importers.keys()]
339
425
  .filter((i) => i !== '.' || rootIsProject)
340
- .map(async (i) => [i, await manifestOf(dirOf(i))] as const),
426
+ .map(async (i) => [i, await manifest(dirOf(i))] as const),
341
427
  )
342
428
  const manifests = new Map<string, Record<string, unknown>>()
343
429
  for (const [i, pkg] of read) if (pkg !== null) manifests.set(i, pkg)
@@ -443,7 +529,7 @@ export function npm(options: LockfileOptions = {}): VxPlugin {
443
529
  return plugin(MANAGERS.npm, options)
444
530
  }
445
531
 
446
- /** `yarn.lock`: berry (yarn 2+, per workspace) and classic (yarn 1, one digest for the root). */
532
+ /** `yarn.lock`: berry (yarn 2+, per workspace) and classic (yarn 1: one digest for the root, links from the workspaces' manifests). */
447
533
  export function yarn(options: LockfileOptions = {}): VxPlugin {
448
534
  refuseUnknownOptions('yarn()', options, LOCKFILE_KEYS)
449
535
  return plugin(MANAGERS.yarn, options)
package/src/yarn.ts CHANGED
@@ -6,20 +6,16 @@
6
6
  // dependency `bar: "npm:^2"` resolves to the entry keyed `bar@npm:^2`.
7
7
  // Classic (yarn 1) is its own text format: `"foo@^1.0.0", "foo@^1.2.0":`
8
8
  // then indented `version`, `resolved`, `integrity` and `dependencies:`
9
- // with `bar "^2"` lines; classic has no workspace entries, so workspaces
10
- // resolve from their own `package.json` — which the digest reads from the
11
- // lockfile's root descriptors only, so a workspace's digest is the root's
12
- // reach through the descriptors the file resolves.
9
+ // with `bar "^2"` lines; classic has no workspace entries: Yarn 1 links a
10
+ // dependency on a workspace's name and keys no entry for it, so its links
11
+ // come from the workspaces' own `package.json` (`classicLinks`), and its
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
15
  import { stable } from './pnpm.js'
16
16
  import { depsOf, record } from './json.js'
17
17
  import { joinPosix } from './posix.js'
18
18
 
19
- /** What yarn() warns of a classic lockfile, after its name: the static part a test holds. */
20
- export const CLASSIC_EDGES =
21
- ': yarn classic (v1) records no workspaces, so yarn() gives vx no project edges — upgrade to Yarn 2+ (`yarn set version stable`)'
22
-
23
19
  export interface Lockfile {
24
20
  readonly generation: 'berry' | 'classic'
25
21
  /** entry id (the resolution, or the first descriptor) → entry */
@@ -197,6 +193,50 @@ function unquote(s: string): string {
197
193
  return s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s
198
194
  }
199
195
 
196
+ /**
197
+ * Classic's links: Yarn 1 records no workspace, so they come from each
198
+ * workspace's manifest (`manifests`: importer dir → `package.json`, `.`
199
+ * the root). A dependency on a workspace's name is linked unless the file
200
+ * keys its descriptor, which Yarn writes only for the registry copy it
201
+ * fetched when the workspace's version misses the range. A peer on a
202
+ * workspace with no installed entry of that name is a peer link.
203
+ */
204
+ export function classicLinks(
205
+ lock: Lockfile,
206
+ manifests: ReadonlyMap<string, Record<string, unknown>>,
207
+ ): ReadonlyMap<string, LockfileImporter> {
208
+ const count = new Map<string, number>()
209
+ const dirOf = new Map<string, string>()
210
+ for (const [dir, pkg] of manifests) {
211
+ const n = pkg['name']
212
+ if (typeof n !== 'string' || n === '') continue
213
+ count.set(n, (count.get(n) ?? 0) + 1)
214
+ dirOf.set(n, dir)
215
+ }
216
+ const workspace = (name: string): string | undefined =>
217
+ count.get(name) === 1 ? dirOf.get(name) : undefined
218
+ const out = new Map<string, LockfileImporter>()
219
+ for (const [dir, pkg] of manifests) {
220
+ const links = { deps: {} as Record<string, string>, peers: {} as Record<string, string> }
221
+ const installed = new Set<string>()
222
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
223
+ for (const [name, range] of depsOf(pkg, [field])) {
224
+ installed.add(name)
225
+ const target = workspace(name)
226
+ if (target !== undefined && target !== dir && !lock.descriptors.has(`${name}@${range}`)) {
227
+ links.deps[name] = target
228
+ }
229
+ }
230
+ }
231
+ for (const name of depsOf(pkg, ['peerDependencies']).keys()) {
232
+ const target = workspace(name)
233
+ if (target !== undefined && target !== dir && !installed.has(name)) links.peers[name] = target
234
+ }
235
+ out.set(dir, links)
236
+ }
237
+ return out
238
+ }
239
+
200
240
  /**
201
241
  * `bar` + `npm:^2` → the entry keyed `bar@npm:^2`; classic: `bar@^2`. A
202
242
  * bare range gets berry's `npm:` prefix. A `workspace:` range resolves to
@@ -256,7 +296,7 @@ function resolveDescriptor(
256
296
  * workspace of that name, which Yarn hoists whatever the range. Only a
257
297
  * descriptor the file keys counts: the name fallbacks the digest takes
258
298
  * (a catalog, an override) name no link. Classic records no workspaces,
259
- * so it has none.
299
+ * so it has none here (`classicLinks`).
260
300
  */
261
301
  export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
262
302
  const out = new Map<string, LockfileImporter>()