@vzn/vx 0.0.636 → 0.0.638

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 (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  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 +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -1,59 +1,38 @@
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,
18
+ refusal,
11
19
  relPosix,
12
- slashBraceExpansions,
13
20
  UserError,
14
- normalizeBunGlob,
15
- realpathOf,
16
21
  } from '../util/index.js'
17
- import { type LoadReads, readOnce, unreadable } from './load-reads.js'
22
+ import { gitTop, workspaceFile, workspaceRootOf } from '../handoff.js'
18
23
  import { repoIdOf } from './repo-id.js'
19
24
 
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
25
  export interface ProjectMeta {
48
- /** Canonical name from package.json. */
26
+ /** `name` in its config, else a `discover` plugin's, else its root-relative path (`//` for the root). */
49
27
  name: string
50
28
  /** Absolute path to the project directory. */
51
29
  dir: string
52
- packageJson: PackageJson
53
- /** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs} or null. */
30
+ /** Absolute path to vx.config.{ts,mts,js,mjs,cts,cjs}; null for a project a plugin added. */
54
31
  configPath: string | null
55
- /** The workspace's catalogs, which the package graph resolves `catalog:` specs through. */
56
- catalogs?: Catalogs
32
+ /** Projects a `discover` plugin says this one builds after (order and reach). */
33
+ deps?: readonly string[]
34
+ /** Projects a `discover` plugin says a change reaches this one through, with no order. */
35
+ reach?: readonly string[]
57
36
  }
58
37
 
59
38
  /** A discovered project joined with its loaded vx config. */
@@ -74,399 +53,73 @@ export const PROJECT_CONFIG_FILENAMES = [
74
53
  'vx.config.cjs',
75
54
  ]
76
55
 
77
- const decoder = new TextDecoder()
78
-
79
56
  /**
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`.
57
+ * The workspace root for `start`: the directory holding the first
58
+ * `.vx/workspace.*` from `start` up to the git top-level, or up to `/`
59
+ * outside git ({@link workspaceFile}, shared with the hand-off). Nothing
60
+ * else decides it (owner, 2026-10-13: "Workspace file is the ultimate"):
61
+ * not a `package.json`, not `pnpm-workspace.yaml`, not a lockfile. Throws
62
+ * `VX_E_NO_WORKSPACE` when there is none.
82
63
  *
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.
64
+ * Outside git, a file another user owns is refused (root aside): a
65
+ * `.vx/workspace.ts` planted in a shared directory above you (`/tmp`) is
66
+ * code that would run as you. The owner is the file's own, a link's
67
+ * included, and its `.vx` directory's. Inside git the walk never leaves your
68
+ * checkout. The directory you run in is yours to judge, and costs no
69
+ * stat. Run as root, vx trusts every owner, as a container over a mounted
70
+ * checkout needs. `uid` is for tests.
99
71
  */
100
72
  export async function findWorkspaceRoot(
101
73
  start: string,
102
- reads: LoadReads = new Map(),
74
+ uid: number = process.getuid?.() ?? 0,
103
75
  ): 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],
