@vzn/vx 0.0.650 → 0.0.652

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 (146) hide show
  1. package/README.md +7 -23
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/schemas/show.json +14 -20
  5. package/src/bin.ts +21 -31
  6. package/src/cache/archive.ts +1 -1
  7. package/src/cache/cache.ts +221 -30
  8. package/src/cache/file-hashes.ts +3 -0
  9. package/src/cache/git-inputs.ts +188 -39
  10. package/src/cache/history-stream.ts +134 -0
  11. package/src/cache/history-writer.ts +76 -0
  12. package/src/cache/index.ts +3 -0
  13. package/src/cache/inputs.ts +77 -14
  14. package/src/cache/layer.ts +17 -6
  15. package/src/cache/layered-cache.ts +4 -0
  16. package/src/cache/linked-inputs.ts +3 -0
  17. package/src/cache/lockfile-memo.ts +5 -3
  18. package/src/cache/output-index.ts +5 -2
  19. package/src/cache/remote-lane.ts +2 -1
  20. package/src/cache/run-history.ts +56 -17
  21. package/src/cache/schema.ts +15 -2
  22. package/src/cli/cache.ts +28 -18
  23. package/src/cli/completions.ts +4 -2
  24. package/src/cli/docs.ts +10 -11
  25. package/src/cli/help.ts +14 -25
  26. package/src/cli/index.ts +28 -9
  27. package/src/cli/info.ts +16 -8
  28. package/src/cli/init.ts +23 -15
  29. package/src/cli/last.ts +246 -204
  30. package/src/cli/lock.ts +55 -5
  31. package/src/cli/plan-format.ts +100 -118
  32. package/src/cli/plugin-commands.ts +8 -2
  33. package/src/cli/plugin-templates.ts +36 -45
  34. package/src/cli/run.ts +46 -27
  35. package/src/cli/select.ts +43 -5
  36. package/src/cli/show.ts +45 -52
  37. package/src/cli/task-verb.ts +22 -0
  38. package/src/cli/upgrade.ts +3 -1
  39. package/src/cli/watch-fs.ts +6 -1
  40. package/src/cli/watch-judge.ts +3 -2
  41. package/src/cli/watch-set.ts +4 -4
  42. package/src/cli/watch.ts +94 -52
  43. package/src/cli/why.ts +123 -89
  44. package/src/cli/workspace-config.ts +2 -1
  45. package/src/config.ts +47 -30
  46. package/src/exec/executor.ts +42 -2
  47. package/src/exec/index.ts +1 -0
  48. package/src/exec/kill-tree.ts +58 -5
  49. package/src/exec/local-executor.ts +1 -1
  50. package/src/exec/pipes.ts +152 -0
  51. package/src/exec/runner.ts +199 -182
  52. package/src/exec/sandbox-binds.ts +1 -1
  53. package/src/exec/sandbox-paths.ts +3 -1
  54. package/src/exec/sandbox-runtime.ts +317 -23
  55. package/src/exec/spawn-off.ts +154 -0
  56. package/src/exec/task-proxy.ts +93 -0
  57. package/src/exec/warm-shell.ts +146 -0
  58. package/src/graph/critical-path.ts +40 -28
  59. package/src/graph/definition.ts +16 -3
  60. package/src/graph/priorities.ts +98 -44
  61. package/src/graph/scheduler.ts +17 -11
  62. package/src/graph/task-graph.ts +51 -24
  63. package/src/handoff.ts +10 -7
  64. package/src/history/failure-mode.ts +24 -1
  65. package/src/history/history.ts +5 -0
  66. package/src/history/index.ts +1 -0
  67. package/src/history/metrics.ts +14 -6
  68. package/src/index.ts +7 -3
  69. package/src/orchestrator/admission.ts +1 -1
  70. package/src/orchestrator/command-windows.ts +1 -1
  71. package/src/orchestrator/deferred-outputs.ts +1 -1
  72. package/src/orchestrator/doctor.ts +1 -1
  73. package/src/orchestrator/download-policy.ts +5 -7
  74. package/src/orchestrator/excluded-keys.ts +1 -1
  75. package/src/orchestrator/execute-task.ts +67 -20
  76. package/src/orchestrator/graph-memo.ts +50 -1
  77. package/src/orchestrator/graph-stage.ts +6 -0
  78. package/src/orchestrator/hit-restore.ts +5 -6
  79. package/src/orchestrator/index.ts +3 -0
  80. package/src/orchestrator/keyed-projects.ts +28 -0
  81. package/src/orchestrator/live-usage.ts +16 -11
  82. package/src/orchestrator/local-shortcircuit.ts +2 -2
  83. package/src/orchestrator/locked-digests.ts +63 -0
  84. package/src/orchestrator/lockfile-claim.ts +108 -29
  85. package/src/orchestrator/miss-save.ts +58 -11
  86. package/src/orchestrator/persistent-task.ts +5 -1
  87. package/src/orchestrator/placement.ts +1 -1
  88. package/src/orchestrator/plan.ts +7 -3
  89. package/src/orchestrator/plugin-host.ts +67 -2
  90. package/src/orchestrator/plugin.ts +53 -2
  91. package/src/orchestrator/prepare.ts +16 -1
  92. package/src/orchestrator/projects.ts +4 -1
  93. package/src/orchestrator/remote-prefetch.ts +1 -1
  94. package/src/orchestrator/run-context.ts +29 -10
  95. package/src/orchestrator/run-failures.ts +62 -13
  96. package/src/orchestrator/run-keys.ts +1 -1
  97. package/src/orchestrator/run-records.ts +88 -59
  98. package/src/orchestrator/run-report.ts +1 -1
  99. package/src/orchestrator/run.ts +145 -41
  100. package/src/orchestrator/sandbox-request.ts +52 -9
  101. package/src/orchestrator/shell-verdict.ts +9 -0
  102. package/src/orchestrator/signals.ts +8 -5
  103. package/src/orchestrator/stable-keys.ts +17 -18
  104. package/src/orchestrator/task-artifacts.ts +1 -1
  105. package/src/orchestrator/task-hash.ts +21 -7
  106. package/src/orchestrator/telemetry.ts +5 -1
  107. package/src/report/events.ts +6 -14
  108. package/src/report/forecast.ts +10 -32
  109. package/src/report/framed-output.ts +283 -175
  110. package/src/report/index.ts +8 -3
  111. package/src/report/logger.ts +74 -39
  112. package/src/report/plain-output.ts +3 -1
  113. package/src/report/status-line.ts +49 -357
  114. package/src/report/summary.ts +105 -136
  115. package/src/settings.ts +8 -8
  116. package/src/ui/ansi.ts +48 -0
  117. package/src/ui/components.ts +506 -0
  118. package/src/ui/index.ts +79 -0
  119. package/src/ui/kit.ts +37 -0
  120. package/src/ui/live-region.ts +363 -0
  121. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  122. package/src/ui/terminal.ts +51 -0
  123. package/src/ui/theme.ts +224 -0
  124. package/src/ui/version-line.ts +28 -0
  125. package/src/util/config-frame.ts +191 -14
  126. package/src/util/edit-distance.ts +6 -1
  127. package/src/util/index.ts +2 -15
  128. package/src/util/paths.ts +21 -3
  129. package/src/util/secret-mask.ts +39 -3
  130. package/src/util/settle.ts +33 -1
  131. package/src/util/tail.ts +4 -1
  132. package/src/util/timing.ts +4 -16
  133. package/src/workspace/affected.ts +89 -30
  134. package/src/workspace/bracket-literal.ts +2 -2
  135. package/src/workspace/config-schema.ts +70 -63
  136. package/src/workspace/dependencies-lock.ts +191 -0
  137. package/src/workspace/filter.ts +36 -1
  138. package/src/workspace/index.ts +9 -0
  139. package/src/workspace/lockfile.ts +159 -6
  140. package/src/workspace/migration.ts +16 -9
  141. package/src/workspace/workspace.ts +3 -0
  142. package/src/util/colors.ts +0 -133
  143. package/src/util/report.ts +0 -44
  144. package/src/util/ui.ts +0 -258
  145. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  146. /package/src/{report → ui}/terminal-signals.ts +0 -0
