@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
@@ -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'
@@ -168,6 +177,14 @@ export interface PreparedRun {
168
177
  * mistaken for a workspace vx was never set up in.
169
178
  */
170
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
171
188
  /** The claimed root files, and whether a task has since rewritten one (fingerprint-watch.ts). */
172
189
  fingerprintWatch: FingerprintWatch
173
190
  nestedDirsByProject: Map<string, string[]>
@@ -335,7 +352,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
335
352
  const asked =
336
353
  options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
337
354
  // A laptop reads the shared cache and does not write it unless a policy
338
- // 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
339
356
  // holds only declared inputs, so a laptop's write could be replayed on CI.
340
357
  // A workspace policy naming the remote asks to write it, as `--cache-policy` does.
341
358
  const readOnly =
@@ -368,7 +385,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
368
385
 
369
386
  // Configs load FROM .vx/projects.lock.json with no evaluation and no check of
370
387
  // the configs (`vx lock --check` re-evaluates them; a run does not):
371
- // only `vx lock` evaluates (owner, 2026-10-14).
388
+ // only `vx lock` evaluates (2026-10-14).
372
389
  // See docs/design/config-lock-2026-06.md.
373
390
  // Read once, and only when a config is to be read from it: the CLI's
374
391
  // selection pass may have staged every config (`options.staged`), and
@@ -412,6 +429,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
412
429
  // name, never by count: a config-less project counts as loaded, so a
413
430
  // scope holding one hid an unloaded declarer (X-129, X-182).
414
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)
415
438
 
416
439
  // Boundary geometry considers every workspace project, loaded or not,
417
440
  // config or not: `--affected` gives a changed path to the deepest project
@@ -429,7 +452,20 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
429
452
  // A dependant's `^build` still reaches it, and `lib#build` names it.
430
453
  const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
431
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])]
432
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
+ )
433
469
  if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
434
470
  // A name a loaded project declares is no typo, whether or not the load
435
471
  // was whole: an unaffected dependency loaded for the closure declared
@@ -497,7 +533,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
497
533
  })
498
534
  }
499
535
  // An injected remote is no plugin's: nothing reads refs for it.
500
- const refs = options.remoteCache ? [] : cacheRefs(workspaceRoot, baseBranch)
536
+ const refs = options.remoteCache ? [] : cacheRefs(workspaceRoot, baseBranch, branchFacts.refs)
501
537
  try {
502
538
  cache = options.remoteCache
503
539
  ? new LayeredCache(localCache, remotes, options.uploadConcurrency)
@@ -570,6 +606,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
570
606
  projects,
571
607
  hintProjects,
572
608
  anyProjectConfig: projectsWithConfigs.length > 0,
609
+ taskless,
610
+ unlockedConfig: () => unlockedConfigOf(projectsWithConfigs, projectMetas, workspaceRoot),
573
611
  fingerprintWatch,
574
612
  nestedDirsByProject,
575
613
  gitFilesCache: new GitFilesCache(),
@@ -590,21 +628,38 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
590
628
  // by a config the scope left out, so the builder hands it back instead of
591
629
  // refusing it, and the rest decide.
592
630
  // A run never checks the graph rules: `vx lock` and `vx lock --check`
593
- // do (owner, 2026-10-14). `exclusiveOutputs: false` still asks the walk
631
+ // do (2026-10-14). `exclusiveOutputs: false` still asks the walk
594
632
  // for its addition marks.
595
633
  const graphRules: GraphRules = { ...runRules(workspaceConfig), locked: true }
596
634
  const unproven: Array<[taskId: string, name: string]> = []
597
- const graphOf = (req: typeof requested) =>
598
- buildTaskGraph({
599
- projects,
600
- packageGraph,
601
- requested: req,
602
- workspaceRoot,
603
- rules: graphRules,
604
- ...(partialLoad
605
- ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
606
- : {}),
607
- })
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
+ }
608
663
  let nodes = graphOf(requested)
609
664
  const hasGraphHook = hasHook(plugins, 'graph')
610
665
  const graphStage = (): Promise<void> =>
@@ -691,6 +746,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
691
746
  // `prepare (graph)` row until 2026-09-10.
692
747
  mark('build graph')
693
748
  const gitFilesCache = new GitFilesCache()
