@vzn/vx-lockfile 0.0.636 → 0.0.638
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 +35 -30
- package/package.json +4 -6
- package/src/bun.ts +88 -92
- package/src/index.ts +349 -73
- package/src/json.ts +6 -22
- package/src/npm.ts +23 -48
- package/src/pnpm.ts +19 -109
- package/src/posix.ts +14 -0
- package/src/yarn.ts +92 -93
- package/src/blocks.ts +0 -83
- package/src/prune.ts +0 -400
- package/src/scope.ts +0 -17
package/src/index.ts
CHANGED
|
@@ -1,89 +1,133 @@
|
|
|
1
1
|
// @vzn/vx-lockfile — one plugin per package manager, each keying every
|
|
2
2
|
// task on its project's own resolved dependency closure from the
|
|
3
|
-
// lockfile instead of the whole file
|
|
3
|
+
// lockfile instead of the whole file, and giving core the project edges
|
|
4
|
+
// the lockfile records (`discover`).
|
|
4
5
|
//
|
|
5
|
-
// Core
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
// `--affected` names the projects whose digest moved. This package is the
|
|
6
|
+
// Core keys no package file: without a plugin here, no lockfile, install
|
|
7
|
+
// setting or `package.json` reaches any key. Each plugin CLAIMS its
|
|
8
|
+
// lockfile (`VxPlugin.fingerprint`) through core's `lockfileClaim`: each
|
|
9
|
+
// task folds one digest per project — what that project can reach through
|
|
10
|
+
// its dependencies, by resolved identity — and `--affected` names the
|
|
11
|
+
// projects whose digest moved. This package is the
|
|
12
12
|
// parsers; the claim, the per-project key (which folds the root
|
|
13
13
|
// importer's digest into every project: the root's tools are on every
|
|
14
|
-
// task's PATH), the memo (one parse per file content
|
|
15
|
-
// and the diff are core's.
|
|
14
|
+
// task's PATH), the memo (one parse per file content, edges included),
|
|
15
|
+
// the per-run read and the diff are core's. The plugin also claims the
|
|
16
|
+
// manager's install settings, folded whole into every task, and adds
|
|
17
|
+
// each importer's `package.json` to its tasks' inputs.
|
|
16
18
|
//
|
|
17
19
|
// Imports core only through the public `@vzn/vx` specifier.
|
|
18
20
|
import { createRequire } from 'node:module'
|
|
21
|
+
import path from 'node:path'
|
|
19
22
|
import {
|
|
20
23
|
refuseUnknownOptions,
|
|
21
24
|
UserError,
|
|
22
|
-
type
|
|
25
|
+
type DiscoveredProject,
|
|
26
|
+
type LockfileClaimHooks,
|
|
27
|
+
type LockfileImporter,
|
|
23
28
|
type PluginOptionKinds,
|
|
29
|
+
type ProjectMeta,
|
|
24
30
|
definePlugin,
|
|
25
31
|
lockfileClaim,
|
|
26
32
|
type VxPlugin,
|
|
27
33
|
} from '@vzn/vx'
|
|
28
34
|
|
|
29
|
-
// Each parser
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
//
|
|
35
|
+
// Each parser loads when first used: every run evaluates
|
|
36
|
+
// `.vx/workspace.ts`, and the four parsers were ~2.5 ms of it for a
|
|
37
|
+
// workspace that declares one manager. The digest seam is synchronous,
|
|
38
|
+
// so a parser loads by `require`.
|
|
33
39
|
const load = createRequire(import.meta.url)
|
|
34
40
|
const pnpmLock = (): typeof import('./pnpm.js') => load('./pnpm.js')
|
|
35
41
|
const bunLock = (): typeof import('./bun.js') => load('./bun.js')
|
|
36
42
|
const npmLock = (): typeof import('./npm.js') => load('./npm.js')
|
|
37
43
|
const yarnLock = (): typeof import('./yarn.js') => load('./yarn.js')
|
|
38
44
|
|
|
39
|
-
/** One object for all four plugins, so a workspace declaring two still has one `prune`. */
|
|
40
|
-
const pruneCommand: PluginCommand = {
|
|
41
|
-
description: 'copy projects and their workspace deps, lockfile pruned, for a Docker build',
|
|
42
|
-
run: async (argv, ctx) => (await import('./prune.js')).prune(argv, ctx),
|
|
43
|
-
}
|
|
44
|
-
|
|
45
45
|
export interface LockfileOptions {
|
|
46
46
|
/**
|
|
47
47
|
* `'project'` (default): each task folds its own project's dependency
|
|
48
48
|
* closure — a lockfile change re-keys only the projects it reaches.
|
|
49
|
-
* `'workspace'`: the whole file
|
|
50
|
-
*
|
|
51
|
-
*
|
|
49
|
+
* `'workspace'`: the whole file into every task — the coarse key, for a
|
|
50
|
+
* workspace that wants the claim but not yet the precision (`vx why`
|
|
51
|
+
* names it either way).
|
|
52
52
|
*/
|
|
53
53
|
readonly scope?: 'project' | 'workspace'
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
/** Bumps when a digest folds differently
|
|
57
|
-
const DIGEST_VERSION =
|
|
56
|
+
/** Bumps when a digest folds differently or the memo holds more (its identity). */
|
|
57
|
+
const DIGEST_VERSION = 15
|
|
58
58
|
|
|
59
59
|
interface Manager {
|
|
60
60
|
readonly name: string
|
|
61
|
-
|
|
61
|
+
/** The lockfiles the manager reads, the one it prefers first: it reads the first present. */
|
|
62
|
+
readonly files: readonly [string, ...string[]]
|
|
63
|
+
/** A lockfile vx cannot parse, refused when none of `files` is present beside it. */
|
|
64
|
+
readonly unreadable?: { readonly file: string; readonly fix: string }
|
|
62
65
|
readonly digest: (text: string, files: ReadonlyMap<string, string>) => ReadonlyMap<string, string>
|
|
66
|
+
readonly importers: (text: string) => ReadonlyMap<string, LockfileImporter>
|
|
63
67
|
readonly extraFiles?: (text: string) => readonly string[]
|
|
68
|
+
/** Root files no lockfile records that lay the install out: folded whole into every task. */
|
|
69
|
+
readonly settings: readonly string[]
|
|
70
|
+
/** The lockfile files a peer under `dependencies`: the manifest says which entries are peers. */
|
|
71
|
+
readonly peersInManifest?: true
|
|
72
|
+
/** Warned when the file records no importer at all, which only this format can. */
|
|
73
|
+
readonly noImporters?: () => string
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The last parse, so `digest` and `importers` on one content parse once.
|
|
78
|
+
* Per manager: the parsers are lazy, and so is this.
|
|
79
|
+
*/
|
|
80
|
+
function lastParse<T>(parse: (text: string) => T): (text: string) => T {
|
|
81
|
+
let last: { text: string; parsed: T } | undefined
|
|
82
|
+
return (text) => {
|
|
83
|
+
if (last?.text !== text) last = { text, parsed: parse(text) }
|
|
84
|
+
return last.parsed
|
|
85
|
+
}
|
|
64
86
|
}
|
|
65
87
|
|
|
88
|
+
const pnpmParse = lastParse((t: string) => pnpmLock().parseLockfile(t))
|
|
89
|
+
const bunParse = lastParse((t: string) => bunLock().parseLockfile(t))
|
|
90
|
+
const npmParse = lastParse((t: string) => npmLock().parseLockfile(t))
|
|
91
|
+
const yarnParse = lastParse((t: string) => yarnLock().parseLockfile(t))
|
|
92
|
+
|
|
66
93
|
const MANAGERS = {
|
|
67
94
|
pnpm: {
|
|
68
95
|
name: 'pnpm',
|
|
69
|
-
|
|
70
|
-
digest: (t) => pnpmLock().importerDigests(
|
|
96
|
+
files: ['pnpm-lock.yaml'],
|
|
97
|
+
digest: (t) => pnpmLock().importerDigests(pnpmParse(t)),
|
|
98
|
+
importers: (t) => pnpmLock().importerLinks(pnpmParse(t)),
|
|
99
|
+
settings: ['pnpm-workspace.yaml', '.npmrc'],
|
|
100
|
+
peersInManifest: true,
|
|
71
101
|
},
|
|
72
102
|
bun: {
|
|
73
103
|
name: 'bun',
|
|
74
|
-
|
|
75
|
-
|
|
104
|
+
files: ['bun.lock'],
|
|
105
|
+
// Bun's binary lockfile: core folded it whole until it keyed no
|
|
106
|
+
// package file, and nothing here parses it.
|
|
107
|
+
unreadable: {
|
|
108
|
+
file: 'bun.lockb',
|
|
109
|
+
fix: 'run `bun install --save-text-lockfile` to write bun.lock',
|
|
110
|
+
},
|
|
111
|
+
digest: (t, files) => bunLock().importerDigests(bunParse(t), files),
|
|
112
|
+
importers: (t) => bunLock().importerLinks(bunParse(t)),
|
|
76
113
|
extraFiles: (t) => bunLock().patchFiles(t),
|
|
114
|
+
settings: ['bunfig.toml', '.npmrc'],
|
|
77
115
|
},
|
|
78
116
|
npm: {
|
|
79
117
|
name: 'npm',
|
|
80
|
-
|
|
81
|
-
|
|
118
|
+
// npm reads npm-shrinkwrap.json over package-lock.json when both exist.
|
|
119
|
+
files: ['npm-shrinkwrap.json', 'package-lock.json'],
|
|
120
|
+
digest: (t) => npmLock().importerDigests(npmParse(t)),
|
|
121
|
+
importers: (t) => npmLock().importerLinks(npmParse(t)),
|
|
122
|
+
settings: ['.npmrc'],
|
|
82
123
|
},
|
|
83
124
|
yarn: {
|
|
84
125
|
name: 'yarn',
|
|
85
|
-
|
|
86
|
-
digest: (t) => yarnLock().importerDigests(
|
|
126
|
+
files: ['yarn.lock'],
|
|
127
|
+
digest: (t) => yarnLock().importerDigests(yarnParse(t)),
|
|
128
|
+
importers: (t) => yarnLock().importerLinks(yarnParse(t)),
|
|
129
|
+
settings: ['.yarnrc.yml'],
|
|
130
|
+
noImporters: () => `yarn.lock${yarnLock().CLASSIC_EDGES}`,
|
|
87
131
|
},
|
|
88
132
|
} satisfies Record<string, Manager>
|
|
89
133
|
|
|
@@ -96,54 +140,286 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
96
140
|
`${manager.name}() scope must be 'project' or 'workspace', not ${JSON.stringify(scope)}`,
|
|
97
141
|
)
|
|
98
142
|
}
|
|
99
|
-
// The parsers name their own file, so prefixing unconditionally said it
|
|
100
|
-
// twice — "bun.lock: bun.lock: Failed to parse JSONC" (2026-09-20).
|
|
101
|
-
// Prefix only what does not already name it, and say what fixes it: a
|
|
102
|
-
// lockfile vx cannot read is an install away from readable, and the
|
|
103
|
-
// alternative to reading it is a WRONG key, so the run refuses rather
|
|
104
|
-
// than guessing.
|
|
105
|
-
const refused = (err: unknown): Error => {
|
|
106
|
-
const message = err instanceof Error ? err.message : String(err)
|
|
107
|
-
const named = message.startsWith(`${manager.file}:`) ? message : `${manager.file}: ${message}`
|
|
108
|
-
return new Error(`${named} — regenerate it with \`${manager.name} install\``)
|
|
109
|
-
}
|
|
110
143
|
const extraFiles = manager.extraFiles
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
144
|
+
const claimOf = (file: string): LockfileClaimHooks => {
|
|
145
|
+
// The parsers name their own file, so prefixing unconditionally said it
|
|
146
|
+
// twice — "bun.lock: bun.lock: Failed to parse JSONC" (2026-09-20).
|
|
147
|
+
// Prefix only what does not already name it, and say what fixes it: a
|
|
148
|
+
// lockfile vx cannot read is an install away from readable, and the
|
|
149
|
+
// alternative to reading it is a WRONG key, so the run refuses rather
|
|
150
|
+
// than guessing.
|
|
151
|
+
const refused = (err: unknown): Error => {
|
|
152
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
153
|
+
const named = message.startsWith(`${file}:`) ? message : `${file}: ${message}`
|
|
154
|
+
return new Error(`${named} — regenerate it with \`${manager.name} install\``)
|
|
155
|
+
}
|
|
156
|
+
const guarded =
|
|
157
|
+
<A extends unknown[], R>(f: (...args: A) => R) =>
|
|
158
|
+
(...args: A): R => {
|
|
159
|
+
try {
|
|
160
|
+
return f(...args)
|
|
161
|
+
} catch (err) {
|
|
162
|
+
throw refused(err)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return lockfileClaim({
|
|
166
|
+
file,
|
|
115
167
|
part: manager.name,
|
|
116
168
|
version: DIGEST_VERSION,
|
|
117
169
|
scope,
|
|
170
|
+
importers: guarded((text: string) => {
|
|
171
|
+
if (CONFLICT.test(text)) throw new Error(`${file}: holds git merge conflict markers`)
|
|
172
|
+
return manager.importers(text)
|
|
173
|
+
}),
|
|
118
174
|
// Read before `digest` on the key path: a malformed bun.lock refused
|
|
119
175
|
// there as a bare "JSONC Parse error", no file named (fuzzed, L-16).
|
|
120
|
-
...(extraFiles !== undefined
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
176
|
+
...(extraFiles !== undefined ? { extraFiles: guarded(extraFiles) } : {}),
|
|
177
|
+
digest: guarded((text: string, files: ReadonlyMap<string, string>) => {
|
|
178
|
+
// A conflicted lockfile names two installs at once. Yarn classic's
|
|
179
|
+
// line parser took the second side without a word, so the key named
|
|
180
|
+
// an install that may not be the one on disk.
|
|
181
|
+
if (CONFLICT.test(text)) throw new Error(`${file}: holds git merge conflict markers`)
|
|
182
|
+
return manager.digest(text, files)
|
|
183
|
+
}),
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
// One part name for every file: `npm shrinkwrap` renames the lockfile,
|
|
187
|
+
// and the same install keys the same.
|
|
188
|
+
const claims = manager.files.map((file) => ({ file, claim: claimOf(file) }))
|
|
189
|
+
const unreadable = manager.unreadable
|
|
190
|
+
// With no lockfile it reads, a lockfile it cannot: the key would name no
|
|
191
|
+
// install at all, so the run refuses rather than guessing.
|
|
192
|
+
const refuseUnreadable = async (workspaceRoot: string): Promise<void> => {
|
|
193
|
+
if (unreadable === undefined) return
|
|
194
|
+
if (!(await Bun.file(path.join(workspaceRoot, unreadable.file)).exists())) return
|
|
195
|
+
throw new UserError(
|
|
196
|
+
`${unreadable.file}: ${manager.name}() reads ${manager.files.join(' or ')} and finds none — ${unreadable.fix}`,
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
const importersOf = async (ctx: {
|
|
200
|
+
readonly workspaceRoot: string
|
|
201
|
+
readonly cacheDir: string
|
|
202
|
+
}): Promise<ReadonlyMap<string, LockfileImporter> | undefined> => {
|
|
203
|
+
for (const { claim } of claims) {
|
|
204
|
+
const importers = await claim.importers(ctx)
|
|
205
|
+
if (importers !== undefined) return importers
|
|
206
|
+
}
|
|
207
|
+
await refuseUnreadable(ctx.workspaceRoot)
|
|
208
|
+
return undefined
|
|
209
|
+
}
|
|
210
|
+
// Per run: core hands every `project` visit of one run the same array.
|
|
211
|
+
const perRun = new WeakMap<
|
|
212
|
+
readonly ProjectMeta[],
|
|
213
|
+
Promise<ReadonlyMap<string, LockfileImporter> | undefined>
|
|
214
|
+
>()
|
|
215
|
+
const settingsPerRun = new WeakMap<object, Promise<Record<string, string>>>()
|
|
216
|
+
const refusedPerRun = new WeakMap<object, Promise<void>>()
|
|
217
|
+
return definePlugin(import.meta, {
|
|
218
|
+
fingerprint: {
|
|
219
|
+
files: [...manager.files, ...manager.settings],
|
|
220
|
+
// A settings file lays out every project's install: every project.
|
|
221
|
+
affected: (change, ctx) =>
|
|
222
|
+
claims.find((c) => c.file === change.file)?.claim.fingerprint.affected(change, ctx),
|
|
223
|
+
},
|
|
224
|
+
async key(task, ctx) {
|
|
225
|
+
let settings = settingsPerRun.get(ctx)
|
|
226
|
+
if (settings === undefined) {
|
|
227
|
+
settings = readSettings(ctx.workspaceRoot, manager.settings)
|
|
228
|
+
settingsPerRun.set(ctx, settings)
|
|
229
|
+
}
|
|
230
|
+
let deps: Readonly<Record<string, string>> | undefined
|
|
231
|
+
for (const { claim } of claims) {
|
|
232
|
+
deps = await claim.key(task, ctx)
|
|
233
|
+
if (deps !== undefined) break
|
|
234
|
+
}
|
|
235
|
+
if (deps === undefined) {
|
|
236
|
+
let refused = refusedPerRun.get(ctx)
|
|
237
|
+
if (refused === undefined) {
|
|
238
|
+
refused = refuseUnreadable(ctx.workspaceRoot)
|
|
239
|
+
refusedPerRun.set(ctx, refused)
|
|
141
240
|
}
|
|
142
|
-
|
|
143
|
-
|
|
241
|
+
await refused
|
|
242
|
+
}
|
|
243
|
+
const folded = await settings
|
|
244
|
+
return deps === undefined && Object.keys(folded).length === 0
|
|
245
|
+
? undefined
|
|
246
|
+
: { ...deps, ...folded }
|
|
247
|
+
},
|
|
248
|
+
async discover(ctx) {
|
|
249
|
+
const importers = await importersOf(ctx)
|
|
250
|
+
if (importers === undefined) return []
|
|
251
|
+
if (importers.size === 0 && manager.noImporters !== undefined) ctx.warn(manager.noImporters())
|
|
252
|
+
const rootIsProject = ctx.projects.some((p) => importerOf(ctx.workspaceRoot, p.dir) === '.')
|
|
253
|
+
return await discovered(
|
|
254
|
+
ctx.workspaceRoot,
|
|
255
|
+
importers,
|
|
256
|
+
manager.peersInManifest === true,
|
|
257
|
+
rootIsProject,
|
|
258
|
+
)
|
|
259
|
+
},
|
|
260
|
+
async project(config, ctx) {
|
|
261
|
+
let importers = perRun.get(ctx.projects)
|
|
262
|
+
if (importers === undefined) {
|
|
263
|
+
importers = importersOf(ctx)
|
|
264
|
+
perRun.set(ctx.projects, importers)
|
|
265
|
+
}
|
|
266
|
+
if ((await importers)?.has(importerOf(ctx.workspaceRoot, ctx.dir)) !== true) return
|
|
267
|
+
const tasks = config.tasks ?? {}
|
|
268
|
+
for (const [name, task] of Object.entries(tasks)) {
|
|
269
|
+
const inputs = task.cache?.inputs
|
|
270
|
+
if (inputs === undefined || inputs.files.includes('package.json')) continue
|
|
271
|
+
// A copy: a loaded config may be shared or frozen.
|
|
272
|
+
tasks[name] = {
|
|
273
|
+
...task,
|
|
274
|
+
cache: {
|
|
275
|
+
...task.cache!,
|
|
276
|
+
inputs: { ...inputs, files: [...inputs.files, 'package.json'] },
|
|
277
|
+
},
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
},
|
|
144
281
|
})
|
|
145
282
|
}
|
|
146
283
|
|
|
284
|
+
/** Each present settings file's content hash, by name. */
|
|
285
|
+
async function readSettings(
|
|
286
|
+
workspaceRoot: string,
|
|
287
|
+
files: readonly string[],
|
|
288
|
+
): Promise<Record<string, string>> {
|
|
289
|
+
const out: Record<string, string> = {}
|
|
290
|
+
for (const file of files) {
|
|
291
|
+
let bytes: Uint8Array
|
|
292
|
+
try {
|
|
293
|
+
bytes = await Bun.file(path.join(workspaceRoot, file)).bytes()
|
|
294
|
+
} catch {
|
|
295
|
+
continue
|
|
296
|
+
}
|
|
297
|
+
out[file] = Bun.hash.xxHash3(bytes).toString(16).padStart(16, '0')
|
|
298
|
+
}
|
|
299
|
+
return out
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** The lockfile's importer path for a project directory: `.` for the root, POSIX otherwise. */
|
|
303
|
+
function importerOf(workspaceRoot: string, dir: string): string {
|
|
304
|
+
return path.relative(workspaceRoot, dir).split(path.sep).join('/') || '.'
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** An importer's `package.json`, or null where it has none or it does not parse. */
|
|
308
|
+
async function manifestOf(dir: string): Promise<Record<string, unknown> | null> {
|
|
309
|
+
try {
|
|
310
|
+
const pkg: unknown = await Bun.file(path.join(dir, 'package.json')).json()
|
|
311
|
+
return pkg !== null && typeof pkg === 'object' && !Array.isArray(pkg)
|
|
312
|
+
? (pkg as Record<string, unknown>)
|
|
313
|
+
: null
|
|
314
|
+
} catch {
|
|
315
|
+
return null
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* One entry per importer with a `package.json` (core reads none): a
|
|
321
|
+
* directory that is no project yet becomes one, the root aside, which is
|
|
322
|
+
* a project only by its own vx config, as it was a member only by one
|
|
323
|
+
* (D-39). Its name is the
|
|
324
|
+
* manifest's `name` when no other importer shares it (core refuses two
|
|
325
|
+
* projects of one name, so a shared one names neither); its `deps` the
|
|
326
|
+
* importers it installs, and its peers on importers. A peer orders a build
|
|
327
|
+
* unless it would close a cycle through the order edges, then it is
|
|
328
|
+
* `reach` only, in name order so the edge kept is stable.
|
|
329
|
+
*/
|
|
330
|
+
async function discovered(
|
|
331
|
+
root: string,
|
|
332
|
+
importers: ReadonlyMap<string, LockfileImporter>,
|
|
333
|
+
peersInManifest: boolean,
|
|
334
|
+
rootIsProject: boolean,
|
|
335
|
+
): Promise<DiscoveredProject[]> {
|
|
336
|
+
const dirOf = (importer: string): string => (importer === '.' ? root : path.join(root, importer))
|
|
337
|
+
const read = await Promise.all(
|
|
338
|
+
[...importers.keys()]
|
|
339
|
+
.filter((i) => i !== '.' || rootIsProject)
|
|
340
|
+
.map(async (i) => [i, await manifestOf(dirOf(i))] as const),
|
|
341
|
+
)
|
|
342
|
+
const manifests = new Map<string, Record<string, unknown>>()
|
|
343
|
+
for (const [i, pkg] of read) if (pkg !== null) manifests.set(i, pkg)
|
|
344
|
+
const names = new Map<string, number>()
|
|
345
|
+
for (const pkg of manifests.values()) {
|
|
346
|
+
const n = pkg['name']
|
|
347
|
+
if (typeof n === 'string') names.set(n, (names.get(n) ?? 0) + 1)
|
|
348
|
+
}
|
|
349
|
+
const nameOf = (i: string): string | undefined => {
|
|
350
|
+
const n = manifests.get(i)?.['name']
|
|
351
|
+
return typeof n === 'string' && n !== '' && names.get(n) === 1 ? n : undefined
|
|
352
|
+
}
|
|
353
|
+
// Ordered by the name it will carry, path where it has none.
|
|
354
|
+
const label = (i: string): string => nameOf(i) ?? i
|
|
355
|
+
const byLabel = (a: string, b: string): number => {
|
|
356
|
+
const [x, y] = [label(a), label(b)]
|
|
357
|
+
return x < y ? -1 : x > y ? 1 : 0
|
|
358
|
+
}
|
|
359
|
+
const order = new Map<string, Set<string>>()
|
|
360
|
+
const peers = new Map<string, Set<string>>()
|
|
361
|
+
for (const [i, links] of importers) {
|
|
362
|
+
if (!manifests.has(i)) continue
|
|
363
|
+
const own = new Set<string>()
|
|
364
|
+
const peer = new Set<string>()
|
|
365
|
+
const manifestPeers = peersInManifest ? manifestPeerNames(manifests.get(i)!) : undefined
|
|
366
|
+
for (const [name, target] of Object.entries(links.deps)) {
|
|
367
|
+
if (manifests.has(target)) (manifestPeers?.has(name) === true ? peer : own).add(target)
|
|
368
|
+
}
|
|
369
|
+
for (const target of Object.values(links.peers)) if (manifests.has(target)) peer.add(target)
|
|
370
|
+
order.set(i, own)
|
|
371
|
+
peers.set(i, peer)
|
|
372
|
+
}
|
|
373
|
+
const reaches = (start: string, goal: string): boolean => {
|
|
374
|
+
const seen = new Set([start])
|
|
375
|
+
const stack = [start]
|
|
376
|
+
while (stack.length > 0) {
|
|
377
|
+
for (const next of order.get(stack.pop()!) ?? []) {
|
|
378
|
+
if (next === goal) return true
|
|
379
|
+
if (!seen.has(next)) {
|
|
380
|
+
seen.add(next)
|
|
381
|
+
stack.push(next)
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
return false
|
|
386
|
+
}
|
|
387
|
+
const sorted = [...order.keys()].sort(byLabel)
|
|
388
|
+
const reach = new Map<string, string[]>()
|
|
389
|
+
for (const i of sorted) {
|
|
390
|
+
const own = order.get(i)!
|
|
391
|
+
const kept: string[] = []
|
|
392
|
+
for (const peer of [...peers.get(i)!].sort(byLabel)) {
|
|
393
|
+
if (own.has(peer)) continue
|
|
394
|
+
if (reaches(peer, i)) kept.push(peer)
|
|
395
|
+
else own.add(peer)
|
|
396
|
+
}
|
|
397
|
+
reach.set(i, kept)
|
|
398
|
+
}
|
|
399
|
+
return sorted.map((i) => {
|
|
400
|
+
const name = nameOf(i)
|
|
401
|
+
return {
|
|
402
|
+
dir: dirOf(i),
|
|
403
|
+
...(name === undefined ? {} : { name }),
|
|
404
|
+
deps: [...order.get(i)!].sort(byLabel).map(dirOf),
|
|
405
|
+
reach: reach.get(i)!.map(dirOf),
|
|
406
|
+
}
|
|
407
|
+
})
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/** A manifest's peers it installs under no other field. */
|
|
411
|
+
function manifestPeerNames(pkg: Record<string, unknown>): ReadonlySet<string> {
|
|
412
|
+
const field = (v: unknown): object => (v !== null && typeof v === 'object' ? v : {})
|
|
413
|
+
const installed = ['dependencies', 'devDependencies', 'optionalDependencies'].map((f) =>
|
|
414
|
+
field(pkg[f]),
|
|
415
|
+
)
|
|
416
|
+
return new Set(
|
|
417
|
+
Object.keys(field(pkg['peerDependencies'])).filter(
|
|
418
|
+
(n) => !installed.some((f) => Object.hasOwn(f, n)),
|
|
419
|
+
),
|
|
420
|
+
)
|
|
421
|
+
}
|
|
422
|
+
|
|
147
423
|
/** Each option `LockfileOptions` names, with its kind: derived from the type, so the two cannot drift. */
|
|
148
424
|
const LOCKFILE_KEYS: PluginOptionKinds<LockfileOptions> = {
|
|
149
425
|
scope: 'string',
|
package/src/json.ts
CHANGED
|
@@ -20,28 +20,12 @@ export function depsOf(
|
|
|
20
20
|
return out
|
|
21
21
|
}
|
|
22
22
|
|
|
23
|
-
|
|
24
|
-
|
|
23
|
+
/** The `peerDependencies` names an entry installs under no other field. */
|
|
24
|
+
export function peerOnly(e: Json): ReadonlySet<string> {
|
|
25
|
+
const installed = depsOf(e, DEP_FIELDS.slice(0, 3))
|
|
26
|
+
return new Set(Object.keys(record(e['peerDependencies']) ?? {}).filter((n) => !installed.has(n)))
|
|
25
27
|
}
|
|
26
28
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
* ONLY as devDependencies: an entry another field also holds is installed
|
|
30
|
-
* in production and stays.
|
|
31
|
-
*/
|
|
32
|
-
export function devOnly(manifest: Json | undefined, dropped: ReadonlySet<string>): Set<string> {
|
|
33
|
-
const out = new Set<string>()
|
|
34
|
-
const dev = record(manifest?.['devDependencies'])
|
|
35
|
-
if (dev === undefined || dropped.size === 0) return out
|
|
36
|
-
const elsewhere = depsOf(manifest!, ['dependencies', 'optionalDependencies', 'peerDependencies'])
|
|
37
|
-
for (const name of Object.keys(dev)) if (dropped.has(name) && !elsewhere.has(name)) out.add(name)
|
|
38
|
-
return out
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/** `manifest`'s devDependencies without `names`; the field goes when nothing is left. */
|
|
42
|
-
export function dropDev(manifest: Json | undefined, names: ReadonlySet<string>): void {
|
|
43
|
-
const dev = record(manifest?.['devDependencies'])
|
|
44
|
-
if (manifest === undefined || dev === undefined || names.size === 0) return
|
|
45
|
-
for (const name of names) delete dev[name]
|
|
46
|
-
if (Object.keys(dev).length === 0) delete manifest['devDependencies']
|
|
29
|
+
export function record(v: unknown): Json | undefined {
|
|
30
|
+
return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : undefined
|
|
47
31
|
}
|
package/src/npm.ts
CHANGED
|
@@ -7,9 +7,8 @@
|
|
|
7
7
|
// at path `p` is `p/node_modules/d` when that key exists, else the nearest
|
|
8
8
|
// ancestor directory's, else `node_modules/d` — Node's own walk.
|
|
9
9
|
|
|
10
|
-
import { reachDigests } from '@vzn/vx'
|
|
11
|
-
import { depsOf,
|
|
12
|
-
import type { PruneScope } from './scope.js'
|
|
10
|
+
import { reachDigests, type LockfileImporter } from '@vzn/vx'
|
|
11
|
+
import { depsOf, peerOnly, record } from './json.js'
|
|
13
12
|
|
|
14
13
|
export interface Lockfile {
|
|
15
14
|
readonly version: number
|
|
@@ -26,6 +25,8 @@ export interface Entry {
|
|
|
26
25
|
/** the path a `link: true` entry points at */
|
|
27
26
|
readonly link: string | undefined
|
|
28
27
|
readonly isWorkspace: boolean
|
|
28
|
+
/** the peers it installs no entry for */
|
|
29
|
+
readonly peers: ReadonlySet<string>
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
export function parseLockfile(text: string): Lockfile {
|
|
@@ -59,6 +60,7 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
59
60
|
: '',
|
|
60
61
|
link,
|
|
61
62
|
isWorkspace: p !== '' && !p.startsWith('node_modules/') && !p.includes('/node_modules/'),
|
|
63
|
+
peers: peerOnly(e),
|
|
62
64
|
})
|
|
63
65
|
}
|
|
64
66
|
// Root `overrides` are not folded: what an override forced is the entry a
|
|
@@ -140,55 +142,28 @@ export function importerDigests(lock: Lockfile): ReadonlyMap<string, string> {
|
|
|
140
142
|
}
|
|
141
143
|
|
|
142
144
|
/**
|
|
143
|
-
* The
|
|
144
|
-
*
|
|
145
|
-
* entry
|
|
146
|
-
* manifest carries. Version 2's legacy `dependencies` tree is cut to the
|
|
147
|
-
* same paths.
|
|
145
|
+
* The root's and each workspace's dependencies that resolve, by Node's
|
|
146
|
+
* walk, to a `link: true` entry pointing at the root or a workspace: an
|
|
147
|
+
* installed entry is a dep, a peer with none of the same name a peer.
|
|
148
148
|
*/
|
|
149
|
-
export function
|
|
150
|
-
const
|
|
151
|
-
const lock = parseLockfile(text)
|
|
152
|
-
const doc = JSON.parse(text) as Json
|
|
153
|
-
const packages = record(doc['packages'])!
|
|
154
|
-
// What production struck from each kept workspace's manifest, struck from its entry too.
|
|
155
|
-
const skip = new Map<string, Set<string>>()
|
|
156
|
-
for (const p of Object.keys(packages)) {
|
|
157
|
-
if (!dirs.has(p === '' ? '.' : p)) continue
|
|
158
|
-
const names = devOnly(record(packages[p]), dropped)
|
|
159
|
-
skip.set(p, names)
|
|
160
|
-
dropDev(record(packages[p]), names)
|
|
161
|
-
}
|
|
162
|
-
const reached = new Set<string>()
|
|
163
|
-
const visit = (p: string | undefined): void => {
|
|
164
|
-
if (p === undefined || reached.has(p)) return
|
|
165
|
-
const e = lock.packages.get(p)!
|
|
166
|
-
if (members.has(p) && !dirs.has(p)) {
|
|
167
|
-
throw new Error(`package-lock.json: ${p} is a workspace the subset leaves out`)
|
|
168
|
-
}
|
|
169
|
-
reached.add(p)
|
|
170
|
-
if (e.link !== undefined) return visit(lock.packages.has(e.link) ? e.link : undefined)
|
|
171
|
-
const gone = skip.get(p)
|
|
172
|
-
for (const name of e.deps.keys()) if (gone?.has(name) !== true) visit(resolve(lock, p, name))
|
|
173
|
-
}
|
|
149
|
+
export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
|
|
150
|
+
const out = new Map<string, LockfileImporter>()
|
|
174
151
|
for (const [p, e] of lock.packages) {
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
if (!reached.has(p)) delete deps[name]
|
|
187
|
-
else prune(record(sub)?.['dependencies'], `${p}/`)
|
|
152
|
+
if (p !== '' && !e.isWorkspace) continue
|
|
153
|
+
const dir = p === '' ? '.' : p
|
|
154
|
+
const links = { deps: {} as Record<string, string>, peers: {} as Record<string, string> }
|
|
155
|
+
for (const name of e.deps.keys()) {
|
|
156
|
+
const key = resolve(lock, p, name)
|
|
157
|
+
const link = key === undefined ? undefined : lock.packages.get(key)!.link
|
|
158
|
+
if (link === undefined) continue
|
|
159
|
+
const target = lock.packages.get(link)
|
|
160
|
+
if (target === undefined || (link !== '' && !target.isWorkspace)) continue
|
|
161
|
+
const targetDir = link === '' ? '.' : link
|
|
162
|
+
if (targetDir !== dir) links[e.peers.has(name) ? 'peers' : 'deps'][name] = targetDir
|
|
188
163
|
}
|
|
164
|
+
out.set(dir, links)
|
|
189
165
|
}
|
|
190
|
-
|
|
191
|
-
return `${JSON.stringify(doc, null, 2)}\n`
|
|
166
|
+
return out
|
|
192
167
|
}
|
|
193
168
|
|
|
194
169
|
/** `node_modules/a/node_modules/@s/b` → `@s/b`. */
|