@vzn/vx 0.0.632 → 0.0.634

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 (48) hide show
  1. package/README.md +14 -10
  2. package/docs/caching.md +2250 -0
  3. package/docs/cli.md +3283 -0
  4. package/docs/execution.md +708 -0
  5. package/docs/features.md +174 -0
  6. package/docs/patterns.md +219 -0
  7. package/docs/schema.md +2151 -0
  8. package/docs/security.md +209 -0
  9. package/package.json +12 -5
  10. package/schemas/docs.json +28 -0
  11. package/schemas/error.json +5 -1
  12. package/schemas/init.json +57 -0
  13. package/schemas/lock.json +21 -0
  14. package/schemas/plan.json +4 -0
  15. package/skills/vx/SKILL.md +13 -3
  16. package/src/cache/cache.ts +556 -122
  17. package/src/cache/chained-cache.ts +9 -0
  18. package/src/cache/inputs.ts +71 -30
  19. package/src/cache/layer.ts +8 -4
  20. package/src/cache/layered-cache.ts +4 -0
  21. package/src/cache/schema.ts +42 -16
  22. package/src/cli/docs-corpus.ts +21 -0
  23. package/src/cli/docs-text.d.ts +5 -0
  24. package/src/cli/docs.ts +173 -0
  25. package/src/cli/help.ts +14 -3
  26. package/src/cli/index.ts +2 -0
  27. package/src/cli/init.ts +44 -8
  28. package/src/cli/lock.ts +43 -15
  29. package/src/cli/plan-format.ts +3 -1
  30. package/src/cli/run.ts +8 -2
  31. package/src/cli/upgrade.ts +18 -6
  32. package/src/config.ts +9 -0
  33. package/src/graph/priorities.ts +72 -6
  34. package/src/graph/scheduler.ts +17 -4
  35. package/src/graph/task-graph.ts +56 -18
  36. package/src/orchestrator/metrics.ts +11 -4
  37. package/src/orchestrator/miss-reason.ts +3 -5
  38. package/src/orchestrator/miss-save.ts +6 -5
  39. package/src/orchestrator/projects.ts +2 -2
  40. package/src/orchestrator/remote-prefetch.ts +8 -2
  41. package/src/orchestrator/run.ts +13 -1
  42. package/src/orchestrator/task-hash.ts +4 -4
  43. package/src/util/errors.ts +9 -2
  44. package/src/util/paths.ts +32 -0
  45. package/src/util/verbs.ts +1 -0
  46. package/src/workspace/config-schema.ts +26 -8
  47. package/src/workspace/lockfile.ts +29 -2
  48. package/src/workspace/migration.ts +30 -3
@@ -4,11 +4,11 @@
4
4
  //
5
5
  // Paid only when a telemetry sink exists, and only on a miss: one scan of
6
6
  // `entries` for the newest entry per task, the first time a miss asks, then
7
- // one primary-key read of that entry's `entry_inputs` per miss.
7
+ // one primary-key read of that entry's `entry_inputs` row per miss.
8
8
 
9
9
  import type { Database } from 'bun:sqlite'
10
10
  import type { InputChanges } from '../graph/index.js'
11
- import { diffKeyComponents } from './metrics.js'
11
+ import { diffKeyComponents, loadEntryInputs } from './metrics.js'
12
12
  import type { TaskInputComponent } from './task-hash.js'
13
13
 
14
14
  /** How many changed components a miss names; `count` says how many there were. */
@@ -37,9 +37,7 @@ export function createMissExplainer(db: Database): MissExplainer {
37
37
  )
38
38
  const prev = newest.get(taskId)
39
39
  if (prev === undefined) return undefined
40
- const before = db
41
- .query('SELECT kind, name, hash FROM entry_inputs WHERE entry_hash = ?')
42
- .all(prev) as TaskInputComponent[]
40
+ const before = loadEntryInputs(db, prev)
43
41
  // Pruned, or saved before fingerprints were kept: nothing to compare.
44
42
  if (before.length === 0) return undefined
45
43
  const { entries } = diffKeyComponents(before, components)
