@vzn/vx 0.0.591 → 0.0.594

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 (55) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/cache/archive.ts +16 -5
  4. package/src/cache/cache.ts +41 -11
  5. package/src/cache/git-inputs.ts +14 -9
  6. package/src/cache/inputs.ts +47 -17
  7. package/src/cache/tar-stream.ts +3 -1
  8. package/src/cli/cache.ts +3 -5
  9. package/src/cli/foreign-flags.ts +33 -4
  10. package/src/cli/help.ts +22 -5
  11. package/src/cli/info.ts +4 -6
  12. package/src/cli/init.ts +3 -3
  13. package/src/cli/last.ts +49 -20
  14. package/src/cli/lock.ts +3 -3
  15. package/src/cli/run.ts +65 -37
  16. package/src/cli/select.ts +14 -2
  17. package/src/cli/show.ts +5 -6
  18. package/src/cli/upgrade.ts +2 -3
  19. package/src/cli/watch-filter.ts +7 -6
  20. package/src/cli/watch-fs.ts +17 -1
  21. package/src/cli/watch.ts +22 -4
  22. package/src/cli/why.ts +4 -13
  23. package/src/cli/workspace-config.ts +1 -1
  24. package/src/exec/env.ts +3 -5
  25. package/src/exec/runner.ts +28 -7
  26. package/src/exec/sandbox-deny-scan.ts +2 -2
  27. package/src/exec/sandbox-runtime.ts +69 -19
  28. package/src/exec/sandbox-violations.ts +23 -9
  29. package/src/graph/scheduler.ts +13 -3
  30. package/src/graph/task-graph.ts +3 -2
  31. package/src/index.ts +3 -0
  32. package/src/orchestrator/affected-tasks.ts +34 -10
  33. package/src/orchestrator/doctor.ts +2 -1
  34. package/src/orchestrator/execute-task.ts +14 -3
  35. package/src/orchestrator/hit-restore.ts +3 -2
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/lockfile-claim.ts +2 -3
  38. package/src/orchestrator/logger.ts +4 -1
  39. package/src/orchestrator/miss-save.ts +10 -3
  40. package/src/orchestrator/placement.ts +17 -3
  41. package/src/orchestrator/plugin-host.ts +16 -0
  42. package/src/orchestrator/plugin.ts +9 -13
  43. package/src/orchestrator/projects.ts +18 -9
  44. package/src/orchestrator/run-context.ts +2 -2
  45. package/src/orchestrator/run-lock.ts +14 -4
  46. package/src/orchestrator/run.ts +42 -8
  47. package/src/orchestrator/sandbox-request.ts +6 -10
  48. package/src/workspace/affected.ts +17 -11
  49. package/src/workspace/config-eval.ts +4 -0
  50. package/src/workspace/config-imports.ts +1 -1
  51. package/src/workspace/config-schema.ts +2 -7
  52. package/src/workspace/filter.ts +5 -4
  53. package/src/workspace/migrate-scripts.ts +22 -5
  54. package/src/workspace/project-loader.ts +17 -2
  55. package/src/workspace/workspace.ts +40 -4
@@ -17,7 +17,14 @@ import {
17
17
  WORKSPACE_OUTPUT_PREFIX,
18
18
  } from '../cache/index.js'
19
19
  import type { TaskNode } from '../graph/index.js'
20
- import { asTrees, span, staticPrefix, taskGlob, wholeSubtreePrefixes } from '../util/index.js'
20
+ import {
21
+ asTrees,
22
+ relPosix,
23
+ span,
24
+ staticPrefix,
25
+ taskGlob,
26
+ wholeSubtreePrefixes,
27
+ } from '../util/index.js'
21
28
  import type { Logger } from './logger.js'
22
29
  import type { TaskInputComponent } from './task-hash.js'
23
30
 
