@vzn/vx 0.0.644 → 0.0.646

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 (83) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +22 -48
  3. package/package.json +5 -5
  4. package/schemas/init.json +5 -0
  5. package/schemas/last.json +15 -0
  6. package/skills/vx/SKILL.md +1 -1
  7. package/src/bin.ts +1 -1
  8. package/src/cache/archive.ts +12 -76
  9. package/src/cache/cache.ts +72 -42
  10. package/src/cache/git-inputs.ts +55 -10
  11. package/src/cache/index.ts +5 -2
  12. package/src/cache/inputs.ts +61 -11
  13. package/src/cache/layer.ts +30 -8
  14. package/src/cache/layered-cache.ts +138 -44
  15. package/src/cache/output-index.ts +25 -11
  16. package/src/cache/plugin-cache.ts +9 -14
  17. package/src/cache/remote-lane.ts +60 -22
  18. package/src/cache/run-history.ts +13 -6
  19. package/src/cache/schema.ts +19 -2
  20. package/src/cache/scoped-cache.ts +3 -80
  21. package/src/cli/core-alias.ts +5 -2
  22. package/src/cli/help.ts +8 -10
  23. package/src/cli/index.ts +0 -12
  24. package/src/cli/init.ts +159 -25
  25. package/src/cli/last.ts +4 -2
  26. package/src/cli/plan-format.ts +6 -2
  27. package/src/cli/plugin-commands.ts +98 -6
  28. package/src/cli/plugin-templates.ts +2 -2
  29. package/src/cli/run.ts +2 -8
  30. package/src/cli/select.ts +5 -3
  31. package/src/cli/upgrade.ts +5 -6
  32. package/src/config.ts +2 -4
  33. package/src/exec/executor.ts +112 -9
  34. package/src/exec/index.ts +2 -0
  35. package/src/exec/proc-sample.ts +67 -5
  36. package/src/exec/runner.ts +4 -0
  37. package/src/exec/sandbox-runtime.ts +11 -4
  38. package/src/graph/scheduler.ts +59 -8
  39. package/src/history/history.ts +119 -7
  40. package/src/history/metrics.ts +53 -16
  41. package/src/index.ts +16 -1
  42. package/src/orchestrator/affected-tasks.ts +0 -18
  43. package/src/orchestrator/execute-args.ts +3 -0
  44. package/src/orchestrator/execute-task.ts +28 -7
  45. package/src/orchestrator/held-servers.ts +86 -0
  46. package/src/orchestrator/hit-restore.ts +11 -4
  47. package/src/orchestrator/index.ts +18 -1
  48. package/src/orchestrator/local-floor.ts +104 -0
  49. package/src/orchestrator/local-shortcircuit.ts +26 -7
  50. package/src/orchestrator/miss-save.ts +10 -4
  51. package/src/orchestrator/options.ts +0 -8
  52. package/src/orchestrator/persistent-task.ts +14 -0
  53. package/src/orchestrator/placement.ts +84 -9
  54. package/src/orchestrator/plan.ts +7 -1
  55. package/src/orchestrator/plugin-host.ts +22 -7
  56. package/src/orchestrator/plugin.ts +48 -7
  57. package/src/orchestrator/prepare.ts +14 -49
  58. package/src/orchestrator/run-context.ts +39 -1
  59. package/src/orchestrator/run-keys.ts +164 -0
  60. package/src/orchestrator/run-records.ts +6 -0
  61. package/src/orchestrator/run.ts +95 -6
  62. package/src/orchestrator/sandbox-request.ts +9 -3
  63. package/src/orchestrator/task-artifacts.ts +142 -0
  64. package/src/orchestrator/task-hash.ts +12 -4
  65. package/src/orchestrator/task-process.ts +16 -0
  66. package/src/orchestrator/telemetry-host.ts +2 -1
  67. package/src/orchestrator/telemetry.ts +60 -17
  68. package/src/report/forecast.ts +6 -27
  69. package/src/report/framed-output.ts +41 -5
  70. package/src/report/logger.ts +60 -20
  71. package/src/report/status-line.ts +38 -18
  72. package/src/report/summary.ts +75 -9
  73. package/src/settings.ts +8 -7
  74. package/src/util/colors.ts +16 -1
  75. package/src/util/index.ts +1 -0
  76. package/src/util/paths.ts +30 -0
  77. package/src/util/ui.ts +4 -1
  78. package/src/workspace/affected.ts +0 -39
  79. package/src/workspace/config-bytecode.ts +276 -0
  80. package/src/workspace/index.ts +0 -2
  81. package/src/workspace/migration.ts +4 -32
  82. package/src/workspace/project-loader.ts +92 -14
  83. package/src/cli/foreign-flags.ts +0 -727
