@vzn/vx 0.0.18 → 0.0.22

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 (149) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +107 -70
  3. package/package.json +10 -20
  4. package/src/bin.ts +64 -5
  5. package/src/cache/archive.ts +279 -28
  6. package/src/cache/cache.ts +952 -1584
  7. package/src/cache/chained-cache.ts +14 -10
  8. package/src/cache/config-evals.ts +112 -0
  9. package/src/cache/file-hashes.ts +290 -0
  10. package/src/cache/git-inputs.ts +966 -0
  11. package/src/cache/index.ts +23 -7
  12. package/src/cache/inputs.ts +553 -739
  13. package/src/cache/key-fold.ts +232 -0
  14. package/src/cache/layer.ts +674 -0
  15. package/src/cache/layered-cache.ts +211 -49
  16. package/src/cache/output-index.ts +323 -0
  17. package/src/cache/policy.ts +84 -0
  18. package/src/cache/run-history.ts +152 -0
  19. package/src/cache/zstd.ts +183 -0
  20. package/src/cli/cache.ts +88 -30
  21. package/src/cli/completions.ts +104 -0
  22. package/src/cli/core-alias.ts +25 -0
  23. package/src/cli/format.ts +4 -15
  24. package/src/cli/help.ts +76 -38
  25. package/src/cli/index.ts +82 -17
  26. package/src/cli/info.ts +162 -91
  27. package/src/cli/init.ts +87 -0
  28. package/src/cli/last.ts +130 -23
  29. package/src/cli/lock.ts +36 -18
  30. package/src/cli/plan-format.ts +18 -7
  31. package/src/cli/plugin-commands.ts +34 -12
  32. package/src/cli/run.ts +138 -313
  33. package/src/cli/select.ts +342 -0
  34. package/src/cli/show.ts +151 -71
  35. package/src/cli/upgrade.ts +136 -15
  36. package/src/cli/watch.ts +1175 -116
  37. package/src/cli/why.ts +51 -29
  38. package/src/cli/workspace-config.ts +138 -0
  39. package/src/config.ts +183 -126
  40. package/src/exec/env.ts +11 -0
  41. package/src/exec/executor.ts +62 -31
  42. package/src/exec/index.ts +16 -2
  43. package/src/exec/kill-tree.ts +281 -0
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +361 -77
  46. package/src/exec/sandbox-binds.ts +211 -0
  47. package/src/exec/sandbox-paths.ts +70 -0
  48. package/src/exec/sandbox-runtime.ts +1152 -415
  49. package/src/exec/sandbox-violations.ts +293 -0
  50. package/src/graph/dependency-spec.ts +14 -14
  51. package/src/graph/index.ts +6 -4
  52. package/src/graph/priorities.ts +130 -0
  53. package/src/graph/scheduler.ts +332 -329
  54. package/src/graph/task-graph.ts +552 -188
  55. package/src/index.ts +111 -102
  56. package/src/orchestrator/admission.ts +161 -0
  57. package/src/orchestrator/colors.ts +8 -3
  58. package/src/orchestrator/deferred-outputs.ts +25 -7
  59. package/src/orchestrator/doctor.ts +363 -0
  60. package/src/orchestrator/events.ts +111 -8
  61. package/src/orchestrator/excluded-keys.ts +155 -0
  62. package/src/orchestrator/execute-task.ts +450 -565
  63. package/src/orchestrator/failure-mode.ts +169 -28
  64. package/src/orchestrator/failure-recap.ts +121 -0
  65. package/src/orchestrator/fingerprint-watch.ts +89 -0
  66. package/src/orchestrator/framed-output.ts +121 -49
  67. package/src/orchestrator/history.ts +123 -104
  68. package/src/orchestrator/hit-restore.ts +285 -0
  69. package/src/orchestrator/index.ts +42 -5
  70. package/src/orchestrator/keyed-projects.ts +89 -0
  71. package/src/orchestrator/local-shortcircuit.ts +83 -23
  72. package/src/orchestrator/lockfile-claim.ts +401 -0
  73. package/src/orchestrator/logger.ts +128 -41
  74. package/src/orchestrator/metrics.ts +201 -72
  75. package/src/orchestrator/miss-save.ts +232 -0
  76. package/src/orchestrator/options.ts +63 -34
  77. package/src/orchestrator/persistent.ts +99 -0
  78. package/src/orchestrator/placement.ts +212 -0
  79. package/src/orchestrator/plan.ts +24 -11
  80. package/src/orchestrator/plugin-host.ts +200 -49
  81. package/src/orchestrator/plugin.ts +190 -14
  82. package/src/orchestrator/prepare.ts +168 -137
  83. package/src/orchestrator/projects.ts +270 -0
  84. package/src/orchestrator/remote-prefetch.ts +22 -2
  85. package/src/orchestrator/run-artifacts.ts +34 -3
  86. package/src/orchestrator/run-context.ts +20 -5
  87. package/src/orchestrator/run-lock.ts +349 -0
  88. package/src/orchestrator/run-records.ts +132 -0
  89. package/src/orchestrator/run-report.ts +6 -4
  90. package/src/orchestrator/run.ts +467 -611
  91. package/src/orchestrator/sandbox-request.ts +569 -0
  92. package/src/orchestrator/save-lane.ts +44 -0
  93. package/src/orchestrator/shell-verdict.ts +117 -0
  94. package/src/orchestrator/signals.ts +167 -0
  95. package/src/orchestrator/stable-keys.ts +259 -10
  96. package/src/orchestrator/status-line.ts +35 -2
  97. package/src/orchestrator/summary.ts +115 -25
  98. package/src/orchestrator/task-hash.ts +103 -21
  99. package/src/orchestrator/task-log-buffer.ts +15 -18
  100. package/src/orchestrator/telemetry-host.ts +27 -8
  101. package/src/orchestrator/telemetry.ts +71 -44
  102. package/src/orchestrator/upstream.ts +91 -45
  103. package/src/util/bun-version.ts +43 -0
  104. package/src/util/cgroup.ts +160 -0
  105. package/src/util/edit-distance.ts +49 -0
  106. package/src/util/errors.ts +86 -0
  107. package/src/util/hash.ts +10 -1
  108. package/src/util/index.ts +42 -5
  109. package/src/util/paths.ts +168 -5
  110. package/src/util/procfs.ts +20 -0
  111. package/src/util/settle.ts +23 -2
  112. package/src/util/size.ts +33 -0
  113. package/src/util/task-id.ts +20 -0
  114. package/src/util/timing.ts +11 -1
  115. package/src/util/verbs.ts +39 -0
  116. package/src/util/which.ts +48 -0
  117. package/src/workspace/affected.ts +368 -60
  118. package/src/workspace/config-cache.ts +271 -32
  119. package/src/workspace/config-eval.ts +60 -16
  120. package/src/workspace/config-imports.ts +131 -9
  121. package/src/workspace/config-schema.ts +1033 -0
  122. package/src/workspace/filter.ts +69 -17
  123. package/src/workspace/fingerprint.ts +59 -7
  124. package/src/workspace/index.ts +37 -3
  125. package/src/workspace/json-data.ts +90 -0
  126. package/src/workspace/load-reads.ts +32 -0
  127. package/src/workspace/lockfile.ts +41 -14
  128. package/src/workspace/migrate-scripts.ts +187 -0
  129. package/src/workspace/migration.ts +353 -0
  130. package/src/workspace/package-graph.ts +239 -31
  131. package/src/workspace/project-loader.ts +295 -815
  132. package/src/workspace/workspace.ts +266 -31
  133. package/plugins/local-cache/index.ts +0 -2
  134. package/plugins/local-executor/index.ts +0 -2
  135. package/plugins/schedule-history/index.ts +0 -2
  136. package/src/cache/cas-backend.ts +0 -115
  137. package/src/cache/digest.ts +0 -32
  138. package/src/cli/migrate-emit.ts +0 -21
  139. package/src/cli/migrate-nx.ts +0 -473
  140. package/src/cli/migrate-scripts.ts +0 -127
  141. package/src/cli/migrate-turbo.ts +0 -401
  142. package/src/cli/migrate.ts +0 -305
  143. package/src/cli/prune.ts +0 -291
  144. package/src/orchestrator/missing-plugin.ts +0 -9
  145. package/src/orchestrator/resources.ts +0 -45
  146. package/src/orchestrator/verify.ts +0 -174
  147. package/src/plugins/local-cache/index.ts +0 -11
  148. package/src/plugins/local-executor/index.ts +0 -45
  149. package/src/plugins/schedule-history/index.ts +0 -109
