@vzn/vx 0.0.650 → 0.0.651

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 (115) hide show
  1. package/README.md +5 -20
  2. package/package.json +5 -5
  3. package/schemas/last.json +15 -0
  4. package/src/bin.ts +9 -31
  5. package/src/cache/cache.ts +202 -25
  6. package/src/cache/file-hashes.ts +3 -0
  7. package/src/cache/git-inputs.ts +188 -39
  8. package/src/cache/index.ts +1 -0
  9. package/src/cache/inputs.ts +3 -0
  10. package/src/cache/layer.ts +11 -0
  11. package/src/cache/layered-cache.ts +4 -0
  12. package/src/cache/linked-inputs.ts +3 -0
  13. package/src/cache/lockfile-memo.ts +3 -2
  14. package/src/cache/output-index.ts +5 -2
  15. package/src/cache/remote-lane.ts +2 -1
  16. package/src/cache/run-history.ts +11 -1
  17. package/src/cache/schema.ts +11 -1
  18. package/src/cli/cache.ts +26 -16
  19. package/src/cli/completions.ts +4 -2
  20. package/src/cli/docs.ts +10 -11
  21. package/src/cli/help.ts +12 -23
  22. package/src/cli/index.ts +4 -9
  23. package/src/cli/info.ts +15 -7
  24. package/src/cli/init.ts +23 -15
  25. package/src/cli/last.ts +213 -203
  26. package/src/cli/lock.ts +55 -5
  27. package/src/cli/plan-format.ts +100 -118
  28. package/src/cli/plugin-commands.ts +8 -2
  29. package/src/cli/plugin-templates.ts +2 -2
  30. package/src/cli/run.ts +46 -27
  31. package/src/cli/select.ts +43 -5
  32. package/src/cli/show.ts +38 -42
  33. package/src/cli/task-verb.ts +22 -0
  34. package/src/cli/upgrade.ts +3 -1
  35. package/src/cli/watch-fs.ts +6 -1
  36. package/src/cli/watch-judge.ts +2 -1
  37. package/src/cli/watch.ts +83 -44
  38. package/src/cli/why.ts +123 -89
  39. package/src/cli/workspace-config.ts +2 -1
  40. package/src/exec/executor.ts +2 -1
  41. package/src/exec/kill-tree.ts +58 -5
  42. package/src/exec/local-executor.ts +1 -1
  43. package/src/exec/pipes.ts +152 -0
  44. package/src/exec/runner.ts +199 -182
  45. package/src/exec/sandbox-binds.ts +1 -1
  46. package/src/exec/sandbox-runtime.ts +11 -2
  47. package/src/exec/spawn-off.ts +154 -0
  48. package/src/exec/warm-shell.ts +146 -0
  49. package/src/graph/critical-path.ts +40 -28
  50. package/src/graph/priorities.ts +98 -44
  51. package/src/graph/scheduler.ts +14 -10
  52. package/src/graph/task-graph.ts +27 -1
  53. package/src/handoff.ts +10 -7
  54. package/src/history/history.ts +5 -0
  55. package/src/history/metrics.ts +9 -1
  56. package/src/index.ts +3 -1
  57. package/src/orchestrator/doctor.ts +1 -1
  58. package/src/orchestrator/execute-task.ts +16 -2
  59. package/src/orchestrator/graph-memo.ts +50 -1
  60. package/src/orchestrator/graph-stage.ts +6 -0
  61. package/src/orchestrator/index.ts +3 -0
  62. package/src/orchestrator/live-usage.ts +16 -11
  63. package/src/orchestrator/locked-digests.ts +63 -0
  64. package/src/orchestrator/lockfile-claim.ts +108 -29
  65. package/src/orchestrator/miss-save.ts +31 -3
  66. package/src/orchestrator/persistent-task.ts +3 -1
  67. package/src/orchestrator/plan.ts +5 -1
  68. package/src/orchestrator/plugin-host.ts +61 -0
  69. package/src/orchestrator/plugin.ts +47 -2
  70. package/src/orchestrator/prepare.ts +16 -1
  71. package/src/orchestrator/projects.ts +4 -1
  72. package/src/orchestrator/run-context.ts +29 -10
  73. package/src/orchestrator/run-failures.ts +1 -1
  74. package/src/orchestrator/run-records.ts +10 -0
  75. package/src/orchestrator/run.ts +53 -22
  76. package/src/orchestrator/sandbox-request.ts +2 -2
  77. package/src/orchestrator/signals.ts +8 -5
  78. package/src/orchestrator/task-hash.ts +17 -3
  79. package/src/report/events.ts +3 -11
  80. package/src/report/forecast.ts +10 -32
  81. package/src/report/framed-output.ts +272 -173
  82. package/src/report/index.ts +8 -3
  83. package/src/report/logger.ts +73 -38
  84. package/src/report/plain-output.ts +1 -1
  85. package/src/report/status-line.ts +49 -357
  86. package/src/report/summary.ts +100 -131
  87. package/src/ui/ansi.ts +48 -0
  88. package/src/ui/components.ts +506 -0
  89. package/src/ui/index.ts +79 -0
  90. package/src/ui/kit.ts +37 -0
  91. package/src/ui/live-region.ts +363 -0
  92. package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
  93. package/src/ui/terminal.ts +51 -0
  94. package/src/ui/theme.ts +224 -0
  95. package/src/ui/version-line.ts +28 -0
  96. package/src/util/config-frame.ts +65 -10
  97. package/src/util/edit-distance.ts +6 -1
  98. package/src/util/index.ts +2 -15
  99. package/src/util/paths.ts +20 -2
  100. package/src/util/secret-mask.ts +39 -3
  101. package/src/util/settle.ts +31 -0
  102. package/src/util/timing.ts +4 -16
  103. package/src/workspace/affected.ts +89 -30
  104. package/src/workspace/config-schema.ts +4 -2
  105. package/src/workspace/dependencies-lock.ts +191 -0
  106. package/src/workspace/filter.ts +28 -0
  107. package/src/workspace/index.ts +9 -0
  108. package/src/workspace/lockfile.ts +159 -6
  109. package/src/workspace/migration.ts +4 -3
  110. package/src/workspace/workspace.ts +3 -0
  111. package/src/util/colors.ts +0 -133
  112. package/src/util/report.ts +0 -44
  113. package/src/util/ui.ts +0 -258
  114. /package/src/{report/path-links.ts → ui/links.ts} +0 -0
  115. /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,22 @@ 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
