@vzn/vx 0.0.18 → 0.0.22

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.
Files changed (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -1,40 +1,142 @@
1
+ import path from 'node:path'
1
2
  import type { ProjectMeta } from './workspace.js'
2
3
 
3
4
  export interface PackageGraph {
4
- /** Immediate workspace deps (sorted). */
5
+ /** Immediate workspace deps the package builds AFTER (sorted): every bucket but peers. */
5
6
  directDeps: (name: string) => string[]
6
- /** All transitive workspace deps for each project. */
7
+ /** All transitive workspace deps a change can reach the package through — peers included. */
7
8
  transitiveDeps: (name: string) => string[]
8
- /** All transitive workspace dependents (packages that depend on the named one). */
9
+ /** All transitive workspace dependents (packages a change here can reach) — peers included. */
9
10
  transitiveDependents: (name: string) => string[]
10
11
  }
11
12
 
12
- export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
13
+ /**
14
+ * `taskEdges`: project → the projects its tasks name in a cross-project
15
+ * `dependsOn` (`e2e` → `app` from `dependsOn: ['app#build']`). A dependent
16
+ * the manifest does not know but the task graph does; without it
17
+ * `--filter '...app'` never selected `e2e`, and a CI that runs "what
18
+ * changed and everything depending on it" silently left it out
19
+ * (2026-09-10). The `^task` walk still reads `directDeps`, which carries
20
+ * both — a task edge IS a dependency.
21
+ */
22
+ export function buildPackageGraph(
23
+ projects: ProjectMeta[],
24
+ taskEdges?: ReadonlyMap<string, readonly string[]>,
25
+ ): PackageGraph {
13
26
  const byName = new Map<string, ProjectMeta>()
14
- for (const p of projects) byName.set(p.name, p)
27
+ const byDir = new Map<string, ProjectMeta>()
28
+ for (const p of projects) {
29
+ byName.set(p.name, p)
30
+ byDir.set(path.resolve(p.dir), p)
31
+ }
32
+ const linked = linkedTarget(byName, byDir)
15
33
 
34
+ // Two adjacencies. ORDER (`directDeps`, the `^task` walk) is what the
35
+ // package imports at build time: dependencies, devDependencies,
36
+ // optionalDependencies, the task edges, and a workspace peer that does
37
+ // not close a cycle. REACH (the transitive closures `--filter pkg...`
38
+ // and `--affected` read) is ORDER plus every workspace peer.
39
+ //
40
+ // A peer on a workspace sibling is an import that resolves to that
41
+ // sibling's build: every package manager links or hoists it (pnpm's
42
+ // `linkWorkspacePackages`, the hoisted root of bun/npm/yarn), and
43
+ // TanStack/router's `router-devtools-core` peers on `router-core` and
44
+ // type-checks against its `dist` — Nx orders `^build` on the peer,
45
+ // vx built the devtools first and failed (2026-09-11). But peers are
46
+ // the one bucket that routinely cycles (medusa's test-utils peers on
47
+ // medusa, which dev-depends on it through analytics; Turbo, which
48
+ // reads no peers, runs it; an unconditional order edge made `^build`
49
+ // a task cycle, 2026-09-11), so a peer edge that would close a cycle
50
+ // through the order graph is reach only: the consumer above provides
51
+ // that peer.
52
+ //
53
+ // Which edge of a MUTUAL two-peer cycle stays is stable across runs,
54
+ // and the PACKAGE sort below is what makes it so: the first package
55
+ // in name order keeps its edge, whatever order the projects arrive
56
+ // in. The per-package peer sort provides none of that — measured
57
+ // (item 571), both manifest orders give identical graphs in every
58
+ // arrangement tried, because `reaches(peer, p)` walks edges INTO `p`
59
+ // and adding an edge OUT of `p` cannot change it. It stays as cheap
60
+ // insurance against a future rule that does depend on it; it is not
61
+ // what the stability rests on.
16
62
  const directDeps = new Map<string, string[]>()
17
- for (const p of projects) {
18
- const seen = new Set<string>()
19
- for (const field of [
20
- 'dependencies',
21
- 'devDependencies',
22
- 'peerDependencies',
23
- 'optionalDependencies',
24
- ] as const) {
25
- const obj = p.packageJson[field]
26
- if (!obj) continue
27
- for (const name of Object.keys(obj)) {
28
- if (name === p.name) continue
29
- if (byName.has(name)) seen.add(name)
63
+ const reachDeps = new Map<string, string[]>()
64
+ const order = new Map<string, Set<string>>()
65
+ const peers = new Map<string, Set<string>>()
66
+ const sorted = [...projects].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
67
+ for (const p of sorted) {
68
+ const own = new Set<string>()
69
+ for (const name of taskEdges?.get(p.name) ?? []) {
70
+ if (name !== p.name && byName.has(name)) own.add(name)
71
+ }
72
+ const optional = bucket(p.packageJson.optionalDependencies)
73
+ const prod = bucket(p.packageJson.dependencies)
74
+ const dev = bucket(p.packageJson.devDependencies)
75
+ const add = (key: string, spec: unknown, installed: boolean, into: Set<string>): void => {
76
+ if (typeof spec !== 'string') return
77
+ const target = linked(key, spec.trim(), p.dir, installed)
78
+ if (target !== undefined && target !== p.name) into.add(target)
79
+ }
80
+ // Which entry is installed when a key sits in more than one field,
81
+ // measured with bun 1.4, npm 10, yarn 1 and pnpm 12 (2026-09-24): bun
82
+ // and npm take devDependencies, then optionalDependencies, then
83
+ // dependencies; yarn and pnpm take optionalDependencies, then
84
+ // dependencies, then devDependencies. vx cannot tell which manager
85
+ // installed the tree, so either winner linking is an edge: a spare
86
+ // edge costs order, a missing one a stale hit. A dev or optional
87
+ // entry wins under one order or the other; a prod entry loses to an
88
+ // optional one under both.
89
+ for (const key in dev) add(key, dev[key], true, own)
90
+ for (const key in optional) add(key, optional[key], true, own)
91
+ for (const key in prod) if (!Object.hasOwn(optional, key)) add(key, prod[key], true, own)
92
+ order.set(p.name, own)
93
+ // A peer is not installed by the package that declares it: when an
94
+ // installed entry names the same key, that entry is what resolves
95
+ // (turborepo#12640: a registry `buffer@^6` dev dependency beside a
96
+ // `workspace:*` peer on it installs the registry copy in all four).
97
+ // Alone, a peer on a sibling resolves to the sibling whatever its
98
+ // range, since bun and yarn hoist the workspace copy (npm refuses an
99
+ // unmet one; pnpm 12 fetched it from the registry).
100
+ const peer = new Set<string>()
101
+ const peerDeps = bucket(p.packageJson.peerDependencies)
102
+ for (const key in peerDeps) {
103
+ if (!Object.hasOwn(optional, key) && !Object.hasOwn(prod, key) && !Object.hasOwn(dev, key)) {
104
+ add(key, peerDeps[key], false, peer)
105
+ }
106
+ }
107
+ peers.set(p.name, peer)
108
+ }
109
+ // `to` reaches `from` through the order edges so far ⇒ from → to
110
+ // would close a cycle.
111
+ const reaches = (start: string, goal: string): boolean => {
112
+ const seen = new Set<string>([start])
113
+ const stack = [start]
114
+ while (stack.length > 0) {
115
+ for (const next of order.get(stack.pop()!) ?? []) {
116
+ if (next === goal) return true
117
+ if (!seen.has(next)) {
118
+ seen.add(next)
119
+ stack.push(next)
120
+ }
30
121
  }
31
122
  }
32
- directDeps.set(p.name, [...seen].sort())
123
+ return false
124
+ }
125
+ for (const p of sorted) {
126
+ const own = order.get(p.name)!
127
+ for (const peer of [...peers.get(p.name)!].sort()) {
128
+ if (!own.has(peer) && !reaches(peer, p.name)) own.add(peer)
129
+ }
130
+ }
131
+ for (const p of sorted) {
132
+ const own = order.get(p.name)!
133
+ directDeps.set(p.name, [...own].sort())
134
+ reachDeps.set(p.name, [...new Set([...own, ...peers.get(p.name)!])].sort())
33
135
  }
34
136
 
35
137
  // Reverse adjacency: who declares X as a workspace dep.
36
138
  const directDependents = new Map<string, string[]>()
37
- for (const [name, deps] of directDeps) {
139
+ for (const [name, deps] of reachDeps) {
38
140
  for (const d of deps) {
39
141
  const arr = directDependents.get(d)
40
142
  if (arr) arr.push(name)
@@ -110,22 +212,24 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
110
212
  return [...seen].sort()
111
213
  }
112
214
 
215
+ // The closures are built on the FIRST query, not here: an unscoped run
216
+ // seeds every project and never asks for a transitive set, and neither
217
+ // does a `^task` walk (it reads `directDeps`). Building both closures
218
+ // eagerly was 12 ms of a 240 ms warm run at 1000 projects × 30 deps
219
+ // (profiled 2026-09-09) for answers nobody read.
113
220
  function makeAccessor(edges: Map<string, string[]>): (name: string) => string[] {
114
- const closures = bitsetClosures(edges)
115
221
  const memo = new Map<string, string[]>()
116
- if (closures === null) {
117
- return (name) => {
118
- const cached = memo.get(name)
119
- if (cached) return cached
120
- const result = reachableFrom(name, edges)
121
- memo.set(name, result)
122
- return result
123
- }
124
- }
222
+ let closures: Uint32Array | null | undefined
125
223
  const words = (names.length + 31) >>> 5
126
224
  return (name) => {
127
225
  const cached = memo.get(name)
128
226
  if (cached) return cached
227
+ if (closures === undefined) closures = bitsetClosures(edges)
228
+ if (closures === null) {
229
+ const result = reachableFrom(name, edges)
230
+ memo.set(name, result)
231
+ return result
232
+ }
129
233
  const i = index.get(name)
130
234
  if (i === undefined) return []
131
235
  const base = i * words
@@ -145,7 +249,111 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
145
249
 
146
250
  return {
147
251
  directDeps: (name) => directDeps.get(name) ?? [],
148
- transitiveDeps: makeAccessor(directDeps),
252
+ transitiveDeps: makeAccessor(reachDeps),
149
253
  transitiveDependents: makeAccessor(directDependents),
150
254
  }
151
255
  }
256
+
257
+ function bucket(field: unknown): Readonly<Record<string, unknown>> {
258
+ return typeof field === 'object' && field !== null ? (field as Record<string, unknown>) : {}
259
+ }
260
+
261
+ // The ranges a manifest carries, in the part of npm's grammar (node-semver)
262
+ // where `Bun.semver.satisfies` answers as npm does: `||`-joined sets, each
263
+ // a hyphen range, one comparator, or two or more `<`/`>` bounds, over
264
+ // versions whose wildcards trail (`1.x`, `1.2.*`). Bun 1.4.2 answers true
265
+ // for text that is no range at all (`latest`, `npm:foo@1`, `github:a/b`,
266
+ // `../x`), ORs bare comparators npm ANDs (`1 2`), and reads `>x` and a
267
+ // leading wildcard (`x.3.1`) its own way; a spec outside this grammar
268
+ // counts as unmet. One corner inside it still differs (item 831): a
269
+ // prerelease at a `<` bound over a partial (`<3.x`, `<3.1`) or at a hyphen
270
+ // range's partial end, beside a lower bound naming that prerelease's own
271
+ // version — `3.0.0-beta` against `>=3.0.0-alpha <3.x` — Bun admits and npm
272
+ // does not. bun installs by the first answer and the others by the second,
273
+ // so neither is right everywhere; the graph takes Bun's. The playground's
274
+ // port (packages/vx-docs, src/playground/shim/semver.ts) is held to Bun
275
+ // over this grammar.
276
+ const W = String.raw`[xX*]`
277
+ const PRE = String.raw`(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?`
278
+ const NUMERIC = String.raw`[vV]?\d+(?:\.${W}(?:\.${W})?|\.\d+(?:\.${W}|\.\d+${PRE})?)?`
279
+ const SINGLE = String.raw`(?:(?:[<>]=?|~>?|\^)\s*${NUMERIC}|=?\s*(?:${NUMERIC}|${W}(?:\.${W}){0,2}))`
280
+ const BOUND = String.raw`[<>]=?\s*${NUMERIC}`
281
+ const SET = String.raw`(?:${NUMERIC}\s+-\s+${NUMERIC}|${BOUND}(?:\s+${BOUND})+|${SINGLE})`
282
+ export const SEMVER_RANGE = new RegExp(String.raw`^(?:${SET}(?:\s*\|\|\s*${SET})*)?$`)
283
+ /** `<name>[@<range>]` after `npm:` or `workspace:`; the name may be scoped. */
284
+ const ALIAS = /^(@[^@/]+\/[^@/]+|[^@/.][^@/]*)(?:@(.*))?$/
285
+ const PATH_PROTOCOLS = ['file:', 'link:', 'portal:'] as const
286
+ const MAY_POINT_ELSEWHERE = /^(?:workspace:|npm:|file:|link:|portal:|\.\.?\/|\/)/
287
+
288
+ /**
289
+ * The workspace package a manifest entry resolves to, or undefined when
290
+ * the package manager installs it from elsewhere. `installed` is false
291
+ * for a peer, which the hoisted workspace copy under its key provides
292
+ * whatever the spec. The rule and its measurements are
293
+ * docs/modules/package-graph.md § Which entries are edges.
294
+ */
295
+ function linkedTarget(
296
+ byName: ReadonlyMap<string, ProjectMeta>,
297
+ byDir: ReadonlyMap<string, ProjectMeta>,
298
+ ): (key: string, spec: string, fromDir: string, installed: boolean) => string | undefined {
299
+ // A monorepo repeats a few ranges and versions thousands of times, and
300
+ // the grammar test and `Bun.semver` cost about a microsecond a call:
301
+ // unmemoised they tripled the graph build at 1000 projects × 30 deps.
302
+ const isRange = new Map<string, boolean>()
303
+ const range = (spec: string): boolean => {
304
+ let ok = isRange.get(spec)
305
+ if (ok === undefined) isRange.set(spec, (ok = SEMVER_RANGE.test(spec)))
306
+ return ok
307
+ }
308
+ const satisfiedBy = new Map<string, Map<string, boolean>>()
309
+ // A dist-tag (`latest`) is no range, and bun, npm, yarn and pnpm all
310
+ // installed it from the registry beside a matching workspace package.
311
+ const satisfies = (p: ProjectMeta, spec: string): boolean => {
312
+ if (spec === '*' || spec === '') return true
313
+ const version = p.packageJson.version
314
+ if (typeof version !== 'string' || !range(spec)) return false
315
+ let memo = satisfiedBy.get(version)
316
+ if (memo === undefined) satisfiedBy.set(version, (memo = new Map()))
317
+ let ok = memo.get(spec)
318
+ if (ok === undefined) memo.set(spec, (ok = Bun.semver.satisfies(version, spec)))
319
+ return ok
320
+ }
321
+ const named = (name: string, spec: string): string | undefined => {
322
+ const p = byName.get(name)
323
+ return p !== undefined && satisfies(p, spec) ? name : undefined
324
+ }
325
+ const atPath = (fromDir: string, rel: string): string | undefined =>
326
+ byDir.get(path.resolve(fromDir, rel))?.name
327
+ return (key, spec, fromDir, installed) => {
328
+ if (spec === 'workspace:*') return named(key, '*')
329
+ // Only a protocol or a path points a key at another package; every
330
+ // other spec (a range, a tag, a URL) is decided by the key.
331
+ if (!MAY_POINT_ELSEWHERE.test(spec)) {
332
+ const local = byName.get(key)
333
+ if (local === undefined) return undefined
334
+ // A catalog entry's range lives where the graph does not read
335
+ // (`pnpm-workspace.yaml`, bun's root `catalog`): it keeps the edge.
336
+ if (!installed || spec.startsWith('catalog:')) return key
337
+ return satisfies(local, spec) ? key : undefined
338
+ }
339
+ if (spec.startsWith('workspace:')) {
340
+ const rest = spec.slice('workspace:'.length)
341
+ if (rest === '^' || rest === '~') return named(key, '*')
342
+ if (range(rest)) return named(key, rest)
343
+ const alias = ALIAS.exec(rest)
344
+ if (alias !== null) return named(alias[1]!, alias[2] ?? '*')
345
+ return atPath(fromDir, rest)
346
+ }
347
+ if (spec.startsWith('npm:')) {
348
+ // bun links a satisfied alias; npm and yarn fetch it from the
349
+ // registry. The edge is the spare one, as above.
350
+ const alias = ALIAS.exec(spec.slice('npm:'.length))
351
+ return alias === null ? undefined : named(alias[1]!, alias[2] ?? '*')
352
+ }
353
+ for (const protocol of PATH_PROTOCOLS) {
354
+ if (spec.startsWith(protocol)) return atPath(fromDir, spec.slice(protocol.length))
355
+ }
356
+ // A bare `./`, `../` or `/` path is a directory to bun, npm and pnpm.
357
+ return atPath(fromDir, spec)
358
+ }
359
+ }