@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,191 @@
1
+ // .vx/dependencies.lock.json — what the workspace's dependencies resolve
2
+ // to, written by `vx lock` beside the config lock and committed.
3
+ //
4
+ // Each project's edges (the `discover` stage's `deps` and `reach`), by
5
+ // directory, and each claimed file's per-project digests
6
+ // (`FingerprintClaim.digests`) with the hash of the bytes they came from
7
+ // and of each other file the digests read. A run keys from these digests
8
+ // and never parses the file: one whose bytes moved since refuses
9
+ // (orchestrator/locked-digests.ts). `--affected` reads the base commit's
10
+ // copy with git: a deleted project's dependants are in its edges, and a
11
+ // changed lockfile's base digests are there, so the base side is never
12
+ // parsed. `vx lock --check` holds it to a fresh derivation.
13
+
14
+ import { rename, rm } from 'node:fs/promises'
15
+ import path from 'node:path'
16
+ import type { ProjectMeta } from './workspace.js'
17
+ import { lockKey } from './lockfile.js'
18
+ import { type LoadReads, readOnce } from './load-reads.js'
19
+
20
+ export const DEPENDENCIES_LOCK_NAME = '.vx/dependencies.lock.json'
21
+ const DEPENDENCIES_LOCK_VERSION = 3
22
+
23
+ // Flat: each directory once, everything else an index into `projects`, so
24
+ // a parse builds few strings and a lookup is an array read.
25
+ export interface DependenciesLock {
26
+ version: number
27
+ /** Project directories (root-relative POSIX, `.` for the root), sorted. */
28
+ projects: string[]
29
+ /** Per project, the indexes of the projects it builds after. */
30
+ deps: number[][]
31
+ /** Per project, the indexes of the projects it reaches for selection only. */
32
+ reach: number[][]
33
+ /** Claimed file → its bytes' xxh3, each project's digest by index ('' when none), and each other file read → its hash. */
34
+ files: Record<string, LockedFile>
35
+ }
36
+
37
+ interface LockedFile {
38
+ hash: string
39
+ digests: string[]
40
+ /** Root-relative files besides this one whose bytes the digests read → their hash (`hashExtra`). */
41
+ extras?: Record<string, string>
42
+ }
43
+
44
+ /** The lock for `metas`, with `files` keyed by project name as the claims answer. */
45
+ export function dependenciesLock(
46
+ root: string,
47
+ metas: readonly ProjectMeta[],
48
+ files: ReadonlyMap<
49
+ string,
50
+ {
51
+ hash: string
52
+ projects: Readonly<Record<string, string>>
53
+ extras?: Readonly<Record<string, string>>
54
+ }
55
+ >,
56
+ ): DependenciesLock {
57
+ const dirOf = new Map(metas.map((m) => [m.name, lockKey(root, m.dir)]))
58
+ const projects = [...new Set(dirOf.values())].sort(cmp)
59
+ const at = new Map(projects.map((d, i) => [d, i]))
60
+ const byDir = new Map(metas.map((m) => [dirOf.get(m.name)!, m]))
61
+ const indexes = (names: readonly string[] | undefined): number[] =>
62
+ [...new Set((names ?? []).flatMap((n) => at.get(dirOf.get(n) ?? '') ?? []))].sort(
63
+ (a, b) => a - b,
64
+ )
65
+ const out: DependenciesLock['files'] = {}
66
+ for (const file of [...files.keys()].sort(cmp)) {
67
+ const { hash, projects: byName, extras } = files.get(file)!
68
+ const digests = projects.map(() => '')
69
+ for (const [name, digest] of Object.entries(byName)) {
70
+ const i = at.get(dirOf.get(name) ?? '')
71
+ if (i !== undefined) digests[i] = digest
72
+ }
73
+ const rels = Object.keys(extras ?? {}).sort(cmp)
74
+ out[file] =
75
+ rels.length === 0
76
+ ? { hash, digests }
77
+ : { hash, digests, extras: Object.fromEntries(rels.map((r) => [r, extras![r]!])) }
78
+ }
79
+ return {
80
+ version: DEPENDENCIES_LOCK_VERSION,
81
+ projects,
82
+ deps: projects.map((d) => indexes(byDir.get(d)!.deps)),
83
+ reach: projects.map((d) => indexes(byDir.get(d)!.reach)),
84
+ files: out,
85
+ }
86
+ }
87
+
88
+ /** Minified, as the config lock: only vx reads it, and minified JSON parses faster. */
89
+ function dependenciesLockText(lock: DependenciesLock): string {
90
+ return `${JSON.stringify(lock)}\n`
91
+ }
92
+
93
+ /** Written beside its name and renamed over it, as `writeLockfile`: a reader never sees half a file. */
94
+ export async function writeDependenciesLock(root: string, lock: DependenciesLock): Promise<void> {
95
+ const file = path.join(root, DEPENDENCIES_LOCK_NAME)
96
+ const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
97
+ try {
98
+ await Bun.write(tmp, dependenciesLockText(lock))
99
+ await rename(tmp, file)
100
+ } catch (err) {
101
+ await rm(tmp, { force: true }).catch(() => {})
102
+ const e = err as NodeJS.ErrnoException
103
+ if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
104
+ if (e.path === tmp) e.path = file
105
+ throw err
106
+ }
107
+ }
108
+
109
+ /** The lock's bytes at `root`, read once per `reads`; null when there is none. */
110
+ export function readDependenciesLock(root: string, reads?: LoadReads): Promise<Uint8Array | null> {
111
+ return readOnce(reads, path.join(root, DEPENDENCIES_LOCK_NAME))
112
+ }
113
+
114
+ /** The lock in `bytes`; null when absent, another version, or not this shape. */
115
+ export function parseDependenciesLock(bytes: Uint8Array | null): DependenciesLock | null {
116
+ if (bytes === null) return null
117
+ try {
118
+ const lock = JSON.parse(new TextDecoder().decode(bytes)) as Partial<DependenciesLock>
119
+ return lock.version === DEPENDENCIES_LOCK_VERSION && wellFormed(lock)
120
+ ? (lock as DependenciesLock)
121
+ : null
122
+ } catch {
123
+ return null
124
+ }
125
+ }
126
+
127
+ // A committed file a merge or a hand edit can break inside, so every
128
+ // entry is checked: one bad entry read on would throw a raw TypeError at
129
+ // its first lookup instead of the "invalid, run `vx lock`" refusal.
130
+ function wellFormed(lock: Partial<DependenciesLock>): boolean {
131
+ const { projects, deps, reach, files } = lock
132
+ if (!Array.isArray(projects) || !projects.every((d) => typeof d === 'string')) return false
133
+ const n = projects.length
134
+ const edges = (lists: unknown): boolean =>
135
+ Array.isArray(lists) &&
136
+ lists.length === n &&
137
+ lists.every((l) => Array.isArray(l) && l.every((i) => Number.isInteger(i) && i >= 0 && i < n))
138
+ if (!edges(deps) || !edges(reach) || !isRecord(files)) return false
139
+ return Object.values(files).every(
140
+ (rec: unknown) =>
141
+ isRecord(rec) &&
142
+ typeof rec['hash'] === 'string' &&
143
+ Array.isArray(rec['digests']) &&
144
+ rec['digests'].length === n &&
145
+ rec['digests'].every((d) => typeof d === 'string') &&
146
+ (rec['extras'] === undefined ||
147
+ (isRecord(rec['extras']) &&
148
+ Object.values(rec['extras']).every((h) => typeof h === 'string'))),
149
+ )
150
+ }
151
+
152
+ function isRecord(v: unknown): v is Record<string, unknown> {
153
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
154
+ }
155
+
156
+ /** The project directories whose edges reach `dir`. */
157
+ export function dependantsOf(lock: DependenciesLock, dir: string): string[] {
158
+ const i = lock.projects.indexOf(dir)
159
+ if (i < 0) return []
160
+ return lock.projects.filter((_, j) => lock.deps[j]!.includes(i) || lock.reach[j]!.includes(i))
161
+ }
162
+
163
+ /** `file`'s recorded hashes and each project directory's digest (undefined when none). */
164
+ export function recordedDigests(
165
+ lock: DependenciesLock,
166
+ file: string,
167
+ ):
168
+ | {
169
+ hash: string
170
+ extras: Readonly<Record<string, string>>
171
+ has: (dir: string) => boolean
172
+ of: (dir: string) => string | undefined
173
+ }
174
+ | undefined {
175
+ const rec = Object.hasOwn(lock.files, file) ? lock.files[file] : undefined
176
+ if (rec === undefined) return undefined
177
+ const at = new Map(lock.projects.map((d, i) => [d, i]))
178
+ return {
179
+ hash: rec.hash,
180
+ extras: rec.extras ?? {},
181
+ has: (dir) => at.has(dir),
182
+ of: (dir) => {
183
+ const i = at.get(dir)
184
+ return i === undefined || rec.digests[i] === '' ? undefined : rec.digests[i]
185
+ },
186
+ }
187
+ }
188
+
189
+ function cmp(a: string, b: string): number {
190
+ return a < b ? -1 : a > b ? 1 : 0
191
+ }
@@ -66,6 +66,8 @@ export interface ParsedFilter {
66
66
  * `./apps/*` when it names no project, as Nx's `--projects` reads it.
67
67
  */
68
68
  dirFallback?: ParsedFilter
69
+ /** `<pattern>{<dir>}`: the `{<dir>}` path form the name matches are narrowed to. */
70
+ within?: ParsedFilter
69
71
  }
