@vzn/vx 0.0.130 → 0.0.132

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.130",
3
+ "version": "0.0.132",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.130",
21
- "@vzn/vx-linux-arm64": "0.0.130",
22
- "@vzn/vx-darwin-x64": "0.0.130",
23
- "@vzn/vx-darwin-arm64": "0.0.130"
20
+ "@vzn/vx-linux-x64": "0.0.132",
21
+ "@vzn/vx-linux-arm64": "0.0.132",
22
+ "@vzn/vx-darwin-x64": "0.0.132",
23
+ "@vzn/vx-darwin-arm64": "0.0.132"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -224,14 +224,14 @@ export async function planArtifact(args: PackArgs): Promise<ArtifactPlan> {
224
224
  const st = await stat(abs).catch((err: NodeJS.ErrnoException) => {
225
225
  if (err.code === 'ENOENT') {
226
226
  throw new UserError(
227
- `output ${shown} is a dangling symlink: vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces (output globs take no '!')`,
227
+ `output ${shown} is a dangling symlink: vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
228
228
  )
229
229
  }
230
230
  throw err
231
231
  })
232
232
  if (!st.isFile()) {
233
233
  throw new UserError(
234
- `output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces (output globs take no '!')`,
234
+ `output ${shown} is not a regular file (a symlink to a directory?): vx stores regular files only — emit a file there, or narrow cache.outputs.files to the files the task produces, or take it back with a '!' entry`,
235
235
  )
236
236
  }
237
237
  // Windows reports 0o666 (0o444 read-only) and never an exec bit: a
@@ -32,16 +32,15 @@ import path from 'node:path'
32
32
  import { createTables } from './schema.js'
33
33
  import {
34
34
  UserError,
35
- asTrees,
36
35
  formatBytes,
37
36
  isDiskFull,
38
37
  isFsRefusal,
39
38
  isOutOfFds,
40
39
  OUT_OF_FDS_HINT,
40
+ outputMatcher,
41
41
  relPosix,
42
42
  span,
43
43
  splitTaskId,
44
- taskGlob,
45
44
  xxh3hex,
46
45
  } from '../util/index.js'
47
46
  import {
@@ -1952,13 +1951,14 @@ function assertArtifactNames(
1952
1951
  }
1953
1952
  }
1954
1953
  if (declared === undefined) return
1955
- const files = asTrees(declared.files).map(taskGlob)
1956
- const wsFiles = asTrees(declared.workspaceFiles).map(taskGlob)
1954
+ // A path a `!` entry takes back is no declared output either (A-44).
1955
+ const isFile = outputMatcher(declared.files)
1956
+ const isWsFile = outputMatcher(declared.workspaceFiles)
1957
1957
  for (const name of names) {
1958
1958
  const ok = name.startsWith('outputs/')
1959
- ? files.some((g) => g.match(name.slice('outputs/'.length)))
1959
+ ? isFile(name.slice('outputs/'.length))
1960
1960
  : name.startsWith(WORKSPACE_OUTPUT_PREFIX)
1961
- ? wsFiles.some((g) => g.match(name.slice(WORKSPACE_OUTPUT_PREFIX.length)))
1961
+ ? isWsFile(name.slice(WORKSPACE_OUTPUT_PREFIX.length))
1962
1962
  : true
1963
1963
  if (!ok) {
1964
1964
  throw new CorruptArtifactError(
@@ -25,7 +25,9 @@ import {
25
25
  isExecutableMissing,
26
26
  isLiteralPattern,
27
27
  normalizeGlob,
28
+ outputMatcher,
28
29
  shellArgv,
30
+ splitNegations,
29
31
  slashBraceExpansions,
30
32
  taskGlob,
31
33
  UserError,
@@ -242,11 +244,10 @@ async function resolveWorkspaceFiles(args: {
242
244
  }
243
245
  if (positive.length === 0) return []
244
246
 
245
- const excludeGlobs = [
246
- ...ALWAYS_IGNORE,
247
- ...asTrees(args.ownWorkspaceOutputs),
248
- ...asTrees(negative),
249
- ].map(globFor)
247
+ const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor)
248
+ // A path the task's own outputs take back with `!` is no output, so it
249
+ // stays an input (A-44).
250
+ const ownOutput = outputMatcher(args.ownWorkspaceOutputs, globFor)
250
251
  const positiveGlobs = asTrees(positive).map(globFor)
251
252
  // Workspace-wide partition, keyed by the workspace root. Populated
252
253
  // up-front by `populateGitFilesCache(..., workspaceWide: true)` when
@@ -276,7 +277,7 @@ async function resolveWorkspaceFiles(args: {
276
277
  gitFiles,
277
278
  positive,
278
279
  positiveGlobs,
279
- excludeGlobs,
280
+ (rel) => excludeGlobs.some((g) => g.match(rel)) || ownOutput(rel),
280
281
  undecodable,
281
282
  )
282
283
  if (memoKey !== undefined) args.memo!.set(memoKey, { snapshot: gitFiles, result })
@@ -288,7 +289,7 @@ async function resolveWorkspaceFilesOver(
288
289
  gitFiles: readonly string[],
289
290
  positive: readonly string[],
290
291
  positiveGlobs: readonly Bun.Glob[],
291
- excludeGlobs: readonly Bun.Glob[],
292
+ excluded: (rel: string) => boolean,
292
293
  undecodable: ReadonlySet<string> | undefined,
293
294
  ): Promise<string[]> {
294
295
  // Second call site of the literal-input guard. `resolveWorkspaceFiles`
@@ -302,7 +303,7 @@ async function resolveWorkspaceFilesOver(
302
303
  for (const rel of gitFiles) {
303
304
  if (unmatchedLiterals.size > 0) settleLiterals(unmatchedLiterals, rel)
304
305
  if (!positiveGlobs.some((g) => g.match(rel))) continue
305
- if (excludeGlobs.some((g) => g.match(rel))) continue
306
+ if (excluded(rel)) continue
306
307
  candidates.push(path.resolve(args.workspaceRoot, rel))
307
308
  }
308
309
  if (unmatchedLiterals.size > 0) {
@@ -510,11 +511,12 @@ export async function resolveOutputs(args: {
510
511
  outputs: string[]
511
512
  nestedProjectDirs: string[]
512
513
  }): Promise<string[]> {
513
- if (args.outputs.length === 0) return []
514
- const excludeGlobs = outputExcludes(args.outputs)
514
+ const { positive, negative } = splitNegations(args.outputs)
515
+ if (positive.length === 0) return []
516
+ const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
515
517
  const scanned = [
516
518
  ...(await scanUnion(
517
- asTrees(args.outputs),
519
+ asTrees(positive),
518
520
  excludeGlobs,
519
521
  args.projectDir,
520
522
  inNestedProject(args.projectDir, args.nestedProjectDirs),
@@ -781,10 +783,10 @@ export async function resolveWorkspaceOutputs(args: {
781
783
  workspaceRoot: string
782
784
  outputs: string[]
783
785
  }): Promise<string[]> {
784
- if (args.outputs.length === 0) return []
785
- const scanned = [
786
- ...(await scanUnion(asTrees(args.outputs), outputExcludes(args.outputs), args.workspaceRoot)),
787
- ]
786
+ const { positive, negative } = splitNegations(args.outputs)
787
+ if (positive.length === 0) return []
788
+ const excludeGlobs = [...outputExcludes(positive), ...asTrees(negative).map(globFor)]
789
+ const scanned = [...(await scanUnion(asTrees(positive), excludeGlobs, args.workspaceRoot))]
788
790
  // Same containment as the project twin, anchored one level out. These globs
789
791
  // deliberately ignore PROJECT boundaries — that is the escape hatch — but
790
792
  // escaping the WORKSPACE was never part of it, and `cleanWorkspaceOutputs`
@@ -964,9 +966,10 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
964
966
  if (positive.length === 0) return []
965
967
 
966
968
  const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
967
- const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(args.ownOutputs), ...asTrees(negative)].map(
968
- globFor,
969
- )
969
+ const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor)
970
+ // A path the task's own outputs take back with `!` is no output, so it
971
+ // stays an input: a tracked file under `dist` the build reads (A-44).
972
+ const ownOutput = outputMatcher(args.ownOutputs, globFor)
970
973
 
971
974
  // Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
972
975
  // files, .git/info/exclude, and global excludes all participate
@@ -1031,7 +1034,7 @@ async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1031
1034
  }
1032
1035
  }
1033
1036
  if (!matched) continue
1034
- if (nested(rel) || excludeGlobs.some((g) => g.match(rel))) continue
1037
+ if (nested(rel) || excludeGlobs.some((g) => g.match(rel)) || ownOutput(rel)) continue
1035
1038
  candidates.push(path.resolve(args.projectDir, rel))
1036
1039
  }
1037
1040
  if (unmatchedLiterals.size > 0) {
package/src/cli/cache.ts CHANGED
@@ -59,6 +59,7 @@ interface PruneArgs {
59
59
  olderThanMs?: number
60
60
  maxBytes?: number
61
61
  dryRun?: boolean
62
+ format?: 'pretty' | 'json'
62
63
  /** `--cache-dir`: prune the cache a run with the same flag uses. */
63
64
  cacheDir?: string
64
65
  error?: string
@@ -110,6 +111,12 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
110
111
  out.maxBytes = bytes
111
112
  } else if (a === '--dry-run') {
112
113
  out.dryRun = true
114
+ } else if (a === '--format' || a?.startsWith('--format=')) {
115
+ const v = a === '--format' ? args[++i] : a.slice('--format='.length)
116
+ if (v !== 'pretty' && v !== 'json') {
117
+ return { error: `--format must be pretty or json${seeHelp('cache')}` }
118
+ }
119
+ out.format = v
113
120
  } else {
114
121
  const cd = parseCacheDirFlag(args, i)
115
122
  if (cd === null)
@@ -151,10 +158,10 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
151
158
  // to find that out made it: `.vx/cache` with a database and a
152
159
  // `.gitignore`, where item 900 had held the dry run to making nothing
153
160
  // (and a read-only checkout was refused as unwritable instead).
161
+ const dry = parsed.dryRun === true
162
+ const json = parsed.format === 'json'
154
163
  if (!existsSync(dir)) {
155
- process.stdout.write(
156
- parsed.dryRun === true ? 'Would prune 0 entries (0 B)\n' : 'Pruned 0 entries (0 B freed)\n',
157
- )
164
+ printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
158
165
  return 0
159
166
  }
160
167
  if (parsed.dryRun === true) {
@@ -163,7 +170,7 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
163
170
  warnToStderr(
164
171
  `[vx] the cache index is schema ${earlier.found} from an earlier vx: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
165
172
  )
166
- printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true)
173
+ printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true, json)
167
174
  return 0
168
175
  }
169
176
  }
