@vzn/vx 0.0.21 → 0.0.23

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 (123) hide show
  1. package/README.md +21 -18
  2. package/package.json +6 -6
  3. package/src/bin.ts +52 -3
  4. package/src/cache/archive.ts +170 -21
  5. package/src/cache/cache.ts +667 -288
  6. package/src/cache/chained-cache.ts +14 -2
  7. package/src/cache/config-evals.ts +29 -6
  8. package/src/cache/file-hashes.ts +63 -35
  9. package/src/cache/git-inputs.ts +398 -69
  10. package/src/cache/index.ts +8 -1
  11. package/src/cache/inputs.ts +403 -102
  12. package/src/cache/key-fold.ts +232 -0
  13. package/src/cache/layer.ts +81 -11
  14. package/src/cache/layered-cache.ts +199 -32
  15. package/src/cache/output-index.ts +109 -10
  16. package/src/cache/run-history.ts +8 -3
  17. package/src/cache/zstd.ts +53 -30
  18. package/src/cli/cache.ts +51 -21
  19. package/src/cli/format.ts +4 -15
  20. package/src/cli/help.ts +13 -3
  21. package/src/cli/index.ts +35 -9
  22. package/src/cli/info.ts +49 -7
  23. package/src/cli/init.ts +17 -5
  24. package/src/cli/last.ts +96 -21
  25. package/src/cli/lock.ts +34 -17
  26. package/src/cli/plan-format.ts +4 -4
  27. package/src/cli/plugin-commands.ts +20 -5
  28. package/src/cli/run.ts +55 -12
  29. package/src/cli/select.ts +23 -13
  30. package/src/cli/show.ts +9 -4
  31. package/src/cli/upgrade.ts +134 -14
  32. package/src/cli/watch.ts +622 -102
  33. package/src/cli/why.ts +8 -16
  34. package/src/cli/workspace-config.ts +21 -9
  35. package/src/config.ts +46 -9
  36. package/src/exec/executor.ts +51 -5
  37. package/src/exec/index.ts +10 -0
  38. package/src/exec/kill-tree.ts +281 -0
  39. package/src/exec/local-executor.ts +1 -1
  40. package/src/exec/runner.ts +327 -82
  41. package/src/exec/sandbox-binds.ts +34 -5
  42. package/src/exec/sandbox-paths.ts +22 -2
  43. package/src/exec/sandbox-runtime.ts +627 -102
  44. package/src/exec/sandbox-violations.ts +36 -23
  45. package/src/graph/dependency-spec.ts +14 -14
  46. package/src/graph/index.ts +12 -2
  47. package/src/graph/priorities.ts +130 -0
  48. package/src/graph/scheduler.ts +182 -134
  49. package/src/graph/task-graph.ts +552 -188
  50. package/src/index.ts +16 -2
  51. package/src/orchestrator/admission.ts +41 -17
  52. package/src/orchestrator/colors.ts +8 -3
  53. package/src/orchestrator/deferred-outputs.ts +22 -7
  54. package/src/orchestrator/doctor.ts +122 -14
  55. package/src/orchestrator/events.ts +71 -2
  56. package/src/orchestrator/excluded-keys.ts +155 -0
  57. package/src/orchestrator/execute-task.ts +331 -37
  58. package/src/orchestrator/failure-recap.ts +121 -0
  59. package/src/orchestrator/fingerprint-watch.ts +89 -0
  60. package/src/orchestrator/framed-output.ts +81 -14
  61. package/src/orchestrator/history.ts +6 -7
  62. package/src/orchestrator/hit-restore.ts +82 -18
  63. package/src/orchestrator/index.ts +7 -1
  64. package/src/orchestrator/keyed-projects.ts +89 -0
  65. package/src/orchestrator/local-shortcircuit.ts +83 -23
  66. package/src/orchestrator/lockfile-claim.ts +152 -32
  67. package/src/orchestrator/logger.ts +116 -20
  68. package/src/orchestrator/metrics.ts +156 -60
  69. package/src/orchestrator/miss-save.ts +106 -43
  70. package/src/orchestrator/options.ts +30 -4
  71. package/src/orchestrator/persistent.ts +41 -21
  72. package/src/orchestrator/placement.ts +55 -25
  73. package/src/orchestrator/plan.ts +24 -11
  74. package/src/orchestrator/plugin-host.ts +17 -23
  75. package/src/orchestrator/plugin.ts +31 -8
  76. package/src/orchestrator/prepare.ts +99 -13
  77. package/src/orchestrator/projects.ts +19 -8
  78. package/src/orchestrator/remote-prefetch.ts +21 -1
  79. package/src/orchestrator/run-artifacts.ts +9 -0
  80. package/src/orchestrator/run-context.ts +19 -4
  81. package/src/orchestrator/run-lock.ts +349 -0
  82. package/src/orchestrator/run-records.ts +6 -23
  83. package/src/orchestrator/run-report.ts +4 -2
  84. package/src/orchestrator/run.ts +264 -57
  85. package/src/orchestrator/sandbox-request.ts +388 -62
  86. package/src/orchestrator/save-lane.ts +4 -7
  87. package/src/orchestrator/shell-verdict.ts +117 -0
  88. package/src/orchestrator/signals.ts +86 -30
  89. package/src/orchestrator/stable-keys.ts +202 -10
  90. package/src/orchestrator/status-line.ts +33 -1
  91. package/src/orchestrator/summary.ts +49 -2
  92. package/src/orchestrator/task-hash.ts +87 -7
  93. package/src/orchestrator/task-log-buffer.ts +7 -7
  94. package/src/orchestrator/telemetry-host.ts +16 -7
  95. package/src/orchestrator/telemetry.ts +69 -28
  96. package/src/orchestrator/upstream.ts +91 -45
  97. package/src/util/bun-version.ts +43 -0
  98. package/src/util/errors.ts +86 -0
  99. package/src/util/hash.ts +10 -1
  100. package/src/util/index.ts +32 -3
  101. package/src/util/paths.ts +147 -5
  102. package/src/util/procfs.ts +20 -0
  103. package/src/util/settle.ts +4 -2
  104. package/src/util/size.ts +33 -0
  105. package/src/util/task-id.ts +20 -0
  106. package/src/util/timing.ts +11 -1
  107. package/src/util/which.ts +48 -0
  108. package/src/workspace/affected.ts +272 -64
  109. package/src/workspace/config-cache.ts +262 -32
  110. package/src/workspace/config-eval.ts +49 -12
  111. package/src/workspace/config-imports.ts +126 -5
  112. package/src/workspace/config-schema.ts +237 -41
  113. package/src/workspace/filter.ts +33 -18
  114. package/src/workspace/fingerprint.ts +29 -11
  115. package/src/workspace/index.ts +7 -0
  116. package/src/workspace/json-data.ts +90 -0
  117. package/src/workspace/load-reads.ts +32 -0
  118. package/src/workspace/lockfile.ts +33 -11
  119. package/src/workspace/migrate-scripts.ts +60 -7
  120. package/src/workspace/migration.ts +40 -1
  121. package/src/workspace/package-graph.ts +154 -16
  122. package/src/workspace/project-loader.ts +227 -77
  123. package/src/workspace/workspace.ts +173 -23
