@vzn/vx 0.0.591 → 0.0.595

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/bin.ts +26 -11
  4. package/src/cache/archive.ts +18 -7
  5. package/src/cache/cache.ts +137 -101
  6. package/src/cache/config-evals.ts +19 -4
  7. package/src/cache/git-inputs.ts +33 -17
  8. package/src/cache/inputs.ts +157 -54
  9. package/src/cache/layered-cache.ts +25 -2
  10. package/src/cache/tar-stream.ts +4 -2
  11. package/src/cli/cache.ts +4 -16
  12. package/src/cli/completions.ts +42 -6
  13. package/src/cli/foreign-flags.ts +56 -6
  14. package/src/cli/help.ts +27 -8
  15. package/src/cli/info.ts +4 -6
  16. package/src/cli/init.ts +4 -4
  17. package/src/cli/last.ts +49 -20
  18. package/src/cli/lock.ts +3 -3
  19. package/src/cli/plan-format.ts +9 -0
  20. package/src/cli/plugin-templates.ts +2 -2
  21. package/src/cli/run.ts +80 -50
  22. package/src/cli/select.ts +44 -5
  23. package/src/cli/show.ts +47 -9
  24. package/src/cli/upgrade.ts +2 -3
  25. package/src/cli/watch-filter.ts +21 -7
  26. package/src/cli/watch-fs.ts +32 -8
  27. package/src/cli/watch-judge.ts +1 -1
  28. package/src/cli/watch.ts +40 -15
  29. package/src/cli/why.ts +4 -13
  30. package/src/cli/workspace-config.ts +1 -1
  31. package/src/config.ts +5 -5
  32. package/src/exec/env.ts +8 -5
  33. package/src/exec/index.ts +1 -0
  34. package/src/exec/proc-sample.ts +5 -1
  35. package/src/exec/runner.ts +126 -47
  36. package/src/exec/sandbox-deny-scan.ts +2 -2
  37. package/src/exec/sandbox-paths.ts +11 -5
  38. package/src/exec/sandbox-runtime.ts +89 -23
  39. package/src/exec/sandbox-violations.ts +23 -9
  40. package/src/graph/index.ts +1 -0
  41. package/src/graph/scheduler.ts +14 -4
  42. package/src/graph/task-graph.ts +3 -2
  43. package/src/index.ts +9 -0
  44. package/src/orchestrator/affected-tasks.ts +34 -10
  45. package/src/orchestrator/doctor.ts +2 -1
  46. package/src/orchestrator/execute-task.ts +43 -10
  47. package/src/orchestrator/hit-restore.ts +9 -5
  48. package/src/orchestrator/index.ts +1 -0
  49. package/src/orchestrator/local-shortcircuit.ts +4 -1
  50. package/src/orchestrator/lockfile-claim.ts +45 -49
  51. package/src/orchestrator/logger.ts +4 -1
  52. package/src/orchestrator/miss-save.ts +10 -3
  53. package/src/orchestrator/placement.ts +17 -3
  54. package/src/orchestrator/plan.ts +25 -1
  55. package/src/orchestrator/plugin-host.ts +41 -3
  56. package/src/orchestrator/plugin.ts +11 -14
  57. package/src/orchestrator/projects.ts +18 -9
  58. package/src/orchestrator/run-context.ts +2 -2
  59. package/src/orchestrator/run-lock.ts +14 -4
  60. package/src/orchestrator/run.ts +94 -16
  61. package/src/orchestrator/sandbox-request.ts +6 -10
  62. package/src/orchestrator/signals.ts +5 -2
  63. package/src/orchestrator/summary.ts +14 -1
  64. package/src/util/index.ts +7 -1
  65. package/src/util/secret-mask.ts +36 -0
  66. package/src/util/settle.ts +15 -0
  67. package/src/workspace/affected.ts +47 -19
  68. package/src/workspace/config-cache.ts +25 -4
  69. package/src/workspace/config-eval.ts +4 -0
  70. package/src/workspace/config-imports.ts +2 -2
  71. package/src/workspace/config-schema.ts +47 -10
  72. package/src/workspace/filter.ts +5 -4
  73. package/src/workspace/migrate-scripts.ts +23 -6
  74. package/src/workspace/package-graph.ts +15 -3
  75. package/src/workspace/project-loader.ts +89 -2
  76. package/src/workspace/workspace.ts +121 -14
@@ -24,7 +24,7 @@ import {
24
24
  type SandboxViolation,
25
25
  } from '../exec/index.js'
26
26
  import type { TaskNode } from '../graph/index.js'
