@vzn/vx 0.0.350 → 0.0.352

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.350",
3
+ "version": "0.0.352",
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.350",
25
- "@vzn/vx-linux-arm64": "0.0.350",
26
- "@vzn/vx-darwin-x64": "0.0.350",
27
- "@vzn/vx-darwin-arm64": "0.0.350"
24
+ "@vzn/vx-linux-x64": "0.0.352",
25
+ "@vzn/vx-linux-arm64": "0.0.352",
26
+ "@vzn/vx-darwin-x64": "0.0.352",
27
+ "@vzn/vx-darwin-arm64": "0.0.352"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -674,7 +674,7 @@ export class Cache implements CacheLayer {
674
674
  }
675
675
  if (found === SCHEMA_VERSION) return null
676
676
  this.db.exec(
677
- 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs;',
677
+ 'DROP TABLE IF EXISTS entries; DROP TABLE IF EXISTS runs; DROP TABLE IF EXISTS file_hashes; DROP TABLE IF EXISTS blob_sizes; DROP TABLE IF EXISTS output_files; DROP TABLE IF EXISTS invocations; DROP TABLE IF EXISTS run_task_inputs; DROP TABLE IF EXISTS entry_inputs; DROP TABLE IF EXISTS config_evals; DROP TABLE IF EXISTS config_closures; DROP TABLE IF EXISTS output_dirs;',
678
678
  )
679
679
  this.db
680
680
  .prepare("UPDATE schema_meta SET value = ? WHERE key = 'version'")
@@ -798,6 +798,14 @@ export class Cache implements CacheLayer {
798
798
  hashFiles(paths: readonly string[]): Promise<Map<string, string>> {
799
799
  return this.guard(() => this.files.hashFiles(paths))
800
800
  }
801
+ /** `BlobSizeMemo`: the sizes of these blobs this cache has learned. */
802
+ knownBlobSizes(oids: readonly string[]): Map<string, number> {
803
+ return this.guard(() => this.files.knownBlobSizes(oids))
804
+ }
805
+ /** `BlobSizeMemo`: remember blob sizes, honouring the local WRITE axis. */
806
+ rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
807
+ this.guard(() => this.files.rememberBlobSizes(sizes))
808
+ }
801
809
  /**
802
810
  * `relPosix` against the run's workspace root, memoized: the same three
803
811
  * thousand files are re-relativized for every task of the project, which
@@ -807,11 +815,25 @@ export class Cache implements CacheLayer {
807
815
  */
808
816
  private relMemo = new Map<string, string>()
809
817
  private relMemoRoot: string | undefined
