@vzn/vx-lockfile 0.0.645 → 0.0.648

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/src/index.ts CHANGED
@@ -17,6 +17,8 @@
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 { readdirSync, readFileSync } from 'node:fs'
21
+ import { mkdir, rename, rm } from 'node:fs/promises'
20
22
  import { createRequire } from 'node:module'
21
23
  import path from 'node:path'
22
24
  import {
@@ -29,6 +31,7 @@ import {
29
31
  type ProjectMeta,
30
32
  definePlugin,
31
33
  lockfileClaim,
34
+ reachDigests,
32
35
  type VxPlugin,
33
36
  } from '@vzn/vx'
34
37
 
@@ -41,6 +44,8 @@ const pnpmLock = (): typeof import('./pnpm.js') => load('./pnpm.js')
41
44
  const bunLock = (): typeof import('./bun.js') => load('./bun.js')
42
45
  const npmLock = (): typeof import('./npm.js') => load('./npm.js')
43
46
  const yarnLock = (): typeof import('./yarn.js') => load('./yarn.js')
47
+ const pnpmSettings = (): typeof import('./pnpm-settings.js') => load('./pnpm-settings.js')
48
+ const yarnSettings = (): typeof import('./yarn-settings.js') => load('./yarn-settings.js')
44
49
 
45
50
  export interface LockfileOptions {
46
51
  /**
@@ -54,7 +59,38 @@ export interface LockfileOptions {
54
59
  }
55
60
 
56
61
  /** Bumps when a digest folds differently or the memo holds more (its identity). */
57
- const DIGEST_VERSION = 18
62
+ const DIGEST_VERSION = 23
63
+
64
+ let memoVersion: number | undefined
65
+ /**
66
+ * The memo's version: DIGEST_VERSION and the parsers' own source, with
67
+ * what core's `reachDigests` answers. The closures core sees wrap every parser alike,
68
+ * so a parser fix that forgot the bump replayed the digests the old one
69
+ * folded. Read once per process (~40 µs for the ten files); a plugin with
70
+ * no source beside it keys on DIGEST_VERSION alone.
71
+ */
72
+ function digestVersion(): number {
73
+ if (memoVersion !== undefined) return memoVersion
74
+ // Core's fold by what it answers, not its text: a cycle's labels live in
75
+ // a helper it calls. The probe has a chain, a cycle, a self-loop and two
76
+ // copies of one material in and out of the cycle, so each branch shows.
77
+ let h = Bun.hash.xxHash3(
78
+ reachDigests({
79
+ material: ['a', 'b', 'x', 'x', 'y', 'z', 'x'],
80
+ edges: [[1, 2, 6], [0, 3], [0, 4], [5], [4], [], [0, 5]],
81
+ }).join('\0'),
82
+ )
83
+ try {
84
+ for (const f of readdirSync(import.meta.dir).sort()) {
85
+ if (/\.[cm]?[jt]s$/.test(f))
86
+ h = Bun.hash.xxHash3(readFileSync(path.join(import.meta.dir, f)), h) ^ h
87
+ }
88
+ } catch {
89
+ // Bundled: the source is not on disk.
90
+ }
91
+ memoVersion = DIGEST_VERSION * 2 ** 32 + Number(h & 0xffffffffn)
92
+ return memoVersion
93
+ }
58
94
 
59
95
  interface Manager {
60
96
  readonly name: string
@@ -62,11 +98,19 @@ interface Manager {
62
98
  readonly files: readonly [string, ...string[]]
63
99
  /** A lockfile vx cannot parse, refused when none of `files` is present beside it. */
64
100
  readonly unreadable?: { readonly file: string; readonly fix: string }
65
- readonly digest: (text: string, files: ReadonlyMap<string, string>) => ReadonlyMap<string, string>
101
+ /** `root`: the workspace root, for a settings file the digest reads (yarn's `packageExtensions`). */
102
+ readonly digest: (
103
+ text: string,
104
+ files: ReadonlyMap<string, string>,
105
+ root: string,
106
+ ) => ReadonlyMap<string, string>
66
107
  readonly importers: (text: string) => ReadonlyMap<string, LockfileImporter>
67
108
  /** Each importer's dependency names the file records, whatever they resolve to. */
68
109
  readonly recorded: (text: string) => ReadonlyMap<string, ReadonlyMap<string, unknown>>
69
- readonly extraFiles?: (text: string) => readonly string[]
110
+ /** `root` is the workspace's when the key reads them, undefined under `--affected`. */
111
+ readonly extraFiles?: (text: string, root: string | undefined) => readonly string[]
112
+ /** Dirs the manager's workspace walk never enters (default `node_modules`). */
113
+ readonly skippedDirs?: readonly string[]
70
114
  /** Root files no lockfile records that lay the install out: folded whole into every task. */
71
115
  readonly settings: readonly string[]
72
116
  /**
@@ -74,8 +118,22 @@ interface Manager {
74
118
  * records part of what it says per project; undefined folds them whole.
75
119
  */
76
120
  readonly settingsText?: (file: string, text: string) => string | undefined
121
+ /**
122
+ * Whether the manager lists a manifest its workspace globs match;
123
+ * undefined lists every one.
124
+ */
125
+ readonly member?: (pkg: Record<string, unknown>) => boolean
77
126
  /** The lockfile files a peer under `dependencies`: the manifest says which entries are peers. */
78
127
  readonly peersInManifest?: true
128
+ /** The file leaves out every `link:` dependency (pnpm's `excludeLinksFromLockfile`): the manifests name them. */
129
+ readonly linksUnrecorded?: (text: string) => boolean
130
+ /**
131
+ * A manifest dependency the install leaves out of the file by design, so
132
+ * its absence is no stale lockfile.
133
+ */
134
+ readonly unrecorded?: (text: string) => (name: string, pkg: Record<string, unknown>) => boolean
135
+ /** The manager reads package.json as JSONC (trailing commas, comments): bun does. */
136
+ readonly jsoncManifests?: true
79
137
  /**
80
138
  * Importers a format leaves out of the lockfile (yarn classic), from the
81
139
  * workspace's manifests through `manifest`; undefined where the file
@@ -112,11 +170,22 @@ const MANAGERS = {
112
170
  digest: (t, files) => pnpmLock().importerDigests(pnpmParse(t), files),
113
171
  importers: (t) => pnpmLock().importerLinks(pnpmParse(t)),
114
172
  extraFiles: (t) => pnpmLock().fileDeps(pnpmParse(t)),
115
- recorded: (t) => pnpmParse(t).importers,
116
- settings: ['pnpm-workspace.yaml', '.npmrc'],
173
+ recorded: (t) => pnpmLock().recordedImporters(t),
174
+ // pnpm's walk skips bower_components as it does node_modules (pnpm 10).
175
+ skippedDirs: ['node_modules', 'bower_components'],
176
+ // The root package.json for its `pnpm` field: pnpm reads install
177
+ // settings there that the lockfile does not record.
178
+ settings: ['pnpm-workspace.yaml', '.npmrc', 'package.json'],
117
179
  settingsText: (file, text) =>
118
- file === 'pnpm-workspace.yaml' ? pnpmLock().workspaceSettings(text) : undefined,
180
+ file === 'pnpm-workspace.yaml'
181
+ ? pnpmSettings().workspaceSettings(text)
182
+ : file === 'package.json'
183
+ ? pnpmSettings().packageSettings(text)
184
+ : undefined,
119
185
  peersInManifest: true,
186
+ // Read from their blocks alone: a whole parse here was on every warm run.
187
+ linksUnrecorded: (t) => pnpmLock().excludesLinks(t),
188
+ unrecorded: (t) => pnpmLock().ignoredOptional(t),
120
189
  },
121
190
  bun: {
122
191
  name: 'bun',
@@ -130,8 +199,12 @@ const MANAGERS = {
130
199
  digest: (t, files) => bunLock().importerDigests(bunParse(t), files),
131
200
  importers: (t) => bunLock().importerLinks(bunParse(t)),
132
201
  recorded: (t) => bunParse(t).workspaces,
202
+ // Bun leaves out a manifest with no name, as build output's
203
+ // `{"type":"module"}` (probed on 1.4.4); no version is listed.
204
+ member: (pkg) => typeof pkg['name'] === 'string' && pkg['name'] !== '',
133
205
  extraFiles: (t) => [...bunLock().patchFiles(t), ...bunLock().fileDeps(bunParse(t))],
134
206
  settings: ['bunfig.toml', '.npmrc'],
207
+ jsoncManifests: true,
135
208
  },
136
209
  npm: {
137
210
  name: 'npm',
@@ -152,23 +225,54 @@ const MANAGERS = {
152
225
  yarn: {
153
226
  name: 'yarn',
154
227
  files: ['yarn.lock'],
155
- digest: (t, files) => yarnLock().importerDigests(yarnParse(t), files),
228
+ digest: (t, files, root) => {
229
+ const lock = yarnParse(t)
230
+ // The working tree's file on both sides of an `--affected` diff: a
231
+ // changed one is a settings change, which selects every project.
232
+ const rc =
233
+ lock.generation === 'berry' ? readText(path.join(root, yarnLock().YARNRC)) : undefined
234
+ return yarnLock().importerDigests(lock, files, yarnLock().packageExtensions(rc))
235
+ },
156
236
  importers: (t) => yarnLock().importerLinks(yarnParse(t)),
157
- extraFiles: (t) => yarnLock().fileDeps(yarnParse(t)),
237
+ extraFiles: (t, root) => {
238
+ // Classic keys a `link:` to a workspace like any linked directory,
239
+ // and records no workspace to tell them apart. Yarn 1 links the
240
+ // workspace itself (an edge, from the manifests), and its content
241
+ // folded into the one digest every task folds re-keyed the whole
242
+ // repo on each edit under it, its own build output included. A
243
+ // `file:` one is a copy, so its content stays.
244
+ // Berry's file names its workspaces, and contentDirs reads them there.
245
+ const lock = yarnParse(t)
246
+ const linked =
247
+ root === undefined || lock.generation !== 'classic'
248
+ ? undefined
249
+ : (d: string) => isClassicWorkspace(root, d)
250
+ return yarnLock().fileDeps(lock, linked)
251
+ },
158
252
  recorded: (t) => {
159
253
  const lock = yarnParse(t)
160
254
  const out = new Map<string, ReadonlyMap<string, unknown>>()
161
255
  for (const [dir, id] of lock.workspaces) out.set(dir, lock.entries.get(id)!.deps)
162
256
  return out
163
257
  },
164
- settings: ['.yarnrc.yml'],
258
+ // `.yarnrc` is Yarn 1's: `ignore-scripts true` there changes the install
259
+ // under an unchanged yarn.lock (probed, yarn 1.22). Yarn 1 reads an
260
+ // option `.yarnrc` leaves unset from `.npmrc`: `production=true` there
261
+ // dropped every devDependency (probed, yarn 1.22).
262
+ // The root package.json for `workspaces.nohoist` and `installConfig`,
263
+ // which lay the install out and the lockfile does not record.
264
+ settings: ['.yarnrc.yml', '.yarnrc', '.npmrc', 'package.json'],
265
+ settingsText: (file, text) =>
266
+ file === 'package.json' ? yarnSettings().packageSettings(text) : undefined,
165
267
  async manifestImporters(t, root, manifest) {
166
268
  const lock = yarnParse(t)
167
269
  if (lock.generation !== 'classic') return undefined
168
270
  const pkg = await manifest(root)
169
271
  const manifests = new Map<string, Record<string, unknown>>()
170
272
  if (pkg !== null) manifests.set('.', pkg)
171
- for (const dir of await workspaceDirs(root, globsOf(pkg))) {
273
+ // Yarn 1 takes no `!` glob: it links the directory anyway.
274
+ const globs = globsOf(pkg).filter((g) => typeof g !== 'string' || !g.startsWith('!'))
275
+ for (const dir of await workspaceDirs(root, globs)) {
172
276
  const m = await manifest(path.join(root, dir))
173
277
  if (m !== null) manifests.set(dir, m)
174
278
  }
@@ -183,11 +287,11 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
183
287
  const scope = options.scope ?? 'project'
184
288
  if (scope !== 'project' && scope !== 'workspace') {
185
289
  throw new UserError(
186
- `${manager.name}() scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`,
290
+ `${manager.name}() option "scope" must be 'project' or 'workspace', got ${JSON.stringify(scope)}`,
187
291
  )
188
292
  }
189
293
  const extraFiles = manager.extraFiles
190
- const claimOf = (file: string): LockfileClaimHooks => {
294
+ const guardedFor = (file: string) => {
191
295
  // The parsers name their own file, so prefixing unconditionally said it
192
296
  // twice — "bun.lock: bun.lock: Failed to parse JSONC" (2026-09-20).
193
297
  // Prefix only what does not already name it, and say what fixes it: a
@@ -199,39 +303,58 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
199
303
  const named = message.startsWith(`${file}:`) ? message : `${file}: ${message}`
200
304
  return new Error(`${named} — regenerate it with \`${manager.name} install\``)
201
305
  }
202
- const guarded =
203
- <A extends unknown[], R>(f: (...args: A) => R) =>
306
+ // Every reader takes the text first. A conflicted lockfile names two
307
+ // installs at once (yarn classic's line parser took the second side
308
+ // without a word), so each refuses it first: checked in the digest and
309
+ // edges alone, the extra files read before them refused pnpm, npm and
310
+ // bun lockfiles with a bare parse error.
311
+ return <A extends [string, ...unknown[]], R>(f: (...args: A) => R) =>
204
312
  (...args: A): R => {
205
313
  try {
314
+ if (CONFLICT.test(args[0])) throw new Error(`${file}: holds git merge conflict markers`)
206
315
  return f(...args)
207
316
  } catch (err) {
208
317
  throw refused(err)
209
318
  }
210
319
  }
320
+ }
321
+ const claimOf = (file: string, root: string, affected: boolean): LockfileClaimHooks => {
322
+ const guarded = guardedFor(file)
211
323
  return lockfileClaim({
212
324
  file,
213
325
  part: manager.name,
214
- version: DIGEST_VERSION,
326
+ version: digestVersion(),
215
327
  scope,
216
- importers: guarded((text: string) => {
217
- if (CONFLICT.test(text)) throw new Error(`${file}: holds git merge conflict markers`)
218
- return manager.importers(text)
219
- }),
328
+ importers: guarded((text: string) => manager.importers(text)),
220
329
  // Read before `digest` on the key path: a malformed bun.lock refused
221
330
  // there as a bare "JSONC Parse error", no file named (fuzzed, L-16).
222
- ...(extraFiles !== undefined ? { extraFiles: guarded(extraFiles) } : {}),
223
- digest: guarded((text: string, files: ReadonlyMap<string, string>) => {
224
- // A conflicted lockfile names two installs at once. Yarn classic's
225
- // line parser took the second side without a word, so the key named
226
- // an install that may not be the one on disk.
227
- if (CONFLICT.test(text)) throw new Error(`${file}: holds git merge conflict markers`)
228
- return manager.digest(text, files)
229
- }),
331
+ ...(extraFiles !== undefined
332
+ ? { extraFiles: guarded((text: string) => extraFiles(text, affected ? undefined : root)) }
333
+ : {}),
334
+ digest: guarded((text: string, files: ReadonlyMap<string, string>) =>
335
+ manager.digest(text, files, root),
336
+ ),
230
337
  })
231
338
  }
232
339
  // One part name for every file: `npm shrinkwrap` renames the lockfile,
233
- // and the same install keys the same.
234
- const claims = manager.files.map((file) => ({ file, claim: claimOf(file) }))
340
+ // and the same install keys the same. Per workspace root: a digest may
341
+ // read a root settings file. `--affected` reads its claims with no root
342
+ // for links: a path under a workspace is its project's change there,
343
+ // never a lockfile's.
344
+ const rooted = new Map<string, { file: string; claim: LockfileClaimHooks }[]>()
345
+ const rootless = new Map<string, { file: string; claim: LockfileClaimHooks }[]>()
346
+ const claimsAt = (
347
+ root: string,
348
+ affected = false,
349
+ ): { file: string; claim: LockfileClaimHooks }[] => {
350
+ const memo = affected ? rootless : rooted
351
+ let at = memo.get(root)
352
+ if (at === undefined) {
353
+ at = manager.files.map((file) => ({ file, claim: claimOf(file, root, affected) }))
354
+ memo.set(root, at)
355
+ }
356
+ return at
357
+ }
235
358
  const unreadable = manager.unreadable
236
359
  // With no lockfile it reads, a lockfile it cannot: the key would name no
237
360
  // install at all, so the run refuses rather than guessing.
@@ -249,7 +372,7 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
249
372
  ): Promise<
250
373
  { importers: ReadonlyMap<string, LockfileImporter>; file: string | undefined } | undefined
251
374
  > => {
252
- for (const { file, claim } of claims) {
375
+ for (const { file, claim } of claimsAt(ctx.workspaceRoot)) {
253
376
  const importers = await claim.importers(ctx)
254
377
  if (importers === undefined) continue
255
378
  if (importers.size > 0 || manager.manifestImporters === undefined) return { importers, file }
@@ -278,7 +401,7 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
278
401
  // A settings file lays out every project's install: every project,
279
402
  // unless all that moved is what the lockfile records per project.
280
403
  affected: (change, ctx) => {
281
- const claim = claims.find((c) => c.file === change.file)?.claim
404
+ const claim = claimsAt(ctx.workspaceRoot, true).find((c) => c.file === change.file)?.claim
282
405
  if (claim !== undefined) return claim.fingerprint.affected(change, ctx)
283
406
  // An added or removed file moves every install.
284
407
  const folded = (bytes: Uint8Array | null): string | undefined =>
@@ -288,8 +411,14 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
288
411
  const before = folded(change.before)
289
412
  return before !== undefined && before === folded(change.after) ? [] : undefined
290
413
  },
291
- extraFiles: (file, bytes) =>
292
- claims.find((c) => c.file === file)?.claim.fingerprint.extraFiles?.(file, bytes) ?? [],
414
+ // The files the key hashes, so the key's own claim answers, from its
415
+ // memo when it holds these bytes: `--affected` asks about every
416
+ // changed path, and a classic `link:` to a workspace named without
417
+ // the root widened an edit under that workspace to all.
418
+ extraFiles: async (file, bytes, ctx) =>
419
+ (await claimsAt(ctx.workspaceRoot)
420
+ .find((c) => c.file === file)
421
+ ?.claim.fingerprint.extraFiles?.(file, bytes, ctx)) ?? [],
293
422
  },
294
423
  async key(task, ctx) {
295
424
  let settings = settingsPerRun.get(ctx)
@@ -298,7 +427,7 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
298
427
  settingsPerRun.set(ctx, settings)
299
428
  }
300
429
  let deps: Readonly<Record<string, string>> | undefined
301
- for (const { claim } of claims) {
430
+ for (const { claim } of claimsAt(ctx.workspaceRoot)) {
302
431
  deps = await claim.key(task, ctx)
303
432
  if (deps !== undefined) break
304
433
  }
@@ -316,10 +445,22 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
316
445
  : { ...deps, ...folded }
317
446
  },
318
447
  async discover(ctx) {
319
- const manifest = manifestReader(ctx.workspaceRoot)
448
+ const manifest = manifestReader(ctx.workspaceRoot, manager.jsoncManifests === true)
449
+ // The staleness check's walk overlaps the lockfile read.
450
+ const dirs = workspaceGlobs(ctx.workspaceRoot, manager.name, manifest).then((globs) =>
451
+ workspaceDirs(ctx.workspaceRoot, globs, manager.skippedDirs),
452
+ )
453
+ dirs.catch(() => {})
320
454
  const read = await readImporters(ctx, manifest)
321
- if (read === undefined) return []
322
- const { importers, file } = read
455
+ if (read === undefined) {
456
+ // Core's word for the edges this leaves out names the plugin, not the file.
457
+ ctx.warn(
458
+ `[vx] ${manager.name}(): no ${manager.files.join(' or ')} in ${ctx.workspaceRoot} — run \`${manager.name} install\`; until then it orders and keys no package's dependencies`,
459
+ )
460
+ return []
461
+ }
462
+ const { file } = read
463
+ let { importers } = read
323
464
  // A lockfile older than the manifests drops edges: a workspace it does
324
465
  // not list yet (a renamed folder, lerna's 31 added Nx projects) is
325
466
  // named by its path with none, and a dependency added to package.json
@@ -329,17 +470,37 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
329
470
  // an install that is not on disk.
330
471
  if (file !== undefined) {
331
472
  const text = await Bun.file(path.join(ctx.workspaceRoot, file)).text()
473
+ const linksUnrecorded = manager.linksUnrecorded?.(text) === true
474
+ if (linksUnrecorded)
475
+ importers = await withManifestLinks(ctx.workspaceRoot, importers, manifest)
332
476
  const missing = await staleness(
333
477
  ctx.workspaceRoot,
334
478
  importers,
335
- manager.recorded(text),
336
- await workspaceDirs(
337
- ctx.workspaceRoot,
338
- await workspaceGlobs(ctx.workspaceRoot, manager.name, manifest),
339
- ),
479
+ await recordedOnce(ctx.cacheDir, file, text, manager.recorded),
480
+ await dirs,
340
481
  manifest,
482
+ manager.member,
483
+ linksUnrecorded,
484
+ manager.unrecorded?.(text),
341
485
  )
342
486
  if (missing.length > 0) {
487
+ // pnpm then writes a lockfile per package, and the root's lists
488
+ // only `.`: the install changes nothing, so it is not the fix.
489
+ // Core claims root files only, so a package's own is not read.
490
+ const workspaces = await dirs
491
+ if (
492
+ manager.name === 'pnpm' &&
493
+ workspaces.length > 0 &&
494
+ !workspaces.some((d) => importers.has(d)) &&
495
+ !pnpmSettings().sharedWorkspaceLockfile(
496
+ await textOrNull(path.join(ctx.workspaceRoot, 'pnpm-workspace.yaml')),
497
+ await textOrNull(path.join(ctx.workspaceRoot, '.npmrc')),
498
+ )
499
+ ) {
500
+ throw new UserError(
501
+ `${file} lists no workspace package: shared-workspace-lockfile is false, so pnpm writes a pnpm-lock.yaml per package, which pnpm() does not read — remove the setting and run \`pnpm install\``,
502
+ )
503
+ }
343
504
  throw new UserError(
344
505
  `${file} is out of date: it does not list ${listed(missing)} — run \`${manager.name} install\``,
345
506
  )
@@ -357,7 +518,10 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
357
518
  async project(config, ctx) {
358
519
  let importers = perRun.get(ctx.projects)
359
520
  if (importers === undefined) {
360
- importers = importersOf(ctx, manifestReader(ctx.workspaceRoot))
521
+ importers = importersOf(
522
+ ctx,
523
+ manifestReader(ctx.workspaceRoot, manager.jsoncManifests === true),
524
+ )
361
525
  perRun.set(ctx.projects, importers)
362
526
  }
363
527
  if ((await importers)?.has(importerOf(ctx.workspaceRoot, ctx.dir)) !== true) return
@@ -375,6 +539,46 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
375
539
  })
376
540
  }
377
541
 
542
+ /**
543
+ * The dependency names each importer records, kept per lockfile content:
544
+ * parsing them (pnpm's importers block, ~2 ms on solid, and the parser's
545
+ * load) was on every run, and only the lockfile text decides them.
546
+ */
547
+ async function recordedOnce(
548
+ cacheDir: string,
549
+ file: string,
550
+ text: string,
551
+ recorded: (text: string) => ReadonlyMap<string, ReadonlyMap<string, unknown>>,
552
+ ): Promise<ReadonlyMap<string, ReadonlySet<string>>> {
553
+ const hash = `${RECORDED_VERSION}:${Bun.hash.xxHash3(text).toString(16)}`
554
+ const memo = path.join(cacheDir, 'lockfile-claims', `${file}.recorded.json`)
555
+ try {
556
+ const kept = (await Bun.file(memo).json()) as { hash: string; names: Record<string, string[]> }
557
+ if (kept.hash === hash) {
558
+ return new Map(Object.entries(kept.names).map(([i, names]) => [i, new Set(names)]))
559
+ }
560
+ } catch {
561
+ // None kept, or unreadable: parsed below.
562
+ }
563
+ const out = new Map([...recorded(text)].map(([i, deps]) => [i, new Set(deps.keys())]))
564
+ const names = Object.fromEntries([...out].map(([i, set]) => [i, [...set]]))
565
+ // Write-then-rename, so a concurrent run reads a whole memo or none. The
566
+ // memo is a speed-up: a failed write costs the next run a parse.
567
+ const tmp = `${memo}.tmp-${process.pid}-${Date.now()}`
568
+ try {
569
+ await mkdir(path.dirname(memo), { recursive: true })
570
+ await Bun.write(tmp, JSON.stringify({ hash, names }))
571
+ await rename(tmp, memo)
572
+ } catch {
573
+ // Left behind, one more temp file piled up in the cache every run.
574
+ await rm(tmp, { force: true }).catch(() => {})
575
+ }
576
+ return out
577
+ }
578
+
579
+ /** Bumped when what `recorded` returns for the same text changes. */
580
+ const RECORDED_VERSION = 1
581
+
378
582
  /** Up to five names, then a count of the rest. */
379
583
  function listed(names: readonly string[]): string {
380
584
  return names.length <= 5
@@ -393,8 +597,13 @@ async function readSettings(
393
597
  let bytes: Uint8Array
394
598
  try {
395
599
  bytes = await Bun.file(path.join(workspaceRoot, file)).bytes()
396
- } catch {
397
- continue
600
+ } catch (err) {
601
+ // Only an absent file is absent: one there but unreadable (EACCES,
602
+ // a directory) folded as none, and the key named no setting at all.
603
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') continue
604
+ throw new UserError(
605
+ `${file}: cannot be read (${err instanceof Error ? err.message : String(err)})`,
606
+ )
398
607
  }
399
608
  const text = settingsText?.(file, new TextDecoder().decode(bytes))
400
609
  out[file] = Bun.hash
@@ -405,6 +614,14 @@ async function readSettings(
405
614
  return out
406
615
  }
407
616
 
617
+ async function textOrNull(file: string): Promise<string | null> {
618
+ try {
619
+ return await Bun.file(file).text()
620
+ } catch {
621
+ return null
622
+ }
623
+ }
624
+
408
625
  /** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
409
626
  function importerOf(workspaceRoot: string, dir: string): string {
410
627
  return path.relative(workspaceRoot, dir).split(path.sep).join('/') || '.'
@@ -415,20 +632,31 @@ function importerOf(workspaceRoot: string, dir: string): string {
415
632
  * not parse refuses, named: read as none, a conflicted manifest lost its
416
633
  * dependencies and its dependents hit with old bytes (Turbo and Nx refuse).
417
634
  */
418
- async function manifestOf(root: string, dir: string): Promise<Record<string, unknown> | null> {
635
+ async function manifestOf(
636
+ root: string,
637
+ dir: string,
638
+ jsonc: boolean,
639
+ ): Promise<Record<string, unknown> | null> {
419
640
  const file = path.join(dir, 'package.json')
641
+ const name = path.relative(root, file).split(path.sep).join('/')
420
642
  let text: string
421
643
  try {
422
644
  text = await Bun.file(file).text()
423
- } catch {
424
- return null
645
+ } catch (err) {
646
+ // As readSettings: one there but unreadable read as none, and its
647
+ // workspace globs and names dropped out of the staleness check. A
648
+ // lockfile importer whose directory is now a file has none either.
649
+ const code = (err as NodeJS.ErrnoException).code
650
+ if (code === 'ENOENT' || code === 'ENOTDIR') return null
651
+ throw new UserError(
652
+ `${name}: cannot be read (${err instanceof Error ? err.message : String(err)})`,
653
+ )
425
654
  }
426
- const name = path.relative(root, file).split(path.sep).join('/')
427
655
  if (CONFLICT.test(text))
428
656
  throw new UserError(`${name}: holds git merge conflict markers — resolve them`)
429
657
  let pkg: unknown
430
658
  try {
431
- pkg = JSON.parse(text)
659
+ pkg = jsonc ? Bun.JSONC.parse(text) : JSON.parse(text)
432
660
  } catch (err) {
433
661
  throw new UserError(`${name}: not JSON (${err instanceof Error ? err.message : String(err)})`)
434
662
  }
@@ -439,18 +667,46 @@ async function manifestOf(root: string, dir: string): Promise<Record<string, unk
439
667
  }
440
668
 
441
669
  /** `manifestOf`, each directory read once. */
442
- function manifestReader(root: string): (dir: string) => Promise<Record<string, unknown> | null> {
670
+ function manifestReader(
671
+ root: string,
672
+ jsonc: boolean,
673
+ ): (dir: string) => Promise<Record<string, unknown> | null> {
443
674
  const read = new Map<string, Promise<Record<string, unknown> | null>>()
444
675
  return (dir) => {
445
676
  let pkg = read.get(dir)
446
677
  if (pkg === undefined) {
447
- pkg = manifestOf(root, dir)
678
+ pkg = manifestOf(root, dir, jsonc)
448
679
  read.set(dir, pkg)
449
680
  }
450
681
  return pkg
451
682
  }
452
683
  }
453
684
 
685
+ /**
686
+ * Whether the root `package.json` `workspaces` globs name `dir` as Yarn 1
687
+ * reads them: it takes no `!` glob and links the directory anyway (`yarn
688
+ * workspaces info` lists it, 1.22), as `manifestImporters` reads them.
689
+ * Honoured here, the excluded workspace's content, its build output
690
+ * included, re-keyed every task.
691
+ */
692
+ function isClassicWorkspace(root: string, dir: string): boolean {
693
+ let pkg: Record<string, unknown> | null
694
+ try {
695
+ pkg = JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')) as Record<
696
+ string,
697
+ unknown
698
+ >
699
+ } catch {
700
+ return false
701
+ }
702
+ const globs = globsOf(pkg).filter((p): p is string => typeof p === 'string')
703
+ const glob = (p: string): Bun.Glob => new Bun.Glob(p.replace(/^\.\//, '').replace(/\/+$/, ''))
704
+ return (
705
+ !dir.split('/').includes('node_modules') &&
706
+ globs.some((p) => !p.startsWith('!') && glob(p).match(dir))
707
+ )
708
+ }
709
+
454
710
  /** A root manifest's `workspaces` globs: an array, or Yarn 1's `{ packages }`. */
455
711
  function globsOf(pkg: Record<string, unknown> | null): readonly unknown[] {
456
712
  const field = pkg?.['workspaces']
@@ -469,11 +725,22 @@ async function workspaceGlobs(
469
725
  manifest: (dir: string) => Promise<Record<string, unknown> | null>,
470
726
  ): Promise<readonly unknown[]> {
471
727
  if (manager !== 'pnpm') return globsOf(await manifest(root))
728
+ let text: string
729
+ try {
730
+ text = await Bun.file(path.join(root, 'pnpm-workspace.yaml')).text()
731
+ } catch (err) {
732
+ if ((err as NodeJS.ErrnoException).code === 'ENOENT') return []
733
+ throw err
734
+ }
735
+ // Read as no globs, an unparsable one turned the staleness check off,
736
+ // as a package.json that does not parse would (manifestOf refuses it).
472
737
  let doc: unknown
473
738
  try {
474
- doc = Bun.YAML.parse(await Bun.file(path.join(root, 'pnpm-workspace.yaml')).text())
475
- } catch {
476
- return []
739
+ doc = Bun.YAML.parse(text)
740
+ } catch (err) {
741
+ throw new UserError(
742
+ `pnpm-workspace.yaml: not YAML (${err instanceof Error ? err.message : String(err)})`,
743
+ )
477
744
  }
478
745
  const list =
479
746
  doc !== null && typeof doc === 'object'
@@ -484,46 +751,65 @@ async function workspaceGlobs(
484
751
 
485
752
  /**
486
753
  * The directories the globs name that hold a `package.json`, root-relative
487
- * POSIX, sorted; none a `!` glob excludes, none under `node_modules`.
754
+ * POSIX, sorted; none a `!` glob excludes, none under a `skipped` dir.
488
755
  */
489
- async function workspaceDirs(root: string, globs: readonly unknown[]): Promise<string[]> {
756
+ async function workspaceDirs(
757
+ root: string,
758
+ globs: readonly unknown[],
759
+ skipped: readonly string[] = ['node_modules'],
760
+ ): Promise<string[]> {
490
761
  const strip = (p: string): string => p.replace(/^\.\//, '').replace(/\/+$/, '')
491
762
  const excluded = globs
492
763
  .filter((p): p is string => typeof p === 'string' && p.startsWith('!'))
493
- .map((p) => new Bun.Glob(strip(p.slice(1))))
764
+ // Matched as the manager does, over `<glob>/package.json`: a `**`
765
+ // ending a negated glob matches zero directories, so `!**/fixtures/**`
766
+ // leaves out `packages/fixtures` itself, as pnpm and npm install it.
767
+ .map((p) => new Bun.Glob(`${strip(p.slice(1))}/package.json`))
494
768
  const dirs = new Set<string>()
495
769
  for (const pattern of globs) {
496
770
  if (typeof pattern !== 'string' || pattern.startsWith('!')) continue
497
771
  const glob = new Bun.Glob(`${strip(pattern)}/package.json`)
498
772
  for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
499
773
  const dir = path.dirname(file).split(path.sep).join('/')
500
- if (dir === '.' || dir.split('/').includes('node_modules')) continue
501
- if (!excluded.some((g) => g.match(dir))) dirs.add(dir)
774
+ if (dir === '.' || dir.split('/').some((seg) => skipped.includes(seg))) continue
775
+ if (!excluded.some((g) => g.match(`${dir}/package.json`))) dirs.add(dir)
502
776
  }
503
777
  }
504
778
  return [...dirs].sort()
505
779
  }
506
780
 
507
781
  /**
508
- * What the manifests name that the lockfile does not: each workspace it
509
- * lists no importer for, and each importer's dependency (any field but
782
+ * What the manifests name that the lockfile does not: each workspace the
783
+ * manager lists (`member`) that it lists no importer for, and each importer's dependency (any field but
510
784
  * peers, which a manager may leave out) on a workspace package's name that
511
785
  * it records no entry for.
512
786
  */
513
787
  async function staleness(
514
788
  root: string,
515
789
  importers: ReadonlyMap<string, LockfileImporter>,
516
- recorded: ReadonlyMap<string, ReadonlyMap<string, unknown>>,
790
+ recorded: ReadonlyMap<string, ReadonlySet<string>>,
517
791
  dirs: readonly string[],
518
792
  manifest: (dir: string) => Promise<Record<string, unknown> | null>,
793
+ member: ((pkg: Record<string, unknown>) => boolean) | undefined,
794
+ linksUnrecorded: boolean,
795
+ unrecorded?: (name: string, pkg: Record<string, unknown>) => boolean,
519
796
  ): Promise<string[]> {
520
797
  const dirOf = (importer: string): string => (importer === '.' ? root : path.join(root, importer))
521
- const missing = dirs.filter((d) => !importers.has(d)).map((d) => `the workspace ${d}`)
798
+ const missing: string[] = []
522
799
  const names = new Set<string>()
523
- for (const d of new Set([...importers.keys(), ...dirs])) {
800
+ const all = new Set([...importers.keys(), ...dirs])
801
+ // Read at once, then visited in order: the first unparsable one still
802
+ // names the refusal.
803
+ await Promise.allSettled([...all].map((d) => manifest(dirOf(d))))
804
+ for (const d of all) {
524
805
  const n = (await manifest(dirOf(d)))?.['name']
525
806
  if (typeof n === 'string') names.add(n)
526
807
  }
808
+ for (const d of dirs) {
809
+ if (importers.has(d)) continue
810
+ const pkg = await manifest(dirOf(d))
811
+ if (pkg === null || member?.(pkg) !== false) missing.push(`the workspace ${d}`)
812
+ }
527
813
  for (const [importer, deps] of recorded) {
528
814
  const pkg = await manifest(dirOf(importer))
529
815
  if (pkg === null) continue
@@ -531,7 +817,9 @@ async function staleness(
531
817
  for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
532
818
  const declared = pkg[field]
533
819
  if (declared === null || typeof declared !== 'object') continue
534
- for (const name of Object.keys(declared)) {
820
+ for (const [name, spec] of Object.entries(declared)) {
821
+ if (linksUnrecorded && typeof spec === 'string' && spec.startsWith('link:')) continue
822
+ if (unrecorded?.(name, pkg) === true) continue
535
823
  if (name !== own && names.has(name) && !deps.has(name)) {
536
824
  missing.push(`${importer}'s dependency on ${name}`)
537
825
  }
@@ -541,6 +829,36 @@ async function staleness(
541
829
  return [...new Set(missing)]
542
830
  }
543
831
 
832
+ /**
833
+ * The importers with each one's `link:` dependencies on another importer
834
+ * added from its manifest, for a lockfile that leaves them out.
835
+ */
836
+ async function withManifestLinks(
837
+ root: string,
838
+ importers: ReadonlyMap<string, LockfileImporter>,
839
+ manifest: (dir: string) => Promise<Record<string, unknown> | null>,
840
+ ): Promise<ReadonlyMap<string, LockfileImporter>> {
841
+ const out = new Map<string, LockfileImporter>()
842
+ for (const [importer, links] of importers) {
843
+ const pkg = await manifest(importer === '.' ? root : path.join(root, importer))
844
+ const deps: Record<string, string> = { ...links.deps }
845
+ for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
846
+ const declared = pkg?.[field]
847
+ if (declared === null || typeof declared !== 'object') continue
848
+ for (const [name, spec] of Object.entries(declared)) {
849
+ if (typeof spec !== 'string' || !spec.startsWith('link:')) continue
850
+ const target = path
851
+ .relative(root, path.resolve(root, importer, spec.slice('link:'.length)))
852
+ .split(path.sep)
853
+ .join('/')
854
+ if (target !== importer && importers.has(target || '.')) deps[name] = target || '.'
855
+ }
856
+ }
857
+ out.set(importer, { ...links, deps })
858
+ }
859
+ return out
860
+ }
861
+
544
862
  /**
545
863
  * One entry per importer with a `package.json` (core reads none): a
546
864
  * directory that is no project yet becomes one, the root aside, which is
@@ -548,9 +866,10 @@ async function staleness(
548
866
  * (D-39). Its name is the
549
867
  * manifest's `name` when no other importer shares it (core refuses two
550
868
  * projects of one name, so a shared one names neither); its `deps` the
551
- * importers it installs, and its peers on importers. A peer orders a build
552
- * unless it would close a cycle through the order edges, then it is
553
- * `reach` only, in name order so the edge kept is stable.
869
+ * importers it installs, its peers on importers, and the importers the root
870
+ * installs. A peer or root-installed importer orders a build unless it
871
+ * would close a cycle through the order edges, then it is `reach` only, in
872
+ * name order so the edge kept is stable.
554
873
  */
555
874
  async function discovered(
556
875
  root: string,
@@ -582,12 +901,17 @@ async function discovered(
582
901
  const [x, y] = [label(a), label(b)]
583
902
  return x < y ? -1 : x > y ? 1 : 0
584
903
  }
904
+ // Installed at the root `node_modules`, so every project resolves it
905
+ // whatever its own manifest declares (Turbo #8364, #8465).
906
+ const rootLinked = Object.values(importers.get('.')?.deps ?? {}).filter(
907
+ (t) => t !== '.' && manifests.has(t),
908
+ )
585
909
  const order = new Map<string, Set<string>>()
586
910
  const peers = new Map<string, Set<string>>()
587
911
  for (const [i, links] of importers) {
588
912
  if (!manifests.has(i)) continue
589
913
  const own = new Set<string>()
590
- const peer = new Set<string>()
914
+ const peer = new Set<string>(i === '.' ? [] : rootLinked.filter((t) => t !== i))
591
915
  const manifestPeers = peersInManifest ? manifestPeerNames(manifests.get(i)!) : undefined
592
916
  for (const [name, target] of Object.entries(links.deps)) {
593
917
  if (manifests.has(target)) (manifestPeers?.has(name) === true ? peer : own).add(target)
@@ -674,3 +998,12 @@ export function yarn(options: LockfileOptions = {}): VxPlugin {
674
998
  refuseUnknownOptions('yarn()', options, LOCKFILE_KEYS)
675
999
  return plugin(MANAGERS.yarn, options)
676
1000
  }
1001
+
1002
+ /** A file's text, undefined when it cannot be read. Sync: the digest seam is. */
1003
+ function readText(file: string): string | undefined {
1004
+ try {
1005
+ return readFileSync(file, 'utf8')
1006
+ } catch {
1007
+ return undefined
1008
+ }
1009
+ }