27
- import { grantPrefix, UserError } from '../util/index.js'
27
+ import { grantPrefix, relPosix, UserError } from '../util/index.js'
28
28
  import { WORKSPACE_FINGERPRINT_FILES } from '../workspace/index.js'
29
29
 
30
30
  /**
@@ -350,6 +350,7 @@ async function linkedDeps(
350
350
  keyed: ReadonlySet<string> | undefined,
351
351
  ): Promise<{ granted: string[]; withheld: WithheldLink[] }> {
352
352
  const [self, root] = await Promise.all([realpath(projectDir), realpath(workspaceRoot)])
353
+ const realDirs = dirs.map(toRealPath)
353
354
  const scan = async (dir: string, scope: string): Promise<Array<[string, string, string]>> => {
354
355
  const found: Array<[target: string, link: string, name: string]> = []
355
356
  const entries = await readdir(dir, { withFileTypes: true }).catch(() => [])
@@ -359,7 +360,7 @@ async function linkedDeps(
359
360
  const target = await realpath(full).catch(() => undefined)
360
361
  if (
361
362
  target !== undefined &&
362
- !dirs.some((d) => atOrUnder(target, d)) &&
363
+ !realDirs.some((d) => atOrUnder(target, d)) &&
363
364
  !atOrUnder(self, target)
364
365
  ) {
365
366
  found.push([target, full, scope + e.name])
@@ -388,8 +389,8 @@ async function linkedDeps(
388
389
  withheld.push({
389
390
  dir: target,
390
391
  name,
391
- target: posixRel(root, target),
392
- link: posixRel(workspaceRoot, link),
392
+ target: relPosix(root, target),
393
+ link: relPosix(workspaceRoot, link),
393
394
  })
394
395
  }
395
396
  return { granted, withheld }
@@ -421,11 +422,6 @@ export function reachedWithheld(
421
422
  )
422
423
  }
423
424
 
424
- /** `p` relative to `from`, with `/` separators. */
425
- function posixRel(from: string, p: string): string {
426
- return path.relative(from, p).split(path.sep).join('/')
427
- }
428
-
429
425
  /**
430
426
  * Ensure each declared write path exists on the host as either an empty
431
427
  * file (a literal path) or a directory (a glob's static prefix, or a
@@ -565,7 +561,7 @@ export function placeholderSweeper(placeholders: readonly Placeholder[]): () =>
565
561
  * never reddens a pass and never buries a real denial.
566
562
  */