@@ -0,0 +1,28 @@
1
+ // `vx --version`'s one line, imported alone: the answer that loads nothing
2
+ // else (the ui barrel costs ~20 ms, `node:tty` 17 ms). Painted as the
3
+ // `status` component's verb and the info hue; piped, the same words a
4
+ // script reads.
5
+
6
+ import { fstatSync } from 'node:fs'
7
+
8
+ function versionLine(version: string, where: string, on: boolean): string {
9
+ const sgr = (code: string, text: string): string => (on ? `\x1b[${code}m${text}\x1b[0m` : text)
10
+ return `${sgr('1', 'vx')} ${sgr('38;2;6;182;212', version)}${where === '' ? '' : sgr('2', where)}\n`
11
+ }
12
+
13
+ /**
14
+ * Write the line to stdout. Colour is decided here, not by `detectColors`:
15
+ * fd 1 as a character device is the TTY answer, and a CI's log is not
16
+ * painted for this one line. `Bun.stdout`, not `process.stdout`: the Node
17
+ * stream's first touch is ~7 ms, half of this verb (2026-10-02). A reader
18
+ * gone (EPIPE) is no failure of the line.
19
+ */
20
+ export async function printVersion(version: string, where: string): Promise<void> {
21
+ const env = process.env
22
+ const on = env['NO_COLOR']
23
+ ? false
24
+ : env['FORCE_COLOR'] !== undefined
25
+ ? env['FORCE_COLOR'] !== '0' && env['FORCE_COLOR'] !== 'false'
26
+ : env['TERM'] !== 'dumb' && fstatSync(1).isCharacterDevice()
27
+ await Bun.write(Bun.stdout, versionLine(version, where, on)).catch(() => {})
28
+ }
@@ -7,9 +7,11 @@
7
7
  import { readFileSync } from 'node:fs'
