@vzn/vx 0.0.646 → 0.0.649

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,11 +1,15 @@
1
+ import path from 'node:path'
1
2
  import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
2
3
  import {
3
4
  asTrees,
5
+ braceAlternatives,
6
+ GLOB_WILDCARDS,
4
7
  checkIgnoreSync,
5
8
  ignoreProbes,
6
9
  isLiteralPattern,
7
10
  literalPath,
8
11
  listedSync,
12
+ nearest,
9
13
  normalizeGlob,
10
14
  relPosix,
11
15
  splitNegations,
@@ -15,15 +19,14 @@ import {
15
19
  UserError,
16
20
  wholeIgnored,
17
21
  wholeSubtreePrefixes,
18
- } from '../util/index.js'
19
- import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
20
- import {
21
22
  DependencySpecError,
22
23
  compileTaskPattern,
23
24
  isTaskPattern,
24
25
  parseDependencySpec,
25
26
  type DependencySpec,
26
- } from './dependency-spec.js'
27
+ } from '../util/index.js'
28
+ import type { PackageGraph, ProjectEntry } from '../workspace/index.js'
29
+ import { isDefaultBuild } from './default-build.js'
27
30
  import { foldedDeps, isGroupTask } from './folds.js'
28
31
  import type { TaskOutcome } from './scheduler.js'
29
32
 
