@vzn/vx-lockfile 0.0.639 → 0.0.641
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 +3 -2
- package/package.json +2 -2
- package/src/index.ts +105 -19
- package/src/yarn.ts +49 -9
package/README.md
CHANGED
|
@@ -56,7 +56,7 @@ A file has one claimant, so two of these plugins that share a settings file (`pn
|
|
|
56
56
|
|
|
57
57
|
## `package.json` as an input
|
|
58
58
|
|
|
59
|
-
Every
|
|
59
|
+
Every task with `inputs` of a project the lockfile lists, cached or not, gets its literal `package.json` appended to `inputs.files` (the `project` stage), so an edit to a manifest re-keys that project's tasks and, through `^` keys, its dependants. A task with no `inputs` stays keyless; one whose inputs already name `package.json` is left alone. A task that rewrites its own `package.json` with the same bytes (tsdown) keeps its hits under `inputStability: { strategy: 'mutable' }`.
|
|
60
60
|
|
|
61
61
|
## Project edges
|
|
62
62
|
|
|
@@ -65,7 +65,8 @@ Every cached task of a project the lockfile lists gets its literal `package.json
|
|
|
65
65
|
- **deps** (order `^task`, reach `--filter …` and `--affected`): a dependency the lockfile resolves to a workspace package — pnpm `link:`, bun's `workspace:` entries (a `workspace:` path or alias, and an `npm:` alias Bun links to the workspace package, by the spec it records), npm's `link: true` entries, yarn berry's workspace entries and `link:` / `portal:` paths.
|
|
66
66
|
- **peers**: a peer with no installed entry of the same name that resolves to a workspace package orders the build too, unless it would close a cycle through the order edges; then it is reach only (in project-name order, so the edge kept is stable). pnpm records an auto-installed peer under `dependencies`, so `pnpm()` asks the project's `package.json` which of those entries are peers; yarn berry resolves a peer to the workspace of its name, which Yarn hoists.
|
|
67
67
|
- Each importer with a `package.json` is a project: one with no vx config joins as a project a plugin added (its tasks come from the `project` stage, else the default `build`). The root importer is the exception: it is a project only by its own vx config. An importer with no `package.json`, and an edge to one, is skipped. A project is named by its `package.json` `name` unless its config names it, or another importer's `package.json` shares the name; then its path names it.
|
|
68
|
-
- A yarn classic (v1) lockfile records no workspaces
|
|
68
|
+
- A yarn classic (v1) lockfile records no workspaces, so `yarn()` reads them as Yarn 1 does: the directories the root `package.json` `workspaces` globs (an array or `{ packages }`) name. A dependency on a workspace's name is a link unless the lockfile keys its descriptor, which Yarn 1 writes only for the registry copy it fetched because the workspace's version missed the range.
|
|
69
|
+
- A workspace the root `package.json` `workspaces` names that `bun()`, `npm()` or berry `yarn()` finds no importer for (added after the last install) is a warning at `vx lock` naming the install that adds it: until then core names it by its path, so a `dependsOn` naming it finds no project.
|
|
69
70
|
|
|
70
71
|
These edges are what was installed, so they differ from a guess over manifest ranges where the manager decided otherwise: pnpm installs a plain range (`^2.0.0`, `*`, `catalog:`) from the registry unless it is `workspace:`, yarn packs a `file:` directory as a copy, bun installs `dependencies` over a devDependency of the same key. They are the only package edges vx has: core reads none from a `package.json`, so a workspace whose tasks use `^build` declares one of these plugins (or another that returns `deps`), and without one vx refuses the `^` naming the fix. A dependency an install adds or removes reaches `--affected` as this lockfile's change.
|
|
71
72
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx-lockfile",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.641",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"description": "Lockfile plugins for vx — pnpm(), bun(), npm(), yarn(): each keys every task on its project's own resolved dependency closure from the package manager's lockfile, so a lockfile change re-keys only the projects it reaches; --affected follows the same answer.",
|
|
6
6
|
"keywords": [
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
|
-
"@vzn/vx": "^0.0.
|
|
38
|
+
"@vzn/vx": "^0.0.641"
|
|
39
39
|
},
|
|
40
40
|
"repository": {
|
|
41
41
|
"type": "git",
|
package/src/index.ts
CHANGED
|
@@ -69,8 +69,16 @@ interface Manager {
|
|
|
69
69
|
readonly settings: readonly string[]
|
|
70
70
|
/** The lockfile files a peer under `dependencies`: the manifest says which entries are peers. */
|
|
71
71
|
readonly peersInManifest?: true
|
|
72
|
-
/**
|
|
73
|
-
|
|
72
|
+
/**
|
|
73
|
+
* Importers a format leaves out of the lockfile (yarn classic), from the
|
|
74
|
+
* workspace's manifests through `manifest`; undefined where the file
|
|
75
|
+
* records them itself.
|
|
76
|
+
*/
|
|
77
|
+
readonly manifestImporters?: (
|
|
78
|
+
text: string,
|
|
79
|
+
workspaceRoot: string,
|
|
80
|
+
manifest: (dir: string) => Promise<Record<string, unknown> | null>,
|
|
81
|
+
) => Promise<ReadonlyMap<string, LockfileImporter> | undefined>
|
|
74
82
|
}
|
|
75
83
|
|
|
76
84
|
/**
|
|
@@ -127,7 +135,18 @@ const MANAGERS = {
|
|
|
127
135
|
digest: (t) => yarnLock().importerDigests(yarnParse(t)),
|
|
128
136
|
importers: (t) => yarnLock().importerLinks(yarnParse(t)),
|
|
129
137
|
settings: ['.yarnrc.yml'],
|
|
130
|
-
|
|
138
|
+
async manifestImporters(t, root, manifest) {
|
|
139
|
+
const lock = yarnParse(t)
|
|
140
|
+
if (lock.generation !== 'classic') return undefined
|
|
141
|
+
const pkg = await manifest(root)
|
|
142
|
+
const manifests = new Map<string, Record<string, unknown>>()
|
|
143
|
+
if (pkg !== null) manifests.set('.', pkg)
|
|
144
|
+
for (const dir of await workspaceDirs(root, pkg)) {
|
|
145
|
+
const m = await manifest(path.join(root, dir))
|
|
146
|
+
if (m !== null) manifests.set(dir, m)
|
|
147
|
+
}
|
|
148
|
+
return yarnLock().classicLinks(lock, manifests)
|
|
149
|
+
},
|
|
131
150
|
},
|
|
132
151
|
} satisfies Record<string, Manager>
|
|
133
152
|
|
|
@@ -196,13 +215,16 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
196
215
|
`${unreadable.file}: ${manager.name}() reads ${manager.files.join(' or ')} and finds none — ${unreadable.fix}`,
|
|
197
216
|
)
|
|
198
217
|
}
|
|
199
|
-
const importersOf = async (
|
|
200
|
-
readonly workspaceRoot: string
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
for (const { claim } of claims) {
|
|
218
|
+
const importersOf = async (
|
|
219
|
+
ctx: { readonly workspaceRoot: string; readonly cacheDir: string },
|
|
220
|
+
manifest: (dir: string) => Promise<Record<string, unknown> | null>,
|
|
221
|
+
): Promise<ReadonlyMap<string, LockfileImporter> | undefined> => {
|
|
222
|
+
for (const { file, claim } of claims) {
|
|
204
223
|
const importers = await claim.importers(ctx)
|
|
205
|
-
if (importers
|
|
224
|
+
if (importers === undefined) continue
|
|
225
|
+
if (importers.size > 0 || manager.manifestImporters === undefined) return importers
|
|
226
|
+
const text = await Bun.file(path.join(ctx.workspaceRoot, file)).text()
|
|
227
|
+
return (await manager.manifestImporters(text, ctx.workspaceRoot, manifest)) ?? importers
|
|
206
228
|
}
|
|
207
229
|
await refuseUnreadable(ctx.workspaceRoot)
|
|
208
230
|
return undefined
|
|
@@ -246,41 +268,66 @@ function plugin(manager: Manager, options: LockfileOptions): VxPlugin {
|
|
|
246
268
|
: { ...deps, ...folded }
|
|
247
269
|
},
|
|
248
270
|
async discover(ctx) {
|
|
249
|
-
const
|
|
271
|
+
const manifest = manifestReader()
|
|
272
|
+
const importers = await importersOf(ctx, manifest)
|
|
250
273
|
if (importers === undefined) return []
|
|
251
|
-
|
|
274
|
+
// A workspace the root manifest names and the lockfile lacks is no
|
|
275
|
+
// project until an install adds it: lerna's 31 added Nx projects
|
|
276
|
+
// failed \`vx lock\` as "no such project" and nothing said why
|
|
277
|
+
// (Growth, 2026-10-16). pnpm reads pnpm-workspace.yaml instead.
|
|
278
|
+
if (manager.name !== 'pnpm') {
|
|
279
|
+
const missing = (
|
|
280
|
+
await workspaceDirs(ctx.workspaceRoot, await manifest(ctx.workspaceRoot))
|
|
281
|
+
).filter((d) => !importers.has(d))
|
|
282
|
+
if (missing.length > 0) {
|
|
283
|
+
let file = manager.files[0]!
|
|
284
|
+
for (const f of manager.files)
|
|
285
|
+
if (await Bun.file(path.join(ctx.workspaceRoot, f)).exists()) {
|
|
286
|
+
file = f
|
|
287
|
+
break
|
|
288
|
+
}
|
|
289
|
+
ctx.warn(
|
|
290
|
+
`${file} does not list the workspace${missing.length > 1 ? 's' : ''} ${listed(missing)} that package.json names — run \`${manager.name} install\` to add ${missing.length > 1 ? 'them; until then each is' : 'it; until then it is'} named by its path, and a dependsOn naming it finds no project`,
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
}
|
|
252
294
|
const rootIsProject = ctx.projects.some((p) => importerOf(ctx.workspaceRoot, p.dir) === '.')
|
|
253
295
|
return await discovered(
|
|
254
296
|
ctx.workspaceRoot,
|
|
255
297
|
importers,
|
|
256
298
|
manager.peersInManifest === true,
|
|
257
299
|
rootIsProject,
|
|
300
|
+
manifest,
|
|
258
301
|
)
|
|
259
302
|
},
|
|
260
303
|
async project(config, ctx) {
|
|
261
304
|
let importers = perRun.get(ctx.projects)
|
|
262
305
|
if (importers === undefined) {
|
|
263
|
-
importers = importersOf(ctx)
|
|
306
|
+
importers = importersOf(ctx, manifestReader())
|
|
264
307
|
perRun.set(ctx.projects, importers)
|
|
265
308
|
}
|
|
266
309
|
if ((await importers)?.has(importerOf(ctx.workspaceRoot, ctx.dir)) !== true) return
|
|
267
310
|
const tasks = config.tasks ?? {}
|
|
268
311
|
for (const [name, task] of Object.entries(tasks)) {
|
|
269
|
-
const inputs = task.
|
|
312
|
+
const inputs = task.inputs
|
|
270
313
|
if (inputs === undefined || inputs.files.includes('package.json')) continue
|
|
271
314
|
// A copy: a loaded config may be shared or frozen.
|
|
272
315
|
tasks[name] = {
|
|
273
316
|
...task,
|
|
274
|
-
|
|
275
|
-
...task.cache!,
|
|
276
|
-
inputs: { ...inputs, files: [...inputs.files, 'package.json'] },
|
|
277
|
-
},
|
|
317
|
+
inputs: { ...inputs, files: [...inputs.files, 'package.json'] },
|
|
278
318
|
}
|
|
279
319
|
}
|
|
280
320
|
},
|
|
281
321
|
})
|
|
282
322
|
}
|
|
283
323
|
|
|
324
|
+
/** Up to five names, then a count of the rest. */
|
|
325
|
+
function listed(names: readonly string[]): string {
|
|
326
|
+
return names.length <= 5
|
|
327
|
+
? names.join(', ')
|
|
328
|
+
: `${names.slice(0, 5).join(', ')} and ${names.length - 5} more`
|
|
329
|
+
}
|
|
330
|
+
|
|
284
331
|
/** Each present settings file's content hash, by name. */
|
|
285
332
|
async function readSettings(
|
|
286
333
|
workspaceRoot: string,
|
|
@@ -316,6 +363,44 @@ async function manifestOf(dir: string): Promise<Record<string, unknown> | null>
|
|
|
316
363
|
}
|
|
317
364
|
}
|
|
318
365
|
|
|
366
|
+
/** `manifestOf`, each directory read once. */
|
|
367
|
+
function manifestReader(): (dir: string) => Promise<Record<string, unknown> | null> {
|
|
368
|
+
const read = new Map<string, Promise<Record<string, unknown> | null>>()
|
|
369
|
+
return (dir) => {
|
|
370
|
+
let pkg = read.get(dir)
|
|
371
|
+
if (pkg === undefined) {
|
|
372
|
+
pkg = manifestOf(dir)
|
|
373
|
+
read.set(dir, pkg)
|
|
374
|
+
}
|
|
375
|
+
return pkg
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* The directories a root manifest's `workspaces` globs (an array, or
|
|
381
|
+
* Yarn 1's `{ packages }`) name that hold a `package.json`, root-relative
|
|
382
|
+
* POSIX, sorted; none under `node_modules`.
|
|
383
|
+
*/
|
|
384
|
+
async function workspaceDirs(root: string, pkg: Record<string, unknown> | null): Promise<string[]> {
|
|
385
|
+
const field = pkg?.['workspaces']
|
|
386
|
+
const list = Array.isArray(field)
|
|
387
|
+
? field
|
|
388
|
+
: field !== null && typeof field === 'object'
|
|
389
|
+
? (field as Record<string, unknown>)['packages']
|
|
390
|
+
: undefined
|
|
391
|
+
if (!Array.isArray(list)) return []
|
|
392
|
+
const dirs = new Set<string>()
|
|
393
|
+
for (const pattern of list) {
|
|
394
|
+
if (typeof pattern !== 'string' || pattern.startsWith('!')) continue
|
|
395
|
+
const glob = new Bun.Glob(`${pattern.replace(/^\.\//, '').replace(/\/+$/, '')}/package.json`)
|
|
396
|
+
for await (const file of glob.scan({ cwd: root, onlyFiles: true })) {
|
|
397
|
+
const dir = path.dirname(file).split(path.sep).join('/')
|
|
398
|
+
if (dir !== '.' && !dir.split('/').includes('node_modules')) dirs.add(dir)
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
return [...dirs].sort()
|
|
402
|
+
}
|
|
403
|
+
|
|
319
404
|
/**
|
|
320
405
|
* One entry per importer with a `package.json` (core reads none): a
|
|
321
406
|
* directory that is no project yet becomes one, the root aside, which is
|
|
@@ -332,12 +417,13 @@ async function discovered(
|
|
|
332
417
|
importers: ReadonlyMap<string, LockfileImporter>,
|
|
333
418
|
peersInManifest: boolean,
|
|
334
419
|
rootIsProject: boolean,
|
|
420
|
+
manifest: (dir: string) => Promise<Record<string, unknown> | null>,
|
|
335
421
|
): Promise<DiscoveredProject[]> {
|
|
336
422
|
const dirOf = (importer: string): string => (importer === '.' ? root : path.join(root, importer))
|
|
337
423
|
const read = await Promise.all(
|
|
338
424
|
[...importers.keys()]
|
|
339
425
|
.filter((i) => i !== '.' || rootIsProject)
|
|
340
|
-
.map(async (i) => [i, await
|
|
426
|
+
.map(async (i) => [i, await manifest(dirOf(i))] as const),
|
|
341
427
|
)
|
|
342
428
|
const manifests = new Map<string, Record<string, unknown>>()
|
|
343
429
|
for (const [i, pkg] of read) if (pkg !== null) manifests.set(i, pkg)
|
|
@@ -443,7 +529,7 @@ export function npm(options: LockfileOptions = {}): VxPlugin {
|
|
|
443
529
|
return plugin(MANAGERS.npm, options)
|
|
444
530
|
}
|
|
445
531
|
|
|
446
|
-
/** `yarn.lock`: berry (yarn 2+, per workspace) and classic (yarn 1
|
|
532
|
+
/** `yarn.lock`: berry (yarn 2+, per workspace) and classic (yarn 1: one digest for the root, links from the workspaces' manifests). */
|
|
447
533
|
export function yarn(options: LockfileOptions = {}): VxPlugin {
|
|
448
534
|
refuseUnknownOptions('yarn()', options, LOCKFILE_KEYS)
|
|
449
535
|
return plugin(MANAGERS.yarn, options)
|
package/src/yarn.ts
CHANGED
|
@@ -6,20 +6,16 @@
|
|
|
6
6
|
// dependency `bar: "npm:^2"` resolves to the entry keyed `bar@npm:^2`.
|
|
7
7
|
// Classic (yarn 1) is its own text format: `"foo@^1.0.0", "foo@^1.2.0":`
|
|
8
8
|
// then indented `version`, `resolved`, `integrity` and `dependencies:`
|
|
9
|
-
// with `bar "^2"` lines; classic has no workspace entries
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// reach through
|
|
9
|
+
// with `bar "^2"` lines; classic has no workspace entries: Yarn 1 links a
|
|
10
|
+
// dependency on a workspace's name and keys no entry for it, so its links
|
|
11
|
+
// come from the workspaces' own `package.json` (`classicLinks`), and its
|
|
12
|
+
// one digest is the root's reach through every entry the file resolves.
|
|
13
13
|
|
|
14
14
|
import { reachDigests, type LockfileImporter } from '@vzn/vx'
|
|
15
15
|
import { stable } from './pnpm.js'
|
|
16
16
|
import { depsOf, record } from './json.js'
|
|
17
17
|
import { joinPosix } from './posix.js'
|
|
18
18
|
|
|
19
|
-
/** What yarn() warns of a classic lockfile, after its name: the static part a test holds. */
|
|
20
|
-
export const CLASSIC_EDGES =
|
|
21
|
-
': yarn classic (v1) records no workspaces, so yarn() gives vx no project edges — upgrade to Yarn 2+ (`yarn set version stable`)'
|
|
22
|
-
|
|
23
19
|
export interface Lockfile {
|
|
24
20
|
readonly generation: 'berry' | 'classic'
|
|
25
21
|
/** entry id (the resolution, or the first descriptor) → entry */
|
|
@@ -197,6 +193,50 @@ function unquote(s: string): string {
|
|
|
197
193
|
return s.length >= 2 && s.startsWith('"') && s.endsWith('"') ? s.slice(1, -1) : s
|
|
198
194
|
}
|
|
199
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Classic's links: Yarn 1 records no workspace, so they come from each
|
|
198
|
+
* workspace's manifest (`manifests`: importer dir → `package.json`, `.`
|
|
199
|
+
* the root). A dependency on a workspace's name is linked unless the file
|
|
200
|
+
* keys its descriptor, which Yarn writes only for the registry copy it
|
|
201
|
+
* fetched when the workspace's version misses the range. A peer on a
|
|
202
|
+
* workspace with no installed entry of that name is a peer link.
|
|
203
|
+
*/
|
|
204
|
+
export function classicLinks(
|
|
205
|
+
lock: Lockfile,
|
|
206
|
+
manifests: ReadonlyMap<string, Record<string, unknown>>,
|
|
207
|
+
): ReadonlyMap<string, LockfileImporter> {
|
|
208
|
+
const count = new Map<string, number>()
|
|
209
|
+
const dirOf = new Map<string, string>()
|
|
210
|
+
for (const [dir, pkg] of manifests) {
|
|
211
|
+
const n = pkg['name']
|
|
212
|
+
if (typeof n !== 'string' || n === '') continue
|
|
213
|
+
count.set(n, (count.get(n) ?? 0) + 1)
|
|
214
|
+
dirOf.set(n, dir)
|
|
215
|
+
}
|
|
216
|
+
const workspace = (name: string): string | undefined =>
|
|
217
|
+
count.get(name) === 1 ? dirOf.get(name) : undefined
|
|
218
|
+
const out = new Map<string, LockfileImporter>()
|
|
219
|
+
for (const [dir, pkg] of manifests) {
|
|
220
|
+
const links = { deps: {} as Record<string, string>, peers: {} as Record<string, string> }
|
|
221
|
+
const installed = new Set<string>()
|
|
222
|
+
for (const field of ['dependencies', 'devDependencies', 'optionalDependencies']) {
|
|
223
|
+
for (const [name, range] of depsOf(pkg, [field])) {
|
|
224
|
+
installed.add(name)
|
|
225
|
+
const target = workspace(name)
|
|
226
|
+
if (target !== undefined && target !== dir && !lock.descriptors.has(`${name}@${range}`)) {
|
|
227
|
+
links.deps[name] = target
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const name of depsOf(pkg, ['peerDependencies']).keys()) {
|
|
232
|
+
const target = workspace(name)
|
|
233
|
+
if (target !== undefined && target !== dir && !installed.has(name)) links.peers[name] = target
|
|
234
|
+
}
|
|
235
|
+
out.set(dir, links)
|
|
236
|
+
}
|
|
237
|
+
return out
|
|
238
|
+
}
|
|
239
|
+
|
|
200
240
|
/**
|
|
201
241
|
* `bar` + `npm:^2` → the entry keyed `bar@npm:^2`; classic: `bar@^2`. A
|
|
202
242
|
* bare range gets berry's `npm:` prefix. A `workspace:` range resolves to
|
|
@@ -256,7 +296,7 @@ function resolveDescriptor(
|
|
|
256
296
|
* workspace of that name, which Yarn hoists whatever the range. Only a
|
|
257
297
|
* descriptor the file keys counts: the name fallbacks the digest takes
|
|
258
298
|
* (a catalog, an override) name no link. Classic records no workspaces,
|
|
259
|
-
* so it has none.
|
|
299
|
+
* so it has none here (`classicLinks`).
|
|
260
300
|
*/
|
|
261
301
|
export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImporter> {
|
|
262
302
|
const out = new Map<string, LockfileImporter>()
|