@vzn/vx 0.0.645 → 0.0.648

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 (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
@@ -1,6 +1,8 @@
1
1
  // Parser for the Turbo/Nx-style task-edge micro-syntax used in
2
2
  // `dependsOn` and `inputs.tasks`. Returns a small discriminated
3
- // union the graph builder + cache filter both consume.
3
+ // union the graph builder + cache filter both consume. In util, not
4
+ // graph: the schema parses `inputs.tasks` at load (`vx lock`), and the
5
+ // workspace module cannot import the graph.
4
6
 
5
7
  export type DependencySpec =
6
8
  | { kind: 'self'; task: string; negated: boolean }
@@ -9,9 +11,12 @@ export type DependencySpec =
9
11
  | { kind: 'wildcardSelf'; negated: boolean }
10
12
  | { kind: 'wildcardDeps'; negated: boolean }
11
13
 
14
+ /** The field an entry came from, which the error names. */
15
+ type SpecField = 'dependsOn' | 'inputs.tasks'
16
+
12
17
  export class DependencySpecError extends Error {
13
- constructor(raw: string, message: string) {
14
- super(`invalid dependsOn "${raw}": ${message}`)
18
+ constructor(field: SpecField, raw: string, message: string) {
19
+ super(`invalid ${field} "${raw}": ${message}`)
15
20
  }
16
21
  }
17
22
 
@@ -31,49 +36,53 @@ export function compileTaskPattern(pattern: string): RegExp {
31
36
  return new RegExp(`^${escaped}$`)
32
37
  }
33
38
 
39
+ // `*` is the matcher's only metacharacter, so a run of them (`**`) matches
40
+ // every name: the bare wildcard, not a pattern that names a namespace.
41
+ const ALL_STARS = /^\*+$/
42
+
34
43
  /**
35
44
  * Parse one `dependsOn` / `inputs.tasks` entry.
36
45
  *
37
46
  * `name` → self.name
38
47
  * `^name` → every dep workspace's `name` task
39
48
  * `pkg#name` → specific package's `name` task (cross-edge)
40
- * `*` → all same-project upstream (filter-only)
41
- * `^*` → all dep upstream (filter-only)
49
+ * `*` → all same-project upstream (filter-only; `**` too)
50
+ * `^*` → all dep upstream (filter-only; `^**` too)
42
51
  * `!<form>` → negation of any of the above (filter-only)
43
52
  *
44
53
  * Wildcards and negation are accepted everywhere — callers (the graph
45
54
  * builder vs the filter) decide which ones make semantic sense and
46
55
  * surface their own validation errors. This keeps the parser pure.
47
56
  */
48
- export function parseDependencySpec(raw: string): DependencySpec {
49
- if (raw.length === 0) throw new DependencySpecError(raw, 'empty spec')
57
+ export function parseDependencySpec(raw: string, field: SpecField): DependencySpec {
58
+ if (raw.length === 0) throw new DependencySpecError(field, raw, 'empty spec')
50
59
 
51
60
  let negated = false
52
61
  let s = raw
53
62
  if (s.startsWith('!')) {
54
63
  negated = true
55
64
  s = s.slice(1)
56
- if (s.length === 0) throw new DependencySpecError(raw, 'negation with no body')
65
+ if (s.length === 0) throw new DependencySpecError(field, raw, 'negation with no body')
57
66
  }
58
67
 
59
68
  if (s.startsWith('^')) {
60
69
  const rest = s.slice(1)
61
- if (rest.length === 0) throw new DependencySpecError(raw, '"^" with no task name')
62
- if (rest === '*') return { kind: 'wildcardDeps', negated }
70
+ if (rest.length === 0) throw new DependencySpecError(field, raw, '"^" with no task name')
71
+ if (ALL_STARS.test(rest)) return { kind: 'wildcardDeps', negated }
63
72
  if (rest.includes('#')) {
64
- throw new DependencySpecError(raw, '"^" cannot combine with "pkg#task" — pick one')
73
+ throw new DependencySpecError(field, raw, '"^" cannot combine with "pkg#task" — pick one')
65
74
  }
66
75
  return { kind: 'deps', task: rest, negated }
67
76
  }
68
77
 
69
- if (s === '*') return { kind: 'wildcardSelf', negated }
78
+ if (ALL_STARS.test(s)) return { kind: 'wildcardSelf', negated }
70
79
 
71
80
  const hashIdx = s.indexOf('#')
72
81
  if (hashIdx >= 0) {
73
82
  const project = s.slice(0, hashIdx)
74
83
  const task = s.slice(hashIdx + 1)
75
84
  if (!project || !task) {
76
- throw new DependencySpecError(raw, 'pkg#task requires a non-empty project AND task')
85
+ throw new DependencySpecError(field, raw, 'pkg#task requires a non-empty project AND task')
77
86
  }
78
87
  return { kind: 'cross', project, task, negated }
79
88
  }
@@ -23,7 +23,8 @@ export function editDistance(a: string, b: string, cap = 3): number {
23
23
 
24
24
  /**
25
25
  * The ONE near-miss rule every "did you mean" hint applies: the closest
26
- * candidate within two edits of `name`, or undefined — an exact match is
26
+ * candidate within two edits of `name` (for a name of four letters or
27
+ * fewer, one or a swap of two), or undefined — an exact match is
27
28
  * not a hint, and anything further would guess rather than help. Task
28
29
  * names, `pkg#task` halves, project names, flags and verbs all go through
29
30
  * here so a typo is hinted the same way wherever it is typed.
@@ -33,6 +34,9 @@ export function nearest(
33
34
  candidates: Iterable<string>,
34
35
  maxEdits = 2,
35
36
  ): string | undefined {
37
+ // Two edits rewrite half of a word of four letters or fewer (`--poll`
38
+ // read as `--all`, `vx test` as `last`), unless they swap two letters.
39
+ const short = maxEdits === 2 && name.replace(/^-+/, '').length <= 4
36
40
  let best: string | undefined
37
41
  let bestD = maxEdits + 1
38
42
  for (const c of candidates) {
@@ -40,6 +44,10 @@ export function nearest(
40
44
  // `--continue-on-error` was nine edits from `--concurrency`, capped to
41
45
  // the three `vx run`'s same-prefix hint allows, and offered.
42
46
  const d = editDistance(name, c, maxEdits + 1)
47
+ // Edits that rewrite the whole of both leave nothing in common:
48
+ // `--filter=x9` offered the root, `//`.
49
+ if (d >= Math.max(name.length, c.length)) continue
50
+ if (short && d === 2 && !sameLetters(name, c)) continue
43
51
  if (d < bestD) {
44
52
  bestD = d
45
53
  best = c
@@ -48,6 +56,10 @@ export function nearest(
48
56
  return best === name ? undefined : best
49
57
  }
50
58
 
59
+ function sameLetters(a: string, b: string): boolean {
60
+ return a.length === b.length && a.split('').sort().join('') === b.split('').sort().join('')
61
+ }
62
+
51
63
  /**
52
64
  * Up to `limit` candidates worth offering for `name`: the nearest by edit
53
65
  * distance first, then any that contain it (or that it contains), case-
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Whether a flag-like variable (`CI`, `GITHUB_ACTIONS`) is on: set, and not
3
+ * `''`, `0` or `false` in any case. Several vendors set `CI=true`, and a
4
+ * PowerShell `$false` writes `False`; each reader of CI asks this one, so a
5
+ * run cannot be CI to one part of vx and a laptop to another.
6
+ */
7
+ export function envFlagOn(v: string | undefined): boolean {
8
+ return v !== undefined && v !== '' && v !== '0' && v.toLowerCase() !== 'false'
9
+ }
@@ -57,8 +57,12 @@ export function refusal(
57
57
  }
58
58
 
59
59
  // A refusal that opens with the config that caused it: the schema's
60
- // `<file>: tasks.<name> …` and the loader's `<file> has unknown field …`.
61
- const CONFIG_REFUSAL = /^\/\S*\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s\b/
60
+ // `<file>: tasks.<name> …`, the loader's `<file> has unknown field …`, and
61
+ // its `Project config <file>: …` and `Workspace config at <file> …` (a load
62
+ // that failed: a syntax error, a missing import, no default object), which
63
+ // answered VX_E_REFUSED.
64
+ const CONFIG_REFUSAL =
65
+ /^(?:(?:Project|Workspace) config (?:at )?)?\/\S*\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s\b/
62
66
 
63
67
  /**
64
68
  * The stable code of anything `bin.ts` catches. A UserError's own, but
@@ -106,6 +110,30 @@ export function errorDocument(code: string, message: string): string {
106
110
  return `${JSON.stringify({ ok: false, error: { code, message, ...docs } })}\n`
107
111
  }
108
112
 
113
+ /**
114
+ * What a thrown value says, for a line that names its thrower. A plugin
115
+ * may throw anything: `String(err)` read `[object Object]` for an
116
+ * error-like `{ message }`, `undefined` for a bare `throw`, and itself threw
117
+ * on an object with no prototype. Never throws.
118
+ */
119
+ export function thrownMessage(err: unknown): string {
120
+ try {
121
+ if (typeof err === 'string') return err === '' ? 'threw an empty string' : err
122
+ if (typeof err === 'object' && err !== null) {
123
+ const message = (err as { message?: unknown }).message
124
+ if (typeof message === 'string' && message !== '') return message
125
+ if (err instanceof Error) return `threw ${err.name} with no message`
126
+ const json = JSON.stringify(err)
127
+ if (json !== undefined) return `threw ${json.length > 200 ? `${json.slice(0, 200)}…` : json}`
128
+ return 'threw an object'
129
+ }
130
+ if (typeof err === 'bigint') return `threw ${err}n`
131
+ return `threw ${String(err)}`
132
+ } catch {
133
+ return 'threw an object'
134
+ }
135
+ }
136
+
109
137
  /**
110
138
  * `instanceof UserError`, plus the same class arriving from ANOTHER COPY of
111
139
  * core. A compiled `vx` binary carries core inside it while a plugin in the
@@ -164,9 +192,21 @@ export function isFsRefusal(err: unknown): err is NodeJS.ErrnoException {
164
192
 
165
193
  export const DISK_FULL_HINT = 'the disk that path is on is full'
166
194
 
195
+ /** What follows the path when the refused call only reads. */
196
+ const READ_PERMISSION_HINT = 'a path vx must read is not readable by this user'
197
+
198
+ /**
199
+ * Calls that only read. `open` is either, so it keeps the write hint: the
200
+ * reads vx opens name their own refusal (`hashFileFromDisk`).
201
+ */
202
+ const READ_SYSCALLS = new Set(['scandir', 'stat', 'lstat', 'readlink', 'read', 'realpath'])
203
+
167
204
  /** The one-line hint after a refusal's path. */
168
205
  export function fsRefusalHint(err: NodeJS.ErrnoException): string {
169
- return isDiskFull(err) ? DISK_FULL_HINT : PERMISSION_HINT
206
+ const { syscall } = err
207
+ if (isDiskFull(err)) return DISK_FULL_HINT
208
+ // A listing told to be a write sent the user to chmod the wrong thing.
209
+ return READ_SYSCALLS.has(syscall ?? '') ? READ_PERMISSION_HINT : PERMISSION_HINT
170
210
  }
171
211
 
172
212
  /**
@@ -188,6 +228,7 @@ export function isExecutableMissing(err: unknown): boolean {
188
228
  export function gitSpawnRefusal(cwd: string): UserError {
189
229
  return new UserError(
190
230
  `vx requires git: failed to spawn 'git' (working dir: ${cwd}). Install git and re-run.`,
231
+ 'VX_E_NO_GIT',
191
232
  )
192
233
  }
193
234
 
@@ -200,6 +241,21 @@ export function notAWorkTree(cwd: string, stderr = ''): UserError {
200
241
  return new UserError(
201
242
  `vx requires git: ${cwd} is not inside a git work tree. ` +
202
243
  `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
244
+ 'VX_E_NO_GIT',
245
+ )
246
+ }
247
+
248
+ /**
249
+ * A git that ran in `cwd` and failed: no repository there is
250
+ * `notAWorkTree`, anything else (a corrupt index, a missing object) is git's
251
+ * own failure, `VX_E_GIT`. Every failed `ls-files` read as "not a work
252
+ * tree", telling a user with a broken index to `git init` (2026-10-28).
253
+ */
254
+ export function gitFailure(cwd: string, args: string, exitCode: number, stderr: string): UserError {
255
+ if (/not a git repository/i.test(stderr)) return notAWorkTree(cwd, stderr)
256
+ return new UserError(
257
+ `git ${args} failed (exit ${exitCode}) in ${cwd}: ${stderr || 'no output'}. Run it there to see why.`,
258
+ 'VX_E_GIT',
203
259
  )
204
260
  }
205
261
 
@@ -1,3 +1,6 @@
1
+ import { existsSync, realpathSync } from 'node:fs'
2
+ import path from 'node:path'
3
+
1
4
  /**
2
5
  * Every task gets these (`exec/env.ts`), so `hideEnvFromTasks` leaves
3
6
  * them. Exported so the schema doc's enumeration of it can be PINNED against the
@@ -28,4 +31,58 @@ export const ESSENTIAL_ENV: readonly string[] = [
28
31
  // manager (offline or sandboxed: fails) and pnpm loses its global dir.
29
32
  'COREPACK_HOME',
30
33
  'PNPM_HOME',
34
+ // The XDG base directories, machine paths as HOME is. Stripped, a task
35
+ // resolved other homes than the shell did: pnpm used another store, and
36
+ // a CLI keeping its login there ran signed out (vercel/turborepo#13145).
37
+ 'XDG_CONFIG_HOME',
38
+ 'XDG_DATA_HOME',
39
+ 'XDG_CACHE_HOME',
40
+ 'XDG_STATE_HOME',
41
+ 'XDG_RUNTIME_DIR',
31
42
  ]
43
+
44
+ /**
45
+ * `PATH` less each `node_modules/.bin` inside `workspaceRoot`. `npm run`
46
+ * in a member puts that member's bins, its parents' and the root's on the
47
+ * PATH vx inherits; a task that kept them ran a sibling's bin, which no
48
+ * key sees. A task gets back the two it may see (`taskBinDirs`). Inside
49
+ * by spelling, or by canonical path: an entry spelled through a symlink
50
+ * (macOS's `/tmp`, a linked home, a shell's `$PWD`) kept a sibling's bin
51
+ * beside a canonical root.
52
+ */
53
+ export function withoutWorkspaceBins(PATH: string, workspaceRoot: string): string {
54
+ const bin = `${path.sep}node_modules${path.sep}.bin`
55
+ const within = (dir: string, root: string): boolean =>
56
+ dir.startsWith(root.endsWith(path.sep) ? root : root + path.sep)
57
+ let root: string | undefined
58
+ return PATH.split(path.delimiter)
59
+ .filter((entry) => {
60
+ const dir = entry.endsWith(path.sep) ? entry.slice(0, -1) : entry
61
+ if (!dir.endsWith(bin)) return true
62
+ if (within(dir, workspaceRoot)) return false
63
+ // Relative, it is the task's cwd's: not a place this check can name.
64
+ if (!path.isAbsolute(dir)) return true
65
+ const real = canonical(dir)
66
+ if (real === undefined) return true
67
+ root ??= canonical(workspaceRoot) ?? workspaceRoot
68
+ return !within(real, root)
69
+ })
70
+ .join(path.delimiter)
71
+ }
72
+
73
+ /**
74
+ * `p`'s canonical spelling; undefined when it is not there, and a missing
75
+ * directory on PATH resolves nothing. Asked per call, never memoized: a
76
+ * task may create the bin directory (an install) or a link above it, and a
77
+ * fact about a file a task can write is not reused across tasks.
78
+ */
79
+ function canonical(p: string): string | undefined {
80
+ // A stat, not a throw: most such entries are npm's ancestors of the
81
+ // root, and an ENOENT thrown per entry was 3 µs a spawn each.
82
+ if (!existsSync(p)) return undefined
83
+ try {
84
+ return realpathSync.native(p)
85
+ } catch {
86
+ return undefined
87
+ }
88
+ }
@@ -1,4 +1,4 @@
1
- // Whether git ignores everything an output glob can write (M, 2026-10-15):
1
+ // Whether git ignores everything an output glob can write (2026-10-15):
2
2
  // a key reads only what git lists, so such an output never reaches a
3
3
  // reader's key. Asked of git, never read from a `.gitignore`: git alone
4
4
  // knows every rule source (nested files, `info/exclude`, `core.excludesFile`)
package/src/util/hash.ts CHANGED
@@ -32,3 +32,38 @@ export function xxh3(input: string | Uint8Array, seed: bigint = 0n): bigint {
32
32
  export function xxh3hex(input: string | Uint8Array, seed: bigint = 0n): string {
33
33
  return xxh3(input, seed).toString(16).padStart(16, '0')
34
34
  }
35
+ /**
36
+ * `v` with every object's keys sorted, all the way down: `v` itself when
37
+ * they are already, a copy when not. A key hashes a config's JSON, and the
38
+ * order a file or a plugin spells its keys in says nothing. Checked first,
39
+ * a config the lock wrote sorted costs a walk, not a copy.
40
+ */
41
+ export function keysSorted<T>(v: T): T {
42
+ return isSorted(v) ? v : (sortedDeep(v) as T)
43
+ }
44
+
45
+ function isSorted(v: unknown): boolean {
46
+ if (v === null || typeof v !== 'object') return true
47
+ if (Array.isArray(v)) return v.every(isSorted)
48
+ let prev: string | undefined
49
+ for (const k in v) {
50
+ if (prev !== undefined && prev > k) return false
51
+ prev = k
52
+ if (!isSorted((v as Record<string, unknown>)[k])) return false
53
+ }
54
+ return true
55
+ }
56
+
57
+ function sortedDeep(v: unknown): unknown {
58
+ if (v === null || typeof v !== 'object') return v
59
+ if (Array.isArray(v)) return v.map(sortedDeep)
60
+ const out: Record<string, unknown> = {}
61
+ for (const k of Object.keys(v).sort()) {
62
+ const value = sortedDeep((v as Record<string, unknown>)[k])
63
+ // Assigned, `__proto__` sets the copy's prototype and leaves the JSON.
64
+ if (k === '__proto__')
65
+ Object.defineProperty(out, k, { value, enumerable: true, writable: true, configurable: true })
66
+ else out[k] = value
67
+ }
68
+ return out
69
+ }
@@ -1,3 +1,5 @@
1
+ import { readFileSync, realpathSync } from 'node:fs'
2
+ import path from 'node:path'
1
3
  import { ESSENTIAL_ENV } from './essential-env.js'
2
4
 
3
5
  const PR_SET_DUMPABLE = 4
@@ -5,8 +7,8 @@ const PR_SET_DUMPABLE = 4
5
7
  /**
6
8
  * Hide vx's environment from a same-user task. A task gets only declared
7
9
  * variables, but vx itself holds the rest. Not a boundary (`exec.sandbox`
8
- * is): it hides the environment, not files. On every verb (owner,
9
- * 2026-10-13).
10
+ * is): it hides the environment, not files. On every verb
11
+ * (2026-10-13).
10
12
  *
11
13
  * Linux: make vx non-dumpable, so its `/proc/<pid>/environ` (and ptrace)
12
14
  * close to the task. A child is dumpable again after `exec`, so tasks are
@@ -16,13 +18,16 @@ const PR_SET_DUMPABLE = 4
16
18
  *
17
19
  * macOS: `ps eww` reads the env strings the kernel copied above the
18
20
  * stack, and no flag closes that to the same user, so vx zeroes the
19
- * values there (owner, 2026-10-13: "Do the override then on macOS").
21
+ * values there (2026-10-13: "Do the override then on macOS").
20
22
  * Bun copied the env at startup, so `process.env`, and every task built
21
23
  * from it, keeps them.
22
24
  */
23
25
  export async function hideEnvFromTasks(): Promise<boolean> {
24
26
  if (process.platform === 'darwin') return wipeStartupEnv()
25
- if (process.platform !== 'linux' || process.env['VX_DEBUG_DUMPABLE']) return false
27
+ // `debug.dumpable` is a bool (util sits below src/settings.ts, so it reads
28
+ // it itself, as util/timing.ts does): `0` and `false` hide too.
29
+ const dumpable = process.env['VX_DEBUG_DUMPABLE']
30
+ if (process.platform !== 'linux' || dumpable === 'true' || dumpable === '1') return false
26
31
  try {
27
32
  const { dlopen, FFIType } = await import('bun:ffi')
28
33
  const libc = dlopen('libc.so.6', {
@@ -97,11 +102,46 @@ export function gitEnv(): Record<string, string> {
97
102
  if (
98
103
  name.startsWith('GIT_') ||
99
104
  name.startsWith('LC_') ||
100
- name === 'XDG_CONFIG_HOME' ||
101
105
  name === 'LANGUAGE' ||
102
106
  ESSENTIAL_ENV.includes(name)
103
107
  )
104
108
  env[name] = value
105
109
  }
110
+ if (env['GIT_DIR'] !== undefined && env['GIT_WORK_TREE'] === undefined) {
111
+ if (rediscovers(env['GIT_DIR'])) delete env['GIT_DIR']
112
+ }
106
113
  return env
107
114
  }
115
+
116
+ const rediscovered = new Map<string, boolean>()
117
+
118
+ /**
119
+ * Whether a plain `git` where vx started finds the repository `gitDir`
120
+ * names anyway. A linked worktree's hook exports its gitdir as GIT_DIR and
121
+ * no GIT_WORK_TREE, and git then takes ITS cwd as the work tree's top: vx
122
+ * runs git in the workspace root, a subfolder there, and git read every
123
+ * file as untracked. Without the variable git finds the top itself. A
124
+ * GIT_DIR naming any other repository (a bare one, dotfiles) is kept.
125
+ */
126
+ function rediscovers(gitDir: string): boolean {
127
+ const cwd = process.cwd()
128
+ const key = `${cwd}\0${gitDir}`
129
+ let hit = rediscovered.get(key)
130
+ if (hit === undefined) {
131
+ hit = false
132
+ try {
133
+ const abs = path.resolve(cwd, gitDir)
134
+ let dotGit = abs
135
+ // A linked worktree's admin dir names its checkout's `.git` file.
136
+ try {
137
+ dotGit = path.resolve(abs, readFileSync(path.join(abs, 'gitdir'), 'utf8').trim())
138
+ } catch {}
139
+ if (path.basename(dotGit) === '.git') {
140
+ const rel = path.relative(realpathSync(path.dirname(dotGit)), realpathSync(cwd))
141
+ hit = !rel.startsWith('..') && !path.isAbsolute(rel)
142
+ }
143
+ } catch {}
144
+ rediscovered.set(key, hit)
145
+ }
146
+ return hit
147
+ }
package/src/util/index.ts CHANGED
@@ -19,11 +19,14 @@ export {
19
19
  gitSpawnRefusal,
20
20
  isExecutableMissing,
21
21
  isOutOfFds,
22
+ gitFailure,
22
23
  notAWorkTree,
23
24
  OUT_OF_FDS_HINT,
25
+ thrownMessage,
24
26
  wantsJson,
25
27
  } from './errors.js'
26
28
  export { configErrorFrame, configRefusal } from './config-frame.js'
29
+ export { envFlagOn } from './env-flag.js'
27
30
  export {
28
31
  detectColors,
29
32
  dumbTerminal,
@@ -34,11 +37,11 @@ export {
34
37
  WARN,
35
38
  type ColorSupport,
36
39
  } from './colors.js'
37
- export { barWidth, gradientRule } from './report.js'
40
+ export { barWidth, gradientRule, ruleWidth } from './report.js'
38
41
  import * as ui from './ui.js'
39
42
  export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
40
43
  export { ui }
41
- export { xxh3, xxh3hex } from './hash.js'
44
+ export { keysSorted, xxh3, xxh3hex } from './hash.js'
42
45
  export {
43
46
  beginRun,
44
47
  mark,
@@ -48,7 +51,7 @@ export {
48
51
  stageTimes,
49
52
  type StageTime,
50
53
  } from './timing.js'
51
- export { clampInt, formatElapsed, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
54
+ export { clampInt, formatElapsed, isMeasure, MAX_TIMEOUT_MS, parseDecimalInt } from './num.js'
52
55
  export {
53
56
  asTrees,
54
57
  BUN_GLOB_WILDCARDS,
@@ -63,6 +66,7 @@ export {
63
66
  outputNever,
64
67
  restorableOutput,
65
68
  printable,
69
+ hasControl,
66
70
  relPosix,
67
71
  DOT_VX_GITATTRIBUTES,
68
72
  DOT_VX_GITIGNORE,
@@ -72,9 +76,13 @@ export {
72
76
  anyTaskGlob,
73
77
  isInstalledPath,
74
78
  taskGlob,
79
+ outputDirPrefixes,
75
80
  wholeSubtreePrefixes,
76
81
  slashBraceExpansions,
77
82
  braceAlternatives,
83
+ braceRange,
84
+ BRACE_RANGE_LIMIT,
85
+ writableUnder,
78
86
  } from './paths.js'
79
87
  export {
80
88
  claimExitForSignal,
@@ -101,7 +109,7 @@ export { isUnsupportedBun, MIN_BUN, unsupportedBunMessage } from './bun-version.
101
109
  export { executablePath, shellArgv, taskShell } from './which.js'
102
110
  export { procfsIsOwn } from './procfs.js'
103
111
  export { hangupIgnored } from './hangup.js'
104
- export { ESSENTIAL_ENV } from './essential-env.js'
112
+ export { ESSENTIAL_ENV, withoutWorkspaceBins } from './essential-env.js'
105
113
  export { gitEnv, helperEnv, hideEnvFromTasks } from './hide-env.js'
106
114
  export {
107
115
  checkIgnore,
@@ -111,9 +119,11 @@ export {
111
119
  wholeIgnored,
112
120
  } from './git-ignored.js'
113
121
  export { realPath, realpathOf } from './real-path.js'
122
+ export { IGNORE_ALL, ignoreDir, refuseSymlink, writeNoFollow } from './write-file.js'
114
123
  export {
124
+ heldSecrets,
115
125
  maskedCommand,
116
- maskedEmitter,
126
+ maskedStreams,
117
127
  maskedLine,
118
128
  MASKED,
119
129
  secretMask,
@@ -130,3 +140,10 @@ export {
130
140
  type SecretUnavailableReason,
131
141
  } from './secrets.js'
132
142
  export { terminalOf, type SecretTerminal } from './hidden-prompt.js'
143
+ export {
144
+ DependencySpecError,
145
+ compileTaskPattern,
146
+ isTaskPattern,
147
+ parseDependencySpec,
148
+ type DependencySpec,
149
+ } from './dependency-spec.js'
package/src/util/num.ts CHANGED
@@ -21,6 +21,14 @@ export function clampInt(n: number, min: number, max: number): number {
21
21
  return Math.min(max, Math.max(min, Math.floor(n)))
22
22
  }
23
23
 
24
+ /**
25
+ * A timing or need SQLite may keep: finite and not below zero. Infinity
26
+ * stored as REAL reads back through `group_concat` as `Inf`, a NaN forecast.
27
+ */
28
+ export function isMeasure(v: number): boolean {
29
+ return Number.isFinite(v) && v >= 0
30
+ }
31
+
24
32
  /**
25
33
  * Parse a PLAIN decimal integer from user input; `null` for anything else.
26
34
  *