@vzn/vx-lockfile 0.0.646 → 0.0.649

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,7 +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 { mkdir, rename } from 'node:fs/promises'
20
+ import { readdirSync, readFileSync } from 'node:fs'
21
+ import { mkdir, rename, rm } from 'node:fs/promises'
21
22
  import { createRequire } from 'node:module'
22
23
  import path from 'node:path'
23
24
  import {
@@ -30,6 +31,7 @@ import {
30
31
  type ProjectMeta,
31
32
  definePlugin,
32
33
  lockfileClaim,
34
+ reachDigests,
33
35
  type VxPlugin,
34
36
  } from '@vzn/vx'
35
37
 
@@ -43,6 +45,7 @@ const bunLock = (): typeof import('./bun.js') => load('./bun.js')
43
45
  const npmLock = (): typeof import('./npm.js') => load('./npm.js')
44
46
  const yarnLock = (): typeof import('./yarn.js') => load('./yarn.js')
45
47
  const pnpmSettings = (): typeof import('./pnpm-settings.js') => load('./pnpm-settings.js')
48
+ const yarnSettings = (): typeof import('./yarn-settings.js') => load('./yarn-settings.js')
46
49
 
47
50
  export interface LockfileOptions {
48
51
  /**
@@ -56,7 +59,38 @@ export interface LockfileOptions {
56
59
  }
57
60
 
58
61
  /** Bumps when a digest folds differently or the memo holds more (its identity). */
59
- 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
+ }
60
94
 
61
95
  interface Manager {
62
96
  readonly name: string
@@ -64,11 +98,19 @@ interface Manager {
64
98
  readonly files: readonly [string, ...string[]]
65
99
  /** A lockfile vx cannot parse, refused when none of `files` is present beside it. */
66
100
  readonly unreadable?: { readonly file: string; readonly fix: string }
67
- 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>
68
107
  readonly importers: (text: string) => ReadonlyMap<string, LockfileImporter>
69
108
  /** Each importer's dependency names the file records, whatever they resolve to. */
70
109
  readonly recorded: (text: string) => ReadonlyMap<string, ReadonlyMap<string, unknown>>
71
- 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[]
72
114
  /** Root files no lockfile records that lay the install out: folded whole into every task. */
73
115
  readonly settings: readonly string[]
74
116
  /**
@@ -76,8 +118,22 @@ interface Manager {
76
118
  * records part of what it says per project; undefined folds them whole.
77
119
  */
78
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
79
126
  /** The lockfile files a peer under `dependencies`: the manifest says which entries are peers. */
80
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
81
137
  /**
82
138
  * Importers a format leaves out of the lockfile (yarn classic), from the
83
139
  * workspace's manifests through `manifest`; undefined where the file
@@ -115,10 +171,21 @@ const MANAGERS = {
115
171
  importers: (t) => pnpmLock().importerLinks(pnpmParse(t)),
116
172
  extraFiles: (t) => pnpmLock().fileDeps(pnpmParse(t)),
117
173
  recorded: (t) => pnpmLock().recordedImporters(t),
118
- settings: ['pnpm-workspace.yaml', '.npmrc'],
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'],
119
179
  settingsText: (file, text) =>
120
- file === 'pnpm-workspace.yaml' ? pnpmSettings().workspaceSettings(text) : undefined,
180
+ file === 'pnpm-workspace.yaml'
181
+ ? pnpmSettings().workspaceSettings(text)
182
+ : file === 'package.json'
183
+ ? pnpmSettings().packageSettings(text)
184
+ : undefined,
121
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),
122
189
  },
123
190
  bun: {
124
191
  name: 'bun',
@@ -132,8 +199,12 @@ const MANAGERS = {
132
199
  digest: (t, files) => bunLock().importerDigests(bunParse(t), files),
133
200
  importers: (t) => bunLock().importerLinks(bunParse(t)),
134
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'] !== '',
135
205
  extraFiles: (t) => [...bunLock().patchFiles(t), ...bunLock().fileDeps(bunParse(t))],
136
206
  settings: ['bunfig.toml', '.npmrc'],
207
+ jsoncManifests: true,
137
208
  },
138
209
  npm: {
139
210
  name: 'npm',
@@ -154,23 +225,54 @@ const MANAGERS = {
154
225
  yarn: {
155
226
  name: 'yarn',
156
227
  files: ['yarn.lock'],
157
- 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
+ },
158
236
  importers: (t) => yarnLock().importerLinks(yarnParse(t)),
159
- 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
+ },
160
252
  recorded: (t) => {
161
253
  const lock = yarnParse(t)
162
254
  const out = new Map<string, ReadonlyMap<string, unknown>>()
163
255
  for (const [dir, id] of lock.workspaces) out.set(dir, lock.entries.get(id)!.deps)
164
256
  return out
165
257
  },
166
- 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,
167
267
  async manifestImporters(t, root, manifest) {
168
268
  const lock = yarnParse(t)
169
269
  if (lock.generation !== 'classic') return undefined
170
270
  const pkg = await manifest(root)
171
271
  const manifests = new Map<string, Record<string, unknown>>()
172
272
  if (pkg !== null) manifests.set('.', pkg)
173
- 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)) {
174
276
  const m = await manifest(path.join(root, dir))
175
277
  if (m !== null) manifests.set(dir, m)
176
278
  }
@@ -185,11 +287,11 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
185
287
  const scope = options.scope ?? 'project'
186
288
  if (scope !== 'project' && scope !== 'workspace') {
187
289
  throw new UserError(
188
- `${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)}`,
189
291
  )
190
292
  }
191
293
  const extraFiles = manager.extraFiles
192
- const claimOf = (file: string): LockfileClaimHooks => {
294
+ const guardedFor = (file: string) => {
193
295
  // The parsers name their own file, so prefixing unconditionally said it
194
296
  // twice — "bun.lock: bun.lock: Failed to parse JSONC" (2026-09-20).
195
297
  // Prefix only what does not already name it, and say what fixes it: a
@@ -201,39 +303,58 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
201
303
  const named = message.startsWith(`${file}:`) ? message : `${file}: ${message}`
202
304
  return new Error(`${named} — regenerate it with \`${manager.name} install\``)
203
305
  }
204
- const guarded =
205
- <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) =>
206
312
  (...args: A): R => {
207
313
  try {
314
+ if (CONFLICT.test(args[0])) throw new Error(`${file}: holds git merge conflict markers`)
208
315
  return f(...args)
209
316
  } catch (err) {
210
317
  throw refused(err)
211
318
  }
212
319
  }
320
+ }
321
+ const claimOf = (file: string, root: string, affected: boolean): LockfileClaimHooks => {
322
+ const guarded = guardedFor(file)
213
323
  return lockfileClaim({
214
324
  file,
215
325
  part: manager.name,
216
- version: DIGEST_VERSION,
326
+ version: digestVersion(),
217
327
  scope,
218
- importers: guarded((text: string) => {
219
- if (CONFLICT.test(text)) throw new Error(`${file}: holds git merge conflict markers`)
220
- return manager.importers(text)
221
- }),
328
+ importers: guarded((text: string) => manager.importers(text)),
222
329
  // Read before `digest` on the key path: a malformed bun.lock refused
223
330
  // there as a bare "JSONC Parse error", no file named (fuzzed, L-16).
224
- ...(extraFiles !== undefined ? { extraFiles: guarded(extraFiles) } : {}),
225
- digest: guarded((text: string, files: ReadonlyMap<string, string>) => {
226
- // A conflicted lockfile names two installs at once. Yarn classic's
227
- // line parser took the second side without a word, so the key named
228
- // an install that may not be the one on disk.
229
- if (CONFLICT.test(text)) throw new Error(`${file}: holds git merge conflict markers`)
230
- return manager.digest(text, files)
231
- }),
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
+ ),
232
337
  })
233
338
  }
234
339
  // One part name for every file: `npm shrinkwrap` renames the lockfile,
235
- // and the same install keys the same.
236
- 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
+ }
237
358
  const unreadable = manager.unreadable
238
359
  // With no lockfile it reads, a lockfile it cannot: the key would name no
239
360
  // install at all, so the run refuses rather than guessing.
@@ -251,7 +372,7 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
251
372
  ): Promise<
252
373
  { importers: ReadonlyMap<string, LockfileImporter>; file: string | undefined } | undefined
253
374
  > => {
254
- for (const { file, claim } of claims) {
375
+ for (const { file, claim } of claimsAt(ctx.workspaceRoot)) {
255
376
  const importers = await claim.importers(ctx)
256
377
  if (importers === undefined) continue
257
378
  if (importers.size > 0 || manager.manifestImporters === undefined) return { importers, file }
@@ -280,7 +401,7 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
280
401
  // A settings file lays out every project's install: every project,
281
402
  // unless all that moved is what the lockfile records per project.
282
403
  affected: (change, ctx) => {
283
- const claim = claims.find((c) => c.file === change.file)?.claim
404
+ const claim = claimsAt(ctx.workspaceRoot, true).find((c) => c.file === change.file)?.claim
284
405
  if (claim !== undefined) return claim.fingerprint.affected(change, ctx)
285
406
  // An added or removed file moves every install.
286
407
  const folded = (bytes: Uint8Array | null): string | undefined =>
@@ -290,8 +411,14 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
290
411
  const before = folded(change.before)
291
412
  return before !== undefined && before === folded(change.after) ? [] : undefined
292
413
  },
293
- extraFiles: (file, bytes) =>
294
- 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)) ?? [],
295
422
  },
296
423
  async key(task, ctx) {
297
424
  let settings = settingsPerRun.get(ctx)
@@ -300,7 +427,7 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
300
427
  settingsPerRun.set(ctx, settings)
301
428
  }
302
429
  let deps: Readonly<Record<string, string>> | undefined
303
- for (const { claim } of claims) {
430
+ for (const { claim } of claimsAt(ctx.workspaceRoot)) {
304
431
  deps = await claim.key(task, ctx)
305
432
  if (deps !== undefined) break
306
433
  }
@@ -318,15 +445,22 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
318
445
  : { ...deps, ...folded }
319
446
  },
320
447
  async discover(ctx) {
321
- const manifest = manifestReader(ctx.workspaceRoot)
448
+ const manifest = manifestReader(ctx.workspaceRoot, manager.jsoncManifests === true)
322
449
  // The staleness check's walk overlaps the lockfile read.
323
450
  const dirs = workspaceGlobs(ctx.workspaceRoot, manager.name, manifest).then((globs) =>
324
- workspaceDirs(ctx.workspaceRoot, globs),
451
+ workspaceDirs(ctx.workspaceRoot, globs, manager.skippedDirs),
325
452
  )
326
453
  dirs.catch(() => {})
327
454
  const read = await readImporters(ctx, manifest)
328
- if (read === undefined) return []
329
- 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
330
464
  // A lockfile older than the manifests drops edges: a workspace it does
331
465
  // not list yet (a renamed folder, lerna's 31 added Nx projects) is
332
466
  // named by its path with none, and a dependency added to package.json
@@ -336,14 +470,37 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
336
470
  // an install that is not on disk.
337
471
  if (file !== undefined) {
338
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)
339
476
  const missing = await staleness(
340
477
  ctx.workspaceRoot,
341
478
  importers,
342
479
  await recordedOnce(ctx.cacheDir, file, text, manager.recorded),
343
480
  await dirs,
344
481
  manifest,
482
+ manager.member,
483
+ linksUnrecorded,
484
+ manager.unrecorded?.(text),
345
485
  )
346
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
+ }
347
504
  throw new UserError(
348
505
  `${file} is out of date: it does not list ${listed(missing)} — run \`${manager.name} install\``,
349
506
  )
@@ -361,7 +518,10 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
361
518
  async project(config, ctx) {
362
519
  let importers = perRun.get(ctx.projects)
363
520
  if (importers === undefined) {
364
- importers = importersOf(ctx, manifestReader(ctx.workspaceRoot))
521
+ importers = importersOf(
522
+ ctx,
523
+ manifestReader(ctx.workspaceRoot, manager.jsoncManifests === true),
524
+ )
365
525
  perRun.set(ctx.projects, importers)
366
526
  }
367
527
  if ((await importers)?.has(importerOf(ctx.workspaceRoot, ctx.dir)) !== true) return
@@ -409,7 +569,10 @@ async function recordedOnce(
409
569
  await mkdir(path.dirname(memo), { recursive: true })
410
570
  await Bun.write(tmp, JSON.stringify({ hash, names }))
411
571
  await rename(tmp, memo)
412
- } catch {}
572
+ } catch {
573
+ // Left behind, one more temp file piled up in the cache every run.
574
+ await rm(tmp, { force: true }).catch(() => {})
575
+ }
413
576
  return out
414
577
  }
415
578
 
@@ -434,8 +597,13 @@ async function readSettings(
434
597
  let bytes: Uint8Array
435
598
  try {
436
599
  bytes = await Bun.file(path.join(workspaceRoot, file)).bytes()
437
- } catch {
438
- 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
+ )
439
607
  }
440
608
  const text = settingsText?.(file, new TextDecoder().decode(bytes))
441
609
  out[file] = Bun.hash
@@ -446,6 +614,14 @@ async function readSettings(
446
614
  return out
447
615
  }
448
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
+
449
625
  /** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
450
626
  function importerOf(workspaceRoot: string, dir: string): string {
451
627
  return path.relative(workspaceRoot, dir).split(path.sep).join('/') || '.'
@@ -456,20 +632,31 @@ function importerOf(workspaceRoot: string, dir: string): string {
456
632
  * not parse refuses, named: read as none, a conflicted manifest lost its
457
633
  * dependencies and its dependents hit with old bytes (Turbo and Nx refuse).
458
634
  */
459
- 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> {
460
640
  const file = path.join(dir, 'package.json')
641
+ const name = path.relative(root, file).split(path.sep).join('/')
461
642
  let text: string
462
643
  try {
463
644
  text = await Bun.file(file).text()
464
- } catch {
465
- 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
+ )
466
654
  }
467
- const name = path.relative(root, file).split(path.sep).join('/')
468
655
  if (CONFLICT.test(text))
469
656
  throw new UserError(`${name}: holds git merge conflict markers — resolve them`)
470
657
  let pkg: unknown
471
658
  try {
472
- pkg = JSON.parse(text)
659
+ pkg = jsonc ? Bun.JSONC.parse(text) : JSON.parse(text)
473
660
  } catch (err) {
474
661
  throw new UserError(`${name}: not JSON (${err instanceof Error ? err.message : String(err)})`)
475
662
  }
@@ -480,18 +667,46 @@ async function manifestOf(root: string, dir: string): Promise<Record<string, unk
480
667
  }
481
668
 
482
669
  /** `manifestOf`, each directory read once. */
483
- 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> {
484
674
  const read = new Map<string, Promise<Record<string, unknown> | null>>()
485
675
  return (dir) => {
486
676
  let pkg = read.get(dir)
487
677
  if (pkg === undefined) {
488
- pkg = manifestOf(root, dir)
678
+ pkg = manifestOf(root, dir, jsonc)
489
679
  read.set(dir, pkg)
490
680
  }
491
681
  return pkg
492
682
  }
493
683
  }
494
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
+
495
710
  /** A root manifest's `workspaces` globs: an array, or Yarn 1's `{ packages }`. */
496
711
  function globsOf(pkg: Record<string, unknown> | null): readonly unknown[] {
497
712
  const field = pkg?.['workspaces']
@@ -510,11 +725,22 @@ async function workspaceGlobs(
510
725
  manifest: (dir: string) => Promise<Record<string, unknown> | null>,
511
726
  ): Promise<readonly unknown[]> {
512
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).
513
737
  let doc: unknown
514
738
  try {
515
- doc = Bun.YAML.parse(await Bun.file(path.join(root, 'pnpm-workspace.yaml')).text())
516
- } catch {
517
- 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
+ )
518
744
  }
519
745
  const list =
520
746
  doc !== null && typeof doc === 'object'
@@ -525,29 +751,36 @@ async function workspaceGlobs(
525
751
 
526
752
  /**
527
753
  * The directories the globs name that hold a `package.json`, root-relative
528
- * POSIX, sorted; none a `!` glob excludes, none under `node_modules`.
754
+ * POSIX, sorted; none a `!` glob excludes, none under a `skipped` dir.
529
755
  */
530
- 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[]> {
531
761
  const strip = (p: string): string => p.replace(/^\.\//, '').replace(/\/+$/, '')
532
762
  const excluded = globs
533
763
  .filter((p): p is string => typeof p === 'string' && p.startsWith('!'))
534
- .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`))
535
768
  const dirs = new Set<string>()
536
769
  for (const pattern of globs) {
537
770
  if (typeof pattern !== 'string' || pattern.startsWith('!')) continue
538
771
  const glob = new Bun.Glob(`${strip(pattern)}/package.json`)
539
772
  for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
540
773
  const dir = path.dirname(file).split(path.sep).join('/')
541
- if (dir === '.' || dir.split('/').includes('node_modules')) continue
542
- 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)
543
776
  }
544
777
  }
545
778
  return [...dirs].sort()
546
779
  }
547
780
 
548
781
  /**
549
- * What the manifests name that the lockfile does not: each workspace it
550
- * 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
551
784
  * peers, which a manager may leave out) on a workspace package's name that
552
785
  * it records no entry for.
553
786
  */
@@ -557,9 +790,12 @@ async function staleness(
557
790
  recorded: ReadonlyMap<string, ReadonlySet<string>>,
558
791
  dirs: readonly string[],
559
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,
560
796
  ): Promise<string[]> {
561
797
  const dirOf = (importer: string): string => (importer === '.' ? root : path.join(root, importer))
562
- const missing = dirs.filter((d) => !importers.has(d)).map((d) => `the workspace ${d}`)
798
+ const missing: string[] = []
563
799
  const names = new Set<string>()
564
800
  const all = new Set([...importers.keys(), ...dirs])
565
801
  // Read at once, then visited in order: the first unparsable one still
@@ -569,6 +805,11 @@ async function staleness(
569
805
  const n = (await manifest(dirOf(d)))?.['name']
570
806
  if (typeof n === 'string') names.add(n)
571
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
+ }
572
813
  for (const [importer, deps] of recorded) {
573
814
  const pkg = await manifest(dirOf(importer))
574
815
  if (pkg === null) continue
@@ -576,7 +817,9 @@ async function staleness(
576
817
  for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
577
818
  const declared = pkg[field]
578
819
  if (declared === null || typeof declared !== 'object') continue
579
- 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
580
823
  if (name !== own && names.has(name) && !deps.has(name)) {
581
824
  missing.push(`${importer}'s dependency on ${name}`)
582
825
  }
@@ -586,6 +829,36 @@ async function staleness(
586
829
  return [...new Set(missing)]
587
830
  }
588
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
+
589
862
  /**
590
863
  * One entry per importer with a `package.json` (core reads none): a
591
864
  * directory that is no project yet becomes one, the root aside, which is
@@ -593,9 +866,10 @@ async function staleness(
593
866
  * (D-39). Its name is the
594
867
  * manifest's `name` when no other importer shares it (core refuses two
595
868
  * projects of one name, so a shared one names neither); its `deps` the
596
- * importers it installs, and its peers on importers. A peer orders a build
597
- * unless it would close a cycle through the order edges, then it is
598
- * `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.
599
873
  */
600
874
  async function discovered(
601
875
  root: string,
@@ -627,12 +901,17 @@ async function discovered(
627
901
  const [x, y] = [label(a), label(b)]
628
902
  return x < y ? -1 : x > y ? 1 : 0
629
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
+ )
630
909
  const order = new Map<string, Set<string>>()
631
910
  const peers = new Map<string, Set<string>>()
632
911
  for (const [i, links] of importers) {
633
912
  if (!manifests.has(i)) continue
634
913
  const own = new Set<string>()
635
- const peer = new Set<string>()
914
+ const peer = new Set<string>(i === '.' ? [] : rootLinked.filter((t) => t !== i))
636
915
  const manifestPeers = peersInManifest ? manifestPeerNames(manifests.get(i)!) : undefined
637
916
  for (const [name, target] of Object.entries(links.deps)) {
638
917
  if (manifests.has(target)) (manifestPeers?.has(name) === true ? peer : own).add(target)
@@ -719,3 +998,12 @@ export function yarn(options: LockfileOptions = {}): VxPlugin {
719
998
  refuseUnknownOptions('yarn()', options, LOCKFILE_KEYS)
720
999
  return plugin(MANAGERS.yarn, options)
721
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
+ }