35
  const named =
27
36
  colon === ':' ? /^`([^`]+)`/.exec(rest) : /^has (?:unknown )?field "([^"]+)"/.exec(rest)
28
- const segments = [...(fieldPath?.split('.') ?? []), ...(named === null ? [] : [named[1]!])]
37
+ const fieldPath =
38
+ taskName === undefined ? (tasksPath ?? setting)?.split('.') : ['tasks', JSON.parse(taskName)]
39
+ const segments = [...(fieldPath ?? []), ...(named === null ? [] : [named[1]!])]
29
40
  if (segments.length === 0) return undefined
30
41
  let source: string
31
42
  try {
@@ -86,29 +97,43 @@ function framed(
86
97
 
87
98
  /**
88
99
  * 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.
100
+ * inside the object literal the one before holds. A task name holds dots
101
+ * (`lint.oxfmt`), so the longest run of segments that names a key wins.
102
+ * A last segment the object leaves out (`outputs is required`) is placed
103
+ * at its parent: searched past it, the next task's `outputs` was framed.
91
104
  */
92
105
  function locate(
93
106
  source: string,
94
107
  segments: readonly string[],
95
108
  ): { line: number; col: number } | undefined {
96
109
  let pos = 0
110
+ let end = source.length
97
111
  let found = -1
98
112
  let i = 0
99
113
  while (i < segments.length) {
100
114
  let next = -1
101
115
  for (let j = segments.length; j > i && next < 0; j--) {
102
116
  const name = segments.slice(i, j).join('.')
103
- const re = new RegExp(`(?<![\\w$.])(['"\`]?)${escapeRe(name)}\\1\\s*:`, 'g')
117
+ // An empty key is quoted, or every `:` matched; a control character
118
+ // is in the source as its escape (`'\u001b'`).
119
+ const spelled = JSON.stringify(name).slice(1, -1)
120
+ const names = spelled === name ? escapeRe(name) : `${escapeRe(name)}|${escapeRe(spelled)}`
121
+ const re = new RegExp(
122
+ `(?<![\\w$.])(['"\`]${name === '' ? '' : '?'})(?:${names})\\1\\s*:`,
123
+ 'g',
124
+ )
104
125
  re.lastIndex = pos
105
126
  const m = re.exec(source)
106
- if (m === null) continue
127
+ if (m === null || m.index >= end) continue
107
128
  found = m.index + m[1]!.length
108
129
  pos = m.index + m[0].length
130
+ end = literalEnd(source, pos)
109
131
  next = j
110
132
  }
111
- if (next < 0) return undefined
133
+ if (next < 0) {
134
+ if (found < 0 || i !== segments.length - 1) return undefined
135
+ break
136
+ }
112
137
  i = next
113
138
  }
