@vzn/vx 0.0.594 → 0.0.596

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 (80) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +2 -2
  5. package/src/cache/cache.ts +235 -174
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/config-evals.ts +19 -4
  8. package/src/cache/git-inputs.ts +19 -8
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/inputs.ts +122 -49
  11. package/src/cache/layer.ts +7 -5
  12. package/src/cache/layered-cache.ts +25 -2
  13. package/src/cache/policy.ts +5 -0
  14. package/src/cache/tar-stream.ts +1 -1
  15. package/src/cache/zstd.ts +10 -4
  16. package/src/cli/cache.ts +11 -15
  17. package/src/cli/completions.ts +42 -6
  18. package/src/cli/foreign-flags.ts +26 -5
  19. package/src/cli/help.ts +5 -3
  20. package/src/cli/init.ts +1 -1
  21. package/src/cli/last.ts +4 -1
  22. package/src/cli/plan-format.ts +9 -0
  23. package/src/cli/plugin-templates.ts +2 -2
  24. package/src/cli/run.ts +60 -42
  25. package/src/cli/select.ts +30 -3
  26. package/src/cli/show.ts +42 -3
  27. package/src/cli/watch-filter.ts +77 -13
  28. package/src/cli/watch-fs.ts +15 -7
  29. package/src/cli/watch-judge.ts +16 -9
  30. package/src/cli/watch.ts +22 -15
  31. package/src/config.ts +26 -26
  32. package/src/exec/env.ts +5 -0
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +6 -2
  35. package/src/exec/runner.ts +98 -40
  36. package/src/exec/sandbox-paths.ts +12 -6
  37. package/src/exec/sandbox-runtime.ts +136 -44
  38. package/src/exec/sandbox-violations.ts +58 -11
  39. package/src/graph/index.ts +1 -0
  40. package/src/graph/scheduler.ts +29 -13
  41. package/src/graph/task-graph.ts +134 -25
  42. package/src/index.ts +6 -0
  43. package/src/orchestrator/admission.ts +12 -3
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/execute-task.ts +65 -20
  46. package/src/orchestrator/failure-mode.ts +8 -2
  47. package/src/orchestrator/framed-output.ts +3 -3
  48. package/src/orchestrator/hit-restore.ts +6 -3
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +43 -46
  51. package/src/orchestrator/logger.ts +11 -6
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +2 -2
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +28 -4
  56. package/src/orchestrator/plugin-host.ts +25 -3
  57. package/src/orchestrator/plugin.ts +39 -17
  58. package/src/orchestrator/prepare.ts +13 -4
  59. package/src/orchestrator/run-artifacts.ts +2 -1
  60. package/src/orchestrator/run.ts +72 -12
  61. package/src/orchestrator/sandbox-request.ts +65 -9
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +5 -2
  64. package/src/orchestrator/summary.ts +16 -5
  65. package/src/orchestrator/telemetry.ts +16 -7
  66. package/src/util/index.ts +7 -1
  67. package/src/util/paths.ts +23 -3
  68. package/src/util/secret-mask.ts +36 -0
  69. package/src/util/settle.ts +15 -0
  70. package/src/workspace/affected.ts +30 -8
  71. package/src/workspace/config-cache.ts +25 -4
  72. package/src/workspace/config-imports.ts +8 -6
  73. package/src/workspace/config-schema.ts +74 -9
  74. package/src/workspace/filter.ts +19 -0
  75. package/src/workspace/migrate-scripts.ts +5 -3
  76. package/src/workspace/migration.ts +1 -1
  77. package/src/workspace/package-graph.ts +15 -3
  78. package/src/workspace/project-loader.ts +76 -2
  79. package/src/workspace/repo-id.ts +42 -4
  80. package/src/workspace/workspace.ts +98 -20
