@vzn/vx 0.0.359 → 0.0.361

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.359",
3
+ "version": "0.0.361",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.359",
25
- "@vzn/vx-linux-arm64": "0.0.359",
26
- "@vzn/vx-darwin-x64": "0.0.359",
27
- "@vzn/vx-darwin-arm64": "0.0.359"
24
+ "@vzn/vx-linux-x64": "0.0.361",
25
+ "@vzn/vx-linux-arm64": "0.0.361",
26
+ "@vzn/vx-darwin-x64": "0.0.361",
27
+ "@vzn/vx-darwin-arm64": "0.0.361"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -6,10 +6,10 @@
6
6
  // task declared. Nothing here reads a config or applies a boundary.
7
7
 
8
8
  import path from 'node:path'
9
- import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs'
9
+ import { existsSync, lstatSync, readdirSync, readFileSync, realpathSync } from 'node:fs'
10
10
  import { UserError, executablePath, gitSpawnRefusal } from '../util/index.js'
11
11
 
12
- /** Three facts of the repository a directory is in, from one `git rev-parse`. */
12
+ /** Three facts of the repository a directory is in: read off the disk, or one `git rev-parse`. */
13
13
  export interface RepoFacts {
14
14
  /** `--show-prefix`: the directory's path below the worktree root, `''` at the root. */
15
15
  prefix: string
@@ -32,6 +32,11 @@ const repoFactsMemo = new Map<string, RepoFacts>()
32
32
  export function repoFacts(dir: string): RepoFacts | null {
33
33
  const hit = repoFactsMemo.get(dir)
34
34
  if (hit !== undefined) return hit
35
+ const read = repoFactsFromDisk(dir)
36
+ if (read !== undefined) {
37
+ repoFactsMemo.set(dir, read)
38
+ return read
39
+ }
35
40
  let proc
36
41
  try {
37
42
  proc = Bun.spawnSync({
@@ -66,6 +71,75 @@ export function repoFacts(dir: string): RepoFacts | null {
66
71
  return facts
67
72
  }
68
73
 
74
+ /** Variables that move where git finds the repository: with any set, git is asked. */
75
+ const GIT_LOCATION_ENV = [
76
+ 'GIT_DIR',
77
+ 'GIT_WORK_TREE',
78
+ 'GIT_COMMON_DIR',
79
+ 'GIT_CEILING_DIRECTORIES',
80
+ 'GIT_DISCOVERY_ACROSS_FILESYSTEM',
81
+ ]
82
+
83
+ /**
84
+ * `repoFacts` for the plain case, read off the disk: the nearest `.git`
85
+ * DIRECTORY above `dir` (resolved as git resolves its cwd), on the same
86
+ * file system, holding a `HEAD`, and its config's object format. The
87
+ * spawn it saves was ~3 ms of every run, synchronous. Undefined — ask
88
+ * git — for anything else: a `.git` file (a linked worktree, a
89
+ * submodule), a location variable, an include in the config, a format it
90
+ * cannot read, no repository at all.
91
+ */
92
+ function repoFactsFromDisk(dir: string): RepoFacts | undefined {
93
+ if (GIT_LOCATION_ENV.some((name) => process.env[name] !== undefined)) return undefined
94
+ try {
95
+ const real = realpathSync(dir)
96
+ const dev = lstatSync(real).dev
97
+ for (let at = real; ;) {
98
+ const dotGit = path.join(at, '.git')
99
+ const st = lstatSync(dotGit, { throwIfNoEntry: false })
100
+ if (st !== undefined) {
101
+ if (!st.isDirectory() || !existsSync(path.join(dotGit, 'HEAD'))) return undefined
102
+ const objectFormat = configObjectFormat(readFileSync(path.join(dotGit, 'config'), 'utf8'))
103
+ if (objectFormat === undefined) return undefined
104
+ const below = path.relative(at, real).split(path.sep).join('/')
105
+ return {
106
+ prefix: below === '' ? '' : `${below}/`,
107
+ commonDir: path.relative(real, dotGit).split(path.sep).join('/'),
108
+ objectFormat,
109
+ }
110
+ }
111
+ const parent = path.dirname(at)
112
+ if (parent === at || lstatSync(parent).dev !== dev) return undefined
113
+ at = parent
114
+ }
115
+ } catch {
116
+ return undefined
117
+ }
118
+ }
119
+
120
+ /** `[extensions] objectFormat` of a repository config, sha1 when unset; undefined when unsure. */
121
+ function configObjectFormat(config: string): 'sha1' | 'sha256' | undefined {
122
+ let section = ''
123
+ let format: 'sha1' | 'sha256' = 'sha1'
124
+ for (const raw of config.split('\n')) {
125
+ const line = raw.trim()
126
+ if (line === '' || line.startsWith('#') || line.startsWith(';')) continue
127
+ const head = /^\[\s*([A-Za-z0-9.-]+)/.exec(line)
128
+ if (head !== null) {
129
+ section = head[1]!.toLowerCase()
130
+ if (section === 'include' || section === 'includeif') return undefined
131
+ continue
132
+ }
133
+ if (section !== 'extensions') continue
134
+ const kv = /^([A-Za-z0-9-]+)\s*=\s*(.*)$/.exec(line)
135
+ if (kv === null || kv[1]!.toLowerCase() !== 'objectformat') continue
136
+ const value = kv[2]!.toLowerCase()
137
+ if (value === 'sha256' || value === 'sha1') format = value
138
+ else return undefined
139
+ }
140
+ return format
141
+ }
142
+
69
143
  export class GitFilesCache extends Map<string, readonly string[]> {
70
144
  private changed = new Map<string, string[]>()
71
145
  /**
@@ -1179,7 +1179,18 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1179
1179
  // claims as an input. See the refusal below for why this is not simply
1180
1180
  // honoured instead.
1181
1181
  const unmatchedLiterals = new Set(plan.literals)
1182
- // First pass: glob-filter to candidate absolute paths (no I/O).
1182
+ // First pass: glob-filter to candidate absolute paths (no I/O). Git
1183
+ // prints normalized relative paths, so under an absolute, normalized
1184
+ // project dir a join is a concatenation: `path.resolve` per file was
1185
+ // ~27 ms of a 900-task, 12,000-file warm run (I-30).
1186
+ const base =
1187
+ path.sep === '/' &&
1188
+ path.isAbsolute(args.projectDir) &&
1189
+ path.normalize(args.projectDir) === args.projectDir
1190
+ ? args.projectDir.endsWith('/')
1191
+ ? args.projectDir
1192
+ : `${args.projectDir}/`
1193
+ : undefined
1183
1194
  const candidates: string[] = []
1184
1195
  const verdicts = plan.verdicts
1185
1196
  for (const rel of gitFiles) {
@@ -1193,7 +1204,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1193
1204
  verdicts.set(rel, input)
1194
1205
  }
1195
1206
  if (!input || nested(rel)) continue
1196
- candidates.push(path.resolve(args.projectDir, rel))
1207
+ candidates.push(base === undefined ? path.resolve(args.projectDir, rel) : base + rel)
1197
1208
  }
1198
1209
  if (unmatchedLiterals.size > 0) {
1199
1210
  await assertNoInvisibleLiteralInputs(unmatchedLiterals, args.projectDir, 'files')
@@ -1206,7 +1217,15 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1206
1217
  // `git ls-files -s` can surface staged entries whose working-tree
1207
1218
  // file is gone; the hasher would otherwise throw ENOENT.
1208
1219
  const oids = args.gitFilesCache?.oidsFor(args.projectDir)
1209
- const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
1220
+ const resolved = candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs))
1221
+ // Git's slice comes sorted and a common prefix keeps it so: one pass
1222
+ // proves it instead of a sort per task.
1223
+ for (let i = 1; i < resolved.length; i++) {
1224
+ if (resolved[i - 1]! > resolved[i]!) {
1225
+ resolved.sort()
1226
+ break
1227
+ }
1228
+ }
1210
1229
  // Stored only on the way out: a declaration whose literal named an
1211
1230
  // invisible file threw above, and every task sharing it must throw too.
1212
1231
  args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
@@ -4,7 +4,7 @@
4
4
  // process exits. cpuMs / peakRssBytes are then surfaced on RunResult and
5
5
  // folded into the v11 `runs` table by the orchestrator.
6
6
 
7
- import { existsSync } from 'node:fs'
7
+ import { existsSync, readFileSync } from 'node:fs'
8
8
  import { constants as osConstants } from 'node:os'
9
9
  import {
10
10
  shellArgv,
@@ -769,15 +769,23 @@ export async function runCommand(opts: RunOptions): Promise<RunResult> {
769
769
  * `true` read 44 MB through vx while its shell's `VmHWM` was 1.9 MB, and
770
770
  * 300 MB allocated in the parent made `true` read 328 MB (2026-09-12).
771
771
  * Read after the child exits so it covers the whole task's span (the mark
772
- * is monotonic). Linux reads its own peak (`getrusage`); elsewhere the
773
- * current RSS is the bound in hand.
772
+ * is monotonic). Linux reads `VmHWM`; elsewhere the current RSS is the
773
+ * bound in hand.
774
774
  */
775
775
  export function ownRssHighWater(): number {
776
- // getrusage's own peak, not `/proc/self/status`'s `VmHWM`: one syscall
777
- // where generating and parsing the status file was ~20 µs a task alone
778
- // and ~70 ms of a 1,000-task cold run. It is the same mark, raised only
779
- // by the image this one exec'd from (a shell), within the floor's slack.
780
- if (process.platform === 'linux') return peakRssBytes(process.resourceUsage().maxRSS)
776
+ // `VmHWM`, not getrusage's own peak: that one also holds the peak of the
777
+ // image this process exec'd from, which under vfork is the PARENT's
778
+ // memory — vx spawned from a 300 MB test runner (or node) read a floor
779
+ // of 300 MB, and a task holding 150 MB reported no peak (I-40's
780
+ // regression, tests/own-rss-high-water.test.ts).
781
+ if (process.platform === 'linux') {
782
+ try {
783
+ const m = /VmHWM:\s+(\d+) kB/.exec(readFileSync('/proc/self/status', 'utf8'))
784
+ if (m !== null) return Number(m[1]) * 1024
785
+ } catch {
786
+ // /proc unreadable: fall through to the current RSS.
787
+ }
788
+ }
781
789
  return process.memoryUsage.rss()
782
790
  }
783
791
 
@@ -33,6 +33,7 @@ import {
33
33
  existsSync,
34
34
  mkdirSync,
35
35
  openSync,
36
+ readFileSync,
36
37
  readlinkSync,
37
38
  realpathSync,
38
39
  rmSync,
@@ -60,6 +61,7 @@ import {
60
61
  executablePath,
61
62
  grantPrefix,
62
63
  isTmpdirRefusal,
64
+ procfsIsOwn,
63
65
  TMPDIR_HINT,
64
66
  UserError,
65
67
  xxh3hex,
@@ -492,6 +494,22 @@ function bundledJavaAgent(): { javaAgentJarPath?: string } {
492
494
  /** Whether this run's SRT scans at depth 1 and vx supplies the task-scoped denies (B-40). */
493
495
  let scopedDenyScan = false
494
496
 
497
+ /**
498
+ * SRT's own deny scan, when `wrapSandboxedCommand` walks each task's write
499
+ * grants (B-40): none. SRT spawns its ripgrep on every wrap, and at depth
500
+ * 1 the scan finds only the root's entries, which SRT keeps only inside a
501
+ * write grant, where the scoped walk already reaches (the parity rows in
502
+ * `sandbox-deny-scan.unsafe.test.ts`). A no-op in rg's place ends the
503
+ * spawn's 3.8 ms at 1.0; SRT has no way to skip it.
504
+ */
505
+ function scopedScanConfig(): { mandatoryDenySearchDepth: number; ripgrep?: { command: string } } {
506
+ try {
507
+ return { mandatoryDenySearchDepth: 1, ripgrep: { command: executablePath('true') } }
508
+ } catch {
509
+ return { mandatoryDenySearchDepth: 1 }
510
+ }
511
+ }
512
+
495
513
  function linuxToolPaths(): { bwrapPath?: string; socatPath?: string } {
496
514
  if (process.platform !== 'linux') return {}
497
515
  const paths: { bwrapPath?: string; socatPath?: string } = {}
@@ -577,9 +595,7 @@ export async function initSandbox(opts?: {
577
595
  filesystem: { denyRead: [], allowWrite: [], denyWrite: [] },
578
596
  ignoreViolations: DEFAULT_IGNORE_VIOLATIONS,
579
597
  ...linuxToolPaths(),
580
- // SRT's own scan walks only the root's entries; `wrapSandboxedCommand`
581
- // walks each task's write grants for the rest (B-40).
582
- ...(scopedDenyScan ? { mandatoryDenySearchDepth: 1 } : {}),
598
+ ...(scopedDenyScan ? scopedScanConfig() : {}),
583
599
  ...bundledJavaAgent(),
584
600
  }
585
601
  if (!srtUp) await unlinkStaleMuxSockets()
@@ -1053,7 +1069,10 @@ export async function wrapSandboxedCommand(
1053
1069
  if (process.platform === 'linux' && !hostHasIpv6())
1054
1070
  wrapped = `SOCAT_DEFAULT_LISTEN_IP=4 ${wrapped}`
1055
1071
  if (args.server === true) liveServers.add(tag)
1056
- if (ports.length > 0) spawnHostBridges(ports, tag)
1072
+ if (ports.length > 0) {
1073
+ spawnHostBridges(ports, tag)
1074
+ await hostBridgesListen(ports, tag)
1075
+ }
1057
1076
  return {
1058
1077
  wrapped,
1059
1078
  tag,
@@ -1306,12 +1325,47 @@ const hostBridges = new Map<
1306
1325
  { ports: readonly number[]; procs: Array<ReturnType<typeof Bun.spawn>> }
1307
1326
  >()
1308
1327
 
1328
+ /**
1329
+ * Wait until each host-side bridge listens on its port, so a server that
1330
+ * says it is ready inside the sandbox is reachable on the host: the socat
1331
+ * starts asynchronously, and a held server's port refused a connection
1332
+ * right after its ready line under I/O load (M-22). Read off
1333
+ * /proc/net/tcp (127.0.0.1, state 0A); skipped where /proc is not this
1334
+ * process's (its net table could be another namespace's), ended early by
1335
+ * a bridge that exited (a port already taken), and bounded at 5 s.
1336
+ */
1337
+ async function hostBridgesListen(ports: readonly number[], tag: string): Promise<void> {
1338
+ if (!procfsIsOwn()) return
1339
+ const want = ports.map((p) => `0100007F:${p.toString(16).toUpperCase().padStart(4, '0')}`)
1340
+ const procs = hostBridges.get(tag)?.procs ?? []
1341
+ const until = Date.now() + 5_000
1342
+ while (Date.now() < until && procs.every((p) => p.exitCode === null)) {
1343
+ let table: string
1344
+ try {
1345
+ table = readFileSync('/proc/net/tcp', 'utf8')
1346
+ } catch {
1347
+ return
1348
+ }
1349
+ const listening = new Set<string>()
1350
+ for (const line of table.split('\n')) {
1351
+ const f = line.trim().split(/\s+/)
1352
+ if (f[3] === '0A' && f[1] !== undefined) listening.add(f[1])
1353
+ }
1354
+ if (want.every((w) => listening.has(w))) return
1355
+ await Bun.sleep(5)
1356
+ }
1357
+ }
1358
+
1309
1359
  function spawnHostBridges(ports: readonly number[], tag: string): void {
1310
1360
  const procs: Array<ReturnType<typeof Bun.spawn>> = []
1311
1361
  for (const p of ports) {
1312
1362
  // A spawn failure (no socat on the host) is the task's to report:
1313
1363
  // its own side dies the same way, in its frame.
1314
1364
  try {
1365
+ // socat resolved on vx's PATH, as every tool vx spawns: by bare name
1366
+ // Bun.spawn walked the startup PATH (M-22).
1367
+ const [tool, ...rest] = portBridgeHostArgv(tag, p)
1368
+ const argv = [executablePath(tool!), ...rest]
1315
1369
  // Guarded, in a group of its own (kill-tree.ts): a plain child of vx
1316
1370
  // was in no group the guard lists, and a `kill -9` of vx left it
1317
1371
  // listening on the port under init, where the next run's bridge
@@ -1319,7 +1373,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
1319
1373
  procs.push(
1320
1374
  spawnGuarded((guard) =>
1321
1375
  guard === undefined
1322
- ? Bun.spawn(portBridgeHostArgv(tag, p), {
1376
+ ? Bun.spawn(argv, {
1323
1377
  stdio: ['ignore', 'ignore', 'ignore'],
1324
1378
  detached: true,
1325
1379
  })
@@ -1327,7 +1381,7 @@ function spawnHostBridges(ports: readonly number[], tag: string): void {
1327
1381
  [
1328
1382
  executablePath('sh'),
1329
1383
  '-c',
1330
- `${guardLine(3)}exec ${portBridgeHostArgv(tag, p).map(shellQuote).join(' ')}`,
1384
+ `${guardLine(3)}exec ${argv.map(shellQuote).join(' ')}`,
1331
1385
  ],
1332
1386
  { stdio: ['ignore', 'ignore', 'ignore', guard], detached: true },
1333
1387
  ),
@@ -41,10 +41,16 @@ import type {
41
41
  */
42
42
  const SYSCALLS = 'openat|access|statx|newfstatat'
43
43
  const QUOTED = '"((?:[^"\\\\]|\\\\.)+)"'
44
+ // The directory descriptor a call names, `-y`'s path included: taken up to
45
+ // the `, "` that opens the path argument, since a directory's name may hold
46
+ // a quote (`4</ws/q"d>`).
47
+ const DIRFD = '(?:AT_FDCWD|\\d+)(?:<.*?>)?, '
44
48
  const STRACE_DONE_RE = new RegExp(
45
- `^(\\d+)\\s+(${SYSCALLS})\\([^"]*${QUOTED}[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
49
+ `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}[^)]*\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
50
+ )
51
+ const STRACE_UNFINISHED_RE = new RegExp(
52
+ `^(\\d+)\\s+(${SYSCALLS})\\((?:${DIRFD})?${QUOTED}[^)]*<unfinished`,
46
53
  )
47
- const STRACE_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+(${SYSCALLS})\\([^"]*${QUOTED}[^)]*<unfinished`)
48
54
  const STRACE_RESUMED_RE = new RegExp(
49
55
  `^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) resumed>.*?=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
50
56
  )
@@ -57,10 +63,10 @@ const STRACE_RESUMED_OK_RE = new RegExp(`^(\\d+)\\s+<\\.\\.\\. (${SYSCALLS}) res
57
63
  */
58
64
  const READ_FLAGS = '(?![A-Z_|]*O_WRONLY)[A-Z_|]+'
59
65
  const OPEN_READ_RE = new RegExp(
60
- `^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<[^"]*>)?, ${QUOTED}, ${READ_FLAGS}[^)]*\\)\\s*=\\s*\\d+(?:<(.*)>)?$`,
66
+ `^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<.*?>)?, ${QUOTED}, ${READ_FLAGS}[^)]*\\)\\s*=\\s*\\d+(?:<(.*)>)?$`,
61
67
  )
62
68
  const OPEN_READ_UNFINISHED_RE = new RegExp(
63
- `^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<[^"]*>)?, ${QUOTED}, ${READ_FLAGS}.*<unfinished`,
69
+ `^(\\d+)\\s+openat\\((AT_FDCWD|\\d+)(?:<.*?>)?, ${QUOTED}, ${READ_FLAGS}.*<unfinished`,
64
70
  )
65
71
  const RESUMED_FD_RE = /resumed>.*\)\s*=\s*\d+(?:<(.*)>)?$/
66
72
 
@@ -517,8 +517,9 @@ export function defaultLogger(
517
517
  // output / only failed tasks print), so they stay silent — the tail
518
518
  // is still captured and still bounded, just never printed. Guarded
519
519
  // like the failures below: run() calls runEnd twice on the success
520
- // path (once before the summary, once in its finally), and a
521
- // kept-alive child keeps writing between the two.
520
+ // path (once before the summary, once in its finally), and though the
521
+ // bus delivers `run:end` once (`busLogger`), a renderer an embedder
522
+ // drives directly may hear both.
522
523
  if (flushedPersistent) flushKept()
523
524
  if (!flushedPersistent) {
524
525
  flushedPersistent = true
@@ -211,9 +211,9 @@ export interface RunOptions {
211
211
  */
212
212
  inflight?: Map<string, Promise<void>>
213
213
  /**
214
- * `--tag k=v` pairs (Tier-3). Persisted verbatim on the run's
215
- * `invocations` row (JSON object) so dashboards can filter runs by
216
- * label. CLI parsing is the CLI's job; the orchestrator just records.
214
+ * `--tag k=v` pairs (Tier-3). Persisted on the run's `invocations`
215
+ * row (JSON object) so dashboards can filter runs by label, a secret
216
+ * value masked (L-38). CLI parsing is the CLI's job.
217
217
  */
218
218
  tags?: Record<string, string>
219
219
  /**
@@ -443,22 +443,20 @@ const executorPlugin = new WeakMap<TaskExecutor, string>()
443
443
  /**
444
444
  * A plugin executor's throw from `execute`, named as every other hook's
445
445
  * is: `plugin 'p' (executor 'e') failed in execute: …`. It read `internal
446
- * error in <task>: pool down`, naming neither (C-63). The error itself is
447
- * kept, its class, cause and code with it, so the scheduler still tells a
448
- * refusal from a bug; the floor's own throw is vx's and stays as it is.
446
+ * error in <task>: pool down`, naming neither (C-63). A refusal, as
447
+ * `safe()` makes every stage's throw: the plugin is named, so it is not
448
+ * vx's bug, and an `internal error` line said it was (C-85). A fresh error
449
+ * with the plugin's as its cause, never the plugin's own renamed: one
450
+ * error an executor rejects every task with was named once per task
451
+ * (C-74). The floor's own throw is vx's and stays as it is.
449
452
  */
450
453
  export function nameExecutorFailure(executor: TaskExecutor, err: unknown): unknown {
451
- if (!(err instanceof Error) || !executorPlugin.has(executor)) return err
452
- // Once: one error an executor rejects several tasks with (a failed
453
- // connection it memoized) reaches here once per task (C-74).
454
- const prefix = `${executorLabel(executor)} failed in execute: `
455
- if (err.message.startsWith(prefix)) return err
456
- try {
457
- err.message = `${prefix}${err.message}`
458
- } catch {
459
- // A frozen error keeps its own words.
460
- }
461
- return err
454
+ if (!executorPlugin.has(executor)) return err
455
+ const named = new UserError(
456
+ `${executorLabel(executor)} failed in execute: ${err instanceof Error ? err.message : String(err)}`,
457
+ )
458
+ named.cause = err
459
+ return named
462
460
  }
463
461
 
464
462
  /** How a message names `executor`: its plugin and its own name, or its name alone (the floor). */
@@ -539,7 +537,7 @@ export async function teardownPlugins(
539
537
  }
540
538
  } catch (err) {
541
539
  warn(
542
- `[vx] plugin '${plugin.name}' teardown failed: ${err instanceof Error ? err.message : String(err)}`,
540
+ `[vx] plugin '${plugin.name}' failed in teardown: ${err instanceof Error ? err.message : String(err)}`,
543
541
  )
544
542
  }
545
543
  }
@@ -109,7 +109,7 @@ export function captureGitContext(
109
109
  * about the layout is unfamiliar (the caller then spawns git). Handles a
110
110
  * `.git` directory, a `.git` FILE (`gitdir: …` — a linked worktree, whose
111
111
  * refs live under `commondir`), a symbolic HEAD, a detached HEAD, loose refs
112
- * and `packed-refs`.
112
+ * and `packed-refs`. A reftable repository is always null.
113
113
  */
114
114
  function readHeadDirect(
115
115
  workspaceRoot: string,
@@ -127,6 +127,16 @@ function readHeadDirect(
127
127
  if (fs.existsSync(commonFile)) {
128
128
  commonDir = path.resolve(gitDir, fs.readFileSync(commonFile, 'utf8').trim())
129
129
  }
130
+ // Reftable storage (`git init --ref-format=reftable`, git 2.45): HEAD
131
+ // names `refs/heads/.invalid` and the refs live in `reftable/`, so the
132
+ // files say nothing true about HEAD. A leftover `packed-refs` there was
133
+ // read as the answer; git is asked instead.
134
+ if (
135
+ fs.existsSync(path.join(gitDir, 'reftable')) ||
136
+ fs.existsSync(path.join(commonDir, 'reftable'))
137
+ ) {
138
+ return null
139
+ }
130
140
  const head = fs.readFileSync(path.join(gitDir, 'HEAD'), 'utf8').trim()
131
141
  const SHA = /^[0-9a-f]{40,64}$/
132
142
  if (SHA.test(head)) return { commitSha: head, branch: null }
@@ -185,6 +185,21 @@ export function shouldShortCircuit(
185
185
  return nodes.size > 0
186
186
  }
187
187
 
188
+ function maskInvocation(command: string): string {
189
+ return secretMask([process.env])?.mask(command) ?? command
190
+ }
191
+
192
+ /**
193
+ * A run's `--tag`s, stored on its history row and handed to telemetry
194
+ * (`vx.tag.<key>`): a tag that carries a secret is masked as the
195
+ * invocation line that spells it is (L-38).
196
+ */
197
+ function maskTags(tags: Readonly<Record<string, string>>): Record<string, string> {
198
+ const mask = secretMask([process.env])
199
+ if (mask === null) return { ...tags }
200
+ return Object.fromEntries(Object.entries(tags).map(([k, v]) => [mask.mask(k), mask.mask(v)]))
201
+ }
202
+
188
203
  /**
189
204
  * The command line a run hands to its telemetry sinks, with what follows
190
205
  * `--` counted, not quoted. Those are the task's arguments,
@@ -192,10 +207,6 @@ export function shouldShortCircuit(
192
207
  * GitHub job summary and a check-run posted over the API verbatim (item
193
208
  * 1057). Local history (`vx last`) keeps the whole line, on this machine.
194
209
  */
195
- function maskInvocation(command: string): string {
196
- return secretMask([process.env])?.mask(command) ?? command
197
- }
198
-
199
210
  export function invocationCommand(argv: readonly string[]): string {
200
211
  const sep = argv.indexOf('--')
201
212
  if (sep === -1) return argv.join(' ')
@@ -208,15 +219,50 @@ export function invocationCommand(argv: readonly string[]): string {
208
219
  * (C-61): a `concurrency` of 0, a negative or NaN left no worker slot and
209
220
  * the run waited for good; a `retries` of NaN retried a failing task
210
221
  * without end; a `timeout` of 0, a negative, NaN or past the timer's range
211
- * killed every task at once.
222
+ * killed every task at once. And the words and shapes the CLI parses
223
+ * (C-86): a `continueMode` of `'sometimes'` ran as `deps-ok`, so a typo
224
+ * lost fail-fast without a word; a string `excludeDependencies` dropped
225
+ * nothing; a `projects` string or a `signal` that is no AbortSignal died
226
+ * a TypeError inside the run.
212
227
  */
213
- function refuseRunNumbers(options: RunOptions): void {
214
- const refuse = (name: string, value: number, rule: string): never => {
215
- throw new UserError(`RunOptions.${name} is ${String(value)}: it must be ${rule}`)
228
+ function refuseRunOptions(options: RunOptions): void {
229
+ const refuse = (name: string, value: unknown, rule: string): never => {
230
+ const shown =
231
+ typeof value === 'string'
232
+ ? JSON.stringify(value)
233
+ : typeof value === 'number'
234
+ ? String(value)
235
+ : value === null
236
+ ? 'null'
237
+ : Array.isArray(value)
238
+ ? 'an array'
239
+ : typeof value === 'object'
240
+ ? 'an object'
241
+ : `a ${typeof value}`
242
+ throw new UserError(`RunOptions.${name} is ${shown}: it must be ${rule}`)
216
243
  }
244
+ const names = (v: unknown): boolean => Array.isArray(v) && v.every((s) => typeof s === 'string')
245
+ if (!names(options.tasks)) refuse('tasks', options.tasks, 'an array of task names')
217
246
  // No task named read "No projects declare task(s): ." (C-61).
218
247
  if (options.tasks.length === 0 || options.tasks.includes(''))
219
248
  throw new UserError(`RunOptions.tasks names no task: give at least one task name`)
249
+ if (options.projects !== undefined && !names(options.projects))
250
+ refuse('projects', options.projects, 'an array of project names')
251
+ if (options.forwardArgs !== undefined && !names(options.forwardArgs))
252
+ refuse('forwardArgs', options.forwardArgs, 'an array of strings')
253
+ const oneOf = (name: string, value: unknown, words: readonly string[]): void => {
254
+ if (value !== undefined && !words.includes(value as string))
255
+ refuse(name, value, `one of ${words.map((w) => `'${w}'`).join(', ')}`)
256
+ }
257
+ oneOf('continueMode', options.continueMode, ['never', 'deps-ok', 'always'])
258
+ oneOf('outputLogs', options.outputLogs, ['full', 'errors-only', 'none', 'hash-only'])
259
+ oneOf('download', options.download, ['all', 'toplevel', 'none'])
260
+ oneOf('flow', options.flow, ['focused', 'broad'])
261
+ const exclude = options.excludeDependencies
262
+ if (exclude !== undefined && exclude !== 'all' && !names(exclude))
263
+ refuse('excludeDependencies', exclude, "'all' or an array of task names")
264
+ if (options.signal !== undefined && !(options.signal instanceof AbortSignal))
265
+ refuse('signal', options.signal, 'an AbortSignal')
220
266
  const { concurrency, retries, timeout } = options
221
267
  if (concurrency !== undefined && !(Number.isInteger(concurrency) && concurrency > 0))
222
268
  refuse('concurrency', concurrency, 'a positive integer')
@@ -230,7 +276,7 @@ function refuseRunNumbers(options: RunOptions): void {
230
276
  }
231
277
 
232
278
  export async function run(options: RunOptions): Promise<RunSummary> {
233
- refuseRunNumbers(options)
279
+ refuseRunOptions(options)
234
280
  // Color decision: a custom logger (tests, embedders) handles its
235
281
  // own formatting and asserts on plain strings, so we suppress
236
282
  // ANSI escapes for them. Only the defaultLogger (real terminal
@@ -663,7 +709,7 @@ async function runOnBus(
663
709
  arch: hostContext.arch,
664
710
  workspaceId: wsIdentity.id,
665
711
  workspaceName: wsIdentity.name,
666
- tags: options.tags ?? {},
712
+ tags: maskTags(options.tags ?? {}),
667
713
  }
668
714
  telemetry = await subscribeTelemetry(
669
715
  prepared.plugins,
@@ -1060,7 +1106,7 @@ async function runOnBus(
1060
1106
  concurrency,
1061
1107
  flow: options.flow ?? null,
1062
1108
  forwardArgs: options.forwardArgs,
1063
- tags: options.tags ?? {},
1109
+ tags: maskTags(options.tags ?? {}),
1064
1110
  git: gitContext,
1065
1111
  ci: ciContext,
1066
1112
  host: hostContext,
@@ -1148,6 +1194,11 @@ async function runOnBus(
1148
1194
  // Not on a stopped run: one stopped while it waited on another run's
1149
1195
  // lock never held it, and its prune evicted under that run (item 858).
1150
1196
  if (!stopRun.signal.aborted) await applyCacheRetention(prepared, log)
1197
+ // A plugin hears the run until its teardown and nothing after: released
1198
+ // only in the finally, its handlers heard a kept server through the
1199
+ // whole keep-alive wait below (C-66). Idempotent; the finally's stay.
1200
+ disposePlugins?.()
1201
+ telemetry?.dispose()
1151
1202
  await teardown()
1152
1203
  await closeCache()
1153
1204
  mark('close')
@@ -1302,6 +1353,7 @@ async function applyCacheRetention(prepared: PreparedRun, log: Logger): Promise<
1302
1353
  * existence check — no artifact download, no ingest, no accessed_at bump.
1303
1354
  */
1304
1355
  export async function planRun(options: RunOptions): Promise<RunPlan> {
1356
+ refuseRunOptions(options)
1305
1357
  // The plan is the product and goes to stdout (`--dry=json` is parsed):
1306
1358
  // what a stage says on the way goes to stderr (C-6).
1307
1359
  const log = options.log ?? defaultLogger(undefined, undefined, process.stderr)
@@ -237,6 +237,89 @@ function isWatcher(name: string, command: string): boolean {
237
237
  )
238
238
  }
239
239
 
240
+ /** A server run alone (`serve website`, `http-server`), or a tool's own server verb. */
241
+ const SERVERS = new Set(['serve', 'http-server', 'live-server', 'sirv', 'webpack-dev-server'])
242
+ const SERVER_TOOLS = new Set([
243
+ 'vite',
244
+ 'next',
245
+ 'astro',
246
+ 'nuxt',
247
+ 'nuxi',
248
+ 'remix',
249
+ 'react-router',
250
+ 'docusaurus',
251
+ 'storybook',
252
+ 'netlify',
253
+ 'wrangler',
254
+ 'webpack',
255
+ 'expo',
256
+ 'react-native',
257
+ ])
258
+ const SERVER_VERBS = new Set(['dev', 'serve', 'start', 'preview'])
259
+ const LAUNCHERS = new Set(['cross-env', 'npx', 'bunx', 'exec', 'dlx'])
260
+
261
+ /**
262
+ * A command that runs a server (D-91): docusaurus's `serve website` and
263
+ * `netlify dev` mapped as one-shot tasks, so a dependent waited forever and
264
+ * a server that exits on stdin EOF ended at once. Read per `&&` segment,
265
+ * past env assignments and launchers; a quoted string is another tool's
266
+ * argument (`start-server-and-test 'vite preview' …` exits).
267
+ */
268
+ function servesCommand(command: string): boolean {
269
+ for (const segment of foreground(command.replace(/"[^"]*"|'[^']*'/g, ' '))) {
270
+ const words = segment
271
+ .trim()
272
+ .split(/\s+/)
273
+ .filter((w) => w !== '')
274
+ let i = 0
275
+ while (
276
+ i < words.length &&
277
+ (/^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]!) ||
278
+ LAUNCHERS.has(words[i]!) ||
279
+ words[i]!.startsWith('-') ||
280
+ (/^(pnpm|npm|yarn)$/.test(words[i]!) && LAUNCHERS.has(words[i + 1] ?? '')))
281
+ ) {
282
+ // A launcher's `--package <name>` / `-p <name>` names what to install,
283
+ // not the program: docusaurus's `pnpm dlx --package netlify-cli netlify
284
+ // dev` read `netlify-cli` as the program (D-112).
285
+ i += words[i] === '--package' || words[i] === '-p' ? 2 : 1
286
+ }
287
+ const [program, verb] = [words[i], words[i + 1]]
288
+ if (program === undefined) continue
289
+ if (SERVERS.has(program)) return true
290
+ if (SERVER_TOOLS.has(program) && verb !== undefined && SERVER_VERBS.has(verb)) return true
291
+ if (program === 'vite' && (verb === undefined || verb.startsWith('-'))) return true
292
+ }
293
+ return false
294
+ }
295
+
296
+ /**
297
+ * Whether script `name` never exits: by name, as a watcher, as a server, or
298
+ * running such a script of its own package by name (docusaurus's
299
+ * `start:baseUrl`: `cross-env BASE_URL=… pnpm start`).
300
+ */
301
+ function isPersistent(
302
+ name: string,
303
+ scripts: Readonly<Record<string, unknown>>,
304
+ seen: Set<string> = new Set(),
305
+ ): boolean {
306
+ const own = scripts[name]
307
+ if (typeof own !== 'string' || seen.has(name)) return false
308
+ seen.add(name)
309
+ if (PERSISTENT_TASK_NAMES.has(name) || isWatcher(name, own) || servesCommand(own)) return true
310
+ return foreground(own).some((segment) =>
311
+ [...segment.matchAll(RUNS_SCRIPT)].some((m) => isPersistent(m[1]!, scripts, seen)),
312
+ )
313
+ }
314
+
315
+ /** The `&&` / `;` / `|` segments of a command, less those it backgrounds with a single `&`. */
316
+ function foreground(command: string): string[] {
317
+ return command
318
+ .split(/&&|\|\||[;|()]/)
319
+ .flatMap((part) => part.split('&').slice(-1))
320
+ .filter((segment) => segment.trim() !== '')
321
+ }
322
+
240
323
  function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
241
324
  // package.json is a boundary: `scripts` is whatever the file holds. A
242
325
  // string or an array would enumerate its indices as script names.
@@ -343,6 +426,38 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
343
426
  return false
344
427
  }
345
428
 
429
+ /**
430
+ * The manager a root's files name, or undefined: `pnpm-workspace.yaml` is
431
+ * pnpm's whatever the lockfile, and the npm `ownerOf` falls back to at the
432
+ * file-system root with no lockfile anywhere is no claim.
433
+ */
434
+ function rootManager(dir: string, memo: Map<string, Owner>): string | undefined {
435
+ if (existsSync(path.join(dir, 'pnpm-workspace.yaml'))) return 'pnpm'
436
+ const owner = ownerOf(dir, memo)
437
+ const guessed =
438
+ owner.at === path.parse(owner.at).root && !existsSync(path.join(owner.at, 'package-lock.json'))
439
+ return guessed ? undefined : owner.manager
440
+ }
441
+
442
+ /**
443
+ * How the repo's own manager runs the members, for the root note: an npm
444
+ * repo read "(`pnpm -r`, `--filter`, a runner)" (insomnia).
445
+ */
446
+ function membersExample(manager: string | undefined): string {
447
+ switch (manager) {
448
+ case 'npm':
449
+ return '`--workspaces`, `-w`'
450
+ case 'yarn':
451
+ return '`yarn workspaces run`, `yarn workspace`'
452
+ case 'berry':
453
+ return '`yarn workspaces foreach`, `yarn workspace`'
454
+ case 'bun':
455
+ return '`bun --filter`'
456
+ default:
457
+ return '`pnpm -r`, `--filter`'
458
+ }
459
+ }
460
+
346
461
  /**
347
462
  * The hooks a package's build hides in when it has no `build` script:
348
463
  * react-navigation's twelve packages build in `prepack: bob build`, their
@@ -395,6 +510,48 @@ function siblingRun(script: string, dir: string, others: readonly string[]): str
395
510
  return undefined
396
511
  }
397
512
 
513
+ /**
514
+ * The package globs a `lerna.json` beside a lone root lists (Lerna's
515
+ * default when it names none), or undefined: Lerna-classic repos list
516
+ * their packages there, not in `workspaces`, and vx saw the root alone
517
+ * (D-111).
518
+ */
519
+ function lernaPackages(dir: string): string[] | undefined {
520
+ let json: unknown
521
+ try {
522
+ json = JSON.parse(readFileSync(path.join(dir, 'lerna.json'), 'utf8'))
523
+ } catch {
524
+ return undefined
525
+ }
526
+ const listed = (json as { packages?: unknown } | null)?.packages
527
+ return Array.isArray(listed) && listed.every((g) => typeof g === 'string') && listed.length > 0
528
+ ? listed
529
+ : ['packages/*']
530
+ }
531
+
532
+ function lernaNote(globs: readonly string[]): string {
533
+ const list = globs.map((g) => JSON.stringify(g)).join(', ')
534
+ return `lerna.json lists the packages (${list}), but package.json declares no \`workspaces\`, so vx sees the root alone: add \`"workspaces": [${list}]\` to package.json and run \`vx init\` again`
535
+ }
536
+
537
+ /**
538
+ * Yarn 2+ installs with Plug'n'Play unless `.yarnrc.yml` names another
539
+ * linker: a package's bins live in `.pnp.cjs`, and a task's `json5` exited
540
+ * 127 under vx, which runs no `yarn` in front of a command (probed on Yarn
541
+ * 4.5).
542
+ */
543
+ function usesPnp(dir: string): boolean {
544
+ let rc = ''
545
+ try {
546
+ rc = readFileSync(path.join(dir, '.yarnrc.yml'), 'utf8')
547
+ } catch {}
548
+ const linker = /^nodeLinker:\s*["']?([\w-]+)/m.exec(rc)?.[1]
549
+ return linker === undefined || linker === 'pnp'
550
+ }
551
+
552
+ const PNP_NOTE =
553
+ "Yarn Plug'n'Play installs this repo: a package's bins live in `.pnp.cjs`, not `node_modules/.bin`, so a task's `tsc` is not found under vx — set `nodeLinker: node-modules` in `.yarnrc.yml` and run `yarn install`, or write each command as `yarn exec '<command>'`"
554
+
398
555
  /** A package manager running a script by name: `pnpm build`, `npm run x`, `bun run x`. */
399
556
  const RUNS_SCRIPT =
400
557
  /(?:^|[\s;&|(])(?:pnpm|pn|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
@@ -559,7 +716,7 @@ export function migrateScripts(
559
716
  : undefined)
560
717
  if (rootName !== undefined && rootMapped > 0) {
561
718
  notes.push(
562
- `${rootName} (the workspace root): its scripts that check the whole repo are its tasks; those that run the members (\`pnpm -r\`, \`--filter\`, a runner) or share a member's task name are left out`,
719
+ `${rootName} (the workspace root): its scripts that check the whole repo are its tasks; those that run the members (${membersExample(rootMeta && rootManager(rootMeta.dir, hookMemo))}, a runner) or share a member's task name are left out`,
563
720
  )
564
721
  } else if (rootName === 'package.json' && outsideDir !== undefined && unnamedMaps().length > 0) {
565
722
  // react's nameless root: "its scripts run the workspace" was not why,
@@ -671,7 +828,7 @@ export function migrateScripts(
671
828
  )
672
829
  }
673
830
  const task: Record<string, unknown> = { exec }
674
- if (PERSISTENT_TASK_NAMES.has(name) || isWatcher(name, own)) {
831
+ if (isPersistent(name, scripts)) {
675
832
  exec['persistent'] = {}
676
833
  todos.push(PERSISTENT_TODO)
677
834
  }
@@ -698,6 +855,9 @@ export function migrateScripts(
698
855
  projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
699
856
  }
700
857
  }
858
+ const owner = metas[0] === undefined ? undefined : ownerOf(metas[0].dir, hookMemo)
859
+ if (owner?.manager === 'berry' && usesPnp(owner.at)) notes.push(PNP_NOTE)
860
+
701
861
  if (lifecycleBuilds.length > 0) {
702
862
  const [name, hook, command] = lifecycleBuilds[0]!
703
863
  const more = lifecycleBuilds.length - 1
@@ -705,6 +865,8 @@ export function migrateScripts(
705
865
  `${lifecycleBuilds.length === 1 ? 'a package builds' : `${lifecycleBuilds.length} packages build`} only in a lifecycle script (\`${hook}: ${command}\` in ${name}${more > 0 ? ` and ${more} more` : ''}), which the package manager runs on pack or install and vx never runs: add a \`build\` script running it and run \`vx init\` again`,
706
866
  )
707
867
  }
868
+ const lerna = metas.length === 1 ? lernaPackages(metas[0]!.dir) : undefined
869
+ if (lerna !== undefined) notes.push(lernaNote(lerna))
708
870
  breakBuildCycles(projects, metas)
709
871
  pruneOrphanPersistentNotes(projects, PERSISTENT_TODO)
710
872
  return {
@@ -134,6 +134,17 @@ export function buildPackageGraph(
134
134
  // built on the first one, as the closures are.
135
135
  const reachDeps = new Map<string, string[]>()
136
136
  const directDependents = new Map<string, string[]>()
137
+ /** One project's REACH list, made on first ask (`buildReach` makes them all). */
138
+ const reachOf = (name: string): string[] => {
139
+ let reach = reachDeps.get(name)
140
+ if (reach === undefined) {
141
+ const own = order.get(name)
142
+ if (own === undefined) return []
143
+ reach = [...new Set([...own, ...peers.get(name)!])].sort()
144
+ reachDeps.set(name, reach)
145
+ }
146
+ return reach
147
+ }
137
148
  let reachBuilt = false
138
149
  const buildReach = (): void => {
139
150
  if (reachBuilt) return
@@ -224,13 +235,40 @@ export function buildPackageGraph(
224
235
  // does a `^task` walk (it reads `directDeps`). Building both closures
225
236
  // eagerly was 12 ms of a 240 ms warm run at 1000 projects × 30 deps
226
237
  // (profiled 2026-09-09) for answers nobody read.
227
- function makeAccessor(edges: Map<string, string[]>): (name: string) => string[] {
238
+ // A scoped run asks one or two seeds' closures: a search from each over
239
+ // the REACH lists it visits answers them without every project's list
240
+ // and the whole graph's bitsets (8–13 ms at 1,000 projects for one
241
+ // `--filter`). The same set, in the same name order; past a few asks
242
+ // the bitsets are cheaper per question.
243
+ const EARLY_SEARCHES = 8
244
+ const searchDeps = (name: string): string[] => {
245
+ const seen = new Set<string>()
246
+ const stack = [...reachOf(name)]
247
+ while (stack.length > 0) {
248
+ const cur = stack.pop()!
249
+ if (seen.has(cur)) continue
250
+ seen.add(cur)
251
+ for (const d of reachOf(cur)) stack.push(d)
252
+ }
253
+ return [...seen].sort()
254
+ }
255
+ function makeAccessor(
256
+ edges: Map<string, string[]>,
257
+ search?: (name: string) => string[],
258
+ ): (name: string) => string[] {
228
259
  const memo = new Map<string, string[]>()
229
260
  let closures: Uint32Array | null | undefined
230
261
  const words = (names.length + 31) >>> 5
262
+ let searched = 0
231
263
  return (name) => {
232
264
  const cached = memo.get(name)
233
265
  if (cached) return cached
266
+ if (search !== undefined && closures === undefined && searched < EARLY_SEARCHES) {
267
+ searched++
268
+ const result = search(name)
269
+ memo.set(name, result)
270
+ return result
271
+ }
234
272
  buildReach()
235
273
  if (closures === undefined) closures = bitsetClosures(edges)
236
274
  if (closures === null) {
@@ -257,7 +295,7 @@ export function buildPackageGraph(
257
295
 
258
296
  return {
259
297
  directDeps: (name) => directDeps.get(name) ?? [],
260
- transitiveDeps: makeAccessor(reachDeps),
298
+ transitiveDeps: makeAccessor(reachDeps, searchDeps),
261
299
  transitiveDependents: makeAccessor(directDependents),
262
300
  }
263
301
  }
@@ -1,4 +1,4 @@
1
- import { realpathSync, statSync } from 'node:fs'
1
+ import { existsSync, realpathSync, statSync } from 'node:fs'
2
2
  import path from 'node:path'
3
3
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
4
4
  import { UserError, xxh3hex } from '../util/index.js'
@@ -200,6 +200,14 @@ const configExit = ((code?: number | string | null) => {
200
200
  throw new Error(`process.exit(${code ?? ''}) in a config: ${CONFIG_EXIT}`)
201
201
  }) as typeof process.exit
202
202
 
203
+ /** A `.pnp.cjs` in `dir` or above: Yarn Plug'n'Play's install. */
204
+ function pnpAbove(dir: string): boolean {
205
+ for (let d = dir; ; d = path.dirname(d)) {
206
+ if (existsSync(path.join(d, '.pnp.cjs'))) return true
207
+ if (path.dirname(d) === d) return false
208
+ }
209
+ }
210
+
203
211
  /**
204
212
  * A bare import nothing above the config provides is refused BEFORE the
205
213
  * evaluation: left to Bun, a workspace with no `node_modules` would have
@@ -215,6 +223,14 @@ function refuseUnprovidedImports(bytes: Uint8Array, configPath: string, kind: st
215
223
  loader,
216
224
  )
217
225
  if (missing.length === 0) return
226
+ // Under Yarn Plug'n'Play the dependencies ARE installed, into a
227
+ // `.pnp.cjs` Bun does not read: "install them first" sent the user to a
228
+ // `yarn install` that changed nothing (D-109).
229
+ if (pnpAbove(path.dirname(configPath))) {
230
+ throw new UserError(
231
+ `${kind} config ${configPath}: cannot find '${missing[0]}' — Yarn Plug'n'Play installed the workspace's dependencies into .pnp.cjs, which Bun does not read; set \`nodeLinker: node-modules\` in .yarnrc.yml and run \`yarn install\``,
232
+ )
233
+ }
218
234
  throw new UserError(
219
235
  `${kind} config ${configPath}: cannot find '${missing[0]}' — no node_modules above the config provides it; install the workspace's dependencies first`,
220
236
  )