@vzn/vx 0.0.636 → 0.0.637

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 (141) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +213 -207
  3. package/docs/cli.md +699 -655
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +376 -198
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -63
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +103 -149
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +169 -70
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +24 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +158 -63
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +42 -29
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +113 -72
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +121 -95
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +209 -268
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +20 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,59 +1,37 @@
1
- import { constants, existsSync, type Dirent } from 'node:fs'
2
- import { access, readdir, realpath, stat } from 'node:fs/promises'
1
+ // What a project is (owner, 2026-10-14): a directory holding a
2
+ // `vx.config.*`. `vx lock` finds them (`findProjectConfigs`) and records
3
+ // each by directory; a run takes them from the lock (`lockedProjects`) and
4
+ // never walks. A project is named by `name` in its config, else by a
5
+ // `discover` plugin, else by its root-relative path, `//` for the root.
6
+ // Design: docs/design/language-agnostic-core-2026-10.md.
7
+ import type { Dirent } from 'node:fs'
8
+ import { lstat, readdir, stat } from 'node:fs/promises'
3
9
  import { homedir } from 'node:os'
4
10
  import path from 'node:path'
5
11
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
6
12
  import {
7
- BUN_GLOB_WILDCARDS,
8
- EXTGLOB,
13
+ executablePath,
14
+ gitEnv,
15
+ gitSpawnRefusal,
16
+ isExecutableMissing,
9
17
  isOutOfFds,
10
- isPermissionError,
11
18
  relPosix,
12
- slashBraceExpansions,
13
19
  UserError,
14
- normalizeBunGlob,
15
- realpathOf,
16
20
  } from '../util/index.js'
17
- import { type LoadReads, readOnce, unreadable } from './load-reads.js'
21
+ import { gitTop, workspaceFile, workspaceRootOf } from '../handoff.js'
18
22
  import { repoIdOf } from './repo-id.js'
19
23
 
20
- export interface PackageJson {
21
- name: string
22
- version?: string
23
- dependencies?: Record<string, string>
24
- devDependencies?: Record<string, string>
25
- peerDependencies?: Record<string, string>
26
- optionalDependencies?: Record<string, string>
27
- /** npm / yarn / bun workspaces. May be a glob array or { packages: [...] }. */
28
- workspaces?: string[] | { packages?: string[] }
29
- }
30
-
31
- export interface Workspace {
32
- root: string
33
- /** Glob patterns relative to root that match project directories. */
34
- packageGlobs: string[]
35
- /** The package manager's catalogs, when the root declares any. */
36
- catalogs?: Catalogs
37
- }
38
-
39
- /**
40
- * Catalog name → dependency → the spec a `catalog:<name>` entry stands
41
- * for; `catalog:` alone is `default`. pnpm's from `pnpm-workspace.yaml`
42
- * (`catalog`, `catalogs`), bun's from the root `package.json` (the same
43
- * keys, top level or under `workspaces`).
44
- */
45
- export type Catalogs = ReadonlyMap<string, Readonly<Record<string, unknown>>>
46
-
47
24
  export interface ProjectMeta {
48
- /** Canonical name from package.json. */
25
+ /** `name` in its config, else a `discover` plugin's, else its root-relative path (`//` for the root). */
49
26
  name: string
50
27
  /** Absolute path to the project directory. */
51
28
  dir: string
52
- packageJson: PackageJson
53
- /** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs} or null. */
29
+ /** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs}; null for a project a plugin added. */
54
30
  configPath: string | null
55
- /** The workspace's catalogs, which the package graph resolves `catalog:` specs through. */
56
- catalogs?: Catalogs
31
+ /** Projects a `discover` plugin says this one builds after (order and reach). */
32
+ deps?: readonly string[]
33
+ /** Projects a `discover` plugin says a change reaches this one through, with no order. */
34
+ reach?: readonly string[]
57
35
  }
58
36
 
59
37
  /** A discovered project joined with its loaded vx config. */
@@ -74,399 +52,73 @@ export const PROJECT_CONFIG_FILENAMES = [
74
52
  'vx.config.cjs',
75
53
  ]
76
54
 
77
- const decoder = new TextDecoder()
78
-
79
55
  /**
80
- * Walk up from `start` to find the workspace root. A directory is a root
81
- * CANDIDATE when it contains `pnpm-workspace.yaml` or a `package.json`.
56
+ * The workspace root for `start`: the directory holding the first
57
+ * `.vx/workspace.*` from `start` up to the git top-level, or up to `/`
58
+ * outside git ({@link workspaceFile}, shared with the hand-off). Nothing
59
+ * else decides it (owner, 2026-10-13: "Workspace file is the ultimate"):
60
+ * not a `package.json`, not `pnpm-workspace.yaml`, not a lockfile. Throws
61
+ * `VX_E_NO_WORKSPACE` when there is none.
82
62
  *
83
- * The nearest candidate that CLAIMS `start` wins — one of the directories
84
- * between it and `start` matches one of its package globs. Every workspace
85
- * member has its own `package.json`, so stopping at the first candidate would
86
- * make a run from inside a package treat that package as the whole workspace:
87
- * `^task` edges vanish, upstream hashes drop out of the cache key (stale
88
- * hits), and a second cache dir appears under the member. Claiming is decided
89
- * with the same globs `loadWorkspace` applies, so "the root that claims me"
90
- * and "the root that lists me as a project" cannot diverge. An outer root
91
- * that lists both the claimer and the claimed member outranks the claimer:
92
- * from the claimer's own directory the walk reaches the outer root too.
93
- *
94
- * When no candidate claims `start` — a standalone package, or a subdirectory
95
- * of a single-project repo — the nearest candidate wins (the root itself IS
96
- * the project). Throws a `UserError` when there is no candidate before `/`.
97
- * A load that goes on to `loadWorkspace` passes its `reads`, so the
98
- * root's manifest is read once for both.
63
+ * Outside git, a file another user owns is refused (root aside): a
64
+ * `.vx/workspace.ts` planted in a shared directory above you (`/tmp`) is
65
+ * code that would run as you. The owner is the file's own, a link's
66
+ * included, and its `.vx` directory's. Inside git the walk never leaves your
67
+ * checkout. The directory you run in is yours to judge, and costs no
68
+ * stat. Run as root, vx trusts every owner, as a container over a mounted
69
+ * checkout needs. `uid` is for tests.
99
70
  */
