@vzn/vx 0.0.646 → 0.0.648

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/README.md +29 -22
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -1,4 +1,4 @@
1
- // What a project is (owner, 2026-10-14): a directory holding a
1
+ // What a project is (2026-10-14): a directory holding a
2
2
  // `vx.config.*`. `vx lock` finds them (`findProjectConfigs`) and records
3
3
  // each by directory; a run takes them from the lock (`lockedProjects`) and
4
4
  // never walks. A project is named by `name` in its config, else by a
@@ -13,13 +13,17 @@ import {
13
13
  executablePath,
14
14
  gitEnv,
15
15
  gitSpawnRefusal,
16
+ hasControl,
16
17
  isExecutableMissing,
17
18
  isOutOfFds,
19
+ printable,
18
20
  refusal,
19
21
  relPosix,
20
22
  UserError,
21
23
  } from '../util/index.js'
22
- import { gitTop, workspaceFile, workspaceRootOf } from '../handoff.js'
24
+ import { gitTop, WORKSPACE_CONFIG_FILENAMES, workspaceFile, workspaceRootOf } from '../handoff.js'
25
+ import { canonicalDir } from './canonical-dir.js'
26
+ import { parseFilter } from './filter.js'
23
27
  import { repoIdOf } from './repo-id.js'
24
28
 