@@ -272,7 +279,7 @@ function markWritten(
272
279
  if (outputFiles.length > 0) {
273
280
  a.gitFilesCache?.markOutputsChanged(
274
281
  projectDir,
275
- outputFiles.map((p) => path.relative(projectDir, p).split(path.sep).join('/')),
282
+ outputFiles.map((p) => relPosix(projectDir, p)),
276
283
  )
277
284
  }
278
285
  // Declared workspace outputs may have landed inside OTHER projects' dirs
@@ -281,7 +288,7 @@ function markWritten(
281
288
  if (wsOutputFiles.length > 0) {
282
289
  a.gitFilesCache?.markWorkspaceOutputsChanged(
283
290
  a.workspaceRoot,
284
- wsOutputFiles.map((f) => path.relative(a.workspaceRoot, f).split(path.sep).join('/')),
291
+ wsOutputFiles.map((f) => relPosix(a.workspaceRoot, f)),
285
292
  )
286
293
  }
287
294
  // The workspace-wide partition (when one exists) spans this project's
@@ -281,13 +281,27 @@ export function hasPooledExecutor(executors: readonly TaskExecutor[]): boolean {
281
281
  return executors.some((e) => e.capacity !== undefined)
282
282
  }
283
283
 
284
+ /**
285
+ * Each pooled executor's pool, under a name no other executor's pool
286
+ * shares: the scheduler counts a pool by its name, and one package declared
287
+ * twice (`@vzn/vx-reapi` against two clusters) names both executors alike,
288
+ * so their two capacities were one. A repeat takes `#2`, `#3` in placement
289
+ * order.
290
+ */
284
291
  export function poolOfPlacement(
285
292
  placements: Placements,
286
293
  ): (id: string) => { name: string; capacity: number } | undefined {
294
+ const pools = new Map<TaskExecutor, { name: string; capacity: number }>()
295
+ const taken = new Set<string>()
296
+ for (const executor of placements.executors.values()) {
297
+ if (executor.capacity === undefined || pools.has(executor)) continue
298
+ let name = executor.name
299
+ for (let n = 2; taken.has(name); n++) name = `${executor.name}#${n}`
300
+ taken.add(name)
301
+ pools.set(executor, { name, capacity: executor.capacity })
302
+ }
287
303
  return (id) => {
288
304
  const executor = placements.executors.get(id)
289
- return executor?.capacity === undefined
290
- ? undefined
291
- : { name: executor.name, capacity: executor.capacity }
305
+ return executor === undefined ? undefined : pools.get(executor)
292
306
  }
293
307
  }
@@ -7,6 +7,7 @@
7
7
  //
8
8
  // See docs/design/pipeline-2026-09.md.
9
9
 
10
+ import path from 'node:path'
10
11
  import { ChainedCache, type CacheLayer } from '../cache/index.js'
11
12
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
12
13
  import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
@@ -207,6 +208,21 @@ function checkNodeShapes(nodes: Map<string, TaskNode>): void {
207
208
  if (!Array.isArray(deps)) {
208
209
  throw new Error(`${key}'s deps is ${what(deps)}, not an array of task ids`)
209
210
  }
211
+ // A node a hook adds is read as it wrote it: one with no `projectDir`
212
+ // ran as `internal error in a#x: TypeError: The "path" property must
213
+ // be of type string`, and a relative one keyed and ran against
214
+ // whatever directory vx was started in.
215
+ const fields = node as { projectName?: unknown; taskName?: unknown; projectDir?: unknown }
216
+ const shown = (v: unknown): string => (typeof v === 'string' ? JSON.stringify(v) : what(v))
217
+ for (const field of ['projectName', 'taskName'] as const) {
218
+ if (typeof fields[field] !== 'string' || fields[field] === '') {
219
+ throw new Error(`${key}'s ${field} is ${shown(fields[field])}, not a name`)
220
+ }
221
+ }
222
+ const dir = fields.projectDir
223
+ if (typeof dir !== 'string' || !path.isAbsolute(dir)) {
224
+ throw new Error(`${key}'s projectDir is ${shown(dir)}, not an absolute path`)
225
+ }
210
226
  }
211
227
  }
212
228
 
@@ -227,7 +227,14 @@ interface BaseContext {
227
227
  warn(message: string): void
228
228
  }
229
229
 
230
- export interface PluginSetupContext extends BaseContext {}
230
+ /** What `setup` receives: the run's lifecycle, observe-only. */
231
+ export interface PluginSetupContext extends BaseContext {
232
+ /**
233
+ * Subscribe a lifecycle handler; it leaves with the run. A throw or a
234
+ * rejection warns once and switches the plugin's handlers off for the run.
235
+ */
236
+ on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
237
+ }
231
238
 
232
239
  /** One CLI verb contributed by a plugin. */
233
240
  export interface PluginCommand {
@@ -357,23 +364,12 @@ export interface ExecutorContext extends BaseContext {
357
364
  readonly concurrency: number
358
365
  }
359
366
 
360
- export interface PluginContext {
361
- /** Where the workspace lives on disk. */
362
- readonly workspaceRoot: string
363
- /** Where vx's cache lives — read-only as far as the plugin is concerned. */
364
- readonly cacheDir: string
365
- /** Funnel warnings into the run:status channel, as every hook's context does. */
366
- warn(message: string): void
367
+ export interface PluginContext extends PluginSetupContext {
367
368
  /**
368
369
  * The run event bus. A plugin can subscribe directly if its needs exceed
369
370
  * the hooks; the subscription ends with the run, as a hook's does.
370
371
  */
371
372
  readonly bus: EventBus
372
- /**
373
- * Convenience: register a typed handler keyed off `RunEvent.kind`.
374
- * Multiple hooks can chain via repeated calls.
375
- */
376
- on<K extends PluginHookName>(hook: K, handler: PluginHookHandlers[K]): void
377
373
  }
378
374
 
379
375
  export type PluginHookName =
@@ -43,16 +43,25 @@ export async function loadWorkspacePlugins(
43
43
  workspaceRoot: string,
44
44
  warn: (message: string) => void,
45
45
  ): Promise<{ workspaceConfig: WorkspaceConfig | null; plugins: readonly VxPlugin[] }> {
46
- const workspaceConfig = await loadWorkspaceConfig(workspaceRoot)
47
- const plugins = (workspaceConfig?.plugins ?? []) as readonly VxPlugin[]
48
- if (workspaceConfig !== null && hasHook(plugins, 'config')) {
49
- // Re-validated after each plugin, as the `project` stage is: an edit
50
- // the loader would refuse from a user (`concurrency: -3`) hung the run,
51
- // and `cacheDir: 42` reached `path.resolve` as a TypeError.
52
- await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
53
- validateWorkspace(workspaceConfig, `vx.workspace (after plugin '${plugin.name}')`),
54
- )
46
+ const loaded = await loadWorkspaceConfig(workspaceRoot)
47
+ const plugins = (loaded?.plugins ?? []) as readonly VxPlugin[]
48
+ if (loaded === null || !hasHook(plugins, 'config')) return { workspaceConfig: loaded, plugins }
49
+ // Bun keeps one module per specifier, so the file's export is one object
50
+ // per process: the CLI's selection pass, the run and each `vx watch`
51
+ // cycle handed the hooks what the last load's hooks had edited
52
+ // (`concurrency *= 2` doubled per load). The hooks edit a copy; the file
53
+ // holds data and plugins, and validation refused anything else.
54
+ const { plugins: list, ...data } = loaded
55
+ const workspaceConfig: WorkspaceConfig = {
56
+ ...structuredClone(data),
57
+ ...(list !== undefined ? { plugins: [...list] } : {}),
55
58
  }
59
+ // Re-validated after each plugin, as the `project` stage is: an edit
60
+ // the loader would refuse from a user (`concurrency: -3`) hung the run,
61
+ // and `cacheDir: 42` reached `path.resolve` as a TypeError.
62
+ await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn }, (plugin) =>
63
+ validateWorkspace(workspaceConfig, `vx.workspace (after plugin '${plugin.name}')`),
64
+ )
56
65
  return { workspaceConfig, plugins }
57
66
  }
58
67
 
@@ -13,7 +13,7 @@
13
13
  import fs from 'node:fs'
14
14
  import os from 'node:os'
15
15
  import path from 'node:path'
16
- import { executablePath, ulid, xxh3hex } from '../util/index.js'
16
+ import { executablePath, relPosix, ulid, xxh3hex } from '../util/index.js'
17
17
 
18
18
  export interface GitContext {
19
19
  /** `git rev-parse HEAD`, or null outside a repo / on failure. */
@@ -410,7 +410,7 @@ function workTreePath(root: string): string | undefined {
410
410
  const abs = path.resolve(root)
411
411
  for (let dir = abs; ;) {
412
412
  if (fs.existsSync(path.join(dir, '.git'))) {
413
- return path.relative(dir, abs).split(path.sep).join('/') || '.'
413
+ return relPosix(dir, abs) || '.'
414
414
  }
415
415
  const up = path.dirname(dir)
416
416
  if (up === dir) return undefined
@@ -41,8 +41,8 @@
41
41
  // A holder's pid can come back: the temp directory outlives a container
42
42
  // restart, and the restarted container's vx got the dead run's pid (1) and
43
43
  // waited for itself forever (nx#36473 reproduced on vx, 2026-09-24). So a
44
- // lock naming OUR pid is stale — a run of this process would be in
45
- // `heldHere` — and on Linux the entry also carries the holder's start
44
+ // lock naming OUR pid is stale unless this process placed that entry and
45
+ // has not left it (`placed`) — and on Linux the entry also carries the holder's start
46
46
  // time, so a pid another process now wears is stale too. Where the lock
47
47
  // cannot be made or read for any reason but "held" (a temp directory this
48
48
  // user cannot write), the run says so once and proceeds unlocked: the
@@ -71,6 +71,12 @@ import { isTmpdirRefusal, procfsIsOwn, TMPDIR_HINT, xxh3hex } from '../util/inde
71
71
  const heldHere = new Map<string, number>()
72
72
  /** The entry this process's taking of each held lock wrote. */
73
73
  const takers = new Map<string, string>()
74
+ /**
75
+ * Every entry this process has placed and not yet left, listed before its
76
+ * rename: a concurrent taking here reads it before `heldHere` names it, and
77
+ * must wait for it, not reclaim it as a dead run of this pid.
78
+ */
79
+ const placed = new Set<string>()
74
80
 
75
81
  /**
76
82
  * A signal exit is `process.exit` (signals.ts): no finally runs and nothing
@@ -248,7 +254,7 @@ function nextEntry(): string {
248
254
  * procfs once per holder, not once per poll.
249
255
  */
250
256
  function holderLive(h: Holder, sameAsLast: boolean): boolean {
251
- if (h.pid === process.pid) return false
257
+ if (h.pid === process.pid) return placed.has(h.entry)
252
258
  if (!alive(h.pid)) return false
253
259
  if (h.start === null || sameAsLast) return true
254
260
  const now = startTime(h.pid)
@@ -278,11 +284,13 @@ async function place(lockDir: string): Promise<string | null> {
278
284
  // directory a killed pid 1 left, and refuse every run after it.
279
285
  const staging = `${lockDir}.${entry}.${Math.random().toString(36).slice(2)}`
280
286
  await mkdir(staging)
287
+ placed.add(entry)
281
288
  try {
282
289
  await writeFile(path.join(staging, entry), '')
283
290
  await rename(staging, lockDir)
284
291
  return entry
285
292
  } catch (err) {
293
+ placed.delete(entry)
286
294
  await rm(staging, { recursive: true, force: true })
287
295
  const code = (err as NodeJS.ErrnoException).code
288
296
  if (code === 'ENOTEMPTY' || code === 'EEXIST') return null
@@ -327,6 +335,8 @@ export async function acquireRunLock(
327
335
  }
328
336
  heldHere.delete(lockDir)
329
337
  const taker = takers.get(lockDir)
338
+ // Leaving: a taking here from now on may reclaim it, as before its unlink.
339
+ if (taker !== undefined) placed.delete(taker)
330
340
  // The unlink of this taking's own entry is the proof no later run
331
341
  // reclaimed the lock: had one, the entry is gone and nothing else is
332
342
  // touched. A run that joined another's taking in this process leaves
@@ -388,6 +398,6 @@ export async function acquireRunLock(
388
398
  opts.log(`[vx] waiting for another vx run (pid ${h.pid}) on this workspace to finish…`)
389
399
  }
390
400
  }
391
- await new Promise((r) => setTimeout(r, POLL_MS))
401
+ await Bun.sleep(POLL_MS)
392
402
  }
393
403
  }
@@ -4,6 +4,7 @@
4
4
 
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
6
  import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
+ import { realpathSync } from 'node:fs'
7
8
  import path from 'node:path'
8
9
  import { Cache, type CacheLayer, type CachePolicy, stopRuntimeProbes } from '../cache/index.js'
9
10
  import { VERSION } from '../version.js'
@@ -18,6 +19,7 @@ import { resolveDownloadModes } from './download-policy.js'
18
19
  import type { TaskExecutor } from '../exec/index.js'
19
20
  import {
20
21
  deadServerBehind,
22
+ declaredTask,
21
23
  isGroupTask,
22
24
  markSurfacedDeps,
23
25
  runGraph,
@@ -366,8 +368,10 @@ async function runOnBus(
366
368
  // its tasks escape the schedule, the concurrency budget and this task's
367
369
  // cache key. The markers `taskEnv` sets on every child (exec/env.ts)
368
370
  // name the task; the check is on the root so both shapes are caught.
371
+ // Compared canonical: a root reached through a symlink (`run({ cwd })`,
372
+ // macOS's /var/folders) and the inner vx's realpath'd cwd are one tree.
369
373
  const outerRoot = process.env[VX_RUN_WORKSPACE_ENV]
370
- if (outerRoot !== undefined && path.resolve(outerRoot) === path.resolve(prepared.workspaceRoot)) {
374
+ if (outerRoot !== undefined && canonical(outerRoot) === canonical(prepared.workspaceRoot)) {
371
375
  await teardown()
372
376
  prepared.cache.close()
373
377
  throw new UserError(
@@ -395,7 +399,8 @@ async function runOnBus(
395
399
  const reachedNone =
396
400
  options.affected !== undefined &&
397
401
  [...prepared.projects.values()].some(
398
- (p) => inScope.has(p.name) && options.tasks.some((t) => p.config.tasks?.[t] !== undefined),
402
+ (p) =>
403
+ inScope.has(p.name) && options.tasks.some((t) => declaredTask(p.config, t) !== undefined),
399
404
  )
400
405
  log.status(
401
406
  reachedNone
@@ -427,7 +432,7 @@ async function runOnBus(
427
432
  let telemetry: TelemetryHandle | undefined
428
433
  try {
429
434
  disposePlugins = await installPlugins({
430
- plugins: prepared.plugins as never,
435
+ plugins: prepared.plugins,
431
436
  bus,
432
437
  workspaceRoot: prepared.workspaceRoot,
433
438
  cacheDir: prepared.cacheDir,
@@ -930,6 +935,9 @@ async function runOnBus(
930
935
  const explainMiss =
931
936
  telemetry === undefined ? undefined : createMissExplainer(prepared.localCache.dbHandle())
932
937
  const lateProbes = probesAfterWrites(nodes, workspaceRoot)
938
+ // The scheduler's fail-fast stop, as the retry loop of a task already
939
+ // in flight sees it.
940
+ const failFast = new AbortController()
933
941
  const buildExecuteArgs = (node: TaskNode, upstream: TaskOutcome[], reuseProbe = true) => {
934
942
  const probe = reuseProbe ? shortCircuit.preProbed.get(node.id) : undefined
935
943
  const upfrontKey = shortCircuit.uncachedKeys.get(node.id)
@@ -977,6 +985,7 @@ async function runOnBus(
977
985
  deferSave: saveLane.defer,
978
986
  deferredSaves,
979
987
  stopSignal: stopRun.signal,
988
+ failFast: failFast.signal,
980
989
  }
981
990
  }
982
991
 
@@ -1025,6 +1034,7 @@ async function runOnBus(
1025
1034
  ...(holders.size > 0 ? { exclusive: holders } : {}),
1026
1035
  ...(options.continueMode !== undefined ? { continueMode: options.continueMode } : {}),
1027
1036
  serverDied,
1037
+ onFailFast: () => failFast.abort(),
1028
1038
  signal: stopRun.signal,
1029
1039
  onStart: (node) => {
1030
1040
  log.taskStart?.(node)
@@ -1371,9 +1381,20 @@ async function runOnBus(
1371
1381
  }
1372
1382
  }
1373
1383
  if (keepAlive.children.length > 0) {
1374
- const first = await Promise.race(
1375
- keepAlive.children.map((c, i) => c.exited.then((code) => ({ code, i }))),
1376
- )
1384
+ // A server kept only as a dependency that exits 0 is a daemon that
1385
+ // forked and returned (schema.md), not the end of the session: a
1386
+ // `db: 'docker compose up -d'` under `app#dev` stopped the dev server
1387
+ // right after the summary (WD-3). It ends the wait only as the last.
1388
+ const first = await new Promise<{ code: number; i: number }>((resolve) => {
1389
+ let left = keepAlive.children.length
1390
+ keepAlive.children.forEach((c, i) => {
1391
+ void c.exited.then((code) => {
1392
+ left--
1393
+ const n = keepAlive.nodes[i]!
1394
+ if (code !== 0 || n.requested || n.surfaced === true || left === 0) resolve({ code, i })
1395
+ })
1396
+ })
1397
+ })
1377
1398
  const node = keepAlive.nodes[first.i]!
1378
1399
  const others = keepAlive.nodes.length - 1
1379
1400
  // Not when the run was stopped: the server ended because the user
@@ -1387,7 +1408,11 @@ async function runOnBus(
1387
1408
  : ''),
1388
1409
  )
1389
1410
  }
1390
- await terminateChildren(() => keepAlive.children)
1411
+ // Under a stop the abort is already taking them down with the stop's
1412
+ // signal; a second SIGTERM in its grace cut short the graceful
1413
+ // shutdown of a server that reads it as "quit now" (WD-16).
1414
+ if (stopRun.signal.aborted) await aborting?.catch(() => {})
1415
+ else await terminateChildren(() => keepAlive.children)
1391
1416
  // The server that ended the session on its own, not cleanly, failed:
1392
1417
  // the rewritten summary said `ok: false` over every task `success`
1393
1418
  // and `failed: 0`, and so did the outcomes `--report` renders (C-53).
@@ -1652,7 +1677,7 @@ export function nxProjectTarget(
1652
1677
  if (colon <= 0 || spec.includes('#')) return undefined
1653
1678
  const [typed, task] = [spec.slice(0, colon), spec.slice(colon + 1)]
1654
1679
  const project = projectNamed(typed, projects)
1655
- return project === undefined || projects.get(project)?.config.tasks?.[task] === undefined
1680
+ return project === undefined || declaredTask(projects.get(project)?.config, task) === undefined
1656
1681
  ? undefined
1657
1682
  : `${project}#${task}`
1658
1683
  }
@@ -1692,3 +1717,12 @@ function flakyCandidates(outcomes: readonly TaskOutcome[]): FlakyCandidate[] {
1692
1717
  }
1693
1718
  return out
1694
1719
  }
1720
+
1721
+ /** `dir` with its links resolved; as written when it is gone (a stale marker). */
1722
+ function canonical(dir: string): string {
1723
+ try {
1724
+ return realpathSync(dir)
1725
+ } catch {
1726
+ return path.resolve(dir)
1727
+ }
1728
+ }
@@ -24,7 +24,7 @@ import {
24
24
  type SandboxViolation,
25
25
  } from '../exec/index.js'
26
26
  import type { TaskNode } from '../graph/index.js'
27
- import { grantPrefix, UserError } from '../util/index.js'
27
+ import { grantPrefix, relPosix, UserError } from '../util/index.js'
28
28
  import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
29
29
 
30
30
  /**
@@ -350,6 +350,7 @@ async function linkedDeps(
350
350
  keyed: ReadonlySet<string> | undefined,
351
351
  ): Promise<{ granted: string[]; withheld: WithheldLink[] }> {
352
352
  const [self, root] = await Promise.all([realpath(projectDir), realpath(workspaceRoot)])
353
+ const realDirs = dirs.map(toRealPath)
353
354
  const scan = async (dir: string, scope: string): Promise<Array<[string, string, string]>> => {
354
355
  const found: Array<[target: string, link: string, name: string]> = []
355
356
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
@@ -359,7 +360,7 @@ async function linkedDeps(
359
360
  const target = await realpath(full).catch(() => undefined)
360
361
  if (
361
362
  target !== undefined &&
362
- !dirs.some((d) => atOrUnder(target, d)) &&
363
+ !realDirs.some((d) => atOrUnder(target, d)) &&
363
364
  !atOrUnder(self, target)
364
365
  ) {
365
366
  found.push([target, full, scope + e.name])
@@ -388,8 +389,8 @@ async function linkedDeps(
388
389
  withheld.push({
389
390
  dir: target,
390
391
  name,
391
- target: posixRel(root, target),
392
- link: posixRel(workspaceRoot, link),
392
+ target: relPosix(root, target),
393
+ link: relPosix(workspaceRoot, link),
393
394
  })
394
395
  }
395
396
  return { granted, withheld }
@@ -421,11 +422,6 @@ export function reachedWithheld(
421
422
  )
422
423
  }
423
424
 
424
- /** `p` relative to `from`, with `/` separators. */
425
- function posixRel(from: string, p: string): string {
426
- return path.relative(from, p).split(path.sep).join('/')
427
- }
428
-
429
425
  /**
430
426
  * Ensure each declared write path exists on the host as either an empty
431
427
  * file (a literal path) or a directory (a glob's static prefix, or a
@@ -565,7 +561,7 @@ export function placeholderSweeper(placeholders: readonly Placeholder[]): () =>
565
561
  * never reddens a pass and never buries a real denial.
566
562
  */
567
563
  export function untouchedPlaceholderLine(projectDir: string, placeholder: string): string {
568
- const rel = path.relative(projectDir, placeholder).split(path.sep).join('/')
564
+ const rel = relPosix(projectDir, placeholder)
569
565
  return (
570
566
  `vx: the sandbox write grant \`${rel}\` named nothing on disk, so vx bound it as an empty ` +
571
567
  `file, which the task never wrote (removed again). If the task creates a directory there ` +
@@ -19,7 +19,7 @@ import {
19
19
  relPosix,
20
20
  } from '../util/index.js'
21
21
  import { LOCKFILE_NAME } from './lockfile.js'
22
- import { configImportOwners } from './config-imports.js'
22
+ import { configImportOwners, realpathOr } from './config-imports.js'
23
23
  import { configImports } from './config-cache.js'
24
24
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
25
25
  import { bunPatchFiles, WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
@@ -570,7 +570,7 @@ async function workspaceConfigChanged(
570
570
  if (config === undefined) return false
571
571
  const root = realpathSync(workspaceRoot)
572
572
  for (const file of await configImports(config)) {
573
- if (set.has(path.relative(root, file).split(path.sep).join('/'))) return true
573
+ if (set.has(relPosix(root, file))) return true
574
574
  }
575
575
  return false
576
576
  }
@@ -613,7 +613,10 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
613
613
  /** Run a NUL-separated path-listing git command from the workspace root. */
614
614
  async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
615
615
  const proc = spawnGit([...cmd], workspaceRoot)
616
- const stdout = await new Response(proc.stdout).text()
616
+ // `Response.text()` strips a leading U+FEFF, the first path's own.
617
+ const stdout = new TextDecoder('utf-8', { ignoreBOM: true }).decode(
618
+ await new Response(proc.stdout).bytes(),
619
+ )
617
620
  const stderr = await new Response(proc.stderr).text()
618
621
  const exit = await proc.exited
619
622
  if (exit !== 0) {
@@ -755,6 +758,17 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
755
758
  const sha = out.trim()
756
759
  if (exit === 0 && sha.length > 0) return sha
757
760
  await verifyRef(workspaceRoot, ref)
761
+ // `<rev>:<path>` names the tree (or blob) at <path>: diffed against the
762
+ // working tree, its paths miss the <path>/ prefix, and `develop:pkgs`
763
+ // selected projects nothing had changed, green. A root tree
764
+ // (`develop^{tree}`, the empty tree) diffs right and stays a base.
765
+ const sub = /^([^:]*):(?!\/)(.+)$/s.exec(ref)
766
+ if (sub !== null) {
767
+ throw new UserError(
768
+ `git ref "${ref}" names what is at ${sub[2]}, not a commit: vx diffs the whole ` +
769
+ `workspace, so pass the commit alone ("${sub[1] || 'HEAD'}").`,
770
+ )
771
+ }
758
772
  return ref
759
773
  }
760
774
 
@@ -800,14 +814,6 @@ function isDirectory(abs: string): boolean {
800
814
  }
801
815
  }
802
816
 
803
- function realpathOr(p: string): string {
804
- try {
805
- return realpathSync(p)
806
- } catch {
807
- return p
808
- }
809
- }
810
-
811
817
  function projectsContaining(
812
818
  workspaceRoot: string,
813
819
  changedRelPaths: readonly string[],
@@ -292,7 +292,11 @@ function acquireWorker(): Worker {
292
292
  // config reports the same text it would from an in-process import.
293
293
  const err = new Error(msg.message) as Error & { position?: WorkerReply['position'] }
294
294
  err.name = msg.name
295
+ // A stackless throw stays stackless: the rebuilt Error's own stack names
296
+ // this file, and `configLoadError` reads a stack naming no file as Bun's
297
+ // JSON loader's.
295
298
  if (msg.stack !== null) err.stack = msg.stack
299
+ else delete err.stack
296
300
  if (msg.position !== null) err.position = msg.position
297
301
  p.reject(err)
298
302
  }
@@ -281,7 +281,7 @@ function tsconfigTarget(spec: string, fromDir: string, memo?: TsconfigMemo): str
281
281
  }
282
282
 
283
283
  /** A path as Bun names it: realpath'd when it exists. */
284
- function realpathOr(file: string): string {
284
+ export function realpathOr(file: string): string {
285
285
  try {
286
286
  return realpathSync(file)
287
287
  } catch {
@@ -28,6 +28,7 @@ import {
28
28
  asTrees,
29
29
  isLiteralPattern,
30
30
  } from '../util/index.js'
31
+ import { compileNameGlob } from './filter.js'
31
32
  import { nonJsonMessage, nonJsonPaths } from './json-data.js'
32
33
 
33
34
  // Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
@@ -1286,7 +1287,7 @@ function assertFilterNamesDeclaredDeps(
1286
1287
  ): void {
1287
1288
  const declared = (dependsOn ?? []).map(specForm)
1288
1289
  const matches = (pattern: string, name: string): boolean =>
1289
- pattern.includes('*') ? taskPatternRegExp(pattern).test(name) : pattern === name
1290
+ pattern.includes('*') ? compileNameGlob(pattern).test(name) : pattern === name
1290
1291
  const named = (f: SpecForm): boolean =>
1291
1292
  declared.some((d) => {
1292
1293
  if (!matches(d.task, f.task)) return false
@@ -1366,12 +1367,6 @@ export function taskNameProblem(name: string): string | null {
1366
1367
  : null
1367
1368
  }
1368
1369
 
1369
- /** The graph's `*`-only task glob (`compileTaskPattern`), mirrored: `*` is the sole metacharacter. */
1370
- function taskPatternRegExp(pattern: string): RegExp {
1371
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
1372
- return new RegExp(`^${escaped}$`)
1373
- }
1374
-
1375
1370
  /**
1376
1371
  * Refuse a non-empty glob list made up ENTIRELY of negations.
1377
1372
  *
@@ -20,7 +20,7 @@
20
20
  // given, the base set is "all projects" and excluded packages are removed.
21
21
 
22
22
  import path from 'node:path'
23
- import { BUN_GLOB_WILDCARDS, UserError } from '../util/index.js'
23
+ import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
24
24
  import type { PackageGraph } from './package-graph.js'
25
25
  import type { ProjectMeta } from './workspace.js'
26
26
 
@@ -180,7 +180,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
180
180
  // against the workspace root like the literal form.
181
181
  // A member glob, not a task glob: `Bun.Glob`'s alphabet, the class included.
182
182
  if (BUN_GLOB_WILDCARDS.test(pathForm)) {
183
- const rel = path.relative(workspaceRoot, matcher).split(path.sep).join('/')
183
+ const rel = relPosix(workspaceRoot, matcher)
184
184
  const glob = rel.replace(/\/+$/, '')
185
185
  pathGlob = new Bun.Glob(glob)
186
186
  if (glob.endsWith('/**')) pathGlobBase = new Bun.Glob(glob.slice(0, -3))
@@ -293,9 +293,10 @@ function matchSelector(
293
293
  * Compile a name pattern where `*` is the sole metacharacter and means "any
294
294
  * characters" — pnpm's rule. A path glob would treat `/` as a separator, so
295
295
  * `*` could never cross the `@scope/` boundary: `--filter '*'` would select
296
- * only UNSCOPED packages, and `*core*` would match nothing at all.
296
+ * only UNSCOPED packages, and `*core*` would match nothing at all. Task
297
+ * patterns share it: it mirrors the graph's `compileTaskPattern`.
297
298
  */
298
- function compileNameGlob(pattern: string): RegExp {
299
+ export function compileNameGlob(pattern: string): RegExp {
299
300
  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
300
301
  return new RegExp(`^${escaped}$`)
301
302
  }
@@ -41,6 +41,7 @@ import {
41
41
  cacheTodo,
42
42
  pruneOrphanPersistentNotes,
43
43
  } from './migration.js'
44
+ import { relPosix } from '../util/index.js'
44
45
 
45
46
  // `lint` is not here: a linter reads sources, and an edge to `build`
46
47
  // serialises the two for nothing (the init walkthrough, 2026-09-04).
@@ -127,12 +128,26 @@ function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
127
128
  : manager === 'npm'
128
129
  ? !set('ignore-scripts', 'true')
129
130
  : manager === 'pnpm'
130
- ? !set('enable-pre-post-scripts', 'false') &&
131
- !/^enablePrePostScripts:\s*false\s*$/m.test(read('pnpm-workspace.yaml') ?? '')
131
+ ? !set('enable-pre-post-scripts', 'false') && !pnpmSkipsHooks(read('pnpm-workspace.yaml'))
132
132
  : true
133
133
  return runs ? manager : null
134
134
  }
135
135
 
136
+ /**
137
+ * `enablePrePostScripts: false` in `pnpm-workspace.yaml`, read as pnpm reads
138
+ * it, as YAML: a line regex missed `false # hooks off` and a flow mapping,
139
+ * which pnpm 10 honours, and folded hooks pnpm does not run.
140
+ */
141
+ function pnpmSkipsHooks(yaml: string | undefined): boolean {
142
+ if (yaml === undefined) return false
143
+ try {
144
+ const parsed = Bun.YAML.parse(yaml) as { enablePrePostScripts?: unknown } | null
145
+ return parsed?.enablePrePostScripts === false
146
+ } catch {
147
+ return false
148
+ }
149
+ }
150
+
136
151
  /** The package manager that owns a directory, and the directory that says so. */
137
152
  interface Owner {
138
153
  manager: string
@@ -155,8 +170,10 @@ function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
155
170
  }
156
171
  let manager: string | undefined
157
172
  try {
158
- const pm = (JSON.parse(read('package.json') ?? '') as { packageManager?: unknown })
159
- .packageManager
173
+ // A byte-order mark is stripped, as npm, pnpm and discovery strip it:
174
+ // JSON.parse threw on one and the field went unread.
175
+ const manifest = (read('package.json') ?? '').replace(/^\uFEFF/, '')
176
+ const pm = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
160
177
  // A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
161
178
  // hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
162
179
  // claim nothing had checked (D-96).
@@ -789,7 +806,7 @@ export function migrateScripts(
789
806
  } else if (clash !== undefined && outsideDir !== undefined && unnamedMaps().length > 0) {
790
807
  const would = unnamedMaps()
791
808
  notes.push(
792
- `${clash.name} (the workspace root) not mapped: ${path.relative(outsideDir, clash.dir).split(path.sep).join('/')} has the same "name", and vx names a project by it; rename the root's and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
809
+ `${clash.name} (the workspace root) not mapped: ${relPosix(outsideDir, clash.dir)} has the same "name", and vx names a project by it; rename the root's and run \`vx init\` again to map ${would.length} of its scripts (${would.slice(0, 8).join(', ')}${would.length > 8 ? ', …' : ''})`,
793
810
  )
794
811
  } else if (rootName === 'package.json' && outsideDir !== undefined && unnamedMaps().length > 0) {
795
812
  // react's nameless root: "its scripts run the workspace" was not why,