@vzn/vx 0.0.645 → 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 (186) hide show
  1. package/README.md +39 -50
  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 +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  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 +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -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/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -7,10 +7,18 @@
7
7
  // closes at the bottom of execution, `planRun()` does so via
8
8
  // try/finally around its plan() call.
9
9
 
10
- import { existsSync } from 'node:fs'
10
+ import { existsSync, realpathSync } from 'node:fs'
11
11
  import path from 'node:path'
12
12
  import type { WorkspaceConfig } from '../config.js'
13
- import { beginRun, mark, nearest, relPosix, type SecretBroker, UserError } from '../util/index.js'
13
+ import {
14
+ beginRun,
15
+ mark,
16
+ nearest,
17
+ relPosix,
18
+ type SecretBroker,
19
+ thrownMessage,
20
+ UserError,
21
+ } from '../util/index.js'
14
22
  import {
15
23
  Cache,
16
24
  type CacheLayer,
@@ -40,6 +48,7 @@ import {
40
48
  findWorkspaceRoot,
41
49
  configuredBaseBranch,
42
50
  type LoadReads,
51
+ lockDigest,
43
52
  PROJECT_CONFIG_FILENAMES,
44
53
  readClaimedFiles,
45
54
  resolveCacheDir,
@@ -47,10 +56,10 @@ import {
47
56
  type ProjectEntry,
48
57
  } from '../workspace/index.js'
49
58
  import {
50
- buildTaskGraph,
51
59
  excludeDependencies,
52
60
  expandRequested,
53
61
  type GraphRules,
62
+ isDefaultBuild,
54
63
  isGroupTask,
55
64
  refuseFiltersThroughGroups,
56
65
  type TaskNode,
@@ -70,7 +79,6 @@ import {
70
79
  discoverProjects,
71
80
  gitOfDiscovery,
72
81
  graphOfDiscovery,
73
- isDefaultBuild,
74
82
  loadProjects,
75
83
  loadWorkspacePlugins,
76
84
  lockOfDiscovery,
@@ -82,6 +90,7 @@ import { applyGraphStage } from './graph-stage.js'
82
90
  import { keyExcludedDependencies } from './excluded-keys.js'
83
91
  import { FingerprintWatch } from './fingerprint-watch.js'
84
92
  import type { VxPlugin } from './plugin.js'
93
+ import { memoGraph, type GraphMemo } from './graph-memo.js'
85
94
  import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
86
95
  import { cacheRefs, currentBranch, resolveCacheScope } from './run-context.js'
87
96
  import { createHashCache, type HashCache } from './task-hash.js'
@@ -116,6 +125,8 @@ export interface PreparedRun {
116
125
  cacheScope: string | undefined
117
126
  /** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
118
127
  remotes: ResolvedRemote[]
128
+ /** The refs the run reads, in order (`CacheContext.refs`). */
129
+ cacheRefs: readonly string[]
119
130
  /**
120
131
  * Scheduling priorities from plugins' `schedule` stage (task id → weight,
121
132
  * merged over the structural baseline by the scheduler). Empty when no
@@ -166,6 +177,14 @@ export interface PreparedRun {
166
177
  * mistaken for a workspace vx was never set up in.
167
178
  */
168
179
  anyProjectConfig: boolean
180
+ /** A workspace project that declares no task: no config, and nothing configured reaches it. */
181
+ taskless: (name: string) => boolean
182
+ /**
183
+ * With no config in the lock, one a directory holds anyway (root-relative):
184
+ * written after `vx lock`, so the fix is `vx lock`, not `vx init`. Asked
185
+ * only by a refusal, so a run that goes on pays no stat.
186
+ */
187
+ unlockedConfig: () => string | undefined
169
188
  /** The claimed root files, and whether a task has since rewritten one (fingerprint-watch.ts). */
170
189
  fingerprintWatch: FingerprintWatch
171
190
  nestedDirsByProject: Map<string, string[]>
@@ -333,7 +352,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
333
352
  const asked =
334
353
  options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
335
354
  // A laptop reads the shared cache and does not write it unless a policy
336
- // names the remote (owner, 2026-10-03; Security F1, 2026-10-14): a key
355
+ // names the remote (2026-10-03; Security F1, 2026-10-14): a key
337
356
  // holds only declared inputs, so a laptop's write could be replayed on CI.
338
357
  // A workspace policy naming the remote asks to write it, as `--cache-policy` does.
339
358
  const readOnly =
@@ -366,7 +385,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
366
385
 
367
386
  // Configs load FROM .vx/projects.lock.json with no evaluation and no check of
368
387
  // the configs (`vx lock --check` re-evaluates them; a run does not):
369
- // only `vx lock` evaluates (owner, 2026-10-14).
388
+ // only `vx lock` evaluates (2026-10-14).
370
389
  // See docs/design/config-lock-2026-06.md.
371
390
  // Read once, and only when a config is to be read from it: the CLI's
372
391
  // selection pass may have staged every config (`options.staged`), and
@@ -410,6 +429,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
410
429
  // name, never by count: a config-less project counts as loaded, so a
411
430
  // scope holding one hid an unloaded declarer (X-129, X-182).
412
431
  const partialLoad = projectsWithConfigs.some((m) => !projects.has(m.name))
432
+ // Asked on a refusal only: a scope never loads a config-less project
433
+ // nothing configured reaches.
434
+ const taskless = (name: string): boolean =>
435
+ !projects.has(name) &&
436
+ projectMetas.some((m) => m.name === name) &&
437
+ !projectsWithConfigs.some((m) => m.name === name)
413
438
 
414
439
  // Boundary geometry considers every workspace project, loaded or not,
415
440
  // config or not: `--affected` gives a changed path to the deepest project
@@ -427,7 +452,20 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
427
452
  // A dependant's `^build` still reaches it, and `lib#build` names it.
428
453
  const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
429
454
  let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
455
+ // A diff that changed only config-less projects loads none of them: no
456
+ // candidate to ask, so every bare name is still to judge, or a typo ran
457
+ // nothing, exit 0.
458
+ if (options.selectedByDiff === true && candidateProjects.length === 0)
459
+ unresolvedTasks = [...new Set([...tasks.filter((t) => !t.includes('#')), ...unresolvedTasks])]
430
460
  refuseUnlockedConfig(tasks, projects, projectMetas, workspaceRoot)
461
+ // A scope that loaded no candidate judged no bare name: each is unresolved.
462
+ if (options.projects !== undefined && options.selectedByDiff !== true)
463
+ refuseUnlockedScope(
464
+ candidateProjects.length === 0 ? tasks : unresolvedTasks,
465
+ options.projects,
466
+ projectMetas,
467
+ workspaceRoot,
468
+ )
431
469
  if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
432
470
  // A name a loaded project declares is no typo, whether or not the load
433
471
  // was whole: an unaffected dependency loaded for the closure declared
@@ -494,6 +532,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
494
532
  onError: (err) => warnOf(log)(`[vx] remote cache: ${err.message}`),
495
533
  })
496
534
  }
535
+ // An injected remote is no plugin's: nothing reads refs for it.
536
+ const refs = options.remoteCache ? [] : cacheRefs(workspaceRoot, baseBranch, branchFacts.refs)
497
537
  try {
498
538
  cache = options.remoteCache
499
539
  ? new LayeredCache(localCache, remotes, options.uploadConcurrency)
@@ -502,7 +542,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
502
542
  cacheDir,
503
543
  warn: warnOf(log),
504
544
  scope: cacheScope,
505
- refs: cacheRefs(workspaceRoot, baseBranch),
545
+ refs,
506
546
  localCache,
507
547
  policy,
508
548
  remotes,
@@ -554,6 +594,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
554
594
  cachePolicy: policy,
555
595
  cacheScope,
556
596
  remotes,
597
+ cacheRefs: refs,
557
598
  keepRunEntry: undefined,
558
599
  priorities: () => Promise.resolve(new Map()),
559
600
  branch,
@@ -565,6 +606,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
565
606
  projects,
566
607
  hintProjects,
567
608
  anyProjectConfig: projectsWithConfigs.length > 0,
609
+ taskless,
610
+ unlockedConfig: () => unlockedConfigOf(projectsWithConfigs, projectMetas, workspaceRoot),
568
611
  fingerprintWatch,
569
612
  nestedDirsByProject,
570
613
  gitFilesCache: new GitFilesCache(),
@@ -585,21 +628,38 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
585
628
  // by a config the scope left out, so the builder hands it back instead of
586
629
  // refusing it, and the rest decide.
587
630
  // A run never checks the graph rules: `vx lock` and `vx lock --check`
588
- // do (owner, 2026-10-14). `exclusiveOutputs: false` still asks the walk
631
+ // do (2026-10-14). `exclusiveOutputs: false` still asks the walk
589
632
  // for its addition marks.
590
633
  const graphRules: GraphRules = { ...runRules(workspaceConfig), locked: true }
591
634
  const unproven: Array<[taskId: string, name: string]> = []
592
- const graphOf = (req: typeof requested) =>
593
- buildTaskGraph({
594
- projects,
595
- packageGraph,
596
- requested: req,
597
- workspaceRoot,
598
- rules: graphRules,
599
- ...(partialLoad
600
- ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
601
- : {}),
602
- })
635
+ // Every config is the lock's only when it was read and no `project`
636
+ // plugin edited one (graph-memo.ts).
637
+ const lock =
638
+ hashCache.folds !== undefined &&
639
+ options.staged === undefined &&
640
+ !allStaged &&
641
+ !hasHook(plugins, 'project')
642
+ ? lockDigest(await readLock())
643
+ : undefined
644
+ const graphMemo: GraphMemo | undefined =
645
+ lock === undefined ? undefined : { folds: hashCache.folds!, lock, metas: projectMetas }
646
+ const graphOf = (req: typeof requested) => {
647
+ const built = memoGraph(
648
+ {
649
+ projects,
650
+ packageGraph,
651
+ requested: req,
652
+ workspaceRoot,
653
+ rules: graphRules,
654
+ ...(partialLoad
655
+ ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
656
+ : {}),
657
+ },
658
+ graphMemo,
659
+ )
660
+ hashCache.graph = built.token
661
+ return built.nodes
662
+ }
603
663
  let nodes = graphOf(requested)
604
664
  const hasGraphHook = hasHook(plugins, 'graph')
605
665
  const graphStage = (): Promise<void> =>
@@ -686,6 +746,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
686
746
  // `prepare (graph)` row until 2026-09-10.
687
747
  mark('build graph')
688
748
  const gitFilesCache = new GitFilesCache()
749
+ gitFilesCache.vxDirs = withinRoot(workspaceRoot, cacheDir)
689
750
  // Bulk-populate via a single `git ls-files` at the workspace root —
690
751
  // partitions the output by project. Avoids one fork+exec per project
691
752
  // (~5-10ms each on Linux; the dominant cold-start cost on big
@@ -742,6 +803,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
742
803
  }
743
804
  refuseFiltersThroughGroups(nodes)
744
805
  const exclude = options.excludeDependencies
806
+ // The nodes are what the graph token names only while nothing edited them.
807
+ if (
808
+ hasGraphHook ||
809
+ hasHook(plugins, 'key') ||
810
+ (exclude !== undefined && (exclude === 'all' || exclude.length > 0))
811
+ )
812
+ hashCache.graph = undefined
745
813
  let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
746
814
  if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
747
815
  const split = excludeDependencies(nodes, exclude)
@@ -787,6 +855,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
787
855
  warn: warnOf(log),
788
856
  localCache,
789
857
  branch: branch(),
858
+ refs,
790
859
  },
791
860
  cacheFor,
792
861
  run,
@@ -804,6 +873,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
804
873
  cachePolicy: policy,
805
874
  cacheScope,
806
875
  remotes,
876
+ cacheRefs: refs,
807
877
  priorities,
808
878
  branch,
809
879
  cacheFor,
@@ -817,6 +887,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
817
887
  projects,
818
888
  hintProjects,
819
889
  anyProjectConfig: projectsWithConfigs.length > 0,
890
+ taskless,
891
+ unlockedConfig: () => unlockedConfigOf(projectsWithConfigs, projectMetas, workspaceRoot),
820
892
  fingerprintWatch,
821
893
  nestedDirsByProject,
822
894
  gitFilesCache,
@@ -891,7 +963,7 @@ async function refuseUndeclaredDeps(
891
963
  for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
892
964
  }
893
965
  for (const [id, name] of unproven) {
894
- if (!declared.has(name)) throw undeclaredDepsError(id, name)
966
+ if (!declared.has(name)) throw undeclaredDepsError(id, name, declared)
895
967
  }
896
968
  }