@@ -10,10 +10,12 @@ import { isGroupTask } from '../graph/index.js'
10
10
  import {
11
11
  barWidth,
12
12
  ERROR,
13
+ formatBytes,
13
14
  formatElapsed,
14
15
  gradientRule,
15
16
  paint,
16
17
  SUCCESS,
18
+ ui,
17
19
  WARN,
18
20
  type ColorSupport,
19
21
  } from '../util/index.js'
@@ -26,6 +28,7 @@ const LOCAL = '#38bdf8' // sky-400
26
28
  const REMOTE = '#2563eb' // blue-600
27
29
  const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
28
30
  const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
31
+ const RUNNING = '#06b6d4' // cyan-500: ui's info
29
32
  const ACCENT = '#06b6d4' // cyan-500: the live region's running word
30
33
 
31
34
  /**
@@ -84,6 +87,11 @@ export interface SummaryStats {
84
87
  aborted?: number
85
88
  /** Reached by the stop before they ran. Not in `total`. */
86
89
  notRun?: number
90
+ /** Live: running now, and handed to an executor whose machine has not begun them. */
91
+ running?: number
92
+ scheduled?: number
93
+ /** Live: not started because a dependency has not finished. */
94
+ blocked?: number
87
95
  total: number
88
96
  upToDate: number
89
97
  restoredLocal: number
@@ -119,13 +127,20 @@ export type CacheBucket =
119
127
  | 'restoredLocal'
120
128
  | 'restoredRemote'
121
129
  | 'saved'
130
+ | 'notSaved'
122
131
 
123
132
  /** A finished task's own cache bucket; none for a group, a skip or an abort. */
124
133
  export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
125
134
  if (isGroupTask(o.node)) return undefined