@@ -176,6 +176,14 @@ export async function affectedChanges(
176
176
  `("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
177
177
  )
178
178
  }
179
+ // `^main` is rev-list's exclusion, not a ref: merge-base refused it, it
180
+ // verified, and `git diff ^main` diffed from main itself, so changes
181
+ // only main made were selected (`^` is illegal in a ref name).
182
+ if (since.startsWith('^')) {
183
+ throw new UserError(
184
+ `git ref "${since}" is an exclusion, not a ref: pass the base alone ("${since.replace(/^\^+/, '') || 'HEAD'}").`,
185
+ )
186
+ }
179
187
  // Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
180
188
  // on a branch whose base has moved on, `git diff <base>` reports every
181
189
  // file OTHER people changed on the base (over-selection that defeats a
@@ -432,7 +440,17 @@ async function dependentsAtBase(
432
440
  ) {
433
441
  continue
434
442
  }
435
- byDir.set(dir, { name: pkg.name, dir, packageJson: pkg, configPath: null })
443
+ // Today's catalogs, or both graphs would differ on every `catalog:`
444
+ // entry. A catalog edit is a `pnpm-workspace.yaml` or root manifest
445
+ // edit, which the fingerprint widening and the root's own change answer.
446
+ const catalogs = projects[0]?.catalogs
447
+ byDir.set(dir, {
448
+ name: pkg.name,
449
+ dir,
450
+ packageJson: pkg,
451
+ configPath: null,
452
+ ...(catalogs === undefined ? {} : { catalogs }),
453
+ })
436
454
  if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
437
455
  }
438
456
  const now = buildPackageGraph([...projects])
@@ -468,8 +486,10 @@ function parentsOfNewNested(
468
486
  projects: readonly ProjectMeta[],
469
487
  ): Set<string> {
470
488
  const out = new Set<string>()
489
+ // Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
490
+ // dir discovered by readdir keeps the spelling it was created with.
471
491
  const dirToName = new Map<string, string>()
472
- for (const p of projects) dirToName.set(p.dir, p.name)
492
+ for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
473
493
  for (const [rel, bytes] of atBase) {
474
494
  const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
475
495
  if (!dirToName.has(dir)) continue
@@ -829,8 +849,10 @@ function projectsContaining(
829
849
  // O(files · path-depth) instead of O(files · projects): independent of the
830
850
  // project count, which is what a big --affected diff on a 1000-project repo
831
851
  // pays for.
852
+ // Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
853
+ // dir discovered by readdir keeps the spelling it was created with.
832
854
  const dirToName = new Map<string, string>()
833
- for (const p of projects) dirToName.set(p.dir, p.name)
855
+ for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
834
856
  // A member linked in from elsewhere in the tree (`pkgs/b -> ../ext/b`) is
835
857
  // indexed by its link, and git reports its files at their real place
836
858
  // (`ext/b/src/a.txt`), which resolved to no project: an edit there
@@ -841,18 +863,18 @@ function projectsContaining(
841
863
  const real = realDirs.get(p.dir) ?? p.dir
842
864
  const rel = path.relative(realRoot, real)
843
865
  if (rel.startsWith('..') || path.isAbsolute(rel)) continue
844
- const spelled = path.resolve(workspaceRoot, rel)
845
- if (spelled !== p.dir && !dirToName.has(spelled)) dirToName.set(spelled, p.name)
866
+ const spelled = path.resolve(workspaceRoot, rel).normalize('NFC')
867
+ if (!dirToName.has(spelled)) dirToName.set(spelled, p.name)
846
868
  }
847
869
  const owned = new Set<string>()
848
870
  for (const rel of changedRelPaths) {
849
- let dir = path.resolve(workspaceRoot, rel)
871
+ let dir = path.resolve(workspaceRoot, rel).normalize('NFC')
850
872
  let hit = false
851
873
  for (;;) {
852
874
  const name = dirToName.get(dir)
853
875
  if (name !== undefined) {
854
876
  owned.add(name)
855
- on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel)))
877
+ on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
856
878
  hit = true
857
879
  break
858
880
  }
@@ -871,7 +893,7 @@ function projectsContaining(
871
893
  // this, `--affected` after an edit inside selected none of them.
872
894
  const abs = path.resolve(workspaceRoot, rel)
873
895
  if (isDirectory(abs)) {
874
- const prefix = abs + path.sep
896
+ const prefix = abs.normalize('NFC') + path.sep
875
897
  for (const [dir, name] of dirToName) {
876
898
  if (!dir.startsWith(prefix)) continue
877
899
  owned.add(name)
@@ -409,6 +409,16 @@ function keySeed(workspaceFingerprint: string): bigint {
409
409
 
410
410
  const EXPLICIT_EXT = /\.(?:m?[jt]s|cjs|cts)$/
411
411
 
412
+ /**
413
+ * A file's identity as the key folds it. `hashFile` names an executable
414
+ * `100755:<oid>`, the slow path's `hashBytes` the bare oid; the mode moves
415
+ * no evaluation, and with it the warm key of an executable config never
416
+ * met the stored one, so every load read and re-keyed it.
417
+ */
418
+ function contentIdentity(identity: string): string {
419
+ return identity.startsWith('100755:') ? identity.slice(7) : identity
420
+ }
421
+
412
422
  /**
413
423
  * The canonical directory Bun resolves `file`'s imports from: its REAL
414
424
  * path's, so a config linked in from elsewhere imports its neighbours
@@ -482,7 +492,7 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
482
492
  const identity = a.hashBytes
483
493
  ? a.hashBytes(bytes, file)
484
494
  : a.hashFile
485
- ? await a.hashFile(file)
495
+ ? contentIdentity(await a.hashFile(file))
486
496
  : await hashOf(file, bytes)
487
497
  h = xxh3(`${file}\0${identity}`, h)
488
498
  closure.push(file)
@@ -542,6 +552,14 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
542
552
  return { key: h.toString(16).padStart(16, '0'), closure, indexable }
543
553
  }
544
554
 
555
+ // `hashFile` names a symlink by its target string (git's mode 120000), not
556
+ // the bytes the evaluation read, so no stored key can match a fold of it.
557
+ // Only a linked config with no relative import is indexed at all; with no
558
+ // warm key it joins the round lookup on its slow key instead of paying a
559
+ // single lookup and an index rewrite per load. The identity is from the
560
+ // lstat `hashFiles` already took: no syscall is added.
561
+ const LINK_IDENTITY = '120000:'
562
+
545
563
  /**
546
564
  * The warm path: the key for a config whose ordered closure the store
547
565
  * remembers, from per-file identities alone — no read, no scan. The fold is
@@ -561,7 +579,10 @@ export async function configEvalKeyFromClosure(a: {
561
579
  } catch {
562
580
  return null
563
581
  }
564
- for (let i = 0; i < a.closure.length; i++) h = xxh3(`${a.closure[i]}\0${identities[i]}`, h)
582
+ if (identities.some((id) => id.startsWith(LINK_IDENTITY))) return null
583
+ for (let i = 0; i < a.closure.length; i++) {
584
+ h = xxh3(`${a.closure[i]}\0${contentIdentity(identities[i]!)}`, h)
585
+ }
565
586
  return h.toString(16).padStart(16, '0')
566
587
  }
567
588
 
@@ -574,8 +595,8 @@ export function configEvalKeyFromIdentities(a: {
574
595
  let h = keySeed(a.workspaceFingerprint)
575
596
  for (const file of a.closure) {
576
597
  const id = a.identities.get(file)
577
- if (id === undefined) return null
578
- h = xxh3(`${file}\0${id}`, h)
598
+ if (id === undefined || id.startsWith(LINK_IDENTITY)) return null
599
+ h = xxh3(`${file}\0${contentIdentity(id)}`, h)
579
600
  }
580
601
  return h.toString(16).padStart(16, '0')
581
602
  }
@@ -20,9 +20,9 @@
20
20
  // the lockfile moves, which the workspace fingerprint already covers.
21
21
  // A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
22
22
  // from disk (D-27).
23
- // - Descend only through files owned by NO project, or by a ROOT project
23
+ // - Descend only through files owned by NO project, by a ROOT project
24
24
  // (whose own files are the shared tooling that is otherwise unowned,
25
- // D-41). A config reaching into another project (say a site's
25
+ // D-41), or by the importing file's own project. A config reaching into another project (say a site's
26
26
  // `vx.config.ts` importing `../core/src/index.ts`) records that edge
27
27
  // and STOPS there — following it would drag substantially all of that
28
28
  // project's `src/` into the closure, and the containment channel
@@ -305,7 +305,7 @@ function selfReference(pkg: string, fromDir: string): boolean {
305
305
  const manifest = path.join(dir, 'package.json')
306
306
  if (existsSync(manifest)) {
307
307
  try {
308
- const json = JSON.parse(readFileSync(manifest, 'utf8')) as {
308
+ const json = JSON.parse(readFileSync(manifest, 'utf8').replace(/^\uFEFF/, '')) as {
309
309
  name?: unknown
310
310
  exports?: unknown
311
311
  }
@@ -486,16 +486,18 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
486
486
  if (entry === null) continue
487
487
  const { file, source } = entry
488
488
  const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
489
+ const from = ownerOf(file, dirToName)
489
490
  for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
490
491
  if (!target.startsWith(workspaceRoot + path.sep)) continue
491
492
  if (target.split(path.sep).includes('node_modules')) continue
492
493
  const list = importedBy.get(target)
493
494
  if (list) list.push(file)
494
495
  else importedBy.set(target, [file])
495
- // Descend ONLY through unowned files, or the root project's own:
496
- // see the header.
496
+ // Descend ONLY through unowned files, the root project's own, or
497
+ // the importer's own project's (a config split into `./tasks.mjs`
498
+ // that imports shared tooling): see the header.
497
499
  const owner = ownerOf(target, dirToName)
498
- if (owner === undefined || owner === rootOwner) frontier.push(target)
500
+ if (owner === undefined || owner === rootOwner || owner === from) frontier.push(target)
499
501
  }
500
502
  }
501
503
  }
@@ -28,7 +28,7 @@ import {
28
28
  asTrees,
29
29
  isLiteralPattern,
30
30
  } from '../util/index.js'
31
- import { compileNameGlob } from './filter.js'
31
+ import { compileNameGlob, parseFilter } from './filter.js'
32
32
  import { nonJsonMessage, nonJsonPaths } from './json-data.js'
33
33
 
34
34
  // Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
@@ -78,6 +78,7 @@ const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
78
78
 
79
79
  function validateRetention(retention: unknown, configPath: string): void {
80
80
  const where = `${configPath}: \`cacheRetention\``
81
+ const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
81
82
  if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
82
83
  throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
83
84
  }
