@vzn/vx 0.0.595 → 0.0.597

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 (82) hide show
  1. package/README.md +1 -1
  2. package/launcher.cjs +7 -3
  3. package/package.json +5 -5
  4. package/src/cache/archive.ts +35 -6
  5. package/src/cache/cache.ts +170 -99
  6. package/src/cache/chained-cache.ts +25 -7
  7. package/src/cache/git-inputs.ts +23 -0
  8. package/src/cache/index.ts +3 -0
  9. package/src/cache/inputs.ts +99 -44
  10. package/src/cache/key-fold.ts +1 -1
  11. package/src/cache/layer.ts +9 -6
  12. package/src/cache/policy.ts +5 -0
  13. package/src/cache/zstd.ts +10 -4
  14. package/src/cli/cache.ts +12 -6
  15. package/src/cli/foreign-flags.ts +57 -3
  16. package/src/cli/info.ts +1 -1
  17. package/src/cli/init.ts +2 -2
  18. package/src/cli/last.ts +10 -4
  19. package/src/cli/lock.ts +5 -3
  20. package/src/cli/plan-format.ts +4 -4
  21. package/src/cli/run.ts +73 -30
  22. package/src/cli/select.ts +6 -3
  23. package/src/cli/show.ts +8 -2
  24. package/src/cli/upgrade.ts +1 -1
  25. package/src/cli/watch-filter.ts +63 -12
  26. package/src/cli/watch-fs.ts +11 -3
  27. package/src/cli/watch-judge.ts +15 -8
  28. package/src/cli/watch.ts +18 -11
  29. package/src/cli/workspace-config.ts +3 -1
  30. package/src/config.ts +42 -21
  31. package/src/exec/executor.ts +1 -1
  32. package/src/exec/kill-tree.ts +89 -18
  33. package/src/exec/proc-sample.ts +1 -1
  34. package/src/exec/runner.ts +74 -36
  35. package/src/exec/sandbox-paths.ts +12 -1
  36. package/src/exec/sandbox-runtime.ts +283 -88
  37. package/src/exec/sandbox-violations.ts +115 -15
  38. package/src/graph/scheduler.ts +30 -14
  39. package/src/graph/task-graph.ts +265 -44
  40. package/src/orchestrator/admission.ts +12 -3
  41. package/src/orchestrator/affected-tasks.ts +6 -2
  42. package/src/orchestrator/deferred-outputs.ts +3 -2
  43. package/src/orchestrator/doctor.ts +2 -2
  44. package/src/orchestrator/events.ts +10 -1
  45. package/src/orchestrator/excluded-keys.ts +2 -13
  46. package/src/orchestrator/execute-task.ts +85 -39
  47. package/src/orchestrator/failure-mode.ts +43 -28
  48. package/src/orchestrator/framed-output.ts +8 -8
  49. package/src/orchestrator/index.ts +0 -1
  50. package/src/orchestrator/keyed-projects.ts +6 -3
  51. package/src/orchestrator/logger.ts +21 -9
  52. package/src/orchestrator/metrics.ts +19 -8
  53. package/src/orchestrator/miss-save.ts +9 -13
  54. package/src/orchestrator/options.ts +7 -0
  55. package/src/orchestrator/plan.ts +7 -23
  56. package/src/orchestrator/plugin.ts +37 -16
  57. package/src/orchestrator/prepare.ts +13 -4
  58. package/src/orchestrator/run-artifacts.ts +4 -3
  59. package/src/orchestrator/run-lock.ts +25 -24
  60. package/src/orchestrator/run.ts +22 -5
  61. package/src/orchestrator/sandbox-request.ts +96 -18
  62. package/src/orchestrator/save-lane.ts +5 -5
  63. package/src/orchestrator/signals.ts +22 -4
  64. package/src/orchestrator/stable-keys.ts +3 -11
  65. package/src/orchestrator/summary.ts +7 -13
  66. package/src/orchestrator/task-hash.ts +13 -3
  67. package/src/orchestrator/telemetry.ts +16 -7
  68. package/src/orchestrator/upstream.ts +11 -0
  69. package/src/util/hangup.ts +42 -0
  70. package/src/util/index.ts +2 -0
  71. package/src/util/paths.ts +24 -4
  72. package/src/workspace/affected.ts +79 -42
  73. package/src/workspace/config-eval.ts +27 -6
  74. package/src/workspace/config-imports.ts +8 -53
  75. package/src/workspace/config-schema.ts +56 -9
  76. package/src/workspace/filter.ts +19 -0
  77. package/src/workspace/lockfile.ts +2 -1
  78. package/src/workspace/migrate-scripts.ts +5 -3
  79. package/src/workspace/migration.ts +1 -1
  80. package/src/workspace/project-loader.ts +19 -4
  81. package/src/workspace/repo-id.ts +42 -4
  82. package/src/workspace/workspace.ts +53 -24