@@ -191,11 +191,12 @@ export async function saveMiss(a: SaveMissArgs): Promise<{ landed: Promise<SaveF
191
191
  endSave()
192
192
  if (a.measure === true) {
193
193
  facts.saveMs = Math.round(performance.now() - saveStart)
194
- try {
195
- facts.artifactBytes = statSync(cache.outputsPath(a.hash)).size
196
- } catch {
197
- // No local artifact (local writes off): no size to say.
198
- }
194
+ // No local artifact (local writes off): no size to say.
195
+ const size =
196
+ cache.artifactSize !== undefined
197
+ ? cache.artifactSize(a.hash)
198
+ : statSync(cache.outputsPath(a.hash), { throwIfNoEntry: false })?.size
199
+ if (size !== undefined) facts.artifactBytes = size
199
200
  }
200
201
  // The files the task just wrote are the entry's bytes: stamp them,
201
202
  // so the next hit can tell them from another entry's with the same
@@ -328,8 +328,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
328
328
  * one (owner, 2026-10-04): a group behind `^build`, keyed on every file of
329
329
  * the project. A package consumed as source then carries its files to every
330
330
  * dependent behind `^build`, in the key and in `--affected`, and the chain
331
- * through it to deeper builds holds. The one keyed group: a config cannot
332
- * declare `cache` on a group (config-schema.ts), so nothing else is.
331
+ * through it to deeper builds holds. A keyed group, as a config may
332
+ * declare one (config-schema.ts).
333
333
  */
334
334
  const DEFAULT_BUILD: TaskConfig = Object.freeze({
335
335
  dependsOn: Object.freeze(['^build']),
@@ -113,11 +113,17 @@ async function runPrefetch(args: PrefetchArgs): Promise<void> {
113
113
  */
114
114
  export function getContext(node: TaskNode, command: string) {
115
115
  const outputs = node.config.cache?.outputs
116
+ let masked: string | undefined
116
117
  return {
117
118
  taskId: node.id,
118
119
  // Stored in the entry row a remote hit inserts: masked as a save's is,
119
- // or the token a config inlined sat in cache.db (L-27).
120
- command: maskedCommand(command, node.config.exec?.env),
120
+ // or the token a config inlined sat in cache.db (L-27). Masked on the
121
+ // first read: only an adopt or a remote hit reads it, and masking every
122
+ // lookup's command cost a cold 1,090-package run ~20 ms before its
123
+ // first task.
124
+ get command() {
125
+ return (masked ??= maskedCommand(command, node.config.exec?.env))
126
+ },
121
127
  outputs: { files: outputs?.files ?? [], workspaceFiles: outputs?.workspaceFiles ?? [] },
122
128
  }
123
129
  }
@@ -945,7 +945,15 @@ async function runOnBus(
945
945
  // shouldShortCircuit (local reads on, no remote layer); when off, both
946
946
  // maps are empty and the run is byte-identical.
947
947
  let shortCircuit: ShortCircuit = emptyShortCircuit()
948
- if (shouldShortCircuit(nodes, policy, cache)) {
948
+ // An empty local cache answers every probe with a miss: each task
949
+ // derives its key as it starts instead (~100 ms before a cold 1,090-
950
+ // package run's first spawn), and every sandboxed task runs.
951
+ const coldLocal =
952
+ cache === prepared.localCache &&
953
+ shouldShortCircuit(nodes, policy, cache) &&
954
+ prepared.localCache.holdsNothing()
955
+ if (coldLocal) prewarm()
956
+ else if (shouldShortCircuit(nodes, policy, cache)) {
949
957
  shortCircuit = await startLocalShortCircuit({
950
958
  nodes,
951
959
  cache,
@@ -1165,6 +1173,10 @@ async function runOnBus(
1165
1173
  // A `schedule` plugin's weights; the scheduler keeps its structural
1166
1174
  // baseline as the tie-break. Empty map → baseline only.
1167
1175
  ...(prepared.priorities.size > 0 ? { priorities: prepared.priorities } : {}),
1176
+ // The baseline under them; `most-work` when the workspace names none.
1177
+ ...(prepared.workspaceConfig?.schedule !== undefined
1178
+ ? { strategy: prepared.workspaceConfig.schedule }
1179
+ : {}),
1168
1180
  // Local short-circuit: confirmed stable local hits the scheduler
1169
1181
  // runs ahead of their deps as low-priority worker-slot backfill.
1170
1182
  // Empty when the short-circuit didn't fire → byte-identical.
@@ -411,8 +411,8 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
411
411
  * in place. Its one behavioural case is a config with NO `exec`, where
412
412
  * the spread would add `exec: {}` — and nothing reaches here that way,
413
413
  * proven by throwing on a no-exec config and running the whole suite:
414
- * a group takes `computeGroupKey`, and the one keyed group (the default
415
- * `build`) declares no `remote`. `timeout`/`retries` stay folded — their keys are
414
+ * a group takes `computeGroupKey`, and a keyed group has no `exec`, so no
415
+ * `remote`. `timeout`/`retries` stay folded — their keys are
416
416
  * distinct by design (see the decision log); stripping them retroactively
417
417
  * would bump CACHE_VERSION.
418
418
  */
@@ -423,8 +423,8 @@ function hashableConfig(cfg: TaskConfig): unknown {
423
423
  }
424
424
 
425
425
  /**
426
- * A group's key: its upstream, and for a keyed group — the default
427
- * `build` (projects.ts), the one group with `cache` — its own inputs too,
426
+ * A group's key: its upstream, and for a keyed group — one with `cache`,
427
+ * the default `build` (projects.ts) or a config's — its own inputs too,
428
428
  * the way a task's key folds them.
429
429
  */
430
430
  export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
@@ -62,9 +62,16 @@ export function wantsJson(argv: readonly string[]): boolean {
62
62
  return false
63
63
  }
64
64
 
65
- /** The JSON a verb that answers JSON prints for a refusal: one line. */
65
+ /**
66
+ * The JSON a verb that answers JSON prints for a refusal: one line. A
67
+ * core code (`VX_E_…`) links its section of cli.md, which says the fix;
68
+ * `vx docs <code>` prints the same section offline. A plugin's code has none.
69
+ */
66
70
  export function errorDocument(code: string, message: string): string {
67
- return `${JSON.stringify({ ok: false, error: { code, message } })}\n`
71
+ const docs = code.startsWith('VX_E_')
72
+ ? { docs: `https://vznjs.github.io/vx/cli/#${code.toLowerCase()}` }
73
+ : {}
74
+ return `${JSON.stringify({ ok: false, error: { code, message, ...docs } })}\n`
68
75
  }
69
76
 
70
77
  /**
package/src/util/paths.ts CHANGED
@@ -195,6 +195,30 @@ export const EXTGLOB = /[!@+*?]\(/
195
195
  * else: one site that forgot is a route directory that keys nothing.
196
196
  */
197
197
  export function taskGlob(pattern: string): Bun.Glob {
198
+ const memo = memoFor(taskGlobs)
199
+ let glob = memo.get(pattern)
200
+ if (glob === undefined) memo.set(pattern, (glob = compileTaskGlob(pattern)))
201
+ return glob
202
+ }
203
+
204
+ /**
205
+ * Compiled once per pattern: a Turbo-mapped config repeats one 400-entry
206
+ * list across two thousand tasks, and the graph's overlap checks compiled
207
+ * each entry per task pair, 3.3 s of an astro plan (2026-10-09). A pattern
208
+ * comes from a config, so the set is bounded by what the configs say. Kept
209
+ * per `Bun.Glob` in force: the playground and a test swap it, and a glob
210
+ * built by one is not the other's.
211
+ */
212
+ const taskGlobs = new WeakMap<object, Map<string, Bun.Glob>>()
213
+ const anyTaskGlobs = new WeakMap<object, Map<string, (rel: string) => boolean>>()
214
+
215
+ function memoFor<T>(memos: WeakMap<object, Map<string, T>>): Map<string, T> {
216
+ let memo = memos.get(Bun.Glob)
217
+ if (memo === undefined) memos.set(Bun.Glob, (memo = new Map()))
218
+ return memo
219
+ }
220
+
221
+ function compileTaskGlob(pattern: string): Bun.Glob {
198
222
  // A `]` with no `[` before it is already literal to `Bun.Glob`.
199
223
  const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
200
224
  const glob = new Bun.Glob(source)
@@ -228,6 +252,14 @@ export function taskGlob(pattern: string): Bun.Glob {
228
252
  * `src/a.ts` and never `src/deep/a.ts`, and an edit there was a stale hit.
229
253
  */
230
254
  export function anyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
255
+ const memo = memoFor(anyTaskGlobs)
256
+ const key = rawPatterns.join('\0')
257
+ let any = memo.get(key)
258
+ if (any === undefined) memo.set(key, (any = compileAnyTaskGlob(rawPatterns)))
259
+ return any
260
+ }
261
+
262
+ function compileAnyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
231
263
  const patterns = rawPatterns.flatMap(braceAlternatives)
232
264
  if (patterns.length === 0) return () => false
233
265
  const sources = patterns.map(regExpSource)
package/src/util/verbs.ts CHANGED
@@ -14,6 +14,7 @@ export const CORE_VERBS = [
14
14
  'info',
15
15
  'why',
16
16
  'last',
17
+ 'docs',
17
18
  'completions',
18
19
  'help',
19
20
  'version',
@@ -56,9 +56,18 @@ const WORKSPACE_FIELDS = new Set([
56
56
  'affectedBase',
57
57
  'cacheScope',
58
58
  'rules',
59
+ 'schedule',
59
60
  'plugins',
60
61
  ])
61
62
 
63
+ type Schedule = NonNullable<WorkspaceConfig['schedule']>
64
+ const SCHEDULES = {
65
+ 'most-work': true,
66
+ 'critical-path': true,
67
+ 'direct-dependents': true,
68
+ 'ready-order': true,
69
+ } satisfies Record<Schedule, true>
70
+
62
71
  const RULE_FIELDS = new Set(['exclusiveOutputs', 'upfrontKeys'])
63
72
 
64
73
  function validateRules(rules: unknown, configPath: string): void {
@@ -184,6 +193,13 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
184
193
  throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
185
194
  }
186
195
  if (config.rules !== undefined) validateRules(config.rules, configPath)
196
+ if (config.schedule !== undefined && !Object.hasOwn(SCHEDULES, config.schedule)) {
197
+ throw new UserError(
198
+ `${configPath}: \`schedule\` must be one of ${Object.keys(SCHEDULES)
199
+ .map((k) => `'${k}'`)
200
+ .join(', ')}`,
201
+ )
202
+ }
187
203
  if (config.plugins !== undefined) {
188
204
  if (!Array.isArray(config.plugins)) {
189
205
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)
@@ -616,15 +632,12 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
616
632
  // so a dependant's `^build` finds it and waits on nothing
617
633
  // (the quickstart). The comment here used to call that case
618
634
  // "almost certainly a config mistake" while the docs taught it.
619
- if (dependsOn === undefined) {
620
- throw new UserError(
621
- `${where}: a task with no \`exec\` must declare \`dependsOn\` ` +
622
- `(group tasks exist to chain dependencies)`,
623
- )
624
- }
625
- if (cache !== undefined) {
635
+ // A keyed group (`cache` with no `exec`) is a declaration on its own:
636
+ // its inputs are a key that dependants fold, with no process.
637
+ if (dependsOn === undefined && cache === undefined) {
626
638
  throw new UserError(
627
- `${where}: \`cache\` requires \`exec\` — a group task has nothing to cache`,
639
+ `${where}: a task with no \`exec\` must declare \`dependsOn\` or \`cache.inputs\` ` +
640
+ `(group tasks exist to chain dependencies or carry a key)`,
628
641
  )
629
642
  }
630
643
  }
@@ -852,6 +865,11 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
852
865
  if (wsOutputs !== undefined) {
853
866
  validateWorkspaceGlobs(wsOutputs, `${where}.cache.outputs.workspaceFiles`, false)
854
867
  }
868
+ if (exec === undefined && (outFiles.length > 0 || wsOutputs !== undefined)) {
869
+ throw new UserError(
870
+ `${where}.cache.outputs: a task with no \`exec\` writes nothing — \`outputs: { files: [] }\``,
871
+ )
872
+ }
855
873
  }
856
874
  }
857
875
  }
@@ -36,6 +36,8 @@ export interface LockfileEntry {
36
36
  export interface Lockfile {
37
37
  version: number
38
38
  projects: Record<string, LockfileEntry>
39
+ /** Written by `writeLockfile` from the bytes; never read off the object. */
40
+ validated?: string
39
41
  }
40
42
 
41
43
  export function lockfilePath(root: string): string {
@@ -100,12 +102,33 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
100
102
  }
101
103
  }
102
104
  lockDigests.set(parsed, xxh3(text).toString(16))
105
+ const stamped = VALIDATED_LINE.exec(text)
106
+ if (stamped !== null && stamped[1] === validatedDigest(text.slice(0, stamped.index) + '\n}')) {
107
+ selfValidated.add(parsed)
108
+ }
103
109
  return parsed as Lockfile
104
110
  }
105
111
 
106
112
  /** The digest of the bytes each lock `readLockfile` returned was parsed from. */
107
113
  const lockDigests = new WeakMap<object, string>()
108
114
 
115
+ /**
116
+ * The lock's own record that this vx validated its entries: `vx lock`
117
+ * evaluates and validates every config, then writes a digest of the bytes
118
+ * it wrote, under vx's version (validation's rules are vx's alone, so Bun's
119
+ * is left out and does not churn a committed lock), as the last field. A lock
120
+ * whose digest matches is validated already, and a cold `--frozen` run
121
+ * skips the ~30 ms of validating 1,090 entries before its first task. An
122
+ * edit, a merge, or another vx version leaves it unmatched, and the
123
+ * entries are validated as before.
124
+ */
125
+ const VALIDATED_LINE = /,\n {2}"validated": "([0-9a-f]+)"\n\}\n?$/
126
+ const selfValidated = new WeakSet<object>()
127
+
128
+ function validatedDigest(body: string): string {
129
+ return xxh3(`vx-lock-valid-v${CONFIG_EVAL_VERSION}\0${VERSION}\0${body}`).toString(16)
130
+ }
131
+
109
132
  export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
110
133
  // Written beside its name and renamed over it: `Bun.write` truncates in
111
134
  // place, so a `--frozen` run, or `vx watch --frozen` woken by the write
@@ -114,7 +137,8 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
114
137
  const file = lockfilePath(root)
115
138
  const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
116
139
  try {
117
- await Bun.write(tmp, `${JSON.stringify(lock, null, 2)}\n`)
140
+ const body = JSON.stringify({ version: lock.version, projects: lock.projects }, null, 2)
141
+ await Bun.write(tmp, `${body.slice(0, -2)},\n "validated": "${validatedDigest(body)}"\n}\n`)
118
142
  await rename(tmp, file)
119
143
  } catch (err) {
120
144
  // Best effort: where the write was refused the temp cannot be named
@@ -187,7 +211,10 @@ export async function frozenProjectConfigs(
187
211
  // asked for 1,090 per-project rows in one `IN` query, ~10 ms of its
188
212
  // config load, where the eval-cache path it replaces is cheaper (X-181).
189
213
  // A scoped run still validates, and remembers, only what it loads.
190
- if (prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null) {
214
+ if (
215
+ selfValidated.has(lock) ||
216
+ (prefix !== undefined && store !== undefined && store.getConfigEval(prefix) !== null)
217
+ ) {
191
218
  return entries.map((e) => e.config)
192
219
  }
193
220
  const keys = prefix === undefined ? undefined : metas.map((m) => `${prefix}\0${m.name}`)
@@ -261,6 +261,11 @@ export interface ApplyMigrationArgs {
261
261
  * and compiled the config into its dist (TanStack/query, 2026-09-11).
262
262
  */
263
263
  format?: MigrationFormat
264
+ /**
265
+ * With `dry`: one JSON document on stdout (`schemas/init.json`) in place
266
+ * of the files and the report, so an agent reads the plan as data.
267
+ */
268
+ json?: boolean
264
269
  }
265
270
 
266
271
  export type MigrationFormat = 'ts' | 'mjs'
@@ -360,11 +365,11 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
360
365
  const existing = metas.find((m) => m.dir === p.dir)?.configPath
361
366
  if (existing && existing !== path.join(p.dir, configName)) replaced.push(existing)
362
367
  }
363
- if (dry) {
368
+ if (dry && args.json !== true) {
364
369
  for (const f of files) {
365
370
  process.stdout.write(`── ${f.relPath} ──\n${f.contents}\n`)
366
371
  }
367
- } else {
372
+ } else if (!dry) {
368
373
  for (const f of files) await Bun.write(f.abs, f.contents)
369
374
  for (const f of replaced) await unlink(f)
370
375
  }
@@ -476,7 +481,29 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
476
481
  ...(inGitWorkTree(root) ? [] : ['git init']),
477
482
  ...(empty ? [`declare a task as the example shows`] : []),
478
483
  ]
479
- report.push('', `next: ${steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`}`)
484
+ const next = steps.length === 0 ? run : `${steps.join(', ')}, then ${run}`
485
+ if (args.json === true) {
486
+ process.stdout.write(
487
+ `${JSON.stringify({
488
+ dry,
489
+ source,
490
+ files: files.map((f) => ({ path: f.relPath, contents: f.contents })),
491
+ kept,
492
+ replaced: replaced.map((f) => relPosix(root, f)),
493
+ todos: [...todos].map(([reason, tasks]) => ({ reason, tasks })),
494
+ notes: [
495
+ ...(empty && unreached.length > 0 ? [unreachedHint(unreached, root)] : []),
496
+ ...(empty ? noMembers : []),
497
+ ...(args.notes ?? []),
498
+ ...plan.headerNotes,
499
+ ...plan.notes,
500
+ ],
501
+ next,
502
+ })}\n`,
503
+ )
504
+ return 0
505
+ }
506
+ report.push('', `next: ${next}`)
480
507
  process.stdout.write(`${report.join('\n')}\n`)
481
508
  return 0
482
509
  }