@vzn/vx-lockfile 0.0.646 → 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 +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/npm.ts
CHANGED
|
@@ -16,58 +16,68 @@ export interface Lockfile {
|
|
|
16
16
|
readonly packages: ReadonlyMap<string, Entry>
|
|
17
17
|
/** Material every workspace folds. */
|
|
18
18
|
readonly global: string
|
|
19
|
-
/** Directories `file:` dependencies link to: not the root, not a workspace. */
|
|
19
|
+
/** Directories `file:` dependencies link to or copy: not the root, not a workspace. */
|
|
20
20
|
readonly fileDirs: readonly string[]
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface Entry {
|
|
24
24
|
readonly deps: ReadonlyMap<string, string>
|
|
25
|
-
/** version + resolved + integrity — whatever pins the bytes; '' for a link */
|
|
25
|
+
/** version + resolved + integrity — whatever pins the bytes; '' for a link, the root, a workspace */
|
|
26
26
|
readonly resolution: string
|
|
27
27
|
/** the path a `link: true` entry points at */
|
|
28
28
|
readonly link: string | undefined
|
|
29
|
+
/** the `file:` directory an entry is a copy of (`install-links=true`) */
|
|
30
|
+
readonly copyOf: string | undefined
|
|
29
31
|
readonly isWorkspace: boolean
|
|
30
32
|
/** the peers it installs no entry for */
|
|
31
33
|
readonly peers: ReadonlySet<string>
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
export function parseLockfile(text: string): Lockfile {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
doc = JSON.parse(text)
|
|
38
|
-
} catch (err) {
|
|
39
|
-
throw new Error(`package-lock.json: ${err instanceof Error ? err.message : String(err)}`)
|
|
40
|
-
}
|
|
37
|
+
// The claim names the file: npm-shrinkwrap.json or package-lock.json.
|
|
38
|
+
const doc: unknown = JSON.parse(text)
|
|
41
39
|
const d = record(doc)
|
|
42
40
|
const version = d === undefined ? undefined : d['lockfileVersion']
|
|
43
41
|
if (typeof version !== 'number') {
|
|
44
|
-
throw new Error('
|
|
42
|
+
throw new Error('not an npm lockfile (no lockfileVersion)')
|
|
45
43
|
}
|
|
46
44
|
if (version < 2) {
|
|
47
45
|
throw new Error(
|
|
48
|
-
`
|
|
46
|
+
`lockfileVersion ${version} has no \`packages\` map — npm 7+ writes version 2 or 3`,
|
|
49
47
|
)
|
|
50
48
|
}
|
|
51
49
|
const raws = record(d!['packages']) ?? {}
|
|
52
|
-
const
|
|
50
|
+
const isMember = workspaceMatcher(raws[''])
|
|
53
51
|
const packages = new Map<string, Entry>()
|
|
54
52
|
for (const [p, raw] of Object.entries(raws)) {
|
|
55
53
|
const e = record(raw) ?? {}
|
|
56
54
|
const link = e['link'] === true && typeof e['resolved'] === 'string' ? e['resolved'] : undefined
|
|
55
|
+
// A tarball carries its integrity; a directory npm packed and copied
|
|
56
|
+
// (`install-links=true`) carries none, only its path.
|
|
57
|
+
const resolved = e['resolved']
|
|
58
|
+
const copyOf =
|
|
59
|
+
link === undefined &&
|
|
60
|
+
typeof resolved === 'string' &&
|
|
61
|
+
resolved.startsWith('file:') &&
|
|
62
|
+
typeof e['integrity'] !== 'string'
|
|
63
|
+
? resolved.slice('file:'.length)
|
|
64
|
+
: undefined
|
|
65
|
+
const isWorkspace =
|
|
66
|
+
p !== '' && !p.startsWith('node_modules/') && !p.includes('/node_modules/') && isMember(p)
|
|
57
67
|
packages.set(p, {
|
|
58
68
|
deps: depsOf(e),
|
|
69
|
+
// The root's and a workspace's `version` is their own package.json's,
|
|
70
|
+
// not what npm installed: a release bump of the root re-keyed every
|
|
71
|
+
// task with node_modules unchanged.
|
|
59
72
|
resolution:
|
|
60
|
-
link === undefined
|
|
73
|
+
link === undefined && p !== '' && !isWorkspace
|
|
61
74
|
? [e['version'], e['resolved'], e['integrity']]
|
|
62
75
|
.map((v) => (typeof v === 'string' ? v : ''))
|
|
63
76
|
.join('\0')
|
|
64
77
|
: '',
|
|
65
78
|
link,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
!p.startsWith('node_modules/') &&
|
|
69
|
-
!p.includes('/node_modules/') &&
|
|
70
|
-
(globs === undefined || globs.some((g) => g.match(p))),
|
|
79
|
+
copyOf,
|
|
80
|
+
isWorkspace,
|
|
71
81
|
peers: peerOnly(e),
|
|
72
82
|
})
|
|
73
83
|
}
|
|
@@ -79,23 +89,34 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
79
89
|
if (e.link !== undefined && e.link !== '' && packages.get(e.link)?.isWorkspace !== true) {
|
|
80
90
|
fileDirs.add(e.link)
|
|
81
91
|
}
|
|
92
|
+
if (e.copyOf !== undefined) fileDirs.add(e.copyOf)
|
|
82
93
|
}
|
|
83
94
|
return { version, packages, global, fileDirs: [...fileDirs].sort() }
|
|
84
95
|
}
|
|
85
96
|
|
|
86
97
|
/**
|
|
87
|
-
*
|
|
88
|
-
* directory, both entries outside node_modules
|
|
89
|
-
* Taking every such entry for a workspace made
|
|
90
|
-
* an edit under it selected nothing.
|
|
98
|
+
* Whether a path is a member by the root's `workspaces` globs: what tells
|
|
99
|
+
* a workspace from a `file:` directory, both entries outside node_modules
|
|
100
|
+
* that a link resolves to. Taking every such entry for a workspace made
|
|
101
|
+
* `vendor/dep` a project, so an edit under it selected nothing. A root
|
|
102
|
+
* that names none has no workspace, and every such entry is a `file:`
|
|
103
|
+
* directory (taken for workspaces, their content was never hashed). A
|
|
104
|
+
* `!` glob excludes, as npm reads it: as a glob of its own it matched
|
|
105
|
+
* every other path.
|
|
91
106
|
*/
|
|
92
|
-
function
|
|
107
|
+
function workspaceMatcher(root: unknown): (p: string) => boolean {
|
|
93
108
|
const ws = record(root)?.['workspaces']
|
|
94
109
|
const list = Array.isArray(ws) ? ws : record(ws)?.['packages']
|
|
95
|
-
if (!Array.isArray(list)) return
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
if (!Array.isArray(list)) return () => false
|
|
111
|
+
// Matched against the manifest's path, as npm globs `<pattern>/package.json`.
|
|
112
|
+
const glob = (g: string): Bun.Glob =>
|
|
113
|
+
new Bun.Glob(`${g.replace(/^\.\//, '').replace(/\/+$/, '')}/package.json`)
|
|
114
|
+
const strings = list.filter((g): g is string => typeof g === 'string')
|
|
115
|
+
const include = strings.filter((g) => !g.startsWith('!')).map(glob)
|
|
116
|
+
const exclude = strings.filter((g) => g.startsWith('!')).map((g) => glob(g.slice(1)))
|
|
117
|
+
return (p) =>
|
|
118
|
+
include.some((g) => g.match(`${p}/package.json`)) &&
|
|
119
|
+
!exclude.some((g) => g.match(`${p}/package.json`))
|
|
99
120
|
}
|
|
100
121
|
|
|
101
122
|
/**
|
|
@@ -141,13 +162,16 @@ export function importerDigests(
|
|
|
141
162
|
material.push(`${p === '' || e.isWorkspace ? p : installName(p)}\0${e.resolution}`)
|
|
142
163
|
edges.push([])
|
|
143
164
|
}
|
|
144
|
-
// npm links a `file:` directory,
|
|
145
|
-
//
|
|
165
|
+
// npm links a `file:` directory, or with `install-links=true` copies it,
|
|
166
|
+
// so what a task reads is its content now, which the lockfile does not
|
|
167
|
+
// record: an edit to it replayed a stale hit.
|
|
146
168
|
for (const dir of lock.fileDirs) {
|
|
147
169
|
const own = `\nfile\0${files.get(dir) ?? ''}`
|
|
148
170
|
const i = index.get(dir)
|
|
171
|
+
for (const [p, e] of lock.packages) {
|
|
172
|
+
if (e.copyOf === dir || (i === undefined && e.link === dir)) material[index.get(p)!] += own
|
|
173
|
+
}
|
|
149
174
|
if (i !== undefined) material[i] += own
|
|
150
|
-
else for (const [p, e] of lock.packages) if (e.link === dir) material[index.get(p)!] += own
|
|
151
175
|
}
|
|
152
176
|
for (const [p, e] of lock.packages) {
|
|
153
177
|
const from = index.get(p)!
|
package/src/pnpm-settings.ts
CHANGED
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
// for them cost ~1.5 ms a run.
|
|
4
4
|
import { record, stable, type Json } from './json.js'
|
|
5
5
|
|
|
6
|
+
/** What pnpm-lock.yaml records per project: its effect keys through the snapshots a project reaches. */
|
|
7
|
+
const RECORDED = new Set(['patchedDependencies', 'overrides', 'catalog', 'catalogs'])
|
|
8
|
+
|
|
6
9
|
/**
|
|
7
|
-
* pnpm-workspace.yaml as every task folds it: without `patchedDependencies
|
|
8
|
-
*
|
|
9
|
-
* keeps them here). Undefined for a file that does not
|
|
10
|
-
* folded whole.
|
|
10
|
+
* pnpm-workspace.yaml as every task folds it: without `patchedDependencies`,
|
|
11
|
+
* `overrides` and the catalogs, whose effect the lockfile records per
|
|
12
|
+
* project (pnpm 10+ keeps them here). Undefined for a file that does not
|
|
13
|
+
* parse to a mapping, folded whole.
|
|
11
14
|
*/
|
|
12
15
|
export function workspaceSettings(text: string): string | undefined {
|
|
13
16
|
let doc: unknown
|
|
@@ -20,7 +23,49 @@ export function workspaceSettings(text: string): string | undefined {
|
|
|
20
23
|
if (map === undefined) return undefined
|
|
21
24
|
const rest: Json = {}
|
|
22
25
|
for (const [k, v] of Object.entries(map)) {
|
|
23
|
-
if (k
|
|
26
|
+
if (!RECORDED.has(k)) rest[k] = v
|
|
24
27
|
}
|
|
25
28
|
return `pnpm-workspace\0${stable(rest)}`
|
|
26
29
|
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* pnpm's `shared-workspace-lockfile`, true unless set: pnpm-workspace.yaml's
|
|
33
|
+
* `sharedWorkspaceLockfile` when a boolean (pnpm 10.8+ reads it first; a
|
|
34
|
+
* quoted "false" installs shared), else `.npmrc`'s.
|
|
35
|
+
*/
|
|
36
|
+
export function sharedWorkspaceLockfile(
|
|
37
|
+
workspaceYaml: string | null,
|
|
38
|
+
npmrc: string | null,
|
|
39
|
+
): boolean {
|
|
40
|
+
if (workspaceYaml !== null) {
|
|
41
|
+
try {
|
|
42
|
+
const set = record(Bun.YAML.parse(workspaceYaml))?.['sharedWorkspaceLockfile']
|
|
43
|
+
if (typeof set === 'boolean') return set
|
|
44
|
+
} catch {}
|
|
45
|
+
}
|
|
46
|
+
return npmrc?.match(/^[ \t]*shared-workspace-lockfile[ \t]*=[ \t]*(\S*)/m)?.[1] !== 'false'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The root package.json as every task folds it: only its `pnpm` field,
|
|
51
|
+
* which pnpm reads install settings from (`onlyBuiltDependencies`,
|
|
52
|
+
* `supportedArchitectures`, …) and the lockfile records none of, without
|
|
53
|
+
* the patches and overrides it does record. Its dependencies are the
|
|
54
|
+
* lockfile's to record. Undefined for a file that does not parse to a
|
|
55
|
+
* mapping, folded whole.
|
|
56
|
+
*/
|
|
57
|
+
export function packageSettings(text: string): string | undefined {
|
|
58
|
+
let doc: unknown
|
|
59
|
+
try {
|
|
60
|
+
doc = JSON.parse(text)
|
|
61
|
+
} catch {
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
const map = record(doc)
|
|
65
|
+
if (map === undefined) return undefined
|
|
66
|
+
const rest: Json = {}
|
|
67
|
+
for (const [k, v] of Object.entries(record(map['pnpm']) ?? {})) {
|
|
68
|
+
if (k !== 'patchedDependencies' && k !== 'overrides') rest[k] = v
|
|
69
|
+
}
|
|
70
|
+
return `pnpm-package\0${stable(rest)}`
|
|
71
|
+
}
|
package/src/pnpm.ts
CHANGED
|
@@ -29,6 +29,8 @@ export interface Lockfile {
|
|
|
29
29
|
readonly directories: ReadonlyMap<string, string>
|
|
30
30
|
/** Material every importer folds: the lockfile version and the install-wide knobs. */
|
|
31
31
|
readonly global: string
|
|
32
|
+
/** `settings.excludeLinksFromLockfile`: no importer records its `link:` dependencies. */
|
|
33
|
+
readonly excludesLinks: boolean
|
|
32
34
|
}
|
|
33
35
|
|
|
34
36
|
type Yaml = Record<string, unknown>
|
|
@@ -119,7 +121,46 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
119
121
|
? { lockfileVersion: version, rest }
|
|
120
122
|
: { lockfileVersion: version, rest, leading },
|
|
121
123
|
)
|
|
122
|
-
|
|
124
|
+
const excludesLinks = record(doc['settings'])?.['excludeLinksFromLockfile'] === true
|
|
125
|
+
return { version, importers, snapshots, resolutions, patches, directories, global, excludesLinks }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Whether pnpm drops a manifest's dependency `name` from the install:
|
|
130
|
+
* one of its `optionalDependencies` the lockfile's
|
|
131
|
+
* `ignoredOptionalDependencies` names (pnpm also drops it from
|
|
132
|
+
* `dependencies`, and records it nowhere), matched as `@pnpm/matcher`
|
|
133
|
+
* does: `*` any run, a leading `!` excludes.
|
|
134
|
+
*/
|
|
135
|
+
export function ignoredOptional(
|
|
136
|
+
text: string,
|
|
137
|
+
): (name: string, pkg: Record<string, unknown>) => boolean {
|
|
138
|
+
const list = topLevel(text, 'ignoredOptionalDependencies')
|
|
139
|
+
const patterns = Array.isArray(list) ? list.filter((p) => typeof p === 'string') : []
|
|
140
|
+
if (patterns.length === 0) return () => false
|
|
141
|
+
const matchers = patterns.map((p) => {
|
|
142
|
+
const ignore = p.startsWith('!')
|
|
143
|
+
const pattern = ignore ? p.slice(1) : p
|
|
144
|
+
const re = new RegExp(`^${pattern.replace(/[|\\{}()[\]^$+?.]/g, '\\$&').replace(/\*/g, '.*')}$`)
|
|
145
|
+
return { ignore, match: (s: string) => re.test(s) }
|
|
146
|
+
})
|
|
147
|
+
const hasInclude = matchers.some((m) => !m.ignore)
|
|
148
|
+
const hasIgnore = matchers.some((m) => m.ignore)
|
|
149
|
+
const matches = (name: string): boolean => {
|
|
150
|
+
if (!hasIgnore) return matchers.some((m) => m.match(name))
|
|
151
|
+
if (!hasInclude) return !matchers.some((m) => m.match(name))
|
|
152
|
+
let matched = false
|
|
153
|
+
for (const m of matchers) {
|
|
154
|
+
if (m.ignore) {
|
|
155
|
+
if (m.match(name)) matched = false
|
|
156
|
+
} else if (!matched && m.match(name)) matched = true
|
|
157
|
+
}
|
|
158
|
+
return matched
|
|
159
|
+
}
|
|
160
|
+
return (name, pkg) => {
|
|
161
|
+
const optional = record(pkg['optionalDependencies'])
|
|
162
|
+
return optional !== undefined && Object.hasOwn(optional, name) && matches(name)
|
|
163
|
+
}
|
|
123
164
|
}
|
|
124
165
|
|
|
125
166
|
/**
|
|
@@ -130,26 +171,37 @@ export function parseLockfile(text: string): Lockfile {
|
|
|
130
171
|
* next top-level key; any other shape is parsed whole.
|
|
131
172
|
*/
|
|
132
173
|
export function recordedImporters(text: string): ReadonlyMap<string, ReadonlyMap<string, string>> {
|
|
133
|
-
const
|
|
134
|
-
const at = text.indexOf('\nimporters:\n', doc)
|
|
135
|
-
const block = at < 0 ? undefined : text.slice(at + 1)
|
|
136
|
-
const end = block === undefined ? null : /\n\S/.exec(block.slice('importers:\n'.length))
|
|
137
|
-
const rawImporters =
|
|
138
|
-
block === undefined
|
|
139
|
-
? undefined
|
|
140
|
-
: record(
|
|
141
|
-
record(
|
|
142
|
-
Bun.YAML.parse(
|
|
143
|
-
end === null ? block : block.slice(0, 'importers:\n'.length + end.index + 1),
|
|
144
|
-
),
|
|
145
|
-
)?.['importers'],
|
|
146
|
-
)
|
|
174
|
+
const rawImporters = record(topLevel(text, 'importers'))
|
|
147
175
|
if (rawImporters === undefined) return parseLockfile(text).importers
|
|
148
176
|
const importers = new Map<string, ReadonlyMap<string, string>>()
|
|
149
177
|
for (const [dir, entry] of Object.entries(rawImporters)) importers.set(dir, depsOf(entry))
|
|
150
178
|
return importers
|
|
151
179
|
}
|
|
152
180
|
|
|
181
|
+
/** `settings.excludeLinksFromLockfile`, read from its block alone as `recordedImporters` reads. */
|
|
182
|
+
export function excludesLinks(text: string): boolean {
|
|
183
|
+
return record(topLevel(text, 'settings'))?.['excludeLinksFromLockfile'] === true
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The last document's top-level `key`, parsed from its block alone (to
|
|
188
|
+
* the next top-level key); undefined where pnpm wrote no such block. A
|
|
189
|
+
* CRLF file (a Windows checkout) heads it `key:\r\n`: found by `\n`
|
|
190
|
+
* alone, the block read as absent though the whole parse has it.
|
|
191
|
+
*/
|
|
192
|
+
function topLevel(text: string, key: string): unknown {
|
|
193
|
+
const doc = Math.max(text.lastIndexOf('\n---'), 0)
|
|
194
|
+
// The first line's ending says which: a second scan cost every LF run.
|
|
195
|
+
const head = text.charCodeAt(text.indexOf('\n') - 1) === 13 ? `\n${key}:\r\n` : `\n${key}:\n`
|
|
196
|
+
const at = text.indexOf(head, doc)
|
|
197
|
+
if (at < 0) return undefined
|
|
198
|
+
const block = text.slice(at + 1)
|
|
199
|
+
const end = /\n\S/.exec(block.slice(head.length - 1))
|
|
200
|
+
return record(
|
|
201
|
+
Bun.YAML.parse(end === null ? block : block.slice(0, head.length - 1 + end.index + 1)),
|
|
202
|
+
)?.[key]
|
|
203
|
+
}
|
|
204
|
+
|
|
153
205
|
/** `dependencies` + `devDependencies` + `optionalDependencies` of an entry, as name → version. */
|
|
154
206
|
function depsOf(entry: unknown): ReadonlyMap<string, string> {
|
|
155
207
|
const out = new Map<string, string>()
|
|
@@ -237,8 +289,15 @@ export function importerDigests(
|
|
|
237
289
|
// The global digest rides as DATA: Bun's xxHash3 reads only the low 32
|
|
238
290
|
// bits of a seed, so two lockfiles' globals could share one (item 682).
|
|
239
291
|
const global = Bun.hash.xxHash3(lock.global).toString(16).padStart(16, '0')
|
|
292
|
+
const reachOf = (dir: string): string => hashes[g.index.get(importerNode(dir))!]!
|
|
293
|
+
// With its links left out, the file does not say which importer links
|
|
294
|
+
// which, so each folds them all: keyed by its own reach, a project
|
|
295
|
+
// linking another missed a bump in the linked one's dependencies.
|
|
296
|
+
const all = lock.excludesLinks
|
|
297
|
+
? [...lock.importers.keys()].sort().map(reachOf).join('\0')
|
|
298
|
+
: undefined
|
|
240
299
|
for (const dir of lock.importers.keys()) {
|
|
241
|
-
const h = Bun.hash.xxHash3(`${global}\0${
|
|
300
|
+
const h = Bun.hash.xxHash3(`${global}\0${all ?? reachOf(dir)}`)
|
|
242
301
|
out.set(dir, h.toString(16).padStart(16, '0'))
|
|
243
302
|
}
|
|
244
303
|
return out
|
|
@@ -265,12 +324,17 @@ function importerNode(dir: string): string {
|
|
|
265
324
|
*/
|
|
266
325
|
export function fileDeps(lock: Lockfile): string[] {
|
|
267
326
|
const out = new Set(lock.directories.values())
|
|
268
|
-
|
|
327
|
+
const add = (dir: string, deps: ReadonlyMap<string, string>): void => {
|
|
269
328
|
for (const version of deps.values()) {
|
|
270
329
|
const target = linkTarget(lock, dir, version)
|
|
271
330
|
if (target !== undefined && !lock.importers.has(target)) out.add(target)
|
|
272
331
|
}
|
|
273
332
|
}
|
|
333
|
+
for (const [dir, deps] of lock.importers) add(dir, deps)
|
|
334
|
+
// An override to a `link:` directory reaches a package's own
|
|
335
|
+
// dependencies, written root-relative in its snapshot: one no importer
|
|
336
|
+
// names was never read, so an edit in it moved no key.
|
|
337
|
+
for (const deps of lock.snapshots.values()) add('.', deps)
|
|
274
338
|
return [...out].sort()
|
|
275
339
|
}
|
|
276
340
|
|
|
@@ -321,19 +385,52 @@ function buildGraph(lock: Lockfile, files: ReadonlyMap<string, string>): Graph {
|
|
|
321
385
|
// is a node of its own, so its version still counts.
|
|
322
386
|
edges[from]!.push(node(key, `${key}\0${lock.resolutions.get(packageKey(key)) ?? ''}\0`))
|
|
323
387
|
}
|
|
388
|
+
// A node folds the names it asks for as well as where they land: by
|
|
389
|
+
// its edges alone, two aliases swapping targets (`foo: npm:x`, `bar:
|
|
390
|
+
// npm:y` → the reverse) reached the same packages and kept the key,
|
|
391
|
+
// while `require('foo')` loads another package.
|
|
392
|
+
const named = (from: number, deps: ReadonlyMap<string, string>): void => {
|
|
393
|
+
material[from] += `\nnames\0${[...deps]
|
|
394
|
+
.map(([n, v]) => `${n}\0${v}`)
|
|
395
|
+
.sort()
|
|
396
|
+
.join('\0')}`
|
|
397
|
+
}
|
|
324
398
|
for (const [dir, deps] of lock.importers) {
|
|
325
399
|
const from = index.get(importerNode(dir))!
|
|
400
|
+
named(from, deps)
|
|
326
401
|
for (const [name, version] of deps) link(from, dir, name, version)
|
|
327
402
|
}
|
|
328
403
|
for (const [key, deps] of lock.snapshots) {
|
|
329
404
|
const from = index.get(key)!
|
|
405
|
+
named(from, deps)
|
|
330
406
|
for (const [name, version] of deps) link(from, '.', name, version)
|
|
331
407
|
}
|
|
332
408
|
return { index, material, edges }
|
|
333
409
|
}
|
|
334
410
|
|
|
335
411
|
/**
|
|
336
|
-
*
|
|
412
|
+
* The root-relative directory a `file:` version installs from: v9 and v6
|
|
413
|
+
* append resolved peers as `(peer@1)`, v5 as `_peer@1`.
|
|
414
|
+
*/
|
|
415
|
+
function fileTarget(lock: Lockfile, version: string): string | undefined {
|
|
416
|
+
let rel = version.slice('file:'.length)
|
|
417
|
+
const paren = rel.indexOf('(')
|
|
418
|
+
if (paren !== -1) rel = rel.slice(0, paren)
|
|
419
|
+
else if (Number.parseInt(lock.version, 10) < 6) {
|
|
420
|
+
const underscore = rel.indexOf('_', rel.lastIndexOf('/') + 1)
|
|
421
|
+
if (underscore !== -1 && rel.includes('@', underscore)) rel = rel.slice(0, underscore)
|
|
422
|
+
}
|
|
423
|
+
return joinPosix('.', rel)
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Each importer's `link:` dependencies on other importers, and its
|
|
428
|
+
* `file:` ones that install an importer's directory: an injected
|
|
429
|
+
* workspace package (`dependenciesMeta.<name>.injected`, or pnpm 10
|
|
430
|
+
* `injectWorkspacePackages` with `dedupeInjectedDeps: false`). pnpm
|
|
431
|
+
* hard-links that copy and re-syncs it after the package's own scripts,
|
|
432
|
+
* so it is the workspace package and `^build` waits for it; yarn's
|
|
433
|
+
* packed `file:` copy is not. pnpm records
|
|
337
434
|
* an auto-installed peer (`autoInstallPeers`, the default) under
|
|
338
435
|
* `dependencies` like any other, so `peers` is empty here: the plugin
|
|
339
436
|
* asks the importer's manifest which of these are peers.
|
|
@@ -343,8 +440,11 @@ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImpor
|
|
|
343
440
|
for (const [dir, deps] of lock.importers) {
|
|
344
441
|
const links: Record<string, string> = {}
|
|
345
442
|
for (const [name, version] of deps) {
|
|
346
|
-
|
|
347
|
-
|
|
443
|
+
const target = version.startsWith('link:')
|
|
444
|
+
? joinPosix(dir, version.slice('link:'.length))
|
|
445
|
+
: version.startsWith('file:')
|
|
446
|
+
? fileTarget(lock, version)
|
|
447
|
+
: undefined
|
|
348
448
|
if (target !== undefined && target !== dir && lock.importers.has(target)) {
|
|
349
449
|
links[name] = target
|
|
350
450
|
}
|
package/src/syml.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// Berry's yarn.lock as Yarn reads it: every scalar is its source text.
|
|
2
|
+
// Yarn writes a plain scalar unquoted (`stream-source: 0.10`, `p: ~`), and
|
|
3
|
+
// a YAML parser reads it as a number or null: `0.10` became `0.1`, which
|
|
4
|
+
// keys a sibling range's entry (turborepo#6296). Yarn's own writer emits a
|
|
5
|
+
// fixed shape — `key:` opening a map indented under it, `key: value`,
|
|
6
|
+
// a key or value double-quoted as JSON, `#` comment lines — so this reads
|
|
7
|
+
// that shape and nothing more. Anything else (a list, a flow collection, a
|
|
8
|
+
// single-quoted or block scalar: a hand edit) returns undefined, and the
|
|
9
|
+
// caller reads the file as YAML.
|
|
10
|
+
|
|
11
|
+
type Doc = Record<string, unknown>
|
|
12
|
+
|
|
13
|
+
export function parseSyml(text: string): Doc | undefined {
|
|
14
|
+
const root: Doc = {}
|
|
15
|
+
const stack: { indent: number; map: Doc }[] = [{ indent: 0, map: root }]
|
|
16
|
+
let open: Doc | undefined
|
|
17
|
+
let at = 0
|
|
18
|
+
while (at < text.length) {
|
|
19
|
+
let end = text.indexOf('\n', at)
|
|
20
|
+
if (end === -1) end = text.length
|
|
21
|
+
let line = text.slice(at, end)
|
|
22
|
+
at = end + 1
|
|
23
|
+
if (line.endsWith('\r')) line = line.slice(0, -1)
|
|
24
|
+
let indent = 0
|
|
25
|
+
while (line.charCodeAt(indent) === 32) indent++
|
|
26
|
+
if (indent === line.length || line[indent] === '#') continue
|
|
27
|
+
if (line[indent] === '\t') return undefined
|
|
28
|
+
if (open !== undefined) {
|
|
29
|
+
if (indent > stack.at(-1)!.indent) stack.push({ indent, map: open })
|
|
30
|
+
open = undefined
|
|
31
|
+
}
|
|
32
|
+
while (indent < stack.at(-1)!.indent) stack.pop()
|
|
33
|
+
if (indent !== stack.at(-1)!.indent) return undefined
|
|
34
|
+
const map = stack.at(-1)!.map
|
|
35
|
+
let key: string
|
|
36
|
+
let rest: number
|
|
37
|
+
if (line[indent] === '"') {
|
|
38
|
+
const close = closingQuote(line, indent)
|
|
39
|
+
if (close === -1 || line[close + 1] !== ':') return undefined
|
|
40
|
+
const k = json(line.slice(indent, close + 1))
|
|
41
|
+
if (k === undefined) return undefined
|
|
42
|
+
key = k
|
|
43
|
+
rest = close + 2
|
|
44
|
+
} else {
|
|
45
|
+
const colon = line.indexOf(':', indent)
|
|
46
|
+
if (colon === -1 || PLAIN_START.includes(line[indent]!)) return undefined
|
|
47
|
+
key = line.slice(indent, colon).trimEnd()
|
|
48
|
+
rest = colon + 1
|
|
49
|
+
}
|
|
50
|
+
const value = line.slice(rest).trim()
|
|
51
|
+
if (value === '') {
|
|
52
|
+
open = {}
|
|
53
|
+
map[key] = open
|
|
54
|
+
continue
|
|
55
|
+
}
|
|
56
|
+
if (line[rest] !== ' ') return undefined
|
|
57
|
+
if (value[0] === '"') {
|
|
58
|
+
const v = json(value)
|
|
59
|
+
if (v === undefined) return undefined
|
|
60
|
+
map[key] = v
|
|
61
|
+
} else if (value === '{}') {
|
|
62
|
+
map[key] = {}
|
|
63
|
+
} else if (PLAIN_START.includes(value[0]!)) {
|
|
64
|
+
return undefined
|
|
65
|
+
} else {
|
|
66
|
+
map[key] = value
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return root
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A character no plain scalar of Yarn's writer starts with. */
|
|
73
|
+
const PLAIN_START = `-?:,[]{}#&*!|>'"%@\``
|
|
74
|
+
|
|
75
|
+
/** The index of the `"` closing the JSON string opened at `from`, or -1. */
|
|
76
|
+
function closingQuote(line: string, from: number): number {
|
|
77
|
+
for (let i = from + 1; i < line.length; i++) {
|
|
78
|
+
if (line[i] === '\\') i++
|
|
79
|
+
else if (line[i] === '"') return i
|
|
80
|
+
}
|
|
81
|
+
return -1
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function json(quoted: string): string | undefined {
|
|
85
|
+
try {
|
|
86
|
+
const v: unknown = JSON.parse(quoted)
|
|
87
|
+
return typeof v === 'string' ? v : undefined
|
|
88
|
+
} catch {
|
|
89
|
+
return undefined
|
|
90
|
+
}
|
|
91
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// What every task folds from the root package.json under yarn(), apart
|
|
2
|
+
// from the lockfile parser, which a warm run need not load for it.
|
|
3
|
+
import { record, stable, type Json } from './json.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The root package.json as every task folds it: `workspaces.nohoist`
|
|
7
|
+
* (Yarn 1 lays node_modules out by it) and `installConfig`, which
|
|
8
|
+
* yarn.lock records neither of. Its dependencies are the lockfile's to
|
|
9
|
+
* record. Undefined for a file that does not parse to a mapping, folded
|
|
10
|
+
* whole.
|
|
11
|
+
*/
|
|
12
|
+
export function packageSettings(text: string): string | undefined {
|
|
13
|
+
let doc: unknown
|
|
14
|
+
try {
|
|
15
|
+
doc = JSON.parse(text)
|
|
16
|
+
} catch {
|
|
17
|
+
return undefined
|
|
18
|
+
}
|
|
19
|
+
const map = record(doc)
|
|
20
|
+
if (map === undefined) return undefined
|
|
21
|
+
const kept: Json = {}
|
|
22
|
+
const nohoist = record(map['workspaces'])?.['nohoist']
|
|
23
|
+
if (nohoist !== undefined) kept['nohoist'] = nohoist
|
|
24
|
+
if (map['installConfig'] !== undefined) kept['installConfig'] = map['installConfig']
|
|
25
|
+
return `yarn-package\0${stable(kept)}`
|
|
26
|
+
}
|