@@ -91,23 +92,27 @@ function validateRetention(retention: unknown, configPath: string): void {
91
92
  olderThan !== undefined &&
92
93
  (typeof olderThan !== 'string' || parseDuration(olderThan) === null)
93
94
  ) {
94
- throw new UserError(`${where}.olderThan must be a duration like '30d', '12h', '90m' or '45s'`)
95
+ throw new UserError(
96
+ `${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${JSON.stringify(olderThan)})`,
97
+ )
95
98
  }
96
99
  if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
97
- throw new UserError(`${where}.maxSize must be a size like '10G', '500MB' or '64KB'`)
100
+ throw new UserError(
101
+ `${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${JSON.stringify(maxSize)})`,
102
+ )
98
103
  }
99
104
  // The bounds `vx cache prune` refuses, for the same reason: each evicts
100
105
  // every entry, and here it would do so at the end of every run, the
101
106
  // entries that run just saved included, so nothing ever hits (item 969).
102
107
  if (olderThan !== undefined && parseDuration(olderThan) === 0) {
103
- throw new UserError(`${where}.olderThan of 0 evicts every entry after every run`)
108
+ throw new UserError(`${field('olderThan')} of 0 evicts every entry after every run`)
104
109
  }
105
110
  if (maxSize !== undefined && parseSize(maxSize) === 0) {
106
- throw new UserError(`${where}.maxSize of 0 evicts every entry after every run`)
111
+ throw new UserError(`${field('maxSize')} of 0 evicts every entry after every run`)
107
112
  }