897
969
 
@@ -905,9 +977,11 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
905
977
  const stack = [...roots]
906
978
  while (stack.length > 0) {
907
979
  const id = stack.pop()!
908
- if (keep.has(id)) continue
980
+ // A `pkg#task` the user named is kept, though a hook may have dropped it.
981
+ const node = nodes.get(id)
982
+ if (node === undefined || keep.has(id)) continue
909
983
  keep.add(id)
910
- stack.push(...nodes.get(id)!.deps)
984
+ stack.push(...node.deps)
911
985
  }
912
986
  for (const [id, n] of nodes) {
913
987
  if (!keep.has(id)) nodes.delete(id)
@@ -915,7 +989,7 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
915
989
  }
916
990
  }
917
991
 
918
- const namesRemote = (spec: string | undefined): boolean =>
992
+ export const namesRemote = (spec: string | undefined): boolean =>
919
993
  spec !== undefined && /remote:[rw]/.test(spec)
920
994
 
921
995
  /**
@@ -923,7 +997,9 @@ const namesRemote = (spec: string | undefined): boolean =>
923
997
  * since the grammar lives in the cache module the workspace module may not
924
998
  * import. Undefined when it names none.
925
999
  */
926
- function workspaceCachePolicy(config: WorkspaceConfig | null | undefined): CachePolicy | undefined {
1000
+ export function workspaceCachePolicy(
1001
+ config: WorkspaceConfig | null | undefined,
1002
+ ): CachePolicy | undefined {
927
1003
  const spec = config?.cache?.policy
928
1004
  if (spec === undefined) return undefined
929
1005
  try {
@@ -936,16 +1012,30 @@ function workspaceCachePolicy(config: WorkspaceConfig | null | undefined): Cache
936
1012
  }
937
1013
  }
938
1014
 
1015
+ /** A config no locked project has, looked for only when the lock holds none. */
1016
+ function unlockedConfigOf(
1017
+ configured: readonly unknown[],
1018
+ metas: ReadonlyArray<{ dir: string }>,
1019
+ workspaceRoot: string,
1020
+ ): string | undefined {
1021
+ if (configured.length > 0) return undefined
1022
+ for (const m of metas) {
1023
+ const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(m.dir, n)))
1024
+ if (config !== undefined) return relPosix(workspaceRoot, path.join(m.dir, config))
1025
+ }
1026
+ return undefined
1027
+ }
1028
+
939
1029
  /**
940
1030
  * A `project#build` naming the default `build` of a project the lock holds
941
- * no config for, while its directory holds one, asks for that config's task, not
1031
+ * no config for (or no project at all), while its directory holds one, asks for that config's task, not
942
1032
  * one that runs nothing: ran, `c#build` was 0 tasks, exit 0, where a
943
1033
  * renamed task gets "run `vx lock`" (Growth, lock drift).
944
1034
  */
