@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,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
+ }
@@ -22,7 +22,7 @@
22
22
  // given, the base set is "all projects" and excluded packages are removed.
23
23
 
24
24
  import path from 'node:path'
25
- import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
25
+ import { BUN_GLOB_WILDCARDS, hasControl, printable, relPosix, UserError } from '../util/index.js'
26
26
  import type { PackageGraph } from './package-graph.js'
27
27
  import { canonicalDir } from './canonical-dir.js'
28
28
  import type { ProjectMeta } from './workspace.js'
@@ -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(
@@ -74,6 +76,13 @@ export function parseFilter(
74
76
  /** The filter as typed, when `raw` is the `./` reading of a name (`dirFallback`). */
75
77
  typed = raw,
76
78
  ): ParsedFilter {
79
+ // No project name, path or git ref holds one; echoed raw, a newline split
80
+ // the refusal in two and an escape painted the terminal.
81
+ if (hasControl(raw)) {
82
+ throw new UserError(
83
+ `filter "${printable(raw)}" holds a control character: no project name, path or git ref does`,
84
+ )
85
+ }
77
86
  let s = raw
78
87
  const negate = s.startsWith('!')
79
88
  if (negate) s = s.slice(1)
@@ -175,6 +184,27 @@ export function parseFilter(
175
184
  }
176
185
  }
177
186
 
187
+ // `<pattern>{<dir>}`: the packages the name selects in the directory, as
188
+ // pnpm and Turbo read it (turborepo#887, `pattern{foo}[master]`). Read as
189
+ // one name glob, it matched nothing.
190
+ const named = /^([^{}]+)\{([^{}]*)\}$/.exec(s)
191
+ if (named !== null && !named[1]!.startsWith('./') && named[1] !== '.') {
192
+ if (named[2]!.trim() === '') throw new UserError(`filter "${raw}" names no project`)
193
+ return {
194
+ raw,
195
+ negate,
196
+ withDeps,
197
+ withDependents,
198
+ onlyDeps,
199
+ onlyDependents,
200
+ isPath: false,
201
+ matcher: named[1]!,
202
+ within: parseFilter(`{${named[2]}}`, workspaceRoot, raw),
203
+ ...(gitSince !== undefined ? { gitSince } : {}),
204
+ ...(sinceViaDeps ? { sinceViaDeps: true as const } : {}),
205
+ }
206
+ }
207
+
178
208
  let isPath = false
179
209
  let matcher = s
180
210
  let pathGlob: Bun.Glob | undefined
@@ -266,6 +296,11 @@ function matchSelector(
266
296
  projects: ProjectMeta[],
267
297
  tags: ProjectTags | undefined,
268
298
  ): string[] {
299
+ if (filter.within !== undefined) {
300
+ const { within, ...name } = filter
301
+ const inDir = new Set(matchSelector(within, projects, tags))
302
+ return matchSelector(name, projects, tags).filter((n) => inDir.has(n))
303
+ }
269
304
  const out: string[] = []
270
305
  if (filter.tag === true) {
271
306
  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
 
@@ -42,6 +42,10 @@ export function isStarterConfig(file: string): boolean {
42
42
  }
43
43
  }
44
44
 
45
+ /** A C0 or C1 control, or DEL. */
46
+ // oxlint-disable-next-line no-control-regex
47
+ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
48
+
45
49
  /**
46
50
  * Escape an arbitrary string into a single-quoted TS literal. Escapes
47
51
  * backslash + quote AND raw newlines/CR — a value with an embedded newline
@@ -55,7 +59,10 @@ export function quoteTsLiteral(s: string): string {
55
59
  .replaceAll('\\', '\\\\')
56
60
  .replaceAll("'", "\\'")
57
61
  .replaceAll('\n', '\\n')
58
- .replaceAll('\r', '\\r')}'`
62
+ .replaceAll('\r', '\\r')
63
+ // Any other control as its escape: a script's raw ESC landed in the
64
+ // file as the byte, and `cat vx.config.ts` retitled the terminal.
65
+ .replace(CONTROL, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)}'`
59
66
  }
60
67
 
61
68
  /**
@@ -451,7 +458,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
451
458
  ...plan.notes,
452
459
  ]
453
460
  if (args.json === true) {
454
- process.stdout.write(
461
+ ui.data(
462
+ 'out',
455
463
  `${JSON.stringify({
456
464
  dry,
457
465
  source,
@@ -472,7 +480,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
472
480
 
473
481
  // The lock's own words become a row; a refusal prints above the report.
474
482
  const lock = dry || args.lock === undefined ? undefined : lockAnswer(await args.lock(root))
475
- if (lock !== undefined && lock.code !== 0) process.stderr.write(lock.said)
483
+ if (lock !== undefined && lock.code !== 0) ui.data('err', lock.said)
476
484
 
477
485
  const rows: ui.FieldRow[] = [['from', source]]
478
486
  if (dry) rows.push(['dry run', 'nothing written'])
@@ -657,7 +665,7 @@ const EXAMPLE_BODY = `export default {
657
665
  build: {
658
666
  exec: { command: 'tsc -b' },
659
667
  dependsOn: ['^build'],
660
- inputs: { files: ['src/**'] }, cache: { outputs: { files: ['dist/**'] } },
668
+ inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] }, cache: true,
661
669
  },
662
670
  },
663
671
  }`
@@ -746,7 +754,7 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
746
754
  // The suggested block sits in the task, commented: deleting `// `
747
755
  // after setting the real globs caches it, no copying from the TODO.
748
756
  const block =
749
- t.task['cache'] === undefined
757
+ t.task['cache'] !== true
750
758
  ? t.todos.find(isCacheTodo)?.match(/^cache: add `(.+?)`/)?.[1]
751
759
  : undefined
752
760
  lines.push(
@@ -789,11 +797,10 @@ function ownConfigNamed(
789
797
  return {
790
798
  ...p,
791
799
  tasks: p.tasks.map((t) => {
792
- const outputs = (t.task?.['cache'] as { outputs?: { files?: unknown } } | undefined)?.outputs
800
+ const outputs = t.task?.['outputs'] as { files?: unknown } | undefined
793
801
  if (!Array.isArray(outputs?.files) || !outputs.files.includes(planned)) return t
794
- const cache = t.task!['cache'] as Record<string, unknown>
795
802
  const files = outputs.files.map((f: unknown) => (f === planned ? `!${name}` : f))
796
- return { ...t, task: { ...t.task, cache: { ...cache, outputs: { ...outputs, files } } } }
803
+ return { ...t, task: { ...t.task, outputs: { ...outputs, files } } }
797
804
  }),
798
805
  }
799
806
  }
@@ -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