76
+ start = path.resolve(start)
77
+ const file = await workspaceFile(start)
78
+ if (file === null) {
79
+ const headline = `no .vx/workspace.ts in ${start} or a parent${(await gitTop(start)) === null ? '' : ' up to the git root'}`
80
+ throw refusal(
81
+ { headline, fix: 'vx init' },
82
+ { code: 'VX_E_NO_WORKSPACE', message: `${headline} — run \`vx init\` to create one` },
292
83
  )
293
84
  }
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.`,
85
+ const root = workspaceRootOf(file)
86
+ if (uid !== 0 && root !== start && (await gitTop(start)) === null) {
87
+ // The link itself and the `.vx` holding it, not what a link names: a
88
+ // link another user planted to a root-owned file passed as root's, and
89
+ // its owner could point it at their own code once it had.
90
+ const owners = await Promise.all(
91
+ [lstat(file), stat(path.dirname(file))].map((p) =>
92
+ p.then(
93
+ (st) => st.uid,
94
+ () => uid,
95
+ ),
96
+ ),
445
97
  )
98
+ if (owners.some((owner) => owner !== uid && owner !== 0)) {
99
+ throw new UserError(
100
+ `${file} belongs to another user, so vx does not take its directory as your workspace: ` +
101
+ 'its config would run as you. Run vx in a checkout you own, or run `vx init` in yours',
102
+ 'VX_E_NO_WORKSPACE',
103
+ )
104
+ }
446
105
  }
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
- )
106
+ return root
455
107
  }
456
108
 
457
109
  /**
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.
110
+ * The workspace's own cache directory (index, history, memos):
111
+ * `VX_CACHE_DIR`, else the workspace's `cache.dir`, else `.vx/cache`,
112
+ * relative to the workspace root (`--cache-dir` is the caller's, above both).
461
113
  */
462
114
  export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string {
463
115
  const env = process.env['VX_CACHE_DIR']
464
- // `cacheDir: ' '` is refused at load (X-22); the variable made the same
116
+ // `cache.dir: ' '` is refused at load (X-22); the variable made the same
465
117
  // directory named in spaces at the root (D-160).
466
- if (config?.cacheDir === undefined && env !== undefined && env !== '' && env.trim() === '') {
118
+ if (env !== undefined && env !== '' && env.trim() === '') {
467
119
  throw new UserError('VX_CACHE_DIR is only whitespace — name a directory, or unset it')
468
120
  }
469
- const rel = config?.cacheDir ?? (env || path.join('.vx', 'cache'))
121
+ const fromEnv = env !== undefined && env !== ''
122
+ const rel = (fromEnv ? env : config?.cache?.dir) ?? path.join('.vx', 'cache')
470
123
  const home = process.env['HOME'] || homedir()
471
124
  // No shell expands `~` in a config string or a quoted variable, and
472
125
  // `'~/.cache/vx'` made a directory named `~` in the workspace.
@@ -475,7 +128,7 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
475
128
  // The cache writes a `*` .gitignore beside its index: a bare `~` made a
476
129
  // directory named `~`, and expanded it would land in home itself (D-153).
477
130
  if (path.resolve(dir) === path.resolve(home)) {
478
- const source = config?.cacheDir !== undefined ? 'cacheDir' : 'VX_CACHE_DIR'
131
+ const source = fromEnv ? 'VX_CACHE_DIR' : 'cache.dir'
479
132
  throw new UserError(
480
133
  `${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
134
  )
@@ -487,7 +140,7 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
487
140
  * Where this repository's shared store lives: `~/.vx/<repo id>/cache`, as
488
141
  * Nx 23 keeps `~/.nx/<id>/cache` (owner, 2026-10-06), so every checkout of
489
142
  * the repository hits what another saved and no other repository shares