8
8
  import path from 'node:path'
9
9
 
10
- // `<file>: tasks.<path>[:] …`, or a top-level `<file> has unknown field …`,
11
- // from a project's vx.config or the workspace's .vx/workspace.ts.
12
- const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (tasks\.[^\s:]+)(:?))? /
10
+ // `<file>: tasks.<path>[:] …`, `<file>: \`<setting>\` …`, `<file>: task name "<name>" …`,
11
+ // or a top-level `<file> has unknown field …`, from a project's vx.config
12
+ // or the workspace's .vx/workspace.ts.
13
+ const HEAD =
14
+ /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (?:(tasks\.[^\s:]+)(:?)|`([^`\n]+)`|task name ("(?:[^"\\\n]|\\.)*")))? /
13
15
 
14
16
  /**
15
17
  * The message with its file named at `line:col`, relative to `cwd`, and a
@@ -19,13 +21,36 @@ const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (task
19
21
  export function configErrorFrame(message: string, cwd: string): string | undefined {
20
22
  const head = HEAD.exec(message)
21
23
  if (head === null) return positionFrame(message, cwd)
22
- const [, file, fieldPath, colon] = head as unknown as [string, string, string?, string?]
24
+ const [, file, tasksPath, colon, setting, taskName] = head as unknown as [
25
+ string,
26
+ string,
27
+ string?,
28
+ string?,
29
+ string?,
30
+ string?,
31
+ ]
23
32
  const rest = message.slice(head[0].length)
24
33
  // The field the refusal is about, past the path that leads to it:
25
34
  // `has unknown field "comand"`, or `tasks.build: \`cache\` is not allowed`.
26
- const named =
27
- colon === ':' ? /^`([^`]+)`/.exec(rest) : /^has (?:unknown )?field "([^"]+)"/.exec(rest)
28
- const segments = [...(fieldPath?.split('.') ?? []), ...(named === null ? [] : [named[1]!])]
35
+ // A refused key is quoted: `tasks.b.exec.env.define: "A=B" is not …`.
36
+ const said =
37
+ colon === ':'
38
+ ? /^(?:`([^`]+)`|("(?:[^"\\\n]|\\.)*"))/.exec(rest)
39
+ : /^has (?:unknown )?field "([^"]+)"/.exec(rest)
40
+ const named = said === null ? null : [said[0], said[1] ?? JSON.parse(said[2]!)]
41
+ // `plugins[2].name`: an element of an array, framed at the element (a
42
+ // plugin is a call, so no key inside it is searched).
43
+ const element = setting === undefined ? null : /^([^[]+)\[(\d+)\]/.exec(setting)
44
+ const fieldPath =
45
+ taskName !== undefined
46
+ ? ['tasks', JSON.parse(taskName)]
47
+ : element !== null
48
+ ? element[1]!.split('.')
49
+ : (tasksPath ?? setting)?.split('.')
50
+ const segments = [
51
+ ...(fieldPath ?? []),
52
+ ...(named === null || element !== null ? [] : [named[1]!]),
53
+ ]
29
54
  if (segments.length === 0) return undefined
30
55
  let source: string
31
56
  try {
@@ -33,7 +58,7 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
33
58
  } catch {
34
59
  return undefined
35
60
  }
36
- const at = locate(source, segments)
61
+ const at = locate(source, segments, element === null ? undefined : Number(element[2]))
37
62
  if (at === undefined) return undefined
38
63
  return framed(file, source, at, message.slice(file.length), cwd)
39
64
  }
