@vzn/vx 0.0.143 → 0.0.145

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.143",
3
+ "version": "0.0.145",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.143",
21
- "@vzn/vx-linux-arm64": "0.0.143",
22
- "@vzn/vx-darwin-x64": "0.0.143",
23
- "@vzn/vx-darwin-arm64": "0.0.143"
20
+ "@vzn/vx-linux-x64": "0.0.145",
21
+ "@vzn/vx-linux-arm64": "0.0.145",
22
+ "@vzn/vx-darwin-x64": "0.0.145",
23
+ "@vzn/vx-darwin-arm64": "0.0.145"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -201,6 +201,30 @@ export class GitFilesCache extends Map<string, readonly string[]> {
201
201
  }
202
202
  }
203
203
 
204
+ /** Tracked files a task's clean removed this run, and whose clean (A-48). */
205
+ private trackedCleans: Array<{ path: string; by: string }> = []
206
+
207
+ /**
208
+ * Note which of the files a task's clean just removed git tracks. Called
209
+ * before `markOutputsChanged`, which drops their OIDs. A committed output
210
+ * another task reads with no edge to its producer is gone for as long as
211
+ * the producer runs (vueuse's `metadata/index.json`, N's dogfood), and the
212
+ * reader failed naming only the missing file (A-48).
213
+ */
214
+ noteClean(by: string, dir: string, rels: readonly string[]): void {
215
+ const oids = this.oids.get(dir)
216
+ if (oids === undefined) return
217
+ for (const rel of rels) {
218
+ const abs = path.resolve(dir, rel)
219
+ if (oids.has(abs)) this.trackedCleans.push({ path: abs, by })
220
+ }
221
+ }
222
+
223
+ /** The tracked files other tasks' cleans removed that are still missing. */
224
+ trackedCleansMissing(except: string): Array<{ path: string; by: string }> {
225
+ return this.trackedCleans.filter((c) => c.by !== except && !existsSync(c.path))
226
+ }
227
+
204
228
  /** Trusted index OIDs for a project (abs path → oid), if any survive. */