490
- * it (`repoIdOf`). Null when the workspace names its cache (`cacheDir`,
143
+ * it (`repoIdOf`). Null when the workspace names its cache (`cache.dir`,
491
144
  * `VX_CACHE_DIR`): that directory then holds everything, as one
492
145
  * workspace's alone. Null with no repository identity or no home.
493
146
  */
@@ -495,7 +148,7 @@ export async function resolveStoreRoot(
495
148
  root: string,
496
149
  config: WorkspaceConfig | null,
497
150
  ): Promise<string | null> {
498
- if (config?.cacheDir !== undefined || process.env['VX_CACHE_DIR']) return null
151
+ if (config?.cache?.dir !== undefined || process.env['VX_CACHE_DIR']) return null
499
152
  // HOME first: Bun's homedir() keeps the HOME the process started with (1.4.2).
500
153
  const home = process.env['HOME'] || homedir()
501
154
  if (!path.isAbsolute(home)) return null
@@ -508,483 +161,362 @@ export async function resolveStoreRoot(
508
161
  const repoIds = new Map<string, Promise<string | null>>()
509
162
 
510
163
  /**
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.
164
+ * Sets each project's `deps` / `reach` from the `discover` entries' edge
165
+ * directories, resolved against every project the stage left. A target
166
+ * outside the root or that is no project is refused naming the plugin.
519
167
  */
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`)
168
+ export function linkDiscoveredEdges(
169
+ root: string,
170
+ projects: readonly ProjectMeta[],
171
+ edges: ReadonlyArray<{
172
+ readonly by: string
173
+ readonly meta: ProjectMeta
174
+ readonly deps: readonly string[] | undefined
175
+ readonly reach: readonly string[]
176
+ }>,
177
+ ): void {
178
+ if (edges.length === 0) return
179
+ const byDir = new Map(projects.map((p) => [p.dir, p.name]))
180
+ const deps = new Map<ProjectMeta, Set<string>>()
181
+ const reach = new Map<ProjectMeta, Set<string>>()
182
+ for (const edge of edges) {
183
+ // An entry carrying `deps`, even none, makes its project a leaf of an
184
+ // edge source rather than a project no plugin spoke for.
185
+ if (edge.deps !== undefined && !deps.has(edge.meta)) deps.set(edge.meta, new Set())
186
+ for (const [kind, into] of [
187
+ [edge.deps ?? [], deps],
188
+ [edge.reach, reach],
189
+ ] as const) {
190
+ for (const target of kind) {
191
+ const dir = path.resolve(root, target)
192
+ const rel = relPosix(root, dir)
193
+ const where = `plugin '${edge.by}' gave project "${edge.meta.name}" an edge to ${target}`
194
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
195
+ throw new UserError(`${where}: outside the workspace root`)
196
+ }
197
+ const name = byDir.get(dir)
198
+ if (name === undefined) throw new UserError(`${where}: not a project`)
199
+ let set = into.get(edge.meta)
200
+ if (set === undefined) into.set(edge.meta, (set = new Set()))
201
+ set.add(name)
202
+ }
203
+ }
527
204
  }
528
- return catalogs === undefined ? { root, packageGlobs } : { root, packageGlobs, catalogs }
205
+ for (const [meta, names] of deps) meta.deps = [...names].sort()
206
+ for (const [meta, names] of reach) meta.reach = [...names].sort()
529
207
  }
530
208
 
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 = /^[^*?{}[\]!]+\/\*$/
209
+ /** The root project's name when nothing names it: Turbo's spelling, and the root alias in filters and `dependsOn`. */
210
+ export const ROOT_NAME = '//'
535
211
 
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]
212
+ /** A project's name when nothing names it: its root-relative POSIX path, `//` for the root. */
213
+ export function pathName(root: string, dir: string): string {
214
+ return relPosix(root, dir) || ROOT_NAME
551
215
  }
552
216
 
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
- }
217
+ const CONFIG_RANK = new Map(PROJECT_CONFIG_FILENAMES.map((n, i) => [n, i]))
574
218
 
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
219
+ /** A directory holding a vx config, and the config file precedence picked there. */
220
+ export interface ConfigDir {
221
+ /** Absolute. */
222
+ readonly dir: string
223
+ readonly configPath: string
647
224
  }
648
225
 
649
226
  /**
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.
227
+ * Every project directory under `root`, for `vx lock` (a run reads the
228
+ * lock and never walks). In git: `git ls-files` over the config names,
229
+ * tracked and untracked, git's ignore rules applied, and each nested
230
+ * repository's own listing. Outside git: a walk
231
+ * that skips dot-directories and `node_modules`. A config at or under a
232
+ * nested `.vx/workspace.*` belongs to that workspace and is left out.
233
+ * Where a directory holds several, `PROJECT_CONFIG_FILENAMES` order picks
234
+ * the first that is a file.
663
235
  */
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
236
+ export async function findProjectConfigs(root: string): Promise<ConfigDir[]> {
237
+ const rels = (await gitTop(root)) === null ? await scanConfigs(root) : await gitConfigs(root)
238
+ const nested: string[] = []
239
+ for (const rel of rels) {
240
+ const at = rel.lastIndexOf('.vx/workspace.')
241
+ if (at > 0 && rel[at - 1] === '/' && rel.lastIndexOf('/') === at + 3)
242
+ nested.push(rel.slice(0, at - 1))
243
+ }
244
+ const byDir = new Map<string, string[]>()
245
+ for (const rel of rels) {
246
+ const slash = rel.lastIndexOf('/')
247
+ const base = rel.slice(slash + 1)
248
+ if (!CONFIG_RANK.has(base)) continue
249
+ const dir = slash === -1 ? '' : rel.slice(0, slash)
250
+ if (nested.some((n) => dir === n || dir.startsWith(`${n}/`))) continue
251
+ const names = byDir.get(dir)
252
+ if (names === undefined) byDir.set(dir, [base])
253
+ else names.push(base)
254
+ }
255
+ const found = await Promise.all(
256
+ [...byDir].map(async ([rel, names]) => {
257
+ const dir = rel === '' ? root : path.join(root, rel)
258
+ names.sort((a, b) => CONFIG_RANK.get(a)! - CONFIG_RANK.get(b)!)
259
+ // A file each: git lists a tracked config deleted from the worktree,
260
+ // and a directory may carry a config's name.
261
+ for (const name of names) {
262
+ const configPath = path.join(dir, name)
263
+ if (await isFile(configPath)) return { dir, configPath }
264
+ }
265
+ return null
266
+ }),
267
+ )
268
+ return found
269
+ .filter((f): f is ConfigDir => f !== null)
270
+ .sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0))
693
271
  }