108
113
  if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
109
114
  throw new UserError(
110
- `${where}.maxSize '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
115
+ `${field('maxSize')} '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
111
116
  )
112
117
  }
113
118
  }
@@ -139,6 +144,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
139
144
  ) {
140
145
  throw new UserError(`${configPath}: \`cacheDir\` is only whitespace — name a directory`)
141
146
  }
147
+ // No path can carry one: the mkdir's argument error was reported as an
148
+ // unwritable workspace.
149
+ if (typeof config.cacheDir === 'string' && config.cacheDir.includes('\0')) {
150
+ throw new UserError(`${configPath}: \`cacheDir\` holds a NUL, which no path can carry`)
151
+ }
142
152
  if (config.timeout !== undefined) {
143
153
  if (
144
154
  typeof config.timeout !== 'number' ||
@@ -151,8 +161,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
151
161
  }
152
162
  if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
153
163
  if (config.affectedBase !== undefined) {
154
- // A leading '-' would reach git as an option, not a ref.
155
- if (typeof config.affectedBase !== 'string' || !/^[^-\s]\S*$/.test(config.affectedBase)) {
164
+ // A leading '-' would reach git as an option, not a ref; a NUL fails the
165
+ // spawn with an argument error naming nothing in this file.
166
+ if (
167
+ typeof config.affectedBase !== 'string' ||
168
+ !/^[^-\s\0][^\s\0]*$/.test(config.affectedBase)
169
+ ) {
156
170
  throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
157
171
  }
158
172
  }
@@ -230,6 +244,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
230
244
  if (plug.commands === null || typeof plug.commands !== 'object') {
231
245
  throw new UserError(`${configPath}: \`plugins[${i}].commands\` must be an object`)
232
246
  }
247
+ // A list of commands loaded as the verbs `0`, `1`, … that help listed.
248
+ if (Array.isArray(plug.commands)) {
249
+ throw new UserError(
250
+ `${configPath}: \`plugins[${i}].commands\` must be an object keyed by verb, not an array`,
251
+ )
252
+ }
233
253
  for (const [verb, cmd] of Object.entries(plug.commands as Record<string, unknown>)) {
234
254
  const c = cmd as { description?: unknown; run?: unknown } | null
235
255
  if (
@@ -345,6 +365,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
345
365
  ) {
346
366
  throw new UserError(`${configPath}: \`tags\` must be an array of non-empty strings`)
347
367
  }
368
+ for (const tag of (tags ?? []) as string[]) {
369
+ const why = tagProblem(tag)
370
+ if (why !== null) {
371
+ throw new UserError(
372
+ `${configPath}: tag ${JSON.stringify(tag)} ${why} — \`--filter tag:<name>\` could not ` +
373
+ `name it. Rename the tag.`,
374
+ )
375
+ }
376
+ }
348
377
  const tasks = config.tasks
349
378
  if (tasks === undefined) return
350
379
  if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
@@ -443,6 +472,8 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
443
472
  `(got "${wild}") — list explicit env var names instead`,
444
473
  )
445
474
  }
475
+ for (const n of passThrough as string[])
476
+ assertShellName(n, `${where}.exec.env.passThrough`)
446
477
  }