@@ -69,6 +72,11 @@ export interface TaskNode {
69
72
  * Absent when no plugin declares the stage.
70
73
  */
71
74
  keyParts?: ReadonlyArray<readonly [name: string, value: string]>
75
+ /**
76
+ * The platform this run runs it on, when it declares `exec.platforms`:
77
+ * set by placement, folded into its key.
78
+ */
79
+ platform?: string
72
80
  /**
73
81
  * Ids of upstream tasks whose declared outputs this task's outputs
74
82
  * overlap: this task ADDS to their trees (twenty's `build:individual`
@@ -102,9 +110,20 @@ export function taskId(project: string, task: string): string {
102
110
  // graph is where every reader of a task id looks for it.
103
111
  export { splitTaskId } from '../util/index.js'
104
112
 
105
- /** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `inputs`. */
106
- function isKeyedGroup(task: TaskConfig): boolean {
107
- return task.exec === undefined && task.inputs !== undefined
113
+ /**
114
+ * A keyed group that is only its dependencies' own `name` (`^name`): the
115
+ * default `build` (orchestrator/projects.ts), or a config's of that shape.
116
+ * Only this shape walks last on a package cycle and is passed through.
117
+ * Any keyed group was: `ci` keyed on `lint` lost its edge and ran nothing,
118
+ * and `deploy` on `^build` walked `^deploy`.
119
+ */
120
+ function isChainGroup(task: TaskConfig, name: string): boolean {
121
+ return (
122
+ task.exec === undefined &&
123
+ task.inputs !== undefined &&
124
+ task.dependsOn?.length === 1 &&
125
+ task.dependsOn[0] === `^${name}`
126
+ )
108
127
  }
109
128
 
110
129
  /**
@@ -283,11 +302,17 @@ export interface BuildGraphOptions {
283
302
  workspaceRoot?: string
284
303
  /** The workspace's `run.rules`; each is on unless set to `false`. */
285
304
  rules?: GraphRules | undefined
305
+ /**
306
+ * By project name, every project rooted inside its dir (absolute dirs).
307
+ * Given with `workspaceRoot` and unlocked (`vx lock`), an `inputs.files`
308
+ * entry rooted inside one is refused (`refuseNestedInputs`).
309
+ */
310
+ nestedProjects?: ReadonlyMap<string, ReadonlyArray<{ name: string; dir: string }>>
286
311
  }
287
312
 
288
313
  /**
289
314
  * `run.rules` as the graph applies them. `locked`: a run, which never
290
- * checks them; `vx lock` and `vx lock --check` do (owner, 2026-10-14).
315
+ * checks them; `vx lock` and `vx lock --check` do (2026-10-14).
291
316
  */
292
317
  export type GraphRules = WorkspaceRules & { readonly locked?: boolean }
293
318
 
@@ -298,9 +323,14 @@ export type GraphRules = WorkspaceRules & { readonly locked?: boolean }
298
323
  * legal without a holder — a preset spreads `^build` over projects whose
299
324
  * dependencies lack it.
300
325
  */
301
- export function undeclaredDepsError(taskId: string, name: string): UserError {
326
+ export function undeclaredDepsError(
327
+ taskId: string,
328
+ name: string,
329
+ declared: Iterable<string>,
330
+ ): UserError {
331
+ const meant = nearest(name, declared)
302
332
  return new UserError(
303
- `${taskId}: dependsOn ^${name} but no project in the workspace declares ${name}`,
333
+ `${taskId}: dependsOn ^${name} but no project in the workspace declares ${name}${meant === undefined ? '' : `. Did you mean ^${meant}?`}`,
304
334
  'VX_E_DEPENDS_ON',
305
335
  )
306
336
  }
@@ -335,7 +365,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
335
365
 
336
366
  function add(node: TaskNode): void {
337
367
  nodes.set(node.id, node)
338
- if (isKeyedGroup(node.config) && onCycle(node.projectName)) deferred.push(node)
368
+ if (isChainGroup(node.config, node.taskName) && onCycle(node.projectName)) deferred.push(node)
339
369
  else if ((node.config.dependsOn?.length ?? 0) > 0) {
340
370
  stack.push({ node, entry: 0, added: false, pending: null, next: 0 })
341
371
  }
@@ -401,7 +431,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
401
431
  return declared.has(name)
402
432
  }
403
433
 
404
- // Package cycles, for the default `build` (`isKeyedGroup`): each project
434
+ // Package cycles, for the default `build` (`isChainGroup`): each project
405
435
  // on one maps to its strongly connected component, found on the first ask
406
436
  // (iterative Tarjan over `directDeps`, the order the `^name` walk reads).
407
437
  const component = new Map<string, number>()
@@ -466,7 +496,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
466
496
  if (visit(frame, target, name, false)) {
467
497
  held = true
468
498
  const task = declaredTask(projects.get(target)!.config, name)!
469
- if (isKeyedGroup(task) && onCycle(target)) {
499
+ if (isChainGroup(task, name) && onCycle(target)) {
470
500
  frontier.push(...packageGraph.directDeps(target))
471
501
  }
472
502
  } else frontier.push(...packageGraph.directDeps(target))
@@ -474,13 +504,14 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
474
504
  return held
475
505
  }
476
506
 
477
- // An edge by name (`build`, `pkg#build`) to a default build on a package
478
- // cycle goes on past it as a `^build` walk does: that build folds nothing
479
- // of the builds on its cycle, so `a#test` on `build` with `a` ↔ `b` both
480
- // on the default build folded none of `b` and hit after `b` changed.
507
+ // An edge by name (`build`, `pkg#build`) or pattern (`bui*`, `^bui*`) to
508
+ // a default build on a package cycle goes on past it as a `^build` walk
509
+ // does: that build folds nothing of the builds on its cycle, so `a#test`
510
+ // on `build` with `a` ↔ `b` both on the default build folded none of `b`
511
+ // and hit after `b` changed.
481
512
  function goPast(frame: Frame, project: string, name: string, visited: Set<string>): void {
482
513
  const task = declaredTask(projects.get(project)!.config, name)!
483
- if (isKeyedGroup(task) && onCycle(project)) walkHolders(frame, name, visited, project)
514
+ if (isChainGroup(task, name) && onCycle(project)) walkHolders(frame, name, visited, project)
484
515
  }
485
516
 
486
517
  // Resolves one `dependsOn` entry of `frame`'s task into its edges.
@@ -489,7 +520,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
489
520
  const { id, projectName, taskName } = node
490
521
  let spec: DependencySpec
491
522
  try {
492
- spec = parseDependencySpec(raw)
523
+ spec = parseDependencySpec(raw, 'dependsOn')
493
524
  } catch (err) {
494
525
  if (err instanceof DependencySpecError) {
495
526
  throw new UserError(`${id}: ${err.message}`, 'VX_E_DEPENDS_ON')
@@ -530,13 +561,20 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
530
561
  for (const name of Object.keys(projects.get(projectName)!.config.tasks ?? {})) {
531
562
  if (name === taskName || !re.test(name)) continue
532
563
  visit(frame, projectName, name, false)
564
+ goPast(frame, projectName, name, new Set([projectName]))
533
565
  }
534
566
  } else {
535
567
  // Missing target is a hard error — the user typed a name that
536
568
  // doesn't resolve in this project.
537
569
  if (!visit(frame, projectName, spec.task, false)) {
570
+ const meant = nearest(
571
+ spec.task,
572
+ Object.keys(projects.get(projectName)!.config.tasks ?? {}).filter(
573
+ (n) => n !== taskName,
574
+ ),
575
+ )
538
576
  throw new UserError(
539
- `${id}: dependsOn ${taskId(projectName, spec.task)} but no such task is declared`,
577
+ `${id}: dependsOn ${taskId(projectName, spec.task)} but no such task is declared${meant === undefined ? '' : `. Did you mean ${meant}?`}`,
540
578
  'VX_E_DEPENDS_ON',
541
579
  )
542
580
  }
@@ -576,7 +614,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
576
614
  // task behind it would fold no upstream either.
577
615
  if (!packageGraph.edgeSource) {
578
616
  throw new UserError(
579
- `${id}: ^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
617
+ `${id}: ${isDefaultBuild(node.config) ? `${projectName} declares no build, so it is the default build, whose ` : ''}^${spec.task} needs project edges; add pnpm()/bun()/npm()/yarn() and install so its lockfile exists, or a plugin that returns \`deps\``,
580
618
  'VX_E_DEPENDS_ON',
581
619
  )
582
620
  }
@@ -584,7 +622,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
584
622
  if (!isTaskPattern(spec.task)) {
585
623
  const held = walkHolders(frame, spec.task, visited, projectName)
586
624
  if (!held && !declaredAnywhere(projectName, spec.task)) {
587
- if (undeclaredDeps === undefined) throw undeclaredDepsError(id, spec.task)
625
+ if (undeclaredDeps === undefined) throw undeclaredDepsError(id, spec.task, declared ?? [])
588
626
  undeclaredDeps(id, spec.task)
589
627
  }
590
628
  return
@@ -600,7 +638,10 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
600
638
  re.test(n),
601
639
  )
602
640
  if (names.length > 0) {
603
- for (const name of names) visit(frame, target, name, false)
641
+ for (const name of names) {
642
+ visit(frame, target, name, false)
643
+ goPast(frame, target, name, new Set([projectName, target]))
644
+ }
604
645
  } else {
605
646
  frontier.push(...packageGraph.directDeps(target))
606
647
  }
@@ -684,13 +725,13 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
684
725
  }
685
726
  const to = taskId(target, name)
686
727
  if (sameCycle(x.projectName, target)) {
687
- if (!isKeyedGroup(task) && !nodes.has(to)) {
728
+ if (!isChainGroup(task, name) && !nodes.has(to)) {
688
729
  visit(null, target, name, false)
689
730
  drain()
690
731
  expanded = true
691
732
  reaching = null
692
733
  }
693
- if (isKeyedGroup(task) || (reaching ??= reachingTo(nodes, x.id)).has(to)) {
734
+ if (isChainGroup(task, name) || (reaching ??= reachingTo(nodes, x.id)).has(to)) {
694
735
  frontier.push(...packageGraph.directDeps(target))
695
736
  continue
696
737
  }
@@ -700,7 +741,8 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
700
741
  visit(frame, target, name, false)
701
742
  drain()
702
743
  if (nodes.size !== size) reaching = null
703
- if (isKeyedGroup(task) && onCycle(target)) frontier.push(...packageGraph.directDeps(target))
744
+ if (isChainGroup(task, name) && onCycle(target))
745
+ frontier.push(...packageGraph.directDeps(target))
704
746
  }
705
747
  x.deps = [...new Set(x.deps)].sort()
706
748
  }
@@ -718,9 +760,50 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
718
760
  }
719
761
 
720
762
  checkGraph(nodes, options.workspaceRoot, options.rules)
763
+ if (options.nestedProjects !== undefined && options.rules?.locked !== true)
764
+ refuseNestedInputs(nodes, options.nestedProjects, options.workspaceRoot)
721
765
  return nodes
722
766
  }
723
767
 
768
+ /**
769
+ * Refuse an `inputs.files` entry rooted inside a project nested in the
770
+ * task's own: every file it can match belongs to that project, which a
771
+ * project's globs never reach, so the entry keys nothing and an edit there
772
+ * hits (a `dist/**` over a build output holding its own config was dropped
773
+ * from the key in silence). Judged by each brace alternative's fixed
774
+ * prefix: `**` or a sibling reaches past the nested dir and is fine.
775
+ */
776
+ function refuseNestedInputs(
777
+ nodes: ReadonlyMap<string, TaskNode>,
778
+ nested: ReadonlyMap<string, ReadonlyArray<{ name: string; dir: string }>>,
779
+ workspaceRoot: string | undefined,
780
+ ): void {
781
+ for (const n of nodes.values()) {
782
+ const inner = nested.get(n.projectName)
783
+ if (inner === undefined || inner.length === 0 || isGroupTask(n)) continue
784
+ for (const g of positives(n.config.inputs?.files)) {
785
+ for (const alt of braceAlternatives(g)) {
786
+ const prefix = staticPrefix(alt)
787
+ if (prefix === '.' || prefix === '') continue
788
+ const abs = path.join(n.projectDir, prefix)
789
+ const hit = inner.find((p) => abs === p.dir || abs.startsWith(p.dir + path.sep))
790
+ if (hit === undefined) continue
791
+ const rel =
792
+ workspaceRoot === undefined
793
+ ? hit.name
794
+ : relPosix(workspaceRoot, path.join(n.projectDir, alt.replace(/^\.\//, '')))
795
+ throw new UserError(
796
+ `${n.id} reads ${JSON.stringify(g)} in inputs.files, which lies inside the project ` +
797
+ `${hit.name}: a project's globs never reach another project's files, so it keys ` +
798
+ `nothing. Depend on a ${hit.name} task, or name the files in inputs.workspaceFiles ` +
799
+ `(${JSON.stringify(rel)}).`,
800
+ 'VX_E_NESTED_INPUT',
801
+ )
802
+ }
803
+ }
804
+ }
805
+ }
806
+
724
807
  /** The ids of the tasks that reach `target` along `deps`, itself included. */
725
808
  function reachingTo(nodes: Map<string, TaskNode>, target: string): Set<string> {
726
809
  const dependants = new Map<string, string[]>()
@@ -913,6 +996,16 @@ export function excludeDependencies(
913
996
  * 445). One rule, one place: the copy is gone.
914
997
  */
915
998
  export function outputsOverlap(rawA: string, rawB: string): boolean {
999
+ // Each brace alternative on its own: the checks below read a brace as
1000
+ // one opaque segment, so `build/{a,b}.js` met `build/*.js`, and
1001
+ // `dist/{esm,cjs}/**` met `**/*.js`, nowhere (fuzz, 2026-10-25).
1002
+ const as = braceAlternatives(rawA)
1003
+ const bs = braceAlternatives(rawB)
1004
+ if (as.length === 1 && bs.length === 1) return overlapOne(rawA, rawB)
1005
+ return as.some((a) => bs.some((b) => overlapOne(a, b)))
1006
+ }
1007
+
1008
+ function overlapOne(rawA: string, rawB: string): boolean {
916
1009
  // A built path proves overlap only between globs as written: a literal's
917
1010
  // `/**` twin reads it as a directory, and `dist/types.d.ts` met
918
1011
  // `dist/**/*.js` at `dist/types.d.ts/x.js`, a file no one can write.
@@ -966,7 +1059,7 @@ function witnessed(a: string, b: string): boolean {
966
1059
  }
967
1060
  // Each glob filled in whole, every wildcard a name and `**` one level or
968
1061
  // none: `**/dist/**` beside `**/*` meets at `x/dist/x`, a path neither
969
- // last segment builds (solid, M 2026-10-15).
1062
+ // last segment builds (solid, 2026-10-15).
970
1063
  for (const glob of [a, b]) {
971
1064
  const segs = glob.split('/')
972
1065
  if (segs.some((s) => /[[\]{}()!+@]/.test(s))) continue
@@ -977,7 +1070,97 @@ function witnessed(a: string, b: string): boolean {
977
1070
  if (candidate !== '' && ga.match(candidate) && gb.match(candidate)) return true
978
1071
  }
979
1072
  }
980
- return false
1073
+ const met = unified(a.split('/'), b.split('/'))
1074
+ return met !== null && met !== '' && ga.match(met) && gb.match(met)
1075
+ }
1076
+
1077
+ /**
1078
+ * A path both globs take, built segment by segment, each a name both
1079
+ * segments take, `**` matching a segment of the other or none: `out/**`
1080
+ * beside every `.d.ts` in a `types` directory one level down meets at
1081
+ * `out/types/x.d.ts`, and `*` beside `[ab]` at `a`, paths neither glob
1082
+ * filled alone builds (fuzz, 2026-10-25). Null when none is found; a find
1083
+ * is checked whole after.
1084
+ */
1085
+ function unified(sa: readonly string[], sb: readonly string[]): string | null {
1086
+ // A `!` opening a class is the class's; anywhere else, an extglob.
1087
+ if ([...sa, ...sb].some((x) => /[{}()!+@]/.test(x.replaceAll('[!', '[')))) return null
1088
+ const memo = new Map<string, string | null>()
1089
+ const join = (name: string, rest: string): string => (rest === '' ? name : `${name}/${rest}`)
1090
+ const walk = (i: number, j: number): string | null => {
1091
+ const key = `${i},${j}`
1092
+ if (memo.has(key)) return memo.get(key)!
1093
+ memo.set(key, null)
1094
+ let out: string | null = null
1095
+ if (i === sa.length && j === sb.length) out = ''
1096
+ else if (sa[i] === '**' || sb[j] === '**') {
1097
+ // A last `**` takes one segment at least: `out/**` is not `out`.
1098
+ const lastA = sa[i] === '**' && i === sa.length - 1
1099
+ const lastB = sb[j] === '**' && j === sb.length - 1
1100
+ const [wide, x, y, other] = sa[i] === '**' ? [sa, i, j, sb] : [sb, j, i, sa]
1101
+ if (lastA && lastB) out = 'x'
1102
+ if (out === null && sa[i] === '**' && !lastA) out = walk(i + 1, j)
1103
+ if (out === null && sb[j] === '**' && !lastB) out = walk(i, j + 1)
1104
+ if (out === null && y < other.length && other[y] !== '**') {
1105
+ const name = segmentMeet('*', other[y]!)
1106
+ // Having taken one, it may take more or stop.
1107
+ const rest =
1108
+ name === null
1109
+ ? null
1110
+ : wide === sa
1111
+ ? (walk(x, y + 1) ?? walk(x + 1, y + 1))
1112
+ : (walk(y + 1, x) ?? walk(y + 1, x + 1))
1113
+ if (name !== null && rest !== null) out = join(name, rest)
1114
+ }
1115
+ } else if (i < sa.length && j < sb.length) {
1116
+ const name = segmentMeet(sa[i]!, sb[j]!)
1117
+ const rest = name === null ? null : walk(i + 1, j + 1)
1118
+ if (name !== null && rest !== null) out = join(name, rest)
1119
+ }
1120
+ memo.set(key, out)
1121
+ return out
1122
+ }
1123
+ return walk(0, 0)
1124
+ }
1125
+
1126
+ /** A name both segments take, from each filled in and the one's head joined to the other's tail. */
1127
+ function segmentMeet(x: string, y: string): string | null {
1128
+ const fill = (seg: string, star: string): string =>
1129
+ seg
1130
+ .replace(/\*+/g, star)
1131
+ .replace(/\?/g, 'x')
1132
+ // A negated class takes a name it does not list: `[!a]` is not `a`.
1133
+ .replace(/\[([!^]?)(.)([^\]]*)\]/g, (_, not: string, first: string, more: string) =>
1134
+ not === ''
1135
+ ? first
1136
+ : (['x', 'y', 'z', '0', '_'].find((c) => !`${first}${more}`.includes(c)) ?? first),
1137
+ )
1138
+ const head = (seg: string): string => {
1139
+ const at = seg.search(GLOB_WILDCARDS)
1140
+ return at === -1 ? seg : seg.slice(0, at)
1141
+ }
1142
+ const tail = (seg: string): string => {
1143
+ let at = seg.length
1144
+ while (at > 0 && !GLOB_WILDCARDS.test(seg[at - 1]!)) at--
1145
+ return seg.slice(at)
1146
+ }
1147
+ const gx = new Bun.Glob(x)
1148
+ const gy = new Bun.Glob(y)
1149
+ for (const c of [
1150
+ fill(x, 'x'),
1151
+ fill(y, 'x'),
1152
+ fill(x, ''),
1153
+ fill(y, ''),
1154
+ head(x) + tail(y),
1155
+ head(y) + tail(x),
1156
+ // Each name a class lists, the other's class filled: `[ab]` beside `[!a]` meets at `b`.
1157
+ ...[x, y].flatMap((seg) => {
1158
+ const listed = /\[([^!^\]][^\]]*)\]/.exec(seg)?.[1] ?? ''
1159
+ return listed.split('').map((ch) => fill(seg.replace(/\[[^!^\]][^\]]*\]/, ch), 'x'))
1160
+ }),
1161
+ ])
1162
+ if (c !== '' && gx.match(c) && gy.match(c)) return c
1163
+ return null
981
1164
  }
982
1165
 
983
1166
  /**
@@ -988,7 +1171,16 @@ function witnessed(a: string, b: string): boolean {
988
1171
  * back its own stale copy of the narrow one's output under a green run
989
1172
  * (item 941).
990
1173
  */
1174
+ const WHOLE_ROOT = new Set(['**', '**/*'])
1175
+
991
1176
  function covers(tree: string, glob: string): boolean {
1177
+ // The root's own tree has no directory to name: `**` beside a `!` that
1178
+ // spared the config cleaned every source under it, and the refusal of
1179
+ // a task's own sources as outputs never fired.
1180
+ if (WHOLE_ROOT.has(normalizeGlob(tree))) {
1181
+ const g = normalizeGlob(glob)
1182
+ return !g.startsWith('!') && !g.startsWith('/') && !g.split('/').includes('..')
1183
+ }
992
1184
  const dir = wholeSubtreePrefixes([tree])?.[0]
993
1185
  if (dir === undefined) return false
994
1186
  const prefix = staticPrefix(glob)
@@ -1317,7 +1509,8 @@ function overlapCandidates<T>(
1317
1509
  for (let i = 0; i < tasks.length; i++) {
1318
1510
  const s = sideOf?.(tasks[i]!) === true ? 1 : 0
1319
1511
  side.push(s)
1320
- for (const tree of asTrees(globsOf(tasks[i]!) ?? [])) {
1512
+ // Each brace alternative, as `outputsOverlap` reads them.
1513
+ for (const tree of asTrees((globsOf(tasks[i]!) ?? []).flatMap(braceAlternatives))) {
1321
1514
  const lit = literalPath(tree)
1322
1515
  if (lit !== null) {
1323
1516
  literals.push([i, lit])
@@ -1567,10 +1760,10 @@ interface Side {
1567
1760
 
1568
1761
  /**
1569
1762
  * Refuse a task whose input globs can match another task's declared
1570
- * outputs (X-54; no opt-out, owner 2026-10-16). Such a key reads bytes a
1763
+ * outputs (X-54; no opt-out, 2026-10-16). Such a key reads bytes a
1571
1764
  * producer writes this run, so it is not known until the producer ran;
1572
1765
  * refused, every key reads only what no task writes and can be derived
1573
- * before anything runs (M, 2026-10-07: "hash ahead of time, not
1766
+ * before anything runs (2026-10-07: "hash ahead of time, not
1574
1767
  * waterfall"). A dependency's own key already cascades through
1575
1768
  * `dependsOn`, so the bytes it wrote add nothing a key needs; the one
1576
1769
  * exception is `refuseOverlaps`'s unfolded dependency.
@@ -1706,9 +1899,9 @@ type Found = Map<string, Overlap[]>
1706
1899
  * Which overlaps stand. A reader may read the outputs of a task in its
1707
1900
  * `dependsOn` closure whose key its own does not fold (it sets
1708
1901
  * `inputs.tasks` and leaves the producer out, also through the tasks it
1709
- * folds): its key waits for the producer and hashes the bytes (owner,
1710
- * 2026-10-16). Otherwise an output git ignores whole is no overlap (M,
1711
- * 2026-10-15), a key reading only what git lists, unless the reader's
1902
+ * folds): its key waits for the producer and hashes the bytes
1903
+ * (2026-10-16). Otherwise an output git ignores whole is no overlap
1904
+ * (2026-10-15), a key reading only what git lists, unless the reader's
1712
1905
  * glob starts in an ignored path (`dist/**`): naming it is asking for it.
1713
1906
  * Git is asked once, for every output and prefix together; one it cannot
1714
1907
  * speak for is refused. The answer holds for the lock; a run's stability
@@ -1858,7 +2051,7 @@ function readsOutputs(
1858
2051
  new UserError(
1859
2052
  `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in inputs.${field}, which matches ` +
1860
2053
  `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1861
- `the outputs of a task it does not depend on, or whose key it already folds. ` +
2054
+ `the outputs of a task it does not depend on, or whose key it already includes. ` +
1862
2055
  `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1863
2056
  `inputs.${field}, or depend on ${writer.node.id} and leave it out of ` +
1864
2057
  `${reader.node.id}'s inputs.tasks, so its key hashes those bytes instead.`,
package/src/handoff.ts CHANGED
@@ -1,11 +1,11 @@
1
- // A global vx hands off to the project's own (owner, 2026-10-13), found
1
+ // A global vx hands off to the project's own (2026-10-13), found
2
2
  // from the workspace root, the one lookup every verb shares. Here for
3
3
  // the compiled binary installed by hand; an npm install's launcher
4
4
  // (npm-launcher.sh) walks the same way and `exec`s, which Bun cannot. The
5
5
  // walk runs before anything else loads, through `Bun.file(…).exists()`:
6
6
  // importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
7
7
 
8
- /** `vx upgrade -g`: moves only the global vx, so it never hands off (owner, 2026-10-16). */
8
+ /** `vx upgrade -g`: moves only the global vx, so it never hands off (2026-10-16). */
9
9
  export function upgradesOnlyItself(argv: readonly string[]): boolean {
10
10
  return argv[0] === 'upgrade' && argv.some((a) => a === '-g' || a === '--global')
11
11
  }
@@ -24,7 +24,8 @@ export async function localVx(
24
24
  compiled: boolean,
25
25
  uid = process.getuid?.(),
26
26
  ): Promise<string | null> {
27
- if (process.env['VX_HANDED_OFF'] !== undefined || !compiled) return null
27
+ // Empty is unset, as the launcher's `[ -z "${VX_HANDED_OFF-}" ]` reads it.
28
+ if (process.env['VX_HANDED_OFF'] || !compiled) return null
28
29
  if (execPath.includes('/node_modules/')) return null
29
30
  const file = await workspaceFile(cwd)
30
31
  if (file === null) return null
@@ -44,7 +45,7 @@ export async function localVx(
44
45
 
45
46
  /**
46
47
  * The workspace config's paths under the root, in lookup order: one marks
47
- * the workspace root, the directory holding its `.vx` (owner, 2026-10-14).
48
+ * the workspace root, the directory holding its `.vx` (2026-10-14).
48
49
  */
49
50
  export const WORKSPACE_CONFIG_FILENAMES = [
50
51
  '.vx/workspace.ts',
@@ -67,7 +68,7 @@ const files = new Map<string, Promise<string | null>>()
67
68
  /**
68
69
  * The workspace config that marks the root for `dir`: the first
69
70
  * `.vx/workspace.*` from `dir` up to the git top-level ({@link gitTop}),
70
- * inclusive, or to `/` outside git (owner, 2026-10-13: "Workspace file is
71
+ * inclusive, or to `/` outside git (2026-10-13: "Workspace file is
71
72
  * the ultimate" and "never go out of git root"). Null when there is none.
72
73
  * Learned once per directory per run: the hand-off and the workspace root
73
74
  * share it (principle 9).
@@ -87,7 +88,7 @@ export function workspaceFileWritten(): void {
87
88
  }
88
89
 
89
90
  async function findWorkspaceFile(start: string): Promise<string | null> {
90
- const top = await gitTop(start)
91
+ let top = await gitTop(start)
91
92
  const passed: string[] = []
92
93
  let found: string | null = null
93
94
  walk: for (let d = start; ; d = d.slice(0, d.lastIndexOf('/'))) {
@@ -99,15 +100,79 @@ async function findWorkspaceFile(start: string): Promise<string | null> {
99
100
  break walk
100
101
  }
101
102
  }
102
- if (d === '' || d === top) break
103
+ if (d === '') break
104
+ if (d === top) {
105
+ // A submodule whose `.git` is a directory (added from a clone, not
106
+ // absorbed) is a top-level by its files: git alone knows the
107
+ // superproject tracks it. Asked only once the walk found nothing.
108
+ const above = await superproject(d)
109
+ if (above === null) break
110
+ top = await gitTop(above)
111
+ }
103
112
  }
113
+ if (found === null && top !== null && top !== '/') found = await outerWorkspaceFile(top)
104
114
  // Every directory walked has the same answer: the loader asks for the root's.
105
115
  for (const d of passed) if (!files.has(d)) files.set(d, Promise.resolve(found))
106
116
  return found
107
117
  }
108
118
 
119
+ /**
120
+ * The workspace config of the repository `top` is embedded in (an untracked
121
+ * clone with its own `.git` directory), when that repository's git does not
122
+ * ignore it: `vx lock` lists such a repository's configs as the outer
123
+ * workspace's, and a run from inside one stopped at its `.git`, "no
124
+ * .vx/workspace.ts". A submodule's walk goes on already ({@link gitTop}).
125
+ * Asked only when the walk found nothing, so a run pays nothing for it.
126
+ */
127
+ async function outerWorkspaceFile(top: string): Promise<string | null> {
128
+ // A worktree's `.git` is a file: another checkout, its own top-level.
129
+ if (!(await Bun.file(`${top}/.git/HEAD`).exists())) return null
130
+ const parent = top.slice(0, top.lastIndexOf('/')) || '/'
131
+ const outer = await gitTop(parent)
132
+ if (outer === null) return null
133
+ const file = await workspaceFile(parent)
134
+ if (file === null) return null
135
+ try {
136
+ // Plain `git`, as handoff imports nothing (the hand-off runs first).
137
+ // Exit 1: not ignored, so the outer listing holds it; 0 ignored; 128 an error.
138
+ // `./`: check-ignore reads `:x` as pathspec magic and refuses `:(literal)`.
139
+ const rel = top.slice(outer.length + (outer === '/' ? 0 : 1))
140
+ const p = Bun.spawnSync({
141
+ cmd: ['git', 'check-ignore', '-q', '--', `./${rel}`],
142
+ cwd: outer,
143
+ stdin: 'ignore',
144
+ stdout: 'ignore',
145
+ stderr: 'ignore',
146
+ })
147
+ return p.exitCode === 1 ? file : null
148
+ } catch {
149
+ return null
150
+ }
151
+ }
152
+
109
153
  const tops = new Map<string, Promise<string | null>>()
110
154
 
155
+ /** The working tree of the repository that tracks `top` as a submodule, or null. */
156
+ async function superproject(top: string): Promise<string | null> {
157
+ const { executablePath, gitEnv } = await import('./util/index.js')
158
+ try {
159
+ const proc = Bun.spawn({
160
+ cmd: [executablePath('git'), 'rev-parse', '--show-superproject-working-tree'],
161
+ cwd: top,
162
+ env: gitEnv(),
163
+ stdin: 'ignore',
164
+ stdout: 'pipe',
165
+ stderr: 'ignore',
166
+ })
167
+ const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
168
+ const dir = out.trim()
169
+ return exit === 0 && dir.length > 0 ? dir : null
170
+ } catch {
171
+ // No git: the files are all there is to go by.
172
+ return null
173
+ }
174
+ }
175
+
111
176
  /**
112
177
  * The git top-level holding `dir`, or null outside git: the nearest `.git`
113
178
  * up from it, a directory in a clone, a file in a worktree. A submodule's
@@ -132,6 +197,11 @@ async function findGitTop(start: string): Promise<string | null> {
132
197
  if (await file.exists()) {
133
198
  const gitdir = await file.text().catch(() => '')
134
199
  if (!/(^|[\s/])\.git\/modules\//.test(gitdir)) return d || '/'
200
+ // A submodule's own worktree points there too, and is a top-level:
201
+ // git writes `commondir` in a worktree's gitdir alone.
202
+ const to = gitdir.replace(/^gitdir:\s*/, '').trim()
203
+ if (await Bun.file(`${to.startsWith('/') ? '' : `${d}/`}${to}/commondir`).exists())
204
+ return d || '/'
135
205
  }
136
206
  if (d === '') return null
137
207
  }
@@ -149,6 +219,10 @@ export function vxPath(): string {
149
219
  * second would read as "stop now" (npm-launcher.cjs, the same rule).
150
220
  */
151
221
  export async function handOff(bin: string, args: readonly string[]): Promise<never> {
222
+ // This vx waits out the whole run beside the tasks, and `run()`, which
223
+ // hides the env on every verb, is never reached here. Imported here:
224
+ // a start that hands off nothing pays nothing.
225
+ await (await import('./util/index.js')).hideEnvFromTasks()
152
226
  const child = Bun.spawn([bin, ...args], {
153
227
  stdio: ['inherit', 'inherit', 'inherit'],
154
228
  env: { ...process.env, VX_HANDED_OFF: process.execPath },
@@ -159,13 +233,27 @@ export async function handOff(bin: string, args: readonly string[]): Promise<nev
159
233
  })
160
234
  }
161
235
  const code = await child.exited
236
+ const signal = child.signalCode
237
+ if (signal === null) process.exit(code)
238
+ // Die of a stop signal the local vx died of, as vx itself does: a shell
239
+ // running vx in a script stops on Ctrl-C only when its child died of
240
+ // SIGINT, and an exit 130 ran the script's next line. The code below is
241
+ // the rest, and the fallback should the signal not land.
242
+ if (signal === 'SIGINT' || signal === 'SIGTERM' || signal === 'SIGHUP') {
243
+ process.removeAllListeners(signal)
244
+ process.kill(process.pid, signal)
245
+ }
246
+ if (typeof signal === 'number') process.exit(128 + signal)
162
247
  const { constants } = await import('node:os')
163
- process.exit(child.signalCode === null ? code : 128 + (constants.signals[child.signalCode] ?? 1))
248
+ process.exit(128 + (constants.signals[signal] ?? 1))
164
249
  }
165
250
 
166
251
  function inForeground(): boolean {
167
252
  try {
168
- const stat = Bun.spawnSync(['ps', '-o', 'pgid=,tpgid=', '-p', String(process.pid)])
253
+ // The handed-off vx's tasks run meanwhile and could read its env: PATH alone (`helperEnv`).
254
+ const stat = Bun.spawnSync(['ps', '-o', 'pgid=,tpgid=', '-p', String(process.pid)], {
255
+ env: { PATH: process.env['PATH'] ?? '' },
256
+ })
169
257
  const [pgid, tpgid] = stat.stdout.toString().trim().split(/\s+/)
170
258
  return pgid !== undefined && pgid === tpgid
171
259
  } catch {