@@ -9,6 +9,7 @@ import {
9
9
  CONFIG_EXIT,
10
10
  evalBudgetMs,
11
11
  evaluateConfigFresh,
12
+ thrownValueMessage,
12
13
  WATCHED_BUILTIN_NAMES,
13
14
  } from './config-eval.js'
14
15
  import { hasEsmExport, unprovidedBareImports } from './config-imports.js'
@@ -127,10 +128,12 @@ function servableSource(bytes: Uint8Array, loader: 'ts' | 'js'): string | null {
127
128
  }
128
129
 
129
130
  // 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
131
+ // level (an escaped `\u006dodule` too, so any backslash counts), or
132
+ // TypeScript's `export =`, which spells none of them. Source
131
133
  // with none of them runs as a module whichever path loads it, so it skips
132
134
  // 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|\\/
135
+ const COMMONJS_HINT =
136
+ /\b(?:module|exports|require|this|__dirname|__filename)\b|\\|\bexport\s*=(?!=)/
134
137
 
135
138
  /** vx's module-cache query, which no user wrote: stripped from anything shown to them. */
136
139
  const BUST_QUERY = /\?vx-(?:bust|held)=[^'"\s]*/g
@@ -189,7 +192,7 @@ async function loadDefaultExport(
189
192
  if (err instanceof Error && err.stack !== undefined) {
190
193
  err.stack = err.stack.replaceAll(specifier, configPath).replace(BUST_QUERY, '')
191
194
  }
192
- throw configLoadError(err, configPath, kind) ?? err
195
+ throw loadFailure(err, configPath, kind)
193
196
  } finally {
194
197
  clearTimeout(deadline)
195
198
  unguard()
@@ -339,6 +342,16 @@ export function configLoadError(err: unknown, configPath: string, kind: string):
339
342
  return null
340
343
  }
341
344
 
345
+ /** What a failed load throws: Bun's own errors and non-Error throws as user errors, the rest as thrown. */
346
+ function loadFailure(err: unknown, configPath: string, kind: string): unknown {
347
+ const user = configLoadError(err, configPath, kind)
348
+ if (user !== null) return user
349
+ if (err !== null && typeof err === 'object' && typeof (err as Error).message === 'string') {
350
+ return err
351
+ }
352
+ return new UserError(thrownValueMessage(kind, configPath, Bun.inspect(err, { compact: true })))
353
+ }
354
+
342
355
  export interface LoadProjectConfigOptions {
343
356
  /** Observe the CURRENT environment: no eval cache, even when `evalCache` is passed. */
344
357
  fresh?: boolean
@@ -521,7 +534,7 @@ export async function loadProjectConfigs(
521
534
  if (repeat) refuseUnprovidedImports(bytes!, configPath, 'Project')
522
535
  const mod = repeat
523
536
  ? await evaluateConfigFresh(configPath).catch((err: unknown) => {
524
- throw configLoadError(err, configPath, 'Project') ?? err
537
+ throw loadFailure(err, configPath, 'Project')
525
538
  })
526
539
  : await loadDefaultExport(configPath, 'Project', bytes!)
527
540
  // Before anything reads through them: a replaced `Array.prototype.includes`
@@ -810,6 +823,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
810
823
  'env',
811
824
  'file',
812
825
  'hash',
826
+ 'inspect',
813
827
  'main',
814
828
  'nanoseconds',
815
829
  'plugin',
@@ -818,6 +832,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
818
832
  'semver',
819
833
  'serve',
820
834
  'sleep',
835
+ 'sleepSync',
821
836
  'spawn',
822
837
  'spawnSync',
823
838
  'stderr',
@@ -13,6 +13,7 @@ import {
13
13
  lstatSync,
14
14
  openSync,
15
15
  readFileSync,
16
+ realpathSync,
16
17
  statSync,
17
18
  } from 'node:fs'
18
19
  import path from 'node:path'
@@ -25,12 +26,20 @@ import path from 'node:path'
25
26
  * git: those share nothing.
26
27
  */
27
28
  export async function repoIdOf(root: string): Promise<string | null> {
28
- const located = locateGitDir(root)
29
+ // Git walks the physical path; the logical one found no `.git` through a
30
+ // symlink from outside the repository, and an alias inside it another id.
31
+ let real: string
32
+ try {
33
+ real = realpathSync(root)
34
+ } catch {
35
+ real = path.resolve(root)
36
+ }
37
+ const located = locateGitDir(real)
29
38
  if (located === null) return null
30
39
  const identity =
31
40
  (await remoteIdentity(root, located.commonDir)) ?? (await firstCommit(located.commonDir, root))
32
41
  if (identity === null) return null
33
- const rel = path.relative(located.gitRoot, path.resolve(root)).split(path.sep).join('/')
42
+ const rel = path.relative(located.gitRoot, real).split(path.sep).join('/')
34
43
  const key = sha256(`${identity}#${rel}`)
35
44
  return sha256(key).slice(0, 16)
36
45
  }
@@ -108,11 +117,40 @@ function readOwnedFile(p: string): string | null {
108
117
  }
109
118
  }
110
119
 
111
- /** The remote's identity from the config file, else from `git remote -v`. */
120
+ /**
121
+ * The remote's identity from the config file, else from `git remote -v`
122
+ * when the file cannot settle it: a remote none of whose urls parse (a
123
+ * rewrite may live in a file vx does not read, as Nx asks git), or
124
+ * remotes git could read from `config.worktree`.
125
+ */
112
126
  async function remoteIdentity(root: string, commonDir: string): Promise<string | null> {
113
127
  const config = readOwnedFile(path.join(commonDir, 'config'))
114
128
  const remotes = config === null ? null : parseGitConfigRemotes(config)
115
- const urls = remotes ?? (await remotesFromGit(root))
129
+ if (remotes !== null) {
130
+ const picked = pickRemote(remotes)
131
+ // A remote-less repo with nothing to pull one from elsewhere spawns
132
+ // nothing: git would answer the same, and that spawn was a cost per run.
133
+ if (picked !== null || (remotes.length === 0 && !worktreeConfigOn(config!))) return picked
134
+ }
135
+ return pickRemote(await remotesFromGit(root))
136
+ }
137
+
138
+ /** `extensions.worktreeConfig` set: git reads `config.worktree` too. */
139
+ function worktreeConfigOn(contents: string): boolean {
140
+ let inExtensions = false
141
+ for (const raw of contents.split('\n')) {
142
+ const line = raw.trim()
143
+ if (line.startsWith('[')) {
144
+ inExtensions = /^\[\s*extensions\s*\]/i.test(line)
145
+ continue
146
+ }
147
+ const m = /^worktreeconfig\s*(?:=\s*(\S*))?/i.exec(line)
148
+ if (inExtensions && m !== null && !/^(false|no|off|0)$/i.test(m[1] ?? 'true')) return true
149
+ }
150
+ return false
151
+ }
152
+
153
+ function pickRemote(urls: [string, string][]): string | null {
116
154
  const found = new Map<string, string>()
117
155
  let first: string | null = null
118
156
  for (const [name, url] of urls) {
@@ -1,4 +1,4 @@
1
- import { constants, type Dirent } from 'node:fs'
1
+ import { constants, existsSync, type Dirent } from 'node:fs'
2
2
  import { access, readdir, realpath, stat } from 'node:fs/promises'
3
3
  import { homedir } from 'node:os'
4
4
  import path from 'node:path'
@@ -267,11 +267,13 @@ async function readRootManifests(
267
267
  }
268
268
  catalogs = catalogsOf([parsed])
269
269
  const packages = (parsed as { packages?: unknown }).packages
270
- if (packages !== undefined) {
270
+ // A list commented out leaves `packages:` null, which pnpm reads as
271
+ // absent; vx refused it as "must be an array" (D-149).
272
+ if (packages !== undefined && packages !== null) {
271
273
  return { globs: assertGlobList(packages, yamlPath, 'packages'), catalogs }
272
274
  }
273
275
  }
274
- // No `packages:`: pnpm 10 keeps its settings and catalogs in this file
276
+ // No `packages:` (or an empty one): pnpm 10 keeps its settings and catalogs in this file
275
277
  // for a single-package repo too. The root's package.json decides, as it
276
278
  // would without the file; read as an empty list, the root found zero
277
279
  // projects and every verb ran nothing and exited 0 (item 984).
@@ -288,13 +290,17 @@ async function readRootManifests(
288
290
  )
289
291
  }
290
292
  if (ws === undefined || ws === null) return { globs: ['.'], catalogs }
293
+ // bun's `{ catalog }` and yarn's `{ nohoist }` name no members, and both
294
+ // managers run the root alone; vx refused them as no array (D-151).
295
+ if (typeof ws === 'object' && !Array.isArray(ws) && !('packages' in ws)) {
296
+ return { globs: ['.'], catalogs }
297
+ }
291
298
  if (ws && typeof ws === 'object' && !Array.isArray(ws) && 'packages' in ws) {
292
- const globs = assertGlobList(
293
- (ws as { packages?: unknown }).packages ?? [],
294
- pkgPath,
295
- 'workspaces.packages',
296
- )
297
- return { globs, catalogs }
299
+ const packages = (ws as { packages?: unknown }).packages
300
+ // Yarn 1 and 4 run the root alone under `packages: null`; read as an
301
+ // empty list, the root was no project and every verb ran nothing (D-150).
302
+ if (packages === null) return { globs: ['.'], catalogs }
303
+ return { globs: assertGlobList(packages, pkgPath, 'workspaces.packages'), catalogs }
298
304
  }
299
305
  return { globs: assertGlobList(ws, pkgPath, 'workspaces'), catalogs }
300
306
  }
@@ -348,8 +354,13 @@ export async function unreachedPackages(workspace: Workspace): Promise<string[]>
348
354
  return found.sort()
349
355
  }
350
356
 
351
- /** The line for `unreachedPackages`' finding: the cause, the packages, the glob to add. */
352
- export function unreachedHint(unreached: readonly string[]): string {
357
+ /**
358
+ * The line for `unreachedPackages`' finding: the cause, the packages, the
359
+ * glob to add, and where. A `pnpm-workspace.yaml` with no `packages:` (pnpm
360
+ * keeps its settings there) is where pnpm reads the globs: the hint said
361
+ * there was no such file and named package.json, beside ngrx's (2026-10-07).
362
+ */
363
+ export function unreachedHint(unreached: readonly string[], root: string): string {
353
364
  const n = unreached.length
354
365
  const shown = unreached.slice(0, 3).join(', ') + (n > 3 ? ` and ${n - 3} more` : '')
355
366
  const globs = [
@@ -357,11 +368,11 @@ export function unreachedHint(unreached: readonly string[]): string {
357
368
  ]
358
369
  .map((g) => `"${g}"`)
359
370
  .join(', ')
360
- return (
361
- `package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), so the root is the only project ` +
362
- `and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}. ` +
363
- `Add \`"workspaces": [${globs}]\` to package.json and re-run.`
364
- )
371
+ const rest = `so the root is the only project and ${n} package.json below it ${n === 1 ? 'is' : 'are'} not: ${shown}.`
372
+ return existsSync(path.join(root, 'pnpm-workspace.yaml'))
373
+ ? `pnpm-workspace.yaml lists no \`packages\`, ${rest} Add \`packages: [${globs}]\` to pnpm-workspace.yaml and re-run.`
374
+ : `package.json declares no \`workspaces\` (and there is no pnpm-workspace.yaml), ${rest} ` +
375
+ `Add \`"workspaces": [${globs}]\` to package.json and re-run.`
365
376
  }
366
377
 
367
378
  /**
@@ -447,13 +458,27 @@ function extglobRefusal(pattern: string, file: string, field: string): UserError
447
458
  * to the workspace root.
448
459
  */
449
460
  export function resolveCacheDir(root: string, config: WorkspaceConfig | null): string {
450
- const rel = config?.cacheDir ?? (process.env['VX_CACHE_DIR'] || path.join('.vx', 'cache'))
461
+ const env = process.env['VX_CACHE_DIR']
462
+ // `cacheDir: ' '` is refused at load (X-22); the variable made the same
463
+ // directory named in spaces at the root (D-160).
464
+ if (config?.cacheDir === undefined && env !== undefined && env !== '' && env.trim() === '') {
465
+ throw new UserError('VX_CACHE_DIR is only whitespace — name a directory, or unset it')
466
+ }
467
+ const rel = config?.cacheDir ?? (env || path.join('.vx', 'cache'))
468
+ const home = process.env['HOME'] || homedir()
451
469
  // No shell expands `~` in a config string or a quoted variable, and
452
470
  // `'~/.cache/vx'` made a directory named `~` in the workspace.
453
- if (rel.startsWith('~/')) {
454
- return path.join(process.env['HOME'] || homedir(), rel.slice(1))
471
+ const dir =
472
+ rel === '~' || rel.startsWith('~/') ? path.join(home, rel.slice(1)) : path.resolve(root, rel)
473
+ // The cache writes a `*` .gitignore beside its index: a bare `~` made a
474
+ // directory named `~`, and expanded it would land in home itself (D-153).
475
+ if (path.resolve(dir) === path.resolve(home)) {
476
+ const source = config?.cacheDir !== undefined ? 'cacheDir' : 'VX_CACHE_DIR'
477
+ throw new UserError(
478
+ `${source} ${JSON.stringify(rel)} is the home directory itself, where the cache's \`*\` .gitignore and index would land — name a directory under it, like '~/.cache/vx'`,
479
+ )
455
480
  }
456
- return path.resolve(root, rel)
481
+ return dir
457
482
  }
458
483
 
459
484
  /**
@@ -704,12 +729,17 @@ async function readManifest(root: string, dir: string, file: string): Promise<st
704
729
  )
705
730
  if (searchable) unreadable(err, file)
706
731
  process.stderr.write(
707
- `vx: ${relPosix(root, dir)} is not readable by this user — skipped, with any project in it\n`,
732
+ `vx: ${shownDir(root, dir)} is not readable by this user — skipped, with any project in it\n`,
708
733
  )
709
734
  return null
710
735
  }
711
736
  }
712
737
 
738
+ /** `dir` as a discovery line names it: the root's own path is '' (D-127, D-154). */
739
+ function shownDir(root: string, dir: string): string {
740
+ return relPosix(root, dir) || 'the workspace root'
741
+ }
742
+
713
743
  export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]> {
714
744
  return discoverProjects(workspace)
715
745
  }
@@ -762,7 +792,7 @@ export async function discoverProjects(
762
792
  // same flight as the failed read, so naming it costs nothing.
763
793
  if (configPath !== null) {
764
794
  process.stderr.write(
765
- `vx: ${relPosix(workspace.root, dir)} has a vx config but no package.json — skipped: vx names a project by its package.json "name"\n`,
795
+ `vx: ${shownDir(workspace.root, dir)} has a vx config but no package.json — skipped: vx names a project by its package.json "name"\n`,
766
796
  )
767
797
  }
768
798
  return null
@@ -793,9 +823,8 @@ export async function discoverProjects(
793
823
  // fine for a dir that declares no tasks; a dir with a vx config was
794
824
  // meant to run.
795
825
  if (configPath !== null) {
796
- const rel = relPosix(workspace.root, dir)
797
826
  process.stderr.write(
798
- `vx: ${rel === '' ? 'the workspace root' : rel} has a vx config but its package.json has no "name" — skipped\n`,
827
+ `vx: ${shownDir(workspace.root, dir)} has a vx config but its package.json has no "name" — skipped\n`,
799
828
  )
800
829
  }
801
830
  continue