114
139
  if (found < 0) return undefined
@@ -117,6 +142,36 @@ function locate(
117
142
  return { line, col: found - before.lastIndexOf('\n') }
118
143
  }
119
144
 
145
+ /**
146
+ * Where the object or array literal starting at `from` (past blanks) ends;
147
+ * `from` itself when the value is no literal (`exec: 'x'`, `cache: shared`),
148
+ * so no key is searched inside it.
149
+ */
150
+ function literalEnd(source: string, from: number): number {
151
+ let i = from
152
+ while (i < source.length && /\s/.test(source[i]!)) i++
153
+ if (source[i] !== '{' && source[i] !== '[') return from
154
+ let depth = 0
155
+ for (; i < source.length; i++) {
156
+ const c = source[i]!
157
+ if (c === '"' || c === "'" || c === '`') {
158
+ for (i++; i < source.length && source[i] !== c; i++) if (source[i] === '\\') i++
159
+ } else if (c === '/' && source[i + 1] === '/') {
160
+ i = source.indexOf('\n', i)
161
+ if (i < 0) return source.length
162
+ } else if (c === '/' && source[i + 1] === '*') {
163
+ i = source.indexOf('*/', i + 2)
164
+ if (i < 0) return source.length
165
+ i++
166
+ } else if (c === '{' || c === '[') depth++
167
+ else if (c === '}' || c === ']') {
168
+ depth--
169
+ if (depth === 0) return i
170
+ }
171
+ }
172
+ return source.length
173
+ }
174
+
120
175
  function escapeRe(s: string): string {
121
176
  return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
122
177
  }