@@ -4,10 +4,22 @@ import {
4
4
  isTaskPattern,
5
5
  parseDependencySpec,
6
6
  type DependencySpec,
7
+ type TaskNode,
7
8
  type TaskOutcome,
8
9
  } from '../graph/index.js'
9
10
  import { UserError } from '../util/index.js'
10
11
 
12
+ /**
13
+ * A task's upstream as its KEY reads it: the live outcomes of its scheduled
14
+ * dependencies, plus the keys of any `--exclude-dependencies` took out of
15
+ * the schedule (`excluded-keys.ts`). Every key site goes through this —
16
+ * the run, the plan, the up-front classify — so a selection cannot change
17
+ * a key at one of them and not the others.
18
+ */
19
+ export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcome[] {
20
+ return node.excludedUpstream === undefined ? upstream : [...upstream, ...node.excludedUpstream]
21
+ }
22
+
11
23
  /**
12
24
  * Pick which upstream task hashes participate in the current task's
13
25
  * cache key, filtered by `cache.inputs.tasks`. The folded value is the
@@ -18,6 +30,44 @@ import { UserError } from '../util/index.js'
18
30
  * output still re-runs its dependents (early cutoff was removed —
19
31
  * rare in practice, not worth the cascade complexity).
20
32
  *
33
+ * The selection itself is `selectFoldedDeps`, which the sandbox's keyed
34
+ * set (keyed-projects.ts) walks over the graph before any hash exists:
35
+ * one matcher, so what the key folds and what the sandbox believes it
36
+ * folds cannot drift.
37
+ *
38
+ * Returns `[upstreamTaskId, hash]` pairs. The hash is the only thing
39
+ * folded into the cache key (the fold sorts by hash, so ordering here
40
+ * doesn't affect derivation); the task id rides along so Tier-3's
41
+ * `entry_inputs` rows can NAME which upstream a hash came from. An
42
+ * upstream with no hash (a persistent task) folds nothing.
43
+ */
44
+ export function filterUpstreamHashes(
45
+ upstream: TaskOutcome[],
46
+ filter: readonly string[] | undefined,
47
+ selfProjectName: string,
48
+ selfTaskId: string,
49
+ ): Array<[upstreamTaskId: string, hash: string]> {
50
+ const candidates: FoldCandidate[] = []
51
+ for (const u of upstream) if (u.hash) candidates.push({ node: u.node, unit: u.hash })
52
+ return selectFoldedDeps(candidates, filter, selfProjectName, selfTaskId).map((c) => [
53
+ c.node.id,
54
+ c.unit,
55
+ ])
56
+ }
57
+
58
+ /**
59
+ * One dependency as the key fold sees it: the task, and the value the
60
+ * fold dedups by — its hash on the hash path, a structural stand-in for
61
+ * the hash on the graph (`foldUnit` in keyed-projects.ts).
62
+ */
63
+ export interface FoldCandidate {
64
+ node: TaskNode
65
+ unit: string
66
+ }
67
+
68
+ /**
69
+ * The dependencies a task's key folds, per its `cache.inputs.tasks`.
70
+ *
21
71
  * Patterns (Turbo/Nx micro-syntax + filter extensions):
22
72
  * '*' all same-project upstream
23
73
  * '^*' all dep-workspace upstream
@@ -36,28 +86,21 @@ import { UserError } from '../util/index.js'
36
86
  * `['*', '^*', '!^noisy']` reads as "all minus deps' noisy".
37
87
  *
38
88
  * Defaults:
39
- * - `filter === undefined` → all upstream contribute.
40
- * - `filter === []` → none contribute (fully decoupled).
89
+ * - `filter === undefined` → every candidate, as given.
90
+ * - `filter === []` → none (fully decoupled).
41
91
  *
42
- * Returns `[upstreamTaskId, hash]` pairs. The hash is the only thing
43
- * folded into the cache key (the fold sorts by hash, so ordering here
44
- * doesn't affect derivation); the task id rides along so Tier-3's
45
- * `entry_inputs` rows can NAME which upstream a hash came from. The
46
- * filter dedups by hash, as before — two upstream tasks with identical
47
- * hashes contribute one pair (first id wins; their key contribution is
48
- * identical anyway).
92
+ * Otherwise deduped by `unit`, first candidate kept. The unit is the
93
+ * fold's own identity, not the task's: two groups over the same members
94
+ * share one hash, so excluding either excludes both, and a selection by
95
+ * task id would keep the other while the key folded neither.
49
96
  */
