@vzn/vx-lockfile 0.0.646 → 0.0.649

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/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)!
@@ -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
- * and `overrides`, whose effect the lockfile records per project (pnpm 10+
9
- * keeps them here). Undefined for a file that does not parse to a mapping,
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 !== 'patchedDependencies' && k !== 'overrides') rest[k] = v
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
- 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
+ }
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 doc = Math.max(text.lastIndexOf('\n---'), 0)
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${hashes[g.index.get(importerNode(dir))!]!}`)
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
- for (const [dir, deps] of lock.importers) {
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
- * 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
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
- if (!version.startsWith('link:')) continue
347
- 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
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
+ }