694
272
 
695
- const CONFIG_RANK = new Map(PROJECT_CONFIG_FILENAMES.map((n, i) => [n, i]))
696
-
697
273
  async function isFile(p: string): Promise<boolean> {
698
274
  try {
699
275
  return (await stat(p)).isFile()
700
276
  } catch (err) {
701
- return absent(err, false)
277
+ if (isOutOfFds(err)) throw err
278
+ return false
702
279
  }
703
280
  }
704
281
 
705
282
  /**
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).
283
+ * The config and workspace-file paths git lists under `root`, root-relative.
284
+ * A nested repository is one entry to the outer git, a gitlink (a
285
+ * submodule) or an untracked `dir/` (an embedded repository), and none of
286
+ * its files: its own git lists them, or a walk when it has no `.git`, as
287
+ * the input enumeration expands one.
710
288
  */
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(
289
+ async function gitConfigs(root: string): Promise<string[]> {
290
+ const [configs, index, untracked] = await Promise.all([
291
+ gitZ(root, [
292
+ 'ls-files',
293
+ '-z',
294
+ '--cached',
295
+ '--others',
296
+ '--exclude-standard',
297
+ '--',
298
+ ':(glob)**/vx.config.*',
299
+ ':(glob)**/.vx/workspace.*',
300
+ ]),
301
+ gitZ(root, ['ls-files', '-z', '--stage']),
302
+ // An embedded repository is the one untracked entry git lists as `dir/`.
303
+ gitZ(root, ['ls-files', '-z', '--others', '--exclude-standard']),
304
+ ])
305
+ // A submodule's `.git` is a file, an embedded repository's a directory.
306
+ const isRepo = (rel: string): Promise<boolean> =>
307
+ stat(path.join(root, rel, '.git')).then(
729
308
  () => true,
730
309
  () => false,
731
310
  )
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),
311
+ const gitlinks = index
312
+ .filter((e) => e.startsWith('160000 '))
313
+ .map((e) => e.slice(e.indexOf('\t') + 1))
314
+ const embedded = untracked.filter((e) => e.endsWith('/')).map((e) => e.slice(0, -1))
315
+ const inner = await Promise.all([
316
+ ...gitlinks.map(async (rel) =>
317
+ // A submodule never initialised, or vendored with its `.git` removed, is walked.
318
+ (await isRepo(rel)) ? gitConfigs(path.join(root, rel)) : scanConfigs(path.join(root, rel)),
319
+ ),
320
+ ...embedded.map((rel) => gitConfigs(path.join(root, rel))),
767
321
  ])
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
- )
322
+ const nested = [...gitlinks, ...embedded]
323
+ const prefixed = inner.flatMap((files, i) => files.map((f) => `${nested[i]}/${f}`))
324
+ return [...configs, ...prefixed]
325
+ }
806
326
 
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)
327
+ /** `git <args>`'s NUL-separated output at `cwd`. */
328
+ async function gitZ(cwd: string, args: string[]): Promise<string[]> {
329
+ let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
330
+ try {
331
+ proc = Bun.spawn({
332
+ cmd: [executablePath('git'), ...args],
333
+ cwd,
334
+ env: gitEnv(),
335
+ stdin: 'ignore',
336
+ stdout: 'pipe',
337
+ stderr: 'pipe',
338
+ })
339
+ } catch (err) {
340
+ if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
341
+ throw err
837
342
  }
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
343
+ const [out, err, code] = await Promise.all([
344
+ new Response(proc.stdout).bytes(),
345
+ new Response(proc.stderr).text(),
346
+ proc.exited,
347
+ ])
348
+ if (code !== 0)
349
+ throw new UserError(`git ls-files failed (exit ${code}): ${err.trim()}`, 'VX_E_GIT')
350
+ return new TextDecoder('utf-8', { ignoreBOM: true })
351
+ .decode(out)
352
+ .split('\0')
353
+ .filter((p) => p !== '')
354
+ }
355
+
356
+ /** The config paths under `root` outside git, root-relative: no dot-directory, no `node_modules`. */
357
+ async function scanConfigs(root: string): Promise<string[]> {
358
+ const out: string[] = []
359
+ const walk = async (rel: string): Promise<void> => {
360
+ let entries: Dirent[]
361
+ try {
362
+ entries = await readdir(rel === '' ? root : path.join(root, rel), { withFileTypes: true })
363
+ } catch (err) {
364
+ if (isOutOfFds(err)) throw err
365
+ return
855
366
  }
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
- )
367
+ // A nested workspace's projects are its own.
368
+ if (rel !== '' && entries.some((e) => e.name === '.vx') && (await workspaceFileIn(root, rel)))
369
+ return
370
+ const below: string[] = []
371
+ for (const e of entries) {
372
+ const child = rel === '' ? e.name : `${rel}/${e.name}`
373
+ if (CONFIG_RANK.has(e.name)) out.push(child)
374
+ else if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
375
+ below.push(child)
872
376
  }
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
- )
377
+ await Promise.all(below.map(walk))
882
378
  }
