@vzn/vx 0.0.651 → 0.0.653

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 (177) hide show
  1. package/README.md +3 -4
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +15 -20
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +37 -3
  8. package/src/cache/archive.ts +197 -32
  9. package/src/cache/cache.ts +882 -230
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +151 -0
  16. package/src/cache/history-writer.ts +76 -0
  17. package/src/cache/index.ts +12 -1
  18. package/src/cache/inputs.ts +616 -135
  19. package/src/cache/key-fold.ts +1 -1
  20. package/src/cache/layer.ts +29 -16
  21. package/src/cache/layered-cache.ts +28 -7
  22. package/src/cache/lockfile-memo.ts +2 -1
  23. package/src/cache/output-index.ts +106 -44
  24. package/src/cache/plugin-cache.ts +2 -2
  25. package/src/cache/policy.ts +6 -2
  26. package/src/cache/remote-lane.ts +3 -1
  27. package/src/cache/run-history.ts +94 -25
  28. package/src/cache/schema.ts +46 -10
  29. package/src/cli/cache.ts +149 -36
  30. package/src/cli/completions.ts +2 -2
  31. package/src/cli/core-alias.ts +9 -2
  32. package/src/cli/docs.ts +4 -2
  33. package/src/cli/help.ts +27 -4
  34. package/src/cli/index.ts +62 -7
  35. package/src/cli/info.ts +5 -4
  36. package/src/cli/init.ts +55 -20
  37. package/src/cli/last.ts +218 -43
  38. package/src/cli/lock.ts +187 -24
  39. package/src/cli/plan-format.ts +24 -21
  40. package/src/cli/plugin-commands.ts +6 -1
  41. package/src/cli/plugin-templates.ts +36 -45
  42. package/src/cli/run.ts +68 -34
  43. package/src/cli/select.ts +160 -64
  44. package/src/cli/show.ts +70 -28
  45. package/src/cli/task-verb.ts +12 -7
  46. package/src/cli/upgrade.ts +210 -76
  47. package/src/cli/watch-filter.ts +88 -15
  48. package/src/cli/watch-fs.ts +34 -47
  49. package/src/cli/watch-judge.ts +73 -9
  50. package/src/cli/watch-set.ts +10 -8
  51. package/src/cli/watch.ts +190 -33
  52. package/src/cli/why.ts +107 -33
  53. package/src/cli/workspace-config.ts +11 -1
  54. package/src/config.ts +56 -30
  55. package/src/exec/env.ts +3 -1
  56. package/src/exec/executor.ts +116 -16
  57. package/src/exec/index.ts +1 -0
  58. package/src/exec/kill-tree.ts +7 -7
  59. package/src/exec/runner.ts +270 -108
  60. package/src/exec/sandbox-binds.ts +109 -18
  61. package/src/exec/sandbox-paths.ts +3 -1
  62. package/src/exec/sandbox-runtime.ts +446 -64
  63. package/src/exec/sandbox-violations.ts +250 -46
  64. package/src/exec/spawn-off.ts +10 -5
  65. package/src/exec/task-proxy.ts +93 -0
  66. package/src/exec/warm-shell.ts +29 -7
  67. package/src/graph/definition.ts +16 -3
  68. package/src/graph/folds.ts +89 -16
  69. package/src/graph/index.ts +8 -0
  70. package/src/graph/priorities.ts +8 -5
  71. package/src/graph/scheduler.ts +176 -39
  72. package/src/graph/stand-ins.ts +36 -0
  73. package/src/graph/task-graph.ts +347 -160
  74. package/src/graph/task-status.ts +5 -1
  75. package/src/handoff.ts +45 -10
  76. package/src/history/failure-mode.ts +39 -6
  77. package/src/history/history.ts +103 -30
  78. package/src/history/index.ts +2 -0
  79. package/src/history/metrics.ts +193 -73
  80. package/src/index.ts +6 -3
  81. package/src/orchestrator/admission.ts +14 -10
  82. package/src/orchestrator/affected-tasks.ts +35 -13
  83. package/src/orchestrator/command-windows.ts +8 -5
  84. package/src/orchestrator/deferred-outputs.ts +42 -13
  85. package/src/orchestrator/doctor.ts +21 -9
  86. package/src/orchestrator/download-policy.ts +8 -10
  87. package/src/orchestrator/excluded-keys.ts +15 -3
  88. package/src/orchestrator/execute-args.ts +9 -2
  89. package/src/orchestrator/execute-task.ts +612 -138
  90. package/src/orchestrator/graph-memo.ts +26 -4
  91. package/src/orchestrator/graph-stage.ts +27 -4
  92. package/src/orchestrator/held-servers.ts +36 -14
  93. package/src/orchestrator/hit-restore.ts +141 -93
  94. package/src/orchestrator/index.ts +4 -1
  95. package/src/orchestrator/keyed-projects.ts +53 -0
  96. package/src/orchestrator/local-floor.ts +21 -3
  97. package/src/orchestrator/local-shortcircuit.ts +2 -2
  98. package/src/orchestrator/lockfile-claim.ts +30 -4
  99. package/src/orchestrator/miss-save.ts +60 -61
  100. package/src/orchestrator/options.ts +13 -0
  101. package/src/orchestrator/output-host.ts +16 -4
  102. package/src/orchestrator/output-log.ts +1 -54
  103. package/src/orchestrator/persistent-task.ts +32 -16
  104. package/src/orchestrator/persistent.ts +152 -15
  105. package/src/orchestrator/placement.ts +82 -16
  106. package/src/orchestrator/plan.ts +39 -17
  107. package/src/orchestrator/plugin-host.ts +238 -110
  108. package/src/orchestrator/plugin.ts +59 -27
  109. package/src/orchestrator/prepare.ts +194 -38
  110. package/src/orchestrator/projects.ts +89 -10
  111. package/src/orchestrator/read-only.ts +115 -0
  112. package/src/orchestrator/remote-prefetch.ts +1 -1
  113. package/src/orchestrator/run-context.ts +6 -2
  114. package/src/orchestrator/run-failures.ts +101 -23
  115. package/src/orchestrator/run-id.ts +13 -2
  116. package/src/orchestrator/run-keys.ts +87 -28
  117. package/src/orchestrator/run-live.ts +16 -13
  118. package/src/orchestrator/run-lock.ts +27 -4
  119. package/src/orchestrator/run-records.ts +95 -74
  120. package/src/orchestrator/run-report.ts +3 -2
  121. package/src/orchestrator/run-stop.ts +17 -9
  122. package/src/orchestrator/run.ts +531 -122
  123. package/src/orchestrator/sandbox-fix.ts +40 -12
  124. package/src/orchestrator/sandbox-request.ts +314 -44
  125. package/src/orchestrator/save-lane.ts +24 -13
  126. package/src/orchestrator/shell-verdict.ts +29 -1
  127. package/src/orchestrator/signals.ts +103 -8
  128. package/src/orchestrator/stable-keys.ts +34 -27
  129. package/src/orchestrator/task-artifacts.ts +16 -3
  130. package/src/orchestrator/task-hash.ts +191 -34
  131. package/src/orchestrator/telemetry-host.ts +25 -6
  132. package/src/orchestrator/telemetry.ts +85 -13
  133. package/src/orchestrator/upstream.ts +3 -1
  134. package/src/report/events.ts +23 -6
  135. package/src/report/forecast.ts +103 -37
  136. package/src/report/framed-output.ts +179 -162
  137. package/src/report/index.ts +2 -1
  138. package/src/report/logger.ts +108 -31
  139. package/src/report/plain-output.ts +15 -9
  140. package/src/report/status-line.ts +28 -55
  141. package/src/report/summary.ts +61 -18
  142. package/src/settings.ts +50 -14
  143. package/src/ui/components.ts +36 -42
  144. package/src/ui/index.ts +3 -1
  145. package/src/ui/live-region.ts +67 -8
  146. package/src/ui/terminal.ts +12 -0
  147. package/src/ui/theme.ts +16 -1
  148. package/src/util/config-frame.ts +142 -6
  149. package/src/util/edit-distance.ts +17 -1
  150. package/src/util/errors.ts +23 -7
  151. package/src/util/foreign-cache-dir.ts +60 -0
  152. package/src/util/git-ignored.ts +59 -5
  153. package/src/util/index.ts +15 -1
  154. package/src/util/paths.ts +51 -7
  155. package/src/util/plain-text.ts +19 -0
  156. package/src/util/secret-mask.ts +57 -1
  157. package/src/util/settle.ts +18 -1
  158. package/src/util/size.ts +12 -2
  159. package/src/util/tail.ts +4 -1
  160. package/src/util/write-file.ts +86 -9
  161. package/src/workspace/affected.ts +225 -27
  162. package/src/workspace/bracket-literal.ts +2 -2
  163. package/src/workspace/config-bytecode.ts +23 -4
  164. package/src/workspace/config-eval.ts +90 -16
  165. package/src/workspace/config-imports.ts +96 -41
  166. package/src/workspace/config-schema.ts +192 -86
  167. package/src/workspace/dependencies-lock.ts +7 -14
  168. package/src/workspace/filter.ts +18 -2
  169. package/src/workspace/index.ts +3 -1
  170. package/src/workspace/json-data.ts +34 -8
  171. package/src/workspace/lockfile.ts +86 -37
  172. package/src/workspace/migration.ts +58 -31
  173. package/src/workspace/module-state.ts +62 -0
  174. package/src/workspace/nested-dirs.ts +8 -0
  175. package/src/workspace/project-loader.ts +276 -32
  176. package/src/workspace/repo-id.ts +29 -6
  177. package/src/workspace/workspace.ts +190 -30