567
563
  export function untouchedPlaceholderLine(projectDir: string, placeholder: string): string {
568
- const rel = path.relative(projectDir, placeholder).split(path.sep).join('/')
564
+ const rel = relPosix(projectDir, placeholder)
569
565
  return (
570
566
  `vx: the sandbox write grant \`${rel}\` named nothing on disk, so vx bound it as an empty ` +
571
567
  `file, which the task never wrote (removed again). If the task creates a directory there ` +
@@ -14,7 +14,7 @@
14
14
  // them to the runner around every spawn.
15
15
 
16
16
  import { signalExitCode } from '../exec/index.js'
17
- import { killGraceMs, settleWithin } from '../util/index.js'
17
+ import { claimExitForSignal, killGraceMs, settleWithin } from '../util/index.js'
18
18
  import { holdGroups, killTree, untilGroupsGone } from '../exec/index.js'
19
19
  import type { Logger } from './logger.js'
20
20
 
@@ -69,7 +69,9 @@ export async function terminateChildren(
69
69
  signal: ForwardedSignal = 'SIGTERM',
70
70
  graceMs: number = killGraceMs(SIGNAL_SHUTDOWN_GRACE_MS),
71
71
  ): Promise<void> {
72
- const children = live()
72
+ // A ready server is in both of run()'s lists: signalled twice, it read
73
+ // the second as a repeated Ctrl-C (force quit).
74
+ const children = [...new Set(live())]
73
75
  // On the group guard's list until the sweep below is done: a shell that
74
76
  // dies on the signal lets its group go while what it forked runs out
75
77
  // the grace (kill-tree.ts, `holdGroups`).
@@ -131,6 +133,7 @@ export function forwardSignals(args: {
131
133
  // that ended the run.
132
134
  if (stopping !== undefined) exit(stopping)
133
135
  stopping = signal
136
+ claimExitForSignal()
134
137
  // Clear the live worker/status region BEFORE exiting so a TTY isn't
135
138
  // left with a frozen region in the scrollback. runEnd is idempotent
136
139
  // and a no-op for non-TTY loggers.
@@ -119,6 +119,8 @@ export interface SummaryStats {
119
119
  spread: { maxMs: number; minMs: number; sumMs: number; count: number } | null
120
120
  /** Tasks an `admit` policy held with a worker free, and the waits summed. Absent with no hold. */
121
121
  held?: { count: number; sumMs: number }
122
+ /** A run of groups only: their names, so `0 tasks` says why. */
123
+ emptyGroups?: readonly string[]
122
124
  }
123
125
 
124
126
  /**
@@ -213,7 +215,14 @@ export function formatSummarySection(
213
215
  // No legend line until the first bucket lands (live: all-gray bar).
214
216
  if (taskParts.length > 0) lines.push(legend(taskParts))
215
217
  } else {
216
- lines.push(row('tasks', dim('0 tasks')))
218
+ const groups = stats.emptyGroups ?? []
219
+ // `vx run build` where no project gives build a command runs the default
220
+ // group alone: say so, or it reads like a selection that matched nothing.
221
+ const why =
222
+ groups.length > 0
223
+ ? ` ${dim('\u00b7')} ${dim(`${groups.join(', ')} ${groups.length === 1 ? 'has' : 'have'} no command in these projects`)}`
224
+ : ''
225
+ lines.push(row('tasks', dim('0 tasks') + why))
217
226
  // A stop before any task finished still names what it took down.
218
227
  if (taskParts.length > 0) lines.push(legend(taskParts))
219
228
  }
@@ -368,6 +377,9 @@ export function formatRunSummary(
368
377
  .map((o) => o.durationMs)
369
378
  const heldOutcomes = outcomes.filter((o) => o.admissionHeldMs !== undefined)
370
379
  const notRun = outcomes.filter((o) => neverStarted(o) && !isGroupTask(o.node)).length
380
+ const emptyGroups = outcomes.every((o) => isGroupTask(o.node))
381
+ ? [...new Set(outcomes.map((o) => o.node.taskName))]
382
+ : []
371
383
  return formatSummarySection(
372
384
  {
373
385
  failed: t.failed,
@@ -398,6 +410,7 @@ export function formatRunSummary(
398
410
  },
399
411
  }
400
412
  : {}),
413
+ ...(emptyGroups.length > 0 ? { emptyGroups } : {}),
401
414
  },
402
415
  totalMs,
403
416
  colors,
package/src/util/index.ts CHANGED
@@ -49,7 +49,13 @@ export {
49
49
  wholeSubtreePrefixes,
50
50
  slashBraceExpansions,
51
51
  } from './paths.js'
52
- export { killGraceMs, settleWithin, teardownTimeoutMs } from './settle.js'
52
+ export {
53
+ claimExitForSignal,
54
+ exitClaimedBySignal,
55
+ killGraceMs,
56
+ settleWithin,
57
+ teardownTimeoutMs,
58
+ } from './settle.js'
53
59
  export { formatBytes, parseDuration, parseSize } from './size.js'
54
60
  export {
55
61
  cgroupCpuQuota,
@@ -49,6 +49,13 @@ export interface SecretMask {
49
49
  * the next chunk or `end`.
50
50
  */
51
51
  stream(): { push(chunk: string): string; end(): string }
52
+ /**
53
+ * `mask` for the two sides of a cut, where the text between them is
54
+ * gone: a value the cut split leaves a piece at the end of `head` or the
55
+ * start of `tail` that no whole-value match sees, so each piece is
56
+ * masked too.
57
+ */
58
+ maskCut(head: string, tail: string): [string, string]
52
59
  }
53
60
 
54
61
  /**
@@ -103,8 +110,37 @@ export function secretMask(
103
110
  }
104
111
  return longest
105
112
  }
113
+ /** The longest head of `text` that is a proper suffix of a value. */
114
+ const partialHead = (text: string): number => {
115
+ let longest = 0
116
+ for (const v of sorted) {
117
+ for (let k = Math.min(v.length - 1, text.length); k > longest; k--) {
118
+ if (text.startsWith(v.slice(v.length - k))) {
119
+ longest = k
120
+ break
121
+ }
122
+ }
123
+ }
124
+ return longest
125
+ }
106
126
  return {
107
127
  mask,
128
+ maskCut(head, tail) {
129
+ let h = head.length - partialTail(head)
130
+ pattern.lastIndex = 0
131
+ for (let m = pattern.exec(head); m !== null; m = pattern.exec(head)) {
132
+ if (m.index < h && m.index + m[0].length > h) h = m.index
133
+ }
134
+ let t = partialHead(tail)
135
+ pattern.lastIndex = 0
136
+ for (let m = pattern.exec(tail); m !== null; m = pattern.exec(tail)) {
137
+ if (m.index < t && m.index + m[0].length > t) t = m.index + m[0].length
138
+ }
139
+ return [
140
+ mask(head.slice(0, h)) + (h < head.length ? MASKED : ''),
141
+ (t > 0 ? MASKED : '') + mask(tail.slice(t)),
142
+ ]
143
+ },
108
144
  stream() {
109
145
  let carry = ''
110
146
  return {
@@ -68,3 +68,18 @@ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boo
68
68
  void p.catch(() => {})
69
69
  }
70
70
  }
71
+
72
+ // Who ends the process. bin.ts exits once its verb has settled and the
73
+ // streams have ended; a signal's handler exits by itself, with the
74
+ // signal's code, once the run it stopped is done. The verb settles first,
75
+ // with the run's verdict (1), so bin.ts asks before it exits.
76
+ let signalOwnsExit = false
77
+
78
+ /** A signal's handler will end the process: bin.ts leaves the exit to it. */
79
+ export function claimExitForSignal(): void {
80
+ signalOwnsExit = true
81
+ }
82
+
83
+ export function exitClaimedBySignal(): boolean {
84
+ return signalOwnsExit
85
+ }
@@ -19,7 +19,7 @@ import {
19
19
  relPosix,
20
20
  } from '../util/index.js'
21
21
  import { LOCKFILE_NAME } from './lockfile.js'
22
- import { configImportOwners } from './config-imports.js'
22
+ import { configImportOwners, realpathOr } from './config-imports.js'
23
23
  import { configImports } from './config-cache.js'
24
24
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
25
25
  import { bunPatchFiles, WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
@@ -176,6 +176,14 @@ export async function affectedChanges(
176
176
  `("${since.slice(0, range) || 'HEAD'}"); vx diffs it against the working tree.`,
177
177
  )
178
178
  }
179
+ // `^main` is rev-list's exclusion, not a ref: merge-base refused it, it
180
+ // verified, and `git diff ^main` diffed from main itself, so changes
181
+ // only main made were selected (`^` is illegal in a ref name).
182
+ if (since.startsWith('^')) {
183
+ throw new UserError(
184
+ `git ref "${since}" is an exclusion, not a ref: pass the base alone ("${since.replace(/^\^+/, '') || 'HEAD'}").`,
185
+ )
186
+ }
179
187
  // Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
180
188
  // on a branch whose base has moved on, `git diff <base>` reports every
181
189
  // file OTHER people changed on the base (over-selection that defeats a
@@ -432,7 +440,17 @@ async function dependentsAtBase(
432
440
  ) {
433
441
  continue
434
442
  }
435
- byDir.set(dir, { name: pkg.name, dir, packageJson: pkg, configPath: null })
443
+ // Today's catalogs, or both graphs would differ on every `catalog:`
444
+ // entry. A catalog edit is a `pnpm-workspace.yaml` or root manifest
445
+ // edit, which the fingerprint widening and the root's own change answer.
446
+ const catalogs = projects[0]?.catalogs
447
+ byDir.set(dir, {
448
+ name: pkg.name,
449
+ dir,
450
+ packageJson: pkg,
451
+ configPath: null,
452
+ ...(catalogs === undefined ? {} : { catalogs }),
453
+ })
436
454
  if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
437
455
  }
438
456
  const now = buildPackageGraph([...projects])
@@ -468,8 +486,10 @@ function parentsOfNewNested(
468
486
  projects: readonly ProjectMeta[],
469
487
  ): Set<string> {
470
488
  const out = new Set<string>()
489
+ // Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
490
+ // dir discovered by readdir keeps the spelling it was created with.
471
491
  const dirToName = new Map<string, string>()
472
- for (const p of projects) dirToName.set(p.dir, p.name)
492
+ for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
473
493
  for (const [rel, bytes] of atBase) {
474
494
  const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
475
495
  if (!dirToName.has(dir)) continue
@@ -570,7 +590,7 @@ async function workspaceConfigChanged(
570
590
  if (config === undefined) return false
571
591
  const root = realpathSync(workspaceRoot)
572
592
  for (const file of await configImports(config)) {
573
- if (set.has(path.relative(root, file).split(path.sep).join('/'))) return true
593
+ if (set.has(relPosix(root, file))) return true
574
594
  }
575
595
  return false
576
596
  }
@@ -613,7 +633,10 @@ async function bytesOrNull(file: string): Promise<Uint8Array | null> {
613
633
  /** Run a NUL-separated path-listing git command from the workspace root. */
614
634
  async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
615
635
  const proc = spawnGit([...cmd], workspaceRoot)
616
- const stdout = await new Response(proc.stdout).text()
636
+ // `Response.text()` strips a leading U+FEFF, the first path's own.
637
+ const stdout = new TextDecoder('utf-8', { ignoreBOM: true }).decode(
638
+ await new Response(proc.stdout).bytes(),
639
+ )
617
640
  const stderr = await new Response(proc.stderr).text()
618
641
  const exit = await proc.exited
619
642
  if (exit !== 0) {
@@ -755,6 +778,17 @@ async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
755
778
  const sha = out.trim()
756
779
  if (exit === 0 && sha.length > 0) return sha
757
780
  await verifyRef(workspaceRoot, ref)
781
+ // `<rev>:<path>` names the tree (or blob) at <path>: diffed against the
782
+ // working tree, its paths miss the <path>/ prefix, and `develop:pkgs`
783
+ // selected projects nothing had changed, green. A root tree
784
+ // (`develop^{tree}`, the empty tree) diffs right and stays a base.
785
+ const sub = /^([^:]*):(?!\/)(.+)$/s.exec(ref)
786
+ if (sub !== null) {
787
+ throw new UserError(
788
+ `git ref "${ref}" names what is at ${sub[2]}, not a commit: vx diffs the whole ` +
789
+ `workspace, so pass the commit alone ("${sub[1] || 'HEAD'}").`,
790
+ )
791
+ }
758
792
  return ref
759
793
  }
760
794
 
@@ -800,14 +834,6 @@ function isDirectory(abs: string): boolean {
800
834
  }
801
835
  }
802
836
 
803
- function realpathOr(p: string): string {
804
- try {
805
- return realpathSync(p)
806
- } catch {
807
- return p
808
- }
809
- }
810
-
811
837
  function projectsContaining(
812
838
  workspaceRoot: string,
813
839
  changedRelPaths: readonly string[],
@@ -823,8 +849,10 @@ function projectsContaining(
823
849
  // O(files · path-depth) instead of O(files · projects): independent of the
824
850
  // project count, which is what a big --affected diff on a 1000-project repo
825
851
  // pays for.
852
+ // Keyed NFC: macOS git reports paths NFC (core.precomposeunicode) while a
853
+ // dir discovered by readdir keeps the spelling it was created with.
826
854
  const dirToName = new Map<string, string>()
827
- for (const p of projects) dirToName.set(p.dir, p.name)
855
+ for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
828
856
  // A member linked in from elsewhere in the tree (`pkgs/b -> ../ext/b`) is
829
857
  // indexed by its link, and git reports its files at their real place
830
858
  // (`ext/b/src/a.txt`), which resolved to no project: an edit there
@@ -835,18 +863,18 @@ function projectsContaining(
835
863
  const real = realDirs.get(p.dir) ?? p.dir
836
864
  const rel = path.relative(realRoot, real)
837
865
  if (rel.startsWith('..') || path.isAbsolute(rel)) continue
838
- const spelled = path.resolve(workspaceRoot, rel)
839
- if (spelled !== p.dir && !dirToName.has(spelled)) dirToName.set(spelled, p.name)
866
+ const spelled = path.resolve(workspaceRoot, rel).normalize('NFC')
867
+ if (!dirToName.has(spelled)) dirToName.set(spelled, p.name)
840
868
  }
841
869
  const owned = new Set<string>()
842
870
  for (const rel of changedRelPaths) {
843
- let dir = path.resolve(workspaceRoot, rel)
871
+ let dir = path.resolve(workspaceRoot, rel).normalize('NFC')
844
872
  let hit = false
845
873
  for (;;) {
846
874
  const name = dirToName.get(dir)
847
875
  if (name !== undefined) {
848
876
  owned.add(name)
849
- on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel)))
877
+ on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel).normalize('NFC')))
850
878
  hit = true
851
879
  break
852
880
  }
@@ -865,7 +893,7 @@ function projectsContaining(
865
893
  // this, `--affected` after an edit inside selected none of them.
866
894
  const abs = path.resolve(workspaceRoot, rel)
867
895
  if (isDirectory(abs)) {
868
- const prefix = abs + path.sep
896
+ const prefix = abs.normalize('NFC') + path.sep
869
897
  for (const [dir, name] of dirToName) {
870
898
  if (!dir.startsWith(prefix)) continue
871
899
  owned.add(name)
@@ -409,6 +409,16 @@ function keySeed(workspaceFingerprint: string): bigint {
409
409
 
410
410
  const EXPLICIT_EXT = /\.(?:m?[jt]s|cjs|cts)$/
411
411
 
412
+ /**
413
+ * A file's identity as the key folds it. `hashFile` names an executable
414
+ * `100755:<oid>`, the slow path's `hashBytes` the bare oid; the mode moves
415
+ * no evaluation, and with it the warm key of an executable config never
416
+ * met the stored one, so every load read and re-keyed it.
417
+ */
418
+ function contentIdentity(identity: string): string {
419
+ return identity.startsWith('100755:') ? identity.slice(7) : identity
420
+ }
421
+
412
422
  /**
413
423
  * The canonical directory Bun resolves `file`'s imports from: its REAL
414
424
  * path's, so a config linked in from elsewhere imports its neighbours
@@ -482,7 +492,7 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
482
492
  const identity = a.hashBytes
483
493
  ? a.hashBytes(bytes, file)
484
494
  : a.hashFile
485
- ? await a.hashFile(file)
495
+ ? contentIdentity(await a.hashFile(file))
486
496
  : await hashOf(file, bytes)
487
497
  h = xxh3(`${file}\0${identity}`, h)
488
498
  closure.push(file)
@@ -542,6 +552,14 @@ export async function configEvalKey(a: ConfigEvalKeyArgs): Promise<ConfigEvalKey
542
552
  return { key: h.toString(16).padStart(16, '0'), closure, indexable }
543
553
  }
544
554
 
555
+ // `hashFile` names a symlink by its target string (git's mode 120000), not
556
+ // the bytes the evaluation read, so no stored key can match a fold of it.
557
+ // Only a linked config with no relative import is indexed at all; with no
558
+ // warm key it joins the round lookup on its slow key instead of paying a
559
+ // single lookup and an index rewrite per load. The identity is from the
560
+ // lstat `hashFiles` already took: no syscall is added.
561
+ const LINK_IDENTITY = '120000:'
562
+
545
563
  /**
546
564
  * The warm path: the key for a config whose ordered closure the store
547
565
  * remembers, from per-file identities alone — no read, no scan. The fold is
@@ -561,7 +579,10 @@ export async function configEvalKeyFromClosure(a: {
561
579
  } catch {
562
580
  return null
563
581
  }
564
- for (let i = 0; i < a.closure.length; i++) h = xxh3(`${a.closure[i]}\0${identities[i]}`, h)
582
+ if (identities.some((id) => id.startsWith(LINK_IDENTITY))) return null
583
+ for (let i = 0; i < a.closure.length; i++) {
584
+ h = xxh3(`${a.closure[i]}\0${contentIdentity(identities[i]!)}`, h)
585
+ }
565
586
  return h.toString(16).padStart(16, '0')
566
587
  }
567
588
 
@@ -574,8 +595,8 @@ export function configEvalKeyFromIdentities(a: {
574
595
  let h = keySeed(a.workspaceFingerprint)
575
596
  for (const file of a.closure) {
576
597
  const id = a.identities.get(file)
577
- if (id === undefined) return null
578
- h = xxh3(`${file}\0${id}`, h)
598
+ if (id === undefined || id.startsWith(LINK_IDENTITY)) return null
599
+ h = xxh3(`${file}\0${contentIdentity(id)}`, h)
579
600
  }
580
601
  return h.toString(16).padStart(16, '0')
581
602
  }
@@ -292,7 +292,11 @@ function acquireWorker(): Worker {
292
292
  // config reports the same text it would from an in-process import.
293
293
  const err = new Error(msg.message) as Error & { position?: WorkerReply['position'] }
294
294
  err.name = msg.name
295
+ // A stackless throw stays stackless: the rebuilt Error's own stack names
296
+ // this file, and `configLoadError` reads a stack naming no file as Bun's
297
+ // JSON loader's.
295
298
  if (msg.stack !== null) err.stack = msg.stack
299
+ else delete err.stack
296
300
  if (msg.position !== null) err.position = msg.position
297
301
  p.reject(err)
298
302
  }
@@ -281,7 +281,7 @@ function tsconfigTarget(spec: string, fromDir: string, memo?: TsconfigMemo): str
281
281
  }
282
282
 
283
283
  /** A path as Bun names it: realpath'd when it exists. */
284
- function realpathOr(file: string): string {
284
+ export function realpathOr(file: string): string {
285
285
  try {
286
286
  return realpathSync(file)
287
287
  } catch {
@@ -305,7 +305,7 @@ function selfReference(pkg: string, fromDir: string): boolean {
305
305
  const manifest = path.join(dir, 'package.json')
306
306
  if (existsSync(manifest)) {
307
307
  try {
308
- const json = JSON.parse(readFileSync(manifest, 'utf8')) as {
308
+ const json = JSON.parse(readFileSync(manifest, 'utf8').replace(/^\uFEFF/, '')) as {
309
309
  name?: unknown
310
310
  exports?: unknown
311
311
  }
@@ -28,6 +28,7 @@ import {
28
28
  asTrees,
29
29
  isLiteralPattern,
30
30
  } from '../util/index.js'
31
+ import { compileNameGlob, parseFilter } from './filter.js'
31
32
  import { nonJsonMessage, nonJsonPaths } from './json-data.js'
32
33
 
33
34
  // Mirrors `WorkspaceConfig` in src/config.ts. Unknown keys are REJECTED for
@@ -77,6 +78,7 @@ const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
77
78
 
78
79
  function validateRetention(retention: unknown, configPath: string): void {
79
80
  const where = `${configPath}: \`cacheRetention\``
81
+ const field = (key: string) => `${configPath}: \`cacheRetention.${key}\``
80
82
  if (retention === null || typeof retention !== 'object' || Array.isArray(retention)) {
81
83
  throw new UserError(`${where} must be { olderThan?: '30d', maxSize?: '10G' }`)
82
84
  }
@@ -90,23 +92,27 @@ function validateRetention(retention: unknown, configPath: string): void {
90
92
  olderThan !== undefined &&
91
93
  (typeof olderThan !== 'string' || parseDuration(olderThan) === null)
92
94
  ) {
93
- throw new UserError(`${where}.olderThan must be a duration like '30d', '12h', '90m' or '45s'`)
95
+ throw new UserError(
96
+ `${field('olderThan')} must be a duration like '30d', '12h', '90m' or '45s' (got ${JSON.stringify(olderThan)})`,
97
+ )
94
98
  }
95
99
  if (maxSize !== undefined && (typeof maxSize !== 'string' || parseSize(maxSize) === null)) {
96
- throw new UserError(`${where}.maxSize must be a size like '10G', '500MB' or '64KB'`)
100
+ throw new UserError(
101
+ `${field('maxSize')} must be a size like '10G', '500MB' or '64KB' (got ${JSON.stringify(maxSize)})`,
102
+ )
97
103
  }
98
104
  // The bounds `vx cache prune` refuses, for the same reason: each evicts
99
105
  // every entry, and here it would do so at the end of every run, the
100
106
  // entries that run just saved included, so nothing ever hits (item 969).
101
107
  if (olderThan !== undefined && parseDuration(olderThan) === 0) {
102
- throw new UserError(`${where}.olderThan of 0 evicts every entry after every run`)
108
+ throw new UserError(`${field('olderThan')} of 0 evicts every entry after every run`)
103
109
  }
104
110
  if (maxSize !== undefined && parseSize(maxSize) === 0) {
105
- throw new UserError(`${where}.maxSize of 0 evicts every entry after every run`)
111
+ throw new UserError(`${field('maxSize')} of 0 evicts every entry after every run`)
106
112
  }
107
113
  if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
108
114
  throw new UserError(
109
- `${where}.maxSize '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
115
+ `${field('maxSize')} '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}M', '${maxSize}G')`,
110
116
  )
111
117
  }
112
118
  }
@@ -229,6 +235,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
229
235
  if (plug.commands === null || typeof plug.commands !== 'object') {
230
236
  throw new UserError(`${configPath}: \`plugins[${i}].commands\` must be an object`)
231
237
  }
238
+ // A list of commands loaded as the verbs `0`, `1`, … that help listed.
239
+ if (Array.isArray(plug.commands)) {
240
+ throw new UserError(
241
+ `${configPath}: \`plugins[${i}].commands\` must be an object keyed by verb, not an array`,
242
+ )
243
+ }
232
244
  for (const [verb, cmd] of Object.entries(plug.commands as Record<string, unknown>)) {
233
245
  const c = cmd as { description?: unknown; run?: unknown } | null
234
246
  if (
@@ -344,6 +356,15 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
344
356
  ) {
345
357
  throw new UserError(`${configPath}: \`tags\` must be an array of non-empty strings`)
346
358
  }
359
+ for (const tag of (tags ?? []) as string[]) {
360
+ const why = tagProblem(tag)
361
+ if (why !== null) {
362
+ throw new UserError(
363
+ `${configPath}: tag ${JSON.stringify(tag)} ${why} — \`--filter tag:<name>\` could not ` +
364
+ `name it. Rename the tag.`,
365
+ )
366
+ }
367
+ }
347
368
  const tasks = config.tasks
348
369
  if (tasks === undefined) return
349
370
  if (typeof tasks !== 'object' || tasks === null || Array.isArray(tasks)) {
@@ -1286,7 +1307,7 @@ function assertFilterNamesDeclaredDeps(
1286
1307
  ): void {
1287
1308
  const declared = (dependsOn ?? []).map(specForm)
1288
1309
  const matches = (pattern: string, name: string): boolean =>
1289
- pattern.includes('*') ? taskPatternRegExp(pattern).test(name) : pattern === name
1310
+ pattern.includes('*') ? compileNameGlob(pattern).test(name) : pattern === name
1290
1311
  const named = (f: SpecForm): boolean =>
1291
1312
  declared.some((d) => {
1292
1313
  if (!matches(d.task, f.task)) return false
@@ -1366,10 +1387,20 @@ export function taskNameProblem(name: string): string | null {
1366
1387
  : null
1367
1388
  }
1368
1389
 
1369
- /** The graph's `*`-only task glob (`compileTaskPattern`), mirrored: `*` is the sole metacharacter. */
1370
- function taskPatternRegExp(pattern: string): RegExp {
1371
- const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
1372
- return new RegExp(`^${escaped}$`)
1390
+ /**
1391
+ * Why `--filter tag:<tag>` cannot name `tag`, or null. Asked of the filter
1392
+ * parser itself, so a suffix it learns to read is refused here too: `v1...`
1393
+ * read as a dependency walk from `v1`, `v[2]` as `v` changed since ref `2`.
1394
+ */
1395
+ function tagProblem(tag: string): string | null {
1396
+ if (tag.trim() !== tag) return 'has surrounding whitespace'
1397
+ if (tag.includes('*')) return "holds '*', which makes it a pattern"
1398
+ const f = parseFilter(`tag:${tag}`, '/')
1399
+ if (f.gitSince !== undefined) {
1400
+ return `ends in "[${f.gitSince}]", which a filter reads as a git range`
1401
+ }
1402
+ if (f.withDeps || f.onlyDeps) return "ends in '...', which a filter reads as a dependency walk"
1403
+ return null
1373
1404
  }
1374
1405
 
1375
1406
  /**
@@ -1493,6 +1524,12 @@ function assertStringArray(v: unknown, where: string): void {
1493
1524
  if (!Array.isArray(v) || v.some((s) => typeof s !== 'string' || s.length === 0)) {
1494
1525
  throw new UserError(`${where} must be an array of non-empty strings`)
1495
1526
  }
1527
+ // No path, host or name holds a NUL; a write grant with one reached the
1528
+ // placeholder create as an internal error.
1529
+ const nul = (v as string[]).find((s) => s.includes('\0'))
1530
+ if (nul !== undefined) {
1531
+ throw new UserError(`${where}: ${JSON.stringify(nul)} holds a NUL byte`)
1532
+ }
1496
1533
  }
1497
1534
 
1498
1535
  /**
@@ -20,7 +20,7 @@
20
20
  // given, the base set is "all projects" and excluded packages are removed.
21
21
 
22
22
  import path from 'node:path'
23
- import { BUN_GLOB_WILDCARDS, UserError } from '../util/index.js'
23
+ import { BUN_GLOB_WILDCARDS, relPosix, UserError } from '../util/index.js'
24
24
  import type { PackageGraph } from './package-graph.js'
25
25
  import type { ProjectMeta } from './workspace.js'
26
26
 
@@ -180,7 +180,7 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
180
180
  // against the workspace root like the literal form.
181
181
  // A member glob, not a task glob: `Bun.Glob`'s alphabet, the class included.
182
182
  if (BUN_GLOB_WILDCARDS.test(pathForm)) {
183
- const rel = path.relative(workspaceRoot, matcher).split(path.sep).join('/')
183
+ const rel = relPosix(workspaceRoot, matcher)
184
184
  const glob = rel.replace(/\/+$/, '')
185
185
  pathGlob = new Bun.Glob(glob)
186
186
  if (glob.endsWith('/**')) pathGlobBase = new Bun.Glob(glob.slice(0, -3))
@@ -293,9 +293,10 @@ function matchSelector(
293
293
  * Compile a name pattern where `*` is the sole metacharacter and means "any
294
294
  * characters" — pnpm's rule. A path glob would treat `/` as a separator, so
295
295
  * `*` could never cross the `@scope/` boundary: `--filter '*'` would select
296
- * only UNSCOPED packages, and `*core*` would match nothing at all.
296
+ * only UNSCOPED packages, and `*core*` would match nothing at all. Task
297
+ * patterns share it: it mirrors the graph's `compileTaskPattern`.
297
298
  */
298
- function compileNameGlob(pattern: string): RegExp {
299
+ export function compileNameGlob(pattern: string): RegExp {
299
300
  const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
300
301
  return new RegExp(`^${escaped}$`)
301
302
  }