70
72
 
71
73
  export function parseFilter(
@@ -175,6 +177,27 @@ export function parseFilter(
175
177
  }
176
178
  }
177
179
 
180
+ // `<pattern>{<dir>}`: the packages the name selects in the directory, as
181
+ // pnpm and Turbo read it (turborepo#887, `pattern{foo}[master]`). Read as
182
+ // one name glob, it matched nothing.
183
+ const named = /^([^{}]+)\{([^{}]*)\}$/.exec(s)
184
+ if (named !== null && !named[1]!.startsWith('./') && named[1] !== '.') {
185
+ if (named[2]!.trim() === '') throw new UserError(`filter "${raw}" names no project`)
186
+ return {
187
+ raw,
188
+ negate,
189
+ withDeps,
190
+ withDependents,
191
+ onlyDeps,
192
+ onlyDependents,
193
+ isPath: false,
194
+ matcher: named[1]!,
195
+ within: parseFilter(`{${named[2]}}`, workspaceRoot, raw),
196
+ ...(gitSince !== undefined ? { gitSince } : {}),
197
+ ...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
198
+ }
199
+ }
200
+
178
201
  let isPath = false
179
202
  let matcher = s
180
203
  let pathGlob: Bun.Glob | undefined
@@ -266,6 +289,11 @@ function matchSelector(
266
289
  projects: ProjectMeta[],
267
290
  tags: ProjectTags | undefined,
268
291
  ): string[] {
292
+ if (filter.within !== undefined) {
293
+ const { within, ...name } = filter
294
+ const inDir = new Set(matchSelector(within, projects, tags))
295
+ return matchSelector(name, projects, tags).filter((n) => inDir.has(n))
296
+ }
269
297
  const out: string[] = []
270
298
  if (filter.tag === true) {
271
299
  const re = compileNameGlob(filter.matcher)
@@ -32,6 +32,15 @@ export {
32
32
  type Lockfile,
33
33
  type LockedProject,
34
34
  } from './lockfile.js'
35
+ export {
36
+ DEPENDENCIES_LOCK_NAME,
37
+ dependenciesLock,
38
+ parseDependenciesLock,
39
+ readDependenciesLock,
40
+ recordedDigests,
41
+ writeDependenciesLock,
42
+ type DependenciesLock,
43
+ } from './dependencies-lock.js'
35
44
  export {
36
45
  ConfigsRefused,
37
46
  loadProjectConfig,
@@ -21,7 +21,7 @@ import { validateProjectConfig } from './config-schema.js'
21
21
  import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
22
22
 
23
23
  export const LOCKFILE_NAME = '.vx/projects.lock.json'
24
- export const LOCKFILE_VERSION = 4
24
+ export const LOCKFILE_VERSION = 5
25
25
 
26
26
  /** One project as the lock holds it. */
27
27
  export interface LockedProject {
@@ -55,14 +55,19 @@ export const LOCK_INVALID = `${LOCKFILE_NAME} is invalid, run \`vx lock\``
55
55
  * raw TypeError; asked only on that crash's path, so a run pays nothing.
56
56
  */
57
57
  export function lockEntryBroken(root: string): boolean {
58
- let projects: unknown
58
+ let lock: Pooled
59
59
  try {
60
- projects = (JSON.parse(readFileSync(lockfilePath(root), 'utf8')) as { projects?: unknown })
61
- .projects
60
+ lock = JSON.parse(readFileSync(lockfilePath(root), 'utf8')) as Pooled
62
61
  } catch {
63
62
  return false
64
63
  }
65
- if (typeof projects !== 'object' || projects === null) return false
64
+ if (typeof lock?.projects !== 'object' || lock.projects === null) return false
65
+ try {
66
+ pooledIn(lock)
67
+ } catch {
68
+ return true
69
+ }
70
+ const projects = lock.projects
66
71
  for (const [dir, entry] of Object.entries(projects)) {
67
72
  try {
68
73
  validateProjectConfig((entry as LockedProject).config, `${LOCKFILE_NAME}: "${dir}"`, null)
@@ -158,10 +163,157 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
158
163
  )
159
164
  }
160
165
  }
166
+ pooledIn(parsed as Pooled)
161
167
  digests.set(parsed as Lockfile, xxh3hex(text))
162
168
  return parsed as Lockfile
163
169
  }
164
170
 
171
+ /**
172
+ * The project entries of a lock's bytes with its pool put back, or null
173
+ * for bytes that do not parse as a lock: an entry compares across two
174
+ * locks only once both are read the same way.
175
+ */
176
+ export function lockEntries(bytes: Uint8Array | null): Record<string, unknown> | null {
177
+ if (bytes === null) return null
178
+ try {
179
+ const lock = JSON.parse(new TextDecoder().decode(bytes)) as Pooled
180
+ if (typeof lock?.projects !== 'object' || lock.projects === null) return null
181
+ pooledIn(lock)
182
+ return lock.projects
183
+ } catch {
184
+ return null
185
+ }
186
+ }
187
+
188
+ /**
189
+ * A task or task part that two places in the lock hold alike is written once,
190
+ * in `pool`, and each place holds its index. Each slot is one the schema
191
+ * types as an object, so a number there can only be an index. Big
192
+ * workspaces repeat the same tasks: 2,000 projects of five tasks read in
193
+ * 0.71 ms against 2.62 ms, a fifth of the bytes. A run reads them shared,
194
+ * once; a stage that edits a config in place copies it first.
195
+ */
196
+ const POOLS = ['env', 'sandbox', 'exec', 'inputs', 'cache', 'task'] as const
197
+ type PoolName = (typeof POOLS)[number]
198
+ type Part = Exclude<PoolName, 'task'>
199
+ type Pools = Partial<Record<PoolName, unknown[]>>
200
+ type Pooled = { pool?: Pools; projects: Record<string, LockedProject> }
201
+ type Obj = Record<string, unknown>
202
+ const isObj = (v: unknown): v is Obj => typeof v === 'object' && v !== null && !Array.isArray(v)
203
+ const TASK_PARTS = ['exec', 'inputs', 'cache'] as const
204
+ const EXEC_PARTS = ['env', 'sandbox'] as const
205
+
206
+ /** `lock.projects` with every index replaced by the value it names, in place. */
207
+ function pooledIn(lock: Pooled): void {
208
+ const pool = lock.pool
209
+ delete lock.pool
210
+ if (pool === undefined) return
211
+ if (!isObj(pool) || Object.values(pool).some((list) => !Array.isArray(list))) {
212
+ throw new UserError(
213
+ `${LOCKFILE_NAME}: \`pool\` must be an object of lists — re-run \`vx lock\``,
214
+ 'VX_E_LOCK',
215
+ )
216
+ }
217
+ const at = (name: PoolName, i: number): unknown => {
218
+ const hit = pool[name]?.[i]
219
+ if (hit === undefined) {
220
+ throw new UserError(
221
+ `${LOCKFILE_NAME}: no ${name} ${i} in its pool — re-run \`vx lock\``,
222
+ 'VX_E_LOCK',
223
+ )
224
+ }
225
+ return hit
226
+ }
227
+ const parts = (o: unknown, names: readonly Part[]): void => {
228
+ if (!isObj(o)) return
229
+ for (const n of names) if (typeof o[n] === 'number') o[n] = at(n, o[n])
230
+ }
231
+ const task = (t: unknown): void => {
232
+ parts(t, TASK_PARTS)
233
+ if (isObj(t)) parts(t['exec'], EXEC_PARTS)
234
+ }
235
+ // Inner pools first: a pooled task's exec may itself be pooled.
236
+ for (const e of pool.exec ?? []) parts(e, EXEC_PARTS)
237
+ for (const t of pool.task ?? []) task(t)
238
+ for (const entry of Object.values(lock.projects)) {
239
+ const tasks = (entry as { config?: { tasks?: unknown } } | null)?.config?.tasks
240
+ if (!isObj(tasks)) continue
241
+ for (const name of Object.keys(tasks)) {
242
+ const t = tasks[name]
243
+ if (typeof t === 'number') tasks[name] = at('task', t)
244
+ else task(t)
245
+ }
246
+ }
247
+ }
248
+
249
+ /** `projects` as written: each value two slots hold alike, once in `pool`. */
250
+ function pooledOut(projects: Record<string, LockedProject>): Pooled {
251
+ const seen = new Map<string, number>()
252
+ const keyOf = (name: PoolName, v: Obj): string => `${name}\0${JSON.stringify(v)}`
253
+ const count = (name: PoolName, v: unknown): void => {
254
+ if (!isObj(v)) return
255
+ const k = keyOf(name, v)
256
+ seen.set(k, (seen.get(k) ?? 0) + 1)
257
+ }
258
+ const tasksOf = (p: LockedProject): Obj | undefined =>
259
+ isObj(p.config.tasks) ? p.config.tasks : undefined
260
+ for (const p of Object.values(projects)) {
261
+ for (const t of Object.values(tasksOf(p) ?? {})) {
262
+ if (!isObj(t)) continue
263
+ count('task', t)
264
+ for (const n of TASK_PARTS) count(n, t[n])
265
+ if (isObj(t['exec'])) for (const n of EXEC_PARTS) count(n, t['exec'][n])
266
+ }
267
+ }
268
+ const pool: Pools = {}
269
+ const index = new Map<string, number>()
270
+ const out = (name: PoolName, v: unknown, inner: (v: Obj) => Obj): unknown => {
271
+ if (!isObj(v)) return v
272
+ const k = keyOf(name, v)
273
+ if ((seen.get(k) ?? 0) < 2) return inner(v)
274
+ let i = index.get(k)
275
+ if (i === undefined) {
276
+ const list = (pool[name] ??= [])
277
+ index.set(k, (i = list.length))
278
+ list.push(inner(v))
279
+ }
280
+ return i
281
+ }
282
+ const same = (v: Obj): Obj => v
283
+ const withParts = (o: Obj, names: readonly Part[]): Obj => {
284
+ const r = { ...o }
285
+ for (const n of names) if (n in o) r[n] = out(n, o[n], n === 'exec' ? exec : same)
286
+ return r
287
+ }
288
+ const exec = (e: Obj): Obj => withParts(e, EXEC_PARTS)
289
+ const task = (t: Obj): Obj => withParts(t, TASK_PARTS)
290
+ // Entries, not assignment: a directory named `__proto__` set the prototype (D-152).
291
+ const written = Object.fromEntries(
292
+ Object.entries(projects).map(([dir, p]) => {
293
+ const tasks = tasksOf(p)
294
+ return [
295
+ dir,
296
+ tasks === undefined
297
+ ? p
298
+ : {
299
+ ...p,
300
+ config: {
301
+ ...p.config,
302
+ tasks: Object.fromEntries(
303
+ Object.entries(tasks).map(([name, t]) => [name, out('task', t, task)]),
304
+ ),
305
+ } as ProjectConfig,
306
+ },
307
+ ]
308
+ }),
309
+ )
310
+ const ordered: Pools = {}
311
+ for (const n of POOLS) if (pool[n] !== undefined) ordered[n] = pool[n]
312
+ return POOLS.some((n) => pool[n] !== undefined)
313
+ ? { pool: ordered, projects: written }
314
+ : { projects: written }
315
+ }
316
+
165
317
  /**
166
318
  * `vx lock` evaluates and validates every config, then writes a digest of
167
319
  * the bytes it wrote, under vx's version, as the last field: the lock's
@@ -194,7 +346,8 @@ export async function writeLockfile(root: string, lock: Lockfile): Promise<void>
194
346
  // Minified: the file only vx reads, and it loads faster than pretty
195
347
  // JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
196
348
  // beside each config is the copy for people.
197
- const body = JSON.stringify({ version: lock.version, projects: lock.projects }, taskKeysSorted)
349
+ const sorted = JSON.parse(JSON.stringify(lock.projects, taskKeysSorted)) as Lockfile['projects']
350
+ const body = JSON.stringify({ version: lock.version, ...pooledOut(sorted) })
198
351
  await Bun.write(tmp, `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`)
199
352
  await rename(tmp, file)
200
353
  } catch (err) {
@@ -16,9 +16,9 @@ import {
16
16
  refuseSymlink,
17
17
  relPosix,
18
18
  UserError,
19
- ui,
20
19
  writeNoFollow,
21
20
  } from '../util/index.js'
21
+ import * as ui from '../ui/index.js'
22
22
  import { VERSION } from '../version.js'
23
23
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
24
24
 
@@ -451,7 +451,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
451
451
  ...plan.notes,
452
452
  ]
453
453
  if (args.json === true) {
454
- process.stdout.write(
454
+ ui.data(
455
+ 'out',
455
456
  `${JSON.stringify({
456
457
  dry,
457
458
  source,
@@ -472,7 +473,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
472
473
 
473
474
  // The lock's own words become a row; a refusal prints above the report.
474
475
  const lock = dry || args.lock === undefined ? undefined : lockAnswer(await args.lock(root))
475
- if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
476
+ if (lock !== undefined && lock.code !== 0) ui.data('err', lock.said)
476
477
 
477
478
  const rows: ui.FieldRow[] = [['from', source]]
478
479
  if (dry) rows.push(['dry run', 'nothing written'])
@@ -159,6 +159,9 @@ export async function resolveStoreRoot(
159
159
  let id = repoIds.get(root)
160
160
  if (id === undefined) repoIds.set(root, (id = repoIdOf(root)))
161
161
  const resolved = await id
162
+ // No identity yet (no commit, no remote) is asked again: the first
163
+ // commit gives one, and `vx watch` runs on in the same process.
164
+ if (resolved === null) repoIds.delete(root)
162
165
  return resolved === null ? null : path.join(home, '.vx', resolved, 'cache')
163
166
  }
164
167
 
@@ -1,133 +0,0 @@
1
- // Tiny color helper — gates Bun.color through a `ColorSupport` token
2
- // so the formatters stay pure. The token is computed once at logger
3
- // construction; tests pass `{ enabled: false }` so assertions match
4
- // plain strings.
5
- //
6
- // Bun.color('red', 'ansi') is broken in current Bun versions (emits
7
- // the color number as a raw byte instead of decimal text), so we use
8
- // 'ansi-16m' which emits 24-bit truecolor sequences. Every modern
9
- // terminal supports them, and the `tput colors`-style downgrade isn't
10
- // worth the complexity for our handful of UI accents.
11
-
12
- const RESET = '\x1b[0m'
13
- const BOLD = '\x1b[1m'
14
- const DIM = '\x1b[2m'
15
- const UNDERLINE = '\x1b[4m'
16
-
17
- // State hues every report shares with the run summary.
18
- export const SUCCESS = '#22c55e'
19
- export const WARN = '#eab308'
20
- export const ERROR = '#ef4444'
21
-
22
- export interface ColorSupport {
23
- enabled: boolean
24
- /** The terminal's width, read live; none off a terminal. Bars and rules fit it. */
25
- readonly columns?: number | undefined
26
- }
27
-
28
- export interface PaintOptions {
29
- bold?: boolean
30
- dim?: boolean
31
- underline?: boolean
32
- }
33
-
34
- /**
35
- * Standard env precedence:
36
- * NO_COLOR non-empty → off (overrides FORCE_COLOR)
37
- * FORCE_COLOR=0 or =false → off, even on a TTY
38
- * FORCE_COLOR any other value → on (the empty string too)
39
- * a CI whose log renders ANSI (`ciPaints`) → on
40
- * else: on iff stdout is a TTY that is not `TERM=dumb`.
41
- * `0` and `false` are the convention's "off" (supports-color, chalk,
42
- * Node); reading every non-empty value as on painted a piped CI log that
43
- * set `FORCE_COLOR=0` (nx#35292's bug).
44
- */
45
- export function detectColors(stream: NodeJS.WriteStream = process.stdout): ColorSupport {
46
- const force = process.env['FORCE_COLOR']
47
- const enabled = process.env['NO_COLOR']
48
- ? false
49
- : force !== undefined
50
- ? force !== '0' && force !== 'false'
51
- : ciPaints() || (stream.isTTY === true && !dumbTerminal())
52
- return {
53
- enabled,
54
- get columns() {
55
- return stream.isTTY === true ? stream.columns : undefined
56
- },
57
- }
58
- }
59
-
60
- /**
61
- * A CI whose log viewer renders ANSI colour though the job's output is a
62
- * pipe (supports-color reads the same variables): its log was plain.
63
- */
64
- function ciPaints(): boolean {
65
- const env = process.env
66
- return (
67
- env['GITHUB_ACTIONS'] === 'true' ||
68
- env['GITLAB_CI'] === 'true' ||
69
- env['BUILDKITE'] === 'true' ||
70
- env['CIRCLECI'] === 'true'
71
- )
72
- }
73
-
74
- /**
75
- * `TERM=dumb` (Emacs `M-x shell`, some CI log viewers) can neither paint
76
- * nor move the cursor: vx prints to it as off a terminal.
77
- */
78
- export function dumbTerminal(): boolean {
79
- return process.env['TERM'] === 'dumb'
80
- }
81
-
82
- // Bun.color() is pure for a given (hex, mode) pair but does FFI work
83
- // on each call. The framed-output renderer paints the same handful of
84
- // accent colors thousands of times per run; memoizing here turns those
85
- // into hash-map hits.
86
- const ansiCache = new Map<string, string>()
87
- function ansiFor(color: string): string {
88
- const cached = ansiCache.get(color)
89
- if (cached !== undefined) return cached
90
- const v = Bun.color(color, 'ansi-16m') ?? ''
91
- ansiCache.set(color, v)
92
- return v
93
- }
94
-
95
- export function paint(
96
- color: string,
97
- text: string,
98
- colors: ColorSupport,
99
- opts: PaintOptions = {},
100
- ): string {
101
- // Empty text paints nothing: an escape pair around no text is bytes and no
102
- // mark (a full bar's empty remainder).
103
- if (!colors.enabled || text === '') return text
104
- let prefix = ''
105
- if (opts.bold) prefix += BOLD
106
- if (opts.dim) prefix += DIM
107
- if (opts.underline) prefix += UNDERLINE
108
- if (color) prefix += ansiFor(color)
109
- if (prefix.length === 0) return text
110
- return `${prefix}${text}${RESET}`
111
- }
112
-
113
- /**
114
- * Identity hues for the two halves of a `project#task` id, never a status
115
- * colour: one name, one hue, in every run, list and row (2026-10-15:
116
- * every `#build` matches every other `#build`).
117
- */
118
- const IDENTITY_PALETTE = [
119
- '#a78bfa', // violet-400
120
- '#60a5fa', // blue-400
121
- '#e879f9', // fuchsia-400
122
- '#818cf8', // indigo-400
123
- '#c084fc', // purple-400
124
- '#93c5fd', // blue-300
125
- '#f472b6', // pink-400
126
- ] as const
127
-
128
- /** A project's or a target's hue: a fold of the name into the palette. */
129
- export function identityColor(name: string): string {
130
- let h = 0
131
- for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0
132
- return IDENTITY_PALETTE[h % IDENTITY_PALETTE.length]!
133
- }
@@ -1,44 +0,0 @@
1
- import { paint, type ColorSupport } from './colors.js'
2
-
3
- // Brand gradient for the rule: identity violet → pink (the project /
4
- // task hues), faded across the dash run. Plain dashes when colors
5
- // are off.
6
- const GRADIENT_FROM = [0xa7, 0x8b, 0xfa] as const
7
- const GRADIENT_TO = [0xf4, 0x72, 0xb6] as const
8
- // The bars' label column: 2 indent + 8 label + 2 gap.
9
- const LABEL = 12
10
-
11
- /** A meter's cells: 50, or fewer so the bar fits a narrower terminal. */
12
- export function barWidth(colors: ColorSupport): number {
13
- const columns = colors.columns
14
- return columns === undefined ? 50 : Math.max(10, Math.min(50, columns - LABEL))
15
- }
16
-
17
- /** A rule's width, flush with the bars: the footer's and a task frame's section rules. */
18
- export function ruleWidth(colors: ColorSupport): number {
19
- return LABEL + barWidth(colors)
20
- }
21
-
22
- /** `─ <mark> ` + dashes, flush with the bars. Shared by the run summary and every report. */
23
- export function gradientRule(colors: ColorSupport, mark = 'vx'): string {
24
- const head = `─ `
25
- const width = ruleWidth(colors)
26
- const dashes = '─'.repeat(Math.max(8, width - 3 - mark.length))
27
- if (!colors.enabled) return `${head}${mark} ${dashes}`
28
- const chunks = 8
29
- // The gradient spans the rule as if its mark were `vx`.
30
- const per = Math.ceil((width - 5) / chunks)
31
- let out = ''
32
- for (let i = 0; i < chunks; i++) {
33
- const seg = dashes.slice(i * per, (i + 1) * per)
34
- if (seg.length === 0) break
35
- const f = i / (chunks - 1)
36
- const hex = `#${GRADIENT_FROM.map((c, j) =>
37
- Math.round(c + (GRADIENT_TO[j]! - c) * f)
38
- .toString(16)
39
- .padStart(2, '0'),
40
- ).join('')}`
41
- out += paint(hex, seg, colors)
42
- }
43
- return `${paint(`#${GRADIENT_FROM.map((c) => c.toString(16).padStart(2, '0')).join('')}`, head, colors)}${paint('', mark, colors, { bold: true })} ${out}`
44
- }