@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
@@ -3,6 +3,9 @@
3
3
  //
4
4
  // `bin.ts` and the scheduler consult `isUserError` to decide what to print.
5
5
 
6
+ import { realpathSync } from 'node:fs'
7
+ import os from 'node:os'
8
+
6
9
  export class UserError extends Error {
7
10
  constructor(message: string) {
8
11
  super(message)
@@ -23,3 +26,86 @@ export class UserError extends Error {
23
26
  export function isUserError(err: unknown): err is UserError {
24
27
  return err instanceof UserError || (err instanceof Error && err.name === 'UserError')
25
28
  }
29
+
30
+ /**
31
+ * An `EACCES`, `EPERM` or `EROFS` from the file system: a path vx must
32
+ * write is not this user's to write (a root-owned `.vx`, a read-only
33
+ * checkout). The environment's failure, reported like a UserError — one
34
+ * line naming the path — never as an internal error with a stack.
35
+ */
36
+ export function isPermissionError(err: unknown): err is NodeJS.ErrnoException {
37
+ if (!(err instanceof Error)) return false
38
+ const code = (err as NodeJS.ErrnoException).code
39
+ return code === 'EACCES' || code === 'EPERM' || code === 'EROFS'
40
+ }
41
+
42
+ /** What follows the path in a permission error's one line. */
43
+ export const PERMISSION_HINT = 'a path vx must write is not writable by this user'
44
+
45
+ /** `ENOSPC` or `EDQUOT`: the disk a path is on is full. */
46
+ export function isDiskFull(err: unknown): err is NodeJS.ErrnoException {
47
+ if (!(err instanceof Error)) return false
48
+ const code = (err as NodeJS.ErrnoException).code
49
+ return code === 'ENOSPC' || code === 'EDQUOT'
50
+ }
51
+
52
+ /** A file system refusing a write for a reason no code path caused: permission or space. */
53
+ export function isFsRefusal(err: unknown): err is NodeJS.ErrnoException {
54
+ return isPermissionError(err) || isDiskFull(err)
55
+ }
56
+
57
+ export const DISK_FULL_HINT = 'the disk that path is on is full'
58
+
59
+ /** The one-line hint after a refusal's path. */
60
+ export function fsRefusalHint(err: NodeJS.ErrnoException): string {
61
+ return isDiskFull(err) ? DISK_FULL_HINT : PERMISSION_HINT
62
+ }
63
+
64
+ /**
65
+ * A spawn that could not run at all — Bun throws `ENOENT` ("Executable not
66
+ * found in $PATH") synchronously from `Bun.spawn` and `Bun.spawnSync` alike.
67
+ * Every git call site tells it from a git that ran and failed, because the
68
+ * remedy differs: install git, not "git init".
69
+ */
70
+ export function isExecutableMissing(err: unknown): boolean {
71
+ return (err as NodeJS.ErrnoException)?.code === 'ENOENT'
72
+ }
73
+
74
+ /**
75
+ * The one refusal for a git that is not on PATH, wherever vx needed it — a
76
+ * `--affected` base, the input enumeration, a watch judgement. A minimal
77
+ * image without git met a stack from `defaultAffectedBase` before
78
+ * (2026-09-16); the enumeration had this line and the others did not.
79
+ */
80
+ export function gitSpawnRefusal(cwd: string): UserError {
81
+ return new UserError(
82
+ `vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
83
+ )
84
+ }
85
+
86
+ export const TMPDIR_HINT = 'point TMPDIR at a writable directory'
87
+
88
+ /**
89
+ * A temp-directory refusal: the path is under `os.tmpdir()` and the error
90
+ * says it is missing, a file, or not writable. The knob is TMPDIR, and a
91
+ * line that names the path should name the knob — a minimal image's
92
+ * sandboxed task said "EACCES … mkdtemp '/tmp/…/srt-obs-…'" and nothing
93
+ * else (2026-09-16). Only for a site whose path IS the temp directory by
94
+ * construction: a workspace under /tmp would pass the path test too.
95
+ */
96
+ export function isTmpdirRefusal(err: unknown): err is NodeJS.ErrnoException {
97
+ if (!(err instanceof Error)) return false
98
+ const code = (err as NodeJS.ErrnoException).code
99
+ if (code !== 'ENOENT' && code !== 'ENOTDIR' && !isPermissionError(err)) return false
100
+ const p = (err as NodeJS.ErrnoException).path
101
+ const named = typeof p === 'string' ? p : err.message
102
+ const tmp = os.tmpdir()
103
+ // The error may name the resolved directory (macOS: /tmp is /private/tmp);
104
+ // a temp directory that does not exist has no real path, so the name as
105
+ // given is the only one it can carry.
106
+ let real = tmp
107
+ try {
108
+ real = realpathSync(tmp)
109
+ } catch {}
110
+ return named.includes(tmp) || named.includes(real)
111
+ }
package/src/util/hash.ts CHANGED
@@ -13,10 +13,19 @@
13
13
  // folds `part` into the running digest. Equivalent to the old
14
14
  // `CryptoHasher.update(...).update(...).digest()` pattern, without
15
15
  // allocating an intermediate buffer.
16
+ //
17
+ // Bun reads only the LOW 32 bits of that seed (measured, item 682), so a
18
+ // bare `xxHash3(part, prev)` carries 32 bits of state from step to step:
19
+ // two input sets whose running digests share their low halves merge at
20
+ // the next step, and a birthday search found such a pair in 0.08 s — a
21
+ // stale hit at ~2^-32 per step, not 2^-64. The seed is XORed back in
22
+ // (feed-forward): states that share their low half keep their exact
23
+ // high-half difference through every later step, so they never merge,
24
+ // and a seed of 0 changes nothing, so single-shot digests are unchanged.
16
25
 
17
26
  /** xxHash3 bigint output. Use as the seed for the next chain step. */
18
27
  export function xxh3(input: string | Uint8Array, seed: bigint = 0n): bigint {
19
- return Bun.hash.xxHash3(input, seed)
28
+ return Bun.hash.xxHash3(input, seed) ^ seed
20
29
  }
21
30
 
22
31
  /** xxHash3 hex-encoded, fixed 16 chars (zero-padded). */
package/src/util/index.ts CHANGED
@@ -1,13 +1,50 @@
1
1
  // Module contract. Cross-module imports must come through here; see
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
- export { isUserError, UserError } from './errors.js'
4
+ export {
5
+ DISK_FULL_HINT,
6
+ fsRefusalHint,
7
+ isDiskFull,
8
+ isFsRefusal,
9
+ isTmpdirRefusal,
10
+ TMPDIR_HINT,
11
+ isPermissionError,
12
+ isUserError,
13
+ PERMISSION_HINT,
14
+ UserError,
15
+ gitSpawnRefusal,
16
+ isExecutableMissing,
17
+ } from './errors.js'
5
18
  export { xxh3, xxh3hex } from './hash.js'
6
19
  export { mark, printTimings, span } from './timing.js'
7
20
  export { clampInt, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
8
- export { relPosix, staticPrefix, wholeSubtreePrefixes } from './paths.js'
9
- export { settleWithin, teardownTimeoutMs } from './settle.js'
10
- export { parseSize } from './size.js'
21
+ export {
22
+ asTrees,
23
+ BUN_GLOB_WILDCARDS,
24
+ grantPrefix,
25
+ isLiteralPattern,
26
+ GLOB_WILDCARDS,
27
+ normalizeBunGlob,
28
+ normalizeGlob,
29
+ relPosix,
30
+ staticPrefix,
31
+ taskGlob,
32
+ wholeSubtreePrefixes,
33
+ } from './paths.js'
34
+ export { killGraceMs, settleWithin, teardownTimeoutMs } from './settle.js'
35
+ export { formatBytes, parseDuration, parseSize } from './size.js'
36
+ export {
37
+ cgroupCpuQuota,
38
+ cgroupMemoryLimitBytes,
39
+ machineMemoryBytes,
40
+ machineParallelism,
41
+ type CgroupProbe,
42
+ } from './cgroup.js'
11
43
  export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.js'
12
44
  export { ulid } from './ulid.js'
13
- export { editDistance } from './edit-distance.js'
45
+ export { splitTaskId } from './task-id.js'
46
+ export { editDistance, nearMatches, nearest } from './edit-distance.js'
47
+ export { CORE_VERBS, DISPATCHED_VERBS, MOVED_VERBS } from './verbs.js'
48
+ export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.js'
49
+ export { executablePath } from './which.js'
50
+ export { procfsIsOwn } from './procfs.js'
package/src/util/paths.ts CHANGED
@@ -24,10 +24,43 @@ export function relPosix(from: string, to: string): string {
24
24
  * baseline joins it onto a dir to get a write prefix, and the deferral
25
25
  * eligibility gate compares two of them for overlap. A second copy is
26
26
  * how the two would disagree about what a prefix is.
27
+ *
28
+ * The spelling is normalized FIRST, here rather than at each call site,
29
+ * because sharing the function was not enough to make the two callers
30
+ * agree: the sandbox joins the prefix onto a directory and `path.join`
31
+ * folds `./`, `//` and `/./` on the way, while the deferral gate compares
32
+ * the raw strings and does not. So `./out/**` and `out/**` named the same
33
+ * tree to the resolver, the sandbox and the watcher, and two DIFFERENT
34
+ * prefixes to the gate — which then deferred a producer a same-project
35
+ * reader's key could see, moving that key with a transfer flag (item 441,
36
+ * measured through a real run: `./out/**` keyed the consumer two ways).
37
+ * Every glob reaching here is user-written, so every one takes the rule.
38
+ */
39
+ export function staticPrefix(rawGlob: string): string {
40
+ return prefixOf(normalizeGlob(rawGlob), GLOB_WILDCARDS)
41
+ }
42
+
43
+ /**
44
+ * {@link staticPrefix} under `Bun.Glob`'s own alphabet, for a sandbox GRANT:
45
+ * the grant is expanded by a `Bun.Glob` scan that reads `[ab]` as a class, so
46
+ * its prefix must stop there too, or `write: ['g/[ab].txt']` would create a
47
+ * DIRECTORY named `g/[ab].txt`.
27
48
  */
28
- export function staticPrefix(glob: string): string {
29
- const wildcardIdx = glob.search(/[*?[\]]/)
30
- if (wildcardIdx === -1) return glob
49
+ export function grantPrefix(rawGlob: string): string {
50
+ return prefixOf(normalizeBunGlob(rawGlob), BUN_GLOB_WILDCARDS)
51
+ }
52
+
53
+ function prefixOf(glob: string, wildcards: RegExp): string {
54
+ // A brace set is a wildcard too: `{dist,build}/**` reaches either dir,
55
+ // and reading it as the literal directory `{dist,build}` gave the
56
+ // sandbox baseline a prefix that exists nowhere (2026-09-10).
57
+ const wildcardIdx = glob.search(wildcards)
58
+ // A LITERAL keeps its trailing slash through `normalizeGlob` on purpose
59
+ // (`asTrees` is what turns `out/` into the tree `out` + `out/**`), but a
60
+ // prefix with a slash on the end compares as a different string: `out/`
61
+ // against `out/sub` found no overlap where `out` does. The prefix is a
62
+ // directory either way. `/` is the root, not a trailing slash.
63
+ if (wildcardIdx === -1) return glob === '/' ? '/' : glob.replace(/\/+$/, '')
31
64
  const head = glob.slice(0, wildcardIdx)
32
65
  const lastSep = head.lastIndexOf('/')
33
66
  if (lastSep === -1) return '.'
@@ -47,8 +80,8 @@ export function staticPrefix(glob: string): string {
47
80
  export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null {
48
81
  if (globs.length === 0) return null
49
82
  const out: string[] = []
50
- for (const g of globs) {
51
- const m = /^([^*?[\]{}!]+?)\/\*\*$/.exec(g)
83
+ for (const g of globs.map(normalizeGlob)) {
84
+ const m = /^([^*?{}!]+?)\/\*\*$/.exec(g)
52
85
  if (m === null) return null
53
86
  const dir = m[1]!.replace(/\/+$/, '')
54
87
  if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
@@ -57,3 +90,133 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
57
90
  }
58
91
  return [...new Set(out)]
59
92
  }
93
+
94
+ /**
95
+ * The spellings a reader, Turbo and `.gitignore` all accept but a matcher
96
+ * fed the raw string turns into NOTHING — and a task keyed on nothing
97
+ * replays old outputs as a green hit (2026-09-10, probed one by one):
98
+ * a leading `./`, an inner `/./` segment, a doubled `//`, and a trailing
99
+ * `/` on a pattern (`src/*\/` means the trees under `src`, so it becomes
100
+ * `src/*\/**`; a trailing slash on a LITERAL is `asTrees`' job). Applied
101
+ * after an optional `!`; a bare `.` is the empty entry the schema refuses.
102
+ */
103
+ export function normalizeGlob(glob: string): string {
104
+ return normalizeWith(glob, GLOB_WILDCARDS)
105
+ }
106
+
107
+ /**
108
+ * {@link normalizeGlob} for a glob in `Bun.Glob`'s own alphabet (a workspace
109
+ * member glob, a sandbox grant): the same spellings, but `\[` stays escaped,
110
+ * since there it is what keeps a bracket from opening a class.
111
+ */
112
+ export function normalizeBunGlob(glob: string): string {
113
+ return normalizeWith(glob, BUN_GLOB_WILDCARDS)
114
+ }
115
+
116
+ function normalizeWith(glob: string, wildcards: RegExp): string {
117
+ const neg = glob.startsWith('!')
118
+ let g = neg ? glob.slice(1) : glob
119
+ g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
120
+ if (g === '.') g = ''
121
+ // Where a bracket is literal, Turbo's escaped spelling `\[id\]` names the
122
+ // same path as `[id]`; one spelling is what lets every literal fast path
123
+ // (string compare, `settleLiterals`, `asTrees`) see it as a literal.
124
+ if (wildcards === GLOB_WILDCARDS && g.includes('\\')) g = g.replace(/\\([[\]])/g, '$1')
125
+ if (wildcards.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
126
+ return neg ? `!${g}` : g
127
+ }
128
+
129
+ /**
130
+ * True when a pattern carries no wildcard — it names exactly one path.
131
+ *
132
+ * The character SET is the whole content: in a task glob `*`, `?` and a
133
+ * brace alternation are wildcards, so a pattern holding any of them must
134
+ * be MATCHED, never compared as a string. It lives here, exported, because
135
+ * four places asked the same question and one of them asked it with a
136
+ * smaller set: `graph/task-graph.ts` omitted `{}`, so `dist/{a,b}.txt`
137
+ * counted as a literal and the overlapping-output refusal compared it to
138
+ * `dist/a.txt` as two unequal strings — the two tasks were accepted and
139
+ * then deleted each other's outputs, green, every run (item 495). That is
140
+ * the same divergence `asTrees` was moved here to end in item 442, and the
141
+ * same one that removed `@vzn/vx-migrate`'s copy of `outputsOverlap` in
142
+ * item 445.
143
+ */
144
+ export function isLiteralPattern(glob: string): boolean {
145
+ return !GLOB_WILDCARDS.test(glob)
146
+ }
147
+
148
+ /**
149
+ * The alphabet of a TASK glob (`cache.inputs.files`, `cache.outputs.files`,
150
+ * `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
151
+ * need the POSITION of the first wildcard rather than a verdict. Every site
152
+ * that asks "must this declaration be matched?" reads this one regex: item
153
+ * 495 found the fourth copy with a smaller set, and item 577 found the
154
+ * class written out nine times — a spelling that drifts is how two guards
155
+ * come to answer differently for one pattern.
156
+ *
157
+ * `[` and `]` are NOT in it: a bracket is a literal character in a task
158
+ * glob, and there are no character classes (item 667). Route directories
159
+ * are named `[id]` across Next.js, SvelteKit and Astro, and read as a class
160
+ * `app/[id]/**` matched `app/i/…` and `app/d/…` and never the route: the
161
+ * file never entered the key (a stale hit) and the output clean deleted an
162
+ * unrelated `app/i/page.js`. {@link taskGlob} is how such a glob reaches
163
+ * `Bun.Glob`, whose own alphabet still reads the class.
164
+ */
165
+ export const GLOB_WILDCARDS = /[*?{}]/
166
+
167
+ /**
168
+ * `Bun.Glob`'s own alphabet, the class included, for the globs vx does not
169
+ * own: package-manager workspace members and `--filter` path globs follow
170
+ * npm/pnpm semantics, and a sandbox grant's prefix must stop where the scan
171
+ * that expands the grant (`Bun.Glob`, `MOUNT_WILDCARDS`) sees a wildcard.
172
+ */
173
+ export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
174
+
175
+ /**
176
+ * Compile a task glob for `Bun.Glob`, with every bare bracket escaped so it
177
+ * matches itself (item 667). Every task glob is compiled here and nowhere
178
+ * else: one site that forgot is a route directory that keys nothing.
179
+ */
180
+ export function taskGlob(pattern: string): Bun.Glob {
181
+ // A `]` with no `[` before it is already literal to `Bun.Glob`.
182
+ if (!pattern.includes('[')) return new Bun.Glob(pattern)
183
+ return new Bun.Glob(pattern.replace(/(?<!\\)[[\]]/g, '\\$&'))
184
+ }
185
+
186
+ function stripTrailingSlash(p: string): string {
187
+ return p.replace(/\/+$/, '')
188
+ }
189
+
190
+ /**
191
+ * A literal entry names a file OR a directory tree: `src/` and `src` both
192
+ * mean everything under `src`, as they do in Turbo and every `.gitignore`.
193
+ * A glob matcher sees only the literal path, so `['src/']` folded ZERO
194
+ * files — a key that never moves with its source, and the most common
195
+ * turbo.json shape (`"outputs": ["dist"]`) captured nothing. Every
196
+ * literal therefore compiles to itself plus its subtree; a literal that
197
+ * names a file still matches exactly that file, since `x/**` matches
198
+ * nothing under a file.
199
+ *
200
+ * It lives in `util` rather than beside the resolver because it is not
201
+ * only the resolver's rule. It decides what a clean DELETES, so the
202
+ * graph's overlapping-output refusal has to read the same one: while it
203
+ * did not, `dist` and `dist/app.js` compared as two unequal literals and
204
+ * the run that wiped `dist/app.js` reported success (item 442). `graph`
205
+ * may not import `cache`, and a second copy is how the two would
206
+ * disagree — which is exactly what item 441 measured about
207
+ * `staticPrefix`.
208
+ */
209
+ export function asTrees(patterns: readonly string[]): string[] {
210
+ const out: string[] = []
211
+ for (const raw of patterns) {
212
+ const p = normalizeGlob(raw)
213
+ if (!isLiteralPattern(p)) {
214
+ out.push(p)
215
+ continue
216
+ }
217
+ const lit = stripTrailingSlash(p)
218
+ if (lit.length === 0) continue
219
+ out.push(lit, `${lit}/**`)
220
+ }
221
+ return out
222
+ }
@@ -0,0 +1,20 @@
1
+ // Is `/proc` this process's view of the pid namespace? A procfs mounted
2
+ // for another namespace is a table of strangers: under the sandbox's
3
+ // nested pid namespace vx is pid 2 while `/proc/self` names 7, and
4
+ // `/proc/<our child's pid>` is some other process or none (2026-09-24).
5
+ // Asked once per process: the mount does not change under a run.
6
+
7
+ import { readlinkSync } from 'node:fs'
8
+
9
+ let own: boolean | undefined
10
+
11
+ export function procfsIsOwn(): boolean {
12
+ if (own === undefined) {
13
+ try {
14
+ own = process.platform === 'linux' && readlinkSync('/proc/self') === String(process.pid)
15
+ } catch {
16
+ own = false
17
+ }
18
+ }
19
+ return own
20
+ }
@@ -28,9 +28,30 @@ export function teardownTimeoutMs(): number {
28
28
  }
29
29
 
30
30
  /**
31
- * Await `p`, giving up after `ms`. Returns true when `p` settled first,
31
+ * The SIGTERM→SIGKILL grace a run grants a child that ignores SIGTERM: the
32
+ * one-shot timeout escalation (`exec/runner.ts`) and the end-of-run
33
+ * persistent shutdown (`orchestrator/persistent.ts`) share it. Two seconds
34
+ * is generous for a real server's cleanup and is what every run waits when
35
+ * a child wedges. `VX_KILL_GRACE_MS` overrides it, read per call and bounded
36
+ * like the teardown deadline (out of range falls back to the default): the
37
+ * tests that prove the escalation used to wait the full two seconds each,
38
+ * a third of the suite's wall time for a claim a 200 ms grace proves too.
39
+ */
40
+ export function killGraceMs(defaultMs: number): number {
41
+ const raw = process.env['VX_KILL_GRACE_MS']
42
+ if (raw !== undefined && /^[0-9]+$/.test(raw)) {
43
+ const n = Number(raw)
44
+ if (n > 0 && n <= MAX_TIMEOUT_MS) return n
45
+ }
46
+ return defaultMs
47
+ }
48
+
49
+ /**
50
+ * Await `p`, giving up after `ms`. Returns true when `p` fulfilled first,
32
51
  * false when the deadline won — the caller decides whether a lost result
33
- * is worth reporting.
52
+ * is worth reporting. A rejection before the deadline is not a timeout: it
53
+ * propagates, so the caller reports the failure rather than a hang
54
+ * (`tests/util-settle.test.ts`).
34
55
  */
35
56
  export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
36
57
  let timer: ReturnType<typeof setTimeout> | undefined
package/src/util/size.ts CHANGED
@@ -18,3 +18,36 @@ export function parseSize(input: string): number | null {
18
18
  u === '' ? 1 : u === 'K' ? 1024 : u === 'M' ? 1024 * 1024 : u === 'G' ? 1024 ** 3 : 1024 ** 4
19
19
  return n * mult
20
20
  }
21
+
22
+ /**
23
+ * Parse `<n><unit>` where unit is s/m/h/d, case-insensitively (`parseSize`
24
+ * has always been case-insensitive; this matched only lowercase, so `30D`
25
+ * was rejected while `1GB` and `1gb` both worked).
26
+ */
27
+ export function parseDuration(input: string): number | null {
28
+ const m = input.match(/^(\d+)([smhd])$/i)
29
+ if (!m) return null
30
+ const n = parseDecimalInt(m[1]!)
31
+ if (n === null) return null
32
+ // Lowercase before the switch: with the /i flag an uppercase `M` would
33
+ // otherwise fall through to the days branch.
34
+ const unit = m[2]!.toLowerCase()
35
+ const mult = unit === 's' ? 1000 : unit === 'm' ? 60_000 : unit === 'h' ? 3_600_000 : 86_400_000
36
+ return n * mult
37
+ }
38
+
39
+ /**
40
+ * Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
41
+ * decimal of precision below 10 of any unit. Used by `vx info`, `vx cache prune` and the
42
+ * run's `cacheRetention` line.
43
+ */
44
+ export function formatBytes(n: number): string {
45
+ if (n < 1024) return `${n} B`
46
+ const units = ['KB', 'MB', 'GB', 'TB']
47
+ let v = n / 1024
48
+ for (const u of units) {
49
+ if (v < 1024) return `${v.toFixed(v < 10 ? 1 : 0)} ${u}`
50
+ v /= 1024
51
+ }
52
+ return `${v.toFixed(0)} PB`
53
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The inverse of `taskId` (`graph/task-graph.ts`). Splits on the FIRST
3
+ * `#`, so a task name that itself contains one round-trips: `taskId('a', 'b#c')` → `'a#b#c'` → `['a', 'b#c']`.
4
+ *
5
+ * This exists because it kept being written by hand as `id.split('#', 2)`,
6
+ * which is NOT the inverse — it discards everything after the second segment,
7
+ * so `'a#b#c'` reads back as task `'b'`. Six call sites had that form while
8
+ * `parseDependencySpec` (the surface that decides what actually runs) has
9
+ * always split on the first `#`, so the query layer and the graph disagreed
10
+ * about the identity of the same task: a lookup either found nothing or, worse,
11
+ * answered with a different task's history.
12
+ *
13
+ * A `#`-containing task name is legal and pinned elsewhere in the suite, so
14
+ * this is reachable rather than theoretical. The cache's run history read
15
+ * the same rule from a private copy until item 646; one rule, one place.
16
+ */
17
+ export function splitTaskId(id: string): [project: string, task: string] {
18
+ const i = id.indexOf('#')
19
+ return i < 0 ? [id, ''] : [id.slice(0, i), id.slice(i + 1)]
20
+ }
@@ -3,7 +3,8 @@
3
3
  // The CPU profiler attributes a tight loop's cost unreliably and hides
4
4
  // where an `await` waited; a stage table answers "where did the warm run
5
5
  // go?" directly, and costs one boolean check per mark when off. Marks are
6
- // cumulative from process start; the table shows each stage's own share.
6
+ // cumulative from this module's load — process start and the imports ahead
7
+ // of it sit outside the table; the table shows each stage's own share.
7
8
 
8
9
  const enabled = process.env.VX_TIMING !== undefined && process.env.VX_TIMING !== ''
9
10
  const t0 = Bun.nanoseconds()
@@ -58,6 +59,15 @@ export function printTimings(): void {
58
59
  ` ${label.padEnd(24)} ${(ns / 1e6).toFixed(1).padStart(8)}ms ${String(count).padStart(6)}`,
59
60
  )
60
61
  }
62
+ // The trap this table sets, said where it is sprung: a span's total is
63
+ // WALL summed per call, and the run's spans are taken under the
64
+ // scheduler's concurrency, so a span that overlaps other work reads
65
+ // far larger than the work it names. `output dirs` at 124 µs a task is
66
+ // a handful of `lstat`s (items 254 and 407). Compare spans to each
67
+ // other, and measure a suspect one in isolation before chasing it.
68
+ lines.push(
69
+ ' (wall per call, summed; concurrent calls overlap — compare spans, not totals)',
70
+ )
61
71
  }
62
72
  process.stderr.write(lines.join('\n') + '\n')
63
73
  }
@@ -0,0 +1,39 @@
1
+ /**
2
+ * The verbs the CLI dispatcher (`cli/index.ts`) owns; `stats` is a
3
+ * deprecated alias of `info` and stays out of hints but is still matched
4
+ * first. Lives in util rather than cli because the workspace VALIDATOR
5
+ * refuses a plugin verb that names one of these — a verb core matches
6
+ * first could never run — and the workspace module cannot import cli.
7
+ */
8
+ export const CORE_VERBS = [
9
+ 'run',
10
+ 'watch',
11
+ 'cache',
12
+ 'lock',
13
+ 'init',
14
+ 'upgrade',
15
+ 'show',
16
+ 'info',
17
+ 'why',
18
+ 'last',
19
+ 'completions',
20
+ 'help',
21
+ 'version',
22
+ ] as const
23
+
24
+ /** Every word the dispatcher matches before consulting plugins. */
25
+ export const DISPATCHED_VERBS: readonly string[] = [...CORE_VERBS, 'stats']
26
+
27
+ /**
28
+ * Verbs core owned once and a package owns now. Not core verbs: a plugin
29
+ * may declare them, and the dispatcher prints the
30
+ * pointer only when no declared plugin claimed the name.
31
+ */
32
+ export const MOVED_VERBS: Readonly<Record<string, string>> = {
33
+ migrate:
34
+ 'vx migrate moved to @vzn/vx-migrate: run `bunx @vzn/vx-migrate` (turbo.json or an Nx ' +
35
+ 'project graph → vx.config.ts); `vx init` reads package.json scripts',
36
+ prune:
37
+ 'vx prune was removed (2026-09-11): the Docker-subset verb is not shipped; copy the ' +
38
+ 'workspace and `--filter` the build instead',
39
+ }
@@ -0,0 +1,48 @@
1
+ // A spawn of a bare name walks PATH with a stat per entry — `sh` for every
2
+ // task, `git` for every git call: 200 tasks paid ~2,000 stats on a 100-package
3
+ // workspace for the same answer each time. The walk is done here once and
4
+ // the spawn is handed the absolute path.
5
+ //
6
+ // The PATH walked is vx's OWN, never a task's. A task's PATH starts with its
7
+ // project's and the workspace's `node_modules/.bin`, and `exec.env.define`
8
+ // may set it outright; the shell that parses the command and the git that
9
+ // enumerates the inputs are the machine's tools, not the task's, so a
10
+ // dependency that ships a `sh` bin must not become the interpreter of every
11
+ // command in its project. The task's PATH still decides what the COMMAND
12
+ // resolves, inside that shell.
13
+ //
14
+ // Memoized per PATH value rather than for the process's life: nothing in vx
15
+ // changes PATH mid-run, so a run pays one walk, and a process whose PATH does
16
+ // change (an embedder, a test) is answered for the PATH it has. A miss is
17
+ // not remembered: it ends the spawn that asked, and a long-lived process
18
+ // (`vx watch`) finds the tool once the user installs it.
19
+
20
+ let memoPath: string | undefined
21
+ const found = new Map<string, string>()
22
+
23
+ /**
24
+ * `name`'s absolute path on vx's own PATH. Throws Bun's own shape for a
25
+ * spawn that could not run (`code: 'ENOENT'`) when it is not there, so a
26
+ * call inside a spawn site's `try` reaches the same `isExecutableMissing`
27
+ * refusal the spawn's throw did.
28
+ */
29
+ export function executablePath(name: string): string {
30
+ const PATH = process.env['PATH'] ?? ''
31
+ if (PATH !== memoPath) {
32
+ found.clear()
33
+ memoPath = PATH
34
+ }
35
+ const hit = found.get(name)
36
+ if (hit !== undefined) return hit
37
+ const at = Bun.which(name, { PATH })
38
+ if (at === null) {
39
+ throw Object.assign(new Error(`Executable not found in $PATH: "${name}"`), {
40
+ code: 'ENOENT',
41
+ path: name,
42
+ })
43
+ }
44
+ // Absolute even for a relative PATH entry (Bun.which resolves it against
45
+ // this process's cwd), so it holds in whatever cwd the spawn is given.
46
+ found.set(name, at)
47
+ return at
48
+ }