100
71
  export async function findWorkspaceRoot(
101
72
  start: string,
102
- reads: LoadReads = new Map(),
73
+ uid: number = process.getuid?.() ?? 0,
103
74
  ): Promise<string> {
104
- let dir = path.resolve(start)
105
- const below: string[] = []
106
- let nearest: string | null = null
107
- // The nearest manifest with workspace globs of its own, once passed: an
108
- // outer root claims the tree only by listing this directory itself.
109
- let inner: string | null = null
110
- // The root that claims `start`, and the members it claimed it through.
111
- let claimed: string | null = null
112
- let members: string[] = []
113
- while (true) {
114
- let globs: string[] | null
115
- try {
116
- globs = await readPackageGlobs(dir, reads)
117
- } catch (err) {
118
- if (isOutOfFds(err)) throw err
119
- // An unparseable manifest is still a root SIGNAL (the pre-existing
120
- // behaviour probed only for existence); it just can't claim members.
121
- // `loadWorkspace` surfaces the parse error if this dir is chosen.
122
- globs = []
123
- }
124
- if (globs !== null) {
125
- nearest ??= dir
126
- if (claimed !== null) {
127
- // An outer root that lists both the claimer and the member owns
128
- // them: `packages/**` holding `packages/inner` (with `workspaces:
129
- // ['sub']`) and `packages/inner/sub` resolved `sub` to `inner` and
130
- // `inner` to the outer root, two roots and two keys for one tree.
131
- if (
132
- (await claimsMember(dir, [claimed], globs)) &&
133
- (await claimsMember(dir, members, globs))
134
- )
135
- claimed = dir
136
- } else if (await claimsMember(dir, inner === null ? below : [inner], globs)) {
137
- claimed = dir
138
- members = inner === null ? [...below] : [inner]
139
- }
140
- // pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
141
- // not. Walking past it, `apps/inner` resolved to the outer workspace
142
- // while `apps/inner/pkgs/x` resolved to the inner one: two roots and
143
- // two caches for one tree (item 990). Already read: `reads` holds it.
144
- if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) {
145
- return claimed ?? dir
146
- }
147
- }
148
- const parent = path.dirname(dir)
149
- if (parent === dir) break
150
- // Only a directory holding a manifest can be a member: a glob that
151
- // matched `packages/tools`, which has none, claimed the standalone
152
- // package below it, and `packages/tools/standalone` ran as a stranger
153
- // in a workspace that does not list it (item 989).
154
- if (globs !== null) below.push(dir)
155
- // `apps/tool/workspace` with its own `workspaces`, under a root listing
156
- // `apps/*`, resolved to the outer root through `apps/tool`, and its
157
- // members ran in a workspace that does not list them (D-137). npm makes
158
- // an outer root own a nested one only when it lists that directory.
159
- if (inner === null && globs !== null && globs.length > 0) inner = dir
160
- dir = parent
161
- }
162
- if (claimed !== null) return claimed
163
- if (nearest !== null) return nearest
164
- throw new UserError(
165
- `Could not find a workspace root in any parent of ${start} ` +
166
- `(looked for pnpm-workspace.yaml or package.json): run vx inside a project, ` +
167
- `or create a package.json (\`bun init\` or \`npm init -y\`) and run \`vx init\``,
168
- 'VX_E_NO_WORKSPACE',
169
- )
170
- }
171
-
172
- /** True when one of `below` (dirs under `root`, toward `start`) is a member. */
173
- async function claimsMember(
174
- root: string,
175
- below: readonly string[],
176
- globs: readonly string[],
177
- ): Promise<boolean> {
178
- if (below.length === 0 || globs.length === 0) return false
179
- const { positive, negative } = splitPackageGlobs(globs)
180
- const rels = below.map((d) => relPosix(root, d)).filter((rel) => !excludedBy(rel, negative))
181
- for (const pattern of positive) {
182
- const normalized = pattern.replace(/\/+$/, '')
183
- // `.` means the root itself is the project — never a directory below it.
184
- if (normalized === '' || normalized === '.') continue
185
- // Matched as discovery scans, on the manifest: `packages/**` lists
186
- // `packages/package.json`, while against the directory `**` needs a
187
- // segment below `packages`, and a run from there was its own root.
188
- const glob = new Bun.Glob(`${normalized}/package.json`)
189
- for (const rel of rels) {
190
- if (!glob.match(`${rel}/package.json`)) continue
191
- // `match` has no `dot: false`: `packages/*` matched `packages/.tpl`,
192
- // which discovery skips, and a run from inside it found a workspace
193
- // that does not list it. Such a path asks discovery's own walk.
194
- if (!SKIPPED_SEGMENT.test(rel)) return true
195
- const dirs = await memberDirs(root, pattern)
196
- if (dirs.includes(path.join(root, rel))) return true
197
- }
198
- }
199
- return false
200
- }
201
-
202
- const SKIPPED_SEGMENT = /(^|\/)(\.|node_modules(\/|$))/
203
-
204
- /**
205
- * pnpm, npm, yarn and Bun all take `!packages/fixtures` in the package
206
- * list. Handed to `Bun.Glob` raw, a leading `!` negates the WHOLE pattern,
207
- * so `!packages/fixtures/package.json` matched every manifest in the tree
208
- * and every directory holding one became a project — the excluded package
209
- * ran under `--all`, and a fixture repeating a name killed the run with
210
- * "Duplicate package name". Negations subtract from what the positive
211
- * globs found; a literal one excludes its tree.
212
- */
213
- function splitPackageGlobs(globs: readonly string[]): { positive: string[]; negative: string[] } {
214
- const positive: string[] = []
215
- const negative: string[] = []
216
- // `!./packages/legacy` and `!packages//legacy` excluded nothing, silently
217
- // (2026-09-10): the same spellings the input globs normalize.
218
- for (const raw of globs) {
219
- // A member glob names directories, so a trailing slash says nothing:
220
- // npm and pnpm read `packages/*/` as `packages/*`. `normalizeBunGlob`
221
- // gives it a task glob's meaning, `/**`, and `packages/*/` found every
222
- // example and fixture package at any depth below the members (item 985).
223
- const g = normalizeBunGlob(raw.replace(/(.)\/+$/, '$1'))
224
- if (g.startsWith('!')) negative.push(g.slice(1).replace(/\/+$/, ''))
225
- else positive.push(g)
226
- }
227
- return { positive, negative }
228
- }
229
-
230
- function excludedBy(rel: string, negative: readonly string[]): boolean {
231
- for (const neg of negative) {
232
- if (neg.length === 0) continue
233
- if (rel === neg || rel.startsWith(`${neg}/`)) return true
234
- if (!BUN_GLOB_WILDCARDS.test(neg)) continue
235
- if (new Bun.Glob(neg).match(rel)) return true
236
- // pnpm matches the MANIFEST (`<pattern>/package.json`), so its documented
237
- // `!**/test/**` excludes `packages/test` itself; matched against the
238
- // directory, `**/test/**` needs something below `test` and the package
239
- // stayed a member (item 986).
240
- if (new Bun.Glob(`${neg}/package.json`).match(`${rel}/package.json`)) return true
241
- }
242
- return false
243
- }
244
-
245
- /**
246
- * Package globs declared by `dir`, or `null` when `dir` is not a root
247
- * candidate. A bare `package.json` (no `workspaces`) is single-project mode:
248
- * the root itself is the only project, hence `['.']`.
249
- */
250
- async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[] | null> {
251
- return (await readRootManifests(dir, reads)).globs
252
- }
253
-
254
- /** `readPackageGlobs`, and the catalogs the same manifests declare. */
255
- async function readRootManifests(
256
- dir: string,
257
- reads?: LoadReads,
258
- ): Promise<{ globs: string[] | null; catalogs: Catalogs | undefined }> {
259
- const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
260
- const yaml = await readOnce(reads, yamlPath)
261
- let yamlWithoutPackages = false
262
- // pnpm reads its catalogs from this file alone, bun from package.json.
263
- let catalogs: Catalogs | undefined
264
- if (yaml !== null) {
265
- const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
266
- if (parsed !== null && parsed !== undefined) {
267
- if (typeof parsed !== 'object' || Array.isArray(parsed)) {
268
- throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
269
- }
270
- catalogs = catalogsOf([parsed])
271
- const packages = (parsed as { packages?: unknown }).packages
272
- // A list commented out leaves `packages:` null, which pnpm reads as
273
- // absent; vx refused it as "must be an array" (D-149).
274
- if (packages !== undefined && packages !== null) {
275
- return { globs: assertGlobList(packages, yamlPath, 'packages'), catalogs }
276
- }
277
- }
278
- // No `packages:` (or an empty one): pnpm 10 keeps its settings and catalogs in this file
279
- // for a single-package repo too. The root's package.json decides, as it
280
- // would without the file; read as an empty list, the root found zero
281
- // projects and every verb ran nothing and exited 0 (item 984).
282
- yamlWithoutPackages = true
283
- }
284
- const pkgPath = path.join(dir, 'package.json')
285
- const pkgBytes = await readOnce(reads, pkgPath)
286
- if (pkgBytes === null) return { globs: yamlWithoutPackages ? [] : null, catalogs }
287
- const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
288
- const ws = pkg.workspaces as unknown
289
- if (yaml === null) {
290
- catalogs = catalogsOf(
291
- ws !== null && typeof ws === 'object' && !Array.isArray(ws) ? [pkg, ws] : [pkg],
75
+ start = path.resolve(start)
76
+ const file = await workspaceFile(start)
77
+ if (file === null) {
78
+ throw new UserError(
79
+ `no .vx/workspace.ts in ${start} or a parent${(await gitTop(start)) === null ? '' : ' up to the git root'} — ` +
80
+ 'run `vx init` to create one',
81
+ 'VX_E_NO_WORKSPACE',
292
82
  )
293
83
  }
294
- if (ws === undefined || ws === null) return { globs: ['.'], catalogs }
295
- // bun's `{ catalog }` and yarn's `{ nohoist }` name no members, and both
296
- // managers run the root alone; vx refused them as no array (D-151).
297
- if (typeof ws === 'object' && !Array.isArray(ws) && !('packages' in ws)) {
298
- return { globs: ['.'], catalogs }
299
- }
300
- if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
301
- const packages = (ws as { packages?: unknown }).packages
302
- // Yarn 1 and 4 run the root alone under `packages: null`; read as an
303
- // empty list, the root was no project and every verb ran nothing (D-150).
304
- if (packages === null) return { globs: ['.'], catalogs }
305
- return { globs: assertGlobList(packages, pkgPath, 'workspaces.packages'), catalogs }
306
- }
307
- return { globs: assertGlobList(ws, pkgPath, 'workspaces'), catalogs }
308
- }
309
-
310
- /**
311
- * The `catalog` and `catalogs` keys of the manifests (bun reads them at
312
- * the top level and under `workspaces`), or undefined when none declares
313
- * one. An entry that is not a mapping is no catalog: a `catalog:` spec
314
- * through it keeps its edge, as one through an unknown catalog does.
315
- */
316
- function catalogsOf(manifests: readonly object[]): Catalogs | undefined {
317
- const isMap = (v: unknown): v is Record<string, unknown> =>
318
- typeof v === 'object' && v !== null && !Array.isArray(v)
319
- let out: Map<string, Readonly<Record<string, unknown>>> | undefined
320
- for (const m of manifests) {
321
- const { catalog, catalogs } = m as { catalog?: unknown; catalogs?: unknown }
322
- if (isMap(catalogs)) {
323
- for (const [name, c] of Object.entries(catalogs))
324
- if (isMap(c)) (out ??= new Map()).set(name, c)
325
- }
326
- // `catalog` is the default catalog to pnpm and bun.
327
- if (isMap(catalog)) (out ??= new Map()).set('default', catalog)
328
- }
329
- return out
330
- }
331
-
332
- /**
333
- * Package manifests the globs never reach, in single-project mode only: a
334
- * root `package.json` with no `workspaces` field makes the root the one
335
- * project, and a `packages/app/package.json` full of scripts beside it is
336
- * then invisible — `vx init` said "no package.json scripts" and `vx run`
337
- * said "run vx init" (2026-09-16). One shallow scan, two levels, on the
338
- * failure path only; `node_modules` and dot directories are skipped.
339
- */
340
- export async function unreachedPackages(workspace: Workspace): Promise<string[]> {
341
- if (workspace.packageGlobs.length !== 1 || workspace.packageGlobs[0] !== '.') return []
342
- const found: string[] = []
343
- // Two scans: Bun.Glob does not expand a brace whose alternatives hold a
344
- // slash (`{*,*/*}/package.json` matched nothing, measured 2026-09-16).
345
- for (const pattern of ['*/package.json', '*/*/package.json']) {
346
- for await (const rel of new Bun.Glob(pattern).scan({
347
- cwd: workspace.root,
348
- onlyFiles: true,
349
- dot: false,
350
- })) {
351
- const posix = rel.split(path.sep).join('/')
352
- if (posix.startsWith('node_modules/') || posix.includes('/node_modules/')) continue
353
- found.push(posix.slice(0, -'/package.json'.length))
354
- }
355
- }
356
- return found.sort()
357
- }
358
-
359
- /**
360
- * The line for `unreachedPackages`' finding: the cause, the packages, the
361
- * glob to add, and where. A `pnpm-workspace.yaml` with no `packages:` (pnpm
362
- * keeps its settings there) is where pnpm reads the globs: the hint said
363
- * there was no such file and named package.json, beside ngrx's (2026-10-07).
364
- */
365
- export function unreachedHint(unreached: readonly string[], root: string): string {
366
- const n = unreached.length
367
- const shown = unreached.slice(0, 3).join(', ') + (n > 3 ? ` and ${n - 3} more` : '')
368
- const globs = [
369
- ...new Set(unreached.map((d) => (d.includes('/') ? `${path.posix.dirname(d)}/*` : d))),
370
- ]
371
- .map((g) => `"${g}"`)
372
- .join(', ')
373
- const rest = `so the root is the only project and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}.`
374
- return existsSync(path.join(root, 'pnpm-workspace.yaml'))
375
- ? `pnpm-workspace.yaml lists no \`packages\`, ${rest} Add \`packages: [${globs}]\` to pnpm-workspace.yaml and re-run.`
376
- : `package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), ${rest} ` +
377
- `Add \`"workspaces": [${globs}]\` to package.json and re-run.`
378
- }
379
-
380
- /**
381
- * Parse a workspace manifest, naming the FILE on failure. The raw parser
382
- * errors (`Failed to parse JSON`) carry no path, so in a 1000-package
383
- * monorepo they say nothing about which manifest is broken.
384
- */
385
- function parseManifest(text: string, file: string, parse: (t: string) => unknown): unknown {
386
- try {
387
- return parse(text)
388
- } catch (err) {
389
- const msg = err instanceof Error ? err.message : String(err)
390
- throw new UserError(`failed to parse ${file}: ${msg}`)
391
- }
392
- }
393
-
394
- /**
395
- * A `package.json` as discovery reads it: a JSON object whose `name`, when
396
- * present, is a string with no surrounding whitespace (npm refuses one).
397
- * Parsed and cast unchecked, `null` crashed with a TypeError and a stack,
398
- * `{"name":123}` planned `123#build`, and `" a"` beside `"a"` made two
399
- * projects (item 988).
400
- */
401
- function parsePackageJson(text: string, file: string): PackageJson {
402
- const pkg = parseManifest(text, file, JSON.parse)
403
- if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
404
- throw new UserError(`${file}: must be a JSON object`)
405
- }
406
- const name = (pkg as { name?: unknown }).name
407
- if (name !== undefined && (typeof name !== 'string' || name.trim() !== name)) {
408
- throw new UserError(`${file}: "name" must be a string with no surrounding whitespace`)
409
- }
410
- // A task is `<project>#<task>` split at the first `#`: `"a#b"` planned
411
- // under `--all` but no run spec or dependsOn could reach it (D-130).
412
- if (typeof name === 'string' && name.includes('#')) {
413
- throw new UserError(`${file}: "name" cannot hold "#" — vx addresses a task as <name>#<task>`)
414
- }
415
- return pkg as PackageJson
416
- }
417
-
418
- /**
419
- * A glob list is user input from a manifest: `packages: "packages/*"`
420
- * (a bare string instead of a list) is an easy YAML slip, and without
421
- * this it surfaces as `workspace.packageGlobs.map is not a function`.
422
- */
423
- function assertGlobList(value: unknown, file: string, field: string): string[] {
424
- if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
425
- throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
426
- }
427
- for (const pattern of value as string[]) {
428
- if (EXTGLOB.test(pattern)) throw extglobRefusal(pattern, file, field)
429
- }
430
- return value as string[]
431
- }
432
-
433
- function extglobRefusal(pattern: string, file: string, field: string): UserError {
434
- const head = `${file}: \`${field}\` entry "${pattern}" is an extglob, which vx's glob engine does not read`
435
- // The common shape, a whole segment excluding plain names, has an exact
436
- // rewrite in the `!` entries every package manager takes.
437
- const segments = pattern.split('/')
438
- const at = segments.findIndex((s) => /^!\([^()]*\)$/.test(s))
439
- const names = at === -1 ? [] : segments[at]!.slice(2, -1).split('|')
440
- const plain = names.every((n) => n !== '' && !BUN_GLOB_WILDCARDS.test(n) && !/[()!@+]/.test(n))
441
- const rest = segments.filter((_s, i) => i !== at).join('/')
442
- if (at === -1 || !plain || EXTGLOB.test(rest)) {
443
- return new UserError(
444
- `${head}. List the members with \`*\`, braces or \`!\` exclusions instead.`,
84
+ const root = workspaceRootOf(file)
85
+ if (uid !== 0 && root !== start && (await gitTop(start)) === null) {
86
+ // The link itself and the `.vx` holding it, not what a link names: a
87
+ // link another user planted to a root-owned file passed as root's, and
88
+ // its owner could point it at their own code once it had.
89
+ const owners = await Promise.all(
90
+ [lstat(file), stat(path.dirname(file))].map((p) =>
91
+ p.then(
92
+ (st) => st.uid,
93
+ () => uid,
94
+ ),
95
+ ),
445
96
  )
97
+ if (owners.some((owner) => owner !== uid && owner !== 0)) {
98
+ throw new UserError(
99
+ `${file} belongs to another user, so vx does not take its directory as your workspace: ` +
100
+ 'its config would run as you. Run vx in a checkout you own, or run `vx init` in yours',
101
+ 'VX_E_NO_WORKSPACE',
102
+ )
103
+ }
446
104
  }
447
- const before = segments.slice(0, at)
448
- const rewrite = [
449
- [...before, '*', ...segments.slice(at + 1)].join('/'),
450
- ...names.map((n) => `!${[...before, n].join('/')}`),
451
- ]
452
- return new UserError(
453
- `${head} (npm and yarn read \`!(…)\` as an exclusion). List the exclusion as its own entry: [${rewrite.map((r) => JSON.stringify(r)).join(', ')}].`,
454
- )
105
+ return root
455
106
  }
456
107
 
457
108
  /**
458
- * The workspace's own cache directory (index, history, memos): the
459
- * workspace's `cacheDir`, else `VX_CACHE_DIR`, else `.vx/cache`, relative
460
- * to the workspace root.
109
+ * The workspace's own cache directory (index, history, memos):
110
+ * `VX_CACHE_DIR`, else the workspace's `cache.dir`, else `.vx/cache`,
111
+ * relative to the workspace root (`--cache-dir` is the caller's, above both).
461
112
  */
462
113
  export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string {
463
114
  const env = process.env['VX_CACHE_DIR']
464
- // `cacheDir: ' '` is refused at load (X-22); the variable made the same
115
+ // `cache.dir: ' '` is refused at load (X-22); the variable made the same
465
116
  // directory named in spaces at the root (D-160).
466
- if (config?.cacheDir === undefined && env !== undefined && env !== '' && env.trim() === '') {
117
+ if (env !== undefined && env !== '' && env.trim() === '') {
467
118
  throw new UserError('VX_CACHE_DIR is only whitespace — name a directory, or unset it')
468
119
  }
469
- const rel = config?.cacheDir ?? (env || path.join('.vx', 'cache'))
120
+ const fromEnv = env !== undefined && env !== ''
121
+ const rel = (fromEnv ? env : config?.cache?.dir) ?? path.join('.vx', 'cache')
470
122
  const home = process.env['HOME'] || homedir()
471
123
  // No shell expands `~` in a config string or a quoted variable, and
472
124
  // `'~/.cache/vx'` made a directory named `~` in the workspace.
@@ -475,7 +127,7 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
475
127
  // The cache writes a `*` .gitignore beside its index: a bare `~` made a
476
128
  // directory named `~`, and expanded it would land in home itself (D-153).
477
129
  if (path.resolve(dir) === path.resolve(home)) {
478
- const source = config?.cacheDir !== undefined ? 'cacheDir' : 'VX_CACHE_DIR'
130
+ const source = fromEnv ? 'VX_CACHE_DIR' : 'cache.dir'
479
131
  throw new UserError(
480
132
  `${source} ${JSON.stringify(rel)} is the home directory itself, where the cache's \`*\` .gitignore and index would land — name a directory under it, like '~/.cache/vx'`,
481
133
  )
@@ -487,7 +139,7 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
487
139
  * Where this repository's shared store lives: `~/.vx/<repo id>/cache`, as
488
140
  * Nx 23 keeps `~/.nx/<id>/cache` (owner, 2026-10-06), so every checkout of
489
141
  * the repository hits what another saved and no other repository shares
490
- * it (`repoIdOf`). Null when the workspace names its cache (`cacheDir`,
142
+ * it (`repoIdOf`). Null when the workspace names its cache (`cache.dir`,
491
143
  * `VX_CACHE_DIR`): that directory then holds everything, as one
492
144
  * workspace's alone. Null with no repository identity or no home.
493
145
  */
@@ -495,7 +147,7 @@ export async function resolveStoreRoot(
495
147
  root: string,
496
148
  config: WorkspaceConfig | null,
497
149
  ): Promise<string | null> {
498
- if (config?.cacheDir !== undefined || process.env['VX_CACHE_DIR']) return null
150
+ if (config?.cache?.dir !== undefined || process.env['VX_CACHE_DIR']) return null
499
151
  // HOME first: Bun's homedir() keeps the HOME the process started with (1.4.2).
500
152
  const home = process.env['HOME'] || homedir()
501
153
  if (!path.isAbsolute(home)) return null
@@ -508,483 +160,362 @@ export async function resolveStoreRoot(
508
160
  const repoIds = new Map<string, Promise<string | null>>()
509
161
 
510
162
  /**
511
- * Read the workspace's package-glob list, supporting all common
512
- * package managers:
513
- * - `pnpm-workspace.yaml` (pnpm)
514
- * - `package.json` `workspaces` array (npm / yarn / bun)
515
- * - `package.json` `workspaces.packages` array (yarn legacy)
516
- *
517
- * If a `package.json` exists with no `workspaces` field, the root
518
- * itself is treated as a single-project workspace.
163
+ * Sets each project's `deps` / `reach` from the `discover` entries' edge
164
+ * directories, resolved against every project the stage left. A target
165
+ * outside the root or that is no project is refused naming the plugin.
519
166
  */
520
- export async function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace> {
521
- const { globs: packageGlobs, catalogs } = await readRootManifests(root, reads)
522
- if (packageGlobs === null) {
523
- // The CLI never lands here (findWorkspaceRoot returns only a dir that
524
- // passes one of the two checks); a direct call of this public function
525
- // on a bare dir does (D-58).
526
- throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
167
+ export function linkDiscoveredEdges(
168
+ root: string,
169
+ projects: readonly ProjectMeta[],
170
+ edges: ReadonlyArray<{
171
+ readonly by: string
172
+ readonly meta: ProjectMeta
173
+ readonly deps: readonly string[] | undefined
174
+ readonly reach: readonly string[]
175
+ }>,
176
+ ): void {
177
+ if (edges.length === 0) return
178
+ const byDir = new Map(projects.map((p) => [p.dir, p.name]))
179
+ const deps = new Map<ProjectMeta, Set<string>>()
180
+ const reach = new Map<ProjectMeta, Set<string>>()
181
+ for (const edge of edges) {
182
+ // An entry carrying `deps`, even none, makes its project a leaf of an
183
+ // edge source rather than a project no plugin spoke for.
184
+ if (edge.deps !== undefined && !deps.has(edge.meta)) deps.set(edge.meta, new Set())
185
+ for (const [kind, into] of [
186
+ [edge.deps ?? [], deps],
187
+ [edge.reach, reach],
188
+ ] as const) {
189
+ for (const target of kind) {
190
+ const dir = path.resolve(root, target)
191
+ const rel = relPosix(root, dir)
192
+ const where = `plugin '${edge.by}' gave project "${edge.meta.name}" an edge to ${target}`
193
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
194
+ throw new UserError(`${where}: outside the workspace root`)
195
+ }
196
+ const name = byDir.get(dir)
197
+ if (name === undefined) throw new UserError(`${where}: not a project`)
198
+ let set = into.get(edge.meta)
199
+ if (set === undefined) into.set(edge.meta, (set = new Set()))
200
+ set.add(name)
201
+ }
202
+ }
527
203
  }
528
- return catalogs === undefined ? { root, packageGlobs } : { root, packageGlobs, catalogs }
204
+ for (const [meta, names] of deps) meta.deps = [...names].sort()
205
+ for (const [meta, names] of reach) meta.reach = [...names].sort()
529
206
  }
530
207
 
531
- // `<dir>/*` — the shape of nearly every workspace glob. Anything with another
532
- // glob character (`**`, `{a,b}`, `?`, `[`) or a negation takes the general
533
- // path.
534
- const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
208
+ /** The root project's name when nothing names it: Turbo's spelling, and the root alias in filters and `dependsOn`. */
209
+ export const ROOT_NAME = '//'
535
210
 
536
- /**
537
- * The directory each `<dir>/*` package glob names — where a member comes
538
- * and goes as one directory entry, so `vx watch` can hear a package added
539
- * or removed without walking anything. A glob of another shape (`apps/**`)
540
- * has no such directory and contributes none.
541
- */
542
- export function memberBaseDirs(workspace: Workspace): string[] {
543
- const bases = new Set<string>()
544
- for (const pattern of splitPackageGlobs(workspace.packageGlobs).positive) {
545
- const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
546
- if (SIMPLE_STAR_RE.test(normalized)) {
547
- bases.add(path.resolve(workspace.root, normalized.slice(0, -2)))
548
- }
549
- }
550
- return [...bases]
211
+ /** A project's name when nothing names it: its root-relative POSIX path, `//` for the root. */
212
+ export function pathName(root: string, dir: string): string {
213
+ return relPosix(root, dir) || ROOT_NAME
551
214
  }
552
215
 
553
- /**
554
- * True when a member glob reaches a `package.json` other than the root's,
555
- * addressable or not: a nameless manifest, or two sharing a name, is
556
- * matched and left out, not unmatched (M-46).
557
- */
558
- export async function reachesManifest(workspace: Workspace): Promise<boolean> {
559
- const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
560
- const root = path.resolve(workspace.root)
561
- for (const pattern of positive) {
562
- for (const dir of await memberDirs(workspace.root, pattern)) {
563
- if (path.resolve(dir) === root) continue
564
- if (negative.length > 0 && excludedBy(relPosix(workspace.root, dir), negative)) continue
565
- try {
566
- if ((await stat(path.join(dir, 'package.json'))).isFile()) return true
567
- } catch (err) {
568
- absent(err, undefined)
569
- }
570
- }
571
- }
572
- return false
573
- }
216
+ const CONFIG_RANK = new Map(PROJECT_CONFIG_FILENAMES.map((n, i) => [n, i]))
574
217
 
575
- /**
576
- * The directories a workspace glob names. For the `<dir>/*` shape this is
577
- * one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
578
- * the cost (measured 2026-09-02: 25 ms → ~2 ms for 1000 members). A
579
- * symlinked member is followed when it points at a directory, and so is
580
- * it by the scan any other shape takes, unless the glob holds `**` (item
581
- * 987). Dot-directories are skipped (`dot: false`), so are `node_modules`.
582
- */
583
- async function memberDirs(root: string, pattern: string): Promise<string[]> {
584
- // Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
585
- const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
586
- if (SIMPLE_STAR_RE.test(normalized)) {
587
- const base = path.resolve(root, normalized.slice(0, -2))
588
- let entries: import('node:fs').Dirent[]
589
- try {
590
- entries = await readdir(base, { withFileTypes: true })
591
- } catch (err) {
592
- return absent(err, [])
593
- }
594
- const dirs: string[] = []
595
- for (const e of entries) {
596
- if (e.name.startsWith('.') || e.name === 'node_modules') continue
597
- if (e.isDirectory()) dirs.push(path.join(base, e.name))
598
- else if (e.isSymbolicLink()) {
599
- try {
600
- if ((await stat(path.join(base, e.name))).isDirectory())
601
- dirs.push(path.join(base, e.name))
602
- } catch (err) {
603
- absent(err, undefined) // dangling link: not a member
604
- }
605
- }
606
- }
607
- return dirs
608
- }
609
- const dirs: string[] = []
610
- // `Bun.Glob`'s scan finds nothing for a brace whose alternatives hold a
611
- // slash, though its `match` reads one: `packages/{a,nested/b}` listed
612
- // neither package, and both ran under no verb while the root still
613
- // claimed them (D-2). Such a pattern is scanned as its expansions.
614
- for (const expanded of slashBraceExpansions(normalized)) {
615
- const globPattern = expanded === '' ? 'package.json' : `${expanded}/package.json`
616
- const glob = new Bun.Glob(globPattern)
617
- // A linked member is a member, as the readdir path above has it:
618
- // `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
619
- // `pack*/*` did not (item 987). Followed only where the depth is bounded:
620
- // under `**` the scan would walk every pnpm `node_modules` link.
621
- const followSymlinks = !expanded.includes('**')
622
- try {
623
- for await (const rel of glob.scan({
624
- cwd: root,
625
- onlyFiles: true,
626
- dot: false,
627
- followSymlinks,
628
- })) {
629
- // Skip nested node_modules — workspace package globs shouldn't
630
- // ever reach into them, but a pathological pattern like `**`
631
- // would. Avoid splitting the path on the hot loop.
632
- if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
633
- if (rel.startsWith(`node_modules${path.sep}`)) continue
634
- dirs.push(path.dirname(path.resolve(root, rel)))
635
- }
636
- } catch (err) {
637
- // The scan stops at a directory it may not open and cannot skip it,
638
- // so the readdir path's skip is not on offer here (D-132).
639
- if (!isPermissionError(err)) throw err
640
- const where = relPosix(root, path.resolve(root, err.path ?? ''))
641
- throw new UserError(
642
- `${where}: not readable by this user (${err.code}), and the workspace glob "${pattern}" walks into it`,
643
- )
644
- }
645
- }
646
- return dirs
218
+ /** A directory holding a vx config, and the config file precedence picked there. */
219
+ export interface ConfigDir {
220
+ /** Absolute. */
221
+ readonly dir: string
222
+ readonly configPath: string
647
223
  }
648
224
 
649
225
  /**
650
- * The project's config file, by `PROJECT_CONFIG_FILENAMES` precedence, or null.
651
- *
652
- * Two shapes, one per platform, each the measured winner there across
653
- * 1,000 projects with the manifest read in flight alongside:
654
- * - macOS stats the candidates in order: a readdir each cost 9.6 ms
655
- * against 6.3 ms of stats even with the config at the LAST name
656
- * (2026-09-03), and a `.ts` config — the first name — pays one stat.
657
- * - Linux lists the directory once: a readdir each is 12 ms against
658
- * 43 ms of stats at the last name and ties the one-stat case
659
- * (2026-09-09, a `vx.config.mjs` workspace; stats there cost a
660
- * thread-pool round trip apiece).
661
- * Both answer identically: a directory or dangling link under a config
662
- * name is skipped for the next name, a symlink to a file counts.
226
+ * Every project directory under `root`, for `vx lock` (a run reads the
227
+ * lock and never walks). In git: `git ls-files` over the config names,
228
+ * tracked and untracked, git's ignore rules applied, and each nested
229
+ * repository's own listing. Outside git: a walk
230
+ * that skips dot-directories and `node_modules`. A config at or under a
231
+ * nested `.vx/workspace.*` belongs to that workspace and is left out.
232
+ * Where a directory holds several, `PROJECT_CONFIG_FILENAMES` order picks
233
+ * the first that is a file.
663
234
  */
664
- async function findConfigFile(dir: string): Promise<string | null> {
665
- if (process.platform === 'linux') {
666
- let entries: Dirent[]
667
- try {
668
- entries = await readdir(dir, { withFileTypes: true })
669
- } catch (err) {
670
- return absent(err, null)
671
- }
672
- // By precedence slot, not a Map per directory, and no `path.join`: at
673
- // 5,000 projects that cut `listProjects` from 53.5 to 47.7 ms (min of
674
- // 15, six interleaved rounds, A/A 54.7).
675
- const found: Dirent[] = []
676
- for (const e of entries) {
677
- const rank = CONFIG_RANK.get(e.name)
678
- if (rank !== undefined) found[rank] = e
679
- }
680
- for (const entry of found) {
681
- if (entry === undefined) continue
682
- const candidate = dir + path.sep + entry.name
683
- if (entry.isFile()) return candidate
684
- if (entry.isSymbolicLink() && (await isFile(candidate))) return candidate
685
- }
686
- return null
687
- }
688
- for (const name of PROJECT_CONFIG_FILENAMES) {
689
- const candidate = path.join(dir, name)
690
- if (await isFile(candidate)) return candidate
691
- }
692
- return null
235
+ export async function findProjectConfigs(root: string): Promise<ConfigDir[]> {
236
+ const rels = (await gitTop(root)) === null ? await scanConfigs(root) : await gitConfigs(root)
237
+ const nested: string[] = []
238
+ for (const rel of rels) {
239
+ const at = rel.lastIndexOf('.vx/workspace.')
240
+ if (at > 0 && rel[at - 1] === '/' && rel.lastIndexOf('/') === at + 3)
241
+ nested.push(rel.slice(0, at - 1))
242
+ }
243
+ const byDir = new Map<string, string[]>()
244
+ for (const rel of rels) {
245
+ const slash = rel.lastIndexOf('/')
246
+ const base = rel.slice(slash + 1)
247
+ if (!CONFIG_RANK.has(base)) continue
248
+ const dir = slash === -1 ? '' : rel.slice(0, slash)
249
+ if (nested.some((n) => dir === n || dir.startsWith(`${n}/`))) continue
250
+ const names = byDir.get(dir)
251
+ if (names === undefined) byDir.set(dir, [base])
252
+ else names.push(base)
253
+ }
254
+ const found = await Promise.all(
255
+ [...byDir].map(async ([rel, names]) => {
256
+ const dir = rel === '' ? root : path.join(root, rel)
257
+ names.sort((a, b) => CONFIG_RANK.get(a)! - CONFIG_RANK.get(b)!)
258
+ // A file each: git lists a tracked config deleted from the worktree,
259
+ // and a directory may carry a config's name.
260
+ for (const name of names) {
261
+ const configPath = path.join(dir, name)
262
+ if (await isFile(configPath)) return { dir, configPath }
263
+ }
264
+ return null
265
+ }),
266
+ )
267
+ return found
268
+ .filter((f): f is ConfigDir => f !== null)
269
+ .sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0))
693
270
  }
694
271
 
695
- const CONFIG_RANK = new Map(PROJECT_CONFIG_FILENAMES.map((n, i) => [n, i]))
696
-
697
272
  async function isFile(p: string): Promise<boolean> {
698
273
  try {
699
274
  return (await stat(p)).isFile()
700
275
  } catch (err) {
701
- return absent(err, false)
276
+ if (isOutOfFds(err)) throw err
277
+ return false
702
278
  }
703
279
  }
704
280
 
705
281
  /**
706
- * A read that found nothing is absent; one the process could not make is
707
- * not. Out of descriptors (`EMFILE`), every member read failed, and the
708
- * catches here read the workspace as empty: "No package matched the
709
- * workspace's package globs" (D-60).
282
+ * The config and workspace-file paths git lists under `root`, root-relative.
283
+ * A nested repository is one entry to the outer git, a gitlink (a
284
+ * submodule) or an untracked `dir/` (an embedded repository), and none of
285
+ * its files: its own git lists them, or a walk when it has no `.git`, as
286
+ * the input enumeration expands one.
710
287
  */
711
- function absent<T>(err: unknown, value: T): T {
712
- if (isOutOfFds(err)) throw err
713
- return value
714
- }
715
-
716
- /**
717
- * A member's manifest text, or null when there is none. A manifest this
718
- * user may not read is refused, not skipped: its project dropped out of
719
- * `--all` and the run went green without it (D-132). A directory it may
720
- * not search hides whether a manifest is there at all, so that one is
721
- * named and skipped (a service's data directory under `packages/*`).
722
- */
723
- async function readManifest(root: string, dir: string, file: string): Promise<string | null> {
724
- try {
725
- return await Bun.file(file).text()
726
- } catch (err) {
727
- if (!isPermissionError(err)) return absent(err, null)
728
- const searchable = await access(dir, constants.X_OK).then(
288
+ async function gitConfigs(root: string): Promise<string[]> {
289
+ const [configs, index, untracked] = await Promise.all([
290
+ gitZ(root, [
291
+ 'ls-files',
292
+ '-z',
293
+ '--cached',
294
+ '--others',
295
+ '--exclude-standard',
296
+ '--',
297
+ ':(glob)**/vx.config.*',
298
+ ':(glob)**/.vx/workspace.*',
299
+ ]),
300
+ gitZ(root, ['ls-files', '-z', '--stage']),
301
+ // An embedded repository is the one untracked entry git lists as `dir/`.
302
+ gitZ(root, ['ls-files', '-z', '--others', '--exclude-standard']),
303
+ ])
304
+ // A submodule's `.git` is a file, an embedded repository's a directory.
305
+ const isRepo = (rel: string): Promise<boolean> =>
306
+ stat(path.join(root, rel, '.git')).then(
729
307
  () => true,
730
308
  () => false,
731
309
  )
732
- if (searchable) unreadable(err, file)
733
- process.stderr.write(
734
- `vx: ${shownDir(root, dir)} is not readable by this user — skipped, with any project in it\n`,
735
- )
736
- return null
737
- }
738
- }
739
-
740
- /** `dir` as a discovery line names it: the root's own path is '' (D-127, D-154). */
741
- function shownDir(root: string, dir: string): string {
742
- return relPosix(root, dir) || 'the workspace root'
743
- }
744
-
745
- export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
746
- return discoverProjects(workspace)
747
- }
748
-
749
- /**
750
- * `listProjects`, and `nameless` collects each member directory whose
751
- * manifest has no `name`: `vx init` names them, since their scripts map to
752
- * nothing.
753
- */
754
- export async function discoverProjects(
755
- workspace: Workspace,
756
- nameless?: string[],
757
- ): Promise<ProjectMeta[]> {
758
- // Run all package globs concurrently. Disk-bound walks parallelize
759
- // well; serializing them just stretches the discovery phase by N×.
760
- const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
761
- // A root the globs do not list is a project when it holds a vx config
762
- // (D-39): the package manager's member list stays the manager's, and
763
- // the root's globs stop at every member as any parent project's do.
764
- const [perPattern, rootConfig] = await Promise.all([
765
- Promise.all(positive.map((pattern) => memberDirs(workspace.root, pattern))),
766
- findConfigFile(workspace.root),
310
+ const gitlinks = index
311
+ .filter((e) => e.startsWith('160000 '))
312
+ .map((e) => e.slice(e.indexOf('\t') + 1))
313
+ const embedded = untracked.filter((e) => e.endsWith('/')).map((e) => e.slice(0, -1))
314
+ const inner = await Promise.all([
315
+ ...gitlinks.map(async (rel) =>
316
+ // A submodule never initialised, or vendored with its `.git` removed, is walked.
317
+ (await isRepo(rel)) ? gitConfigs(path.join(root, rel)) : scanConfigs(path.join(root, rel)),
318
+ ),
319
+ ...embedded.map((rel) => gitConfigs(path.join(root, rel))),
767
320
  ])
768
- const matches = new Set<string>()
769
- for (const arr of perPattern) {
770
- for (const m of arr) {
771
- if (negative.length > 0 && excludedBy(relPosix(workspace.root, m), negative)) continue
772
- matches.add(m)
773
- }
774
- }
775
- if (rootConfig !== null) matches.add(workspace.root)
776
-
777
- // Per-project discovery: the manifest read answers "is there a
778
- // package.json" (an ENOENT is the "not a member" answer, at the cost of
779
- // one failed open) and `findConfigFile` answers "which config file", both
780
- // in flight together. All async and all in flight at once — the thread
781
- // pool runs them in parallel, and a sync read here measured 2× slower on
782
- // a 1000-member workspace.
783
- const loaded = await Promise.all(
784
- [...matches].map(async (dir) => {
785
- const pkgJsonPath = dir + path.sep + 'package.json'
786
- const [configPath, text] = await Promise.all([
787
- findConfigFile(dir),
788
- readManifest(workspace.root, dir, pkgJsonPath),
789
- ])
790
- if (text === null) {
791
- // A member dir with a vx config and no manifest was skipped without
792
- // a word: `--all` said no package matched, and a run from inside
793
- // it "not inside a project" (D-128). The config was found in the
794
- // same flight as the failed read, so naming it costs nothing.
795
- if (configPath !== null) {
796
- process.stderr.write(
797
- `vx: ${shownDir(workspace.root, dir)} has a vx config but no package.json — skipped: vx names a project by its package.json "name"\n`,
798
- )
799
- }
800
- return null
801
- }
802
- const pkg = parsePackageJson(text, pkgJsonPath)
803
- return { dir, pkg, configPath }
804
- }),
805
- )
321
+ const nested = [...gitlinks, ...embedded]
322
+ const prefixed = inner.flatMap((files, i) => files.map((f) => `${nested[i]}/${f}`))
323
+ return [...configs, ...prefixed]
324
+ }
806
325
 
807
- const byName = new Map<string, NonNullable<(typeof loaded)[number]>[]>()
808
- for (const entry of loaded) {
809
- if (entry === null) continue
810
- const { dir, pkg, configPath } = entry
811
- // npm and pnpm take `../ext/*`, but `--affected` asks git from the root
812
- // and sees nothing outside it: an edit there moved the task's key and
813
- // selected nothing, green.
814
- const rel = relPosix(workspace.root, dir)
815
- if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
816
- throw new UserError(
817
- `workspace member ${rel} (${dir}) is outside the workspace root ${workspace.root}: ` +
818
- 'vx keeps every project under the root. Move the workspace root up to a directory that holds every member.',
819
- )
820
- }
821
- if (!pkg.name) {
822
- nameless?.push(dir)
823
- // A nameless manifest can't be addressed, filtered, or made affected —
824
- // and vx identifies projects by name, so it simply vanishes. Silent is
825
- // fine for a dir that declares no tasks; a dir with a vx config was
826
- // meant to run.
827
- if (configPath !== null) {
828
- process.stderr.write(
829
- `vx: ${shownDir(workspace.root, dir)} has a vx config but its package.json has no "name" — skipped\n`,
830
- )
831
- }
832
- continue
833
- }
834
- const group = byName.get(pkg.name)
835
- if (group === undefined) byName.set(pkg.name, [entry])
836
- else group.push(entry)
326
+ /** `git <args>`'s NUL-separated output at `cwd`. */
327
+ async function gitZ(cwd: string, args: string[]): Promise<string[]> {
328
+ let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
329
+ try {
330
+ proc = Bun.spawn({
331
+ cmd: [executablePath('git'), ...args],
332
+ cwd,
333
+ env: gitEnv(),
334
+ stdin: 'ignore',
335
+ stdout: 'pipe',
336
+ stderr: 'pipe',
337
+ })
338
+ } catch (err) {
339
+ if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
340
+ throw err
837
341
  }
838
- const projects: ProjectMeta[] = []
839
- const shared: string[] = []
840
- let rootReal: string | undefined
841
- for (const [name, found] of byName) {
842
- // One package reached by two paths (a member and a link to it) is one
843
- // project, not a name two packages share: `apps/docs -> ../packages/docs`
844
- // was refused as a duplicate and told to rename one (D-135). Resolved
845
- // only here, so a workspace without a shared name pays nothing.
846
- let group = found
847
- if (group.length > 1) {
848
- rootReal ??= await realpath(workspace.root)
849
- group = await oneEntryPerPackage(group, workspace.root, rootReal)
850
- }
851
- if (group.length === 1) {
852
- const { dir, pkg, configPath } = group[0]!
853
- projects.push(withCatalogs({ name, dir, packageJson: pkg, configPath }, workspace))
854
- continue
342
+ const [out, err, code] = await Promise.all([
343
+ new Response(proc.stdout).bytes(),
344
+ new Response(proc.stderr).text(),
345
+ proc.exited,
346
+ ])
347
+ if (code !== 0)
348
+ throw new UserError(`git ls-files failed (exit ${code}): ${err.trim()}`, 'VX_E_GIT')
349
+ return new TextDecoder('utf-8', { ignoreBOM: true })
350
+ .decode(out)
351
+ .split('\0')
352
+ .filter((p) => p !== '')
353
+ }
354
+
355
+ /** The config paths under `root` outside git, root-relative: no dot-directory, no `node_modules`. */
356
+ async function scanConfigs(root: string): Promise<string[]> {
357
+ const out: string[] = []
358
+ const walk = async (rel: string): Promise<void> => {
359
+ let entries: Dirent[]
360
+ try {
361
+ entries = await readdir(rel === '' ? root : path.join(root, rel), { withFileTypes: true })
362
+ } catch (err) {
363
+ if (isOutOfFds(err)) throw err
364
+ return
855
365
  }
856
- // The root's own manifest is '' relative to itself, and the refusal read
857
- // "in workspace: and packages/a" (D-127).
858
- const dirs = group
859
- .map((e) => relPosix(workspace.root, e.dir))
860
- .sort()
861
- .map((d) => (d === '' ? 'the workspace root' : d))
862
- // pnpm accepts two manifests of one name (vite's playground, sveltejs/kit's
863
- // test apps); vx cannot, since a project is addressed by it. Like a
864
- // nameless one, a pair that declares no vx tasks is left out, so the
865
- // rest of the workspace runs (vite was refused whole, 2026-10-01); one
866
- // with a vx config was meant to run, and is refused with the way on.
867
- if (group.some((e) => e.configPath !== null)) {
868
- throw new UserError(
869
- `Duplicate package name "${name}" in workspace: ${dirs.join(' and ')}; ` +
870
- 'vx names a project by its package name — rename one, or leave one out with a `!` pattern in the workspace globs',
871
- )
366
+ // A nested workspace's projects are its own.
367
+ if (rel !== '' && entries.some((e) => e.name === '.vx') && (await workspaceFileIn(root, rel)))
368
+ return
369
+ const below: string[] = []
370
+ for (const e of entries) {
371
+ const child = rel === '' ? e.name : `${rel}/${e.name}`
372
+ if (CONFIG_RANK.has(e.name)) out.push(child)
373
+ else if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
374
+ below.push(child)
872
375
  }
873
- shared.push(`${name} (${dirs.join(', ')})`)
874
- }
875
- if (shared.length > 0) {
876
- shared.sort()
877
- const shown =
878
- shared.slice(0, 2).join('; ') + (shared.length > 2 ? `; and ${shared.length - 2} more` : '')
879
- process.stderr.write(
880
- `vx: left out, as no project can be addressed by a name several manifests share (none has a vx config): ${shown}\n`,
881
- )
376
+ await Promise.all(below.map(walk))
882
377
  }
378
+ await walk('')
379
+ return out
380
+ }
883
381
 
884
- // Code-unit order, not `localeCompare`: ICU collation cost 28 ms of a
885
- // 300 ms warm run at 1000 projects, and a stable deterministic order is
886
- // all any consumer needs.
887
- return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
382
+ async function workspaceFileIn(root: string, rel: string): Promise<boolean> {
383
+ const names = await readdir(path.join(root, rel, '.vx')).catch(() => [] as string[])
384
+ return names.some((n) => n.startsWith('workspace.'))
888
385
  }
889
386
 
387
+ /** How each project got its name, when not by its path: `config`, or the plugin's name. */
388
+ const namedBy = new WeakMap<ProjectMeta, string>()
389
+
890
390
  /**
891
- * The entries of a name group, one per real directory. Of several paths to
892
- * one package, the one that reaches it through no link inside the
893
- * workspace is kept, else the first in path order.
391
+ * The projects a lock records, by directory: what a run starts from. Named
392
+ * by the locked config's `name`, else by path; a `discover` plugin may name
393
+ * a path-named one after this.
894
394
  */
895
- async function oneEntryPerPackage<T extends { dir: string }>(
896
- group: T[],
395
+ export function lockedProjects(
897
396
  root: string,
898
- rootReal: string,
899
- ): Promise<T[]> {
900
- const byReal = new Map<string, T[]>()
901
- for (const entry of group) {
902
- const real = realpathOf(entry.dir)
903
- const same = byReal.get(real)
904
- if (same === undefined) byReal.set(real, [entry])
905
- else same.push(entry)
906
- }
907
- if (byReal.size === group.length) return group
908
- const kept: T[] = []
909
- for (const [real, paths] of byReal) {
910
- const direct = paths.find((e) => relPosix(root, e.dir) === relPosix(rootReal, real))
911
- kept.push(direct ?? paths.sort((a, b) => (a.dir < b.dir ? -1 : 1))[0]!)
397
+ locked: Readonly<Record<string, { readonly file: string; readonly config: ProjectConfig }>>,
398
+ ): ProjectMeta[] {
399
+ const metas: ProjectMeta[] = []
400
+ for (const rel of Object.keys(locked)) {
401
+ const entry = locked[rel]!
402
+ const dir = rel === '.' ? root : path.join(root, rel)
403
+ const name = entry.config.name
404
+ const meta: ProjectMeta = {
405
+ name: name ?? pathName(root, dir),
406
+ dir,
407
+ configPath: path.join(dir, entry.file),
408
+ }
409
+ if (name !== undefined) namedBy.set(meta, 'config')
410
+ metas.push(meta)
912
411
  }
913
- return kept
412
+ return metas
914
413
  }
915
414
 
916
415
  /**
917
- * A directory a plugin's `discover` hook names, as a project meta; null
918
- * when `known` already holds it under that name. `by` names the plugin in
919
- * a refusal. A directory without a `package.json` is a project by the
920
- * plugin's name alone.
416
+ * The project a plugin's `discover` entry names: a new meta (`added`) for a
417
+ * directory that is not a project yet, else the project already there,
418
+ * renamed when its name was its path's. A config's `name` wins over the
419
+ * plugin's; a name another plugin gave is not changed. `by` names the
420
+ * plugin in a refusal.
921
421
  */
922
- export async function namedProject(
923
- workspace: Workspace,
422
+ export async function discoveredProject(
423
+ root: string,
924
424
  known: readonly ProjectMeta[],
925
- named: { readonly dir: string; readonly name: string },
425
+ entry: {
426
+ readonly dir: string
427
+ readonly name?: string
428
+ readonly deps?: readonly string[]
429
+ readonly reach?: readonly string[]
430
+ },
926
431
  by: string,
927
- ): Promise<ProjectMeta | null> {
432
+ ): Promise<{ meta: ProjectMeta; added: boolean }> {
928
433
  const where = `plugin '${by}' named project`
929
- if (typeof named?.name !== 'string' || named.name === '' || typeof named.dir !== 'string') {
930
- throw new UserError(`${where} ${JSON.stringify(named)}: expected { dir: string, name: string }`)
931
- }
932
- // The rules `parsePackageJson` holds a manifest's name to: a plugin's name
933
- // for a directory with no `name` of its own skipped them, and `a#b` planned
934
- // under `--all` while no run spec or dependsOn could reach it.
935
- if (named.name.trim() !== named.name) {
434
+ const dirList = (v: unknown): boolean =>
435
+ v === undefined || (Array.isArray(v) && v.every((d) => typeof d === 'string'))
436
+ if (
437
+ typeof entry?.dir !== 'string' ||
438
+ (entry.name !== undefined && (typeof entry.name !== 'string' || entry.name === '')) ||
439
+ !dirList(entry.deps) ||
440
+ !dirList(entry.reach)
441
+ ) {
936
442
  throw new UserError(
937
- `${where} ${JSON.stringify(named.name)}: the name has surrounding whitespace`,
443
+ `${where} ${JSON.stringify(entry)}: expected { dir: string, name?: string, deps?: string[], reach?: string[] }`,
938
444
  )
939
445
  }
940
- if (named.name.includes('#')) {
941
- throw new UserError(
942
- `${where} "${named.name}": the name holds "#" — vx addresses a task as <name>#<task>`,
943
- )
446
+ const name = entry.name
447
+ if (name !== undefined) {
448
+ const bad = nameFault(name)
449
+ if (bad !== null) throw new UserError(`${where} ${JSON.stringify(name)}: ${bad}`)
944
450
  }
945
- const dir = path.resolve(workspace.root, named.dir)
946
- const rel = relPosix(workspace.root, dir)
451
+ const dir = path.resolve(root, entry.dir)
452
+ const rel = relPosix(root, dir)
453
+ const named = name === undefined ? '' : ` "${name}"`
947
454
  if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
948
- throw new UserError(`${where} "${named.name}" at ${dir}: outside the workspace root`)
455
+ throw new UserError(`${where}${named} at ${dir}: outside the workspace root`)
949
456
  }
950
457
  const shown = rel === '' ? '.' : rel
951
- for (const p of known) {
952
- if (p.dir === dir && p.name === named.name) return null
953
- if (p.dir === dir) {
954
- throw new UserError(`${where} "${named.name}" at ${shown}: already the project "${p.name}"`)
955
- }
956
- if (p.name === named.name) {
458
+ const meta = known.find((p) => p.dir === dir)
459
+ if (meta !== undefined) {
460
+ const from = namedBy.get(meta)
461
+ if (name === undefined || name === meta.name || from === 'config') return { meta, added: false }
462
+ if (from !== undefined) {
957
463
  throw new UserError(
958
- `${where} "${named.name}" at ${shown}: the name is taken by ${relPosix(workspace.root, p.dir) || '.'}`,
464
+ `${where} "${name}" at ${shown}: plugin '${from}' already named it "${meta.name}"`,
959
465
  )
960
466
  }
467
+ meta.name = name
468
+ namedBy.set(meta, by)
469
+ return { meta, added: false }
961
470
  }
962
- const pkgJsonPath = dir + path.sep + 'package.json'
963
- const [configPath, text] = await Promise.all([
964
- findConfigFile(dir),
965
- readManifest(workspace.root, dir, pkgJsonPath),
966
- ])
967
471
  if (
968
- text === null &&
969
472
  !(await stat(dir).then(
970
473
  (st) => st.isDirectory(),
971
474
  () => false,
972
475
  ))
973
476
  ) {
974
- throw new UserError(`${where} "${named.name}" at ${shown}: no such directory`)
975
- }
976
- const parsed: Partial<PackageJson> = text === null ? {} : parsePackageJson(text, pkgJsonPath)
977
- // A nameless manifest names nothing to disagree with (Nx's own projects).
978
- const pkg: PackageJson = { ...parsed, name: parsed.name ?? named.name }
979
- if (pkg.name !== named.name) {
980
- throw new UserError(
981
- `${where} "${named.name}" at ${shown}: its package.json names it "${pkg.name ?? ''}"`,
982
- )
477
+ throw new UserError(`${where}${named} at ${shown}: no such directory`)
983
478
  }
984
- return withCatalogs({ name: named.name, dir, packageJson: pkg, configPath }, workspace)
479
+ const added: ProjectMeta = { name: name ?? pathName(root, dir), dir, configPath: null }
480
+ if (name !== undefined) namedBy.set(added, by)
481
+ return { meta: added, added: true }
985
482
  }
986
483
 
987
- function withCatalogs(meta: ProjectMeta, workspace: Workspace): ProjectMeta {
988
- if (workspace.catalogs !== undefined) meta.catalogs = workspace.catalogs
989
- return meta
484
+ /** Why `name` cannot name a project, or null. */
485
+ function nameFault(name: string): string | null {
486
+ if (name.trim() !== name) return 'the name has surrounding whitespace'
487
+ // A task is `<project>#<task>` split at the first `#`.
488
+ if (name.includes('#')) return 'the name holds "#" — vx addresses a task as <name>#<task>'
489
+ return null
490
+ }
491
+
492
+ /**
493
+ * Refuses two projects of one name, naming both directories, and a name
494
+ * no task spec can address: a path name holding `#` or surrounding
495
+ * whitespace (set `name`), or `//` on a directory other than the root.
496
+ * Returns `metas` sorted by name.
497
+ */
498
+ export function checkProjectNames(root: string, metas: ProjectMeta[]): ProjectMeta[] {
499
+ metas.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
500
+ for (let i = 0; i < metas.length; i++) {
501
+ const meta = metas[i]!
502
+ const rel = relPosix(root, meta.dir) || '.'
503
+ if (meta.name === ROOT_NAME && rel !== '.') {
504
+ throw new UserError(`${rel}: "//" names the workspace root — set another \`name\``)
505
+ }
506
+ const fault = namedBy.has(meta) ? null : nameFault(meta.name)
507
+ if (fault !== null) {
508
+ throw new UserError(
509
+ `${rel}: a project is named by its path unless named, and ${fault.replace('the name', 'this one')} — set \`name\` in its vx.config`,
510
+ )
511
+ }
512
+ const next = metas[i + 1]
513
+ if (next !== undefined && next.name === meta.name) {
514
+ const dirs = [meta, next].map((m) => relPosix(root, m.dir) || '.').sort()
515
+ throw new UserError(
516
+ `two projects are named "${meta.name}": ${dirs[0]} and ${dirs[1]} — give one another \`name\` in its vx.config`,
517
+ )
518
+ }
519
+ }
520
+ return metas
990
521
  }