@@ -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
@@ -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(
@@ -69,6 +69,37 @@ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boo
69
69
  // with the run's verdict (1), so bin.ts asks before it exits.
70
70
  let signalOwnsExit = false
71
71
 
72
+ /** How long `dieOf` waits for its own signal to land before the caller exits anyway. */
73
+ const SELF_SIGNAL_WAIT_MS = 1000
74
+
75
+ /**
76
+ * Die of `signal`: its listeners off, so Bun restores the default action,
77
+ * the signal sent to this process, and the caller held until it lands. A
78
+ * kill is not always delivered before it returns: a signal another thread
79
+ * takes, or one a tracer holds (the sandbox's `strace -f` sees every
80
+ * signal before the tracee does), lands later, and the `process.exit`
81
+ * after it won: vx exited with the signal's code (130), not of the
82
+ * signal, and a script running it
83
+ * went on (CI, PR #4867). Bounded: the caller's exit code is the fallback
84
+ * should the signal never land. `testTimerMs('SELF_SIGNAL_DELAY')` sends
85
+ * it from a helper that late instead, the window a test widens.
86
+ */
87
+ export function dieOf(signal: 'SIGINT' | 'SIGTERM' | 'SIGHUP'): void {
88
+ process.removeAllListeners(signal)
89
+ const lateMs = testTimerMs('SELF_SIGNAL_DELAY', 0)
90
+ if (lateMs > 0) {
91
+ Bun.spawn(['sh', '-c', `sleep ${lateMs / 1000}; kill -${signal.slice(3)} ${process.pid}`], {
92
+ stdio: ['ignore', 'ignore', 'ignore'],
93
+ })
94
+ } else {
95
+ process.kill(process.pid, signal)
96
+ }
97
+ // Synchronous: an `exit` listener's loop never turns again. A signal
98
+ // that lands ends the process here; the wait costs nothing then.
99
+ const until = performance.now() + lateMs + SELF_SIGNAL_WAIT_MS
100
+ while (performance.now() < until) Bun.sleepSync(5)
101
+ }
102
+
72
103
  /** A signal's handler will end the process: bin.ts leaves the exit to it. */
73
104
  export function claimExitForSignal(): void {
74
105
  signalOwnsExit = true
@@ -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
  }
@@ -18,8 +18,16 @@ import {
18
18
  notAWorkTree,
19
19
  relPosix,
20
20
  UserError,
21
+ xxh3hex,
21
22
  } from '../util/index.js'
22
- import { LOCKFILE_NAME, lockKey } from './lockfile.js'
23
+ import { LOCKFILE_NAME, lockEntries, lockKey } from './lockfile.js'
24
+ import {
25
+ DEPENDENCIES_LOCK_NAME,
26
+ type DependenciesLock,
27
+ dependantsOf,
28
+ recordedDigests,
29
+ parseDependenciesLock,
30
+ } from './dependencies-lock.js'
23
31
  import { configImportOwners, realpathOr } from './config-imports.js'
24
32
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
25
33
  import { bunPatchFiles } from './fingerprint.js'
@@ -143,6 +151,10 @@ export interface FingerprintClaims {
143
151
  }): Promise<ReadonlySet<string> | undefined>
144
152
  /** The files a claimed file names that its claimant keys too; absent = it reads none. */
145
153
  extraFiles?(file: string, bytes: Uint8Array): Promise<readonly string[] | undefined>
154
+ /** Each project's digest of a claimed file now (`FingerprintClaim.digests`); absent or undefined = none. */
155
+ digests?(
156
+ file: string,
157
+ ): Promise<{ hash: string; projects: Readonly<Record<string, string>> } | undefined>
146
158
  }
147
159
 