749
+ gitFilesCache.vxDirs = withinRoot(workspaceRoot, cacheDir)
694
750
  // Bulk-populate via a single `git ls-files` at the workspace root —
695
751
  // partitions the output by project. Avoids one fork+exec per project
696
752
  // (~5-10ms each on Linux; the dominant cold-start cost on big
@@ -747,6 +803,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
747
803
  }
748
804
  refuseFiltersThroughGroups(nodes)
749
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
750
813
  let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
751
814
  if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
752
815
  const split = excludeDependencies(nodes, exclude)
@@ -792,6 +855,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
792
855
  warn: warnOf(log),
793
856
  localCache,
794
857
  branch: branch(),
858
+ refs,
795
859
  },
796
860
  cacheFor,
797
861
  run,
@@ -823,6 +887,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
823
887
  projects,
824
888
  hintProjects,
825
889
  anyProjectConfig: projectsWithConfigs.length > 0,
890
+ taskless,
891
+ unlockedConfig: () => unlockedConfigOf(projectsWithConfigs, projectMetas, workspaceRoot),
826
892
  fingerprintWatch,
827
893
  nestedDirsByProject,
828
894
  gitFilesCache,
@@ -897,7 +963,7 @@ async function refuseUndeclaredDeps(
897
963
  for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
898
964
  }
899
965
  for (const [id, name] of unproven) {
900
- if (!declared.has(name)) throw undeclaredDepsError(id, name)
966
+ if (!declared.has(name)) throw undeclaredDepsError(id, name, declared)
901
967
  }
902
968
  }
903
969
 
@@ -911,9 +977,11 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
911
977
  const stack = [...roots]
912
978
  while (stack.length > 0) {
913
979
  const id = stack.pop()!
914
- 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
915
983
  keep.add(id)
916
- stack.push(...nodes.get(id)!.deps)
984
+ stack.push(...node.deps)
917
985
  }
918
986
  for (const [id, n] of nodes) {
919
987
  if (!keep.has(id)) nodes.delete(id)
@@ -944,16 +1012,30 @@ export function workspaceCachePolicy(
944
1012
  }
945
1013
  }
946
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
+
947
1029
  /**
948
1030
  * A `project#build` naming the default `build` of a project the lock holds
949
- * 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
950
1032
  * one that runs nothing: ran, `c#build` was 0 tasks, exit 0, where a
951
1033
  * renamed task gets "run `vx lock`" (Growth, lock drift).
952
1034
  */
953
1035
  function refuseUnlockedConfig(
954
1036
  tasks: readonly string[],
955
1037
  projects: ReadonlyMap<string, ProjectEntry>,
956
- metas: ReadonlyArray<{ name: string; configPath?: string | null }>,
1038
+ metas: ReadonlyArray<{ name: string; dir: string; configPath?: string | null }>,
957
1039
  workspaceRoot: string,
958
1040
  ): void {
959
1041
  for (const spec of tasks) {
@@ -961,17 +1043,55 @@ function refuseUnlockedConfig(
961
1043
  if (at < 0) continue
962
1044
  const name = spec.slice(0, at)
963
1045
  const entry = projects.get(name)
964
- if (entry === undefined || !isDefaultBuild(entry.config.tasks?.[spec.slice(at + 1)])) continue
965
- const locked = metas.find((m) => m.name === name)?.configPath
966
- if (typeof locked === 'string' && locked !== '') continue
967
- 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)
968
1075
  if (config === undefined) continue
969
1076
  throw new UserError(
970
- `${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',
971
1079
  )
972
1080
  }
973
1081
  }
974
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
+
975
1095
  /**
976
1096
  * Each remote's `open`, in declared order, for the ones the policy lets read
977
1097
  * or write. One at a time: two that ask on the terminal must not interleave.
@@ -996,10 +1116,28 @@ export async function openRemotes(
996
1116
  } catch (err) {
997
1117
  entry.read = false
998
1118
  entry.write = false
999
- const e = err instanceof Error ? err : new Error(String(err))
1119
+ const e = err instanceof Error ? err : new Error(thrownMessage(err))
1000
1120
  entry.onError?.(e)
1001
1121
  closed = true
1002
1122
  }
1003
1123
  }
1004
1124
  return closed && !remotes.some((r) => r.read || r.write)
1005
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
  }