205
229
  oidsFor(projectDir: string): ReadonlyMap<string, string> | undefined {
206
230
  return this.oids.get(projectDir)
@@ -0,0 +1,369 @@
1
+ // What a Turbo or Nx user's hands type, and what `vx run` does with it.
2
+ // Every flag here has one outcome: vx takes it as it is (`same`), takes it
3
+ // as another spelling (`alias`, rewritten before the parse), or refuses it
4
+ // with the vx way to say it (`refuse`). Nothing is dropped in silence: a
5
+ // flag vx does not know was already a refusal, but one that named no way
6
+ // on left a `turbo run --dry-run` user at a dead end. The table renders
7
+ // cli.md's parity section (`renderForeignFlags`), pinned by a test.
8
+
9
+ export interface ForeignFlag {
10
+ runner: 'turbo' | 'nx'
11
+ /** Every spelling, first the one shown. */
12
+ names: readonly string[]
13
+ /** It takes a value: `--flag <v>` or `--flag=<v>`. */
14
+ value: boolean
15
+ /** When only some values are foreign (`--continue=dependencies-successful`). */
16
+ when?: (value: string | undefined) => boolean
17
+ /** How the table shows a `when` row. */
18
+ shown?: string
19
+ outcome: 'same' | 'alias' | 'refuse'
20
+ /** The vx spelling, or what to use instead. */
21
+ vx: string
22
+ /** For an alias: the vx argv. For a refusal: none. */
23
+ to?: (value: string | undefined) => string[]
24
+ }
25
+
26
+ const list = (v: string | undefined): string[] =>
27
+ (v ?? '')
28
+ .split(',')
29
+ .map((s) => s.trim())
30
+ .filter((s) => s !== '')
31
+
32
+ const GRAPH_IMAGE = /\.(svg|png|jpe?g|pdf|json|html|mermaid)$/i
33
+
34
+ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
35
+ {
36
+ runner: 'turbo',
37
+ names: ['--filter'],
38
+ value: true,
39
+ outcome: 'same',
40
+ vx: '`--filter`, the same grammar (`...[ref]`, `{dir}`, `^`, `!`)',
41
+ },
42
+ {
43
+ runner: 'turbo',
44
+ names: ['--concurrency'],
45
+ value: true,
46
+ outcome: 'same',
47
+ vx: '`--concurrency <n|n%>`',
48
+ },
49
+ {
50
+ runner: 'turbo',
51
+ names: ['--continue'],
52
+ value: true,
53
+ when: (v) => v === 'dependencies-successful',
54
+ shown: '`--continue=dependencies-successful`',
55
+ outcome: 'alias',
56
+ vx: '`--continue=deps-ok`',
57
+ to: () => ['--continue=deps-ok'],
58
+ },
59
+ {
60
+ runner: 'turbo',
61
+ names: ['--continue'],
62
+ value: false,
63
+ outcome: 'same',
64
+ vx: '`--continue[=never|deps-ok|always]`',
65
+ },
66
+ {
67
+ runner: 'turbo',
68
+ names: ['--dry-run'],
69
+ value: false,
70
+ outcome: 'alias',
71
+ vx: '`--dry[=text|json]`',
72
+ to: (v) => [v === undefined ? '--dry' : `--dry=${v}`],
73
+ },
74
+ {
75
+ runner: 'turbo',
76
+ names: ['--graph'],
77
+ value: true,
78
+ when: (v) => v !== undefined && GRAPH_IMAGE.test(v),
79
+ shown: '`--graph=<file>.svg|png|json|html|…`',
80
+ outcome: 'refuse',
81
+ vx: 'vx writes Graphviz DOT only: `--graph=<file>.dot`, then `dot -Tsvg`',
82
+ },
83
+ {
84
+ runner: 'turbo',
85
+ names: ['--graph'],
86
+ value: false,
87
+ outcome: 'same',
88
+ vx: '`--graph[=<file>.dot]`',
89
+ },
90
+ {
91
+ runner: 'turbo',
92
+ names: ['--force'],
93
+ value: false,
94
+ outcome: 'same',
95
+ vx: '`--force`: skip cache reads, keep writes',
96
+ },
97
+ {
98
+ runner: 'turbo',
99
+ names: ['--affected'],
100
+ value: false,
101
+ outcome: 'same',
102
+ vx: '`--affected[=<base>]`',
103
+ },
104
+ {
105
+ runner: 'turbo',
106
+ names: ['--summarize'],
107
+ value: false,
108
+ outcome: 'same',
109
+ vx: '`--summarize[=<path>]`',
110
+ },
111
+ {
112
+ runner: 'turbo',
113
+ names: ['--output-logs'],
114
+ value: true,
115
+ when: (v) => v === 'new-only',
116
+ shown: '`--output-logs=new-only`',
117
+ outcome: 'refuse',
118
+ vx: 'use `--output-logs=full` (a hit replays its log) or `errors-only`',
119
+ },
120
+ {
121
+ runner: 'turbo',
122
+ names: ['--output-logs'],
123
+ value: true,
124
+ outcome: 'same',
125
+ vx: '`--output-logs full|errors-only|hash-only|none`',
126
+ },
127
+ { runner: 'turbo', names: ['--no-cache'], value: false, outcome: 'same', vx: '`--no-cache`' },
128
+ {
129
+ runner: 'turbo',
130
+ names: ['--cache'],
131
+ value: true,
132
+ outcome: 'same',
133
+ vx: '`--cache local:rw,remote:r`',
134
+ },
135
+ {
136
+ runner: 'turbo',
137
+ names: ['--cache-dir'],
138
+ value: true,
139
+ outcome: 'same',
140
+ vx: '`--cache-dir <path>`',
141
+ },
142
+ {
143
+ runner: 'turbo',
144
+ names: ['--profile'],
145
+ value: false,
146
+ outcome: 'same',
147
+ vx: '`--profile[=<path>]` (Chrome trace)',
148
+ },
149
+ {
150
+ runner: 'turbo',
151
+ names: ['--only'],
152
+ value: false,
153
+ outcome: 'alias',
154
+ vx: '`--exclude-dependencies`',
155
+ to: () => ['--exclude-dependencies'],
156
+ },
157
+ {
158
+ runner: 'nx',
159
+ names: ['--parallel'],
160
+ value: true,
161
+ when: (v) => v !== undefined && v !== 'true',
162
+ shown: '`--parallel <n>`',
163
+ outcome: 'alias',
164
+ vx: '`--concurrency <n>` (`--parallel=false` is 1)',
165
+ to: (v) => ['--concurrency', v === 'false' ? '1' : (v ?? '')],
166
+ },
167
+ {
168
+ runner: 'turbo',
169
+ names: ['--parallel'],
170
+ value: false,
171
+ when: (v) => v === undefined || v === 'true',
172
+ shown: '`--parallel`',
173
+ outcome: 'refuse',
174
+ vx: 'vx always honours `dependsOn`; `--concurrency <n>` sets how many run at once',
175
+ },
176
+ {
177
+ runner: 'turbo',
178
+ names: ['--scope'],
179
+ value: true,
180
+ outcome: 'refuse',
181
+ vx: 'use `--filter <pkg>`',
182
+ },
183
+ {
184
+ runner: 'turbo',
185
+ names: ['--since'],
186
+ value: true,
187
+ outcome: 'refuse',
188
+ vx: "use `--filter '[<ref>]'` or `--affected=<ref>`",
189
+ },
190
+ {
191
+ runner: 'turbo',
192
+ names: ['--remote-only'],
193
+ value: false,
194
+ outcome: 'refuse',
195
+ vx: 'use `--cache local:,remote:rw`',
196
+ },
197
+ {
198
+ runner: 'turbo',
199
+ names: ['--remote-cache-read-only'],
200
+ value: false,
201
+ outcome: 'refuse',
202
+ vx: 'use `--cache local:rw,remote:r`',
203
+ },
204
+ {
205
+ runner: 'turbo',
206
+ names: ['--token', '--team', '--api'],
207
+ value: true,
208
+ outcome: 'refuse',
209
+ vx: 'a remote cache is a plugin: `turboCache()` from @vzn/vx-migrate in vx.workspace.ts reads TURBO_TOKEN / TURBO_TEAM / TURBO_API',
210
+ },
211
+ {
212
+ runner: 'turbo',
213
+ names: ['--no-daemon', '--daemon'],
214
+ value: false,
215
+ outcome: 'refuse',
216
+ vx: 'vx has no daemon: drop it',
217
+ },
218
+ {
219
+ runner: 'turbo',
220
+ names: ['--ui', '--log-order', '--log-prefix'],
221
+ value: true,
222
+ outcome: 'refuse',
223
+ vx: 'vx frames each task’s output: `--output-logs <mode>` sets how much',
224
+ },
225
+ {
226
+ runner: 'nx',
227
+ names: ['-t', '--targets', '--target'],
228
+ value: true,
229
+ outcome: 'alias',
230
+ vx: 'the task names, positional: `vx run build test`',
231
+ to: (v) => list(v),
232
+ },
233
+ {
234
+ runner: 'nx',
235
+ names: ['-p', '--projects'],
236
+ value: true,
237
+ outcome: 'alias',
238
+ vx: '`--filter <pattern>`, one per project',
239
+ to: (v) => list(v).flatMap((p) => ['--filter', p]),
240
+ },
241
+ {
242
+ runner: 'nx',
243
+ names: ['--exclude'],
244
+ value: true,
245
+ outcome: 'alias',
246
+ vx: "`--filter '!<pattern>'`, one per project",
247
+ to: (v) => list(v).flatMap((p) => ['--filter', `!${p}`]),
248
+ },
249
+ {
250
+ runner: 'nx',
251
+ names: ['--base'],
252
+ value: true,
253
+ outcome: 'alias',
254
+ vx: '`--affected=<ref>`',
255
+ to: (v) => [`--affected=${v ?? ''}`],
256
+ },
257
+ {
258
+ runner: 'nx',
259
+ names: ['--head'],
260
+ value: true,
261
+ when: (v) => v === 'HEAD',
262
+ shown: '`--head HEAD`',
263
+ outcome: 'alias',
264
+ vx: 'nothing: vx compares `--affected=<base>` with the working tree',
265
+ to: () => [],
266
+ },
267
+ {
268
+ runner: 'nx',
269
+ names: ['--head'],
270
+ value: true,
271
+ outcome: 'refuse',
272
+ vx: 'vx compares `--affected=<base>` with the working tree: check out the head first',
273
+ },
274
+ {
275
+ runner: 'nx',
276
+ names: ['--skip-nx-cache'],
277
+ value: false,
278
+ outcome: 'alias',
279
+ vx: '`--force`',
280
+ to: () => ['--force'],
281
+ },
282
+ { runner: 'nx', names: ['--all'], value: false, outcome: 'same', vx: '`--all`' },
283
+ {
284
+ runner: 'nx',
285
+ names: ['--nx-bail'],
286
+ value: false,
287
+ outcome: 'alias',
288
+ vx: '`--continue=never`',
289
+ to: () => ['--continue=never'],
290
+ },
291
+ {
292
+ runner: 'nx',
293
+ names: ['-c', '--configuration'],
294
+ value: true,
295
+ outcome: 'refuse',
296
+ vx: 'a configuration is its own task: `vx run <target>:<configuration>`',
297
+ },
298
+ {
299
+ runner: 'nx',
300
+ names: ['--output-style'],
301
+ value: true,
302
+ outcome: 'refuse',
303
+ vx: 'use `--output-logs <mode>`',
304
+ },
305
+ {
306
+ runner: 'nx',
307
+ names: ['--uncommitted', '--untracked'],
308
+ value: false,
309
+ outcome: 'refuse',
310
+ vx: 'use `--affected=HEAD` (the working tree against the last commit)',
311
+ },
312
+ {
313
+ runner: 'nx',
314
+ names: ['--no-cloud'],
315
+ value: false,
316
+ outcome: 'refuse',
317
+ vx: 'vx has no cloud: drop it',
318
+ },
319
+ ]
320
+
321
+ /** Nx's verbs, as its users type them: `vx run` takes their flags. */
322
+ export const FOREIGN_VERBS: Readonly<Record<string, string>> = {
323
+ 'run-many':
324
+ '`nx run-many` is `vx run <task> --all` here; -t, -p, --exclude and --parallel work as they are',
325
+ affected:
326
+ '`nx affected` is `vx run <task> --affected` here; -t, --base and --exclude work as they are',
327
+ }
328
+
329
+ /**
330
+ * Rewrite each aliased foreign flag into its vx argv, or refuse the first
331
+ * refused one. A `same` flag and anything unknown pass through to the
332
+ * parser. Arguments after `--` are the task's and are never read.
333
+ */
334
+ export function translateForeign(args: readonly string[]): string[] | { error: string } {
335
+ const out: string[] = []
336
+ const sep = args.indexOf('--')
337
+ const before = sep === -1 ? args : args.slice(0, sep)
338
+ for (let i = 0; i < before.length; i++) {
339
+ const a = before[i]!
340
+ const eq = a.indexOf('=')
341
+ const name = eq === -1 ? a : a.slice(0, eq)
342
+ const inline = eq === -1 ? undefined : a.slice(eq + 1)
343
+ const entries = FOREIGN_FLAGS.filter((f) => f.names.includes(name) || f.names.includes(a))
344
+ const next = before[i + 1]
345
+ const spaced = inline === undefined && next !== undefined && !next.startsWith('-')
346
+ const hit = entries.find((f) => {
347
+ const v = f.value ? (inline ?? (spaced ? next : undefined)) : inline
348
+ return f.when === undefined || f.when(v)
349
+ })
350
+ if (hit === undefined || hit.outcome === 'same') {
351
+ out.push(a)
352
+ continue
353
+ }
354
+ const v = hit.value ? (inline ?? (spaced ? next : undefined)) : inline
355
+ if (hit.value && inline === undefined && spaced) i++
356
+ if (hit.outcome === 'refuse') return { error: `${name} (${hit.runner}): ${hit.vx}` }
357
+ out.push(...hit.to!(v))
358
+ }
359
+ return sep === -1 ? out : [...out, ...args.slice(sep)]
360
+ }
361
+
362
+ /** cli.md's parity table, one row per entry. */
363
+ export function renderForeignFlags(): string {
364
+ const rows = FOREIGN_FLAGS.map((f) => {
365
+ const flag = f.shown ?? f.names.map((n) => `\`${n}${f.value ? ' <v>' : ''}\``).join(', ')
366
+ return `| ${f.runner} | ${flag.replaceAll('|', '\\|')} | ${f.outcome} | ${f.vx.replaceAll('|', '\\|')} |`
367
+ })
368
+ return ['| runner | flag | outcome | in vx |', '| --- | --- | --- | --- |', ...rows].join('\n')
369
+ }
package/src/cli/index.ts CHANGED
@@ -6,6 +6,8 @@ import { VERSION } from '../version.js'
6
6
  import { runCmd } from './run.js'