818
+ /** `root` and a slash, when a file under it is relative by slicing; else undefined. */
819
+ private relPrefix: string | undefined
810
820
  private relFor(root: string, file: string): string {
811
821
  if (root !== this.relMemoRoot) {
812
822
  this.relMemoRoot = root
813
823
  this.relMemo.clear()
824
+ this.relPrefix =
825
+ path.sep === '/' && path.isAbsolute(root) && path.normalize(root) === root
826
+ ? root.endsWith('/')
827
+ ? root
828
+ : `${root}/`
829
+ : undefined
814
830
  }
831
+ // An input file is a normalized absolute path (`resolveInputs` makes
832
+ // every one), and under a normalized root that is the root, a slash and
833
+ // its relative path: a slice, where the memo hashed the whole path per
834
+ // file per task (~15 ms over 36,000 files, I-31).
835
+ const prefix = this.relPrefix
836
+ if (prefix !== undefined && file.startsWith(prefix)) return file.slice(prefix.length)
815
837
  let rel = this.relMemo.get(file)
816
838
  if (rel === undefined) this.relMemo.set(file, (rel = relPosix(root, file)))
817
839
  return rel
@@ -68,12 +68,42 @@ export class FileHashStore {
68
68
  if (last !== null && now - Number(last.value) < DAY_MS) return
69
69
  this.db.transaction(() => {
70
70
  this.db.prepare('DELETE FROM file_hashes WHERE seen_at < ?').run(cutoff)
71
+ this.db.prepare('DELETE FROM blob_sizes WHERE seen_at < ?').run(cutoff)
71
72
  this.db
72
73
  .prepare('INSERT OR REPLACE INTO schema_meta(key, value) VALUES (?, ?)')
73
74
  .run(FILE_HASHES_SWEPT_AT, String(now))
74
75
  })()
75
76
  }
76
77
 
78
+ /** Each of `oids` whose size a `rememberBlobSizes` stored: one `IN` query per 900. */
79
+ knownBlobSizes(oids: readonly string[]): Map<string, number> {
80
+ const out = new Map<string, number>()
81
+ for (let i = 0; i < oids.length; i += 900) {
82
+ const chunk = oids.slice(i, i + 900)
83
+ const rows = this.db
84
+ .query(`SELECT oid, size FROM blob_sizes WHERE oid IN (${chunk.map(() => '?').join(',')})`)
85
+ .all(...chunk) as Array<{ oid: string; size: number }>
86
+ for (const r of rows) out.set(r.oid, r.size)
87
+ }
88
+ return out
89
+ }
90
+
91
+ /** Store blob sizes in one transaction; a memo, so a full disk skips it. */
92
+ rememberBlobSizes(sizes: ReadonlyMap<string, number>): void {
93
+ if (!this.write || sizes.size === 0) return
94
+ const now = Date.now()
95
+ const insert = this.db.prepare(
96
+ 'INSERT OR REPLACE INTO blob_sizes(oid, size, seen_at) VALUES (?, ?, ?)',
97
+ )
98
+ try {
99
+ this.db.transaction(() => {
100
+ for (const [oid, size] of sizes) insert.run(oid, size, now)
101
+ })()
102
+ } catch (err) {
103
+ if (!isIndexFull(err)) throw err
104
+ }
105
+ }
106
+
77
107
  /**
78
108
  * Content-hash a file (as a git blob OID, v20) with an mtime+size
79
109
  * fast path. If the `file_hashes` table has a row for `path` whose
@@ -6,7 +6,7 @@
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, realpathSync } from 'node:fs'
9
+ import { existsSync, lstatSync, readdirSync, realpathSync } from 'node:fs'
10
10
  import { UserError, executablePath, gitSpawnRefusal } from '../util/index.js'
11
11
 
12
12
  /** Three facts of the repository a directory is in, from one `git rev-parse`. */
@@ -394,7 +394,14 @@ function expandNestedRepos(
394
394
  }
395
395
  out ??= files.slice(0, i)
396
396
  const abs = path.join(cwd, nested)
397
- if (!existsSync(path.join(abs, '.git'))) continue
397
+ if (!existsSync(path.join(abs, '.git'))) {
398
+ // No repository answers for it: a submodule never initialised (empty)
399
+ // or one whose `.git` was removed to vendor its files, the gitlink
400
+ // left in the index and `git status` silent. Its files are what the
401
+ // task reads, so a walk lists them; they hash by content (A-61).
402
+ for (const f of walkFiles(abs)) out.push(`${nested}/${f}`)
403
+ continue
404
+ }
398
405
  const inner = runGitLsFiles(abs)
399
406
  for (const f of inner.files) out.push(`${nested}/${f}`)
400
407
  for (const f of inner.undecodable) undecodable.push(`${nested}/${f}`)
@@ -402,6 +409,24 @@ function expandNestedRepos(
402
409
  return { files: out ?? files, undecodable }
403
410
  }
404
411
 
412
+ /** Every file and symlink under `dir`, relative and `/`-separated; a `.git` is skipped, a link not followed. */
413
+ function walkFiles(dir: string, rel = ''): string[] {
414
+ let entries
415
+ try {
416
+ entries = readdirSync(path.join(dir, rel), { withFileTypes: true })
417
+ } catch {
418
+ return []
419
+ }
420
+ const out: string[] = []
421
+ for (const e of entries) {
422
+ if (e.name === '.git') continue
423
+ const r = rel === '' ? e.name : `${rel}/${e.name}`
424
+ if (e.isDirectory()) out.push(...walkFiles(dir, r))
425
+ else out.push(r)
426
+ }
427
+ return out
428
+ }
429
+
405
430
  /**
406
431
  * A file's identity as the key folds it: its blob OID, prefixed by its git
407
432
  * mode unless that is a plain file's (100644). A blob OID holds no mode, so
@@ -451,6 +476,108 @@ function parseLsFilesOutput(out: string, undecodableRecords: ReadonlySet<string>
451
476
  return { files, oids, flagged, gitlinks, undecodable }
452
477
  }
453
478
 
479
+ /**
480
+ * `ls-files -s -v -z --debug` as the plain `-s -v -z` stream, plus the size
481
+ * the index records for each regular stage-0 entry's worktree file and its
482
+ * raw OID. With `-z`, each record's NUL is followed by its five
483
+ * newline-ended debug lines (ctime, mtime, dev/ino, uid/gid, size/flags),
484
+ * then the next record.
485
+ */
486
+ function stripLsDebug(run: GitRun): {
487
+ plain: string
488
+ undecodable: Set<string>
489
+ indexed: Map<string, { oid: string; size: number }>
490
+ } {
491
+ const segs = run.stdout.split('\0')
492
+ const records: string[] = []
493
+ const undecodable = new Set<string>()
494
+ const indexed = new Map<string, { oid: string; size: number }>()
495
+ let record = segs[0]!
496
+ let recordRaw = record
497
+ for (let i = 1; i <= segs.length; i++) {
498
+ const seg = segs[i]
499
+ let rest = ''
500
+ let size = -1
501
+ if (seg !== undefined) {
502
+ let at = 0
503
+ for (let k = 0; k < 5 && at >= 0; k++) {
504
+ const nl = seg.indexOf('\n', at)
505
+ if (nl < 0) at = -1
506
+ else {
507
+ if (k === 4) size = Number(/size: (\d+)/.exec(seg.slice(at, nl))?.[1] ?? -1)
508
+ at = nl + 1
509
+ }
510
+ }
511
+ rest = at < 0 ? '' : seg.slice(at)
512
+ }
513
+ if (record.length > 0) {
514
+ records.push(record)
515
+ if (run.undecodable.has(recordRaw)) undecodable.add(record)
516
+ const m = LS_FILES_STAGE_RE.exec(record)
517
+ if (m !== null && m[4] === '0' && (m[2] === '100644' || m[2] === '100755')) {
518
+ indexed.set(record.slice(m[0].length), { oid: m[3]!, size })
519
+ }
520
+ }
521
+ record = rest
522
+ recordRaw = seg ?? ''
523
+ }
524
+ return { plain: records.join('\0'), undecodable, indexed }
525
+ }
526
+
527
+ /** Blob sizes learned once and kept: a blob's size is fixed for its OID. `Cache` is one. */
528
+ export interface BlobSizeMemo {
529
+ knownBlobSizes(oids: readonly string[]): Map<string, number>
530
+ rememberBlobSizes(sizes: ReadonlyMap<string, number>): void
531
+ }
532
+
533
+ /**
534
+ * Drop each trusted OID whose blob is not the size the index recorded for
535
+ * the worktree file (A-60). A clean filter's blob differs from the file it
536
+ * was added from, and git holds a stat-clean entry clean without re-reading
537
+ * it once the filter is gone (`core.autocrlf` turned off, a `.gitattributes`
538
+ * rule removed): `status` and the filter gate, which reads today's config,
539
+ * both let the blob stand for bytes it does not hold. The recorded sizes
540
+ * cost no read of the worktree; the blob sizes come from `memo`, and only
541
+ * the ones it lacks from one `cat-file` (65 ms over 3,000 loose objects,
542
+ * the whole of the cost, measured 2026-10-02). A smudged (racy) entry
543
+ * records 0 and is hashed from disk. An answer that cannot be read trusts
544
+ * nothing.
545
+ */
546
+ async function dropResizedOids(enumeration: GitEnumeration, memo?: BlobSizeMemo): Promise<void> {
547
+ const { trusted, indexed } = enumeration
548
+ const wanted = new Set<string>()
549
+ for (const rel of trusted.keys()) {
550
+ const entry = indexed.get(rel)
551
+ if (entry !== undefined) wanted.add(entry.oid)
552
+ }
553
+ if (wanted.size === 0) return
554
+ const sizes = memo?.knownBlobSizes([...wanted]) ?? new Map<string, number>()
555
+ const unknown = [...wanted].filter((oid) => !sizes.has(oid))
556
+ if (unknown.length > 0) {
557
+ const run = await enumeration.catFile(unknown.join('\n') + '\n')
558
+ if (run === null || run.exitCode !== 0) {
559
+ trusted.clear()
560
+ return
561
+ }
562
+ const learned = new Map<string, number>()
563
+ for (const line of run.stdout.split('\n')) {
564
+ const sp = line.indexOf(' ')
565
+ const n = Number(line.slice(sp + 1))
566
+ if (sp > 0 && line.slice(sp + 1) !== '' && Number.isInteger(n))
567
+ learned.set(line.slice(0, sp), n)
568
+ }
569
+ for (const [oid, n] of learned) sizes.set(oid, n)
570
+ memo?.rememberBlobSizes(learned)
571
+ }
572
+ for (const rel of trusted.keys()) {
573
+ const entry = indexed.get(rel)
574
+ if (entry === undefined) continue // a symlink: its blob is its target string
575
+ const size = sizes.get(entry.oid)
576
+ // The index keeps the low 32 bits of a size.
577
+ if (size === undefined || size % 2 ** 32 !== entry.size) trusted.delete(rel)
578
+ }
579
+ }
580
+
454
581
  /** One completed `git` invocation. */
455
582
  interface GitRun {
456
583
  exitCode: number
@@ -825,7 +952,7 @@ export async function populateGitFilesCache(
825
952
  workspaceRoot,
826
953
  gitPathspecs(workspaceRoot, projectDirs, workspaceWide),
827
954
  )
828
- applyGitEnumeration(enumeration, workspaceRoot, projectDirs, cache, workspaceWide)
955
+ await applyGitEnumeration(enumeration, workspaceRoot, projectDirs, cache, workspaceWide)
829
956
  }
830
957
 
831
958
  /** What one workspace-wide enumeration learned, before it is partitioned per project. */
@@ -851,6 +978,10 @@ export interface GitEnumeration {
851
978
  undecodable: readonly string[]
852
979
  /** `Date.now()` before the spawns: what `trusted` says is true as of no earlier. */
853
980
  startedAtMs: number
981
+ /** Each regular stage-0 entry's raw OID and the worktree size the index recorded. */
982
+ indexed: ReadonlyMap<string, { oid: string; size: number }>
983
+ /** `git cat-file --batch-check` over `stdin`'s OIDs; null when it could not spawn. */
984
+ catFile(stdin: string): Promise<{ exitCode: number; stdout: string } | null>
854
985
  }
855
986
 
856
987
  /** Above this many project dirs the enumeration walks the whole tree. */
@@ -891,7 +1022,11 @@ export async function startGitEnumeration(
891
1022
  // bulk-populate costs max(ls-files, status) wall time, not the sum
892
1023
  // (status alone is ~74 ms on a 1000-project tree; serial spawning
893
1024
  // was a measurable warm-path regression vs the pre-OID code).
894
- const spawnGit = async (args: string[], stdin?: string): Promise<GitRun | null> => {
1025
+ const spawnGit = async (
1026
+ args: string[],
1027
+ stdin?: string,
1028
+ env?: Record<string, string>,
1029
+ ): Promise<GitRun | null> => {
895
1030
  try {
896
1031
  const proc = Bun.spawn({
897
1032
  // `status` refreshes the index when it can take `index.lock`, so
@@ -903,6 +1038,7 @@ export async function startGitEnumeration(
903
1038
  stdin: stdin === undefined ? 'ignore' : new TextEncoder().encode(stdin),
904
1039
  stdout: 'pipe',
905
1040
  stderr: 'pipe',
1041
+ ...(env !== undefined ? { env: { ...process.env, ...env } } : {}),
906
1042
  })
907
1043
  const [stdout, stderr, exitCode] = await Promise.all([
908
1044
  new Response(proc.stdout).bytes(),
@@ -924,8 +1060,14 @@ export async function startGitEnumeration(
924
1060
  // tracked paths are dirty AND which files are untracked. Asking
925
1061
  // `ls-files --others` for the untracked set walked the same tree a second
926
1062
  // time (~50 ms of CPU, concurrent with status but contending with it).
1063
+ // `--debug` adds each entry's recorded stat, for the blob-size check
1064
+ // (`dropResizedOids`, A-60).
1065
+ const listing = spawnGit(['ls-files', '-s', '-v', '-z', '--debug', '--', ...pathspecs]).then(
1066
+ (run) =>
1067
+ run === null || run.exitCode !== 0 ? { run, debug: null } : { run, debug: stripLsDebug(run) },
1068
+ )
927
1069
  const running = Promise.all([
928
- spawnGit(['ls-files', '-s', '-v', '-z', '--', ...pathspecs]),
1070
+ listing,
929
1071
  // `--ignored=matching` names an ignored path without descending into an
930
1072
  // ignored directory: git applies an ignored `.gitattributes` as it does
931
1073
  // any other, and the filter gate below must see it (A-19).
@@ -965,7 +1107,7 @@ export async function startGitEnumeration(
965
1107
  // at all. `--git-dir` named the per-worktree directory, so the gate
966
1108
  // looked where the rule can never be.
967
1109
  const facts = repoFacts(workspaceRoot)
968
- const [ls, status, vars] = await running
1110
+ const [{ run: ls, debug }, status, vars] = await running
969
1111
  if (ls === null) {
970
1112
  throw gitSpawnRefusal(workspaceRoot)
971
1113
  }
@@ -982,7 +1124,7 @@ export async function startGitEnumeration(
982
1124
  flagged,
983
1125
  gitlinks,
984
1126
  undecodable,
985
- } = parseLsFilesOutput(ls.stdout, ls.undecodable)
1127
+ } = parseLsFilesOutput(debug!.plain, debug!.undecodable)
986
1128
  // Normalize `status`'s repo-root-relative paths to workspace-relative (strip
987
1129
  // the `--show-prefix`) so the dirty set is keyed identically to the trusted
988
1130
  // OID map. Without this, when the workspace root is a git subdir, a modified
@@ -1060,6 +1202,13 @@ export async function startGitEnumeration(
1060
1202
  untracked: dirty === null ? null : untracked,
1061
1203
  undecodable,
1062
1204
  startedAtMs,
1205
+ indexed: debug!.indexed,
1206
+ // A missing blob is an answer ("missing"), never a fetch from a partial
1207
+ // clone's promisor remote.
1208
+ catFile: (stdin) =>
1209
+ spawnGit(['cat-file', '--batch-check=%(objectname) %(objectsize)'], stdin, {
1210
+ GIT_NO_LAZY_FETCH: '1',
1211
+ }),
1063
1212
  }
1064
1213
  }
1065
1214
 
@@ -1089,13 +1238,15 @@ export function lazyGitEnumeration(workspaceRoot: string): LazyGitEnumeration {
1089
1238
  }
1090
1239
 
1091
1240
  /** The partition half of `populateGitFilesCache`: store per-project slices of one enumeration. */
1092
- export function applyGitEnumeration(
1241
+ export async function applyGitEnumeration(
1093
1242
  enumeration: GitEnumeration,
1094
1243
  workspaceRoot: string,
1095
1244
  projectDirs: readonly string[],
1096
1245
  cache: GitFilesCache,
1097
1246
  workspaceWide = false,
1098
- ): void {
1247
+ memo?: BlobSizeMemo,
1248
+ ): Promise<void> {
1249
+ await dropResizedOids(enumeration, memo)
1099
1250
  const { all, trusted } = enumeration
1100
1251
  cache.setWorktreeDirty(enumeration.dirty)
1101
1252
  cache.enumeratedAtMs = enumeration.startedAtMs
@@ -43,6 +43,7 @@ export {
43
43
  ownWorkspaceOutputsSince,
44
44
  stampOutputs,
45
45
  stampWorkspaceOutputs,
46
+ stopRuntimeProbes,
46
47
  type OutputStamp,
47
48
  } from './inputs.js'
48
49
  export {
@@ -448,6 +448,22 @@ async function runRuntimeCommand(
448
448
  */
449
449
  const liveProbes = new Set<ReturnType<typeof Bun.spawn>>()
450
450
  let probeExitHooked = false
451
+
452
+ /**
453
+ * Kill every runtime probe still running, with its tree. A run's stop asks
454
+ * this: a Ctrl-C while a probe ran waited for the probe, or for the signal
455
+ * handler's bound, about 7 s, before vx exited (C-65). Its answer is no
456
+ * longer needed; its caller sees it fail and the run reads it aborted.
457
+ */
458
+ export function stopRuntimeProbes(): void {
459
+ for (const p of liveProbes) {
460
+ try {
461
+ process.kill(-p.pid, 'SIGKILL')
462
+ } catch {
463
+ // the group is gone
464
+ }
465
+ }
466
+ }
451
467
  function killProbesOnExit(): void {
452
468
  if (probeExitHooked) return
453
469
  probeExitHooked = true
@@ -1041,6 +1057,13 @@ interface FilesPlan {
1041
1057
  positiveGlobs: Bun.Glob[]
1042
1058
  /** The literal entries, each naming one path (see `unmatchedLiterals`). */
1043
1059
  literals: string[]
1060
+ /**
1061
+ * Whether a project-relative path is an input by the declaration alone:
1062
+ * a positive glob selects it, and no exclude and no own output takes it
1063
+ * back. Projects repeat their relative paths (`src/index.ts`,
1064
+ * `package.json`), so each is matched once per plan.
1065
+ */
1066
+ verdicts: Map<string, boolean>
1044
1067
  }
1045
1068
 
1046
1069
  /**
@@ -1082,6 +1105,7 @@ function filesPlan(
1082
1105
  ownOutput: outputMatcher(ownOutputs, globFor),
1083
1106
  positiveGlobs: asTrees(positive).map(globFor),
1084
1107
  literals: positive.map(normalizeGlob).filter(isLiteralPattern).map(stripTrailingSlash),
1108
+ verdicts: new Map<string, boolean>(),
1085
1109
  }
1086
1110
  filesPlans.set(key, plan)
1087
1111
  return plan
@@ -1144,17 +1168,18 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1144
1168
  const unmatchedLiterals = new Set(plan.literals)
1145
1169
  // First pass: glob-filter to candidate absolute paths (no I/O).
1146
1170
  const candidates: string[] = []
1171
+ const verdicts = plan.verdicts
1147
1172
  for (const rel of gitFiles) {
1148
1173
  if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
1149
- let matched = false
1150
- for (const g of positiveGlobs) {
1151
- if (g.match(rel)) {
1152
- matched = true
1153
- break
1154
- }
1174
+ let input = verdicts.get(rel)
1175
+ if (input === undefined) {
1176
+ input =
1177
+ positiveGlobs.some((g) => g.match(rel)) &&
1178
+ !excludeGlobs.some((g) => g.match(rel)) &&
1179
+ !ownOutput(rel)
1180
+ verdicts.set(rel, input)
1155
1181
  }
1156
- if (!matched) continue
1157
- if (nested(rel) || excludeGlobs.some((g) => g.match(rel)) || ownOutput(rel)) continue
1182
+ if (!input || nested(rel)) continue
1158
1183
  candidates.push(path.resolve(args.projectDir, rel))
1159
1184
  }
1160
1185
  if (unmatchedLiterals.size > 0) {
@@ -99,7 +99,11 @@ import type { CacheKeyInput } from './layer.js'
99
99
  // v37: an entry saved while `git status` hid a deletion (paired as an
100
100
  // unmerged path's rename source) holds output built without a file its key
101
101
  // folds (A-59).
102
- export const CACHE_VERSION = 'vx-cache-v37'
102
+ // v38: an entry saved while the key folded a filtered LF blob for CRLF
103
+ // bytes on disk, the filter since removed, holds the CRLF build (A-60).
104
+ // v39: an entry saved while a gitlink without `.git` held files the key
105
+ // never listed sits under the key of that directory empty (A-61).
106
+ export const CACHE_VERSION = 'vx-cache-v39'
103
107
 
104
108
  /**
105
109
  * The digest a key folds for an input gone between its enumeration and its
@@ -115,6 +115,15 @@ export function createTables(db: Database): void {
115
115
  content_hash TEXT NOT NULL,
116
116
  seen_at INTEGER NOT NULL
117
117
  );
118
+ -- The size of each index blob the enumeration checked against the
119
+ -- worktree size git recorded (A-60). Fixed for its OID, so a warm
120
+ -- run asks git for none; swept with file_hashes by seen_at, the
121
+ -- time the row was written.
122
+ CREATE TABLE IF NOT EXISTS blob_sizes (
123
+ oid TEXT PRIMARY KEY,
124
+ size INTEGER NOT NULL,
125
+ seen_at INTEGER NOT NULL
126
+ );
118
127
  -- v16: per-output-file fingerprints, scoped by the cache entry
119
128
  -- that produced them. Lets loadOutputFilesBatch(hashes) answer
120
129
  -- "for entry X, what are its outputs supposed to look like?"
package/src/cli/show.ts CHANGED
@@ -339,6 +339,12 @@ function taskBlock(taskName: string, raw: TaskConfig): string {
339
339
  add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
340
340
  }
341
341
  const labelW = Math.max(...rows.map(([label]) => label.length))
342
- const body = rows.map(([label, value]) => ` ${`${label}:`.padEnd(labelW + 1)} ${value}`)
342
+ // A multi-line value (a generated `vx_script` wrapper, a heredoc)
343
+ // continues under its first line, not at column 0 where it reads as
344
+ // the next task's header.
345
+ const indent = `\n${' '.repeat(labelW + 4)}`
346
+ const body = rows.map(
347
+ ([label, value]) => ` ${`${label}:`.padEnd(labelW + 1)} ${value.replaceAll('\n', indent)}`,
348
+ )
343
349
  return `${taskName}\n${body.join('\n')}\n`
344
350
  }
@@ -141,7 +141,7 @@ export async function loadCliProjects(
141
141
  projectMetas: metas,
142
142
  seeds: scope,
143
143
  closure: false,
144
- lock,
144
+ lock: lock === null ? null : async () => lock,
145
145
  evalCache:
146
146
  cache === null
147
147
  ? undefined
package/src/exec/index.ts CHANGED
@@ -29,7 +29,13 @@ export {
29
29
  thrownReason,
30
30
  } from './sandbox-runtime.js'
31
31
  export { type DeniedCall } from './sandbox-violations.js'
32
- export { atOrUnder, isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
32
+ export {
33
+ atOrUnder,
34
+ isMountableLiteral,
35
+ MOUNT_WILDCARDS,
36
+ sandboxReads,
37
+ toRealPath,
38
+ } from './sandbox-paths.js'
33
39
  export { bindableWrites, punchWalls } from './sandbox-binds.js'
34
40
  export { isLocalExecutor, localExecutor } from './local-executor.js'
35
41
  export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
@@ -76,3 +76,25 @@ export function unique(arr: readonly string[]): string[] {
76
76
  export function localBindingOn(c: { localBinding?: boolean | readonly number[] }): boolean {
77
77
  return c.localBinding === true || (Array.isArray(c.localBinding) && c.localBinding.length > 0)
78
78
  }
79
+
80
+ /**
81
+ * Whether a sandboxed task may read `file`: it lies under a read grant,
82
+ * a write grant or a baseline read, by its canonical path. A path no
83
+ * grant holds is not there inside the sandbox, which the shell reports
84
+ * as "not found" with no denial a trace can see (an `execve`).
85
+ */
86
+ export function sandboxReads(
87
+ sandbox: {
88
+ readonly baseAllowRead: readonly string[]
89
+ readonly config: {
90
+ readonly allowRead: readonly string[]
91
+ readonly allowWrite: readonly string[]
92
+ }
93
+ },
94
+ file: string,
95
+ ): boolean {
96
+ const real = toRealPath(file)
97
+ return [...sandbox.baseAllowRead, ...sandbox.config.allowRead, ...sandbox.config.allowWrite].some(
98
+ (g) => atOrUnder(real, toRealPath(g)),
99
+ )
100
+ }
@@ -1006,12 +1006,21 @@ export async function wrapSandboxedCommand(
1006
1006
  process.platform === 'linux'
1007
1007
  ? ownGroupCommand(tag, inTmp, args.trace)
1008
1008
  : { command: taggedCommand, forwards: false, traced: false }
1009
- const inner =
1010
- ports.length > 0 ? `${portBridgeInner(ports, tag)} ${grouped.command}` : grouped.command
1009
+ const inner = [
1010
+ ports.length > 0 ? portBridgeInner(ports, tag) : '',
1011
+ process.platform === 'linux' && args.config.network !== undefined ? PROXY_BRIDGE_WAIT : '',
1012
+ grouped.command,
1013
+ ]
1014
+ .filter((part) => part !== '')
1015
+ .join(' ')
1011
1016
  let wrapped = await wrapForTask(
1012
1017
  SandboxManager,
1013
1018
  inner,
1014
- process.platform === 'linux' ? literalReadPaths(customConfig) : customConfig,
1019
+ process.platform === 'linux'
1020
+ ? literalReadPaths(customConfig)
1021
+ : process.platform === 'darwin'
1022
+ ? seatbeltBrackets(customConfig)
1023
+ : customConfig,
1015
1024
  ports.length > 0 || asksUnixSockets(args.config),
1016
1025
  args.config.gitConfig === true,
1017
1026
  )
@@ -1176,6 +1185,30 @@ function literalReadPaths(
1176
1185
  }
1177
1186
  }
1178
1187
 
1188
+ /**
1189
+ * macOS: a grant's escaped bracket as seatbelt's SRT can read it. vx hands
1190
+ * it the pattern, and SRT compiles any spelling holding `[` as a regex in
1191
+ * which a backslash is a literal one, so `pages/\[id\].tsx` matched no
1192
+ * file and the route could not be granted. `[[]` is a class of one `[`; a
1193
+ * lone `]` is plain text (B-65).
1194
+ */
1195
+ function seatbeltBrackets(
1196
+ config: Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2],
1197
+ ): Parameters<SrtModule['SandboxManager']['wrapWithSandbox']>[2] {
1198
+ const fs = config?.filesystem
1199
+ if (fs === undefined) return config
1200
+ const literal = (paths: readonly string[]): string[] =>
1201
+ paths.map((p) => p.replaceAll('\\[', '[[]').replaceAll('\\]', ']'))
1202
+ return {
1203
+ ...config,
1204
+ filesystem: {
1205
+ ...fs,
1206
+ allowWrite: literal(fs.allowWrite),
1207
+ ...(fs.allowRead !== undefined ? { allowRead: literal(fs.allowRead) } : {}),
1208
+ },
1209
+ }
1210
+ }
1211
+
1179
1212
  /** SRT's wrap, with the socket lift and the git-config grant this task asked for, or none (L-6, B-41). */
1180
1213
  function wrapForTask(
1181
1214
  SandboxManager: SrtModule['SandboxManager'],
@@ -1203,6 +1236,18 @@ export function bridgedPorts(c: Pick<ResolvedSandboxConfig, 'localBinding'>): nu
1203
1236
  return Array.isArray(c.localBinding) ? [...new Set(c.localBinding)] : []
1204
1237
  }
1205
1238
 
1239
+ /**
1240
+ * Linux: SRT starts its in-sandbox proxy bridges (`socat TCP-LISTEN:3128`
1241
+ * and `:1080`) in the background and runs the command at once, so a
1242
+ * networked task that dialled the proxy first met "connection refused"
1243
+ * (curl's `000`) on a loaded box (M-20). This waits, in front of the
1244
+ * command, until both listen in the task's network namespace, read off
1245
+ * /proc/net (IPv4 or IPv6, state 0A). Bounded at ~5 s: a bridge that never
1246
+ * listens leaves the command to meet the refusal it met before.
1247
+ */
1248
+ const PROXY_BRIDGE_WAIT =
1249
+ "( i=0; until grep -qsE ':0C38 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6 && grep -qsE ':0438 [0-9A-F]+:0000 0A' /proc/net/tcp /proc/net/tcp6; do [ $i -ge 500 ] && break; i=$((i+1)); sleep 0.01; done );"
1250
+
1206
1251
  /** Where a bridge's unix socket lives: the sandbox tmpdir, bound read-write on both sides. */
1207
1252
  export function portBridgeSocket(tag: string, port: number): string {
1208
1253
  return path.join(taskTmpdir(tag), `vx-port-${tag}-${port}.sock`)
@@ -100,7 +100,14 @@ export interface DeniedCall {
100
100
  // a child starts in its parent's cwd as it was at the fork.
101
101
  const CHDIR_DONE_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*0`)
102
102
  const CHDIR_UNFINISHED_RE = new RegExp(`^(\\d+)\\s+chdir\\(${QUOTED} <unfinished`)
103
- const CHDIR_RESUMED_RE = /^(\d+)\s+<\.\.\. chdir resumed>.*?=\s*(-?\d+)/
103
+ const CHDIR_RESUMED_RE =
104
+ /^(\d+)\s+<\.\.\. chdir resumed>.*?=\s*(-?\d+)(?:\s+(ENOENT|EACCES|EPERM))?/
105
+ // A denied `chdir` is a denied read of the directory: `cd src` into a
106
+ // directory no grant holds failed with no line of the trace's own, and
107
+ // `cd src || …` passed and cached.
108
+ const CHDIR_DENIED_RE = new RegExp(
109
+ `^(\\d+)\\s+chdir\\(${QUOTED}\\)\\s*=\\s*-1\\s+(ENOENT|EACCES|EPERM)`,
110
+ )
104
111
  const FCHDIR_RE = /^(\d+)\s+(?:fchdir\(\d+\)|<\.\.\. fchdir resumed>.*?)\s*=\s*0/
105
112
  const FORK = '(?:clone3?|v?fork)'
106
113
  const FORK_DONE_RE = new RegExp(`^(\\d+)\\s+${FORK}\\(.*\\)\\s*=\\s*(\\d+)$`)
@@ -150,6 +157,17 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
150
157
  out.push(call)
151
158
  }
152
159
  for (const line of text.split('\n')) {
160
+ // Most lines are opens that succeeded: two substring tests skip them.
161
+ const opener =
162
+ line.includes('= -1') || line.includes('<unfinished') || line.includes('resumed>')
163
+ if (!opener) {
164
+ if (
165
+ cwd !== undefined &&
166
+ (line.includes('chdir(') || line.includes('fork(') || line.includes('clone'))
167
+ )
168
+ follow(line)
169
+ continue
170
+ }
153
171
  const done = STRACE_DONE_RE.exec(line)
154
172
  if (done?.[2] !== undefined && done[3] !== undefined && done[4] !== undefined) {
155
173
  denied(done[1]!, { syscall: done[2], rawPath: cStringPath(done[3]), errno: done[4] })
@@ -176,15 +194,32 @@ export function deniedCalls(text: string, cwd?: string): DeniedCall[] {
176
194
  }
177
195
  continue
178
196
  }
179
- if (cwd === undefined) continue
197
+ // A refused or split `chdir` arrives here: its denial counts with or
198
+ // without the cwd tracking.
199
+ follow(line)
200
+ }
201
+ function follow(line: string): void {
180
202
  let m: RegExpExecArray | null
181
- if ((m = CHDIR_DONE_RE.exec(line)) !== null) opsOf(m[1]!).push({ chdir: cStringPath(m[2]!) })
182
- else if ((m = CHDIR_UNFINISHED_RE.exec(line)) !== null) chdirring.set(m[1]!, cStringPath(m[2]!))
183
- else if ((m = CHDIR_RESUMED_RE.exec(line)) !== null) {
203
+ if ((m = CHDIR_DENIED_RE.exec(line)) !== null) {
204
+ denied(m[1]!, { syscall: 'chdir', rawPath: cStringPath(m[2]!), errno: m[3]! })
205
+ return
206
+ }
207
+ if ((m = CHDIR_UNFINISHED_RE.exec(line)) !== null) {
208
+ chdirring.set(m[1]!, cStringPath(m[2]!))
209
+ return
210
+ }
211
+ if ((m = CHDIR_RESUMED_RE.exec(line)) !== null) {
184
212
  const to = chdirring.get(m[1]!)
185
213
  chdirring.delete(m[1]!)
186
214
  if (to !== undefined && m[2] === '0') opsOf(m[1]!).push({ chdir: to })
187
- } else if ((m = FCHDIR_RE.exec(line)) !== null) opsOf(m[1]!).push({ lost: true })
215
+ else if (to !== undefined && m[3] !== undefined) {
216
+ denied(m[1]!, { syscall: 'chdir', rawPath: to, errno: m[3] })
217
+ }
218
+ return
219
+ }
220
+ if (cwd === undefined) return
221
+ if ((m = CHDIR_DONE_RE.exec(line)) !== null) opsOf(m[1]!).push({ chdir: cStringPath(m[2]!) })
222
+ else if ((m = FCHDIR_RE.exec(line)) !== null) opsOf(m[1]!).push({ lost: true })
188
223
  else if ((m = FORK_DONE_RE.exec(line)) !== null) {
189
224
  forked(m[1]!, m[2]!, opsOf(m[1]!).length, line.includes('CLONE_FS'))
190
225
  } else if ((m = FORK_UNFINISHED_RE.exec(line)) !== null) {
@@ -726,14 +726,22 @@ export async function runGraph(options: ScheduleOptions): Promise<Map<string, Ta
726
726
  return
727
727
  }
728
728
  const message = err instanceof Error ? err.message : String(err)
729
+ // After the run's stop a rejection is the stop's doing (a probe
730
+ // it killed, C-65): aborted, as any task it kills, and unsaid.
731
+ const stopped = aborted()
729
732
  const outcome: TaskOutcome = withHold({
730
733
  node,
731
- status: 'failed',
734
+ status: stopped ? 'aborted' : 'failed',
732
735
  exitCode: 1,
733
736
  durationMs: 0,
734
737
  })
735
738
  leave()
736
739
  untrack()
740
+ if (stopped) {
741
+ finishOne(id, outcome)
742
+ tick()
743
+ return
744
+ }
737
745
  // A UserError is a config/input failure (e.g. a failed
738
746
  // `cache.inputs.runtime` command), not a vx bug — report it
739
747
  // plainly, never as an "internal error".
@@ -33,6 +33,7 @@ import {
33
33
  type TaskExecutor,
34
34
  type TaskInputs,
35
35
  PersistentReadyError,
36
+ sandboxReads,
36
37
  } from '../exec/index.js'
37
38
  import { isGroupTask, RestoreDemoted, type TaskNode, type TaskOutcome } from '../graph/index.js'
38
39
  import { killGraceMs, maskedEmitter, printable, relPosix, secretMask, span } from '../util/index.js'
@@ -875,6 +876,7 @@ async function executeCachedTask(args: ExecuteArgs): Promise<TaskOutcome> {
875
876
  cwd: node.projectDir,
876
877
  bins: taskBinDirs(node, args.workspaceRoot),
877
878
  signal: res.signal,
879
+ hidden: req.sandbox && ((f: string) => !sandboxReads(req.sandbox!, f)),
878
880
  })
879
881
  if (verdict !== undefined) log.taskStderr(node, `\n${verdict}\n`)
880
882
  // A committed file another task's clean removed, still gone: vx
@@ -230,7 +230,8 @@ export interface RunOptions {
230
230
  * The raw command string for the `invocations` row (e.g.
231
231
  * `'vx run build test --all'`). When absent, run() falls back to
232
232
  * `process.argv.slice(1).join(' ')` so programmatic callers still
233
- * record something useful.
233
+ * record something useful. Telemetry sinks get it with what follows
234
+ * `--` counted, not quoted, as they get the argv (C-67).
234
235
  */
235
236
  command?: string
236
237
  /**
@@ -32,6 +32,7 @@ import {
32
32
  type LoadReads,
33
33
  loadWorkspace,
34
34
  FROZEN_WITHOUT_LOCK,
35
+ type Lockfile,
35
36
  readLockfile,
36
37
  resolveCacheDir,
37
38
  type ProjectEntry,
@@ -282,10 +283,24 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
282
283
  // so consuming the lock by default would silently serve stale
283
284
  // freezes. `vx lock --check` is the full re-evaluation audit.
284
285
  // See docs/design/config-lock-2026-06.md.
285
- const lock = options.frozen === true ? await readLockfile(workspaceRoot) : null
286
- if (options.frozen === true && lock === null) {
287
- throw new UserError(FROZEN_WITHOUT_LOCK)
288
- }
286
+ // Read once, and only when a config is to be read from it: the CLI's
287
+ // selection pass may have staged every config (`options.staged`), and
288
+ // its load refused a frozen run without a lock. A 1,000-project lock is
289
+ // 1.1 MB of JSON (I-27).
290
+ let lockRead: Promise<Lockfile> | undefined
291
+ const readLock = (): Promise<Lockfile> =>
292
+ (lockRead ??= readLockfile(workspaceRoot).then((read) => {
293
+ if (read === null) throw new UserError(FROZEN_WITHOUT_LOCK)
294
+ return read
295
+ }))
296
+ const staged = options.staged
297
+ const allStaged =
298
+ staged !== undefined &&
299
+ projectMetas.every(
300
+ (m) => typeof m.configPath !== 'string' || m.configPath === '' || staged.has(m.name),
301
+ )
302
+ if (options.frozen === true && !allStaged) await readLock()
303
+ const lock = options.frozen === true ? readLock : null
289
304
 
290
305
  const loadArgs = {
291
306
  workspaceRoot,
@@ -441,7 +456,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
441
456
  workspaceRoot,
442
457
  gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
443
458
  ))
444
- applyGitEnumeration(enumeration, workspaceRoot, projectDirs, gitFilesCache, usesWorkspaceInputs)
459
+ await applyGitEnumeration(
460
+ enumeration,
461
+ workspaceRoot,
462
+ projectDirs,
463
+ gitFilesCache,
464
+ usesWorkspaceInputs,
465
+ localCache,
466
+ )
445
467
  mark('git enumeration')
446
468
  const hashCache = createHashCache()
447
469
  const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
@@ -116,8 +116,12 @@ interface LoadProjectsBase {
116
116
  * way, since the package graph cannot see the cross form.
117
117
  */
118
118
  seeds: 'all' | Iterable<string>
119
- /** Read configs from the lock instead of evaluating them (`--frozen`). */
120
- lock: Lockfile | null
119
+ /**
120
+ * Read configs from the lock instead of evaluating them (`--frozen`),
121
+ * asked only when a config is to be read: a run whose every config the
122
+ * CLI's selection pass staged never parses it again.
123
+ */
124
+ lock: (() => Promise<Lockfile>) | null
121
125
  evalCache: LoadProjectConfigOptions['evalCache']
122
126
  warn: (message: string) => void
123
127
  /**
@@ -223,7 +227,9 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
223
227
  typeof m.configPath === 'string' && args.staged?.get(m.name) === undefined,
224
228
  )
225
229
  const loaded = lock
226
- ? await frozenProjectConfigs(lock, withFile, workspaceRoot, args.evalCache?.store)
230
+ ? withFile.length === 0
231
+ ? []
232
+ : await frozenProjectConfigs(await lock(), withFile, workspaceRoot, args.evalCache?.store)
227
233
  : await loadProjectConfigs(
228
234
  withFile.map((m) => m.configPath),
229
235
  args.evalCache !== undefined ? { evalCache: args.evalCache } : {},
@@ -5,7 +5,12 @@
5
5
  import type { ProjectEntry } from '../workspace/index.js'
6
6
  import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
7
  import path from 'node:path'
8
- import { type CacheLayer, type CachePolicy, FULL_CACHE_POLICY } from '../cache/index.js'
8
+ import {
9
+ type CacheLayer,
10
+ type CachePolicy,
11
+ FULL_CACHE_POLICY,
12
+ stopRuntimeProbes,
13
+ } from '../cache/index.js'
9
14
  import { VERSION } from '../version.js'
10
15
  import {
11
16
  resetSandbox,
@@ -533,6 +538,7 @@ async function runOnBus(
533
538
  // ends after it is the stop's own kill, and was named a crash (item 1061).
534
539
  let endedBeforeStop: ReadonlySet<ReturnType<typeof Bun.spawn>> | undefined
535
540
  const onAbort = (): void => {
541
+ stopRuntimeProbes()
536
542
  endedBeforeStop = new Set([...persistentRegistry.values()].filter(hasEnded))
537
543
  aborting = terminateChildren(
538
544
  () => [...liveChildren, ...persistentRegistry.values()],
@@ -634,7 +640,9 @@ async function runOnBus(
634
640
  runContextRecord = {
635
641
  runId,
636
642
  vxVersion: VERSION,
637
- command: options.command ?? invocationCommand(process.argv.slice(1)),
643
+ // An embedder's `command` is redacted as the argv is: it passed a
644
+ // token after `--` to every sink verbatim (C-67).
645
+ command: invocationCommand(options.command?.split(' ') ?? process.argv.slice(1)),
638
646
  requestedTasks: [...options.tasks],
639
647
  cachePolicy: compactCachePolicy(policy),
640
648
  concurrency,
@@ -187,13 +187,14 @@ export async function sandboxRequestFor(
187
187
  */
188
188
  cacheDir?: string,
189
189
  ): Promise<SandboxRequest> {
190
- // SRT reads a Linux path holding a bracket as a glob and drops it from
191
- // the writes, so no grant here could be mounted (B-60).
190
+ // The runtime reads a path holding a bracket as a pattern: on Linux it
191
+ // mounts no such write path (B-60); seatbelt's rules compile it as a
192
+ // character class, so vx's own workspace wall matched nothing (B-65).
192
193
  const home = toRealPath(node.projectDir)
193
- if (process.platform === 'linux' && /[[\]]/.test(home)) {
194
+ if (process.platform !== 'win32' && /[[\]]/.test(home)) {
194
195
  throw new UserError(
195
- `exec.sandbox: ${home} holds a bracket ([ or ]), and the Linux sandbox mounts no path ` +
196
- `that does — rename the directory, or run the task without exec.sandbox`,
196
+ `exec.sandbox: ${home} holds a bracket ([ or ]), which the sandbox runtime reads as a ` +
197
+ `pattern, not a name — rename the directory, or run the task without exec.sandbox`,
197
198
  )
198
199
  }
199
200
  const depDirs = [
@@ -23,13 +23,22 @@ export function shellVerdict(args: {
23
23
  bins: string[]
24
24
  /** The signal that killed the child when the runner saw one (`RunResult.signal`). */
25
25
  signal?: string | undefined
26
+ /** A sandboxed task's view: whether a file is hidden from it (no grant reads it). */
27
+ hidden?: ((file: string) => boolean) | undefined
26
28
  }): string | undefined {
27
29
  if (args.code !== 127 && args.code !== 126) return signalVerdict(args.code, args.signal)
28
30
  const word = execWord(args.command)
29
31
  const what = word ?? 'a command in this task'
30
32
  if (word !== undefined && word.includes('/')) {
31
33
  const file = path.resolve(args.cwd, word)
32
- return `[vx] exit ${args.code} is the shell's "${args.code === 127 ? 'not found' : 'cannot execute'}": ${word} ${fileVerdict(file)}`
34
+ // The file the host has may be one the sandbox does not: then the
35
+ // shell's "not found" is the sandbox's, and the file's own state (its
36
+ // `#!` line) is not the reason.
37
+ const verdict =
38
+ existsSync(file) && args.hidden?.(file) === true
39
+ ? `exists, but no sandbox grant reads it, so inside the sandbox it is not there — add it (or its directory) to exec.sandbox.allow.read`
40
+ : fileVerdict(file)
41
+ return `[vx] exit ${args.code} is the shell's "${args.code === 127 ? 'not found' : 'cannot execute'}": ${word} ${verdict}`
33
42
  }
34
43
  if (args.code === 126) {
35
44
  return `[vx] exit 126 is the shell's "found but cannot execute": ${what} is not executable or is a directory — chmod +x it`
@@ -765,6 +765,24 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
765
765
  implicitDependencies: 'a `dependsOn` entry `pkg#task`, or a package.json dependency',
766
766
  name: 'the package.json `name` (a project is named by its package)',
767
767
  tags: '`--filter` (a name glob or a directory) to select projects',
768
+ // A package.json's, turbo.json's and project.json's keys, and a
769
+ // task's fields one level too high (D-99).
770
+ scripts:
771
+ "`tasks` (a script is a task's `exec.command`; `vx init` writes them from package.json)",
772
+ pipeline: '`tasks` (`bunx @vzn/vx-migrate` writes them from turbo.json)',
773
+ namedInputs: 'a module the vx.config imports, spread into `cache.inputs.files`',
774
+ root: "no field: a project's directory is where its package.json sits",
775
+ sourceRoot: "a task's `cache.inputs.files`",
776
+ projectType: 'no field: vx runs every project alike',
777
+ ...Object.fromEntries(
778
+ ['dependsOn', 'cache', 'exec', 'description'].map((k) => [
779
+ k,
780
+ `\`tasks.<name>.${k}\`: a project holds tasks, and each task its own \`${k}\``,
781
+ ]),
782
+ ),
783
+ inputs: '`tasks.<name>.cache.inputs.files`',
784
+ outputs: '`tasks.<name>.cache.outputs.files`',
785
+ env: '`tasks.<name>.cache.inputs.env` (to key a task on it) or `tasks.<name>.exec.env`',
768
786
  },
769
787
  ],
770
788
  // Turbo's turbo.json and Nx's nx.json keys, written into vx.workspace
@@ -55,12 +55,14 @@ const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepubli
55
55
  */
56
56
  export function delegatedScript(command: string): string | null {
57
57
  const m =
58
- /^(?:(?:npm run|(pnpm|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
58
+ /^(?:(?:npm run|(pnpm|pn|yarn|bun)(?: (run))?) ([^\s&|;<>()$`'"\\]+)|npm (test|start))$/.exec(
59
59
  command.trim(),
60
60
  )
61
61
  if (m === null) return null
62
62
  if (m[4] !== undefined) return m[4]
63
- const [, manager, run, name] = m
63
+ const [, alias, run, name] = m
64
+ // `pn` is pnpm's own short name (pnpm 11; pnpm/pnpm's scripts run it).
65
+ const manager = alias === 'pn' ? 'pnpm' : alias
64
66
  // Bare, the manager's own command wins over a script of that name:
65
67
  // `bun test` is Bun's test runner and `bun build` its bundler, never the
66
68
  // `test` / `build` script, and a group over the script ran the wrong
@@ -305,7 +307,7 @@ const MEMBER_FLAG =
305
307
  function pmRunsMembers(script: string): boolean {
306
308
  for (const segment of script.split(/&&|\|\||[;|()]/)) {
307
309
  const words = segment.trim().split(/\s+/)
308
- let i = words.findIndex((w) => /^(pnpm|npm|yarn|bun)$/.test(w))
310
+ let i = words.findIndex((w) => /^(pnpm|pn|npm|yarn|bun)$/.test(w))
309
311
  // npm/cli runs itself: `node . run test --workspaces`.
310
312
  if (i < 0) i = words.findIndex((w, j) => w === 'run' && words[j - 2] === 'node')
311
313
  if (i < 0) continue
@@ -343,7 +345,7 @@ function cdsToMembers(script: string, rootDir: string, memberDirs: readonly stri
343
345
 
344
346
  /** A package manager running a script by name: `pnpm build`, `npm run x`, `bun run x`. */
345
347
  const RUNS_SCRIPT =
346
- /(?:^|[\s;&|(])(?:pnpm|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
348
+ /(?:^|[\s;&|(])(?:pnpm|pn|npm|yarn|bun)\s+(?:run(?:-script)?\s+)?([^\s;&|()'"-][^\s;&|()'"]*)/g
347
349
 
348
350
  /**
349
351
  * The scripts a command runs by name: a package manager's (`pnpm x`, `npm