@vzn/vx 0.0.18 → 0.0.21

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 (121) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +99 -65
  3. package/package.json +10 -20
  4. package/src/bin.ts +12 -2
  5. package/src/cache/archive.ts +120 -18
  6. package/src/cache/cache.ts +313 -1324
  7. package/src/cache/chained-cache.ts +0 -8
  8. package/src/cache/config-evals.ts +89 -0
  9. package/src/cache/file-hashes.ts +262 -0
  10. package/src/cache/git-inputs.ts +637 -0
  11. package/src/cache/index.ts +16 -7
  12. package/src/cache/inputs.ts +246 -733
  13. package/src/cache/layer.ts +604 -0
  14. package/src/cache/layered-cache.ts +12 -17
  15. package/src/cache/output-index.ts +224 -0
  16. package/src/cache/policy.ts +84 -0
  17. package/src/cache/run-history.ts +147 -0
  18. package/src/cache/zstd.ts +160 -0
  19. package/src/cli/cache.ts +39 -11
  20. package/src/cli/completions.ts +104 -0
  21. package/src/cli/core-alias.ts +25 -0
  22. package/src/cli/help.ts +64 -36
  23. package/src/cli/index.ts +54 -15
  24. package/src/cli/info.ts +120 -91
  25. package/src/cli/init.ts +75 -0
  26. package/src/cli/last.ts +38 -6
  27. package/src/cli/lock.ts +2 -1
  28. package/src/cli/plan-format.ts +14 -3
  29. package/src/cli/plugin-commands.ts +15 -8
  30. package/src/cli/run.ts +84 -302
  31. package/src/cli/select.ts +332 -0
  32. package/src/cli/show.ts +144 -69
  33. package/src/cli/upgrade.ts +2 -1
  34. package/src/cli/watch.ts +647 -97
  35. package/src/cli/why.ts +44 -14
  36. package/src/cli/workspace-config.ts +126 -0
  37. package/src/config.ts +156 -136
  38. package/src/exec/env.ts +11 -0
  39. package/src/exec/executor.ts +11 -26
  40. package/src/exec/index.ts +6 -2
  41. package/src/{plugins/local-executor/index.ts → exec/local-executor.ts} +12 -16
  42. package/src/exec/runner.ts +57 -18
  43. package/src/exec/sandbox-binds.ts +182 -0
  44. package/src/exec/sandbox-paths.ts +50 -0
  45. package/src/exec/sandbox-runtime.ts +582 -370
  46. package/src/exec/sandbox-violations.ts +280 -0
  47. package/src/graph/index.ts +1 -9
  48. package/src/graph/scheduler.ts +158 -203
  49. package/src/graph/task-graph.ts +2 -2
  50. package/src/index.ts +97 -102
  51. package/src/orchestrator/admission.ts +137 -0
  52. package/src/orchestrator/deferred-outputs.ts +3 -0
  53. package/src/orchestrator/doctor.ts +255 -0
  54. package/src/orchestrator/events.ts +42 -8
  55. package/src/orchestrator/execute-task.ts +145 -554
  56. package/src/orchestrator/failure-mode.ts +169 -28
  57. package/src/orchestrator/framed-output.ts +41 -36
  58. package/src/orchestrator/history.ts +118 -98
  59. package/src/orchestrator/hit-restore.ts +221 -0
  60. package/src/orchestrator/index.ts +35 -4
  61. package/src/orchestrator/lockfile-claim.ts +281 -0
  62. package/src/orchestrator/logger.ts +13 -22
  63. package/src/orchestrator/metrics.ts +53 -20
  64. package/src/orchestrator/miss-save.ts +169 -0
  65. package/src/orchestrator/options.ts +34 -31
  66. package/src/orchestrator/persistent.ts +79 -0
  67. package/src/orchestrator/placement.ts +182 -0
  68. package/src/orchestrator/plugin-host.ts +195 -38
  69. package/src/orchestrator/plugin.ts +162 -9
  70. package/src/orchestrator/prepare.ts +77 -132
  71. package/src/orchestrator/projects.ts +259 -0
  72. package/src/orchestrator/remote-prefetch.ts +1 -1
  73. package/src/orchestrator/run-artifacts.ts +25 -3
  74. package/src/orchestrator/run-context.ts +1 -1
  75. package/src/orchestrator/run-records.ts +149 -0
  76. package/src/orchestrator/run-report.ts +2 -2
  77. package/src/orchestrator/run.ts +246 -597
  78. package/src/orchestrator/sandbox-request.ts +243 -0
  79. package/src/orchestrator/save-lane.ts +47 -0
  80. package/src/orchestrator/signals.ts +111 -0
  81. package/src/orchestrator/stable-keys.ts +60 -3
  82. package/src/orchestrator/status-line.ts +2 -1
  83. package/src/orchestrator/summary.ts +68 -25
  84. package/src/orchestrator/task-hash.ts +20 -18
  85. package/src/orchestrator/task-log-buffer.ts +8 -11
  86. package/src/orchestrator/telemetry-host.ts +11 -1
  87. package/src/orchestrator/telemetry.ts +3 -17
  88. package/src/util/cgroup.ts +160 -0
  89. package/src/util/edit-distance.ts +49 -0
  90. package/src/util/index.ts +11 -3
  91. package/src/util/paths.ts +23 -2
  92. package/src/util/settle.ts +19 -0
  93. package/src/util/verbs.ts +39 -0
  94. package/src/workspace/affected.ts +111 -11
  95. package/src/workspace/config-cache.ts +11 -2
  96. package/src/workspace/config-eval.ts +11 -4
  97. package/src/workspace/config-imports.ts +5 -4
  98. package/src/workspace/config-schema.ts +837 -0
  99. package/src/workspace/filter.ts +43 -6
  100. package/src/workspace/fingerprint.ts +37 -3
  101. package/src/workspace/index.ts +30 -3
  102. package/src/workspace/lockfile.ts +12 -7
  103. package/src/{cli → workspace}/migrate-scripts.ts +26 -19
  104. package/src/{cli/migrate.ts → workspace/migration.ts} +133 -124
  105. package/src/workspace/package-graph.ts +98 -28
  106. package/src/workspace/project-loader.ts +76 -746
  107. package/src/workspace/workspace.ts +98 -13
  108. package/plugins/local-cache/index.ts +0 -2
  109. package/plugins/local-executor/index.ts +0 -2
  110. package/plugins/schedule-history/index.ts +0 -2
  111. package/src/cache/cas-backend.ts +0 -115
  112. package/src/cache/digest.ts +0 -32
  113. package/src/cli/migrate-emit.ts +0 -21
  114. package/src/cli/migrate-nx.ts +0 -473
  115. package/src/cli/migrate-turbo.ts +0 -401
  116. package/src/cli/prune.ts +0 -291
  117. package/src/orchestrator/missing-plugin.ts +0 -9
  118. package/src/orchestrator/resources.ts +0 -45
  119. package/src/orchestrator/verify.ts +0 -174
  120. package/src/plugins/local-cache/index.ts +0 -11
  121. package/src/plugins/schedule-history/index.ts +0 -109