379
+ await walk('')
380
+ return out
381
+ }
883
382
 
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))
383
+ async function workspaceFileIn(root: string, rel: string): Promise<boolean> {
384
+ const names = await readdir(path.join(root, rel, '.vx')).catch(() => [] as string[])
385
+ return names.some((n) => n.startsWith('workspace.'))
888
386
  }
889
387
 
388
+ /** How each project got its name, when not by its path: `config`, or the plugin's name. */
389
+ const namedBy = new WeakMap<ProjectMeta, string>()
390
+
890
391
  /**
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.
392
+ * The projects a lock records, by directory: what a run starts from. Named
393
+ * by the locked config's `name`, else by path; a `discover` plugin may name
394
+ * a path-named one after this.
894
395
  */
895
- async function oneEntryPerPackage<T extends { dir: string }>(
896
- group: T[],
396
+ export function lockedProjects(
897
397
  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]!)
398
+ locked: Readonly<Record<string, { readonly file: string; readonly config: ProjectConfig }>>,
399
+ ): ProjectMeta[] {
400
+ const metas: ProjectMeta[] = []
401
+ for (const rel of Object.keys(locked)) {
402
+ const entry = locked[rel]!
403
+ const dir = rel === '.' ? root : path.join(root, rel)
404
+ const name = entry.config.name
405
+ const meta: ProjectMeta = {
406
+ name: name ?? pathName(root, dir),
407
+ dir,
408
+ configPath: path.join(dir, entry.file),
409
+ }
410
+ if (name !== undefined) namedBy.set(meta, 'config')
411
+ metas.push(meta)
912
412
  }
913
- return kept
413
+ return metas
914
414
  }
915
415
 
916
416
  /**
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.
417
+ * The project a plugin's `discover` entry names: a new meta (`added`) for a
418
+ * directory that is not a project yet, else the project already there,
419
+ * renamed when its name was its path's. A config's `name` wins over the
420
+ * plugin's; a name another plugin gave is not changed. `by` names the
421
+ * plugin in a refusal.
921
422
  */