945
1035
  function refuseUnlockedConfig(
946
1036
  tasks: readonly string[],
947
1037
  projects: ReadonlyMap<string, ProjectEntry>,
948
- metas: ReadonlyArray<{ name: string; configPath?: string | null }>,
1038
+ metas: ReadonlyArray<{ name: string; dir: string; configPath?: string | null }>,
949
1039
  workspaceRoot: string,
950
1040
  ): void {
951
1041
  for (const spec of tasks) {
@@ -953,17 +1043,55 @@ function refuseUnlockedConfig(
953
1043
  if (at < 0) continue
954
1044
  const name = spec.slice(0, at)
955
1045
  const entry = projects.get(name)
956
- if (entry === undefined || !isDefaultBuild(entry.config.tasks?.[spec.slice(at + 1)])) continue
957
- const locked = metas.find((m) => m.name === name)?.configPath
958
- if (typeof locked === 'string' && locked !== '') continue
959
- const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(entry.dir, n)))
1046
+ // No entry: a package the lock holds no project for, whose config came after it.
1047
+ if (entry !== undefined && !isDefaultBuild(entry.config.tasks?.[spec.slice(at + 1)])) continue
1048
+ const config = unlockedConfig(name, metas)
1049
+ if (config === undefined) continue
1050
+ throw new UserError(
1051
+ `${spec}: ${relPosix(workspaceRoot, config)} is not in the lock yet — run \`vx lock\``,
1052
+ 'VX_E_LOCK',
1053
+ )
1054
+ }
1055
+ }
1056
+
1057
+ /**
1058
+ * The same for a bare name the selection (the cwd's project, `--filter`)
1059
+ * left unresolved: run from a member whose config came after the lock, it
1060
+ * said only projects outside the selection declare the task — pass
1061
+ * `--all`, which skips that member too.
1062
+ */
1063
+ function refuseUnlockedScope(
1064
+ unresolved: readonly string[],
1065
+ selected: readonly string[],
1066
+ metas: ReadonlyArray<{ name: string; dir: string; configPath?: string | null }>,
1067
+ workspaceRoot: string,
1068
+ ): void {
1069
+ const spec = unresolved.find((t) => !t.includes('#'))
1070
+ if (spec === undefined) return
1071
+ // The selection, not the loaded projects: a scope loads no config-less
1072
+ // member nothing configured reaches, and that is the one to ask about.
1073
+ for (const name of selected) {
1074
+ const config = unlockedConfig(name, metas)
960
1075
  if (config === undefined) continue
961
1076
  throw new UserError(
962
- `${spec}: ${relPosix(workspaceRoot, path.join(entry.dir, config))} is not in the lock yet — run \`vx lock\``,
1077
+ `${spec}: ${relPosix(workspaceRoot, config)} is not in the lock yet — run \`vx lock\``,
1078
+ 'VX_E_LOCK',
963
1079
  )
964
1080
  }
965
1081
  }