@@ -43,8 +68,70 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
43
68
  const POSITION =
44
69
  /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s):(\d+):(\d+)(: [^]*)$/
45
70
 
71
+ // The loader's `Project config <file> threw: <message> (at <file>:<line>:<col>)`:
72
+ // the config's own throw, placed by its stack in the config itself.
73
+ const THREW =
74
+ /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s) threw: ([^\n]*?) \(at (\/[^\n]*?):(\d+):(\d+)\)([^]*)$/
75
+
76
+ // The loader's `<kind> config <file>: cannot find '<pkg>' …`: placed at the
77
+ // specifier. And `<kind> config at <file> did not export a default object`:
78
+ // at the `export default` that exported something else, when there is one.
79
+ const UNPROVIDED =
80
+ /^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s): (cannot find '([^'\n]+)'[^]*)$/
81
+ const NOT_OBJECT =
82
+ /^(?:Project|Workspace) config at (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s) (did not export a default object[^]*)$/
83
+
84
+ /** The file, the source offset `find` places in it, and what follows `<file>:<line>:<col>`. */
85
+ function sourceFrame(
86
+ file: string,
87
+ find: (source: string) => number,
88
+ rest: string,
89
+ cwd: string,
90
+ ): string | undefined {
91
+ let source: string
92
+ try {
93
+ source = readFileSync(file, 'utf8')
94
+ } catch {
95
+ return undefined
96
+ }
97
+ const at = find(source)
98
+ if (at < 0) return undefined
99
+ const before = source.slice(0, at)
100
+ const line = before.split('\n').length
101
+ return framed(file, source, { line, col: at - before.lastIndexOf('\n') }, rest, cwd)
102
+ }
103
+
46
104
  function positionFrame(message: string, cwd: string): string | undefined {
47
- const pos = POSITION.exec(message)
105
+ const unprovided = UNPROVIDED.exec(message)
106
+ if (unprovided !== null) {
107
+ const [, file, said, pkg] = unprovided as unknown as [string, string, string, string]
108
+ const spec = new RegExp(
109
+ `(?<=\\b(?:from|import|import\\(|require\\()\\s*)(['"])${escapeRe(pkg)}\\1`,
110
+ )
111
+ return sourceFrame(file, (src) => spec.exec(src)?.index ?? -1, `: ${said}`, cwd)
112
+ }
113
+ const notObject = NOT_OBJECT.exec(message)
114
+ if (notObject !== null) {
115
+ const [, file, said] = notObject as unknown as [string, string, string]
116
+ return sourceFrame(
117
+ file,
118
+ (src) => /^export\s+default\b/m.exec(src)?.index ?? -1,
119
+ `: ${said}`,
120
+ cwd,
121
+ )
122
+ }
123
+ const threw = THREW.exec(message)
124
+ const pos =
125
+ threw !== null && threw[3] === threw[1]
126
+ ? [
127
+ threw[0],
128
+ threw[1],
129
+ threw[4],
130
+ threw[5],
131
+ // A cause's own `(at …)` names the same file: relative there too.
132
+ `: threw: ${threw[2]}${threw[6]!.replaceAll(threw[1]!, path.relative(cwd, threw[1]!))}`,
133
+ ]
134
+ : POSITION.exec(message)
48
135
  if (pos === null) return undefined
49
136
  const [, file, line, col, rest] = pos as unknown as [string, string, string, string, string]
50
137
  let source: string
@@ -86,37 +173,127 @@ function framed(
86
173
 
87
174
  /**
88
175
  * Where the last of `segments` is declared as a key, each one searched
89
- * after the one before. A task name holds dots (`lint.oxfmt`), so the
90
- * longest run of segments that names a key wins.
176
+ * inside the object literal the one before holds. A task name holds dots
177
+ * (`lint.oxfmt`), so the longest run of segments that names a key wins.
178
+ * A last segment the object leaves out (`outputs is required`) is placed
179
+ * at its parent: searched past it, the next task's `outputs` was framed.
91
180
  */
92
181
  function locate(
93
182
  source: string,
94
183
  segments: readonly string[],
184
+ /** The element of the array the last segment holds; past its end, the key. */
185
+ index?: number,
95
186
  ): { line: number; col: number } | undefined {
96
187
  let pos = 0
188
+ let end = source.length
97
189
  let found = -1
98
190
  let i = 0
99
191
  while (i < segments.length) {
100
192
  let next = -1
101
193
  for (let j = segments.length; j > i && next < 0; j--) {
102
194
  const name = segments.slice(i, j).join('.')
103
- const re = new RegExp(`(?<![\\w$.])(['"\`]?)${escapeRe(name)}\\1\\s*:`, 'g')
195
+ // An empty key is quoted, or every `:` matched; a control character
196
+ // is in the source as its escape (`'\u001b'`).
197
+ const spelled = JSON.stringify(name).slice(1, -1)
198
+ const names = spelled === name ? escapeRe(name) : `${escapeRe(name)}|${escapeRe(spelled)}`
199
+ const re = new RegExp(
200
+ `(?<![\\w$.])(['"\`]${name === '' ? '' : '?'})(?:${names})\\1\\s*:`,
201
+ 'g',
202
+ )
104
203
  re.lastIndex = pos
105
204
  const m = re.exec(source)
106
- if (m === null) continue
205
+ if (m === null || m.index >= end) continue
107
206
  found = m.index + m[1]!.length
108
207
  pos = m.index + m[0].length
208
+ end = literalEnd(source, pos)
109
209
  next = j
110
210
  }
111
- if (next < 0) return undefined
211
+ if (next < 0) {
212
+ if (found < 0 || i !== segments.length - 1) return undefined
213
+ break
214
+ }
112
215
  i = next
113
216
  }
114
217
  if (found < 0) return undefined
218
+ if (index !== undefined && i === segments.length) {
219
+ const el = elementStart(source, pos, index)
220
+ if (el !== undefined) found = el
221
+ }
115
222
  const before = source.slice(0, found)
116
223
  const line = before.split('\n').length
117
224
  return { line, col: found - before.lastIndexOf('\n') }
118
225
  }
119
226
 
227
+ /**
228
+ * Where the object or array literal starting at `from` (past blanks) ends;
229
+ * `from` itself when the value is no literal (`exec: 'x'`, `cache: shared`),
230
+ * so no key is searched inside it.
231
+ */
232
+ function literalEnd(source: string, from: number): number {
233
+ let i = from
234
+ while (i < source.length && /\s/.test(source[i]!)) i++
235
+ if (source[i] !== '{' && source[i] !== '[') return from
236
+ let depth = 0
237
+ for (; i < source.length; i++) {
238
+ const c = source[i]!
239
+ if (c === '"' || c === "'" || c === '`') {
240
+ for (i++; i < source.length && source[i] !== c; i++) if (source[i] === '\\') i++
241
+ } else if (c === '/' && source[i + 1] === '/') {
242
+ i = source.indexOf('\n', i)
243
+ if (i < 0) return source.length
244
+ } else if (c === '/' && source[i + 1] === '*') {
245
+ i = source.indexOf('*/', i + 2)
246
+ if (i < 0) return source.length
247
+ i++
248
+ } else if (c === '{' || c === '[') depth++
249
+ else if (c === '}' || c === ']') {
250
+ depth--
251
+ if (depth === 0) return i
252
+ }
253
+ }
254
+ return source.length
255
+ }
256
+
257
+ /** Where element `index` of the array literal at `from` (past blanks) starts, if it has one. */
258
+ function elementStart(source: string, from: number, index: number): number | undefined {
259
+ let i = from
260
+ while (i < source.length && /\s/.test(source[i]!)) i++
261
+ if (source[i] !== '[') return undefined
262
+ let depth = 0
263
+ let n = 0
264
+ // Each element starts at the first code past `[` or a top-level `,`.
265
+ let pending = true
266
+ for (i++; i < source.length; i++) {
267
+ const c = source[i]!
268
+ if (c === '/' && source[i + 1] === '/') {
269
+ i = source.indexOf('\n', i)
270
+ if (i < 0) return undefined
271
+ continue
272
+ }
273
+ if (c === '/' && source[i + 1] === '*') {
274
+ i = source.indexOf('*/', i + 2)
275
+ if (i < 0) return undefined
276
+ i++
277
+ continue
278
+ }
279
+ if (/\s/.test(c)) continue
280
+ if (depth === 0 && (c === ']' || c === ')' || c === '}')) return undefined
281
+ if (pending) {
282
+ if (n === index) return i
283
+ pending = false
284
+ }
285
+ if (c === '"' || c === "'" || c === '`') {
286
+ for (i++; i < source.length && source[i] !== c; i++) if (source[i] === '\\') i++
287
+ } else if (c === '{' || c === '[' || c === '(') depth++
288
+ else if (c === '}' || c === ']' || c === ')') depth--
289
+ else if (c === ',' && depth === 0) {
290
+ n++
291
+ pending = true
292
+ }
293
+ }
294
+ return undefined
295
+ }
296
+
120
297
  function escapeRe(s: string): string {
121
298
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
122
299
  }
@@ -37,9 +37,14 @@ export function nearest(
37
37
  // Two edits rewrite half of a word of four letters or fewer (`--poll`
38
38
  // read as `--all`, `vx test` as `last`), unless they swap two letters.
39
39
  const short = maxEdits === 2 && name.replace(/^-+/, '').length <= 4
40
+ // The same word in another case is the nearest whatever its length:
41
+ // `--ALL` is three edits from `--all`, `BUILD` five from `build`.
42
+ const lower = name.toLowerCase()
43
+ const list = [...candidates]
44
+ for (const c of list) if (c !== name && c.toLowerCase() === lower) return c
40
45
  let best: string | undefined
41
46
  let bestD = maxEdits + 1
42
- for (const c of candidates) {
47
+ for (const c of list) {
43
48
  // Capped one past the budget, so "past it" never reads as within it:
44
49
  // `--continue-on-error` was nine edits from `--concurrency`, capped to
45
50
  // the three `vx run`'s same-prefix hint allows, and offered.
package/src/util/index.ts CHANGED
@@ -27,25 +27,11 @@ export {
27
27
  } from './errors.js'
28
28
  export { configErrorFrame, configRefusal } from './config-frame.js'
29
29
  export { envFlagOn } from './env-flag.js'
30
- export {
31
- detectColors,
32
- dumbTerminal,
33
- ERROR,
34
- identityColor,
35
- paint,
36
- SUCCESS,
37
- WARN,
38
- type ColorSupport,
39
- } from './colors.js'
40
- export { barWidth, gradientRule, ruleWidth } from './report.js'
41
- import * as ui from './ui.js'
42
- export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
43
- export { ui }
44
30
  export { keysSorted, xxh3, xxh3hex } from './hash.js'
45
31
  export {
46
32
  beginRun,
47
33
  mark,
48
- printTimings,
34
+ timingLines,
49
35
  restartTimings,
50
36
  span,
51
37
  stageTimes,
@@ -86,6 +72,7 @@ export {
86
72
  } from './paths.js'
87
73
  export {
88
74
  claimExitForSignal,
75
+ dieOf,
89
76
  exitClaimedBySignal,
90
77
  killGraceMs,
91
78
  settleWithin,
package/src/util/paths.ts CHANGED
@@ -202,7 +202,7 @@ export function isLiteralPattern(glob: string): boolean {
202
202
  }
203
203
 
204
204
  /**
205
- * The alphabet of a TASK glob (`inputs.files`, `cache.outputs.files`,
205
+ * The alphabet of a TASK glob (`inputs.files`, `outputs.files`,
206
206
  * `workspaceFiles`), behind {@link isLiteralPattern}, for the callers that
207
207
  * need the POSITION of the first wildcard rather than a verdict. Every site
208
208
  * that asks "must this declaration be matched?" reads this one regex: item
@@ -523,15 +523,33 @@ export function braceAlternatives(pattern: string): string[] {
523
523
  return expandBraces(pattern, false)
524
524
  }
525
525
 
526
+ /**
527
+ * Where the `[`…`]` class opening at `i` ends, as `bracket-literal.ts`
528
+ * reads one (a `]` right after the `[` is a member); `i` for a `[` with no
529
+ * `]`, which `Bun.Glob` takes literally. A `,` `{` or `}` inside a class
530
+ * is a member: split there, `src/{a[,]b,x}.ts` became `src/a[.ts` and
531
+ * `src/]b.ts`, so an edit to `src/a,b.ts` kept the key (turborepo#13763).
532
+ */
533
+ function classEnd(pattern: string, i: number): number {
534
+ const end = pattern.indexOf(']', i + 2)
535
+ return end === -1 ? i : end
536
+ }
537
+
526
538
  function expandBraces(pattern: string, slashOnly: boolean): string[] {
527
539
  if (pattern.includes('\\')) return [pattern]
528
- for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
540
+ for (let open = 0; open < pattern.length; open++) {
541
+ if (pattern[open] === '[') {
542
+ open = classEnd(pattern, open)
543
+ continue
544
+ }
545
+ if (pattern[open] !== '{') continue
529
546
  let depth = 0
530
547
  const cuts: number[] = []
531
548
  let close = -1
532
549
  for (let i = open; i < pattern.length && close === -1; i++) {
533
550
  const c = pattern[i]
534
- if (c === '{') depth++
551
+ if (c === '[') i = classEnd(pattern, i)
552
+ else if (c === '{') depth++
535
553
  else if (c === '}' && --depth === 0) close = i
536
554
  else if (c === ',' && depth === 1) cuts.push(i)
537
555
  }
@@ -115,15 +115,51 @@ export function secretMask(
115
115
  for (const name in source) {
116
116
  const value = source[name]
117
117
  if (secretNamed(name)) add(value)
118
- if (value?.includes('://') === true && value.includes('@')) {
119
- for (const m of value.matchAll(URL_PASSWORD)) add(m[1])
120
- }
118
+ if (value?.includes('://') === true && value.includes('@'))
119
+ for (const pw of urlPasswords(value)) add(pw)
121
120
  }
122
121
  for (const name of named) add(source[name])
123
122
  }
124
123
  // This process's secrets: the environment's and the held ones.
125
124
  if (sources.includes(process.env)) for (const value of held) add(value)
126
125
  if (values.size === 0) return null
126
+ const found = [...values]
127
+ for (const m of masks) if (sameValues(m.values, found)) return m.mask
128
+ if (masks.length >= MASKS_KEPT) masks.shift()
129
+ const mask = maskOf(values)
130
+ masks.push({ values: found, mask })
131
+ return mask
132
+ }
133
+
134
+ /**
135
+ * Masks built, with the values each masks: every task of a run asks for
136
+ * the same one, and building it (a sort, a RegExp the engine compiles
137
+ * again) took 30 ms of a cold run's main thread at 500 tasks. The values
138
+ * are read fresh on every call; only the same values reuse a mask.
139
+ */
140
+ const masks: Array<{ values: readonly string[]; mask: SecretMask }> = []
141
+ const MASKS_KEPT = 8
142
+
143
+ function sameValues(a: readonly string[], b: readonly string[]): boolean {
144
+ if (a.length !== b.length) return false
145
+ for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
146
+ return true
147
+ }
148
+
149
+ /** Each URL value's passwords, found once: the value decides them. */
150
+ const urlPasswordsOf = new Map<string, string[]>()
151
+
152
+ function urlPasswords(value: string): string[] {
153
+ let found = urlPasswordsOf.get(value)
154
+ if (found === undefined) {
155
+ found = []
156
+ for (const m of value.matchAll(URL_PASSWORD)) if (m[1] !== undefined) found.push(m[1])
157
+ urlPasswordsOf.set(value, found)
158
+ }
159
+ return found
160
+ }
161
+
162
+ function maskOf(values: ReadonlySet<string>): SecretMask {
127
163
  // Longest first, so a value holding another is replaced whole.
128
164
  const sorted = [...values].sort((a, b) => b.length - a.length)
129
165
  const pattern = new RegExp(
@@ -51,7 +51,8 @@ export function killGraceMs(defaultMs: number): number {
51
51
  export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boolean> {
52
52
  let timer: ReturnType<typeof setTimeout> | undefined
53
53
  const deadline = new Promise<false>((resolve) => {
54
- timer = setTimeout(() => resolve(false), ms)
54
+ // A delay past a 32-bit int fires at 1 ms: the longest the timer holds instead.
55
+ timer = setTimeout(() => resolve(false), Math.min(ms, MAX_TIMEOUT_MS))
55
56
  })
56
57
  try {
57
58
  return (await Promise.race([p.then(() => true), deadline])) !== false
@@ -69,6 +70,37 @@ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boo
69
70
  // with the run's verdict (1), so bin.ts asks before it exits.
70
71
  let signalOwnsExit = false
71
72
 
73
+ /** How long `dieOf` waits for its own signal to land before the caller exits anyway. */
74
+ const SELF_SIGNAL_WAIT_MS = 1000
75
+
76
+ /**
77
+ * Die of `signal`: its listeners off, so Bun restores the default action,
78
+ * the signal sent to this process, and the caller held until it lands. A
79
+ * kill is not always delivered before it returns: a signal another thread
80
+ * takes, or one a tracer holds (the sandbox's `strace -f` sees every
81
+ * signal before the tracee does), lands later, and the `process.exit`
82
+ * after it won: vx exited with the signal's code (130), not of the
83
+ * signal, and a script running it
84
+ * went on (CI, PR #4867). Bounded: the caller's exit code is the fallback
85
+ * should the signal never land. `testTimerMs('SELF_SIGNAL_DELAY')` sends
86
+ * it from a helper that late instead, the window a test widens.
87
+ */
88
+ export function dieOf(signal: 'SIGINT' | 'SIGTERM' | 'SIGHUP'): void {
89
+ process.removeAllListeners(signal)
90
+ const lateMs = testTimerMs('SELF_SIGNAL_DELAY', 0)
91
+ if (lateMs > 0) {
92
+ Bun.spawn(['sh', '-c', `sleep ${lateMs / 1000}; kill -${signal.slice(3)} ${process.pid}`], {
93
+ stdio: ['ignore', 'ignore', 'ignore'],
94
+ })
95
+ } else {
96
+ process.kill(process.pid, signal)
97
+ }
98
+ // Synchronous: an `exit` listener's loop never turns again. A signal
99
+ // that lands ends the process here; the wait costs nothing then.
100
+ const until = performance.now() + lateMs + SELF_SIGNAL_WAIT_MS
101
+ while (performance.now() < until) Bun.sleepSync(5)
102
+ }
103
+
72
104
  /** A signal's handler will end the process: bin.ts leaves the exit to it. */
73
105
  export function claimExitForSignal(): void {
74
106
  signalOwnsExit = true
package/src/util/tail.ts CHANGED
@@ -32,8 +32,11 @@ export function createTail(): Tail {
32
32
  return { chunks: [], chars: 0, dropped: 0 }
33
33
  }
34
34
 
35
- export function appendTail(t: Tail, chunk: string, limit = PERSISTENT_TAIL_CHARS): void {
35
+ export function appendTail(t: Tail, chunk: string, cap = PERSISTENT_TAIL_CHARS): void {
36
36
  if (chunk.length === 0) return
37
+ // Every bound below is `chars > limit`, which NaN never is: a NaN cap
38
+ // kept everything, the unbounded heap this exists to prevent.
39
+ const limit = Number.isNaN(cap) ? 0 : cap
37
40
  t.chunks.push(chunk)
38
41
  t.chars += chunk.length
39
42
  // Evict WHOLE chunks from the head — no concatenation until the flush
@@ -9,7 +9,6 @@
9
9
  // prints (a push per stage, ~15 a run): a telemetry sink draws them as
10
10
  // spans (`stageTimes`). The per-call `span`s stay off unless enabled.
11
11
 
12
- import { detectColors, paint } from './colors.js'
13
12
  const enabled = process.env.VX_DEBUG_TIMING === 'true' || process.env.VX_DEBUG_TIMING === '1'
14
13
  let t0 = Bun.nanoseconds()
15
14
  // The wall clock at t0, read then: `performance.timeOrigin` plus the
@@ -88,9 +87,9 @@ export function span(label: string): () => void {
88
87
  }
89
88
  }
90
89
 
91
- /** Print the stage table (once, at the end of a run). No-op unless enabled. */
92
- export function printTimings(): void {
93
- if (!enabled || marks.length === 0) return
90
+ /** The stage table's lines (once, at the end of a run); none unless enabled. */
91
+ export function timingLines(): string[] | undefined {
92
+ if (!enabled || marks.length === 0) return undefined
94
93
  const width = Math.max(...marks.map(([l]) => l.length))
95
94
  let prev = 0
96
95
  const lines = ['[vx timing] stage'.padEnd(width + 14) + ' own cumulative']
@@ -118,16 +117,5 @@ export function printTimings(): void {
118
117
  ' (wall per call, summed; concurrent calls overlap — compare spans, not totals)',
119
118
  )
120
119
  }
121
- const c = detectColors(process.stderr)
122
- process.stderr.write(
123
- `${lines
124
- .map((l) =>
125
- l.startsWith('[vx timing]')
126
- ? `${paint('#06b6d4', '[vx timing]', c, { bold: true })}${paint('', l.slice(11), c, { dim: true })}`
127
- : l.startsWith(' (')
128
- ? paint('', l, c, { dim: true })
129
- : l,
130
- )
131
- .join('\n')}\n`,
132
- )
120
+ return lines
133
121
  }