922
- export async function namedProject(
923
- workspace: Workspace,
423
+ export async function discoveredProject(
424
+ root: string,
924
425
  known: readonly ProjectMeta[],
925
- named: { readonly dir: string; readonly name: string },
426
+ entry: {
427
+ readonly dir: string
428
+ readonly name?: string
429
+ readonly deps?: readonly string[]
430
+ readonly reach?: readonly string[]
431
+ },
926
432
  by: string,
927
- ): Promise<ProjectMeta | null> {
433
+ ): Promise<{ meta: ProjectMeta; added: boolean }> {
928
434
  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) {
435
+ const dirList = (v: unknown): boolean =>
436
+ v === undefined || (Array.isArray(v) && v.every((d) => typeof d === 'string'))
437
+ if (
438
+ typeof entry?.dir !== 'string' ||
439
+ (entry.name !== undefined && (typeof entry.name !== 'string' || entry.name === '')) ||
440
+ !dirList(entry.deps) ||
441
+ !dirList(entry.reach)
442
+ ) {
936
443
  throw new UserError(
937
- `${where} ${JSON.stringify(named.name)}: the name has surrounding whitespace`,
444
+ `${where} ${JSON.stringify(entry)}: expected { dir: string, name?: string, deps?: string[], reach?: string[] }`,
938
445
  )
939
446
  }
940
- if (named.name.includes('#')) {
941
- throw new UserError(
942
- `${where} "${named.name}": the name holds "#" — vx addresses a task as <name>#<task>`,
943
- )
447
+ const name = entry.name
448
+ if (name !== undefined) {
449
+ const bad = nameFault(name)
450
+ if (bad !== null) throw new UserError(`${where} ${JSON.stringify(name)}: ${bad}`)
944
451
  }
945
- const dir = path.resolve(workspace.root, named.dir)
946
- const rel = relPosix(workspace.root, dir)
452
+ const dir = path.resolve(root, entry.dir)
453
+ const rel = relPosix(root, dir)
454
+ const named = name === undefined ? '' : ` "${name}"`
947
455
  if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
948
- throw new UserError(`${where} "${named.name}" at ${dir}: outside the workspace root`)
456
+ throw new UserError(`${where}${named} at ${dir}: outside the workspace root`)
949
457
  }
950
458
  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) {
459
+ const meta = known.find((p) => p.dir === dir)
460
+ if (meta !== undefined) {
461
+ const from = namedBy.get(meta)
462
+ if (name === undefined || name === meta.name || from === 'config') return { meta, added: false }
463
+ if (from !== undefined) {
957
464
  throw new UserError(
958
- `${where} "${named.name}" at ${shown}: the name is taken by ${relPosix(workspace.root, p.dir) || '.'}`,
465
+ `${where} "${name}" at ${shown}: plugin '${from}' already named it "${meta.name}"`,
959
466
  )
960
467
  }
468
+ meta.name = name
469
+ namedBy.set(meta, by)
470
+ return { meta, added: false }
961
471
  }
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
472
  if (
968
- text === null &&
969
473
  !(await stat(dir).then(
970
474
  (st) => st.isDirectory(),
971
475
  () => false,
972
476
  ))
973
477
  ) {
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
- )
478
+ throw new UserError(`${where}${named} at ${shown}: no such directory`)
983
479
  }
984
- return withCatalogs({ name: named.name, dir, packageJson: pkg, configPath }, workspace)
480
+ const added: ProjectMeta = { name: name ?? pathName(root, dir), dir, configPath: null }
481
+ if (name !== undefined) namedBy.set(added, by)
482
+ return { meta: added, added: true }
985
483
  }
986
484
 
987
- function withCatalogs(meta: ProjectMeta, workspace: Workspace): ProjectMeta {
988
- if (workspace.catalogs !== undefined) meta.catalogs = workspace.catalogs
989
- return meta
485
+ /** Why `name` cannot name a project, or null. */
486
+ function nameFault(name: string): string | null {
487
+ if (name.trim() !== name) return 'the name has surrounding whitespace'
488
+ // A task is `<project>#<task>` split at the first `#`.
489
+ if (name.includes('#')) return 'the name holds "#" — vx addresses a task as <name>#<task>'
490
+ return null
491
+ }
492
+
493
+ /**
494
+ * Refuses two projects of one name, naming both directories, and a name
495
+ * no task spec can address: a path name holding `#` or surrounding
496
+ * whitespace (set `name`), or `//` on a directory other than the root.
497
+ * Returns `metas` sorted by name.
498
+ */
499
+ export function checkProjectNames(root: string, metas: ProjectMeta[]): ProjectMeta[] {
500
+ metas.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
501
+ for (let i = 0; i < metas.length; i++) {
502
+ const meta = metas[i]!
503
+ const rel = relPosix(root, meta.dir) || '.'
504
+ if (meta.name === ROOT_NAME && rel !== '.') {
505
+ throw new UserError(`${rel}: "//" names the workspace root — set another \`name\``)
506
+ }
507
+ const fault = namedBy.has(meta) ? null : nameFault(meta.name)
508
+ if (fault !== null) {
509
+ throw new UserError(
510
+ `${rel}: a project is named by its path unless named, and ${fault.replace('the name', 'this one')} — set \`name\` in its vx.config`,
511
+ )
512
+ }
513
+ const next = metas[i + 1]
514
+ if (next !== undefined && next.name === meta.name) {
515
+ const dirs = [meta, next].map((m) => relPosix(root, m.dir) || '.').sort()
516
+ throw new UserError(
517
+ `two projects are named "${meta.name}": ${dirs[0]} and ${dirs[1]} — give one another \`name\` in its vx.config`,
518
+ )
519
+ }
520
+ }
521
+ return metas
990
522
  }