@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/src/json.ts CHANGED
@@ -29,3 +29,12 @@ export function peerOnly(e: Json): ReadonlySet<string> {
29
29
  export function record(v: unknown): Json | undefined {
30
30
  return v !== null && typeof v === 'object' && !Array.isArray(v) ? (v as Json) : undefined
31
31
  }
32
+
33
+ /** JSON with sorted keys, so YAML key order cannot move a digest. */
34
+ export function stable(v: unknown): string {
35
+ return JSON.stringify(v, (_k, val: unknown) =>
36
+ val !== null && typeof val === 'object' && !Array.isArray(val)
37
+ ? Object.fromEntries(Object.entries(val as Json).sort(([a], [b]) => (a < b ? -1 : 1)))
38
+ : val,
39
+ )
40
+ }
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
- let doc: unknown
36
- try {
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('package-lock.json: not an npm lockfile (no lockfileVersion)')
42
+ throw new Error('not an npm lockfile (no lockfileVersion)')
45
43
  }
46
44
  if (version < 2) {
47
45
  throw new Error(
48
- `package-lock.json: lockfileVersion ${version} has no \`packages\` map — npm 7+ writes version 2 or 3`,
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 globs = workspaceGlobs(raws[''])
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
- isWorkspace:
67
- p !== '' &&
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
- * The root's `workspaces` globs: what tells a workspace from a `file:`
88
- * directory, both entries outside node_modules that a link resolves to.
89
- * Taking every such entry for a workspace made `vendor/dep` a project, so
90
- * an edit under it selected nothing. `undefined` when the root names none.
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 workspaceGlobs(root: unknown): Bun.Glob[] | undefined {
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 undefined
96
- return list
97
- .filter((g): g is string => typeof g === 'string')
98
- .map((g) => new Bun.Glob(g.replace(/^\.\//, '').replace(/\/+$/, '')))
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, so what a task reads is its content now,
145
- // which the lockfile does not record: an edit to it replayed a stale hit.
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)!
@@ -0,0 +1,71 @@
1
+ // What every task folds from pnpm-workspace.yaml, apart from the lockfile
2
+ // parser: a warm run reads the settings on every key, and loading pnpm.ts
3
+ // for them cost ~1.5 ms a run.
4
+ import { record, stable, type Json } from './json.js'
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
+
9
+ /**
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.
14
+ */
15
+ export function workspaceSettings(text: string): string | undefined {
16
+ let doc: unknown
17
+ try {
18
+ doc = Bun.YAML.parse(text)
19
+ } catch {
20
+ return undefined
21
+ }
22
+ const map = record(doc)
23
+ if (map === undefined) return undefined
24
+ const rest: Json = {}
25
+ for (const [k, v] of Object.entries(map)) {
26
+ if (!RECORDED.has(k)) rest[k] = v
27
+ }
28
+ return `pnpm-workspace\0${stable(rest)}`
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
@@ -12,7 +12,7 @@
12
12
  // Bun.YAML is the parser: no dependency, and the file is plain YAML.
13
13
 
14
14
  import { reachDigests, type LockfileImporter } from '@vzn/vx'
15
- import { record } from './json.js'
15
+ import { record, stable } from './json.js'
16
16
  import { joinPosix } from './posix.js'
17
17
 
18
18
  export interface Lockfile {
@@ -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,85 @@ export function parseLockfile(text: string): Lockfile {
119
121
  ? { lockfileVersion: version, rest }
120
122
  : { lockfileVersion: version, rest, leading },
121
123
  )
122
- return { version, importers, snapshots, resolutions, patches, directories, global }
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
+ }
164
+ }
165
+
166
+ /**
167
+ * The importers' dependencies alone, as `parseLockfile` reads them. The
168
+ * staleness check asks for them on every run, after the digest memo spared
169
+ * the parse: solid's 6,900-line lockfile took 8 ms to parse whole and
170
+ * 0.4 ms for its `importers` block. Sliced from the last document to the
171
+ * next top-level key; any other shape is parsed whole.
172
+ */
173
+ export function recordedImporters(text: string): ReadonlyMap<string, ReadonlyMap<string, string>> {
174
+ const rawImporters = record(topLevel(text, 'importers'))
175
+ if (rawImporters === undefined) return parseLockfile(text).importers
176
+ const importers = new Map<string, ReadonlyMap<string, string>>()
177
+ for (const [dir, entry] of Object.entries(rawImporters)) importers.set(dir, depsOf(entry))
178
+ return importers
179
+ }
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]
123
203
  }
124
204
 
125
205
  /** `dependencies` + `devDependencies` + `optionalDependencies` of an entry, as name → version. */
@@ -153,37 +233,6 @@ function scalar(v: unknown): string {
153
233
  : ''
154
234
  }
155
235
 
156
- /**
157
- * pnpm-workspace.yaml as every task folds it: without `patchedDependencies`
158
- * and `overrides`, whose effect the lockfile records per project (pnpm 10+
159
- * keeps them here). Undefined for a file that does not parse to a mapping,
160
- * folded whole.
161
- */
162
- export function workspaceSettings(text: string): string | undefined {
163
- let doc: unknown
164
- try {
165
- doc = Bun.YAML.parse(text)
166
- } catch {
167
- return undefined
168
- }
169
- const map = record(doc)
170
- if (map === undefined) return undefined
171
- const rest: Yaml = {}
172
- for (const [k, v] of Object.entries(map)) {
173
- if (k !== 'patchedDependencies' && k !== 'overrides') rest[k] = v
174
- }
175
- return `pnpm-workspace\0${stable(rest)}`
176
- }
177
-
178
- /** JSON with sorted keys, so YAML key order cannot move a digest. */
179
- export function stable(v: unknown): string {
180
- return JSON.stringify(v, (_k, val: unknown) =>
181
- val !== null && typeof val === 'object' && !Array.isArray(val)
182
- ? Object.fromEntries(Object.entries(val as Yaml).sort(([a], [b]) => (a < b ? -1 : 1)))
183
- : val,
184
- )
185
- }
186
-
187
236
  /**
188
237
  * The snapshot key a dependency `name@version` resolves to, per lockfile
189
238
  * generation: `name@1.0.0(peer@2)` (v9), `/name@1.0.0(peer@2)` (v6),
@@ -240,8 +289,15 @@ export function importerDigests(
240
289
  // The global digest rides as DATA: Bun's xxHash3 reads only the low 32
241
290
  // bits of a seed, so two lockfiles' globals could share one (item 682).
242
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
243
299
  for (const dir of lock.importers.keys()) {
244
- const h = Bun.hash.xxHash3(`${global}\0${hashes[g.index.get(importerNode(dir))!]!}`)
300
+ const h = Bun.hash.xxHash3(`${global}\0${all ?? reachOf(dir)}`)
245
301
  out.set(dir, h.toString(16).padStart(16, '0'))
246
302
  }
247
303
  return out
@@ -268,12 +324,17 @@ function importerNode(dir: string): string {
268
324
  */
269
325
  export function fileDeps(lock: Lockfile): string[] {
270
326
  const out = new Set(lock.directories.values())
271
- for (const [dir, deps] of lock.importers) {
327
+ const add = (dir: string, deps: ReadonlyMap<string, string>): void => {
272
328
  for (const version of deps.values()) {
273
329
  const target = linkTarget(lock, dir, version)
274
330
  if (target !== undefined && !lock.importers.has(target)) out.add(target)
275
331
  }
276
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)
277
338
  return [...out].sort()
278
339
  }
279
340
 
@@ -324,19 +385,52 @@ function buildGraph(lock: Lockfile, files: ReadonlyMap<string, string>): Graph {
324
385
  // is a node of its own, so its version still counts.
325
386
  edges[from]!.push(node(key, `${key}\0${lock.resolutions.get(packageKey(key)) ?? ''}\0`))
326
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
+ }
327
398
  for (const [dir, deps] of lock.importers) {
328
399
  const from = index.get(importerNode(dir))!
400
+ named(from, deps)
329
401
  for (const [name, version] of deps) link(from, dir, name, version)
330
402
  }
331
403
  for (const [key, deps] of lock.snapshots) {
332
404
  const from = index.get(key)!
405
+ named(from, deps)
333
406
  for (const [name, version] of deps) link(from, '.', name, version)
334
407
  }
335
408
  return { index, material, edges }
336
409
  }
337
410
 
338
411
  /**
339
- * Each importer's `link:` dependencies on other importers. pnpm records
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
340
434
  * an auto-installed peer (`autoInstallPeers`, the default) under
341
435
  * `dependencies` like any other, so `peers` is empty here: the plugin
342
436
  * asks the importer's manifest which of these are peers.
@@ -346,8 +440,11 @@ export function importerLinks(lock: Lockfile): ReadonlyMap<string, LockfileImpor
346
440
  for (const [dir, deps] of lock.importers) {
347
441
  const links: Record<string, string> = {}
348
442
  for (const [name, version] of deps) {
349
- if (!version.startsWith('link:')) continue
350
- const target = joinPosix(dir, version.slice('link:'.length))
443
+ const target = version.startsWith('link:')
444
+ ? joinPosix(dir, version.slice('link:'.length))
445
+ : version.startsWith('file:')
446
+ ? fileTarget(lock, version)
447
+ : undefined
351
448
  if (target !== undefined && target !== dir && lock.importers.has(target)) {
352
449
  links[name] = target
353
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
+ }