@@ -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'
@@ -76,6 +76,13 @@ export function parseFilter(
76
76
  /** The filter as typed, when `raw` is the `./` reading of a name (`dirFallback`). */
77
77
  typed = raw,
78
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
+ }
79
86
  let s = raw
80
87
  const negate = s.startsWith('!')
81
88
  if (negate) s = s.slice(1)
@@ -124,9 +131,18 @@ export function parseFilter(
124
131
  if (scoped !== null && !scoped[1]!.startsWith('./') && scoped[1] !== '.') {
125
132
  s = scoped[1]!
126
133
  gitSince = scoped[2]!
134
+ // Neither Turbo nor pnpm reads it (both keep `^...` in the name):
135
+ // `{dir}^...[ref]` dropped the `^...` and selected the inverse, and
136
+ // the name spellings matched nothing.
137
+ if (s.endsWith('^...'))
138
+ throw new UserError(
139
+ `filter "${raw}" puts ^... before a git ref, which no form reads: ` +
140
+ `<sel>...[<ref>] is <sel> where it or a dependency changed, ` +
141
+ `<sel>[<ref>]^... the dependencies of the changed ones`,
142
+ )
127
143
  // The name glob kept the `...` and matched nothing (create-t3-turbo,
128
144
  // `@acme/*...[HEAD]`).
129
- if (s.endsWith('...') && !s.endsWith('^...')) {
145
+ if (s.endsWith('...')) {
130
146
  s = s.slice(0, -3)
131
147
  sinceViaDeps = true
132
148
  }
@@ -28,6 +28,7 @@ export {
28
28
  lockKey,
29
29
  NO_LOCK,
30
30
  readLockfile,
31
+ lockfileWrite,
31
32
  writeLockfile,
32
33
  type Lockfile,
33
34
  type LockedProject,
@@ -38,7 +39,7 @@ export {
38
39
  parseDependenciesLock,
39
40
  readDependenciesLock,
40
41
  recordedDigests,
41
- writeDependenciesLock,
42
+ dependenciesLockWrite,
42
43
  type DependenciesLock,
43
44
  } from './dependencies-lock.js'
44
45
  export {
@@ -60,6 +61,7 @@ export {
60
61
  validateWorkspace,
61
62
  } from './config-schema.js'
62
63
  export { configImports } from './config-imports.js'
64
+ export { repoIdsOf } from './repo-id.js'
63
65
  export {
64
66
  checkProjectNames,
65
67
  findWorkspaceRoot,
@@ -8,10 +8,14 @@
8
8
  // worker's, which never saw it — or, where the validator does not type
9
9
  // the value, it was silently outside the key on both. So every path
10
10
  // refuses it, before schema validation, by this one rule: the first load
11
- // on the live object (`validateProjectConfig`), the worker before its
11
+ // on the live object before its JSON copy (project-loader.ts), the worker before its
12
12
  // `JSON.stringify` (config-eval.ts). The worker embeds `nonJsonPaths` by its source text, so
13
13
  // the function must stay self-contained: no import, no module-scope name.
14
14
  //
15
+ // JSON writes only own enumerable string keys: a symbol key or a
16
+ // non-enumerable property vanished from the key and the lock
17
+ // (`"config":{}`), so it is refused too.
18
+ //
15
19
  // An `undefined` PROPERTY is allowed: JSON drops it, the validator reads
16
20
  // it as absent, and configs write it with conditional spreads. Inside an
17
21
  // array it is refused, because JSON writes it as `null`.
@@ -29,13 +33,20 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
29
33
  // The key or index at each level, rendered only for a finding: the walk
30
34
  // runs on every first load, and a path string built per node cost three
31
35
  // times the schema check itself.
32
- const keys: Array<string | number> = []
36
+ const keys: Array<string | number | symbol> = []
33
37
  // The objects on the path to the one being walked: a cycle throws in
34
38
  // `JSON.stringify` and would recurse here forever. A DAG is data.
35
39
  const ancestors: object[] = []
40
+ const MAX_DEPTH = 256
36
41
  const found = (is: string): void => {
37
42
  let path = ''
38
- for (const k of keys) path += typeof k === 'number' ? `[${k}]` : path === '' ? k : `.${k}`
43
+ for (const k of keys)
44
+ path +=
45
+ typeof k === 'number' || typeof k === 'symbol'
46
+ ? `[${String(k)}]`
47
+ : path === ''
48
+ ? k
49
+ : `.${k}`
39
50
  out.push({ path: path === '' ? 'the default export' : path, is })
40
51
  }
41
52
  const walk = (v: unknown, inArray: boolean): void => {
@@ -45,6 +56,12 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
45
56
  else if (typeof v === 'number' && !Number.isFinite(v)) found(String(v))
46
57
  else if (v === undefined && inArray) found('undefined in an array, which JSON writes as null')
47
58
  else if (typeof v === 'object' && v !== null) {
59
+ // Deeper, the walk and `JSON.stringify` overflowed the stack: a
60
+ // config 100,000 levels deep was vx's own RangeError (VX_E_INTERNAL).
61
+ if (ancestors.length >= MAX_DEPTH) {
62
+ found(`nested deeper than ${MAX_DEPTH} levels`)
63
+ return
64
+ }
48
65
  if (ancestors.includes(v)) {
49
66
  found('a cyclic reference')
50
67
  return
@@ -57,6 +74,13 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
57
74
  walk(v[i], true)
58
75
  keys.pop()
59
76
  }
77
+ // Symbols only: listing an array's own names allocates every index
78
+ // string, +1-4% on a 20k-task lock, for a named property no config writes.
79
+ for (const key of Object.getOwnPropertySymbols(v)) {
80
+ keys.push(key)
81
+ found('a symbol key, which JSON drops')
82
+ keys.pop()
83
+ }
60
84
  ancestors.pop()
61
85
  return
62
86
  }
@@ -71,16 +95,18 @@ export function nonJsonPaths(value: unknown): NonJsonValue[] {
71
95
  return
72
96
  }
73
97
  ancestors.push(v)
74
- for (const key of Object.keys(v)) {
98
+ for (const key of Reflect.ownKeys(v)) {
75
99
  keys.push(key)
76
100
  // Read by descriptor: a getter is code, and each read may answer
77
101
  // differently. `vx show` read one three times, so the key, the
78
102
  // check and the command could each see another value, and a
79
103
  // throwing one surfaced as vx's own stack (D-67). Never called here.
80
- const d = Object.getOwnPropertyDescriptor(v, key)
81
- if (d !== undefined && d.get !== undefined) found('a getter')
82
- else if (d !== undefined && d.set !== undefined) found('a setter')
83
- else walk(d?.value, false)
104
+ const d = Object.getOwnPropertyDescriptor(v, key) as PropertyDescriptor
105
+ if (typeof key === 'symbol') found('a symbol key, which JSON drops')
106
+ else if (d.enumerable !== true) found('a non-enumerable property, which JSON drops')
107
+ else if (d.get !== undefined) found('a getter')
108
+ else if (d.set !== undefined) found('a setter')
109
+ else walk(d.value, false)
84
110
  keys.pop()
85
111
  }
86
112
  ancestors.pop()
@@ -11,11 +11,18 @@
11
11
  // eval-free.
12
12
  // - `vx lock --check` AUDITS it: full re-evaluation + deep equality.
13
13
 
14
- import { readFileSync } from 'node:fs'
15
- import { rename, rm } from 'node:fs/promises'
14
+ import { readFileSync, statSync } from 'node:fs'
16
15
  import path from 'node:path'
17
16
  import type { ProjectConfig } from '../config.js'
18
- import { keysSorted, relPosix, UserError, xxh3, xxh3hex } from '../util/index.js'
17
+ import {
18
+ keysSorted,
19
+ type PendingWrite,
20
+ relPosix,
21
+ UserError,
22
+ writeAllOrNone,
23
+ xxh3,
24
+ xxh3hex,
25
+ } from '../util/index.js'
19
26
  import { VERSION } from '../version.js'
20
27
  import { validateProjectConfig } from './config-schema.js'
21
28
  import { PROJECT_CONFIG_FILENAMES } from './workspace.js'
@@ -38,6 +45,12 @@ export interface Lockfile {
38
45
  * by directory, so a name a plugin gives never invalidates the lock.
39
46
  */
40
47
  projects: Record<string, LockedProject>
48
+ /**
49
+ * Root-relative POSIX directories holding their own `.vx/workspace.*`,
50
+ * where every outer project's outputs stop. Absent when there is none, so a
51
+ * workspace without one locks the bytes it always did.
52
+ */
53
+ nestedWorkspaces?: string[]
41
54
  /** Written by `writeLockfile` from the bytes; never read off the object. */
42
55
  validated?: string
43
56
  }
@@ -101,7 +114,16 @@ export function lockDigest(lock: Lockfile): string | undefined {
101
114
  */
102
115
  export async function readLockfile(root: string): Promise<Lockfile | null> {
103
116
  const file = Bun.file(lockfilePath(root))
104
- if (!(await file.exists())) return null
117
+ if (!(await file.exists())) {
118
+ // A directory reads as no file: `--check` said "run `vx lock`", which cannot write over it.
119
+ if (statSync(lockfilePath(root), { throwIfNoEntry: false })?.isDirectory()) {
120
+ throw new UserError(
121
+ `${LOCKFILE_NAME} is a directory — remove it and run \`vx lock\``,
122
+ 'VX_E_LOCK',
123
+ )
124
+ }
125
+ return null
126
+ }
105
127
  let parsed: unknown
106
128
  let text: string
107
129
  try {
@@ -123,7 +145,7 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
123
145
  if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
124
146
  throw new UserError(`${LOCKFILE_NAME} must be a JSON object — re-run \`vx lock\``, 'VX_E_LOCK')
125
147
  }
126
- const lock = parsed as { version?: unknown; projects?: unknown }
148
+ const lock = parsed as { version?: unknown; projects?: unknown; nestedWorkspaces?: unknown }
127
149
  if (lock.version !== LOCKFILE_VERSION) {
128
150
  throw new UserError(
129
151
  `${LOCKFILE_NAME} has unsupported version ${String(lock.version)} ` +
@@ -153,21 +175,47 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
153
175
  Array.isArray(entry) ||
154
176
  typeof file !== 'string' ||
155
177
  !PROJECT_CONFIG_FILENAMES.includes(file) ||
156
- typeof config !== 'object' ||
157
- config === null ||
158
- Array.isArray(config)
178
+ !isObj(config) ||
179
+ // A run trusts these shapes: a string `name`, a `tags` list, a `tasks` object.
180
+ (config['name'] !== undefined && typeof config['name'] !== 'string') ||
181
+ (config['tags'] !== undefined && !Array.isArray(config['tags'])) ||
182
+ (config['tasks'] !== undefined && !isObj(config['tasks']))
159
183
  ) {
160
- throw new UserError(
161
- `${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``,
162
- 'VX_E_LOCK',
163
- )
184
+ throw malformed(dir)
164
185
  }
165
186
  }
187
+ const nested = lock.nestedWorkspaces
188
+ if (
189
+ nested !== undefined &&
190
+ (!Array.isArray(nested) ||
191
+ nested.some((d) => typeof d !== 'string' || d === '.' || !isLockKey(d)))
192
+ ) {
193
+ throw new UserError(
194
+ `${LOCKFILE_NAME}: \`nestedWorkspaces\` must list directories under the workspace root — re-run \`vx lock\``,
195
+ 'VX_E_LOCK',
196
+ )
197
+ }
166
198
  pooledIn(parsed as Pooled)
199
+ // After the pool is put back, as a task may be an index into it. A
200
+ // string `dependsOn` was walked per character: "dependsOn b#a".
201
+ for (const [dir, entry] of Object.entries(lock.projects as Record<string, LockedProject>)) {
202
+ const tasks = entry.config.tasks as Obj | undefined
203
+ for (const name in tasks) {
204
+ const t = tasks[name]
205
+ if (!isObj(t) || (t['dependsOn'] !== undefined && !Array.isArray(t['dependsOn'])))
206
+ throw malformed(dir)
207
+ }
208
+ }
167
209
  digests.set(parsed as Lockfile, xxh3hex(text))
168
210
  return parsed as Lockfile
169
211
  }
170
212
 
213
+ const malformed = (dir: string): UserError =>
214
+ new UserError(
215
+ `${LOCKFILE_NAME}: entry for "${dir}" is malformed — re-run \`vx lock\``,
216
+ 'VX_E_LOCK',
217
+ )
218
+
171
219
  /**
172
220
  * The project entries of a lock's bytes with its pool put back, or null
173
221
  * for bytes that do not parse as a lock: an entry compares across two
@@ -335,31 +383,32 @@ function taskKeysSorted(key: string, value: unknown): unknown {
335
383
  return Object.fromEntries(Object.entries(value).map(([name, task]) => [name, keysSorted(task)]))
336
384
  }
337
385
 
338
- export async function writeLockfile(root: string, lock: Lockfile): Promise<void> {
339
- // Written beside its name and renamed over it: `Bun.write` truncates in
340
- // place, so a run, or `vx watch` woken by the write
341
- // itself (item 971), could read an empty or half-written lock and fail
342
- // on "not valid JSON" (item 972).
343
- const file = lockfilePath(root)
344
- const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2)}`
345
- try {
346
- // Minified: the file only vx reads, and it loads faster than pretty
347
- // JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
348
- // beside each config is the copy for people.
349
- const sorted = JSON.parse(JSON.stringify(lock.projects, taskKeysSorted)) as Lockfile['projects']
350
- const body = JSON.stringify({ version: lock.version, ...pooledOut(sorted) })
351
- await Bun.write(tmp, `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`)
352
- await rename(tmp, file)
353
- } catch (err) {
354
- // Best effort: where the write was refused the temp cannot be named
355
- // either (ENOTDIR), and that must not replace the refusal itself.
356
- await rm(tmp, { force: true }).catch(() => {})
357
- // The refusal names the file the user knows: a read-only checkout read
358
- // `open '…/.vx/projects.lock.json.tmp-…'` where it had read the lock's own name.
359
- const e = err as NodeJS.ErrnoException
360
- if (typeof e.message === 'string') e.message = e.message.split(tmp).join(file)
361
- if (e.path === tmp) e.path = file
362
- throw err
386
+ /**
387
+ * Written beside its name and renamed over it: `Bun.write` truncates in
388
+ * place, so a run, or `vx watch` woken by the write itself (item 971),
389
+ * could read an empty or half-written lock and fail on "not valid JSON"
390
+ * (item 972).
391
+ */
392
+ export function writeLockfile(root: string, lock: Lockfile): Promise<void> {
393
+ return writeAllOrNone([lockfileWrite(root, lock)], root)
394
+ }
395
+
396
+ /** The lock's file and bytes, for `writeAllOrNone`. */
397
+ export function lockfileWrite(root: string, lock: Lockfile): PendingWrite {
398
+ // Minified: the file only vx reads, and it loads faster than pretty
399
+ // JSON (measured, 10k projects: 15.6 ms against 22.5 ms). `vx.lock.json`
400
+ // beside each config is the copy for people.
401
+ const sorted = JSON.parse(JSON.stringify(lock.projects, taskKeysSorted)) as Lockfile['projects']
402
+ const nested = lock.nestedWorkspaces ?? []
403
+ const body = JSON.stringify({
404
+ version: lock.version,
405
+ ...(nested.length > 0 ? { nestedWorkspaces: nested } : {}),
406
+ ...pooledOut(sorted),
407
+ })
408
+ return {
409
+ file: lockfilePath(root),
410
+ text: `${body.slice(0, -1)},"validated":"${validatedDigest(body)}"}\n`,
411
+ shown: LOCKFILE_NAME,
363
412
  }
364
413
  }
365
414
 
@@ -5,11 +5,12 @@
5
5
  // whichever tool wrote it. Core knows no source format here: a mapper returns a
6
6
  // `MigrationPlan` and this file does the rest.
7
7
 
8
- import { existsSync, readFileSync } from 'node:fs'
8
+ import { existsSync, readFileSync, realpathSync } from 'node:fs'
9
9
  import { unlink } from 'node:fs/promises'
10
10
  import path from 'node:path'
11
11
  import { workspaceFileWritten } from '../handoff.js'
12
12
  import {
13
+ checkIgnoreSync,
13
14
  DOT_VX_GITIGNORE,
14
15
  executablePath,
15
16
  gitEnv,
@@ -22,6 +23,31 @@ import * as ui from '../ui/index.js'
22
23
  import { VERSION } from '../version.js'
23
24
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
24
25
 
26
+ /**
27
+ * The nearest existing directory at or above `dir` (under `root`) and its
28
+ * real path when that path is outside `realRoot`; none when it is inside.
29
+ */
30
+ function linkOut(
31
+ root: string,
32
+ realRoot: string,
33
+ dir: string,
34
+ ): [link: string | undefined, real: string] {
35
+ for (let at = dir; ; at = path.dirname(at)) {
36
+ let real: string
37
+ try {
38
+ real = realpathSync(at)
39
+ } catch {
40
+ // quiet: not made yet; mkdir makes it a real directory under its parent
41
+ if (at === root) return [undefined, realRoot]
42
+ continue
43
+ }
44
+ const rel = path.relative(realRoot, real)
45
+ return rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)
46
+ ? [at, real]
47
+ : [undefined, real]
48
+ }
49
+ }
50
+
25
51
  /** The root config `vx init` writes outside a Turbo or Nx repo: no task yet. */
26
52
  export function starterProject(format: MigrationFormat): string {
27
53
  return format === 'ts'
@@ -42,6 +68,10 @@ export function isStarterConfig(file: string): boolean {
42
68
  }
43
69
  }
44
70
 
71
+ /** A C0 or C1 control, or DEL. */
72
+ // oxlint-disable-next-line no-control-regex
73
+ const CONTROL = /[\x00-\x1f\x7f-\x9f]/g
74
+
45
75
  /**
46
76
  * Escape an arbitrary string into a single-quoted TS literal. Escapes
47
77
  * backslash + quote AND raw newlines/CR — a value with an embedded newline
@@ -55,7 +85,10 @@ export function quoteTsLiteral(s: string): string {
55
85
  .replaceAll('\\', '\\\\')
56
86
  .replaceAll("'", "\\'")
57
87
  .replaceAll('\n', '\\n')
58
- .replaceAll('\r', '\\r')}'`
88
+ .replaceAll('\r', '\\r')
89
+ // Any other control as its escape: a script's raw ESC landed in the
90
+ // file as the byte, and `cat vx.config.ts` retitled the terminal.
91
+ .replace(CONTROL, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`)}'`
59
92
  }
60
93
 
61
94
  /**
@@ -239,8 +272,9 @@ export interface ApplyMigrationArgs {
239
272
  * the migrated workspace has its .vx/projects.lock.json and its graph rules checked
240
273
  * (2026-10-14). On success `said` is its line, shown as the
241
274
  * report's `lock` row; a refusal (a graph rule, a plugin not installed
242
- * yet) prints above the report and the migration still succeeds: its
243
- * files are written, and `vx lock` is the step to re-run.
275
+ * yet) prints above the report, the files stay written, `vx lock` is the
276
+ * step to re-run, and the exit is 1: a run reads only the lock, so the
277
+ * migration is not done (as `vx init`'s own path exits).
244
278
  */
245
279
  lock?: (root: string) => Promise<{ code: number; said: string }>
246
280
  /** Rows of what the tool did before the write, e.g. `['ran', 'bun add -d …']`. */
@@ -345,11 +379,23 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
345
379
 
346
380
  // A plan is a mapper's reading of the repo's own files (an Nx node's
347
381
  // `root`, a workspace glob): one that names `../x` wrote outside it.
382
+ // A member directory linked out of the root is spelled inside it, so the
383
+ // real directory is asked too: core's discovery never loads a config
384
+ // there. `.vx` is not a project: its files are refused outside the
385
+ // root at the write (`refuseSymlink`).
386
+ const realRoot = realpathSync(root)
348
387
  for (const f of files) {
349
388
  const rel = relPosix(root, f.abs)
350
389
  if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
351
390
  throw new UserError(`${f.relPath}: outside the workspace root`)
352
391
  }
392
+ if (rel.startsWith('.vx/')) continue
393
+ const [link, real] = linkOut(root, realRoot, path.dirname(f.abs))
394
+ if (link !== undefined) {
395
+ throw new UserError(
396
+ `${f.relPath}: outside the workspace root (${relPosix(root, link)} resolves to ${real}); vx loads no project there`,
397
+ )
398
+ }
353
399
  }
354
400
 
355
401
  if (!dry && !force) {
@@ -389,8 +435,8 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
389
435
  ui.print('out', ui.excerpt(f.relPath, f.contents))
390
436
  }
391
437
  } else if (!dry) {
392
- for (const f of files) refuseSymlink(f.abs)
393
- for (const f of files) await writeNoFollow(f.abs, f.contents)
438
+ for (const f of files) refuseSymlink(f.abs, root)
439
+ for (const f of files) await writeNoFollow(f.abs, f.contents, root)
394
440
  for (const f of replaced) await unlink(f)
395
441
  // The lock that follows loads its plugins from the file just written.
396
442
  workspaceFileWritten()
@@ -531,7 +577,7 @@ export async function applyMigration(args: ApplyMigrationArgs): Promise<number>
531
577
  lock === undefined || lock.code === 0 ? next : `fix the above, then vx lock and ${next}`
532
578
  rows.push(null, ['next', said])
533
579
  ui.print('out', ui.report(`${verb} ${VERSION}`, rows))
534
- return 0
580
+ return lock === undefined || lock.code === 0 ? 0 : 1
535
581
  }
536
582
 
537
583
  /**
@@ -583,25 +629,7 @@ function ignoreAdvice(root: string, ignored: readonly string[], dry: boolean): s
583
629
 
584
630
  /** The paths (root-relative) git's ignore rules drop; none outside a work tree. */
585
631
  function gitIgnored(root: string, relPaths: readonly string[]): string[] {
586
- if (relPaths.length === 0) return []
587
- try {
588
- const r = Bun.spawnSync({
589
- cmd: [executablePath('git'), 'check-ignore', '--stdin', '-z'],
590
- env: gitEnv(),
591
- cwd: root,
592
- stdin: Buffer.from(relPaths.map((p) => `${p}\0`).join('')),
593
- stdout: 'pipe',
594
- stderr: 'ignore',
595
- })
596
- // 0: some ignored; 1: none; anything else (no work tree): nothing to say.
597
- if (r.exitCode !== 0) return []
598
- return r.stdout
599
- .toString()
600
- .split('\0')
601
- .filter((p) => p !== '')
602
- } catch {
603
- return []
604
- }
632
+ return [...(checkIgnoreSync(root, relPaths) ?? [])]
605
633
  }
606
634
 
607
635
  function inGitWorkTree(root: string): boolean {
@@ -658,7 +686,7 @@ const EXAMPLE_BODY = `export default {
658
686
  build: {
659
687
  exec: { command: 'tsc -b' },
660
688
  dependsOn: ['^build'],
661
- inputs: { files: ['src/**'] }, cache: { outputs: { files: ['dist/**'] } },
689
+ inputs: { files: ['src/**'] }, outputs: { files: ['dist/**'] }, cache: true,
662
690
  },
663
691
  },
664
692
  }`
@@ -747,7 +775,7 @@ function renderConfigFile(p: GeneratedProject, format: MigrationFormat): string
747
775
  // The suggested block sits in the task, commented: deleting `// `
748
776
  // after setting the real globs caches it, no copying from the TODO.
749
777
  const block =
750
- t.task['cache'] === undefined
778
+ t.task['cache'] !== true
751
779
  ? t.todos.find(isCacheTodo)?.match(/^cache: add `(.+?)`/)?.[1]
752
780
  : undefined
753
781
  lines.push(
@@ -790,11 +818,10 @@ function ownConfigNamed(
790
818
  return {
791
819
  ...p,
792
820
  tasks: p.tasks.map((t) => {
793
- const outputs = (t.task?.['cache'] as { outputs?: { files?: unknown } } | undefined)?.outputs
821
+ const outputs = t.task?.['outputs'] as { files?: unknown } | undefined
794
822
  if (!Array.isArray(outputs?.files) || !outputs.files.includes(planned)) return t
795
- const cache = t.task!['cache'] as Record<string, unknown>
796
823
  const files = outputs.files.map((f: unknown) => (f === planned ? `!${name}` : f))
797
- return { ...t, task: { ...t.task, cache: { ...cache, outputs: { ...outputs, files } } } }
824
+ return { ...t, task: { ...t.task, outputs: { ...outputs, files } } }
798
825
  }),
799
826
  }
800
827
  }
@@ -0,0 +1,62 @@
1
+ // What a module's exports hold, to tell whether a config changed a module
2
+ // other configs import. Both functions are self-contained: the config
3
+ // worker embeds them by their source (config-eval.ts), as it does
4
+ // `nonJsonPaths`, and a minified build renames only what each one holds.
5
+
6
+ /** One entry per export: every value reachable from it, in walk order, compared by identity. */
7
+ export type ExportState = Map<PropertyKey, unknown[]>
8
+
9
+ /**
10
+ * Every value each export of `ns` reaches through own properties and Map
11
+ * and Set entries, without calling a getter. Functions and typed arrays
12
+ * are compared by identity only. State a module keeps unexported, behind a
13
+ * function, is not reached.
14
+ */
15
+ export function exportState(ns: object): ExportState {
16
+ const ownKeys = Reflect.ownKeys
17
+ const describe = Object.getOwnPropertyDescriptor
18
+ const protoOf = Object.getPrototypeOf
19
+ const state: ExportState = new Map()
20
+ for (const name of ownKeys(ns)) {
21
+ const out: unknown[] = []
22
+ const seen = new Set<object>()
23
+ const walk = (v: unknown): void => {
24
+ out.push(v)
25
+ if (v === null || typeof v !== 'object' || seen.has(v) || ArrayBuffer.isView(v)) return
26
+ seen.add(v)
27
+ out.push(protoOf(v))
28
+ if (v instanceof Map)
29
+ for (const [k, x] of v) {
30
+ out.push(k)
31
+ walk(x)
32
+ }
33
+ else if (v instanceof Set) for (const x of v) walk(x)
34
+ for (const k of ownKeys(v)) {
35
+ const d = describe(v, k)!
36
+ out.push(k, d.get, d.set, d.writable)
37
+ if ('value' in d) walk(d.value)
38
+ }
39
+ }
40
+ try {
41
+ walk((ns as Record<PropertyKey, unknown>)[name])
42
+ } catch {
43
+ // A binding not yet initialized, or a Proxy's trap that throws.
44
+ out.push('\0threw')
45
+ }
46
+ state.set(name, out)
47
+ }
48
+ return state
49
+ }
50
+
51
+ /** The exports whose state differs between `before` and `after`. */
52
+ export function changedExports(before: ExportState, after: ExportState): string[] {
53
+ const out: string[] = []
54
+ for (const name of new Set([...before.keys(), ...after.keys()])) {
55
+ const a = before.get(name)
56
+ const b = after.get(name)
57
+ let same = a !== undefined && b !== undefined && a.length === b.length
58
+ for (let i = 0; same && i < a!.length; i++) same = Object.is(a![i], b![i])
59
+ if (!same) out.push(String(name))
60
+ }
61
+ return out
62
+ }
@@ -19,9 +19,16 @@ import type { ProjectEntry } from './workspace.js'
19
19
  * once we leave the `p.dir` block entirely. The inner loop is still bounded by
20
20
  * that block (near-O(P log P) on real trees; interlopers are few), not the
21
21
  * full tail.
22
+ *
23
+ * `workspaces`: the absolute roots of nested workspaces (each holds its own
24
+ * `.vx/workspace.*`), a boundary for outputs: each joins the dirs of every
25
+ * project it lies under. An outer output glob that ran into one let the
26
+ * clean before a run delete its files. Never passed for inputs: a task may
27
+ * read them, and an input left out of the key is a stale hit.
22
28
  */
23
29
  export function computeNestedProjectDirs(
24
30
  entries: Array<Pick<ProjectEntry, 'name' | 'dir'>>,
31
+ workspaces: readonly string[] = [],
25
32
  ): Map<string, string[]> {
26
33
  const result = new Map<string, string[]>()
27
34
  if (entries.length === 0) return result
@@ -42,6 +49,7 @@ export function computeNestedProjectDirs(
42
49
  // else: an interloping sibling (`foo-utils` for `foo`) that shares the
43
50
  // string prefix but isn't nested — skip it, descendants may follow.
44
51
  }
52
+ for (const w of workspaces) if (w.startsWith(prefix)) nested.push(w)
45
53
  result.set(p.name, nested)
46
54
  }
47
55
  return result