@@ -8,11 +8,15 @@
8
8
  // run with a clean UserError naming plugin + hook. A plugin that throws
9
9
  // inside a bus hook is logged and disabled for the remainder of the run.
10
10
 
11
+ import { readFileSync } from 'node:fs'
12
+ import path from 'node:path'
13
+ import { fileURLToPath } from 'node:url'
11
14
  import type { Cache, CacheLayer, CachePolicy } from '../cache/index.js'
12
- import type { ProjectConfig, WorkspaceConfig } from '../config.js'
15
+ import { PLUGIN_PACKAGE, type ProjectConfig, type WorkspaceConfig } from '../config.js'
13
16
  import type { TaskExecutor } from '../exec/index.js'
14
17
  import type { TaskNode, TaskOutcome } from '../graph/index.js'
15
18
  import { UserError } from '../util/index.js'
19
+ import type { ProjectMeta } from '../workspace/index.js'
16
20
  import type { EventBus, RunStartInfo } from './events.js'
17
21
  import type { TelemetryContext, TelemetrySink } from './telemetry.js'
18
22
 
@@ -32,7 +36,12 @@ import type { TelemetryContext, TelemetrySink } from './telemetry.js'
32
36
  * outside the list.
33
37
  */
34
38
  export interface VxPlugin {
35
- /** Stable identifier, convention `'org/name'`. Used in errors + precedence logs. */
39
+ /**
40
+ * The name of the package the plugin was defined in — set by
41
+ * `definePlugin` from the nearest `package.json`, never by the plugin.
42
+ * Heads every warning about the plugin, every `vx info` line, and the
43
+ * key material a `key` hook contributes.
44
+ */
36
45
  readonly name: string
37
46
 
38
47
  // --- PIPELINE stages (shape the run before it executes — opt-in) ----------
@@ -54,7 +63,10 @@ export interface VxPlugin {
54
63
  * One project's validated config, right after it loaded and before the
55
64
  * graph is built. Add, remove or edit tasks. Runs for every loaded project
56
65
  * on every run — a config's cached evaluation is the user's file, and the
57
- * hook is applied on top of it live.
66
+ * hook is applied on top of it live. A package with NO config file is
67
+ * loaded too, as `{ tasks: {} }`, so a plugin can give tasks to packages
68
+ * that never wrote one (`ctx.packageJson` carries their scripts); with no
69
+ * `project` plugin such a package is never visited.
58
70
  */
59
71
  project?(config: ProjectConfig, ctx: ProjectHookContext): void | Promise<void>
60
72
 
@@ -82,6 +94,20 @@ export interface VxPlugin {
82
94
  | undefined
83
95
  | Promise<Readonly<Record<string, string>> | undefined>
84
96
 
97
+ /**
98
+ * Workspace-root files this plugin keys on its own, taken OUT of the
99
+ * workspace fingerprint. Core folds every lockfile it knows into every
100
+ * task's key, so one `pnpm install` re-keys the whole workspace. A plugin
101
+ * that reads the lockfile and folds each project's own dependency
102
+ * closure through `key` claims the file here: core leaves it out of the
103
+ * fingerprint every key sees (the config-evaluation cache still keys on
104
+ * it — a config may import a dependency), and `--affected` asks
105
+ * `affected` which projects a change to it touches instead of selecting
106
+ * every project. A file has one claimant; a name core does not fold is
107
+ * refused, since there is nothing to take out.
108
+ */
109
+ readonly fingerprint?: FingerprintClaim
110
+
85
111
  /**
86
112
  * Scheduling priorities: task id → weight, higher runs first among READY
87
113
  * tasks (merged over the structural baseline, which stays the tie-break).
@@ -93,6 +119,20 @@ export interface VxPlugin {
93
119
  ctx: ScheduleHookContext,
94
120
  ): ReadonlyMap<string, number> | undefined | Promise<ReadonlyMap<string, number> | undefined>
95
121
 
122
+ /**
123
+ * Admission over the worker count: asked for every task about to start
124
+ * on this machine, with the tasks running here right now. Return `false`
125
+ * to hold it until something finishes (it is asked again then). Asked
126
+ * many times per run, so it must be cheap and synchronous; a throw is
127
+ * reported once and the plugin admits from then on — a policy never
128
+ * breaks a run. Restore-tier hits and tasks on an executor pool hold no
129
+ * local resources and are never asked. When several plugins answer, all
130
+ * must admit. Core keeps no notion of what a task needs: a plugin that
131
+ * packs memory or CPU learns or declares the numbers itself
132
+ * (`@vzn/vx-schedule-history` packs what past executions used).
133
+ */
134
+ admit?(task: TaskNode, ctx: AdmitContext): boolean
135
+
96
136
  // --- CLI verbs (opt-in) ---------------------------------------------------
97
137
 
98
138
  /**
@@ -110,8 +150,9 @@ export interface VxPlugin {
110
150
  * Contribute a cache layer. Returns a CacheLayer wrapping (or replacing)
111
151
  * the local Cache, or undefined to decline. Consulted ONCE per prepareRun.
112
152
  * Precedence: first non-undefined plugin cache wins, in declaration
113
- * order. `@vzn/vx/plugins/local-cache` hands back the bare local Cache;
114
- * a list with no provider fails the run before any task starts.
153
+ * order, ahead of core's own `.vx/cache` handle at the tail. A layer
154
+ * that WRAPS the local handle subsumes that tail, so it is not written
155
+ * twice.
115
156
  */
116
157
  cache?(ctx: CacheContext): CacheLayer | undefined | Promise<CacheLayer | undefined>
117
158
 
@@ -119,10 +160,10 @@ export interface VxPlugin {
119
160
  * Contribute a task executor — WHERE one task's command runs. Consulted
120
161
  * ONCE per run; every contributed executor is kept, in declaration order,
121
162
  * and per task the first whose `accepts()` passes executes it. Nothing is
122
- * appended: a workspace declares `localExecutorPlugin()` (from
123
- * `@vzn/vx/plugins/local-executor`) like any other, and a run with no
124
- * executor at all fails before any task runs. Persistent tasks never
125
- * reach an executor (local by construction).
163
+ * appended in front: core's own local executor is always the TAIL, so
164
+ * declining a task hands it back to this machine rather than failing
165
+ * the run. Persistent tasks never reach an executor (local by
166
+ * construction).
126
167
  */
127
168
  executor?(ctx: ExecutorContext): TaskExecutor | undefined | Promise<TaskExecutor | undefined>
128
169
 
@@ -188,6 +229,18 @@ export interface ProjectHookContext extends BaseContext {
188
229
  readonly dir: string
189
230
  /** The parsed `package.json`, read-only. */
190
231
  readonly packageJson: Readonly<Record<string, unknown>>
232
+ /**
233
+ * Every package the workspace discovered — the one being visited, the
234
+ * rest of the run's scope and the packages outside it, config file or
235
+ * not — as core read them at startup. A plugin whose mapping needs the
236
+ * whole workspace (a `dependsOn` is only valid against every package's
237
+ * scripts at once) reads it here instead of walking the workspace a
238
+ * second time. The same array is handed to every visit of a run and a
239
+ * new one to the next run — a plugin instance outlives a run under `vx
240
+ * watch`, so a per-run memo keys on this array's identity, never on the
241
+ * process.
242
+ */
243
+ readonly projects: readonly ProjectMeta[]
191
244
  }
192
245
 
193
246
  export interface GraphHookContext extends BaseContext {
@@ -197,6 +250,42 @@ export interface GraphHookContext extends BaseContext {
197
250
 
198
251
  export interface KeyHookContext extends BaseContext {}
199
252
 
253
+ /** A plugin's claim on workspace fingerprint files — see `VxPlugin.fingerprint`. */
254
+ export interface FingerprintClaim {
255
+ /** Root-relative names from `WORKSPACE_FINGERPRINT_FILES`, e.g. `['pnpm-lock.yaml']`. */
256
+ readonly files: readonly string[]
257
+ /**
258
+ * The projects (package names) a change to a claimed file affects. Called
259
+ * by `--affected` with the file's bytes at the base ref and in the
260
+ * working tree (`null` where it does not exist). Return `undefined` to
261
+ * select every project — the answer a claimant gives when it cannot
262
+ * tell, and what core does for an unclaimed file.
263
+ */
264
+ affected(
265
+ change: FingerprintChange,
266
+ ctx: FingerprintContext,
267
+ ): Iterable<string> | undefined | Promise<Iterable<string> | undefined>
268
+ }
269
+
270
+ export interface FingerprintChange {
271
+ /** The claimed file's root-relative name. */
272
+ readonly file: string
273
+ readonly before: Uint8Array | null
274
+ readonly after: Uint8Array | null
275
+ }
276
+
277
+ export interface FingerprintContext extends BaseContext {
278
+ /** Every project in the workspace: package name and absolute directory. */
279
+ readonly projects: ReadonlyArray<{ readonly name: string; readonly dir: string }>
280
+ }
281
+
282
+ export interface AdmitContext {
283
+ /** The tasks executing on this machine right now, in dispatch order. */
284
+ readonly running: readonly TaskNode[]
285
+ /** The run's worker count — the ceiling the count gate already applies. */
286
+ readonly concurrency: number
287
+ }
288
+
200
289
  export interface ScheduleHookContext extends BaseContext {
201
290
  /** The run's local cache handle — its `dbHandle()` holds the run history a policy can learn from. */
202
291
  readonly localCache: Cache
@@ -263,6 +352,70 @@ export interface Plugin {
263
352
  setup?(ctx: PluginContext): void | Promise<void>
264
353
  }
265
354
 
355
+ /** What a plugin author writes: every hook, and no name. */
356
+ export type PluginHooks = Omit<VxPlugin, 'name'>
357
+
358
+ /**
359
+ * Where a plugin is defined — `import.meta` of its module. `dir` is Bun's
360
+ * field; `url` is the standard one, for a module evaluated elsewhere.
361
+ */
362
+ export interface PluginOrigin {
363
+ readonly dir?: string
364
+ readonly url?: string
365
+ }
366
+
367
+ const packageNameByDir = new Map<string, string>()
368
+
369
+ /** The name of the nearest `package.json` above `dir` — the one that owns it. */
370
+ function pluginPackageName(dir: string): string {
371
+ const memo = packageNameByDir.get(dir)
372
+ if (memo !== undefined) return memo
373
+ for (let d = dir; ;) {
374
+ let text: string | undefined
375
+ try {
376
+ text = readFileSync(path.join(d, 'package.json'), 'utf8')
377
+ } catch {
378
+ /* not here; look one level up */
379
+ }
380
+ if (text !== undefined) {
381
+ const name = (JSON.parse(text) as { name?: unknown }).name
382
+ if (typeof name !== 'string' || name.length === 0) {
383
+ throw new UserError(
384
+ `definePlugin: ${path.join(d, 'package.json')} has no name — a plugin is a package, and its name is the package's`,
385
+ )
386
+ }
387
+ packageNameByDir.set(dir, name)
388
+ return name
389
+ }
390
+ const parent = path.dirname(d)
391
+ if (parent === d) {
392
+ throw new UserError(
393
+ `definePlugin: no package.json above ${dir} — a plugin is a package, and its name is the package's`,
394
+ )
395
+ }
396
+ d = parent
397
+ }
398
+ }
399
+
400
+ /**
401
+ * The one way to make a plugin: `definePlugin(import.meta, { ...hooks })`.
402
+ * The name is read from the package the calling module belongs to — the
403
+ * nearest `package.json` above it — and stamped where the workspace loader
404
+ * checks for it, so a plugin cannot be named anything but its package.
405
+ */
406
+ export function definePlugin(origin: PluginOrigin, hooks: PluginHooks): VxPlugin {
407
+ if ('name' in hooks) {
408
+ throw new UserError(`definePlugin: a plugin's name is its package name — drop the 'name' field`)
409
+ }
410
+ const dir =
411
+ origin.dir ?? (origin.url !== undefined ? path.dirname(fileURLToPath(origin.url)) : undefined)
412
+ if (dir === undefined) {
413
+ throw new UserError(`definePlugin: the first argument must be the plugin module's import.meta`)
414
+ }
415
+ const name = pluginPackageName(dir)
416
+ return { ...hooks, name, [PLUGIN_PACKAGE]: name } as VxPlugin
417
+ }
418
+
266
419
  export interface InstallPluginsArgs {
267
420
  plugins: readonly Plugin[]
268
421
  workspaceRoot: string
@@ -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,30 @@ import {
25
26
  import {
26
27
  buildPackageGraph,
27
28
  computeNestedProjectDirs,
28
- computeWorkspaceFingerprint,
29
+ computeWorkspaceFingerprints,
29
30
  findWorkspaceRoot,
30
- frozenProjectConfig,
31
31
  listProjects,
32
- loadProjectConfigs,
33
32
  loadWorkspace,
34
- loadWorkspaceConfig,
33
+ FROZEN_WITHOUT_LOCK,
35
34
  readLockfile,
36
35
  resolveCacheDir,
37
- validateProjectConfig,
38
36
  type ProjectEntry,
39
37
  } from '../workspace/index.js'
40
38
  import {
41
39
  buildTaskGraph,
42
40
  expandRequested,
43
- parseDependencySpec,
44
41
  type TaskNode,
45
42
  unresolvedRequests,
46
43
  } from '../graph/index.js'
47
44
  import {
48
- applyConfigHooks,
49
45
  applyGraphHooks,
50
46
  applyKeyHooks,
51
- applyProjectHooks,
52
47
  applyScheduleHooks,
48
+ fingerprintClaims,
53
49
  hasHook,
54
50
  resolveCache,
55
51
  } from './plugin-host.js'
52
+ import { loadProjects, loadWorkspacePlugins, type LoadedProjects } from './projects.js'
56
53
  import type { VxPlugin } from './plugin.js'
57
54
  import { createHashCache, type HashCache } from './task-hash.js'
58
55
  import type { Logger } from './logger.js'
@@ -76,8 +73,7 @@ export interface PreparedRun {
76
73
  * injected remote layer, or one a plugin's `cache` capability built. The
77
74
  * remote axes of the policy only mean anything then: without a remote
78
75
  * 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.
76
+ * would be saved still cleans its outputs before executing.
81
77
  */
82
78
  hasRemoteLayer: boolean
83
79
  /**
@@ -96,6 +92,12 @@ export interface PreparedRun {
96
92
  unresolvedTasks: readonly string[]
97
93
  /** Every discovered project — the declared task names a typo is measured against. */
98
94
  projects: ReadonlyMap<string, ProjectEntry>
95
+ /**
96
+ * True iff at least one package in the workspace has a `vx.config.*` at
97
+ * all — regardless of scope, so a `--filter` that matched nothing is not
98
+ * mistaken for a workspace vx was never set up in.
99
+ */
100
+ anyProjectConfig: boolean
99
101
  workspaceFingerprint: string
100
102
  nestedDirsByProject: Map<string, string[]>
101
103
  /**
@@ -129,25 +131,6 @@ export interface PreparedRun {
129
131
  empty: null | 'no-tasks-declared' | 'empty-graph'
130
132
  }
131
133
 
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
134
  /**
152
135
  * Build the prepared-run context: workspace discovery, project-config
153
136
  * load, package + task graph, cache handle (local, optionally wrapped
@@ -178,16 +161,9 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
178
161
  // await further down still sees the error).
179
162
  earlyGit?.catch(() => {})
180
163
  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
- }
164
+ const { workspaceConfig, plugins } = await loadWorkspacePlugins(workspaceRoot, (m) =>
165
+ log.status(m),
166
+ )
191
167
  mark('workspace config')
192
168
  const projectMetas = await listProjects(workspace)
193
169
  mark('discover projects')
@@ -201,17 +177,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
201
177
  // Turbo-like: a broken config in an unrelated package no longer
202
178
  // fails a scoped run — it surfaces when that package enters scope.
203
179
  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.
180
+ // Seeds: explicit scope, plus anchored pkg#task targets (which bypass
181
+ // scope by design). With no explicit scope, bare task names fan out
182
+ // across the whole workspace — but when EVERY spec is anchored, the
183
+ // anchors alone are the scope and nothing else needs its config
184
+ // evaluated.
215
185
  const anchored: string[] = []
216
186
  let hasBare = false
217
187
  for (const spec of options.tasks) {
@@ -219,16 +189,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
219
189
  if (hashIdx > 0) anchored.push(spec.slice(0, hashIdx))
220
190
  else hasBare = true
221
191
  }
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
- }
192
+ const seeds: 'all' | string[] =
193
+ options.projects !== undefined ? [...options.projects, ...anchored] : hasBare ? 'all' : anchored
232
194
 
233
195
  // The local cache opens BEFORE the configs load: it is also where their
234
196
  // cached evaluations live. `--cache-dir <path>` (RunOptions.cacheDir)
@@ -239,25 +201,17 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
239
201
  ? path.resolve(options.cwd, options.cacheDir)
240
202
  : resolveCacheDir(workspaceRoot, workspaceConfig)
241
203
  const localCache = new Cache(cacheDir, { read: policy.localRead, write: policy.localWrite })
242
- const workspaceFingerprint = await computeWorkspaceFingerprint(workspaceRoot)
204
+ noteSchemaReset(localCache, (m) => log.status(m))
205
+ // Two digests from one read: the config-evaluation cache keys on every
206
+ // file (a config may import a dependency), the task keys on the files no
207
+ // plugin claims (`VxPlugin.fingerprint`).
208
+ const fingerprints = await computeWorkspaceFingerprints(
209
+ workspaceRoot,
210
+ new Set(fingerprintClaims(plugins).keys()),
211
+ )
212
+ const workspaceFingerprint = fingerprints.unclaimed
243
213
  mark('open cache')
244
214
 
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
215
  // Frozen mode (--frozen, CI): configs load FROM vx-lock.json after a
262
216
  // content-hash tripwire — no evaluation; env-dependent configs keep
263
217
  // their locked values. Default (local) runs ALWAYS evaluate live:
@@ -267,55 +221,31 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
267
221
  // See docs/design/config-lock-2026-06.md.
268
222
  const lock = options.frozen === true ? await readLockfile(workspaceRoot) : null
269
223
  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
- )
224
+ throw new UserError(FROZEN_WITHOUT_LOCK)
273
225
  }
274
226
 
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')
227
+ let loaded: LoadedProjects
282
228
  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
- }
229
+ loaded = await loadProjects({
230
+ workspaceRoot,
231
+ cacheDir,
232
+ plugins,
233
+ projectMetas,
234
+ packageGraph,
235
+ seeds,
236
+ closure: true,
237
+ lock,
238
+ evalCache: { store: localCache, workspaceFingerprint: fingerprints.all },
239
+ warn: (m) => log.status(m),
240
+ ...(options.staged !== undefined ? { staged: options.staged } : {}),
241
+ })
313
242
  } catch (err) {
314
243
  // The cache opened before the configs loaded (it holds their cached
315
244
  // evaluations); a config error must not leak the handle.
316
245
  localCache.close()
317
246
  throw err
318
247
  }
248
+ const { projects, configured: projectsWithConfigs } = loaded
319
249
  mark('load configs')
320
250
 
321
251
  // Boundary geometry considers every config-bearing project in the
@@ -333,24 +263,32 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
333
263
  const unresolvedTasks = unresolvedRequests(options.tasks, candidateProjects, projects)
334
264
 
335
265
  // Cache seam precedence: an EXPLICITLY injected remote layer
336
- // (RunOptions.remoteCache — a distribution agent or serve that already
266
+ // (RunOptions.remoteCache — a distribution agent or daemon that already
337
267
  // holds a wire client) wins outright; else a plugin's `cache` capability;
338
268
  // else the local cache alone. Core ships no wire client — the remote
339
- // cache is a plugin concern (native-cache-wire-2026-07). Injection
269
+ // cache is a plugin concern (docs/patterns.md § Remote cache wire). Injection
340
270
  // winning prevents double-wrapping when the workspace also declares a
341
271
  // 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
- })
272
+ let cache: CacheLayer
273
+ try {
274
+ cache = options.remoteCache
275
+ ? new LayeredCache(localCache, options.remoteCache, {
276
+ policy,
277
+ onRemoteError: (err) => log.status(`[vx] remote cache: ${err.message}`),
278
+ })
279
+ : await resolveCache(plugins, {
280
+ workspaceRoot,
281
+ cacheDir,
282
+ warn: (m) => log.status(m),
283
+ localCache,
284
+ policy,
285
+ })
286
+ } catch (err) {
287
+ // A cache plugin that throws or returns something off-contract is
288
+ // refused by name; the local handle opened above must not leak with it.
289
+ localCache.close()
290
+ throw err
291
+ }
354
292
  // Ask the LAYER, don't infer. Identity against `localCache` answers a
355
293
  // DIFFERENT question — "did the plugin hand back something other than the
356
294
  // handle I passed in?" — which an ordinary pass-through decorator (a
@@ -397,6 +335,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
397
335
  nodes: new Map(),
398
336
  unresolvedTasks,
399
337
  projects,
338
+ anyProjectConfig: projectsWithConfigs.length > 0,
400
339
  workspaceFingerprint,
401
340
  nestedDirsByProject,
402
341
  gitFilesCache,
@@ -414,6 +353,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
414
353
  ? { excludeDependencies: options.excludeDependencies }
415
354
  : {}),
416
355
  })
356
+ // The graph is built; what follows is the plugins' (graph, key,
357
+ // schedule). Two rows, so a plugin's key stage reads as its own cost
358
+ // and not as graph building — a lockfile plugin's 1000 stats per run
359
+ // hid inside one `prepare (graph)` row until 2026-09-10.
360
+ mark('build graph')
417
361
  if (hasHook(plugins, 'graph')) {
418
362
  await applyGraphHooks(plugins, nodes, {
419
363
  workspaceRoot,
@@ -447,6 +391,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
447
391
  nodes,
448
392
  unresolvedTasks,
449
393
  projects,
394
+ anyProjectConfig: projectsWithConfigs.length > 0,
450
395
  workspaceFingerprint,
451
396
  nestedDirsByProject,
452
397
  gitFilesCache,