@@ -8,10 +8,11 @@
8
8
  // try/finally around its plan() call.
9
9
 
10
10
  import path from 'node:path'
11
- import type { ProjectConfig, WorkspaceConfig } from '../config.js'
11
+ import type { WorkspaceConfig } from '../config.js'
12
12
  import { mark, UserError } from '../util/index.js'
13
13
  import {
14
14
  Cache,
15
+ noteSchemaReset,
15
16
  type CacheLayer,
16
17
  type CachePolicy,
17
18
  FULL_CACHE_POLICY,
@@ -25,34 +26,35 @@ import {
25
26
  import {
26
27
  buildPackageGraph,
27
28
  computeNestedProjectDirs,
28
- computeWorkspaceFingerprint,
29
+ computeWorkspaceFingerprints,
29
30
  findWorkspaceRoot,
30
- frozenProjectConfig,
31
+ type LoadReads,
31
32
  listProjects,
32
- loadProjectConfigs,
33
33
  loadWorkspace,
34
- loadWorkspaceConfig,
34
+ FROZEN_WITHOUT_LOCK,
35
35
  readLockfile,
36
36
  resolveCacheDir,
37
- validateProjectConfig,
38
37
  type ProjectEntry,
39
38
  } from '../workspace/index.js'
40
39
  import {
41
40
  buildTaskGraph,
41
+ excludeDependencies,
42
42
  expandRequested,
43
- parseDependencySpec,
44
43
  type TaskNode,
44
+ undeclaredDepsError,
45
45
  unresolvedRequests,
46
46
  } from '../graph/index.js'
47
47
  import {
48
- applyConfigHooks,
49
48
  applyGraphHooks,
50
49
  applyKeyHooks,
51
- applyProjectHooks,
52
50
  applyScheduleHooks,
51
+ fingerprintClaims,
53
52
  hasHook,
54
53
  resolveCache,
55
54
  } from './plugin-host.js'
55
+ import { loadProjects, loadWorkspacePlugins, type LoadedProjects } from './projects.js'
56
+ import { keyExcludedDependencies } from './excluded-keys.js'
57
+ import { FingerprintWatch } from './fingerprint-watch.js'
56
58
  import type { VxPlugin } from './plugin.js'
57
59
  import { createHashCache, type HashCache } from './task-hash.js'
58
60
  import type { Logger } from './logger.js'
@@ -76,8 +78,7 @@ export interface PreparedRun {
76
78
  * injected remote layer, or one a plugin's `cache` capability built. The
77
79
  * remote axes of the policy only mean anything then: without a remote
78
80
  * layer a `remote:w` policy writes NOWHERE, so a task that believed it
79
- * would be saved still cleans its outputs before executing and (under
80
- * `--verify`) restores an artifact that was never written.
81
+ * would be saved still cleans its outputs before executing.
81
82
  */
82
83
  hasRemoteLayer: boolean
83
84
  /**
@@ -96,7 +97,15 @@ export interface PreparedRun {
96
97
  unresolvedTasks: readonly string[]
97
98
  /** Every discovered project — the declared task names a typo is measured against. */
98
99
  projects: ReadonlyMap<string, ProjectEntry>
100
+ /**
101
+ * True iff at least one package in the workspace has a `vx.config.*` at
102
+ * all — regardless of scope, so a `--filter` that matched nothing is not
103
+ * mistaken for a workspace vx was never set up in.
104
+ */
105
+ anyProjectConfig: boolean
99
106
  workspaceFingerprint: string
107
+ /** Whether a task has since rewritten what that fingerprint folded (fingerprint-watch.ts). */
108
+ fingerprintWatch: FingerprintWatch
100
109
  nestedDirsByProject: Map<string, string[]>
101
110
  /**
102
111
  * Per-run memo for `git ls-files` output, keyed by project dir.
@@ -129,25 +138,6 @@ export interface PreparedRun {
129
138
  empty: null | 'no-tasks-declared' | 'empty-graph'
130
139
  }
131
140
 
132
- /** Project names named by a `pkg#task` dependsOn entry anywhere in `config`. */
133
- function crossDepProjects(config: ProjectConfig): string[] {
134
- const out: string[] = []
135
- for (const task of Object.values(config.tasks ?? {})) {
136
- for (const raw of task.dependsOn ?? []) {
137
- // A malformed spec is the graph builder's error to report — it names
138
- // the offending task. Here it just contributes no project.
139
- if (!raw.includes('#')) continue
140
- try {
141
- const spec = parseDependencySpec(raw)
142
- if (spec.kind === 'cross') out.push(spec.project)
143
- } catch {
144
- continue
145
- }
146
- }
147
- }
148
- return out
149
- }
150
-
151
141
  /**
152
142
  * Build the prepared-run context: workspace discovery, project-config
153
143
  * load, package + task graph, cache handle (local, optionally wrapped
@@ -160,7 +150,10 @@ function crossDepProjects(config: ProjectConfig): string[] {
160
150
  */
161
151
  export async function prepareRun(options: RunOptions, log: Logger): Promise<PreparedRun> {
162
152
  mark('startup')
163
- const workspaceRoot = await findWorkspaceRoot(options.cwd)
153
+ // The root manifest is read once for the root, the globs and the
154
+ // fingerprint. A watch cycle is a new run and reads it afresh.
155
+ const reads: LoadReads = new Map()
156
+ const workspaceRoot = await findWorkspaceRoot(options.cwd, reads)
164
157
  // An UNSCOPED run (no explicit scope, at least one bare task name)
165
158
  // enumerates the whole tree whatever the configs say, so git starts
166
159
  // HERE — the walk needs only the root — and overlaps the workspace
@@ -177,17 +170,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
177
170
  // handler keeps that from surfacing as an unhandled rejection (the real
178
171
  // await further down still sees the error).
179
172
  earlyGit?.catch(() => {})
180
- const workspace = await loadWorkspace(workspaceRoot)
181
- const workspaceConfig = await loadWorkspaceConfig(workspaceRoot)
182
- // The `config` stage runs before anything is derived from the workspace
183
- // config; the plugin list itself is already fixed.
184
- const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
185
- if (workspaceConfig !== null && hasHook(plugins, 'config')) {
186
- await applyConfigHooks(plugins, workspaceConfig, {
187
- workspaceRoot,
188
- warn: (m) => log.status(m),
189
- })
190
- }
173
+ const workspace = await loadWorkspace(workspaceRoot, reads)
174
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
175
+ log.status(m),
176
+ )
191
177
  mark('workspace config')
192
178
  const projectMetas = await listProjects(workspace)
193
179
  mark('discover projects')
@@ -201,17 +187,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
201
187
  // Turbo-like: a broken config in an unrelated package no longer
202
188
  // fails a scoped run — it surfaces when that package enters scope.
203
189
  const packageGraph = buildPackageGraph(projectMetas)
204
- const projectsWithConfigs = projectMetas.filter(
205
- (m): m is typeof m & { configPath: string } =>
206
- typeof m.configPath === 'string' && m.configPath.length > 0,
207
- )
208
- const haveConfig = new Set(projectsWithConfigs.map((m) => m.name))
209
-
210
- // Seeds: explicit scope, plus anchored pkg#task targets (which
211
- // bypass scope by design). With no explicit scope, bare task names
212
- // fan out across the whole workspace — but when EVERY spec is
213
- // anchored, the anchors alone are the scope and nothing else needs
214
- // its config evaluated.
190
+ // Seeds: explicit scope, plus anchored pkg#task targets (which bypass
191
+ // scope by design). With no explicit scope, bare task names fan out
192
+ // across the whole workspace — but when EVERY spec is anchored, the
193
+ // anchors alone are the scope and nothing else needs its config
194
+ // evaluated.
215
195
  const anchored: string[] = []
216
196
  let hasBare = false
217
197
  for (const spec of options.tasks) {
@@ -219,16 +199,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
219
199
  if (hashIdx > 0) anchored.push(spec.slice(0, hashIdx))
220
200
  else hasBare = true
221
201
  }
222
- const seeds = new Set<string>(
223
- options.projects
224
- ? options.projects.filter((p) => haveConfig.has(p))
225
- : hasBare
226
- ? haveConfig
227
- : [],
228
- )
229
- for (const project of anchored) {
230
- if (haveConfig.has(project)) seeds.add(project)
231
- }
202
+ const seeds: 'all' | string[] =
203
+ options.projects !== undefined ? [...options.projects, ...anchored] : hasBare ? 'all' : anchored
204
+ // Its own mark: the package graph is the bulk of what used to be charged
205
+ // to `open cache` (measured on a 1,000-project tree, 2026-09-20 — the
206
+ // graph 3-8 ms against the cache open's ~1 ms and the fingerprints' ~0.3),
207
+ // so a reader profiling that stage was sent to the wrong code.
208
+ mark('package graph')
232
209
 
233
210
  // The local cache opens BEFORE the configs load: it is also where their
234
211
  // cached evaluations live. `--cache-dir <path>` (RunOptions.cacheDir)
@@ -238,26 +215,26 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
238
215
  const cacheDir = options.cacheDir
239
216
  ? path.resolve(options.cwd, options.cacheDir)
240
217
  : resolveCacheDir(workspaceRoot, workspaceConfig)
241
- const localCache = new Cache(cacheDir, { read: policy.localRead, write: policy.localWrite })
242
- const workspaceFingerprint = await computeWorkspaceFingerprint(workspaceRoot)
218
+ const localCache = new Cache(
219
+ cacheDir,
220
+ { read: policy.localRead, write: policy.localWrite },
221
+ workspaceRoot,
222
+ options.artifactCeiling,
223
+ )
224
+ localCache.assertWritable()
225
+ noteSchemaReset(localCache, (m) => log.status(m))
226
+ // Two digests from one read: the config-evaluation cache keys on every
227
+ // file (a config may import a dependency), the task keys on the files no
228
+ // plugin claims (`VxPlugin.fingerprint`).
229
+ const fingerprintsAt = Date.now()
230
+ const fingerprints = await computeWorkspaceFingerprints(
231
+ workspaceRoot,
232
+ new Set(fingerprintClaims(plugins).keys()),
233
+ reads,
234
+ )
235
+ const workspaceFingerprint = fingerprints.unclaimed
243
236
  mark('open cache')
244
237
 
245
- type ConfigMeta = (typeof projectsWithConfigs)[number]
246
- const metaByName = new Map<string, ConfigMeta>(projectsWithConfigs.map((m) => [m.name, m]))
247
- const needed = new Set<string>()
248
- const pending: ConfigMeta[] = []
249
- const consider = (name: string): void => {
250
- if (needed.has(name)) return
251
- needed.add(name)
252
- const meta = metaByName.get(name)
253
- if (meta) pending.push(meta)
254
- }
255
- const considerWithDeps = (name: string): void => {
256
- consider(name)
257
- for (const dep of packageGraph.transitiveDeps(name)) consider(dep)
258
- }
259
- for (const seed of seeds) considerWithDeps(seed)
260
-
261
238
  // Frozen mode (--frozen, CI): configs load FROM vx-lock.json after a
262
239
  // content-hash tripwire — no evaluation; env-dependent configs keep
263
240
  // their locked values. Default (local) runs ALWAYS evaluate live:
@@ -267,55 +244,34 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
267
244
  // See docs/design/config-lock-2026-06.md.
268
245
  const lock = options.frozen === true ? await readLockfile(workspaceRoot) : null
269
246
  if (options.frozen === true && lock === null) {
270
- throw new UserError(
271
- `--frozen requires vx-lock.json at the workspace root — run 'vx lock' and commit it`,
272
- )
247
+ throw new UserError(FROZEN_WITHOUT_LOCK)
273
248
  }
274
249
 
275
- // Load in rounds to a fixpoint. A `pkg#task` dependsOn entry names a
276
- // project the PACKAGE graph cannot reach (the cross form ignores npm
277
- // deps by design), so its config has to be pulled in — and that config
278
- // may declare cross edges of its own. The common case (no cross deps)
279
- // is a single round, identical to loading the closure in one batch.
280
- const projects = new Map<string, ProjectEntry>()
281
- const projectStage = hasHook(plugins, 'project')
250
+ const loadArgs = {
251
+ workspaceRoot,
252
+ cacheDir,
253
+ plugins,
254
+ projectMetas,
255
+ packageGraph,
256
+ lock,
257
+ evalCache: { store: localCache, workspaceFingerprint: fingerprints.all },
258
+ warn: (m: string) => log.status(m),
259
+ }
260
+ let loaded: LoadedProjects
282
261
  try {
283
- while (pending.length > 0) {
284
- const round = pending.splice(0, pending.length)
285
- const configs = lock
286
- ? await Promise.all(round.map((m) => frozenProjectConfig(lock, m, workspaceRoot)))
287
- : await loadProjectConfigs(
288
- round.map((m) => m.configPath),
289
- { evalCache: { store: localCache, workspaceFingerprint } },
290
- )
291
- for (let i = 0; i < round.length; i++) {
292
- const meta = round[i]!
293
- const config = configs[i] as ProjectConfig
294
- if (projectStage) {
295
- // The `project` stage edits the validated object in place; core
296
- // then re-validates so a plugin can only produce what the loader
297
- // accepts from a user. The message names the config file — the
298
- // plugin's edit is a defect in THAT project's tasks.
299
- await applyProjectHooks(plugins, config, {
300
- workspaceRoot,
301
- cacheDir,
302
- warn: (m) => log.status(m),
303
- name: meta.name,
304
- dir: meta.dir,
305
- packageJson: meta.packageJson as unknown as Readonly<Record<string, unknown>>,
306
- })
307
- validateProjectConfig(config, `${meta.configPath} (after plugins)`)
308
- }
309
- projects.set(meta.name, { name: meta.name, dir: meta.dir, config })
310
- for (const name of crossDepProjects(config)) considerWithDeps(name)
311
- }
312
- }
262
+ loaded = await loadProjects({
263
+ ...loadArgs,
264
+ seeds,
265
+ closure: true,
266
+ ...(options.staged !== undefined ? { staged: options.staged } : {}),
267
+ })
313
268
  } catch (err) {
314
269
  // The cache opened before the configs loaded (it holds their cached
315
270
  // evaluations); a config error must not leak the handle.
316
271
  localCache.close()
317
272
  throw err
318
273
  }
274
+ const { projects, configured: projectsWithConfigs } = loaded
319
275
  mark('load configs')
320
276
 
321
277
  // Boundary geometry considers every config-bearing project in the
@@ -333,24 +289,32 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
333
289
  const unresolvedTasks = unresolvedRequests(options.tasks, candidateProjects, projects)
334
290
 
335
291
  // Cache seam precedence: an EXPLICITLY injected remote layer
336
- // (RunOptions.remoteCache — a distribution agent or serve that already
292
+ // (RunOptions.remoteCache — a distribution agent or daemon that already
337
293
  // holds a wire client) wins outright; else a plugin's `cache` capability;
338
294
  // else the local cache alone. Core ships no wire client — the remote
339
- // cache is a plugin concern (native-cache-wire-2026-07). Injection
295
+ // cache is a plugin concern (docs/patterns.md § Remote cache wire). Injection
340
296
  // winning prevents double-wrapping when the workspace also declares a
341
297
  // cache plugin.
342
- const cache = options.remoteCache
343
- ? new LayeredCache(localCache, options.remoteCache, {
344
- policy,
345
- onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
346
- })
347
- : await resolveCache(plugins, {
348
- workspaceRoot,
349
- cacheDir,
350
- warn: (m) => log.status(m),
351
- localCache,
352
- policy,
353
- })
298
+ let cache: CacheLayer
299
+ try {
300
+ cache = options.remoteCache
301
+ ? new LayeredCache(localCache, options.remoteCache, {
302
+ policy,
303
+ onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
304
+ })
305
+ : await resolveCache(plugins, {
306
+ workspaceRoot,
307
+ cacheDir,
308
+ warn: (m) => log.status(m),
309
+ localCache,
310
+ policy,
311
+ })
312
+ } catch (err) {
313
+ // A cache plugin that throws or returns something off-contract is
314
+ // refused by name; the local handle opened above must not leak with it.
315
+ localCache.close()
316
+ throw err
317
+ }
354
318
  // Ask the LAYER, don't infer. Identity against `localCache` answers a
355
319
  // DIFFERENT question — "did the plugin hand back something other than the
356
320
  // handle I passed in?" — which an ordinary pass-through decorator (a
@@ -381,6 +345,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
381
345
  applyGitEnumeration(enumeration, workspaceRoot, projectDirs, gitFilesCache, usesWorkspaceInputs)
382
346
  mark('git enumeration')
383
347
  const hashCache = createHashCache()
348
+ const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
384
349
 
385
350
  // Empty-cases bookkeeping. We still construct the cache + fingerprint
386
351
  // so the caller's try/finally pattern can close it uniformly.
@@ -397,7 +362,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
397
362
  nodes: new Map(),
398
363
  unresolvedTasks,
399
364
  projects,
365
+ anyProjectConfig: projectsWithConfigs.length > 0,
400
366
  workspaceFingerprint,
367
+ fingerprintWatch,
401
368
  nestedDirsByProject,
402
369
  gitFilesCache,
403
370
  hashCache,
@@ -406,14 +373,31 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
406
373
  }
407
374
  }
408
375
 
376
+ // The whole graph, whatever `--exclude-dependencies` says: the stages
377
+ // below shape and key the tasks it drops as a full run would, because
378
+ // a dropped task is still keyed (excluded-keys.ts). A scoped load is not
379
+ // the whole workspace: a `^name` nothing loaded declares may be declared
380
+ // by a config the scope left out, so the builder hands it back instead of
381
+ // refusing it, and the rest decide.
382
+ const unproven: Array<[taskId: string, name: string]> = []
409
383
  const nodes = buildTaskGraph({
410
384
  projects,
411
385
  packageGraph,
412
386
  requested,
413
- ...(options.excludeDependencies !== undefined
414
- ? { excludeDependencies: options.excludeDependencies }
387
+ ...(projects.size < projectsWithConfigs.length
388
+ ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
415
389
  : {}),
416
390
  })
391
+ if (unproven.length > 0) {
392
+ await refuseUndeclaredDeps(unproven, () =>
393
+ loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
394
+ )
395
+ }
396
+ // The graph is built; what follows is the plugins' (graph, key,
397
+ // schedule). Two rows, so a plugin's key stage reads as its own cost
398
+ // and not as graph building — a lockfile plugin's 1000 stats per run
399
+ // hid inside one `prepare (graph)` row until 2026-09-10.
400
+ mark('build graph')
417
401
  if (hasHook(plugins, 'graph')) {
418
402
  await applyGraphHooks(plugins, nodes, {
419
403
  workspaceRoot,
@@ -425,6 +409,24 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
425
409
  if (hasHook(plugins, 'key')) {
426
410
  await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
427
411
  }
412
+ const exclude = options.excludeDependencies
413
+ if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
414
+ const { keyOnly, dropped } = excludeDependencies(nodes, exclude)
415
+ if (dropped.size > 0) {
416
+ await keyExcludedDependencies({
417
+ nodes,
418
+ keyOnly,
419
+ dropped,
420
+ cache,
421
+ workspaceRoot,
422
+ workspaceFingerprint,
423
+ forwardArgs: options.forwardArgs,
424
+ nestedDirsByProject,
425
+ gitFilesCache,
426
+ hashCache,
427
+ })
428
+ }
429
+ }
428
430
  let priorities: ReadonlyMap<string, number> = new Map()
429
431
  if (hasHook(plugins, 'schedule')) {
430
432
  priorities = await applyScheduleHooks(plugins, nodes, {
@@ -447,7 +449,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
447
449
  nodes,
448
450
  unresolvedTasks,
449
451
  projects,
452
+ anyProjectConfig: projectsWithConfigs.length > 0,
450
453
  workspaceFingerprint,
454
+ fingerprintWatch,
451
455
  nestedDirsByProject,
452
456
  gitFilesCache,
453
457
  hashCache,
@@ -455,3 +459,30 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
455
459
  empty: nodes.size === 0 ? 'empty-graph' : null,
456
460
  }
457
461
  }
462
+
463
+ /**
464
+ * Refuses the first `^name` in `unproven` that no project in the whole
465
+ * workspace declares. `load` evaluates the configs a scoped run left out;
466
+ * it is reached only when a `^name` found no holder and nothing loaded
467
+ * declares it, so a run that names only declared tasks never pays for it.
468
+ * The rest failing to load leaves the names unjudged: an out-of-scope
469
+ * broken config does not fail a scoped run.
470
+ */
471
+ async function refuseUndeclaredDeps(
472
+ unproven: ReadonlyArray<readonly [taskId: string, name: string]>,
473
+ load: () => Promise<LoadedProjects>,
474
+ ): Promise<void> {
475
+ let all: LoadedProjects
476
+ try {
477
+ all = await load()
478
+ } catch {
479
+ return
480
+ }
481
+ const declared = new Set<string>()
482
+ for (const p of all.projects.values()) {
483
+ for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
484
+ }
485
+ for (const [id, name] of unproven) {
486
+ if (!declared.has(name)) throw undeclaredDepsError(id, name)
487
+ }
488
+ }