447
478
  const secret = (env as { secret?: unknown }).secret
448
479
  if (
@@ -469,6 +500,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
469
500
  `${where}.exec.env.define: ${JSON.stringify(k)} is not an env var name (non-empty, no '=' or NUL)`,
470
501
  )
471
502
  }
503
+ assertShellName(k, `${where}.exec.env.define`)
472
504
  if (typeof val !== 'string' || val.includes('\0')) {
473
505
  throw new UserError(`${where}.exec.env.define.${k} must be a string with no NUL`)
474
506
  }
@@ -1330,8 +1362,19 @@ function specForm(spec: string): SpecForm {
1330
1362
  * A name an environment can hold. An `=` splits at the first one, so
1331
1363
  * `define: { 'A=B': 'x' }` gave the child `A` with the value `B=x`; `''`
1332
1364
  * was dropped; a NUL failed the spawn with a hint about exit 127 (item
1333
- * 999). Refused at load, where the config is named.
1365
+ * 999). And the task runs under `sh -c`, whose dash (Linux) drops a
1366
+ * variable whose name is no shell identifier: `my.var` reached a task
1367
+ * under macOS's bash and nothing on Linux. Refused at load, where the
1368
+ * config is named.
1334
1369
  */
1370
+ function assertShellName(name: string, field: string): void {
1371
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1372
+ throw new UserError(
1373
+ `${field}: ${JSON.stringify(name)} is not a shell variable name ([A-Za-z_][A-Za-z0-9_]*); sh would drop it before the task runs`,
1374
+ )
1375
+ }
1376
+ }
1377
+
1335
1378
  function isEnvName(name: unknown): name is string {
1336
1379
  return typeof name === 'string' && name.length > 0 && !name.includes('=') && !name.includes('\0')
1337
1380
  }
@@ -1367,6 +1410,22 @@ export function taskNameProblem(name: string): string | null {
1367
1410
  : null
1368
1411
  }
1369
1412
 
1413
+ /**
1414
+ * Why `--filter tag:<tag>` cannot name `tag`, or null. Asked of the filter
1415
+ * parser itself, so a suffix it learns to read is refused here too: `v1...`
1416
+ * read as a dependency walk from `v1`, `v[2]` as `v` changed since ref `2`.
1417
+ */
1418
+ function tagProblem(tag: string): string | null {
1419
+ if (tag.trim() !== tag) return 'has surrounding whitespace'
1420
+ if (tag.includes('*')) return "holds '*', which makes it a pattern"
1421
+ const f = parseFilter(`tag:${tag}`, '/')
1422
+ if (f.gitSince !== undefined) {
1423
+ return `ends in "[${f.gitSince}]", which a filter reads as a git range`
1424
+ }
1425
+ if (f.withDeps || f.onlyDeps) return "ends in '...', which a filter reads as a dependency walk"
1426
+ return null
1427
+ }
1428
+
1370
1429
  /**
1371
1430
  * Refuse a non-empty glob list made up ENTIRELY of negations.
1372
1431
  *
@@ -1488,6 +1547,12 @@ function assertStringArray(v: unknown, where: string): void {
1488
1547
  if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
1489
1548
  throw new UserError(`${where} must be an array of non-empty strings`)
1490
1549
  }
1550
+ // No path, host or name holds a NUL; a write grant with one reached the
1551
+ // placeholder create as an internal error.
1552
+ const nul = (v as string[]).find((s) => s.includes('\0'))
1553
+ if (nul !== undefined) {
1554
+ throw new UserError(`${where}: ${JSON.stringify(nul)} holds a NUL byte`)
1555
+ }
1491
1556
  }
1492
1557
 
1493
1558
  /**
@@ -5,6 +5,8 @@
5
5
  // {<dir>} same as ./<dir>
6
6
  // // the workspace-root project only (Turbo's name for the root)
7
7
  // tag:<pattern> the projects whose config `tags` hold a match (Nx's `tag:`)
8
+ // <dir>/<glob> a name with a `/` outside a scope that names no project:
9
+ // ./<dir>/<glob> (Nx's `--projects 'apps/*'`)
8
10
  // <pattern>... pattern + its transitive workspace dependencies
9
11
  // ...<pattern> pattern + its transitive workspace dependents
10
12
  // <pattern>^... only the transitive deps of pattern (excluding the matched package)
@@ -58,6 +60,11 @@ export interface ParsedFilter {
58
60
  exactDir?: true
59
61
  /** `tag:<pattern>`: `matcher` is a glob over the projects' tags, not their names. */