25
29
  export interface ProjectMeta {
@@ -57,7 +61,7 @@ export const PROJECT_CONFIG_FILENAMES = [
57
61
  * The workspace root for `start`: the directory holding the first
58
62
  * `.vx/workspace.*` from `start` up to the git top-level, or up to `/`
59
63
  * outside git ({@link workspaceFile}, shared with the hand-off). Nothing
60
- * else decides it (owner, 2026-10-13: "Workspace file is the ultimate"):
64
+ * else decides it (2026-10-13: "Workspace file is the ultimate"):
61
65
  * not a `package.json`, not `pnpm-workspace.yaml`, not a lockfile. Throws
62
66
  * `VX_E_NO_WORKSPACE` when there is none.
63
67
  *
@@ -138,7 +142,7 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
138
142
 
139
143
  /**
140
144
  * Where this repository's shared store lives: `~/.vx/<repo id>/cache`, as
141
- * Nx 23 keeps `~/.nx/<id>/cache` (owner, 2026-10-06), so every checkout of
145
+ * Nx 23 keeps `~/.nx/<id>/cache` (2026-10-06), so every checkout of
142
146
  * the repository hits what another saved and no other repository shares
143
147
  * it (`repoIdOf`). Null when the workspace names its cache (`cache.dir`,
144
148
  * `VX_CACHE_DIR`): that directory then holds everything, as one
@@ -192,7 +196,11 @@ export function linkDiscoveredEdges(
192
196
  // one that does not), so a target naming one needs no other check:
193
197
  // a plugin hands project dirs as they are, absolute, and resolving
194
198
  // and judging each of 5,000 edges was ~15 ms at 1,000 projects.
195
- const name = byDir.get(target) ?? byDir.get(path.resolve(root, target))
199
+ let name = byDir.get(target) ?? byDir.get(path.resolve(root, target))
200
+ if (name === undefined) {
201
+ const real = canonicalDir(root, path.resolve(root, target))
202
+ if (real !== null) name = byDir.get(real)
203
+ }
196
204
  if (name === undefined) {
197
205
  const rel = relPosix(root, path.resolve(root, target))
198
206
  const where = `plugin '${edge.by}' gave project "${edge.meta.name}" an edge to ${target}`
@@ -220,6 +228,7 @@ export function pathName(root: string, dir: string): string {
220
228
  }
221
229
 
222
230
  const CONFIG_RANK = new Map(PROJECT_CONFIG_FILENAMES.map((n, i) => [n, i]))
231
+ const WORKSPACE_RANK = new Map(WORKSPACE_CONFIG_FILENAMES.map((n, i) => [n, i]))
223
232
 
224
233
  /** A directory holding a vx config, and the config file precedence picked there. */
225
234
  export interface ConfigDir {
@@ -228,51 +237,132 @@ export interface ConfigDir {
228
237
  readonly configPath: string
229
238
  }
230
239
 
240
+ /** `vx lock`'s review copy of a project's resolved config, beside its `vx.config`. */
241
+ export const REVIEW_FILE = 'vx.lock.json'
242
+
243
+ export interface ProjectConfigs {
244
+ readonly configs: ConfigDir[]
245
+ /** Absolute: review copies in a directory with no config, which no `vx lock` rewrites. */
246
+ readonly strayReviews: string[]
247
+ /** Absolute: config files a higher-precedence one beside them hides, the root's workspace config among them, by the one read. */
248
+ readonly shadowed: { file: string; read: string }[]
249
+ }
250
+
231
251
  /**
232
252
  * Every project directory under `root`, for `vx lock` (a run reads the
233
253
  * lock and never walks). In git: `git ls-files` over the config names,
234
- * tracked and untracked, git's ignore rules applied, and each nested
235
- * repository's own listing. Outside git: a walk
236
- * that skips dot-directories and `node_modules`. A config at or under a
254
+ * tracked and untracked, git's ignore rules applied, none under
255
+ * `node_modules`, and each nested repository's own listing. Outside git: a walk
256
+ * that skips `node_modules` and vx's own `.vx`, and enters every other
257
+ * dot-directory, as git lists `.github/actions/x`. A config at or under a
237
258
  * nested `.vx/workspace.*` belongs to that workspace and is left out.
238
259
  * Where a directory holds several, `PROJECT_CONFIG_FILENAMES` order picks
239
260
  * the first that is a file.
240
261
  */
241
- export async function findProjectConfigs(root: string): Promise<ConfigDir[]> {
242
- const rels = (await gitTop(root)) === null ? await scanConfigs(root) : await gitConfigs(root)
262
+ export async function findProjectConfigs(root: string): Promise<ProjectConfigs> {
263
+ const inGit = (await gitTop(root)) !== null
264
+ const rels = inGit ? await gitConfigs(root) : await scanConfigs(root)
243
265
  const nested: string[] = []
244
266
  for (const rel of rels) {
245
- const at = rel.lastIndexOf('.vx/workspace.')
246
- if (at > 0 && rel[at - 1] === '/' && rel.lastIndexOf('/') === at + 3)
247
- nested.push(rel.slice(0, at - 1))
267
+ const at = rel.lastIndexOf('/.vx/')
268
+ if (at > 0 && WORKSPACE_RANK.has(rel.slice(at + 1))) nested.push(rel.slice(0, at))
248
269
  }
270
+ if (inGit) nested.push(...(await unlistedNested(root, rels, nested)))
249
271
  const byDir = new Map<string, string[]>()
272
+ const reviewed: string[] = []
250
273
  for (const rel of rels) {
251
274
  const slash = rel.lastIndexOf('/')
252
275
  const base = rel.slice(slash + 1)
253
- if (!CONFIG_RANK.has(base)) continue
254
276
  const dir = slash === -1 ? '' : rel.slice(0, slash)
255
277
  if (nested.some((n) => dir === n || dir.startsWith(`${n}/`))) continue
278
+ if (base === REVIEW_FILE) reviewed.push(dir)
279
+ if (!CONFIG_RANK.has(base)) continue
256
280
  const names = byDir.get(dir)
257
281
  if (names === undefined) byDir.set(dir, [base])
258
282
  else names.push(base)
259
283
  }
284
+ const shadowed: { file: string; read: string }[] = []
285
+ // The files among `names` (ranked), the first of them the one read: git
286
+ // lists a tracked config deleted from the worktree, and a directory may
287
+ // carry a config's name. Every other file is shadowed, said by `vx lock`.
288
+ const firstFile = async (
289
+ dir: string,
290
+ names: string[],
291
+ rank: ReadonlyMap<string, number>,
292
+ ): Promise<string | null> => {
293
+ names.sort((a, b) => rank.get(a)! - rank.get(b)!)
294
+ const files = (
295
+ await Promise.all(names.map(async (n) => ((await isFile(path.join(dir, n))) ? n : null)))
296
+ ).filter((n) => n !== null)
297
+ for (const n of files.slice(1))
298
+ shadowed.push({ file: path.join(dir, n), read: path.join(dir, files[0]!) })
299
+ return files[0] === undefined ? null : path.join(dir, files[0])
300
+ }
260
301
  const found = await Promise.all(
261
302
  [...byDir].map(async ([rel, names]) => {
262
303
  const dir = rel === '' ? root : path.join(root, rel)
263
- names.sort((a, b) => CONFIG_RANK.get(a)! - CONFIG_RANK.get(b)!)
264
- // A file each: git lists a tracked config deleted from the worktree,
265
- // and a directory may carry a config's name.
266
- for (const name of names) {
267
- const configPath = path.join(dir, name)
268
- if (await isFile(configPath)) return { dir, configPath }
269
- }
270
- return null
304
+ const configPath = await firstFile(dir, names, CONFIG_RANK)
305
+ return configPath === null ? null : { dir, configPath }
271
306
  }),
272
307
  )
273
- return found
308
+ // The root's own, asked by name: a walk outside git lists no dot-directory.
309
+ await firstFile(root, [...WORKSPACE_CONFIG_FILENAMES], WORKSPACE_RANK)
310
+ shadowed.sort((a, b) => (a.file < b.file ? -1 : 1))
311
+ const configs = found
274
312
  .filter((f): f is ConfigDir => f !== null)
275
313
  .sort((a, b) => (a.dir < b.dir ? -1 : a.dir > b.dir ? 1 : 0))
314
+ const configured = new Set(configs.map((c) => c.dir))
315
+ // On disk only: git lists a tracked copy deleted with its project.
316
+ const strayReviews = (
317
+ await Promise.all(
318
+ reviewed
319
+ .map((rel) => (rel === '' ? root : path.join(root, rel)))
320
+ .filter((dir) => !configured.has(dir))
321
+ .map(async (dir) => {
322
+ const file = path.join(dir, REVIEW_FILE)
323
+ return (await isFile(file)) ? file : null
324
+ }),
325
+ )
326
+ )
327
+ .filter((f) => f !== null)
328
+ .sort()
329
+ return { configs, strayReviews, shadowed }
330
+ }
331
+
332
+ /**
333
+ * The nested workspace roots git did not list (an ignored `.vx/`) among the
334
+ * directories holding `rels` and their ancestors under `root`: a run from
335
+ * under one takes it as its root, so the outer lock must not claim it.
336
+ */
337
+ async function unlistedNested(
338
+ root: string,
339
+ rels: readonly string[],
340
+ listed: readonly string[],
341
+ ): Promise<string[]> {
342
+ const dirs = new Set<string>()
343
+ for (const rel of rels) {
344
+ for (let d = rel.slice(0, Math.max(rel.lastIndexOf('/'), 0)); d !== '';) {
345
+ if (dirs.has(d)) break
346
+ dirs.add(d)
347
+ d = d.slice(0, Math.max(d.lastIndexOf('/'), 0))
348
+ }
349
+ }
350
+ for (const n of listed) dirs.delete(n)
351
+ // A stat, no descriptor: most directories have no `.vx`.
352
+ const found = await Promise.all(
353
+ [...dirs].map(async (d) =>
354
+ (await stat(path.join(root, d, '.vx')).then(
355
+ (st) => st.isDirectory(),
356
+ (err: unknown) => {
357
+ if (isOutOfFds(err)) throw err
358
+ return false
359
+ },
360
+ )) && (await workspaceFileIn(root, d))
361
+ ? d
362
+ : null,
363
+ ),
364
+ )
365
+ return found.filter((d) => d !== null)
276
366
  }
277
367
 
278
368
  async function isFile(p: string): Promise<boolean> {
@@ -302,10 +392,12 @@ async function gitConfigs(root: string): Promise<string[]> {
302
392
  '--',
303
393
  ':(glob)**/vx.config.*',
304
394
  ':(glob)**/.vx/workspace.*',
395
+ `:(glob)**/${REVIEW_FILE}`,
396
+ NOT_INSTALLS,
305
397
  ]),
306
- gitZ(root, ['ls-files', '-z', '--stage']),
398
+ gitZ(root, ['ls-files', '-z', '--stage', '--', NOT_INSTALLS]),
307
399
  // An embedded repository is the one untracked entry git lists as `dir/`.
308
- gitZ(root, ['ls-files', '-z', '--others', '--exclude-standard']),
400
+ gitZ(root, ['ls-files', '-z', '--others', '--exclude-standard', '--', NOT_INSTALLS]),
309
401
  ])
310
402
  // A submodule's `.git` is a file, an embedded repository's a directory.
311
403
  const isRepo = (rel: string): Promise<boolean> =>
@@ -329,6 +421,13 @@ async function gitConfigs(root: string): Promise<string[]> {
329
421
  return [...configs, ...prefixed]
330
422
  }
331
423
 
424
+ /**
425
+ * An install is no project, tracked or not, as the walk outside git skips
426
+ * it: a repo whose .gitignore does not name `node_modules` listed each
427
+ * installed package's vx.config as one.
428
+ */
429
+ const NOT_INSTALLS = ':(exclude,glob)**/node_modules/**'
430
+
332
431
  /** `git <args>`'s NUL-separated output at `cwd`. */
333
432
  async function gitZ(cwd: string, args: string[]): Promise<string[]> {
334
433
  let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
@@ -358,7 +457,7 @@ async function gitZ(cwd: string, args: string[]): Promise<string[]> {
358
457
  .filter((p) => p !== '')
359
458
  }
360
459
 
361
- /** The config paths under `root` outside git, root-relative: no dot-directory, no `node_modules`. */
460
+ /** The config paths under `root` outside git, root-relative: none under `node_modules`, `.git` or `.vx`. */
362
461
  async function scanConfigs(root: string): Promise<string[]> {
363
462
  const out: string[] = []
364
463
  const walk = async (rel: string): Promise<void> => {
@@ -375,9 +474,8 @@ async function scanConfigs(root: string): Promise<string[]> {
375
474
  const below: string[] = []
376
475
  for (const e of entries) {
377
476
  const child = rel === '' ? e.name : `${rel}/${e.name}`
378
- if (CONFIG_RANK.has(e.name)) out.push(child)
379
- else if (e.isDirectory() && !e.name.startsWith('.') && e.name !== 'node_modules')
380
- below.push(child)
477
+ if (CONFIG_RANK.has(e.name) || e.name === REVIEW_FILE) out.push(child)
478
+ else if (e.isDirectory() && !SCAN_SKIPS.has(e.name)) below.push(child)
381
479
  }
382
480
  await Promise.all(below.map(walk))
383
481
  }
@@ -385,9 +483,12 @@ async function scanConfigs(root: string): Promise<string[]> {
385
483
  return out
386
484
  }
387
485
 
486
+ // `.vx` holds vx's state (the cache), which a checkout ignores.
487
+ const SCAN_SKIPS = new Set(['node_modules', '.git', '.vx'])
488
+
388
489
  async function workspaceFileIn(root: string, rel: string): Promise<boolean> {
389
490
  const names = await readdir(path.join(root, rel, '.vx')).catch(() => [] as string[])
390
- return names.some((n) => n.startsWith('workspace.'))
491
+ return names.some((n) => WORKSPACE_RANK.has(`.vx/${n}`))
391
492
  }
392
493
 
393
494
  /** How each project got its name, when not by its path: `config`, or the plugin's name. */
@@ -455,14 +556,24 @@ export async function discoveredProject(
455
556
  const bad = nameFault(name)
456
557
  if (bad !== null) throw new UserError(`${where} ${JSON.stringify(name)}: ${bad}`)
457
558
  }
458
- const dir = path.resolve(root, entry.dir)
559
+ let dir = path.resolve(root, entry.dir)
459
560
  const rel = relPosix(root, dir)
460
561
  const named = name === undefined ? '' : ` "${name}"`
461
562
  if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
462
563
  throw new UserError(`${where}${named} at ${dir}: outside the workspace root`)
463
564
  }
464
565
  const shown = rel === '' ? '.' : rel
465
- const meta = known.get(dir)
566
+ // Refused once the stage ended too, but naming no plugin and saying to set a `name`.
567
+ if (name === ROOT_NAME && rel !== '') {
568
+ throw new UserError(`${where} "//" at ${shown}: "//" names the workspace root`)
569
+ }
570
+ let meta = known.get(dir)
571
+ if (meta === undefined) {
572
+ // Only a directory no project holds is resolved: a locked one is
573
+ // already real, and a realpath per entry is a syscall per project.
574
+ dir = canonicalDir(root, dir) ?? dir
575
+ meta = known.get(dir)
576
+ }
466
577
  if (meta !== undefined) {
467
578
  const from = namedBy.get(meta)
468
579
  if (name === undefined || name === meta.name || from === 'config') return { meta, added: false }
@@ -493,6 +604,38 @@ function nameFault(name: string): string | null {
493
604
  if (name.trim() !== name) return 'the name has surrounding whitespace'
494
605
  // A task is `<project>#<task>` split at the first `#`.
495
606
  if (name.includes('#')) return 'the name holds "#" — vx addresses a task as <name>#<task>'
607
+ // Printed raw by the status line and summary: `ESC ]0;` retitled the terminal.
608
+ if (hasControl(name)) return 'the name holds a control character'
609
+ const why = projectNameProblem(name)
610
+ return why === null ? null : `the name ${why}`
611
+ }
612
+
613
+ /**
614
+ * Why `dependsOn` or `--filter` cannot name a project called `name`, or
615
+ * null; surrounding whitespace and `#` are each caller's own words. Each
616
+ * shape loaded and could not be addressed: `^ui#build` was refused as `^`
617
+ * with `pkg#task`, `--filter '!ui'` excluded it, `tag:ui` and `ui...` read
618
+ * as a tag and a walk. Asked of the filter parser itself, as `tagProblem`.
619
+ */
620
+ export function projectNameProblem(name: string): string | null {
621
+ if (name.includes('*')) return "holds '*', which makes it a pattern"
622
+ if (name.startsWith('^')) return "starts with '^', which names dependencies' tasks"
623
+ if (name.startsWith('!')) return "starts with '!', which a filter reads as an exclusion"
624
+ // The root's own name, which the filter reads as the root's directory.
625
+ if (name === ROOT_NAME) return null
626
+ const walk = "starts or ends with '...', which a filter reads as a dependency walk"
627
+ let f: ReturnType<typeof parseFilter>
628
+ try {
629
+ f = parseFilter(name, '/')
630
+ } catch {
631
+ // `...` alone: the walk with no project between.
632
+ return walk
633
+ }
634
+ if (f.withDeps || f.withDependents || f.onlyDeps || f.onlyDependents) return walk
635
+ if (f.gitSince !== undefined)
636
+ return `ends in "[${f.gitSince}]", which a filter reads as a git range`
637
+ if (f.tag === true) return "starts with 'tag:', which a filter reads as a tag selector"
638
+ if (f.isPath) return 'reads as a directory to a filter'
496
639
  return null
497
640
  }
498
641
 
@@ -513,7 +656,7 @@ export function checkProjectNames(root: string, metas: ProjectMeta[]): ProjectMe
513
656
  const fault = namedBy.has(meta) ? null : nameFault(meta.name)
514
657
  if (fault !== null) {
515
658
  throw new UserError(
516
- `${rel}: a project is named by its path unless named, and ${fault.replace('the name', 'this one')} — set \`name\` in its vx.config`,
659
+ `${printable(rel)}: a project is named by its path unless named, and ${fault.replace('the name', 'this one')} — set \`name\` in its vx.config`,
517
660
  )
518
661
  }
519
662
  const next = metas[i + 1]