@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/README.md +32 -28
- package/package.json +3 -3
- package/src/bun.ts +49 -20
- package/src/index.ts +353 -65
- package/src/npm.ts +53 -29
- package/src/pnpm-settings.ts +50 -5
- package/src/pnpm.ts +120 -20
- package/src/syml.ts +91 -0
- package/src/yarn-settings.ts +26 -0
- package/src/yarn.ts +193 -16
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 {
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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'
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
-
|
|
205
|
-
|
|
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:
|
|
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
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
294
|
-
|
|
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
|
|
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)
|
|
329
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
516
|
-
} catch {
|
|
517
|
-
|
|
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 `
|
|
754
|
+
* POSIX, sorted; none a `!` glob excludes, none under a `skipped` dir.
|
|
529
755
|
*/
|
|
530
|
-
async function workspaceDirs(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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 =
|
|
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.
|
|
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,
|
|
597
|
-
*
|
|
598
|
-
*
|
|
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
|
+
}
|