60
62
  tag?: true
63
+ /**
64
+ * `apps/*`: a name pattern holding a `/` outside a scope, read as
65
+ * `./apps/*` when it names no project, as Nx's `--projects` reads it.
66
+ */
67
+ dirFallback?: ParsedFilter
61
68
  }
62
69
 
63
70
  export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
@@ -187,6 +194,15 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
187
194
  }
188
195
  }
189
196
 
197
+ // A package name holds a `/` only after its `@scope`, so `apps/*` is a
198
+ // directory for every project named after its package.json; Nx matches
199
+ // a `--projects` entry by name, then by root (find-matching-projects,
200
+ // 23.2.1), and `-p 'apps/*'` matched nothing here.
201
+ const dirFallback =
202
+ !isPath && s.includes('/') && !s.startsWith('@')
203
+ ? parseFilter(`./${s}`, workspaceRoot)
204
+ : undefined
205
+
190
206
  return {
191
207
  raw,
192
208
  negate,
@@ -196,6 +212,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
196
212
  onlyDependents,
197
213
  isPath,
198
214
  matcher,
215
+ ...(dirFallback !== undefined ? { dirFallback } : {}),
199
216
  ...(pathGlob !== undefined ? { pathGlob, pathRoot: workspaceRoot } : {}),
200
217
  ...(pathGlobBase !== undefined ? { pathGlobBase } : {}),
201
218
  ...(gitSince !== undefined ? { gitSince } : {}),
@@ -278,6 +295,8 @@ function matchSelector(
278
295
  for (const p of projects) {
279
296
  if (re.test(p.name)) out.push(p.name)
280
297
  }
298
+ if (out.length === 0 && filter.dirFallback !== undefined)
299
+ return matchSelector(filter.dirFallback, projects, tags)
281
300
  if (out.length > 0 || filter.matcher.includes('/')) return out
282
301
  // pnpm's rule: the scope may be left out (`--filter core` is
283
302
  // `@babel/core`), an exact name only when one package carries it. Nx
@@ -429,7 +429,8 @@ function pmRunsMembers(script: string): boolean {
429
429
  return false
430
430
  }
431
431
 
432
- const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|[^\s;&|()]+)/g
432
+ // A bare target may escape a space or paren (`cd packages/my\ app`).
433
+ const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|(?:\\.|[^\s;&|()\\])+)/g
433
434
 
434
435
  /**
435
436
  * A `cd` into a member's directory, or one holding members, runs that
@@ -439,7 +440,8 @@ const CD = /(?:^|[\s;&|(])cd\s+("[^"]*"|'[^']*'|[^\s;&|()]+)/g
439
440
  */