966
1082
 
1083
+ /** The config file in a project's directory the lock holds none for. */
1084
+ function unlockedConfig(
1085
+ name: string,
1086
+ metas: ReadonlyArray<{ name: string; dir: string; configPath?: string | null }>,
1087
+ ): string | undefined {
1088
+ const meta = metas.find((m) => m.name === name)
1089
+ if (meta === undefined) return undefined
1090
+ if (typeof meta.configPath === 'string' && meta.configPath !== '') return undefined
1091
+ const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(meta.dir, n)))
1092
+ return config === undefined ? undefined : path.join(meta.dir, config)
1093
+ }
1094
+
967
1095
  /**
968
1096
  * Each remote's `open`, in declared order, for the ones the policy lets read
969
1097
  * or write. One at a time: two that ask on the terminal must not interleave.
@@ -988,10 +1116,28 @@ export async function openRemotes(
988
1116
  } catch (err) {
989
1117
  entry.read = false
990
1118
  entry.write = false
991
- const e = err instanceof Error ? err : new Error(String(err))
1119
+ const e = err instanceof Error ? err : new Error(thrownMessage(err))
992
1120
  entry.onError?.(e)
993
1121
  closed = true
994
1122
  }
995
1123
  }
996
1124
  return closed && !remotes.some((r) => r.read || r.write)
997
1125
  }
1126
+
1127
+ /**
1128
+ * `dir` spelled under `root` as the run spells the root, when it lies
1129
+ * there; compared through links, so a root reached by a symlink (macOS's
1130
+ * `/var/folders`) still finds a cache directory written canonical.
1131
+ */
1132
+ function withinRoot(root: string, dir: string): string[] {
1133
+ const real = (p: string): string => {
1134
+ try {
1135
+ return realpathSync(p)
1136
+ } catch {
1137
+ return path.resolve(p)
1138
+ }
1139
+ }
1140
+ const rel = path.relative(real(root), real(dir))
1141
+ const outside = rel === '' || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)
1142
+ return outside ? [] : [path.join(root, rel)]
1143
+ }
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { stat } from 'node:fs/promises'
8
8
  import path from 'node:path'
