@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/README.md +33 -29
- package/package.json +3 -3
- package/src/bun.ts +49 -20
- package/src/index.ts +403 -70
- package/src/json.ts +9 -0
- package/src/npm.ts +53 -29
- package/src/pnpm-settings.ts +71 -0
- package/src/pnpm.ts +135 -38
- package/src/syml.ts +91 -0
- package/src/yarn-settings.ts +26 -0
- package/src/yarn.ts +194 -18
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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) =>
|
|
116
|
-
|
|
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'
|
|
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) =>
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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',
|
|
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
|
|
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
|
-
|
|
203
|
-
|
|
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:
|
|
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
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
292
|
-
|
|
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
|
|
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)
|
|
322
|
-
|
|
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
|
|
336
|
-
await
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
475
|
-
} catch {
|
|
476
|
-
|
|
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 `
|
|
754
|
+
* POSIX, sorted; none a `!` glob excludes, none under a `skipped` dir.
|
|
488
755
|
*/
|
|
489
|
-
async function workspaceDirs(
|
|
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
|
-
|
|
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(
|
|
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
|
|
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,
|
|
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 =
|
|
798
|
+
const missing: string[] = []
|
|
522
799
|
const names = new Set<string>()
|
|
523
|
-
|
|
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.
|
|
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,
|
|
552
|
-
*
|
|
553
|
-
*
|
|
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
|
+
}
|