@@ -187,7 +194,7 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
187
194
  if (parsed.olderThanMs !== undefined) opts.olderThanMs = parsed.olderThanMs
188
195
  if (parsed.maxBytes !== undefined) opts.maxBytes = parsed.maxBytes
189
196
  if (parsed.dryRun) opts.dryRun = true
190
- printPruned(await cache.prune(opts), parsed.dryRun === true)
197
+ printPruned(await cache.prune(opts), dry, json)
191
198
  } finally {
192
199
  cache.close()
193
200
  await releaseRunLock()
@@ -198,7 +205,15 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
198
205
  function printPruned(
199
206
  result: { evicted: number; bytesFreed: number; orphans: number; orphanBytes: number },
200
207
  dry: boolean,
208
+ json: boolean,
201
209
  ): void {
210
+ if (json) {
211
+ const { evicted, bytesFreed, orphans, orphanBytes } = result
212
+ process.stdout.write(
213
+ `${JSON.stringify({ dryRun: dry, evicted, bytesFreed, orphans, orphanBytes })}\n`,
214
+ )
215
+ return
216
+ }
202
217
  const orphans =
203
218
  result.orphans > 0
204
219
  ? `, ${dry ? 'would reap' : 'reaped'} ${result.orphans} orphaned artifact${result.orphans === 1 ? '' : 's'} (${formatBytes(result.orphanBytes)})`
package/src/cli/help.ts CHANGED
@@ -42,7 +42,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
42
42
  'Usage:',
43
43
  ' vx run [OPTIONS] [TASK | PKG#TASK] [-- forwarded-args...]',
44
44
  ' vx watch [OPTIONS] TASK [-- forwarded-args...]',
45
- ' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--cache-dir <path>]',
45
+ ' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--format pretty|json] [--cache-dir <path>]',
46
46
  ' vx lock [--check]',
47
47
  ' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
48
48
  ' vx upgrade [tag]',
@@ -125,6 +125,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
125
125
  ' vx cache prune --older-than 30d Evict entries last accessed > 30 days ago.',
126
126
  ' vx cache prune --max-size 1G Keep total cache under 1 GB (LRU eviction).',
127
127
  ' --dry-run Say what the policy would reap; delete nothing.',
128
+ ' --format <fmt> pretty (default) | json.',
128
129
  ' --cache-dir <path> The cache a run with the same flag uses (why, last, info too).',
129
130
  '',
130
131
  ' Duration units: s, m, h, d. Size units: K, M, G, T (powers of 1024).',
@@ -7,6 +7,7 @@ import {
7
7
  executablePath,
8
8
  isLiteralPattern,
9
9
  normalizeGlob,
10
+ outputMatcher,
10
11
  staticPrefix,
11
12
  taskGlob,
12
13
  } from '../util/index.js'
@@ -67,11 +68,14 @@ export function makeWatchIgnore(
67
68
  // every ancestor of it under the dir are output containers.
68
69
  // A literal entry means the file or its whole tree — the resolver's own
69
70
  // rule (`asTrees`), so the tree's files never count as edits.
71
+ // A `!` entry takes its paths back, so an edit there is an edit (A-44);
72
+ // compiled as a glob it would be `Bun.Glob`'s own negation and hide every
73
+ // other path.
70
74
  const declared = [...outputs].map(
71
75
  ([dir, globs]) =>
72
76
  [
73
77
  path.resolve(dir),
74
- asTrees(globs).map((g) => taskGlob(g)),
78
+ outputMatcher(globs),
75
79
  globs.map(outputContainer).filter((c) => c !== ''),
76
80
  ] as const,
77
81
  )
@@ -101,13 +105,13 @@ export function makeWatchIgnore(
101
105
  const abs = path.resolve(base, filename)
102
106
  if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
103
107
  if (isInput(abs)) return false
104
- for (const [dir, globs, containers] of declared) {
108
+ for (const [dir, isOutput, containers] of declared) {
105
109
  if (!abs.startsWith(dir + path.sep)) continue
106
110
  const rel = abs
107
111
  .slice(dir.length + 1)
108
112
  .split(path.sep)
109
113
  .join('/')
110
- if (globs.some((g) => g.match(rel))) return true
114
+ if (isOutput(rel)) return true
111
115
  if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
112
116
  }
113
117
  return false
package/src/exec/index.ts CHANGED
@@ -28,7 +28,7 @@ export {
28
28
  thrownReason,
29
29
  } from './sandbox-runtime.js'
30
30
  export { type DeniedCall } from './sandbox-violations.js'
31
- export { isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
31
+ export { atOrUnder, isMountableLiteral, MOUNT_WILDCARDS, toRealPath } from './sandbox-paths.js'
32
32
  export { bindableWrites, punchWalls } from './sandbox-binds.js'
33
33
  export { isLocalExecutor, localExecutor } from './local-executor.js'
34
34
  export { holdGroups, killTree, untilGroupsGone } from './kill-tree.js'
@@ -52,10 +52,18 @@ export function absolutize(p: string, cwd?: string): string {
52
52
  return path.resolve(cwd ?? process.cwd(), p)
53
53
  }
54
54
 
55
+ /**
56
+ * `p` is `dir` or below it, by path: a sibling sharing `dir`'s name prefix
57
+ * is not, and `/` already ends in the separator.
58
+ */
59
+ export function atOrUnder(p: string, dir: string): boolean {
60
+ return p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
61
+ }
62
+
55
63
  export function isUnderAny(abs: string, allow: Set<string>): boolean {
56
64
  if (allow.has(abs)) return true
57
65
  for (const a of allow) {
58
- if (abs === a || abs.startsWith(a + path.sep)) return true
66
+ if (atOrUnder(abs, a)) return true
59
67
  }
60
68
  return false
61
69
  }
@@ -64,6 +64,7 @@ import {
64
64
  } from '../util/index.js'
65
65
  import { bindableWrites, buildCustomConfig } from './sandbox-binds.js'
66
66
  import {
67
+ atOrUnder,
67
68
  isMountableLiteral,
68
69
  localBindingOn,
69
70
  MOUNT_WILDCARDS,
@@ -719,16 +720,14 @@ function canonicalBaselines(
719
720
  function assertWriteStaysHome(grant: string, real: string, projectDir: string): void {
720
721
  if (grant.startsWith('~') || path.isAbsolute(grant)) return
721
722
  const lexical = path.resolve(projectDir, grant)
722
- const inside = (p: string, dir: string): boolean =>
723
- p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
724
- if (!inside(lexical, projectDir)) return
723
+ if (!atOrUnder(lexical, projectDir)) return
725
724
  const home = toRealPath(projectDir)
726
725
  // `toRealPath` stops at a DANGLING link and keeps its own path, which is
727
726
  // inside; the link's target is where a write through it would land.
728
727
  const target = throughLinks(real)
729
- if (inside(real, home) && inside(target, home)) return
728
+ if (atOrUnder(real, home) && atOrUnder(target, home)) return
730
729
  throw new UserError(
731
- `exec.sandbox.allow.write: "${grant}" resolves through a symlink to ${inside(real, home) ? target : real}, outside the ` +
730
+ `exec.sandbox.allow.write: "${grant}" resolves through a symlink to ${atOrUnder(real, home) ? target : real}, outside the ` +
732
731
  `project — a write grant binds the path it names in the project, and vx does not follow a ` +
733
732
  `link out of it. Remove the link, or grant the target by its own path.`,
734
733
  )
@@ -1557,8 +1556,6 @@ async function runSandboxedOnce(
1557
1556
  */
1558
1557
  function readableUnder(dir: string, granted: readonly string[]): boolean {
1559
1558
  const target = toRealPath(dir)
1560
- const under = (a: string, b: string): boolean =>
1561
- a === b || a.startsWith(b.endsWith(path.sep) ? b : b + path.sep)
1562
1559
  return granted.some((p) => {
1563
1560
  const g = toRealPath(p)
1564
1561
  // On Linux a grant INSIDE the cwd makes the cwd listable too: bwrap
@@ -1566,7 +1563,7 @@ function readableUnder(dir: string, granted: readonly string[]): boolean {
1566
1563
  // children around the walls (`wallOff`), so every failing root task
1567
1564
  // was told its cwd was unreadable while it listed it (B-20). Seatbelt
1568
1565
  // grants no parent, so on macOS the cwd itself must be covered.
1569
- return under(target, g) || (process.platform === 'linux' && under(g, target))
1566
+ return atOrUnder(target, g) || (process.platform === 'linux' && atOrUnder(g, target))
1570
1567
  })
1571
1568
  }
1572
1569
 
@@ -1736,9 +1733,7 @@ export function wallsGlobsReach(grants: readonly string[], walls: readonly strin
1736
1733
  const heads = grants
1737
1734
  .filter((g) => !isMountableLiteral(g))
1738
1735
  .map((g) => path.dirname(g.slice(0, g.search(MOUNT_WILDCARDS))))
1739
- return walls.filter((w) =>
1740
- heads.some((h) => w === h || w.startsWith(h === path.sep ? h : h + path.sep)),
1741
- )
1736
+ return walls.filter((w) => heads.some((h) => atOrUnder(w, h)))
1742
1737
  }
1743
1738
 
1744
1739
  /**
@@ -1760,7 +1755,7 @@ export function darwinWallRules(
1760
1755
  for (const w of walls) {
1761
1756
  const at = `(subpath "${sbplResolvedPath(w, 'wall')}")`
1762
1757
  const kept = literals
1763
- .filter((l) => isMountableLiteral(l) && (l === w || l.startsWith(w + path.sep)))
1758
+ .filter((l) => isMountableLiteral(l) && atOrUnder(l, w))
1764
1759
  .map((l) => `(require-not (subpath "${sbplResolvedPath(l, 'grant')}"))`)
1765
1760
  rules.push(
1766
1761
  kept.length === 0
@@ -1827,12 +1822,12 @@ function expandGrants(
1827
1822
  for (const hit of scanOrNothing(pattern, base)) {
1828
1823
  const abs = path.join(base, hit)
1829
1824
  const real = toRealPath(abs)
1830
- if (real !== home && !real.startsWith(home + path.sep)) continue
1825
+ if (!atOrUnder(real, home)) continue
1831
1826
  // A hit that IS a wall, or lies inside one, was matched, not named:
1832
1827
  // `read: ['*']` in a root project bound `.git` and `.vx`, and
1833
1828
  // `packages/*` a nested project its key excludes (B-1). A grant that
1834
1829
  // names a wall literally never reaches this loop and stays.
1835
- if (walls.some((w) => real === w || real.startsWith(w + path.sep))) continue
1830
+ if (walls.some((w) => atOrUnder(real, w))) continue
1836
1831
  out.push(abs)
1837
1832
  hits++
1838
1833
  }
@@ -5,7 +5,7 @@
5
5
  // no grant can avoid, minus what the task chose to ignore.
6
6
 
7
7
  import path from 'node:path'
8
- import { absolutize, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
8
+ import { absolutize, atOrUnder, isUnderAny, localBindingOn, toRealPath } from './sandbox-paths.js'
9
9
  import type {
10
10
  ResolvedSandboxConfig,
11
11
  SandboxedRunArgs,
@@ -119,7 +119,7 @@ export async function parseStraceViolations(
119
119
  const abs = toRealPath(absolutize(rawPath, baselines.cwd))
120
120
  // Only report paths under the workspace-root deny anchor — system
121
121
  // libs / /proc / /sys / etc. probes are not interesting violations.
122
- if (!denyAnchors.some((root) => abs === root || abs.startsWith(root + path.sep))) continue
122
+ if (!denyAnchors.some((root) => atOrUnder(abs, root))) continue
123
123
  // Skip paths the user explicitly allowed (and their descendants).
124
124
  if (isUnderAny(abs, allowAbs)) continue
125
125
  const key = `${syscall}|${abs}`
@@ -3,6 +3,7 @@ import type { ProjectConfig, TaskConfig } from '../config.js'
3
3
  import {
4
4
  asTrees,
5
5
  isLiteralPattern,
6
+ splitNegations,
6
7
  staticPrefix,
7
8
  taskGlob,
8
9
  UserError,
@@ -773,9 +774,16 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
773
774
  if ((outs?.workspaceFiles?.length ?? 0) > 0) wsDeclarers.push(n)
774
775
  }
775
776
 
776
- const filesOf = (n: TaskNode): readonly string[] | undefined => n.config.cache?.outputs.files
777
+ // Where each task's outputs MIGHT land: its positive globs. A `!` entry
778
+ // only narrows, so leaving it out can refuse a pair it separates, never
779
+ // pass one it does not (A-44); read as a glob it is `Bun.Glob`'s own
780
+ // negation, which overlaps nearly everything.
781
+ const positiveOf = (globs: readonly string[] | undefined): readonly string[] | undefined =>
782
+ globs === undefined ? undefined : splitNegations(globs).positive
783
+ const filesOf = (n: TaskNode): readonly string[] | undefined =>
784
+ positiveOf(n.config.cache?.outputs.files)
777
785
  const wsFilesOf = (n: TaskNode): readonly string[] | undefined =>
778
- n.config.cache?.outputs.workspaceFiles
786
+ positiveOf(n.config.cache?.outputs.workspaceFiles)
779
787
  for (const bucket of byProject.values()) {
780
788
  // Most projects hold one task with outputs, and a call per project
781
789
  // cost the cold build a measured 0.4 ms at 1,000 projects.
@@ -994,10 +1002,11 @@ function collide(
994
1002
  // edge the two run in either order, and the refusal below stands.
995
1003
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
996
1004
  if (up !== undefined && down !== undefined) {
997
- const downGlobs =
998
- field === 'files'
1005
+ const downGlobs = splitNegations(
1006
+ (field === 'files'
999
1007
  ? down.config.cache?.outputs.files
1000
- : down.config.cache?.outputs.workspaceFiles
1008
+ : down.config.cache?.outputs.workspaceFiles) ?? [],
1009
+ ).positive
1001
1010
  ;(down.addsToOutputsOf ??= []).push(up.id)
1002
1011
  ;(up.outputsAddedToBy ??= []).push(...(downGlobs ?? []))
1003
1012
  return
@@ -58,10 +58,6 @@ export class DeferredOutputs {
58
58
  return [...this.entries.keys()].sort()
59
59
  }
60
60
 
61
- get size(): number {
62
- return this.entries.size
63
- }
64
-
65
61
  /**
66
62
  * Materialise every deferred producer in `node`'s TRANSITIVE dependency
67
63
  * closure. Which upstream bytes a command reads is unknowable — that is
@@ -182,9 +182,9 @@ function restoreTierExclusions(nodes: Map<string, TaskNode>, workspaceRoot: stri
182
182
  let everything = false
183
183
  for (const node of nodes.values()) {
184
184
  for (const raw of node.config.cache?.outputs.workspaceFiles ?? []) {
185
- // No negation to skip: the schema refuses '!' in output globs
186
- // (`validateWorkspaceGlobs`), so the `continue` that stood here
187
- // guarded a shape no config can carry (item 640).
185
+ // A `!` entry needs no skip: its prefix (`!gen`) names no directory,
186
+ // so it excludes nothing (A-44; the `continue` that stood here was
187
+ // dropped in item 640).
188
188
  const glob = normalizeGlob(raw)
189
189
  const prefix = staticPrefix(glob)
190
190
  if (prefix === '.' || prefix === '' || prefix === '/') everything = true
@@ -18,6 +18,7 @@ import {
18
18
  toRealPath,
19
19
  type ExecuteRequest,
20
20
  isMountableLiteral,
21
+ atOrUnder,
21
22
  type SandboxViolation,
22
23
  } from '../exec/index.js'
23
24
  import type { TaskNode } from '../graph/index.js'
@@ -299,7 +300,11 @@ async function linkedDeps(
299
300
  const full = path.join(dir, e.name)
300
301
  if (e.isSymbolicLink()) {
301
302
  const target = await realpath(full).catch(() => undefined)
302
- if (target !== undefined && !dirs.some((d) => within(target, d)) && !within(self, target)) {
303
+ if (
304
+ target !== undefined &&
305
+ !dirs.some((d) => atOrUnder(target, d)) &&
306
+ !atOrUnder(self, target)
307
+ ) {
303
308
  found.push([target, full, scope + e.name])
304
309
  }
305
310
  } else if (scope === '' && e.isDirectory() && e.name.startsWith('@')) {
@@ -321,7 +326,7 @@ async function linkedDeps(
321
326
  const granted: string[] = []
322
327
  const withheld: WithheldLink[] = []
323
328
  for (const [target, { link, name }] of targets) {
324
- if (!within(target, root) || keyedDirs.has(target)) granted.push(target)
329
+ if (!atOrUnder(target, root) || keyedDirs.has(target)) granted.push(target)
325
330
  else
326
331
  withheld.push({
327
332
  dir: target,
@@ -355,7 +360,7 @@ export function reachedWithheld(
355
360
  violations: readonly SandboxViolation[],
356
361
  ): WithheldLink[] {
357
362
  return withheld.filter((w) =>
358
- violations.some((v) => v.path !== undefined && within(v.path, w.dir)),
363
+ violations.some((v) => v.path !== undefined && atOrUnder(v.path, w.dir)),
359
364
  )
360
365
  }
361
366
 
@@ -364,15 +369,6 @@ function posixRel(from: string, p: string): string {
364
369
  return path.relative(from, p).split(path.sep).join('/')
365
370
  }
366
371
 
367
- /**
368
- * `p` is `dir` or below it — by path, so a sibling sharing `dir`'s name
369
- * prefix is not. `/` already ends in the separator, and a link to it
370
- * holds every project.
371
- */
372
- function within(p: string, dir: string): boolean {
373
- return p === dir || p.startsWith(dir.endsWith(path.sep) ? dir : dir + path.sep)
374
- }
375
-
376
372
  /**
377
373
  * Ensure each declared write path exists on the host as either an empty
378
374
  * file (a literal path) or a directory (a glob's static prefix, or a
@@ -451,8 +447,8 @@ function wallOff(
451
447
  for (const bind of bindableWrites(config.allowWrite)) {
452
448
  // A bind outside the workspace is the user's own path (`/tmp/x`,
453
449
  // `~/.cache/y`), spelled there on purpose; only one inside is judged.
454
- if (bind !== home && !bind.startsWith(home + path.sep)) continue
455
- const wall = walls.find((w) => w === bind || w.startsWith(bind + path.sep))
450
+ if (!atOrUnder(bind, home)) continue
451
+ const wall = walls.find((w) => atOrUnder(w, bind))
456
452
  if (wall === undefined) continue
457
453
  throw new UserError(
458
454
  `exec.sandbox.allow.write: the grant binding ${bind} would make ${wall} writable — another ` +
@@ -558,8 +554,8 @@ export function commandWriteReach(node: TaskNode, workspaceRoot: string): WriteR
558
554
  let reach: 'none' | 'project' = 'none'
559
555
  for (const grant of exec.sandbox.allow?.write ?? []) {
560
556
  const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
561
- if (within(abs, node.projectDir)) reach = 'project'
562
- else if (within(abs, workspaceRoot) || within(workspaceRoot, abs)) return 'workspace'
557
+ if (atOrUnder(abs, node.projectDir)) reach = 'project'
558
+ else if (atOrUnder(abs, workspaceRoot) || atOrUnder(workspaceRoot, abs)) return 'workspace'
563
559
  }
564
560
  return reach
565
561
  }
@@ -579,7 +575,7 @@ export function mayWriteFingerprint(node: TaskNode, workspaceRoot: string): bool
579
575
  for (const grant of exec.sandbox.allow?.write ?? []) {
580
576
  const abs = path.resolve(node.projectDir, expandHome(grantPrefix(grant)))
581
577
  for (const f of WORKSPACE_FINGERPRINT_FILES) {
582
- if (within(path.join(workspaceRoot, f), abs)) return true
578
+ if (atOrUnder(path.join(workspaceRoot, f), abs)) return true
583
579
  }
584
580
  }
585
581
  return false
@@ -18,7 +18,13 @@ import { UserError } from '../util/index.js'
18
18
  */
19
19
  export function keyUpstream(node: TaskNode, upstream: TaskOutcome[]): TaskOutcome[] {
20
20
  const order = node.orderOnly
21
- const keyed = order === undefined ? upstream : upstream.filter((o) => !order.includes(o.node.id))
21
+ // A restore-tier hit dispatches before its dependencies settle, and the
22
+ // scheduler hands it their outcomes so far: a hole stays a hole, as it
23
+ // does with no order-only edge (reading `.node` of one threw).
24
+ const keyed =
25
+ order === undefined
26
+ ? upstream
27
+ : upstream.filter((o) => o === undefined || !order.includes(o.node.id))
22
28
  return node.excludedUpstream === undefined ? keyed : [...keyed, ...node.excludedUpstream]
23
29
  }
24
30
 
package/src/util/index.ts CHANGED
@@ -28,7 +28,9 @@ export {
28
28
  GLOB_WILDCARDS,
29
29
  normalizeBunGlob,
30
30
  normalizeGlob,
31
+ outputMatcher,
31
32
  relPosix,
33
+ splitNegations,
32
34
  staticPrefix,
33
35
  taskGlob,
34
36
  wholeSubtreePrefixes,
package/src/util/paths.ts CHANGED
@@ -228,6 +228,42 @@ export function asTrees(patterns: readonly string[]): string[] {
228
228
  return out
229
229
  }
230
230
 
231
+ /**
232
+ * An output list's positive globs, and what its `!` entries take back
233
+ * (A-44). A reader that only needs where outputs MIGHT land (an overlap
234
+ * refusal, a preliminary key) takes `positive` alone; one that decides
235
+ * whether a path IS an output takes both, through `outputMatcher`.
236
+ */
237
+ export function splitNegations(globs: readonly string[]): {
238
+ positive: string[]
239
+ negative: string[]
240
+ } {
241
+ const positive: string[] = []
242
+ const negative: string[] = []
243
+ for (const g of globs) {
244
+ if (g.startsWith('!')) negative.push(g.slice(1))
245
+ else positive.push(g)
246
+ }
247
+ return { positive, negative }
248
+ }
249
+
250
+ /**
251
+ * Whether a relative path is one of the outputs `globs` declare: a positive
252
+ * glob selects it and no `!` entry takes it back. A `!` entry fed to a
253
+ * matcher as-is would be `Bun.Glob`'s own negation, true of every OTHER
254
+ * path (A-44).
255
+ */
256
+ export function outputMatcher(
257
+ globs: readonly string[],
258
+ compile: (pattern: string) => Bun.Glob = taskGlob,
259
+ ): (rel: string) => boolean {
260
+ const { positive, negative } = splitNegations(globs)
261
+ if (positive.length === 0) return () => false
262
+ const pos = asTrees(positive).map(compile)
263
+ const neg = asTrees(negative).map(compile)
264
+ return (rel) => pos.some((g) => g.match(rel)) && !neg.some((g) => g.match(rel))
265
+ }
266
+
231
267
  /**
232
268
  * `pattern` with its first brace group expanded, recursively, while that
233
269
  * group holds a `/`; any other pattern as itself, since `Bun.Glob`
@@ -511,7 +511,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
511
511
  if (typeof g !== 'string' || g.length === 0) {
512
512
  throw new UserError(`${where}.cache.outputs.files must be an array of non-empty strings`)
513
513
  }
514
- if (g.startsWith('/')) {
514
+ if (g.startsWith('/') || g.startsWith('!/')) {
515
515
  throw new UserError(
516
516
  `${where}.cache.outputs.files: absolute paths are not allowed (got "${g}") — ` +
517
517
  `outputs must be project-relative globs`,
@@ -530,14 +530,10 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
530
530
  `name the directory the task writes, such as "dist/**"`,
531
531
  )
532
532
  }
533
- if (g.startsWith('!')) {
534
- throw new UserError(
535
- `${where}.cache.outputs.files: negation is not supported (got "${g}") — ` +
536
- `unlike inputs, output globs are never split on '!', so this is read as a literal ` +
537
- `path beginning with '!' and matches nothing. List the outputs you DO produce.`,
538
- )
539
- }
540
- const own = ownFileCovered(g, configPath)
533
+ // A `!` entry takes a path back from the outputs (A-44): it is not
534
+ // cleaned, saved or restored, and it stays an input.
535
+ assertNotDoubleNegated(g, `${where}.cache.outputs.files`)
536
+ const own = g.startsWith('!') ? null : ownFileCovered(g, configPath)
541
537
  if (own !== null) {
542
538
  throw new UserError(
543
539
  `${where}.cache.outputs.files: "${g}" covers the project's own ${own} — vx deletes a ` +
@@ -546,6 +542,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
546
542
  )
547
543
  }
548
544
  }
545
+ assertOutputsNotNegationOnly(outFiles as string[], `${where}.cache.outputs.files`)
549
546
  // Same for inputs.files.
550
547
  for (const g of (inputs as { files: unknown[] }).files) {
551
548
  if (typeof g !== 'string' || g.length === 0) {
@@ -861,7 +858,7 @@ function assertNotDoubleNegated(glob: string, where: string): void {
861
858
  `${where}: '!!' is not a double negation (got "${glob}") — it INVERTS the set. ` +
862
859
  `One '!' is stripped and the remainder is compiled as a glob, which applies its own ` +
863
860
  `leading-'!' negation, so this excludes everything EXCEPT ${JSON.stringify(inner)} and ` +
864
- `the task folds only that. Use ${JSON.stringify(`!${inner}`)} to subtract it, or ` +
861
+ `keeps only that. Use ${JSON.stringify(`!${inner}`)} to subtract it, or ` +
865
862
  `${JSON.stringify(inner)} to include it.`,
866
863
  )
867
864
  }
@@ -996,9 +993,8 @@ function assertNotNegationOnly(globs: readonly string[], where: string): void {
996
993
  }
997
994
 
998
995
  /**
999
- * `negation: false` for OUTPUT globs — `resolveOutputs` /
1000
- * `resolveWorkspaceOutputs` never split on `!`, so such an entry is read as a
1001
- * literal path starting with `!` and matches nothing.
996
+ * `negation: false` for OUTPUT globs: a `!` entry takes a path back (A-44),
997
+ * and a list of only negations selects nothing and is refused.
1002
998
  */
1003
999
  function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): void {
1004
1000
  if (!Array.isArray(v)) {
@@ -1025,16 +1021,23 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
1025
1021
  `${where}: "${g}" names the workspace root itself and selects nothing — use "**" for everything under it`,
1026
1022
  )
1027
1023
  }
1028
- if (!negation && g.startsWith('!')) {
1029
- throw new UserError(
1030
- `${where}: negation is not supported (got "${g}") — ` +
1031
- `unlike inputs, output globs are never split on '!', so this is read as a literal ` +
1032
- `path beginning with '!' and matches nothing. List the outputs you DO produce.`,
1033
- )
1034
- }
1035
- if (negation) assertNotDoubleNegated(g, where)
1024
+ assertNotDoubleNegated(g, where)
1036
1025
  }
1037
1026
  if (negation) assertNotNegationOnly(v as string[], where)
1027
+ else assertOutputsNotNegationOnly(v as string[], where)
1028
+ }
1029
+
1030
+ /**
1031
+ * An output list of only `!` entries selects nothing: the task saves and
1032
+ * restores nothing while it reads as if it declared outputs (A-44).
1033
+ */
1034
+ function assertOutputsNotNegationOnly(globs: readonly string[], where: string): void {
1035
+ if (globs.length === 0 || globs.some((g) => !g.startsWith('!'))) return
1036
+ throw new UserError(
1037
+ `${where}: every entry is a negation, which selects NOTHING (got ${JSON.stringify(globs)}) — ` +
1038
+ `a '!' entry only takes back what a positive glob selected. Add the outputs the task ` +
1039
+ `produces, e.g. ['dist/**', ${JSON.stringify(globs[0])}], or declare [] for none.`,
1040
+ )
1038
1041
  }
1039
1042
 
1040
1043
  const SANDBOX_FIELDS = new Set([
@@ -507,9 +507,13 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
507
507
  // Run all package globs concurrently. Disk-bound walks parallelize
508
508
  // well; serializing them just stretches the discovery phase by N×.
509
509
  const { positive, negative } = splitPackageGlobs(workspace.packageGlobs)
510
- const perPattern = await Promise.all(
511
- positive.map((pattern) => memberDirs(workspace.root, pattern)),
512
- )
510
+ // A root the globs do not list is a project when it holds a vx config
511
+ // (D-39): the package manager's member list stays the manager's, and
512
+ // the root's globs stop at every member as any parent project's do.
513
+ const [perPattern, rootConfig] = await Promise.all([
514
+ Promise.all(positive.map((pattern) => memberDirs(workspace.root, pattern))),
515
+ findConfigFile(workspace.root),
516
+ ])
513
517
  const matches = new Set<string>()
514
518
  for (const arr of perPattern) {
515
519
  for (const m of arr) {
@@ -517,6 +521,7 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
517
521
  matches.add(m)
518
522
  }
519
523
  }
524
+ if (rootConfig !== null) matches.add(workspace.root)
520
525
 
521
526
  // Per-project discovery: the manifest read answers "is there a
522
527
  // package.json" (an ENOENT is the "not a member" answer, at the cost of
@@ -558,8 +563,15 @@ export async function listProjects(workspace: Workspace): Promise<ProjectMeta[]>
558
563
  }
559
564
  const previous = seenName.get(pkg.name)
560
565
  if (previous) {
566
+ // pnpm accepts two manifests of one name (sveltejs/kit's test apps,
567
+ // the first-five-minutes walk, 2026-09-28); vx cannot, since a
568
+ // project is addressed by it. Say which two, short, and the way on.
561
569
  throw new UserError(
562
- `Duplicate package name "${pkg.name}" in workspace: ${previous} and ${dir}`,
570
+ `Duplicate package name "${pkg.name}" in workspace: ${[previous, dir]
571
+ .map((d) => relPosix(workspace.root, d))
572
+ .sort()
573
+ .join(' and ')}; ` +
574
+ 'vx names a project by its package name — rename one, or leave one out with a `!` pattern in the workspace globs',
563
575
  )
564
576
  }
565
577
  seenName.set(pkg.name, dir)