@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
@@ -1,7 +1,9 @@
1
+ import type { Dirent } from 'node:fs'
1
2
  import { readdir, stat } from 'node:fs/promises'
2
3
  import path from 'node:path'
3
4
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
4
- import { relPosix, UserError } from '../util/index.js'
5
+ import { BUN_GLOB_WILDCARDS, relPosix, UserError, normalizeBunGlob } from '../util/index.js'
6
+ import { type LoadReads, readOnce } from './load-reads.js'
5
7
 
6
8
  export interface PackageJson {
7
9
  name: string
@@ -37,7 +39,14 @@ export interface ProjectEntry {
37
39
  config: ProjectConfig
38
40
  }
39
41
 
40
- const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
42
+ export const PROJECT_CONFIG_FILENAMES = [
43
+ 'vx.config.ts',
44
+ 'vx.config.mts',
45
+ 'vx.config.js',
46
+ 'vx.config.mjs',
47
+ ]
48
+
49
+ const decoder = new TextDecoder()
41
50
 
42
51
  /**
43
52
  * Walk up from `start` to find the workspace root. A directory is a root
@@ -55,15 +64,20 @@ const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.c
55
64
  * When no candidate claims `start` — a standalone package, or a subdirectory
56
65
  * of a single-project repo — the nearest candidate wins (the root itself IS
57
66
  * the project). Throws a `UserError` when there is no candidate before `/`.
67
+ * A load that goes on to `loadWorkspace` passes its `reads`, so the
68
+ * root's manifest is read once for both.
58
69
  */
59
- export async function findWorkspaceRoot(start: string): Promise<string> {
70
+ export async function findWorkspaceRoot(
71
+ start: string,
72
+ reads: LoadReads = new Map(),
73
+ ): Promise<string> {
60
74
  let dir = path.resolve(start)
61
75
  const below: string[] = []
62
76
  let nearest: string | null = null
63
77
  while (true) {
64
78
  let globs: string[] | null
65
79
  try {
66
- globs = await readPackageGlobs(dir)
80
+ globs = await readPackageGlobs(dir, reads)
67
81
  } catch {
68
82
  // An unparseable manifest is still a root SIGNAL (the pre-existing
69
83
  // behaviour probed only for existence); it just can't claim members.
@@ -73,10 +87,19 @@ export async function findWorkspaceRoot(start: string): Promise<string> {
73
87
  if (globs !== null) {
74
88
  nearest ??= dir
75
89
  if (claimsMember(dir, below, globs)) return dir
90
+ // pnpm takes the nearest `pnpm-workspace.yaml` as the root, listed or
91
+ // not. Walking past it, `apps/inner` resolved to the outer workspace
92
+ // while `apps/inner/pkgs/x` resolved to the inner one: two roots and
93
+ // two caches for one tree (item 990). Already read: `reads` holds it.
94
+ if ((await readOnce(reads, path.join(dir, 'pnpm-workspace.yaml'))) !== null) return dir
76
95
  }
77
96
  const parent = path.dirname(dir)
78
97
  if (parent === dir) break
79
- below.push(dir)
98
+ // Only a directory holding a manifest can be a member: a glob that
99
+ // matched `packages/tools`, which has none, claimed the standalone
100
+ // package below it, and `packages/tools/standalone` ran as a stranger
101
+ // in a workspace that does not list it (item 989).
102
+ if (globs !== null) below.push(dir)
80
103
  dir = parent
81
104
  }
82
105
  if (nearest !== null) return nearest
@@ -89,8 +112,9 @@ export async function findWorkspaceRoot(start: string): Promise<string> {
89
112
  /** True when one of `below` (dirs under `root`, toward `start`) is a member. */
90
113
  function claimsMember(root: string, below: readonly string[], globs: readonly string[]): boolean {
91
114
  if (below.length === 0 || globs.length === 0) return false
92
- const rels = below.map((d) => relPosix(root, d))
93
- for (const pattern of globs) {
115
+ const { positive, negative } = splitPackageGlobs(globs)
116
+ const rels = below.map((d) => relPosix(root, d)).filter((rel) => !excludedBy(rel, negative))
117
+ for (const pattern of positive) {
94
118
  const normalized = pattern.replace(/\/+$/, '')
95
119
  // `.` means the root itself is the project — never a directory below it.
96
120
  if (normalized === '' || normalized === '.') continue
@@ -100,21 +124,75 @@ function claimsMember(root: string, below: readonly string[], globs: readonly st
100
124
  return false
101
125
  }
102
126
 
127
+ /**
128
+ * pnpm, npm, yarn and Bun all take `!packages/fixtures` in the package
129
+ * list. Handed to `Bun.Glob` raw, a leading `!` negates the WHOLE pattern,
130
+ * so `!packages/fixtures/package.json` matched every manifest in the tree
131
+ * and every directory holding one became a project — the excluded package
132
+ * ran under `--all`, and a fixture repeating a name killed the run with
133
+ * "Duplicate package name". Negations subtract from what the positive
134
+ * globs found; a literal one excludes its tree.
135
+ */
136
+ function splitPackageGlobs(globs: readonly string[]): { positive: string[]; negative: string[] } {
137
+ const positive: string[] = []
138
+ const negative: string[] = []
139
+ // `!./packages/legacy` and `!packages//legacy` excluded nothing, silently
140
+ // (2026-09-10): the same spellings the input globs normalize.
141
+ for (const raw of globs) {
142
+ // A member glob names directories, so a trailing slash says nothing:
143
+ // npm and pnpm read `packages/*/` as `packages/*`. `normalizeBunGlob`
144
+ // gives it a task glob's meaning, `/**`, and `packages/*/` found every
145
+ // example and fixture package at any depth below the members (item 985).
146
+ const g = normalizeBunGlob(raw.replace(/(.)\/+$/, '$1'))
147
+ if (g.startsWith('!')) negative.push(g.slice(1).replace(/\/+$/, ''))
148
+ else positive.push(g)
149
+ }
150
+ return { positive, negative }
151
+ }
152
+
153
+ function excludedBy(rel: string, negative: readonly string[]): boolean {
154
+ for (const neg of negative) {
155
+ if (neg.length === 0) continue
156
+ if (rel === neg || rel.startsWith(`${neg}/`)) return true
157
+ if (!BUN_GLOB_WILDCARDS.test(neg)) continue
158
+ if (new Bun.Glob(neg).match(rel)) return true
159
+ // pnpm matches the MANIFEST (`<pattern>/package.json`), so its documented
160
+ // `!**/test/**` excludes `packages/test` itself; matched against the
161
+ // directory, `**/test/**` needs something below `test` and the package
162
+ // stayed a member (item 986).
163
+ if (new Bun.Glob(`${neg}/package.json`).match(`${rel}/package.json`)) return true
164
+ }
165
+ return false
166
+ }
167
+
103
168
  /**
104
169
  * Package globs declared by `dir`, or `null` when `dir` is not a root
105
170
  * candidate. A bare `package.json` (no `workspaces`) is single-project mode:
106
171
  * the root itself is the only project, hence `['.']`.
107
172
  */
108
- async function readPackageGlobs(dir: string): Promise<string[] | null> {
173
+ async function readPackageGlobs(dir: string, reads?: LoadReads): Promise<string[] | null> {
109
174
  const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
110
- if (await Bun.file(yamlPath).exists()) {
111
- const parsed = (parseManifest(await Bun.file(yamlPath).text(), yamlPath, Bun.YAML.parse) ??
112
- {}) as { packages?: unknown }
113
- return assertGlobList(parsed.packages ?? [], yamlPath, 'packages')
175
+ const yaml = await readOnce(reads, yamlPath)
176
+ let yamlWithoutPackages = false
177
+ if (yaml !== null) {
178
+ const parsed = parseManifest(decoder.decode(yaml), yamlPath, Bun.YAML.parse)
179
+ if (parsed !== null && parsed !== undefined) {
180
+ if (typeof parsed !== 'object' || Array.isArray(parsed)) {
181
+ throw new UserError(`${yamlPath}: must be a mapping (\`packages:\` and pnpm's settings)`)
182
+ }
183
+ const packages = (parsed as { packages?: unknown }).packages
184
+ if (packages !== undefined) return assertGlobList(packages, yamlPath, 'packages')
185
+ }
186
+ // No `packages:`: pnpm 10 keeps its settings and catalogs in this file
187
+ // for a single-package repo too. The root's package.json decides, as it
188
+ // would without the file; read as an empty list, the root found zero
189
+ // projects and every verb ran nothing and exited 0 (item 984).
190
+ yamlWithoutPackages = true
114
191
  }
115
192
  const pkgPath = path.join(dir, 'package.json')
116
- if (!(await Bun.file(pkgPath).exists())) return null
117
- const pkg = parseManifest(await Bun.file(pkgPath).text(), pkgPath, JSON.parse) as PackageJson
193
+ const pkgBytes = await readOnce(reads, pkgPath)
194
+ if (pkgBytes === null) return yamlWithoutPackages ? [] : null
195
+ const pkg = parsePackageJson(decoder.decode(pkgBytes), pkgPath)
118
196
  const ws = pkg.workspaces as unknown
119
197
  if (ws === undefined || ws === null) return ['.']
120
198
  if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
@@ -127,6 +205,49 @@ async function readPackageGlobs(dir: string): Promise<string[] | null> {
127
205
  return assertGlobList(ws, pkgPath, 'workspaces')
128
206
  }
129
207
 
208
+ /**
209
+ * Package manifests the globs never reach, in single-project mode only: a
210
+ * root `package.json` with no `workspaces` field makes the root the one
211
+ * project, and a `packages/app/package.json` full of scripts beside it is
212
+ * then invisible — `vx init` said "no package.json scripts" and `vx run`
213
+ * said "run vx init" (2026-09-16). One shallow scan, two levels, on the
214
+ * failure path only; `node_modules` and dot directories are skipped.
215
+ */
216
+ export async function unreachedPackages(workspace: Workspace): Promise<string[]> {
217
+ if (workspace.packageGlobs.length !== 1 || workspace.packageGlobs[0] !== '.') return []
218
+ const found: string[] = []
219
+ // Two scans: Bun.Glob does not expand a brace whose alternatives hold a
220
+ // slash (`{*,*/*}/package.json` matched nothing, measured 2026-09-16).
221
+ for (const pattern of ['*/package.json', '*/*/package.json']) {
222
+ for await (const rel of new Bun.Glob(pattern).scan({
223
+ cwd: workspace.root,
224
+ onlyFiles: true,
225
+ dot: false,
226
+ })) {
227
+ const posix = rel.split(path.sep).join('/')
228
+ if (posix.startsWith('node_modules/') || posix.includes('/node_modules/')) continue
229
+ found.push(posix.slice(0, -'/package.json'.length))
230
+ }
231
+ }
232
+ return found.sort()
233
+ }
234
+
235
+ /** The line for `unreachedPackages`' finding: the cause, the packages, the glob to add. */
236
+ export function unreachedHint(unreached: readonly string[]): string {
237
+ const n = unreached.length
238
+ const shown = unreached.slice(0, 3).join(', ') + (n > 3 ? ` and ${n - 3} more` : '')
239
+ const globs = [
240
+ ...new Set(unreached.map((d) => (d.includes('/') ? `${path.posix.dirname(d)}/*` : d))),
241
+ ]
242
+ .map((g) => `"${g}"`)
243
+ .join(', ')
244
+ return (
245
+ `package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), so the root is the only project ` +
246
+ `and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}. ` +
247
+ `Add \`"workspaces": [${globs}]\` to package.json and re-run.`
248
+ )
249
+ }
250
+
130
251
  /**
131
252
  * Parse a workspace manifest, naming the FILE on failure. The raw parser
132
253
  * errors (`Failed to parse JSON`) carry no path, so in a 1000-package
@@ -141,6 +262,25 @@ function parseManifest(text: string, file: string, parse: (t: string) => unknown
141
262
  }
142
263
  }
143
264
 
265
+ /**
266
+ * A `package.json` as discovery reads it: a JSON object whose `name`, when
267
+ * present, is a string with no surrounding whitespace (npm refuses one).
268
+ * Parsed and cast unchecked, `null` crashed with a TypeError and a stack,
269
+ * `{"name":123}` planned `123#build`, and `" a"` beside `"a"` made two
270
+ * projects (item 988).
271
+ */
272
+ function parsePackageJson(text: string, file: string): PackageJson {
273
+ const pkg = parseManifest(text, file, JSON.parse)
274
+ if (pkg === null || typeof pkg !== 'object' || Array.isArray(pkg)) {
275
+ throw new UserError(`${file}: must be a JSON object`)
276
+ }
277
+ const name = (pkg as { name?: unknown }).name
278
+ if (name !== undefined && (typeof name !== 'string' || name.trim() !== name)) {
279
+ throw new UserError(`${file}: "name" must be a string with no surrounding whitespace`)
280
+ }
281
+ return pkg as PackageJson
282
+ }
283
+
144
284
  /**
145
285
  * A glob list is user input from a manifest: `packages: "packages/*"`
146
286
  * (a bare string instead of a list) is an easy YAML slip, and without
@@ -150,9 +290,46 @@ function assertGlobList(value: unknown, file: string, field: string): string[] {
150
290
  if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
151
291
  throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
152
292
  }
293
+ for (const pattern of value as string[]) {
294
+ if (EXTGLOB.test(pattern)) throw extglobRefusal(pattern, file, field)
295
+ }
153
296
  return value as string[]
154
297
  }
155
298
 
299
+ /**
300
+ * An extglob group: `!(…)`, `@(…)`, `+(…)`, `*(…)`, `?(…)`. npm (minimatch)
301
+ * and yarn (micromatch) read `packages/!(x)` as "every directory in
302
+ * packages/ but x"; `Bun.Glob` has no extglob, and its scan read the
303
+ * segment as a wildcard, so the excluded package became a project
304
+ * (turborepo#3766) while its match read the same text literally. vx cannot
305
+ * honour the pattern, so it refuses it rather than read it either way.
306
+ */
307
+ const EXTGLOB = /[!@+*?]\(/
308
+
309
+ function extglobRefusal(pattern: string, file: string, field: string): UserError {
310
+ const head = `${file}: \`${field}\` entry "${pattern}" is an extglob, which vx's glob engine does not read`
311
+ // The common shape, a whole segment excluding plain names, has an exact
312
+ // rewrite in the `!` entries every package manager takes.
313
+ const segments = pattern.split('/')
314
+ const at = segments.findIndex((s) => /^!\([^()]*\)$/.test(s))
315
+ const names = at === -1 ? [] : segments[at]!.slice(2, -1).split('|')
316
+ const plain = names.every((n) => n !== '' && !BUN_GLOB_WILDCARDS.test(n) && !/[()!@+]/.test(n))
317
+ const rest = segments.filter((_s, i) => i !== at).join('/')
318
+ if (at === -1 || !plain || EXTGLOB.test(rest)) {
319
+ return new UserError(
320
+ `${head}. List the members with \`*\`, braces or \`!\` exclusions instead.`,
321
+ )
322
+ }
323
+ const before = segments.slice(0, at)
324
+ const rewrite = [
325
+ [...before, '*', ...segments.slice(at + 1)].join('/'),
326
+ ...names.map((n) => `!${[...before, n].join('/')}`),
327
+ ]
328
+ return new UserError(
329
+ `${head} (npm and yarn read \`!(…)\` as an exclusion). List the exclusion as its own entry: [${rewrite.map((r) => JSON.stringify(r)).join(', ')}].`,
330
+ )
331
+ }
332
+
156
333
  /**
157
334
  * Resolve the cache directory for a workspace. Respects the user's
158
335
  * `defineWorkspace({ cacheDir })` override (relative to the workspace
@@ -173,8 +350,8 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
173
350
  * If a `package.json` exists with no `workspaces` field, the root
174
351
  * itself is treated as a single-project workspace.
175
352
  */
176
- export async function loadWorkspace(root: string): Promise<Workspace> {
177
- const packageGlobs = await readPackageGlobs(root)
353
+ export async function loadWorkspace(root: string, reads?: LoadReads): Promise<Workspace> {
354
+ const packageGlobs = await readPackageGlobs(root, reads)
178
355
  if (packageGlobs === null) {
179
356
  // Should be unreachable: findWorkspaceRoot only returns dirs that
180
357
  // pass at least one of the two existence checks.
@@ -188,12 +365,30 @@ export async function loadWorkspace(root: string): Promise<Workspace> {
188
365
  // path.
189
366
  const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
190
367
 
368
+ /**
369
+ * The directory each `<dir>/*` package glob names — where a member comes
370
+ * and goes as one directory entry, so `vx watch` can hear a package added
371
+ * or removed without walking anything. A glob of another shape (`apps/**`)
372
+ * has no such directory and contributes none.
373
+ */
374
+ export function memberBaseDirs(workspace: Workspace): string[] {
375
+ const bases = new Set<string>()
376
+ for (const pattern of splitPackageGlobs(workspace.packageGlobs).positive) {
377
+ const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
378
+ if (SIMPLE_STAR_RE.test(normalized)) {
379
+ bases.add(path.resolve(workspace.root, normalized.slice(0, -2)))
380
+ }
381
+ }
382
+ return [...bases]
383
+ }
384
+
191
385
  /**
192
386
  * The directories a workspace glob names. For the `<dir>/*` shape this is
193
387
  * one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
194
388
  * the cost (measured 2026-09-02: 25 ms → ~2 ms for 1000 members). A
195
- * symlinked member is followed when it points at a directory, as the glob
196
- * did. Dot-directories are skipped (`dot: false`), so are `node_modules`.
389
+ * symlinked member is followed when it points at a directory, and so is
390
+ * it by the scan any other shape takes, unless the glob holds `**` (item
391
+ * 987). Dot-directories are skipped (`dot: false`), so are `node_modules`.
197
392
  */
198
393
  async function memberDirs(root: string, pattern: string): Promise<string[]> {
199
394
  // Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
@@ -224,7 +419,12 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
224
419
  const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
225
420
  const glob = new Bun.Glob(globPattern)
226
421
  const dirs: string[] = []
227
- for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false })) {
422
+ // A linked member is a member, as the readdir path above has it:
423
+ // `packages/*` found `packages/b -> ../ext/b` while `packages/{a,b}` and
424
+ // `pack*/*` did not (item 987). Followed only where the depth is bounded:
425
+ // under `**` the scan would walk every pnpm `node_modules` link.
426
+ const followSymlinks = !normalized.includes('**')
427
+ for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false, followSymlinks })) {
228
428
  // Skip nested node_modules — workspace package globs shouldn't
229
429
  // ever reach into them, but a pathological pattern like `**`
230
430
  // would. Avoid splitting the path on the hot loop.
@@ -236,32 +436,67 @@ async function memberDirs(root: string, pattern: string): Promise<string[]> {
236
436
  }
237
437
 
238
438
  /**
239
- * The project's config file, by `CONFIG_FILENAMES` precedence, or null.
240
- * Stats the candidates in order rather than listing the directory: across
241
- * 1000 projects a readdir each cost 9.6 ms against 6.3 ms of stats even
242
- * with the config at the LAST name (measured 2026-09-03), and a `.ts`
243
- * config — the first name — pays one stat.
439
+ * The project's config file, by `PROJECT_CONFIG_FILENAMES` precedence, or null.
440
+ *
441
+ * Two shapes, one per platform, each the measured winner there across
442
+ * 1,000 projects with the manifest read in flight alongside:
443
+ * - macOS stats the candidates in order: a readdir each cost 9.6 ms
444
+ * against 6.3 ms of stats even with the config at the LAST name
445
+ * (2026-09-03), and a `.ts` config — the first name — pays one stat.
446
+ * - Linux lists the directory once: a readdir each is 12 ms against
447
+ * 43 ms of stats at the last name and ties the one-stat case
448
+ * (2026-09-09, a `vx.config.mjs` workspace; stats there cost a
449
+ * thread-pool round trip apiece).
450
+ * Both answer identically: a directory or dangling link under a config
451
+ * name is skipped for the next name, a symlink to a file counts.
244
452
  */
245
453
  async function findConfigFile(dir: string): Promise<string | null> {
246
- for (const name of CONFIG_FILENAMES) {
247
- const candidate = path.join(dir, name)
454
+ if (process.platform === 'linux') {
455
+ let entries: Dirent[]
248
456
  try {
249
- if ((await stat(candidate)).isFile()) return candidate
457
+ entries = await readdir(dir, { withFileTypes: true })
250
458
  } catch {
251
- // not this name
459
+ return null
252
460
  }
461
+ const byName = new Map(entries.map((e) => [e.name, e]))
462
+ for (const name of PROJECT_CONFIG_FILENAMES) {
463
+ const entry = byName.get(name)
464
+ if (entry === undefined) continue
465
+ const candidate = path.join(dir, name)
466
+ if (entry.isFile()) return candidate
467
+ if (entry.isSymbolicLink() && (await isFile(candidate))) return candidate
468
+ }
469
+ return null
470
+ }
471
+ for (const name of PROJECT_CONFIG_FILENAMES) {
472
+ const candidate = path.join(dir, name)
473
+ if (await isFile(candidate)) return candidate
253
474
  }
254
475
  return null
255
476
  }
256
477
 
478
+ async function isFile(p: string): Promise<boolean> {
479
+ try {
480
+ return (await stat(p)).isFile()
481
+ } catch {
482
+ return false
483
+ }
484
+ }
485
+
257
486
  export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
258
487
  // Run all package globs concurrently. Disk-bound walks parallelize
259
488
  // well; serializing them just stretches the discovery phase by N×.
489
+ const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
260
490
  const perPattern = await Promise.all(
261
- workspace.packageGlobs.map((pattern) => memberDirs(workspace.root, pattern)),
491
+ positive.map((pattern) => memberDirs(workspace.root, pattern)),
262
492
  )
263
493
  const matches = new Set<string>()
264
- for (const arr of perPattern) for (const m of arr) matches.add(m)
494
+ for (const arr of perPattern) {
495
+ for (const m of arr) {
496
+ if (negative.length > 0 && excludedBy(relPosix(workspace.root, m), negative)) continue
497
+ matches.add(m)
498
+ }
499
+ }
265
500
 
266
501
  // Per-project discovery: the manifest read answers "is there a
267
502
  // package.json" (an ENOENT is the "not a member" answer, at the cost of
@@ -279,7 +514,7 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
279
514
  .catch(() => null),
280
515
  ])
281
516
  if (text === null) return null
282
- const pkg = parseManifest(text, pkgJsonPath, JSON.parse) as PackageJson
517
+ const pkg = parsePackageJson(text, pkgJsonPath)
283
518
  return { dir, pkg, configPath }
284
519
  }),
285
520
  )
@@ -1,2 +0,0 @@
1
- // Subpath shim for Bun's compiled binaries — see ../../index.ts.
2
- export * from '../../src/plugins/local-cache/index.js'
@@ -1,2 +0,0 @@
1
- // Subpath shim for Bun's compiled binaries — see ../../index.ts.
2
- export * from '../../src/plugins/local-executor/index.js'
@@ -1,2 +0,0 @@
1
- // Subpath shim for Bun's compiled binaries — see ../../index.ts.
2
- export * from '../../src/plugins/schedule-history/index.js'
@@ -1,115 +0,0 @@
1
- // CASBackend — pluggable content-addressed storage beneath the cache.
2
- //
3
- // Today's Cache + LayeredCache both bundle "where do the bytes live"
4
- // with "how do we look up entries metadata + key derivation."
5
- // CASBackend separates the former so a future R2/S3 backend
6
- // or REAPI CAS bridge can drop in without touching the
7
- // orchestrator or the SQL entries index.
8
- //
9
- // Two reference implementations in this file:
10
- // - MemoryCASBackend — testing + in-memory builds. Holds raw bytes.
11
- // - FsCASBackend — writes bytes to a directory; mirrors what
12
- // Cache.save does today. Bun-native using Bun.file
13
- // for the read path so big artifacts stream
14
- // without slurping into memory.
15
- //
16
- // Cache.ts has NOT yet been rewired to use a CASBackend — that's a
17
- // follow-up (Phase 1b). This file ships the abstraction + the two
18
- // reference impls so downstream work (R2, otel-bridge, distributed-ci)
19
- // can rely on the type. Byte-identical behaviour today.
20
-
21
- import { mkdir, rename, rm } from 'node:fs/promises'
22
- import path from 'node:path'
23
- import type { Digest } from './digest.js'
24
-
25
- /** Per-process counter for put() temp names; uniqueness only. */
26
- let casTmpSeq = 0
27
-
28
- export interface CASBackend {
29
- /** Write bytes under `digest`. Idempotent — putting the same digest twice is a no-op. */
30
- put(digest: Digest, bytes: Uint8Array): Promise<void>
31
- /** Read bytes under `digest`, or null if absent. */
32
- get(digest: Digest): Promise<Uint8Array | null>
33
- /** Cheap existence probe — no bytes round-tripped if possible. */
34
- has(digest: Digest): Promise<boolean>
35
- /** Drop one entry (eviction). No-op if absent. */
36
- remove(digest: Digest): Promise<void>
37
- }
38
-
39
- /** In-memory backend, useful for tests and ephemeral runs. */
40
- export class MemoryCASBackend implements CASBackend {
41
- private readonly store = new Map<string, Uint8Array>()
42
-
43
- async put(digest: Digest, bytes: Uint8Array): Promise<void> {
44
- if (bytes.byteLength !== digest.sizeBytes) {
45
- throw new Error(
46
- `MemoryCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
47
- )
48
- }
49
- this.store.set(digest.hash, bytes)
50
- }
51
-
52
- async get(digest: Digest): Promise<Uint8Array | null> {
53
- return this.store.get(digest.hash) ?? null
54
- }
55
-
56
- async has(digest: Digest): Promise<boolean> {
57
- return this.store.has(digest.hash)
58
- }
59
-
60
- async remove(digest: Digest): Promise<void> {
61
- this.store.delete(digest.hash)
62
- }
63
-
64
- /** Test-only: how many entries are held. */
65
- size(): number {
66
- return this.store.size
67
- }
68
- }
69
-
70
- /** Filesystem-backed backend writing `<rootDir>/<hash>.tar.zst` files. */
71
- export class FsCASBackend implements CASBackend {
72
- constructor(private readonly rootDir: string) {}
73
-
74
- private pathFor(digest: Digest): string {
75
- return path.join(this.rootDir, `${digest.hash}.tar.zst`)
76
- }
77
-
78
- async put(digest: Digest, bytes: Uint8Array): Promise<void> {
79
- if (bytes.byteLength !== digest.sizeBytes) {
80
- throw new Error(
81
- `FsCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
82
- )
83
- }
84
- // Temp + rename, the same shape `Cache.writeArtifactAndIndex` and the
85
- // archive extractor use: two writers of the SAME content-addressed
86
- // blob are expected (that is what content-addressing invites), and a
87
- // plain write lets a reader observe a half-written file under a name
88
- // that promises complete bytes. rename(2) is atomic, so a concurrent
89
- // reader sees either the old file or the whole new one.
90
- const final = this.pathFor(digest)
91
- const tmp = `${final}.tmp-${process.pid.toString(36)}-${(casTmpSeq++).toString(36)}`
92
- await mkdir(this.rootDir, { recursive: true })
93
- try {
94
- await Bun.write(tmp, bytes)
95
- await rename(tmp, final)
96
- } catch (err) {
97
- await rm(tmp, { force: true })
98
- throw err
99
- }
100
- }
101
-
102
- async get(digest: Digest): Promise<Uint8Array | null> {
103
- const file = Bun.file(this.pathFor(digest))
104
- if (!(await file.exists())) return null
105
- return new Uint8Array(await file.arrayBuffer())
106
- }
107
-
108
- async has(digest: Digest): Promise<boolean> {
109
- return Bun.file(this.pathFor(digest)).exists()
110
- }
111
-
112
- async remove(digest: Digest): Promise<void> {
113
- await rm(this.pathFor(digest), { force: true })
114
- }
115
- }
@@ -1,32 +0,0 @@
1
- // Digest — the explicit content-address used by every cache layer.
2
- //
3
- // Lifted as a first-class type (architecture-review-2026-06.md §4.3)
4
- // so storage backends become pluggable beneath CacheLayer: local FS,
5
- // R2, S3, or a future REAPI CAS bridge all speak (hash, sizeBytes).
6
- //
7
- // Two practical wins from making this explicit:
8
- // 1. sizeBytes is the truncation check at transport boundaries (HTTP
9
- // Content-Length, R2 ETag-by-size). Free correctness, free corrupt-
10
- // artifact-early-fail.
11
- // 2. CASBackend.has(digest) can answer with just the metadata, no
12
- // bytes round-trip — what existence probes want.
13
- //
14
- // Why sizeBytes is `number` and not `bigint`: Bun.file().size returns
15
- // number; JS Number safely represents integers to 2^53-1 (~9 PB); no
16
- // real cache artifact gets there. Bigint would create the same
17
- // JSON.stringify-throws nuisance we already navigated for ns timestamps.
18
-
19
- export interface Digest {
20
- readonly hash: string
21
- readonly sizeBytes: number
22
- }
23
-
24
- export function makeDigest(hash: string, sizeBytes: number): Digest {
25
- if (!/^[0-9a-f]+$/i.test(hash)) {
26
- throw new Error(`invalid digest hash: ${JSON.stringify(hash)}`)
27
- }
28
- if (!Number.isInteger(sizeBytes) || sizeBytes < 0) {
29
- throw new Error(`invalid digest sizeBytes: ${sizeBytes}`)
30
- }
31
- return { hash, sizeBytes }
32
- }
@@ -1,21 +0,0 @@
1
- // Shared TS-literal emission helper for the migrate mappers. Lives in its own
2
- // leaf module so both migrate.ts (the config emitter) and migrate-turbo.ts (the
3
- // preset renderer) can escape strings identically without a dependency cycle
4
- // (migrate.ts already imports the turbo/nx mappers, so a back-import of a value
5
- // from migrate.ts would close a runtime cycle).
6
-
7
- /**
8
- * Escape an arbitrary string into a single-quoted TS literal. Escapes
9
- * backslash + quote AND raw newlines/CR — a value with an embedded newline
10
- * (legal JSON, e.g. a script `"echo a\necho b"`, or a glob with a `'`) would
11
- * otherwise splice into a single-quoted literal as an unterminated / malformed
12
- * string that fails to load (generated files must round-trip through the
13
- * loader).
14
- */
15
- export function quote(s: string): string {
16
- return `'${s
17
- .replaceAll('\\', '\\\\')
18
- .replaceAll("'", "\\'")
19
- .replaceAll('\n', '\\n')
20
- .replaceAll('\r', '\\r')}'`
21
- }