@vzn/vx 0.0.646 → 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 (184) hide show
  1. package/README.md +29 -22
  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 +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  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 +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -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/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
@@ -12,6 +12,7 @@ export interface TerminalSignals {
12
12
  export function terminalSignals(
13
13
  env: Readonly<Record<string, string | undefined>>,
14
14
  ): TerminalSignals {
15
+ if (inMultiplexer(env)) return { progress: false, notify: false }
15
16
  const program = env['TERM_PROGRAM']
16
17
  if (program === 'ghostty') return { progress: true, notify: true }
17
18
  // iTerm2 posts OSC 9 since 3.0 and draws OSC 9;4 since 3.6.
@@ -24,6 +25,17 @@ export function terminalSignals(
24
25
  return { progress: false, notify: false }
25
26
  }
26
27
 
28
+ /** Inside tmux or screen, which keep the outer terminal's variables (`WT_SESSION`, its `TERM_PROGRAM`). */
29
+ function inMultiplexer(env: Readonly<Record<string, string | undefined>>): boolean {
30
+ const program = env['TERM_PROGRAM']
31
+ return (
32
+ env['TMUX'] !== undefined ||
33
+ env['STY'] !== undefined ||
34
+ program === 'tmux' ||
35
+ program === 'screen'
36
+ )
37
+ }
38
+
27
39
  function atLeast(version: string | undefined, major: number, minor: number): boolean {
28
40
  const m = /^(\d+)\.(\d+)/.exec(version ?? '')
29
41
  if (m === null) return false
@@ -50,9 +62,8 @@ export function notifyEscape(text: string): string {
50
62
  * tmux and screen pass it through only when configured to.
51
63
  */
52
64
  export function terminalLinks(env: Readonly<Record<string, string | undefined>>): boolean {
53
- if (env['TMUX'] !== undefined || env['STY'] !== undefined) return false
65
+ if (inMultiplexer(env)) return false
54
66
  const program = env['TERM_PROGRAM']
55
- if (program === 'tmux' || program === 'screen') return false
56
67
  if (program === 'ghostty' || program === 'WezTerm' || program === 'vscode') return true
57
68
  if (program === 'iTerm.app') return atLeast(env['TERM_PROGRAM_VERSION'], 3, 1)
58
69
  if (env['WT_SESSION'] !== undefined || env['KITTY_WINDOW_ID'] !== undefined) return true
package/src/settings.ts CHANGED
@@ -15,8 +15,7 @@ import {
15
15
  export type SettingKind =
16
16
  | { readonly type: 'int'; readonly min: number }
17
17
  | { readonly type: 'concurrency' }
18
- // `zero`: 0 is a value ("wait for nothing not already done"), not a typo.
19
- | { readonly type: 'duration'; readonly timer?: true; readonly zero?: true }
18
+ | { readonly type: 'duration'; readonly timer?: true }
20
19
  // `off`: `false` is a value, 0 bytes (the thing the size bounds is off).
21
20
  | { readonly type: 'size'; readonly off?: true }
22
21
  | { readonly type: 'bool' }
@@ -48,7 +47,7 @@ export const SETTINGS: readonly Setting[] = [
48
47
  path: 'baseBranch',
49
48
  kind: { type: 'string' },
50
49
  default: "origin/HEAD's branch, then main, then master",
51
- doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`.',
50
+ doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`. Named alone: `main`, not `origin/main`.',
52
51
  },
53
52
  {
54
53
  path: 'affectedBase',
@@ -323,7 +322,7 @@ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
323
322
  return parseConcurrency(typeof raw === 'number' ? String(raw) : raw)
324
323
  case 'duration': {
325
324
  const n = typeof raw === 'string' ? parseDuration(raw) : null
326
- if (n === null || (n === 0 && kind.zero !== true)) return null
325
+ if (n === null || n === 0) return null
327
326
  return kind.timer === true && n > MAX_TIMEOUT_MS ? null : n
328
327
  }
329
328
  case 'size':
@@ -359,10 +358,12 @@ export function settingRefusal(
359
358
  : typeof raw === 'string' && /^\d+$/.test(raw)
360
359
  ? Number(raw)
361
360
  : null
362
- if (n !== null && Number.isSafeInteger(n) && n > 0) {
361
+ const bare = n !== null && Number.isSafeInteger(n) && n > 0
362
+ // A bare number past the ceiling is past it in either unit it could mean.
363
+ const ms = bare ? n : typeof raw === 'string' ? parseDuration(raw) : null
364
+ if (bare && !(kind.timer === true && n > MAX_TIMEOUT_MS)) {
363
365
  return `${where} takes a duration with its unit, like '${n}ms' or '${Math.max(1, Math.round(n / 1000))}s' (got ${got})`
364
366
  }
365
- const ms = typeof raw === 'string' ? parseDuration(raw) : null
366
367
  if (kind.timer === true && ms !== null && ms > MAX_TIMEOUT_MS) {
367
368
  return (
368
369
  `${where} of ${got} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days): ` +
@@ -20,8 +20,11 @@
20
20
  * refusing would be heavier than the measurement supports. What the
21
21
  * measurement supports is that a green exit can hide a truncated answer, and
22
22
  * that is what the warning says.
23
+ *
24
+ * 1.4.4: vx expands a brace range (`{1..3}`) as `Bun.Glob` does from 1.4.4
25
+ * on; an older `Bun.Glob` reads it as text, so a scan and a match disagree.
23
26
  */
24
- export const MIN_BUN = [1, 4, 0] as const
27
+ export const MIN_BUN = [1, 4, 4] as const
25
28
 
26
29
  /** True when `version` is older than {@link MIN_BUN}. */
27
30
  export function isUnsupportedBun(version: string): boolean {
@@ -96,7 +96,9 @@ export function paint(
96
96
  colors: ColorSupport,
97
97
  opts: PaintOptions = {},
98
98
  ): string {
99
- if (!colors.enabled) return text
99
+ // Empty text paints nothing: an escape pair around no text is bytes and no
100
+ // mark (a full bar's empty remainder).
101
+ if (!colors.enabled || text === '') return text
100
102
  let prefix = ''
101
103
  if (opts.bold) prefix += BOLD
102
104
  if (opts.dim) prefix += DIM
@@ -107,7 +109,7 @@ export function paint(
107
109
 
108
110
  /**
109
111
  * Identity hues for the two halves of a `project#task` id, never a status
110
- * colour: one name, one hue, in every run, list and row (M, 2026-10-15:
112
+ * colour: one name, one hue, in every run, list and row (2026-10-15:
111
113
  * every `#build` matches every other `#build`).
112
114
  */
113
115
  const IDENTITY_PALETTE = [
@@ -18,7 +18,7 @@ const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (task
18
18
  */
19
19
  export function configErrorFrame(message: string, cwd: string): string | undefined {
20
20
  const head = HEAD.exec(message)
21
- if (head === null) return undefined
21
+ if (head === null) return positionFrame(message, cwd)
22
22
  const [, file, fieldPath, colon] = head as unknown as [string, string, string?, string?]
23
23
  const rest = message.slice(head[0].length)
24
24
  // The field the refusal is about, past the path that leads to it:
@@ -35,6 +35,37 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
35
35
  }
36
36
  const at = locate(source, segments)
37
37
  if (at === undefined) return undefined
38
+ return framed(file, source, at, message.slice(file.length), cwd)
39
+ }
40
+
41
+ // The loader's `Project config <file>:<line>:<col>: …`: a syntax error,
42
+ // placed by Bun, in the config itself.
43
+ const POSITION =
44
+ /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s):(\d+):(\d+)(: [^]*)$/
45
+
46
+ function positionFrame(message: string, cwd: string): string | undefined {
47
+ const pos = POSITION.exec(message)
48
+ if (pos === null) return undefined
49
+ const [, file, line, col, rest] = pos as unknown as [string, string, string, string, string]
50
+ let source: string
51
+ try {
52
+ source = readFileSync(file, 'utf8')
53
+ } catch {
54
+ return undefined
55
+ }
56
+ const at = { line: Number(line), col: Math.max(1, Number(col)) }
57
+ if (at.line < 1 || at.line > source.split('\n').length) return undefined
58
+ return framed(file, source, at, rest, cwd)
59
+ }
60
+
61
+ /** `<file>:<line>:<col><rest>` relative to `cwd`, over the source lines up to `at`. */
62
+ function framed(
63
+ file: string,
64
+ source: string,
65
+ at: { line: number; col: number },
66
+ rest: string,
67
+ cwd: string,
68
+ ): string {
38
69
  const lines = source.split('\n')
39
70
  // Relative to where the user stands, unless that means climbing out.
40
71
  const rel = path.relative(cwd, file)
@@ -50,7 +81,7 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
50
81
  .replace(/\t/g, ' ')
51
82
  .replace(/\S/g, ' ')
52
83
  frame.push(` ${' '.repeat(width)} | ${lead}^`)
53
- return `${shown}:${at.line}:${at.col}${message.slice(file.length)}\n\n${frame.join('\n')}`
84
+ return `${shown}:${at.line}:${at.col}${rest}\n\n${frame.join('\n')}`
54
85
  }
55
86
 
56
87
  /**
@@ -106,14 +137,20 @@ export function configRefusal(message: string, cwd: string): ConfigRefusal | und
106
137
  const cut = framed.indexOf('\n\n')
107
138
  const line = framed.slice(0, cut)
108
139
  const frame = framed.slice(cut + 2)
109
- const at = /^(\S+?:\d+:\d+):? (.*)$/s.exec(line)
140
+ // A path may hold spaces (`packages/my app/`); the first `:line:col` ends it.
141
+ const at = /^([^\n]+?:\d+:\d+):? (.*)$/s.exec(line)
110
142
  if (at === null) return { where: line, problem: '', frame }
111
- const said = /^(.*?)(?: \(allowed: ([^)]*)\))?(?: — did you mean ([^?]+)\?)?$/s.exec(at[2]!)!
143
+ // After ` — `: the field meant, or how vx spells another runner's field.
144
+ const said =
145
+ /^(.*?)(?: \(allowed: ([^)]*)\))?(?: — (?:did you mean ([^?]+)\?|vx spells it (.+)))?$/s.exec(
146
+ at[2]!,
147
+ )!
112
148
  return {
113
149
  where: at[1]!,
114
150
  problem: said[1]!.replace(/^has /, ''),
115
151
  ...(said[2] !== undefined ? { allowed: said[2] } : {}),
116
152
  ...(said[3] !== undefined ? { fix: `use ${said[3]}` } : {}),
153
+ ...(said[4] !== undefined ? { fix: `use ${said[4]}` } : {}),
117
154
  frame,
118
155
  }
119
156
  }
@@ -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
+ }