148
160
  /**
@@ -388,6 +400,12 @@ export async function affectedChanges(
388
400
  if (wsConfig !== undefined) return everything(wsConfig)
389
401
  // The workspace file is loaded once, for the extra files below and the
390
402
  // changed root names after them.
403
+ // The base commit's `vx lock` record, read once and only when asked.
404
+ let baseDeps: Promise<DependenciesLock | null> | undefined
405
+ const baseDependencies = (): Promise<DependenciesLock | null> =>
406
+ (baseDeps ??= gitBytesAt(args.workspaceRoot, base, DEPENDENCIES_LOCK_NAME).then(
407
+ parseDependenciesLock,
408
+ ))
391
409
  let claims: FingerprintClaims | undefined
392
410
  const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
393
411
  (claims ??= await args.fingerprintClaims?.())
@@ -446,6 +464,32 @@ export async function affectedChanges(
446
464
  if (patch !== undefined) return everything(patch)
447
465
  }
448
466
  }
467
+ // The projects whose digest of `file` moved, from the base's recorded
468
+ // digests and the claimant's now: the base side is never parsed. Null
469
+ // when the base recorded none for these bytes (no lock, or another
470
+ // hash) or the claimant gives none now: `affected` answers then.
471
+ const recordedMoves = async (
472
+ asked: FingerprintClaims,
473
+ file: string,
474
+ before: Uint8Array | null,
475
+ after: Uint8Array | null,
476
+ ): Promise<ReadonlySet<string> | null> => {
477
+ if (before === null || after === null || asked.digests === undefined) return null
478
+ const base = await baseDependencies()
479
+ const was = base === null ? undefined : recordedDigests(base, file)
480
+ // Base digests that read another file are that file's at the base,
481
+ // which the record does not prove.
482
+ if (was === undefined || was.hash !== xxh3hex(before) || Object.keys(was.extras).length > 0)
483
+ return null
484
+ const now = await asked.digests(file)
485
+ if (now === undefined) return null
486
+ const moved = new Set<string>()
487
+ for (const p of projects) {
488
+ const d = was.of(lockKey(rootDir, p.dir))
489
+ if (d === undefined || d !== now.projects[p.name]) moved.add(p.name)
490
+ }
491
+ return moved
492
+ }
449
493
  const claimedOwned = new Map<string, string>()
450
494
  const rootNamesChanged = changed.filter((p) => !p.includes('/'))
451
495
  for (const file of extrasBy.keys()) {
@@ -455,12 +499,11 @@ export async function affectedChanges(
455
499
  for (const file of rootNamesChanged) {
456
500
  if (claimed === undefined || !claimed.files.has(file)) continue
457
501
  const extras = extrasBy.get(file)
458
- const answer = await claimed.affected({
459
- file,
460
- before: await gitBytesAt(args.workspaceRoot, base, file),
461
- after: await bytesOrNull(path.join(args.workspaceRoot, file)),
462
- ...(extras !== undefined ? { extras } : {}),
463
- })
502
+ const before = await gitBytesAt(args.workspaceRoot, base, file)
503
+ const after = await bytesOrNull(path.join(args.workspaceRoot, file))
504
+ const answer =
505
+ (extras === undefined ? await recordedMoves(claimed, file, before, after) : undefined) ??
506
+ (await claimed.affected({ file, before, after, ...(extras !== undefined ? { extras } : {}) }))
464
507
  const why =
465
508
  extras === undefined || changed.includes(file)
466
509
  ? file
@@ -505,20 +548,8 @@ export async function affectedChanges(
505
548
  if (diffed.paths.includes(LOCKFILE_NAME) || untracked.includes(LOCKFILE_NAME)) {
506
549
  // A side with no lock (or one that will not parse) ran nothing from
507
550
  // it: the paths of the configs say what changed, as before the lock.
508
- const entries = (bytes: Uint8Array | null): Record<string, unknown> | null => {
509
- if (bytes === null) return null
510
- try {
511
- const locked = (JSON.parse(new TextDecoder().decode(bytes)) as { projects?: unknown })
512
- .projects
513
- return typeof locked === 'object' && locked !== null
514
- ? (locked as Record<string, unknown>)
515
- : null
516
- } catch {
517
- return null
518
- }
519
- }
520
- const before = entries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
521
- const after = entries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
551
+ const before = lockEntries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
552
+ const after = lockEntries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
522
553
  const same = (dir: string): boolean =>
523
554
  before === null ||
524
555
  after === null ||
@@ -538,9 +569,23 @@ export async function affectedChanges(
538
569
  const atBase = await gitBlobsAt(args.workspaceRoot, base, markers)
539
570
  // A config the base had in a directory that is no project now: the
540
571
  // project is gone, and with it the edge naming who depended on it,
541
- // while their keys lost an upstream. Today's graph cannot say who.
542
- const gone = await removedProjectConfig(args.workspaceRoot, atBase, projects)
543
- if (gone !== undefined) return everything(gone)
572
+ // while their keys lost an upstream. Today's graph cannot say who; the
573
+ // base's dependencies lock can. Without one, every project.
574
+ // A config that moved, the same bytes under a new project, is no
575
+ // loss: its files select it where it lives.
576
+ const nameAt = new Map(projects.map((p) => [lockKey(rootDir, p.dir), p.name]))
577
+ for (const gone of await removedProjectConfigs(args.workspaceRoot, atBase, projects)) {
578
+ const recorded = await baseDependencies()
579
+ if (recorded === null) return everything(gone)
580
+ const held = dependantsOf(recorded, path.posix.dirname(gone)).flatMap(
581
+ (d) => nameAt.get(d) ?? [],
582
+ )
583
+ for (const name of held) {
584
+ owned.add(name)
585
+ whole.add(name)
586
+ if (!claimedOwned.has(name)) claimedOwned.set(name, gone)
587
+ }
588
+ }
544
589
  for (const name of parentsOfNewNested(args.workspaceRoot, atBase, projects)) {
545
590
  owned.add(name)
546
591
  whole.add(name)
@@ -587,7 +632,12 @@ export async function affectedChanges(
587
632
  }
588
633
 
589
634
  /** What vx writes under `.vx`, which no key reads (cache/inputs.ts `ALWAYS_IGNORE`). */