7
7
  import { CORE_VERBS, printHelp } from './help.js'
8
8
  import { pluginCommandHelp, resolvePluginCommand, pluginVerbs } from './plugin-commands.js'
9
+ import { FOREIGN_VERBS } from './foreign-flags.js'
10
+ import { taskVerbHint } from './task-verb.js'
9
11
  import { isUserError, MOVED_VERBS, nearest, UserError } from '../util/index.js'
10
12
 
11
13
  // Every verb but `run` is imported when invoked. `vx run` is the hot path
@@ -45,8 +47,9 @@ export async function run(argv: readonly string[]): Promise<number> {
45
47
  // for the whole reference: it printed 145 lines and no hint.
46
48
  const resolved = await resolvePluginCommand(verb)
47
49
  const unknown = resolved === null || 'declaredVerbs' in resolved
48
- if (unknown && MOVED_VERBS[verb] !== undefined) {
49
- process.stderr.write(`${MOVED_VERBS[verb]}\n`)
50
+ const pointer = MOVED_VERBS[verb] ?? FOREIGN_VERBS[verb]
51
+ if (unknown && pointer !== undefined) {
52
+ process.stderr.write(`${pointer}\n`)
50
53
  return 1
51
54
  }
52
55
  if (unknown) {
@@ -127,11 +130,18 @@ export async function run(argv: readonly string[]): Promise<number> {
127
130
  // A verb core owned once (`migrate`, `prune`) and a package owns now:
128
131
  // the pointer, but only after the plugins had their chance — a
129
132
  // workspace that declares a plugin verb of that name keeps it.
130
- const moved = MOVED_VERBS[command]
133
+ const moved = MOVED_VERBS[command] ?? FOREIGN_VERBS[command]
131
134
  if (moved !== undefined) {
132
135
  process.stderr.write(`${moved}${loadNote}\n`)
133
136
  return 1
134
137
  }
138
+ // A task typed where the verb goes (`turbo build`, `nx build app`),
139
+ // `turbo dev` included: a repo's own `dev` task beats the no-service note.
140
+ const task = loadNote === '' ? await taskVerbHint(command, rest, process.cwd()) : null
141
+ if (task !== null) {
142
+ process.stderr.write(`vx: ${task}\n`)
143
+ return 1
144
+ }
135
145
  if (command === 'serve' || command === 'dev') {
136
146
  // vx core is only a task runner — it has no service layer of its
137
147
  // own. A dashboard, remote cache, distributed execution, etc. are
package/src/cli/run.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
+ import { translateForeign } from './foreign-flags.js'
3
4
  import { flagHint, seeHelp } from './help.js'
4
5
  import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
5
6
  import {
@@ -109,7 +110,7 @@ export function parseConcurrency(v: string, cpus = machineParallelism()): number
109
110
  return n === null || n < 1 ? null : n
110
111
  }
111
112
 
112
- export function parseRunArgs(args: readonly string[]): RunArgs {
113
+ export function parseRunArgs(rawArgs: readonly string[]): RunArgs {
113
114
  const out: RunArgs = {
114
115
  tasks: [],
115
116
  filters: [],
@@ -133,6 +134,10 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
133
134
  reportFile: undefined,
134
135
  }
135
136
 
137
+ // A Turbo or Nx spelling becomes vx's, or a refusal naming vx's.
138
+ const translated = translateForeign(rawArgs)
139
+ if ('error' in translated) return { ...out, error: translated.error }
140
+ const args = translated
136
141
  const sepIdx = args.indexOf('--')
137
142
  const before = sepIdx === -1 ? args : args.slice(0, sepIdx)
138
143
  out.forwardArgs = sepIdx === -1 ? [] : args.slice(sepIdx + 1)
@@ -0,0 +1,38 @@
1
+ // `turbo build` runs the build; `nx build app` builds app. A Turbo or Nx
2
+ // user types the task where vx wants a verb, and `vx build` answered
3
+ // "unknown command" with no way on. The answer names the `vx run` that
4
+ // does it. It stays a refusal: a verb that runs whatever task shares its
5
+ // name would change meaning the day a plugin declares that verb.
6
+
7
+ import { findWorkspaceRoot, listProjects, loadWorkspace } from '../workspace/index.js'
8
+ import { findCwdProject } from './select.js'
9
+ import { loadCliProjects } from './workspace-config.js'
10
+
11
+ /** The `vx run` a task typed as a verb means, or null when no project declares it. */
12
+ export async function taskVerbHint(
13
+ command: string,
14
+ rest: readonly string[],
15
+ cwd: string,
16
+ ): Promise<string | null> {
17
+ if (command.includes('#')) return `\`${command}\` is a task: vx run ${command}`
18
+ let projects: Awaited<ReturnType<typeof loadCliProjects>>
19
+ try {
20
+ const root = await findWorkspaceRoot(cwd)
21
+ projects = await loadCliProjects(root, await listProjects(await loadWorkspace(root)), 'all', {
22
+ noCreate: true,
23
+ })
24
+ } catch {
25
+ // No workspace, or a config that does not load: the plain unknown-command line stands.
26
+ return null
27
+ }
28
+ if (![...projects.values()].some((p) => p.config.tasks?.[command] !== undefined)) return null
29
+ // Nx's `nx build app`: the word after the target is its project.
30
+ const project = rest[0] !== undefined && projects.has(rest[0]) ? rest[0] : undefined
31
+ const run =
32
+ project !== undefined
33
+ ? `vx run ${command} --filter ${project}`
34
+ : (await findCwdProject(cwd)) === null
35
+ ? `vx run ${command} --all`
36
+ : `vx run ${command}`
37
+ return `\`${command}\` is a task here, not a command: ${run}`
38
+ }
@@ -106,6 +106,8 @@ export interface ExecuteArgs {
106
106
  * where a locally-placed consumer fetches its producers from. */
107
107
  deferred?: DeferredOutputs
108
108
  nestedProjectDirs: string[]
109
+ /** The run's cache directory: walled off from a sandboxed task's grants. */
110
+ cacheDir?: string
109
111
  /** Anchor for hrtime spans across all tasks in this run. */
110
112
  runStartHrTimeNs: bigint
111
113
  /**
@@ -312,6 +314,7 @@ async function executePersistentTask(args: ExecuteArgs): Promise<TaskOutcome> {
312
314
  args.workspaceRoot,
313
315
  undefined,
314
316
  args.nestedProjectDirs,
317
+ args.cacheDir,
315
318
  )
316
319
  placeholders = sb.placeholders
317
320
  const wrapped = await wrapSandboxedCommand({
@@ -757,6 +760,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
757
760
  const endClean = span('miss: clean outputs')
758
761
  const cleanedRels = await cleanOutputs(cleanArgs)
759
762
  endClean()
763
+ args.gitFilesCache?.noteClean(node.id, node.projectDir, cleanedRels)
760
764
  args.gitFilesCache?.markOutputsChanged(node.projectDir, cleanedRels)
761
765
  }
762
766
  if (additive && willWrite && !deferralRequested && wsOutputs.length > 0) {
@@ -872,6 +876,18 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
872
876
  signal: res.signal,
873
877
  })
874
878
  if (verdict !== undefined) log.taskStderr(node, `\n${verdict}\n`)
879
+ // A committed file another task's clean removed, still gone: vx
880
+ // cleans outputs before a run where Turbo does not, so a reader with
881
+ // no edge to the producer fails naming only the file (A-48).
882
+ if (code !== 0) {
883
+ for (const c of (args.gitFilesCache?.trackedCleansMissing(node.id) ?? []).slice(0, 3)) {
884
+ const rel = path.relative(args.workspaceRoot, c.path).split(path.sep).join('/')
885
+ log.taskStderr(
886
+ node,
887
+ `\n[vx] ${rel} is tracked by git, and ${c.by} removed it as an output before its run; it is still missing. A task that reads it needs dependsOn on ${c.by}.\n`,
888
+ )
889
+ }
890
+ }
875
891
  }
876
892
  return { result: res, exitCode: code }
877
893
  }
@@ -1002,6 +1018,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
1002
1018
  args.workspaceRoot,
1003
1019
  cfgCacheable ? args.keyedProjects(node) : undefined,
1004
1020
  args.nestedProjectDirs,
1021
+ args.cacheDir,
1005
1022
  )
1006
1023
  placeholders = sb.placeholders
1007
1024
  withheld = sb.withheld
@@ -755,6 +755,7 @@ async function runOnBus(
755
755
  ...(placements.remoteOnlyNoop.has(node.id) ? { remoteOnlyNoop: true } : {}),
756
756
  ...(placements.remoteOnly.has(node.id) ? { remoteOnly: true } : {}),
757
757
  nestedProjectDirs: nestedDirsByProject.get(node.projectName) ?? [],
758
+ cacheDir,
758
759
  runStartHrTimeNs,
759
760
  persistentRegistry,
760
761
  liveChildren,
@@ -174,6 +174,12 @@ export async function sandboxRequestFor(
174
174
  keyed: ReadonlySet<string> | undefined,
175
175
  /** The projects nested in this one (their dirs): a wall its grants do not reach. */
176
176
  nested: readonly string[] = [],
177
+ /**
178
+ * The run's cache directory. `.vx` is walled wherever it is; a
179
+ * `cacheDir` configured inside a project was not, and a task whose write
180
+ * grant covered it could plant artifacts and index rows (L-24).
181
+ */
182
+ cacheDir?: string,
177
183
  ): Promise<SandboxRequest> {
178
184
  const depDirs = [
179
185
  path.join(node.projectDir, 'node_modules'),
@@ -192,9 +198,15 @@ export async function sandboxRequestFor(
192
198
  // Pre-create what the task said it will write — after the grants are
193
199
  // resolved, which refuses one that leaves the project through a link
194
200
  // before anything is created along it (item 1003).
195
- const walls = [...nested, path.join(workspaceRoot, '.git'), path.join(workspaceRoot, '.vx')].map(
196
- toRealPath,
197
- )
201
+ const walls = [
202
+ ...nested,
203
+ path.join(workspaceRoot, '.git'),
204
+ path.join(workspaceRoot, '.vx'),
205
+ ...(cacheDir !== undefined &&
206
+ toRealPath(cacheDir).startsWith(toRealPath(workspaceRoot) + path.sep)
207
+ ? [cacheDir]
208
+ : []),
209
+ ].map(toRealPath)
198
210
  const config = wallOff(
199
211
  resolveSandboxConfig(sandbox, node.projectDir, walls),
200
212
  workspaceRoot,
@@ -243,7 +243,20 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
243
243
  export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
244
244
  const projects: GeneratedProject[] = []
245
245
  const hookMemo = new Map<string, boolean>()
246
+ // The workspace root among members: its scripts run the workspace
247
+ // (`npm run build --workspaces`, `pnpm -r build`), and a root task made
248
+ // of one ran every member's build again under `--all`. Since D-39 a
249
+ // hand-written root config makes the root a project, and `--force`
250
+ // replaced it so (D-45). A lone package is its repo's project and maps.
251
+ const root = metas.length > 1 ? workspaceRootOf(metas) : undefined
252
+ const notes: string[] = []
253
+ if (root !== undefined) {
254
+ notes.push(
255
+ `${root.name} (the workspace root) not mapped: its scripts run the workspace; declare its own tasks in its vx.config by hand`,
256
+ )
257
+ }
246
258
  for (const meta of metas) {
259
+ if (meta === root) continue
247
260
  const scripts = scriptsOf(meta)
248
261
  const runsHooks = runsScriptHooks(meta.dir, hookMemo)
249
262
  const runnable = Object.keys(scripts).filter(
@@ -350,6 +363,13 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
350
363
  ],
351
364
  projects,
352
365
  extraFiles: [],
353
- notes: [],
366
+ notes,
354
367
  }
355
368
  }
369
+
370
+ /** The member whose directory holds every other member's, if one does. */
371
+ function workspaceRootOf(metas: readonly ProjectMeta[]): ProjectMeta | undefined {
372
+ const outer = metas.reduce((a, b) => (b.dir.length < a.dir.length ? b : a))
373
+ const prefix = outer.dir.endsWith(path.sep) ? outer.dir : outer.dir + path.sep
374
+ return metas.every((m) => m === outer || m.dir.startsWith(prefix)) ? outer : undefined
375
+ }