9
- import type { ProjectConfig, TaskConfig } from '../config.js'
9
+ import type { ProjectConfig } from '../config.js'
10
10
  import {
11
11
  checkProjectNames,
12
12
  frozenProjectConfigs,
@@ -33,7 +33,7 @@ import {
33
33
  resolveCacheDir,
34
34
  validateWorkspace,
35
35
  } from '../workspace/index.js'
36
- import { parseDependencySpec } from '../graph/index.js'
36
+ import { DEFAULT_BUILD, parseDependencySpec } from '../graph/index.js'
37
37
  import { applyConfigHooks, applyDiscoverHooks, applyProjectHooks, hasHook } from './plugin-host.js'
38
38
  import type { BranchFacts, VxPlugin } from './plugin.js'
39
39
 
@@ -79,14 +79,35 @@ export async function loadWorkspacePlugins(
79
79
  branch.scope = facts.scope
80
80
  delete branch.trusted
81
81
  }
82
- if (facts.base !== undefined) branch.base = facts.base
82
+ if (facts.base !== undefined) {
83
+ // Read into `--affected`'s `...[<base>]` filter.
84
+ if (typeof facts.base !== 'string' || !/^[^\s\0[\]-][^\s\0[\]]*$/.test(facts.base))
85
+ throw new UserError('setBranch: base must be a commit or a ref')
86
+ branch.base = facts.base
87
+ }
88
+ if (facts.refs !== undefined) {
89
+ if (!Array.isArray(facts.refs) || facts.refs.some((r) => !isRef(r)))
90
+ throw new UserError('setBranch: refs must be a list of refs, each starting "refs/"')
91
+ branch.refs = [...new Set(facts.refs)]
92
+ }
83
93
  }