50
- export function filterUpstreamHashes(
51
- upstream: TaskOutcome[],
97
+ export function selectFoldedDeps(
98
+ deps: readonly FoldCandidate[],
52
99
  filter: readonly string[] | undefined,
53
100
  selfProjectName: string,
54
101
  selfTaskId: string,
55
- ): Array<[upstreamTaskId: string, hash: string]> {
56
- if (filter === undefined) {
57
- const out: Array<[string, string]> = []
58
- for (const u of upstream) if (u.hash) out.push([u.node.id, u.hash])
59
- return out
60
- }
102
+ ): FoldCandidate[] {
103
+ if (filter === undefined) return [...deps]
61
104
 
62
105
  const specs: DependencySpec[] = filter.map((raw) => {
63
106
  try {
@@ -73,21 +116,18 @@ export function filterUpstreamHashes(
73
116
  // Per-spec predicate, compiled once (exact compares + patterns).
74
117
  const matchers = specs.map((spec) => specMatcher(spec))
75
118
 
76
- // Dedup by hash (the key fold's unit), but remember the first task id
77
- // seen for each hash so the diff row can name the upstream.
78
- const selected = new Map<string, string>()
119
+ const selected = new Map<string, FoldCandidate>()
79
120
  for (let i = 0; i < specs.length; i++) {
80
121
  const spec = specs[i]!
81
122
  const matches = matchers[i]!
82
- for (const u of upstream) {
83
- if (!u.hash) continue
84
- const isSelf = u.node.projectName === selfProjectName
85
- if (!matches(u, isSelf)) continue
86
- if (spec.negated) selected.delete(u.hash)
87
- else if (!selected.has(u.hash)) selected.set(u.hash, u.node.id)
123
+ for (const d of deps) {
124
+ const isSelf = d.node.projectName === selfProjectName
125
+ if (!matches(d.node, isSelf)) continue
126
+ if (spec.negated) selected.delete(d.unit)
127
+ else if (!selected.has(d.unit)) selected.set(d.unit, d)
88
128
  }
89
129
  }
90
- return [...selected].map(([hash, id]) => [id, hash])
130
+ return [...selected.values()]
91
131
  }
92
132
 
93
133
  /**
@@ -102,25 +142,31 @@ export function filterUpstreamHashes(
102
142
  * IS the input root: `dependsOn: ['install']` shipped a worker an action with
103
143
  * none of what `install` chains.
104
144
  *
105
- * Groups may nest, so the walk recurses. Only what the FILTER already
106
- * selected is passed in, so a group excluded from a task's
145
+ * Groups may nest, as deep as the graph (the builder takes a 50,000-deep
146
+ * chain, item 737), so the walk keeps its own stack; and a group reached
147
+ * along two paths (a group `build` whose `^build` meets a package diamond)
148
+ * is expanded once, or the walk doubles per layer. Only what the FILTER
149
+ * already selected is passed in, so a group excluded from a task's
107
150
  * `cache.inputs.tasks` brings no members with it.
108
151
  */
109
152
  export function expandGroupUpstream(upstream: readonly TaskOutcome[]): TaskOutcome[] {
110
153
  const out: TaskOutcome[] = []
111
154
  const seen = new Set<string>()
112
- const walk = (list: readonly TaskOutcome[]): void => {
113
- for (const u of list) {
114
- if (u.groupUpstream !== undefined) {
115
- walk(u.groupUpstream)
116
- continue
117
- }
118
- if (seen.has(u.node.id)) continue
119
- seen.add(u.node.id)
120
- out.push(u)
155
+ const expanded = new Set<TaskOutcome>()
156
+ const stack = upstream.toReversed()
157
+ while (stack.length > 0) {
158
+ const u = stack.pop()!
159
+ if (expanded.has(u)) continue
160
+ const members = u.groupUpstream
161
+ if (members !== undefined) {
162
+ expanded.add(u)
163
+ for (let i = members.length - 1; i >= 0; i--) stack.push(members[i]!)
164
+ continue
121
165
  }
166
+ if (seen.has(u.node.id)) continue
167
+ seen.add(u.node.id)
168
+ out.push(u)
122
169
  }
123
- walk(upstream)
124
170
  return out
125
171
  }
126
172
 
@@ -139,24 +185,24 @@ function nameMatcher(name: string): (candidate: string) => boolean {
139
185
  * so a package pattern is unambiguous here — unlike dependsOn, which must
140
186
  * materialize concrete edges and therefore rejects it.
141
187
  */
142
- function specMatcher(spec: DependencySpec): (u: TaskOutcome, isSelf: boolean) => boolean {
188
+ function specMatcher(spec: DependencySpec): (n: TaskNode, isSelf: boolean) => boolean {
143
189
  switch (spec.kind) {
144
190
  case 'wildcardSelf':
145
- return (_u, isSelf) => isSelf
191
+ return (_n, isSelf) => isSelf
146
192
  case 'wildcardDeps':
147
- return (_u, isSelf) => !isSelf
193
+ return (_n, isSelf) => !isSelf
148
194
  case 'self': {
149
195
  const task = nameMatcher(spec.task)
150
- return (u, isSelf) => isSelf && task(u.node.taskName)
196
+ return (n, isSelf) => isSelf && task(n.taskName)
151
197
  }
152
198
  case 'deps': {
153
199
  const task = nameMatcher(spec.task)
154
- return (u, isSelf) => !isSelf && task(u.node.taskName)
200
+ return (n, isSelf) => !isSelf && task(n.taskName)
155
201
  }
156
202
  case 'cross': {
157
203
  const project = nameMatcher(spec.project)
158
204
  const task = nameMatcher(spec.task)
159
- return (u) => project(u.node.projectName) && task(u.node.taskName)
205
+ return (n) => project(n.projectName) && task(n.taskName)
160
206
  }
161
207
  }
162
208
  }
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The runtime floor. `package.json`'s `engines` states it, but `engines` is
3
+ * advice an install may print and a run never reads: `bun src/bin.ts` on an
4
+ * older Bun starts fine and then answers WRONGLY, which is the failure class
5
+ * this repo treats as the worst one.
6
+ *
7
+ * Measured on Bun 1.3.11 (2026-09-19, STATUS item 366): a 2 MiB `--format
8
+ * json` answer reached a pipe as 219 KB — `bin.ts` ends stdout in a callback
9
+ * precisely to prevent that, and the fix does not hold below the floor; the
10
+ * runner read no `peakRssBytes` at all, so `vx last` and `vx why` reported
11
+ * nothing a task used; and a config syntax error arrived as a `BuildMessage`
12
+ * the loader's classifier does not know, surfacing as an internal error
13
+ * instead of the UserError naming file, line and column. Ten of that
14
+ * container's twenty-three failures were this, and every one of them is a
15
+ * green exit over a wrong answer.
16
+ *
17
+ * `@vzn/vx-reapi` REFUSES an old Bun at its wire, because its failure mode is
18
+ * a hang and a hang leaves the user nothing to read. Core warns instead: most
19
+ * of it works below the floor (23 of ~2,000 tests failed on 1.3.11), so
20
+ * refusing would be heavier than the measurement supports. What the
21
+ * measurement supports is that a green exit can hide a truncated answer, and
22
+ * that is what the warning says.
23
+ */
24
+ export const MIN_BUN = [1, 4, 0] as const
25
+
26
+ /** True when `version` is older than {@link MIN_BUN}. */
27
+ export function isUnsupportedBun(version: string): boolean {
28
+ const [maj = 0, min = 0, patch = 0] = version.split('.').map((p) => Number.parseInt(p, 10) || 0)
29
+ const [reqMaj, reqMin, reqPatch] = MIN_BUN
30
+ if (maj !== reqMaj) return maj < reqMaj
31
+ if (min !== reqMin) return min < reqMin
32
+ return patch < reqPatch
33
+ }
34
+
35
+ /** The warning a CLI entry prints once, before the verb runs. */
36
+ export function unsupportedBunMessage(version: string): string {
37
+ return (
38
+ `vx needs Bun >= ${MIN_BUN.join('.')} (running ${version}). ` +
39
+ `An older Bun does not fail — it answers wrongly: a large \`--format json\` ` +
40
+ `answer is truncated mid-write, no task reports what it used, and a config ` +
41
+ `syntax error surfaces as an internal error. Upgrade with \`bun upgrade\`.`
42
+ )
43
+ }
@@ -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,38 @@
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 { normalizeGlob, relPosix, staticPrefix, wholeSubtreePrefixes } from './paths.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'
9
34
  export { killGraceMs, settleWithin, teardownTimeoutMs } from './settle.js'
10
- export { parseSize } from './size.js'
35
+ export { formatBytes, parseDuration, parseSize } from './size.js'
11
36
  export {
12
37
  cgroupCpuQuota,
13
38
  cgroupMemoryLimitBytes,
@@ -17,5 +42,9 @@ export {
17
42
  } from './cgroup.js'
18
43
  export { appendTail, createTail, resetTail, tailText, type Tail } from './tail.js'
19
44
  export { ulid } from './ulid.js'
45
+ export { splitTaskId } from './task-id.js'
20
46
  export { editDistance, nearMatches, nearest } from './edit-distance.js'
21
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,13 +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 {
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 {
29
54
  // A brace set is a wildcard too: `{dist,build}/**` reaches either dir,
30
55
  // and reading it as the literal directory `{dist,build}` gave the
31
56
  // sandbox baseline a prefix that exists nowhere (2026-09-10).
32
- const wildcardIdx = glob.search(/[*?[\]{}]/)
33
- if (wildcardIdx === -1) return glob
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(/\/+$/, '')
34
64
  const head = glob.slice(0, wildcardIdx)
35
65
  const lastSep = head.lastIndexOf('/')
36
66
  if (lastSep === -1) return '.'
@@ -51,7 +81,7 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
51
81
  if (globs.length === 0) return null
52
82
  const out: string[] = []
53
83
  for (const g of globs.map(normalizeGlob)) {
54
- const m = /^([^*?[\]{}!]+?)\/\*\*$/.exec(g)
84
+ const m = /^([^*?{}!]+?)\/\*\*$/.exec(g)
55
85
  if (m === null) return null
56
86
  const dir = m[1]!.replace(/\/+$/, '')
57
87
  if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
@@ -71,10 +101,122 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
71
101
  * after an optional `!`; a bare `.` is the empty entry the schema refuses.
72
102
  */
73
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 {
74
117
  const neg = glob.startsWith('!')
75
118
  let g = neg ? glob.slice(1) : glob
76
119
  g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
77
120
  if (g === '.') g = ''
78
- if (/[*?[\]{}]/.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
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(/\/+$/, '')}/**`
79
126
  return neg ? `!${g}` : g
80
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
+ }
@@ -47,9 +47,11 @@ export function killGraceMs(defaultMs: number): number {
47
47
  }
48
48
 
49
49
  /**
50
- * Await `p`, giving up after `ms`. Returns true when `p` settled first,
50
+ * Await `p`, giving up after `ms`. Returns true when `p` fulfilled first,
51
51
  * false when the deadline won — the caller decides whether a lost result
52
- * 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`).
53
55
  */
54
56
  export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
55
57
  let timer: ReturnType<typeof setTimeout> | undefined