126
135
  switch (o.status) {
127
136
  case 'success':
128
- return ranNoCache(o) ? 'noCache' : o.saved === true ? 'saved' : 'miss'
137
+ return ranNoCache(o)
138
+ ? 'noCache'
139
+ : o.saved === true
140
+ ? 'saved'
141
+ : ranUnsaved(o)
142
+ ? 'notSaved'
143
+ : 'miss'
129
144
  case 'failed':
130
145
  return ranNoCache(o) ? 'noCache' : 'miss'
131
146
  case 'cache-hit':
@@ -148,8 +163,12 @@ type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', n
148
163
  */
149
164
  function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets {
150
165
  const saved = layers === undefined ? 0 : (stats.saved ?? 0)
166
+ // Its own yellow segment, as its legend count is: in the misses' red it
167
+ // was a cell with no count under it (M, 2026-10-21).
168
+ const notSaved = layers === undefined ? 0 : (stats.notSaved ?? 0)
151
169
  const b: Buckets = {
152
- miss: stats.miss - saved,
170
+ miss: stats.miss - saved - notSaved,
171
+ notSaved,
153
172
  noCache: stats.noCache ?? 0,
154
173
  upToDate: stats.upToDate,
155
174
  restoredLocal: stats.restoredLocal,
@@ -182,6 +201,10 @@ export interface RunContext {
182
201
  packageCount: number
183
202
  /** The run's whole id, as `vx last` takes it; shown on the `info` row. */
184
203
  runId?: string | undefined
204
+ /** The executors' own footer rows (`TaskExecutor.footer`), above the `info` row. */
205
+ executorRows?: () => readonly (readonly [string, ui.Inline])[]
206
+ /** Where a running task runs, `null` while it waits for a machine (`TaskExecutor.running`): its live row. */
207
+ whereRunning?: (taskId: string) => string | null | undefined
185
208
  /** Worker-pool size for this run; shown on the `info` row. */
186
209
  concurrency?: number
187
210
  remoteCacheEnabled: boolean
@@ -203,6 +226,9 @@ export interface RunContext {
203
226
  readonly inFlight: number
204
227
  readonly keys: ReadonlyMap<string, { uploading: number; failures: readonly string[] }>
205
228
  }
229
+ /** Bytes the run's remote hits pulled, and its landed uploads pushed (live refs). */
230
+ downloads?: { readonly bytes: number }
231
+ uploads?: { readonly bytes: number }
206
232
  /** Tasks whose outputs are still in the remote store. */
207
233
  notDownloaded: () => number
208
234
  }
@@ -222,7 +248,15 @@ export function formatSummarySection(
222
248
  const left = stats.left ?? 0
223
249
  const aborted = stats.aborted ?? 0
224
250
  const notRun = stats.notRun ?? 0
225
- const remainder = { n: left, color: '', glyph: '\u25b1', dim: true }
251
+ const running = stats.running ?? 0
252
+ const scheduled = stats.scheduled ?? 0
253
+ const blocked = stats.blocked ?? 0
254
+ const remainder = {
255
+ n: Math.max(0, left - running - scheduled - blocked),
256
+ color: '',
257
+ glyph: '\u25b1',
258
+ dim: true,
259
+ }
226
260
 
227
261
  const dim = (txt: string) => paint('', txt, colors, { dim: true })
228
262
  const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
@@ -239,6 +273,9 @@ export function formatSummarySection(
239
273
  if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
240
274
  if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
241
275
  if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
276
+ if (running > 0) taskParts.push(paint(RUNNING, `${running} running`, colors))
277
+ if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
278
+ if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
242
279
  if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
243
280
  const taskBar = segmentBar(
244
281
  [
@@ -247,6 +284,9 @@ export function formatSummarySection(
247
284
  { n: stats.skipped, color: WARN },
248
285
  { n: aborted, color: ABORTED },
249
286
  { n: notRun, color: NOT_RUN },
287
+ { n: running, color: RUNNING },
288
+ { n: scheduled, color: '', dim: true },
289
+ { n: blocked, color: '', dim: true },
250
290
  remainder,
251
291
  ],
252
292
  colors,
@@ -306,6 +346,7 @@ export function formatSummarySection(
306
346
  const cacheBar = segmentBar(
307
347
  [
308
348
  { n: b.miss, color: ERROR },
349
+ { n: b.notSaved, color: WARN },
309
350
  { n: b.noCache, color: '', dim: true },
310
351
  { n: b.upToDate, color: SUCCESS },
311
352
  { n: b.restoredLocal, color: LOCAL },
@@ -335,16 +376,24 @@ export function formatSummarySection(
335
376
  ]
336
377
  // The layer words are dim; only the counts carry colour (M, 2026-10-15).
337
378
  if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
379
+ // Each transfer count is followed by its bytes, dim: what the
380
+ // remote moved is the cost a hit or an upload paid.
381
+ const sized = (count: number, glyph: string, bytes: number | undefined): string[] =>
382
+ count === 0
383
+ ? []
384
+ : bytes === undefined || bytes === 0
385
+ ? n(count, glyph, REMOTE)
386
+ : [...n(count, glyph, REMOTE), dim(formatBytes(bytes))]
338
387
  const remote = [
339
- ...n(b.restoredRemote, '\u2193', REMOTE),
340
- ...n(b.uploaded, '\u2191', REMOTE),
388
+ ...sized(b.restoredRemote, '\u2193', layers.downloads?.bytes),
389
+ ...sized(b.uploaded, '\u2191', layers.uploads?.bytes),
341
390
  ...n(b.remoteFailed, '\u2717', ERROR),
342
391
  ...n(b.uploading, '\u21e1', ACCENT),
343
392
  ]
344
393
  if (layers.remote && remote.length > 0)
345
394
  cacheParts.push(`${dim('remote')} ${remote.join(' ')}`)
346
- if ((stats.notSaved ?? 0) > 0)
347
- cacheParts.push(paint(WARN, `${stats.notSaved} not saved`, colors))
395
+ if (b.miss > 0) cacheParts.push(paint(ERROR, `${b.miss} miss`, colors))
396
+ if (b.notSaved > 0) cacheParts.push(paint(WARN, `${b.notSaved} not saved`, colors))
348
397
  const notDownloaded = layers.notDownloaded()
349
398
  if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
350
399
  } else {
@@ -398,10 +447,27 @@ export function formatSummarySection(
398
447
  // Run-shape footer (final summary only): worker pool + cache mode,
399
448
  // grouped with time under a blank line below the meters.
400
449
  if (context !== undefined) {
450
+ const own = context.executorRows?.() ?? []
451
+ if (own.length > 0)
452
+ lines.push('', ...own.map(([label, text]) => row(label, ui.inline(text, colors))))
401
453
  const info: string[] = []
402
- if (context.concurrency !== undefined)
454
+ // An executor that says where tasks ran says it better than the pool.
455
+ if (context.concurrency !== undefined && own.length === 0)
403
456
  info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
404
- info.push(context.remoteCacheEnabled ? 'local + remote cache' : 'local cache')
457
+ const layers = context.cache
458
+ info.push(
459
+ layers === undefined
460
+ ? context.remoteCacheEnabled
461
+ ? 'local + remote cache'
462
+ : 'local cache'
463
+ : layers.local && layers.remote
464
+ ? 'local + remote cache'
465
+ : layers.local
466
+ ? 'local cache'
467
+ : layers.remote
468
+ ? 'remote cache'
469
+ : 'no cache',
470
+ )
405
471
  if (context.cacheScope !== undefined) info.push(`scope ${context.cacheScope}`)
406
472
  // The plugin's hand on the run: a task a policy held with a worker
407
473
  // free would otherwise have started; without the line nothing says
package/src/settings.ts CHANGED
@@ -50,6 +50,13 @@ export const SETTINGS: readonly Setting[] = [
50
50
  default: "origin/HEAD's branch, then main, then master",
51
51
  doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`.',
52
52
  },
53
+ {
54
+ path: 'affectedBase',
55
+ kind: { type: 'string' },
56
+ default: "a plugin's base commit, else the merge base with `baseBranch`",
57
+ doc: "The commit a bare `--affected` compares with, on the base branch too. `--affected=<base>` beats it; it beats a plugin's base commit. Env only: a committed base would be stale on the next push.",
58
+ config: false,
59
+ },
53
60
  {
54
61
  path: 'run.concurrency',
55
62
  kind: { type: 'concurrency' },
@@ -127,7 +134,7 @@ export const SETTINGS: readonly Setting[] = [
127
134
  path: 'cache.scope',
128
135
  kind: { type: 'string' },
129
136
  default: "the branch's, `<owner>/<repo>:<branch>`, with a remote cache off `baseBranch`",
130
- doc: "A branch's remote entries (`'vznjs/vx:feat-x'`): tasks the branch changed read the scope's key, the rest the default key; the local cache never uses it. Empty: no scope.",
137
+ doc: "The branch scope plugin entries are keyed under (`'vznjs/vx:feat-x'`); task keys never carry it. Empty: no scope.",
131
138
  config: false,
132
139
  },
133
140
  {
@@ -192,12 +199,6 @@ export const SETTINGS: readonly Setting[] = [
192
199
  default: 'true',
193
200
  doc: 'Keep a larger entry as its plain files, which a hit copies or clones out; off, as one `.tar.zst`.',
194
201
  },
195
- {
196
- path: 'cache.store.verify',
197
- kind: { type: 'enum', values: ['stat', 'hash', 'none'] },
198
- default: 'stat',
199
- doc: "What a hit on plain files checks first: each file's size and mtime, every byte, or nothing.",
200
- },
201
202
  {
202
203
  path: 'cache.upload.failures',
203
204
  kind: { type: 'enum', values: ['ci', 'local', 'always'] },
@@ -34,6 +34,7 @@ export interface PaintOptions {
34
34
  * NO_COLOR non-empty → off (overrides FORCE_COLOR)
35
35
  * FORCE_COLOR=0 or =false → off, even on a TTY
36
36
  * FORCE_COLOR any other value → on (the empty string too)
37
+ * a CI whose log renders ANSI (`ciPaints`) → on
37
38
  * else: on iff stdout is a TTY that is not `TERM=dumb`.
38
39
  * `0` and `false` are the convention's "off" (supports-color, chalk,
39
40
  * Node); reading every non-empty value as on painted a piped CI log that
@@ -45,7 +46,7 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
45
46
  ? false
46
47
  : force !== undefined
47
48
  ? force !== '0' && force !== 'false'
48
- : stream.isTTY === true && !dumbTerminal()
49
+ : ciPaints() || (stream.isTTY === true && !dumbTerminal())
49
50
  return {
50
51
  enabled,
51
52
  get columns() {
@@ -54,6 +55,20 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
54
55
  }
55
56
  }
56
57
 
58
+ /**
59
+ * A CI whose log viewer renders ANSI colour though the job's output is a
60
+ * pipe (supports-color reads the same variables): its log was plain.
61
+ */
62
+ function ciPaints(): boolean {
63
+ const env = process.env
64
+ return (
65
+ env['GITHUB_ACTIONS'] === 'true' ||
66
+ env['GITLAB_CI'] === 'true' ||
67
+ env['BUILDKITE'] === 'true' ||
68
+ env['CIRCLECI'] === 'true'
69
+ )
70
+ }
71
+
57
72
  /**
58
73
  * `TERM=dumb` (Emacs `M-x shell`, some CI log viewers) can neither paint
59
74
  * nor move the cursor: vx prints to it as off a terminal.
package/src/util/index.ts CHANGED
@@ -72,6 +72,7 @@ export {
72
72
  anyTaskGlob,
73
73
  isInstalledPath,
74
74
  taskGlob,
75
+ outputDirPrefixes,
75
76
  wholeSubtreePrefixes,
76
77
  slashBraceExpansions,
77
78
  braceAlternatives,
package/src/util/paths.ts CHANGED
@@ -117,6 +117,36 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
117
117
  return [...new Set(out)]
118
118
  }
119
119
 
120
+ /**
121
+ * The directories whose snapshot covers every path the positive `globs`
122
+ * can match, or `null` when one reaches outside the project. A
123
+ * whole-subtree glob or a literal is its own tree
124
+ * ({@link wholeSubtreePrefixes}); any other glob is the tree under its
125
+ * static prefix, and a root-anchored one (`**\/dist/**`, Turbo's shape) is
126
+ * the whole project, `''`. Sound for the same reason: a file added or
127
+ * removed anywhere under a recorded directory bumps a recorded mtime. The
128
+ * snapshot's file list is then held to the entry's rows through the globs
129
+ * themselves, so a source file beside the outputs is no stray.
130
+ */
131
+ export function outputDirPrefixes(globs: readonly string[]): string[] | null {
132
+ if (globs.length === 0) return null
133
+ const dirs: string[] = []
134
+ for (const g of globs) {
135
+ const whole = wholeSubtreePrefixes([g])?.[0]
136
+ if (whole !== undefined) {
137
+ dirs.push(whole)
138
+ continue
139
+ }
140
+ const prefix = staticPrefix(g)
141
+ if (prefix === '.') return ['']
142
+ if (prefix.startsWith('/') || prefix.startsWith('!') || prefix.split('/').includes('..'))
143
+ return null
144
+ dirs.push(prefix)
145
+ }
146
+ const sorted = [...new Set(dirs)].sort()
147
+ return sorted.filter((d, i) => !sorted.slice(0, i).some((a) => d.startsWith(`${a}/`)))
148
+ }
149
+
120
150
  /**
121
151
  * The spellings a reader, Turbo and `.gitignore` all accept but a matcher
122
152
  * fed the raw string turns into NOTHING — and a task keyed on nothing
package/src/util/ui.ts CHANGED
@@ -40,10 +40,13 @@ export const path = (text: string): Inline => ({ style: 'bold', text })
40
40
  export const cmd = (text: string): Inline => ({ style: 'bold', text })
41
41
  /** A secondary fact. */
42
42
  export const dim = (text: string): Inline => ({ style: 'dim', text })
43
+ /** Where a task runs (`small#1`): its name's hue in every row and block; `local` plain. */
44
+ export const place = (text: string): Inline => (text === 'local' ? text : id(text))
43
45
  /** Text in a tone's color. */
44
46
  export const tone = (t: Tone, text: string): Inline => ({ style: t, text })
45
47
 
46
- function inline(x: Inline, c: ColorSupport): string {
48
+ /** One inline as text, colored for `c`. */
49
+ export function inline(x: Inline, c: ColorSupport): string {
47
50
  if (typeof x === 'string') return x
48
51
  if (Array.isArray(x)) return (x as readonly Inline[]).map((p) => inline(p, c)).join('')
49
52
  const { style, text } = x as { style: Style; text: string }
@@ -14,7 +14,6 @@ import {
14
14
  gitSpawnRefusal,
15
15
  isExecutableMissing,
16
16
  isInstalledPath,
17
- isUserError,
18
17
  notAWorkTree,
19
18
  relPosix,
20
19
  UserError,
@@ -741,44 +740,6 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
741
740
  return undefined
742
741
  }
743
742
 
744
- /**
745
- * What a branch changed, for which of its tasks read its cache scope
746
- * (owner, 2026-10-17): from `base` (a plugin's: a pull request's base
747
- * commit), else from the merge base with the base branch. No base, or
748
- * one git cannot resolve (a CI checkout at `fetch-depth: 1`), is no
749
- * change: every task reads the default key, which costs hits, never
750
- * correctness, since a key must still match.
751
- */
752
- export async function branchChanges(
753
- workspaceRoot: string,
754
- base: string | undefined,
755
- baseBranch: string | undefined,
756
- projects: Promise<readonly ProjectMeta[]>,
757
- /** The run's own enumeration, so the diff walks nothing a second time. */
758
- walked: Pick<AffectedArgs, 'untracked' | 'links'> = {},
759
- ): Promise<AffectedChanges> {
760
- const none: AffectedChanges = {
761
- projects: new Set(),
762
- changed: [],
763
- paths: new Map(),
764
- whole: new Set(),
765
- }
766
- const since = base ?? (await baseBranchRef(workspaceRoot, baseBranch))?.ref
767
- if (since === undefined) return none
768
- try {
769
- return await affectedChanges({
770
- workspaceRoot,
771
- since,
772
- projects,
773
- configImports: false,
774
- ...walked,
775
- })
776
- } catch (err) {
777
- if (!isUserError(err)) throw err
778
- return none
779
- }
780
- }
781
-
782
743
  /**
783
744
  * The base branch as a ref: `origin/<name>`, else the local branch (a
784
745
  * clone with no remote), and whether HEAD is on it. The name is the
@@ -0,0 +1,276 @@
1
+ // Every run evaluates `.vx/workspace.ts`, and with it every plugin package
2
+ // it imports: Bun parses and compiles that TypeScript on each run, and a
3
+ // plugin's functions compile again on their first call, while core itself
4
+ // runs from the binary's bytecode. On a solid-sized workspace that was
5
+ // ~4 ms of a ~60 ms warm run (the `discover` stage alone 9 → 5 ms,
6
+ // 2026-10-22). So the config's module graph is bundled once into CommonJS
7
+ // with Bun's bytecode under `.vx/cache/compiled/`, and a later run whose
8
+ // every input still has the bytes it was built from requires that instead.
9
+ //
10
+ // The bundle is built from the bytes it records, file by file, and is used
11
+ // only while each recorded file still hashes the same and each bare import
12
+ // still resolves to the file it did; anything else loads the source as
13
+ // before and builds again. A graph the bundle could run differently is
14
+ // never built: an `import.meta` field beyond its location, a `require` or
15
+ // `import()` of a computed specifier (relative to the bundle, not the
16
+ // file), top-level await, a CommonJS config, a file this process already
17
+ // evaluated (a bundled copy would be a second state; run from source, core
18
+ // itself). The bundle goes
19
+ // into `.vx/cache` only once a run made it, so a reading verb on a fresh
20
+ // workspace writes nothing (item 900). `.vx/cache` is trusted as the rest
21
+ // of the cache is: what it holds is restored into the tree too.
22
+
23
+ import {
24
+ existsSync,
25
+ mkdirSync,
26
+ readdirSync,
27
+ readFileSync,
28
+ realpathSync,
29
+ renameSync,
30
+ rmSync,
31
+ } from 'node:fs'
32
+ import { writeFile } from 'node:fs/promises'
33
+ import { createRequire } from 'node:module'
34
+ import path from 'node:path'
35
+ import { xxh3hex } from '../util/index.js'
36
+
37
+ /** Bumped when what a bundle holds, or how it is checked, changes. */
38
+ const COMPILED_VERSION = 1
39
+
40
+ /** What `import.meta` becomes in a bundled file: an identifier no source spells. */
41
+ const META = '__vxImportMeta'
42
+ const META_FIELDS = new Set(['dir', 'dirname', 'path', 'filename', 'file', 'url', 'main'])
43
+ const META_USE = new RegExp(`\\b${META}\\b(\\.[\\w$]+)?`, 'g')
44
+ const COMPUTED_LOAD = /\b(?:require|import)\(\s*(?![\s'"])|\brequire\.resolve\b/
45
+
46
+ interface Manifest {
47
+ version: number
48
+ /** The config file by real path; its bytes are in the bundle's name, so it is not read again (principle 9). */
49
+ entry: string
50
+ /** Every file the bundle was built from, by real path, and its bytes' hash. */
51
+ inputs: Record<string, string>
52
+ /** Each bare import: the importing file's directory, the specifier, the real path it resolved to. */
53
+ bare: [string, string, string][]
54
+ /** The graph cannot be bundled faithfully: load the source while the inputs stay as they are. */
55
+ skip?: true
56
+ }
57
+
58
+ function compiledDir(root: string): string {
59
+ return path.join(root, '.vx', 'cache', 'compiled')
60
+ }
61
+
62
+ function nameOf(configPath: string, bytes: Uint8Array): string {
63
+ return `workspace-${xxh3hex(`${COMPILED_VERSION}\0${Bun.revision}\0${configPath}\0${xxh3hex(bytes)}`)}`
64
+ }
65
+
66
+ function fresh(manifest: Manifest): boolean {
67
+ for (const [file, hash] of Object.entries(manifest.inputs)) {
68
+ if (file === manifest.entry) continue
69
+ let bytes: Uint8Array
70
+ try {
71
+ bytes = readFileSync(file)
72
+ } catch {
73
+ return false
74
+ }
75
+ if (xxh3hex(bytes) !== hash) return false
76
+ }
77
+ for (const [from, spec, to] of manifest.bare) {
78
+ try {
79
+ if (realpathSync(Bun.resolveSync(spec, from)) !== to) return false
80
+ } catch {
81
+ return false
82
+ }
83
+ }
84
+ return true
85
+ }
86
+
87
+ /**
88
+ * The workspace config's module namespace from its bundle: `undefined` when
89
+ * there is none for these bytes or an input moved (`build` then makes one),
90
+ * `null` when the graph is one no bundle runs faithfully.
91
+ */
92
+ export function loadCompiled(
93
+ root: string,
94
+ configPath: string,
95
+ bytes: Uint8Array,
96
+ ): { default?: unknown } | null | undefined {
97
+ // A repeat load in this process (the run after the CLI's selection pass,
98
+ // a `vx watch` cycle) of the same bytes: the loader already refused an
99
+ // import edited since the first, so the answer stands (principle 9).
100
+ const seen = answered.get(configPath)
101
+ if (seen !== undefined && Buffer.from(seen.bytes).equals(bytes)) return seen.ns
102
+ const name = nameOf(configPath, bytes)
103
+ const dir = compiledDir(root)
104
+ let manifest: Manifest
105
+ try {
106
+ manifest = JSON.parse(readFileSync(path.join(dir, `${name}.json`), 'utf8')) as Manifest
107
+ } catch {
108
+ return undefined
109
+ }
110
+ if (manifest.version !== COMPILED_VERSION || !fresh(manifest)) return undefined
111
+ const ns =
112
+ manifest.skip === true
113
+ ? null
114
+ : (createRequire(path.join(dir, 'index.js'))(path.join(dir, `${name}.js`)) as {
115
+ default?: unknown
116
+ })
117
+ answered.set(configPath, { bytes, ns })
118
+ return ns
119
+ }
120
+
121
+ /** What `loadCompiled` answered for each config, by the bytes it was asked with. */
122
+ const answered = new Map<string, { bytes: Uint8Array; ns: { default?: unknown } | null }>()
123
+
124
+ /**
125
+ * The files this process has evaluated as modules. One the config imports
126
+ * by path (run from source, core's own `src/index.ts`) is shared with the
127
+ * process, and a bundle would run a second copy of its state.
128
+ */
129
+ export function loadedModules(root: string): Set<string> {
130
+ // Not `import.meta`: core's planner also runs as a browser script (the playground).
131
+ return new Set(Object.keys(createRequire(path.join(root, 'index.js')).cache))
132
+ }
133
+
134
+ /** The loader Bun reads `file` with, or null for a file the bundle must not hold. */
135
+ function loaderOf(file: string): 'ts' | 'tsx' | 'js' | 'jsx' | null {
136
+ const ext = path.extname(file)
137
+ if (ext === '.ts' || ext === '.mts') return 'ts'
138
+ if (ext === '.tsx') return 'tsx'
139
+ if (ext === '.js' || ext === '.mjs') return 'js'
140
+ if (ext === '.jsx') return 'jsx'
141
+ return null
142
+ }
143
+
144
+ /**
145
+ * Bundle the workspace config's graph for the next run. A speed-up only:
146
+ * every failure leaves the next run loading the source.
147
+ */
148
+ export async function buildCompiled(
149
+ root: string,
150
+ configPath: string,
151
+ bytes: Uint8Array,
152
+ /** `loadedModules()` before the config's source load. */
153
+ loaded: ReadonlySet<string>,
154
+ /** What that load evaluated: a repeat load in this process gets it, not a bundle or a second build. */
155
+ ns: { default?: unknown },
156
+ ): Promise<void> {
157
+ answered.set(configPath, { bytes, ns })
158
+ const dir = compiledDir(root)
159
+ if (!existsSync(path.dirname(dir))) return
160
+ const name = nameOf(configPath, bytes)
161
+ const entryFile = realpathSync(configPath)
162
+ const inputs: Record<string, string> = {}
163
+ let faithful = true
164
+ const transpilers = new Map<string, Bun.Transpiler>()
165
+ const tmp = path.join(dir, `.tmp-${process.pid}-${Date.now()}`)
166
+ try {
167
+ mkdirSync(tmp, { recursive: true })
168
+ const built = await Bun.build({
169
+ entrypoints: [configPath],
170
+ target: 'bun',
171
+ format: 'cjs',
172
+ bytecode: true,
173
+ external: ['@vzn/vx'],
174
+ outdir: tmp,
175
+ naming: `${name}.js`,
176
+ metafile: true,
177
+ throw: false,
178
+ plugins: [
179
+ {
180
+ name: 'vx-config-bytecode',
181
+ setup(build) {
182
+ build.onLoad({ filter: /\.[cm]?[jt]sx?$/ }, async (args) => {
183
+ const file = realpathSync(args.path)
184
+ if (loaded.has(file)) {
185
+ faithful = false
186
+ inputs[file] = xxh3hex(await Bun.file(file).bytes())
187
+ return { contents: '', loader: 'js' }
188
+ }
189
+ const loader = loaderOf(file)
190
+ const source = file === entryFile ? bytes : await Bun.file(file).bytes()
191
+ inputs[file] = xxh3hex(source)
192
+ if (loader === null || /\.c[jt]s$/.test(file)) {
193
+ faithful = false
194
+ return { contents: source, loader: loader ?? 'js' }
195
+ }
196
+ const fileDir = path.dirname(file)
197
+ let t = transpilers.get(file)
198
+ if (t === undefined) {
199
+ t = new Bun.Transpiler({
200
+ loader,
201
+ define: {
202
+ 'import.meta': META,
203
+ __dirname: JSON.stringify(fileDir),
204
+ __filename: JSON.stringify(file),
205
+ },
206
+ })
207
+ transpilers.set(file, t)
208
+ }
209
+ const js = t.transformSync(new TextDecoder().decode(source))
210
+ for (const m of js.matchAll(META_USE)) {
211
+ if (m[1] !== undefined && !META_FIELDS.has(m[1].slice(1))) faithful = false
212
+ }
213
+ if (COMPUTED_LOAD.test(js)) faithful = false
214
+ const meta = {
215
+ dir: fileDir,
216
+ dirname: fileDir,
217
+ path: file,
218
+ filename: file,
219
+ file: path.basename(file),
220
+ url: Bun.pathToFileURL(file).href,
221
+ main: false,
222
+ }
223
+ return { contents: `const ${META} = ${JSON.stringify(meta)};\n${js}`, loader: 'js' }
224
+ })
225
+ },
226
+ },
227
+ ],
228
+ })
229
+ const out = path.join(tmp, `${name}.js`)
230
+ const bytecode = built.success && Bun.file(`${out}.jsc`).size > 0
231
+ const manifest: Manifest = {
232
+ version: COMPILED_VERSION,
233
+ entry: entryFile,
234
+ inputs,
235
+ bare: bareImports(built.metafile),
236
+ ...(faithful && bytecode ? {} : { skip: true as const }),
237
+ }
238
+ // The metafile names every input; one no onLoad saw (a JSON import) is
239
+ // bytes the manifest does not check.
240
+ for (const [input, entry] of Object.entries(built.metafile?.inputs ?? {})) {
241
+ const file = realpathSync(path.resolve(input))
242
+ if (inputs[file] === undefined) manifest.skip = true
243
+ // A CommonJS config's export is `module.exports`, not a namespace's `default`.
244
+ if (file === entryFile && entry.format === 'cjs') manifest.skip = true
245
+ }
246
+ for (const old of readdirSync(dir)) {
247
+ if (old.startsWith('workspace-')) rmSync(path.join(dir, old), { force: true })
248
+ }
249
+ if (manifest.skip !== true) {
250
+ renameSync(`${out}.jsc`, path.join(dir, `${name}.js.jsc`))
251
+ renameSync(out, path.join(dir, `${name}.js`))
252
+ }
253
+ // Last: a manifest names a bundle already in place.
254
+ await writeFile(path.join(tmp, 'manifest.json'), JSON.stringify(manifest))
255
+ renameSync(path.join(tmp, 'manifest.json'), path.join(dir, `${name}.json`))
256
+ } catch {
257
+ // A read-only cache directory, a full disk, a graph Bun cannot bundle:
258
+ // the next run loads the source, as this one did.
259
+ } finally {
260
+ rmSync(tmp, { recursive: true, force: true })
261
+ }
262
+ }
263
+
264
+ function bareImports(metafile: Bun.BuildMetafile | undefined): [string, string, string][] {
265
+ const out: [string, string, string][] = []
266
+ for (const [input, entry] of Object.entries(metafile?.inputs ?? {})) {
267
+ const from = path.dirname(realpathSync(path.resolve(input)))
268
+ for (const imp of entry.imports) {
269
+ const spec = imp.original
270
+ if (imp.external === true || spec === undefined) continue
271
+ if (spec.startsWith('.') || path.isAbsolute(spec)) continue
272
+ out.push([from, spec, realpathSync(path.resolve(imp.path))])
273
+ }
274
+ }
275
+ return out
276
+ }
@@ -6,7 +6,6 @@ export {
6
6
  type AffectedChanges,
7
7
  affectedProjects,
8
8
  defaultAffectedBase,
9
- branchChanges,
10
9
  type FingerprintClaims,
11
10
  refIsHead,
12
11
  } from './affected.js'
@@ -78,5 +77,4 @@ export {
78
77
  type GeneratedTask,
79
78
  type MigrationPlan,
80
79
  type RawExpr,
81
- vxInvocation,
82
80
  } from './migration.js'