440
441
  function cdsToMembers(script: string, rootDir: string, memberDirs: readonly string[]): boolean {
441
442
  for (const m of script.matchAll(CD)) {
442
- const target = m[1]!.replace(/^(["'])(.*)\1$/, '$2')
443
+ const quoted = /^(["'])(.*)\1$/.exec(m[1]!)
444
+ const target = quoted ? quoted[2]! : m[1]!.replace(/\\(.)/g, '$1')
443
445
  if (/[$`~*?]/.test(target) || target === '-') return true
444
446
  const dir = path.resolve(rootDir, target)
445
447
  const inside = (a: string, b: string): boolean => a === b || a.startsWith(b + path.sep)
@@ -541,7 +543,7 @@ function siblingRun(script: string, dir: string, others: readonly string[]): str
541
543
  function lernaPackages(dir: string): string[] | undefined {
542
544
  let json: unknown
543
545
  try {
544
- json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8'))
546
+ json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8').replace(/^\uFEFF/, ''))
545
547
  } catch {
546
548
  return undefined
547
549
  }
@@ -406,7 +406,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
406
406
  : []
407
407
  if (empty && unreached.length > 0) {
408
408
  report.push(
409
- `${verb}: ${unreachedHint(unreached)}`,
409
+ `${verb}: ${unreachedHint(unreached, root)}`,
410
410
  hasWorkspaceFile
411
411
  ? `${workspaceName} already exists.`
412
412
  : dry
@@ -73,7 +73,7 @@ export function buildPackageGraph(
73
73
  const dev = bucket(p.packageJson.devDependencies)
74
74
  const add = (key: string, spec: unknown, installed: boolean, into: Set<string>): void => {
75
75
  if (typeof spec !== 'string') return
76
- const target = linked(key, spec.trim(), p.dir, installed)
76
+ const target = linked(key, fromCatalog(p, key, spec.trim()), p.dir, installed)
77
77
  if (target !== undefined && target !== p.name) into.add(target)
78
78
  }
79
79
  // Which entry is installed when a key sits in more than one field,
@@ -300,6 +300,19 @@ export function buildPackageGraph(
300
300
  }
301
301
  }
302
302
 
303
+ /**
304
+ * The spec a `catalog:` / `catalog:<name>` entry stands for in the
305
+ * workspace's catalog, so it is an edge by the rule its range or protocol
306
+ * meets (zenstack's `nuxt: "catalog:"` is `4.3.1`, which the workspace's
307
+ * versionless `nuxt` sample does not satisfy). A catalog the graph was not
308
+ * given or that lacks the key leaves the spec, which keeps its edge.
309
+ */
310
+ function fromCatalog(p: ProjectMeta, key: string, spec: string): string {
311
+ if (p.catalogs === undefined || !spec.startsWith('catalog:')) return spec
312
+ const resolved = p.catalogs.get(spec.slice('catalog:'.length).trim() || 'default')?.[key]
313
+ return typeof resolved === 'string' && !resolved.startsWith('catalog:') ? resolved.trim() : spec
314
+ }
315
+
303
316
  function bucket(field: unknown): Readonly<Record<string, unknown>> {
304
317
  return typeof field === 'object' && field !== null ? (field as Record<string, unknown>) : {}
305
318
  }
@@ -377,8 +390,7 @@ function linkedTarget(
377
390
  if (!MAY_POINT_ELSEWHERE.test(spec)) {
378
391
  const local = byName.get(key)
379
392
  if (local === undefined) return undefined
380
- // A catalog entry's range lives where the graph does not read
381
- // (`pnpm-workspace.yaml`, bun's root `catalog`): it keeps the edge.
393
+ // A catalog entry its workspace's catalog did not resolve keeps the edge.
382
394
  if (!installed || spec.startsWith('catalog:')) return key
383
395
  return satisfies(local, spec) ? key : undefined
384
396
  }
@@ -127,10 +127,12 @@ function servableSource(bytes: Uint8Array, loader: 'ts' | 'js'): string | null {
127
127
  }
128
128
 
129
129
  // What makes Bun run a file as CommonJS is one of these names at the top
130
- // level (an escaped `\u006dodule` too, so any backslash counts). Source
130
+ // level (an escaped `\u006dodule` too, so any backslash counts), or
131
+ // TypeScript's `export =`, which spells none of them. Source
131
132
  // with none of them runs as a module whichever path loads it, so it skips
132
133
  // the parse: 16–20 µs a config, 1,000 cold configs (2026-10-03).
133
- const COMMONJS_HINT = /\b(?:module|exports|require|this|__dirname|__filename)\b|\\/
134
+ const COMMONJS_HINT =
135
+ /\b(?:module|exports|require|this|__dirname|__filename)\b|\\|\bexport\s*=(?!=)/
134
136
 
135
137
  /** vx's module-cache query, which no user wrote: stripped from anything shown to them. */
136
138
  const BUST_QUERY = /\?vx-(?:bust|held)=[^'"\s]*/g
@@ -479,6 +481,7 @@ export async function loadProjectConfigs(
479
481
  let env: Readonly<Record<string, string | undefined>> = {}
480
482
  let cwd = ''
481
483
  let umask = -1
484
+ let unredirect: (() => void) | undefined
482
485
  // More than one evaluation in flight: a change seen after one load may
483
486
  // be another's.
484
487
  let overlapping = false
@@ -588,6 +591,7 @@ export async function loadProjectConfigs(
588
591
  else misses.push(i)
589
592
  }
590
593
  if (misses.length > 0) {
594
+ unredirect = stdoutToStderr()
591
595
  builtins = builtinSnapshot()
592
596
  env = { ...process.env }
593
597
  cwd = process.cwd()
@@ -651,6 +655,7 @@ export async function loadProjectConfigs(
651
655
  )
652
656
  return results.map((r) => (r as Loaded).config)
653
657
  } finally {
658
+ unredirect?.()
654
659
  endRound()
655
660
  if (store !== undefined && !tainted) {
656
661
  if (evals.length > 0) {
@@ -665,6 +670,72 @@ export async function loadProjectConfigs(
665
670
  }
666
671
  }
667
672
 
673
+ /**
674
+ * Every route to fd 1 sent to stderr while configs evaluate in this
675
+ * process; returns the release. A verb's stdout is its output (`vx show
676
+ * --format json`), and a config's `console.log` came out ahead of the
677
+ * JSON. The worker does the same for a repeat load (D-64), `vx mcp` for
678
+ * its whole serve. Taken before a round's built-in snapshot and released
679
+ * after its check, so the guard sees no change of vx's own.
680
+ *
681
+ * Counted, because rounds overlap (`--affected`'s per-file sweep loads each
682
+ * config in a round of its own): a second install over the first moved
683
+ * `Bun.write` and `console` under the first round's snapshot, which refused
684
+ * the loads, and the undos ran out of order and left a redirect in place
685
+ * for good. The first taker installs; the last release restores.
686
+ */
687
+ let redirectHolders = 0
688
+ let undoRedirect = (): void => {}
689
+ /**
690
+ * Bun's own `Console`, taken at load: the console a round finds may be a
691
+ * constructed one (`vx mcp` serves under its own), which has no `Console`.
692
+ * The global's, not `node:console`'s: the playground bundles this module
693
+ * for the browser, where that specifier is a stub.
694
+ */
695
+ const BunConsole = (globalThis.console as { Console?: typeof console.Console }).Console
696
+
697
+ function stdoutToStderr(): () => void {
698
+ if (redirectHolders++ === 0) undoRedirect = installRedirect()
699
+ return () => {
700
+ if (--redirectHolders === 0) undoRedirect()
701
+ }
702
+ }
703
+
704
+ function installRedirect(): () => void {
705
+ const out = process.stdout
706
+ const ownWrite = Object.getOwnPropertyDescriptor(out, 'write')
707
+ const ownConsole = globalThis.console
708
+ const bun = Bun as { write: typeof Bun.write }
709
+ const ownBunWrite = Bun.write
710
+ const ownWriter = Object.getOwnPropertyDescriptor(Bun.stdout, 'writer')
711
+ out.write = ((...args: Parameters<typeof process.stderr.write>) =>
712
+ process.stderr.write(...args)) as typeof out.write
713
+ bun.write = ((dest: unknown, ...rest: unknown[]) =>
714
+ (ownBunWrite as (...a: unknown[]) => Promise<number>)(
715
+ dest === Bun.stdout ? Bun.stderr : dest,
716
+ ...rest,
717
+ )) as typeof Bun.write
718
+ ;(Bun.stdout as { writer: typeof Bun.stdout.writer }).writer = ((
719
+ ...args: Parameters<typeof Bun.stderr.writer>
720
+ ) => Bun.stderr.writer(...args)) as typeof Bun.stdout.writer
721
+ // Bun's console adds `write`, which a constructed Console lacks.
722
+ globalThis.console = Object.assign(new BunConsole!(process.stderr, process.stderr), {
723
+ write: (...data: string[]) => {
724
+ const text = data.join('')
725
+ process.stderr.write(text)
726
+ return text.length
727
+ },
728
+ })
729
+ return () => {
730
+ if (ownWrite === undefined) delete (out as { write?: unknown }).write
731
+ else Object.defineProperty(out, 'write', ownWrite)
732
+ bun.write = ownBunWrite
733
+ if (ownWriter === undefined) delete (Bun.stdout as { writer?: unknown }).writer
734
+ else Object.defineProperty(Bun.stdout, 'writer', ownWriter)
735
+ globalThis.console = ownConsole
736
+ }
737
+ }
738
+
668
739
  /**
669
740
  * The built-in prototypes a config's object is read through. A config that
670
741
  * set `Object.prototype.exec` gave every other project's task that
@@ -751,6 +822,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
751
822
  'sleep',
752
823
  'spawn',
753
824
  'spawnSync',
825
+ 'stderr',
754
826
  'stdout',
755
827
  'stringWidth',
756
828
  'stripANSI',
@@ -1039,6 +1111,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
1039
1111
  // left out: it loads first in every run, filtered or not, so none
1040
1112
  // depends on what else loaded (D-122's case), and plugins' tests and
1041
1113
  // tools hand state through them.
1114
+ const unredirect = stdoutToStderr()
1042
1115
  const builtins = builtinSnapshot('globalThis', true)
1043
1116
  const env = { ...process.env }
1044
1117
  const cwd = process.cwd()
@@ -1053,6 +1126,7 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
1053
1126
  ...restoreCwd(cwd),
1054
1127
  ...restoreUmask(umask),
1055
1128
  ]
1129
+ unredirect()
1056
1130
  // eslint-disable-next-line no-unsafe-finally -- the refusal outranks the load's own error
1057
1131
  if (changed.length > 0) throw builtinsChanged(changed, configPath)
1058
1132
  }