@vzn/vx 0.0.16 → 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 (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -1,7 +1,8 @@
1
- import { readdir } from 'node:fs/promises'
1
+ import type { Dirent } from 'node:fs'
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 { UserError } from '../util/index.js'
5
+ import { relPosix, UserError, normalizeGlob } from '../util/index.js'
5
6
 
6
7
  export interface PackageJson {
7
8
  name: string
@@ -40,29 +41,149 @@ export interface ProjectEntry {
40
41
  const CONFIG_FILENAMES = ['vx.config.ts', 'vx.config.mts', 'vx.config.js', 'vx.config.mjs']
41
42
 
42
43
  /**
43
- * Walk up from `start` to find the workspace root. A directory is a
44
- * workspace root if it contains either:
45
- * - `pnpm-workspace.yaml`, OR
46
- * - a `package.json` (with or without a `workspaces` field).
44
+ * Walk up from `start` to find the workspace root. A directory is a root
45
+ * CANDIDATE when it contains `pnpm-workspace.yaml` or a `package.json`.
47
46
  *
48
- * The first match wins. A bare `package.json` without `workspaces`
49
- * means a single-project workspace (the root itself IS the project).
50
- * Throws a `UserError` if neither signal is found before `/`.
47
+ * The nearest candidate that CLAIMS `start` wins — one of the directories
48
+ * between it and `start` matches one of its package globs. Every workspace
49
+ * member has its own `package.json`, so stopping at the first candidate would
50
+ * make a run from inside a package treat that package as the whole workspace:
51
+ * `^task` edges vanish, upstream hashes drop out of the cache key (stale
52
+ * hits), and a second cache dir appears under the member. Claiming is decided
53
+ * with the same globs `loadWorkspace` applies, so "the root that claims me"
54
+ * and "the root that lists me as a project" cannot diverge.
55
+ *
56
+ * When no candidate claims `start` — a standalone package, or a subdirectory
57
+ * of a single-project repo — the nearest candidate wins (the root itself IS
58
+ * the project). Throws a `UserError` when there is no candidate before `/`.
51
59
  */
52
60
  export async function findWorkspaceRoot(start: string): Promise<string> {
53
61
  let dir = path.resolve(start)
62
+ const below: string[] = []
63
+ let nearest: string | null = null
54
64
  while (true) {
55
- if (await Bun.file(path.join(dir, 'pnpm-workspace.yaml')).exists()) return dir
56
- if (await Bun.file(path.join(dir, 'package.json')).exists()) return dir
57
- const parent = path.dirname(dir)
58
- if (parent === dir) {
59
- throw new UserError(
60
- `Could not find a workspace root in any parent of ${start} ` +
61
- `(looked for pnpm-workspace.yaml or package.json)`,
62
- )
65
+ let globs: string[] | null
66
+ try {
67
+ globs = await readPackageGlobs(dir)
68
+ } catch {
69
+ // An unparseable manifest is still a root SIGNAL (the pre-existing
70
+ // behaviour probed only for existence); it just can't claim members.
71
+ // `loadWorkspace` surfaces the parse error if this dir is chosen.
72
+ globs = []
73
+ }
74
+ if (globs !== null) {
75
+ nearest ??= dir
76
+ if (claimsMember(dir, below, globs)) return dir
63
77
  }
78
+ const parent = path.dirname(dir)
79
+ if (parent === dir) break
80
+ below.push(dir)
64
81
  dir = parent
65
82
  }
83
+ if (nearest !== null) return nearest
84
+ throw new UserError(
85
+ `Could not find a workspace root in any parent of ${start} ` +
86
+ `(looked for pnpm-workspace.yaml or package.json)`,
87
+ )
88
+ }
89
+
90
+ /** True when one of `below` (dirs under `root`, toward `start`) is a member. */
91
+ function claimsMember(root: string, below: readonly string[], globs: readonly string[]): boolean {
92
+ if (below.length === 0 || globs.length === 0) return false
93
+ const { positive, negative } = splitPackageGlobs(globs)
94
+ const rels = below.map((d) => relPosix(root, d)).filter((rel) => !excludedBy(rel, negative))
95
+ for (const pattern of positive) {
96
+ const normalized = pattern.replace(/\/+$/, '')
97
+ // `.` means the root itself is the project — never a directory below it.
98
+ if (normalized === '' || normalized === '.') continue
99
+ const glob = new Bun.Glob(normalized)
100
+ if (rels.some((rel) => glob.match(rel))) return true
101
+ }
102
+ return false
103
+ }
104
+
105
+ /**
106
+ * pnpm, npm, yarn and Bun all take `!packages/fixtures` in the package
107
+ * list. Handed to `Bun.Glob` raw, a leading `!` negates the WHOLE pattern,
108
+ * so `!packages/fixtures/package.json` matched every manifest in the tree
109
+ * and every directory holding one became a project — the excluded package
110
+ * ran under `--all`, and a fixture repeating a name killed the run with
111
+ * "Duplicate package name". Negations subtract from what the positive
112
+ * globs found; a literal one excludes its tree.
113
+ */
114
+ function splitPackageGlobs(globs: readonly string[]): { positive: string[]; negative: string[] } {
115
+ const positive: string[] = []
116
+ const negative: string[] = []
117
+ // `!./packages/legacy` and `!packages//legacy` excluded nothing, silently
118
+ // (2026-09-10): the same spellings the input globs normalize.
119
+ for (const raw of globs) {
120
+ const g = normalizeGlob(raw)
121
+ if (g.startsWith('!')) negative.push(g.slice(1).replace(/\/+$/, ''))
122
+ else positive.push(g)
123
+ }
124
+ return { positive, negative }
125
+ }
126
+
127
+ function excludedBy(rel: string, negative: readonly string[]): boolean {
128
+ for (const neg of negative) {
129
+ if (neg.length === 0) continue
130
+ if (rel === neg || rel.startsWith(`${neg}/`)) return true
131
+ if (/[*?[\]{}]/.test(neg) && new Bun.Glob(neg).match(rel)) return true
132
+ }
133
+ return false
134
+ }
135
+
136
+ /**
137
+ * Package globs declared by `dir`, or `null` when `dir` is not a root
138
+ * candidate. A bare `package.json` (no `workspaces`) is single-project mode:
139
+ * the root itself is the only project, hence `['.']`.
140
+ */
141
+ async function readPackageGlobs(dir: string): Promise<string[] | null> {
142
+ const yamlPath = path.join(dir, 'pnpm-workspace.yaml')
143
+ if (await Bun.file(yamlPath).exists()) {
144
+ const parsed = (parseManifest(await Bun.file(yamlPath).text(), yamlPath, Bun.YAML.parse) ??
145
+ {}) as { packages?: unknown }
146
+ return assertGlobList(parsed.packages ?? [], yamlPath, 'packages')
147
+ }
148
+ const pkgPath = path.join(dir, 'package.json')
149
+ if (!(await Bun.file(pkgPath).exists())) return null
150
+ const pkg = parseManifest(await Bun.file(pkgPath).text(), pkgPath, JSON.parse) as PackageJson
151
+ const ws = pkg.workspaces as unknown
152
+ if (ws === undefined || ws === null) return ['.']
153
+ if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
154
+ return assertGlobList(
155
+ (ws as { packages?: unknown }).packages ?? [],
156
+ pkgPath,
157
+ 'workspaces.packages',
158
+ )
159
+ }
160
+ return assertGlobList(ws, pkgPath, 'workspaces')
161
+ }
162
+
163
+ /**
164
+ * Parse a workspace manifest, naming the FILE on failure. The raw parser
165
+ * errors (`Failed to parse JSON`) carry no path, so in a 1000-package
166
+ * monorepo they say nothing about which manifest is broken.
167
+ */
168
+ function parseManifest(text: string, file: string, parse: (t: string) => unknown): unknown {
169
+ try {
170
+ return parse(text)
171
+ } catch (err) {
172
+ const msg = err instanceof Error ? err.message : String(err)
173
+ throw new UserError(`failed to parse ${file}: ${msg}`)
174
+ }
175
+ }
176
+
177
+ /**
178
+ * A glob list is user input from a manifest: `packages: "packages/*"`
179
+ * (a bare string instead of a list) is an easy YAML slip, and without
180
+ * this it surfaces as `workspace.packageGlobs.map is not a function`.
181
+ */
182
+ function assertGlobList(value: unknown, file: string, field: string): string[] {
183
+ if (!Array.isArray(value) || value.some((p) => typeof p !== 'string')) {
184
+ throw new UserError(`${file}: \`${field}\` must be an array of glob strings`)
185
+ }
186
+ return value as string[]
66
187
  }
67
188
 
68
189
  /**
@@ -86,83 +207,185 @@ export function resolveCacheDir(root: string, config: WorkspaceConfig | null): s
86
207
  * itself is treated as a single-project workspace.
87
208
  */
88
209
  export async function loadWorkspace(root: string): Promise<Workspace> {
89
- const yamlPath = path.join(root, 'pnpm-workspace.yaml')
90
- if (await Bun.file(yamlPath).exists()) {
91
- const parsed = (Bun.YAML.parse(await Bun.file(yamlPath).text()) ?? {}) as {
92
- packages?: string[]
210
+ const packageGlobs = await readPackageGlobs(root)
211
+ if (packageGlobs === null) {
212
+ // Should be unreachable: findWorkspaceRoot only returns dirs that
213
+ // pass at least one of the two existence checks.
214
+ throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
215
+ }
216
+ return { root, packageGlobs }
217
+ }
218
+
219
+ // `<dir>/*` — the shape of nearly every workspace glob. Anything with another
220
+ // glob character (`**`, `{a,b}`, `?`, `[`) or a negation takes the general
221
+ // path.
222
+ const SIMPLE_STAR_RE = /^[^*?{}[\]!]+\/\*$/
223
+
224
+ /**
225
+ * The directory each `<dir>/*` package glob names — where a member comes
226
+ * and goes as one directory entry, so `vx watch` can hear a package added
227
+ * or removed without walking anything. A glob of another shape (`apps/**`)
228
+ * has no such directory and contributes none.
229
+ */
230
+ export function memberBaseDirs(workspace: Workspace): string[] {
231
+ const bases = new Set<string>()
232
+ for (const pattern of splitPackageGlobs(workspace.packageGlobs).positive) {
233
+ const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
234
+ if (SIMPLE_STAR_RE.test(normalized)) {
235
+ bases.add(path.resolve(workspace.root, normalized.slice(0, -2)))
236
+ }
237
+ }
238
+ return [...bases]
239
+ }
240
+
241
+ /**
242
+ * The directories a workspace glob names. For the `<dir>/*` shape this is
243
+ * one readdir of `<dir>` — the same answer `Bun.Glob` gives, at a third of
244
+ * the cost (measured 2026-09-02: 25 ms → ~2 ms for 1000 members). A
245
+ * symlinked member is followed when it points at a directory, as the glob
246
+ * did. Dot-directories are skipped (`dot: false`), so are `node_modules`.
247
+ */
248
+ async function memberDirs(root: string, pattern: string): Promise<string[]> {
249
+ // Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
250
+ const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
251
+ if (SIMPLE_STAR_RE.test(normalized)) {
252
+ const base = path.resolve(root, normalized.slice(0, -2))
253
+ let entries: import('node:fs').Dirent[]
254
+ try {
255
+ entries = await readdir(base, { withFileTypes: true })
256
+ } catch {
257
+ return []
258
+ }
259
+ const dirs: string[] = []
260
+ for (const e of entries) {
261
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
262
+ if (e.isDirectory()) dirs.push(path.join(base, e.name))
263
+ else if (e.isSymbolicLink()) {
264
+ try {
265
+ if ((await stat(path.join(base, e.name))).isDirectory())
266
+ dirs.push(path.join(base, e.name))
267
+ } catch {
268
+ // dangling link: not a member
269
+ }
270
+ }
93
271
  }
94
- return { root, packageGlobs: parsed.packages ?? [] }
272
+ return dirs
273
+ }
274
+ const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
275
+ const glob = new Bun.Glob(globPattern)
276
+ const dirs: string[] = []
277
+ for await (const rel of glob.scan({ cwd: root, onlyFiles: true, dot: false })) {
278
+ // Skip nested node_modules — workspace package globs shouldn't
279
+ // ever reach into them, but a pathological pattern like `**`
280
+ // would. Avoid splitting the path on the hot loop.
281
+ if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
282
+ if (rel.startsWith(`node_modules${path.sep}`)) continue
283
+ dirs.push(path.dirname(path.resolve(root, rel)))
95
284
  }
285
+ return dirs
286
+ }
96
287
 
97
- const pkgPath = path.join(root, 'package.json')
98
- if (await Bun.file(pkgPath).exists()) {
99
- const pkg = (await Bun.file(pkgPath).json()) as PackageJson
100
- const ws = pkg.workspaces
101
- if (Array.isArray(ws)) return { root, packageGlobs: ws }
102
- if (ws && typeof ws === 'object' && Array.isArray(ws.packages)) {
103
- return { root, packageGlobs: ws.packages }
288
+ /**
289
+ * The project's config file, by `CONFIG_FILENAMES` precedence, or null.
290
+ *
291
+ * Two shapes, one per platform, each the measured winner there across
292
+ * 1,000 projects with the manifest read in flight alongside:
293
+ * - macOS stats the candidates in order: a readdir each cost 9.6 ms
294
+ * against 6.3 ms of stats even with the config at the LAST name
295
+ * (2026-09-03), and a `.ts` config — the first name — pays one stat.
296
+ * - Linux lists the directory once: a readdir each is 12 ms against
297
+ * 43 ms of stats at the last name and ties the one-stat case
298
+ * (2026-09-09, a `vx.config.mjs` workspace; stats there cost a
299
+ * thread-pool round trip apiece).
300
+ * Both answer identically: a directory or dangling link under a config
301
+ * name is skipped for the next name, a symlink to a file counts.
302
+ */
303
+ async function findConfigFile(dir: string): Promise<string | null> {
304
+ if (process.platform === 'linux') {
305
+ let entries: Dirent[]
306
+ try {
307
+ entries = await readdir(dir, { withFileTypes: true })
308
+ } catch {
309
+ return null
104
310
  }
105
- // Bare package.json with no `workspaces` — single-project mode.
106
- // Treat the root itself as the only project.
107
- return { root, packageGlobs: ['.'] }
311
+ const byName = new Map(entries.map((e) => [e.name, e]))
312
+ for (const name of CONFIG_FILENAMES) {
313
+ const entry = byName.get(name)
314
+ if (entry === undefined) continue
315
+ const candidate = path.join(dir, name)
316
+ if (entry.isFile()) return candidate
317
+ if (entry.isSymbolicLink() && (await isFile(candidate))) return candidate
318
+ }
319
+ return null
320
+ }
321
+ for (const name of CONFIG_FILENAMES) {
322
+ const candidate = path.join(dir, name)
323
+ if (await isFile(candidate)) return candidate
108
324
  }
325
+ return null
326
+ }
109
327
 
110
- // Should be unreachable: findWorkspaceRoot only returns dirs that
111
- // pass at least one of the two existence checks.
112
- throw new UserError(`workspace root ${root} has neither pnpm-workspace.yaml nor package.json`)
328
+ async function isFile(p: string): Promise<boolean> {
329
+ try {
330
+ return (await stat(p)).isFile()
331
+ } catch {
332
+ return false
333
+ }
113
334
  }
114
335
 
115
336
  export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
116
337
  // Run all package globs concurrently. Disk-bound walks parallelize
117
338
  // well; serializing them just stretches the discovery phase by N×.
339
+ const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
118
340
  const perPattern = await Promise.all(
119
- workspace.packageGlobs.map(async (pattern) => {
120
- // Normalize: `"."` -> the root itself; `"foo/"` -> `"foo"`.
121
- const normalized = pattern === '.' ? '' : pattern.replace(/\/$/, '')
122
- const globPattern = normalized === '' ? 'package.json' : `${normalized}/package.json`
123
- const glob = new Bun.Glob(globPattern)
124
- const hits: string[] = []
125
- for await (const rel of glob.scan({
126
- cwd: workspace.root,
127
- onlyFiles: true,
128
- dot: false,
129
- })) {
130
- // Skip nested node_modules — workspace package globs shouldn't
131
- // ever reach into them, but a pathological pattern like `**`
132
- // would. Avoid splitting the path on the hot loop.
133
- if (rel.includes(`${path.sep}node_modules${path.sep}`)) continue
134
- if (rel.startsWith(`node_modules${path.sep}`)) continue
135
- hits.push(path.resolve(workspace.root, rel))
136
- }
137
- return hits
138
- }),
341
+ positive.map((pattern) => memberDirs(workspace.root, pattern)),
139
342
  )
140
343
  const matches = new Set<string>()
141
- for (const arr of perPattern) for (const m of arr) matches.add(m)
344
+ for (const arr of perPattern) {
345
+ for (const m of arr) {
346
+ if (negative.length > 0 && excludedBy(relPosix(workspace.root, m), negative)) continue
347
+ matches.add(m)
348
+ }
349
+ }
142
350
 
143
- // Per-project I/O (manifest read + config discovery) is independent
144
- // — run it all concurrently, then a deterministic sequential pass
145
- // for the duplicate-name check. One readdir per project replaces
146
- // up to four exists() probes (CONFIG_FILENAMES has 4 candidates):
147
- // ~5440 awaited syscalls → ~2180 concurrent ones at 1090 projects.
351
+ // Per-project discovery: the manifest read answers "is there a
352
+ // package.json" (an ENOENT is the "not a member" answer, at the cost of
353
+ // one failed open) and `findConfigFile` answers "which config file", both
354
+ // in flight together. All async and all in flight at once — the thread
355
+ // pool runs them in parallel, and a sync read here measured 2× slower on
356
+ // a 1000-member workspace.
148
357
  const loaded = await Promise.all(
149
- [...matches].map(async (pkgJsonPath) => {
150
- const dir = path.dirname(pkgJsonPath)
151
- const [pkg, entries] = await Promise.all([
152
- Bun.file(pkgJsonPath).json() as Promise<PackageJson>,
153
- readdir(dir).catch(() => [] as string[]),
358
+ [...matches].map(async (dir) => {
359
+ const pkgJsonPath = path.join(dir, 'package.json')
360
+ const [configPath, text] = await Promise.all([
361
+ findConfigFile(dir),
362
+ Bun.file(pkgJsonPath)
363
+ .text()
364
+ .catch(() => null),
154
365
  ])
155
- const names = new Set(entries)
156
- const configName = CONFIG_FILENAMES.find((f) => names.has(f))
157
- const configPath = configName !== undefined ? path.join(dir, configName) : null
366
+ if (text === null) return null
367
+ const pkg = parseManifest(text, pkgJsonPath, JSON.parse) as PackageJson
158
368
  return { dir, pkg, configPath }
159
369
  }),
160
370
  )
161
371
 
162
372
  const projects: ProjectMeta[] = []
163
373
  const seenName = new Map<string, string>()
164
- for (const { dir, pkg, configPath } of loaded) {
165
- if (!pkg.name) continue
374
+ for (const entry of loaded) {
375
+ if (entry === null) continue
376
+ const { dir, pkg, configPath } = entry
377
+ if (!pkg.name) {
378
+ // A nameless manifest can't be addressed, filtered, or made affected —
379
+ // and vx identifies projects by name, so it simply vanishes. Silent is
380
+ // fine for a dir that declares no tasks; a dir with a vx config was
381
+ // meant to run.
382
+ if (configPath !== null) {
383
+ process.stderr.write(
384
+ `vx: ${relPosix(workspace.root, dir)} has a vx config but its package.json has no "name" — skipped\n`,
385
+ )
386
+ }
387
+ continue
388
+ }
166
389
  const previous = seenName.get(pkg.name)
167
390
  if (previous) {
168
391
  throw new UserError(
@@ -172,5 +395,8 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
172
395
  seenName.set(pkg.name, dir)
173
396
  projects.push({ name: pkg.name, dir, packageJson: pkg, configPath })
174
397
  }
175
- return projects.sort((a, b) => a.name.localeCompare(b.name))
398
+ // Code-unit order, not `localeCompare`: ICU collation cost 28 ms of a
399
+ // 300 ms warm run at 1000 projects, and a stable deterministic order is
400
+ // all any consumer needs.
401
+ return projects.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))
176
402
  }
@@ -1,97 +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 { rm } from 'node:fs/promises'
22
- import path from 'node:path'
23
- import type { Digest } from './digest.js'
24
-
25
- export interface CASBackend {
26
- /** Write bytes under `digest`. Idempotent — putting the same digest twice is a no-op. */
27
- put(digest: Digest, bytes: Uint8Array): Promise<void>
28
- /** Read bytes under `digest`, or null if absent. */
29
- get(digest: Digest): Promise<Uint8Array | null>
30
- /** Cheap existence probe — no bytes round-tripped if possible. */
31
- has(digest: Digest): Promise<boolean>
32
- /** Drop one entry (eviction). No-op if absent. */
33
- remove(digest: Digest): Promise<void>
34
- }
35
-
36
- /** In-memory backend, useful for tests and ephemeral runs. */
37
- export class MemoryCASBackend implements CASBackend {
38
- private readonly store = new Map<string, Uint8Array>()
39
-
40
- async put(digest: Digest, bytes: Uint8Array): Promise<void> {
41
- if (bytes.byteLength !== digest.sizeBytes) {
42
- throw new Error(
43
- `MemoryCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
44
- )
45
- }
46
- this.store.set(digest.hash, bytes)
47
- }
48
-
49
- async get(digest: Digest): Promise<Uint8Array | null> {
50
- return this.store.get(digest.hash) ?? null
51
- }
52
-
53
- async has(digest: Digest): Promise<boolean> {
54
- return this.store.has(digest.hash)
55
- }
56
-
57
- async remove(digest: Digest): Promise<void> {
58
- this.store.delete(digest.hash)
59
- }
60
-
61
- /** Test-only: how many entries are held. */
62
- size(): number {
63
- return this.store.size
64
- }
65
- }
66
-
67
- /** Filesystem-backed backend writing `<rootDir>/<hash>.tar.zst` files. */
68
- export class FsCASBackend implements CASBackend {
69
- constructor(private readonly rootDir: string) {}
70
-
71
- private pathFor(digest: Digest): string {
72
- return path.join(this.rootDir, `${digest.hash}.tar.zst`)
73
- }
74
-
75
- async put(digest: Digest, bytes: Uint8Array): Promise<void> {
76
- if (bytes.byteLength !== digest.sizeBytes) {
77
- throw new Error(
78
- `FsCASBackend.put: sizeBytes mismatch (digest=${digest.sizeBytes}, actual=${bytes.byteLength})`,
79
- )
80
- }
81
- await Bun.write(this.pathFor(digest), bytes)
82
- }
83
-
84
- async get(digest: Digest): Promise<Uint8Array | null> {
85
- const file = Bun.file(this.pathFor(digest))
86
- if (!(await file.exists())) return null
87
- return new Uint8Array(await file.arrayBuffer())
88
- }
89
-
90
- async has(digest: Digest): Promise<boolean> {
91
- return Bun.file(this.pathFor(digest)).exists()
92
- }
93
-
94
- async remove(digest: Digest): Promise<void> {
95
- await rm(this.pathFor(digest), { force: true })
96
- }
97
- }
@@ -1,47 +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
- }
33
-
34
- export function digestEqual(a: Digest, b: Digest): boolean {
35
- return a.hash === b.hash && a.sizeBytes === b.sizeBytes
36
- }
37
-
38
- /** Stable wire format for logging / RPC: `hash/sizeBytes`. */
39
- export function digestString(d: Digest): string {
40
- return `${d.hash}/${d.sizeBytes}`
41
- }
42
-
43
- export function parseDigest(s: string): Digest {
44
- const slash = s.indexOf('/')
45
- if (slash === -1) throw new Error(`invalid digest string: ${JSON.stringify(s)}`)
46
- return makeDigest(s.slice(0, slash), Number(s.slice(slash + 1)))
47
- }