84
- await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn, setBranch }, (plugin) =>
85
- validateWorkspace(workspaceConfig, `.vx/workspace (after plugin '${plugin.name}')`),
86
- )
94
+ await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn, setBranch }, (plugin) => {
95
+ const at = `.vx/workspace (after plugin '${plugin.name}')`
96
+ validateWorkspace(workspaceConfig, at)
97
+ // The run takes `plugins` from the file: an edited list validated and was dropped.
98
+ const now = workspaceConfig.plugins
99
+ if (now?.length !== list?.length || now?.some((p, i) => p !== list![i])) {
100
+ throw new UserError(
101
+ `${at}: \`plugins\` is fixed before the config stage, so an edit to it would change nothing — declare plugins in .vx/workspace`,
102
+ )
103
+ }
104
+ })
87
105
  return { workspaceConfig, plugins, branch }
88
106
  }
89
107
 
108
+ const isRef = (r: unknown): boolean =>
109
+ typeof r === 'string' && r.startsWith('refs/') && !/[\s\0]/.test(r)
110
+
90
111
  /**
91
112
  * The enumeration a discovery's `discover` hooks shared, by the array it
92
113
  * returned: a run handed that array (`RunOptions.discovered`, the CLI's
@@ -176,7 +197,8 @@ interface LoadProjectsBase {
176
197
  projectMetas: readonly ProjectMeta[]
177
198
  /**
178
199
  * Projects whose configs must load — `'all'`, or names (unknown and
179
- * config-less ones are ignored). With `closure`, each seed's transitive
200
+ * config-less ones are ignored, unless a configured project's closure
201
+ * reaches them, as in the `'all'` load). With `closure`, each seed's transitive
180
202
  * package dependencies load too, which bounds `^task` frontier
181
203
  * expansion; a `pkg#task` dependsOn entry pulls its project in either
182
204
  * way, since the package graph cannot see the cross form.
@@ -268,7 +290,17 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
268
290
  for (const dep of packageGraph.transitiveDeps(name)) consider(dep)
269
291
  }
270
292
  const seedAll = (): Iterable<string> => configured.map((m) => m.name)
271
- const seeds = args.seeds === 'all' ? seedAll() : [...args.seeds]
293
+ // A named config-less seed loads only where the `'all'` load would load
294
+ // it: a configured project's closure reaches it. Seeded as itself, it got
295
+ // a default build `vx show` listed nowhere and no run of the workspace had
296
+ // (`vx run lone#build` ran a group no dependant reaches).
297
+ const configuredNames = new Set(configured.map((m) => m.name))
298
+ const seedable = (name: string): boolean =>
299
+ configuredNames.has(name) ||
300
+ (graph !== null &&
301
+ metaByName.has(name) &&
302
+ graph.transitiveDependents(name).some((d) => configuredNames.has(d)))
303
+ const seeds = args.seeds === 'all' ? seedAll() : [...args.seeds].filter(seedable)
272
304
  for (const seed of seeds) consider(seed)
273
305
  if (graph !== null && pending.length < metaByName.size) {
274
306
  for (const seed of args.seeds === 'all' ? seedAll() : seeds) {
@@ -304,7 +336,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
304
336
  const present = await Promise.all(withFile.map((m) => Bun.file(m.configPath).exists()))
305
337
  const gone = withFile.find((_, i) => !present[i])
306
338
  if (gone !== undefined) {
307
- const rel = relPosix(workspaceRoot, gone.dir) || '.'
339
+ // `.` read as a sentence's end: the notice cut the line there.
340
+ const rel = relPosix(workspaceRoot, gone.dir) || 'the workspace root project'
308
341
  const named = gone.name === pathName(workspaceRoot, gone.dir) ? '' : ` ("${gone.name}")`
309
342
  const file = relPosix(gone.dir, gone.configPath)
310
343
  // exists() says false for a file behind a directory this user may not
@@ -314,11 +347,15 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
314
347
  (err: NodeJS.ErrnoException) =>
315
348
  err.code === 'EACCES' || err.code === 'EPERM' ? err.code : null,
316
349
  )
317
- throw new UserError(
318
- denied !== null
319
- ? `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is not readable by this user (${denied})`
320
- : `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is gone — run \`vx lock\``,
321
- )
350
+ throw denied !== null
351
+ ? new UserError(
352
+ `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is not readable by this user (${denied})`,
353
+ 'VX_E_FS',
354
+ )
355
+ : new UserError(
356
+ `${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is gone — run \`vx lock\``,
357
+ 'VX_E_LOCK',
358
+ )
322
359
  }
323
360
  const loaded =
324
361
  withFile.length === 0 ? [] : frozenProjectConfigs(await lock(), withFile, workspaceRoot)
@@ -341,6 +378,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
341
378
  // the loader accepts from a user, and the refusal names both the
342
379
  // config file and the plugin whose edit broke it.
343
380
  const where = meta.configPath ?? `${meta.name} (no config file)`
381
+ const name = config.name
344
382
  await applyProjectHooks(
345
383
  plugins,
346
384
  config,
@@ -353,12 +391,17 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
353
391
  projects: args.projectMetas,
354
392
  lock: args.lock === true,
355
393
  },
356
- (plugin) =>
357
- validateProjectConfig(
358
- config,
359
- `${where} (after plugin '${plugin.name}')`,
360
- meta.configPath,
361
- ),
394
+ (plugin) => {
395
+ const at = `${where} (after plugin '${plugin.name}')`
396
+ validateProjectConfig(config, at, meta.configPath)
397
+ // Projects are named and edged before any config loads: a new
398
+ // `name` here validated, and the project kept its old one.
399
+ if (config.name !== name) {
400
+ throw new UserError(
401
+ `${at}: project "${meta.name}" is named before the project stage, so \`name: ${JSON.stringify(config.name)}\` cannot rename it — a plugin names a project in its \`discover\` hook`,
402
+ )
403
+ }
404
+ },
362
405
  )
363
406
  }
364
407
  const built = withDefaultBuild(config)
@@ -373,24 +416,6 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
373
416
  return { projects, configured }
374
417
  }
375
418
 
376
- /**
377
- * The `build` a project gets when neither its config nor a plugin declared
378
- * one (owner, 2026-10-04): a group behind `^build`, keyed on every file of
379
- * the project. A package consumed as source then carries its files to every
380
- * dependent behind `^build`, in the key and in `--affected`, and the chain
381
- * through it to deeper builds holds. A keyed group, as a config may
382
- * declare one (config-schema.ts).
383
- */
384
- const DEFAULT_BUILD: TaskConfig = Object.freeze({
385
- dependsOn: Object.freeze(['^build']),
386
- inputs: Object.freeze({ files: Object.freeze(['**']) }),
387
- })
388
-
389
- /** The default `build`, which a menu of the tasks a user declared leaves out. */
390
- export function isDefaultBuild(task: TaskConfig | undefined): boolean {
391
- return task === DEFAULT_BUILD
392
- }
393
-
394
419
  function withDefaultBuild(config: ProjectConfig): ProjectConfig {
395
420
  if (config.tasks?.['build'] !== undefined) return config
396
421
  return { ...config, tasks: { ...config.tasks, build: DEFAULT_BUILD } }
@@ -432,7 +457,7 @@ function crossDepProjects(config: ProjectConfig): string[] {
432
457
  // the offending task. Here it just contributes no project.
433
458
  if (!raw.includes('#')) continue
434
459
  try {
435
- const spec = parseDependencySpec(raw)
460
+ const spec = parseDependencySpec(raw, 'dependsOn')
436
461
  if (spec.kind === 'cross') out.push(spec.project)
437
462
  } catch {
438
463
  continue
@@ -475,7 +500,7 @@ export function lockReader(workspaceRoot: string): () => Promise<Lockfile> {
475
500
  let read: Promise<Lockfile> | undefined
476
501
  return () =>
477
502
  (read ??= readLockfile(workspaceRoot).then((lock) => {
478
- if (lock === null) throw new UserError(NO_LOCK)
503
+ if (lock === null) throw new UserError(NO_LOCK, 'VX_E_LOCK')
479
504
  return lock
480
505
  }))
481
506
  }