590
- const VX_OWN: ReadonlySet<string> = new Set([LOCKFILE_NAME, '.vx/.gitattributes', '.vx/.gitignore'])
635
+ const VX_OWN: ReadonlySet<string> = new Set([
636
+ LOCKFILE_NAME,
637
+ DEPENDENCIES_LOCK_NAME,
638
+ '.vx/.gitattributes',
639
+ '.vx/.gitignore',
640
+ ])
591
641
 
592
642
  const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
593
643
  /**
@@ -632,22 +682,31 @@ function parentsOfNewNested(
632
682
  return out
633
683
  }
634
684
 
635
- /** The first config in `atBase` the base had whose file and project are both gone now. */
636
- async function removedProjectConfig(
685
+ /** Each config in `atBase` the base had whose file and project are both gone now. */
686
+ async function removedProjectConfigs(
637
687
  workspaceRoot: string,
638
688
  atBase: ReadonlyMap<string, Uint8Array | null>,
639
689
  projects: readonly ProjectMeta[],
640
- ): Promise<string | undefined> {
690
+ ): Promise<string[]> {
641
691
  const dirs = new Set(projects.map((p) => p.dir.normalize('NFC')))
692
+ const gone: string[] = []
693
+ let added: Uint8Array[] | undefined
642
694
  for (const [rel, bytes] of atBase) {
643
695
  // A manifest's directory may never have been a project; a config's was.
644
696
  if (bytes === null || !CONFIG_NAMES.has(path.posix.basename(rel))) continue
645
697
  const dir = path.resolve(workspaceRoot, path.posix.dirname(rel)).normalize('NFC')
646
698
  if (dirs.has(dir)) continue
647
699
  if (await Bun.file(path.join(workspaceRoot, rel)).exists()) continue
648
- return rel
700
+ // Each config the base lacked, read once, only when one went.
701
+ added ??= await Promise.all(
702
+ [...atBase]
703
+ .filter(([r, b]) => b === null && CONFIG_NAMES.has(path.posix.basename(r)))
704
+ .map(([r]) => bytesOrNull(path.join(workspaceRoot, r))),
705
+ ).then((all) => all.filter((b): b is Uint8Array => b !== null))
706
+ if (added!.some((b) => Buffer.from(b).equals(bytes))) continue
707
+ gone.push(rel)
649
708
  }
650
- return undefined
709
+ return gone
651
710
  }
652
711
 
653
712
  /**
@@ -440,6 +440,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
440
440
  files?: unknown
441
441
  affected?: unknown
442
442
  extraFiles?: unknown
443
+ digests?: unknown
443
444
  } | null
444
445
  if (
445
446
  claim === null ||
@@ -447,10 +448,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
447
448
  !Array.isArray(claim.files) ||
448
449
  claim.files.length === 0 ||
449
450
  typeof claim.affected !== 'function' ||
450
- (claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function')
451
+ (claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function') ||
452
+ (claim.digests !== undefined && typeof claim.digests !== 'function')
451
453
  ) {
452
454
  throw new UserError(
453
- `${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function }`,
455
+ `${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function, digests?: function }`,
454
456
  )
455
457
  }
456
458
  for (const file of claim.files as unknown[]) {