@vzn/vx 0.0.595 → 0.0.596

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 (50) hide show
  1. package/README.md +1 -1
  2. package/package.json +5 -5
  3. package/src/cache/cache.ts +139 -84
  4. package/src/cache/chained-cache.ts +25 -7
  5. package/src/cache/index.ts +1 -0
  6. package/src/cache/inputs.ts +12 -12
  7. package/src/cache/layer.ts +7 -5
  8. package/src/cache/policy.ts +5 -0
  9. package/src/cache/zstd.ts +10 -4
  10. package/src/cli/cache.ts +10 -4
  11. package/src/cli/foreign-flags.ts +1 -1
  12. package/src/cli/last.ts +4 -1
  13. package/src/cli/run.ts +31 -15
  14. package/src/cli/watch-filter.ts +63 -12
  15. package/src/cli/watch-judge.ts +15 -8
  16. package/src/cli/watch.ts +4 -4
  17. package/src/config.ts +21 -21
  18. package/src/exec/proc-sample.ts +1 -1
  19. package/src/exec/sandbox-paths.ts +1 -1
  20. package/src/exec/sandbox-runtime.ts +116 -40
  21. package/src/exec/sandbox-violations.ts +58 -11
  22. package/src/graph/scheduler.ts +28 -12
  23. package/src/graph/task-graph.ts +134 -25
  24. package/src/orchestrator/admission.ts +12 -3
  25. package/src/orchestrator/events.ts +10 -1
  26. package/src/orchestrator/execute-task.ts +36 -13
  27. package/src/orchestrator/failure-mode.ts +8 -2
  28. package/src/orchestrator/framed-output.ts +3 -3
  29. package/src/orchestrator/logger.ts +11 -6
  30. package/src/orchestrator/metrics.ts +19 -8
  31. package/src/orchestrator/miss-save.ts +2 -2
  32. package/src/orchestrator/options.ts +7 -0
  33. package/src/orchestrator/plan.ts +3 -3
  34. package/src/orchestrator/plugin.ts +37 -16
  35. package/src/orchestrator/prepare.ts +13 -4
  36. package/src/orchestrator/run-artifacts.ts +2 -1
  37. package/src/orchestrator/run.ts +20 -4
  38. package/src/orchestrator/sandbox-request.ts +65 -9
  39. package/src/orchestrator/save-lane.ts +5 -5
  40. package/src/orchestrator/summary.ts +2 -4
  41. package/src/orchestrator/telemetry.ts +16 -7
  42. package/src/util/paths.ts +23 -3
  43. package/src/workspace/config-imports.ts +7 -5
  44. package/src/workspace/config-schema.ts +26 -3
  45. package/src/workspace/filter.ts +19 -0
  46. package/src/workspace/migrate-scripts.ts +4 -2
  47. package/src/workspace/migration.ts +1 -1
  48. package/src/workspace/project-loader.ts +4 -2
  49. package/src/workspace/repo-id.ts +42 -4
  50. package/src/workspace/workspace.ts +17 -10
@@ -142,9 +142,9 @@ export interface VxPlugin {
142
142
  * many times per run, so it must be cheap and synchronous; a throw, or
143
143
  * an answer that is a Promise, is reported once and the plugin admits
144
144
  * from then on — a policy never
145
- * breaks a run. Restore-tier hits and tasks on an executor pool hold no
146
- * local resources and are never asked. When several plugins answer, all
147
- * must admit. Core keeps no notion of what a task needs: a plugin that
145
+ * breaks a run. Restore-tier hits, tasks on an executor pool and groups
146
+ * hold no local resources: never asked, never in `ctx.running`. When
147
+ * several plugins answer, all must admit. Core keeps no notion of what a task needs: a plugin that
148
148
  * packs memory or CPU learns or declares the numbers itself
149
149
  * (`@vzn/vx-schedule-history` packs what past executions used).
150
150
  */
@@ -341,7 +341,12 @@ export interface FingerprintContext extends BaseContext {
341
341
  }
342
342
 
343
343
  export interface AdmitContext {
344
- /** The tasks executing on this machine right now, in dispatch order. */
344
+ /**
345
+ * The tasks holding a worker on this machine right now, in dispatch
346
+ * order. A persistent task leaves at ready, when it gives its worker
347
+ * back: it runs until the graph ends, so a policy that counted it would
348
+ * hold its dependants with no completion left to ask again.
349
+ */
345
350
  readonly running: readonly TaskNode[]
346
351
  /** The run's worker count — the ceiling the count gate already applies. */
347
352
  readonly concurrency: number
@@ -601,7 +606,8 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
601
606
  const { plugins, bus, workspaceRoot, cacheDir } = args
602
607
  const warn = args.warn ?? ((m) => console.error(m))
603
608
  const disposers: Array<() => void> = []
604
- const disabled = new Set<string>()
609
+ // By object, not name: one package can export two plugins.
610
+ const disabled = new Set<Plugin>()
605
611
 
606
612
  for (const plugin of plugins) {
607
613
  if (typeof plugin.name !== 'string' || plugin.name.length === 0) {
@@ -615,6 +621,13 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
615
621
  throw new UserError(`plugin '${plugin.name}' setup is not a function`)
616
622
  }
617
623
 
624
+ const fail = (where: string, err: unknown): void => {
625
+ if (disabled.has(plugin)) return
626
+ disabled.add(plugin)
627
+ warn(
628
+ `[vx] plugin '${plugin.name}' threw in ${where}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
629
+ )
630
+ }
618
631
  const ctx: PluginContext = {
619
632
  workspaceRoot,
620
633
  cacheDir,
@@ -625,7 +638,18 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
625
638
  bus: {
626
639
  emit: (event) => bus.emit(event),
627
640
  subscribe(subscriber) {
628
- const dispose = bus.subscribe(subscriber)
641
+ // Isolated like an `on` handler: the bus itself swallows a throw.
642
+ const dispose = bus.subscribe((event) => {
643
+ if (disabled.has(plugin)) return
644
+ let ret: unknown
645
+ try {
646
+ ret = subscriber(event)
647
+ } catch (err) {
648
+ fail('a bus subscriber', err)
649
+ return
650
+ }
651
+ if (ret instanceof Promise) ret.catch((err: unknown) => fail('a bus subscriber', err))
652
+ })
629
653
  disposers.push(dispose)
630
654
  return dispose
631
655
  },
@@ -638,15 +662,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
638
662
  `ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
639
663
  )
640
664
  }
641
- const fail = (err: unknown): void => {
642
- if (disabled.has(plugin.name)) return
643
- disabled.add(plugin.name)
644
- warn(
645
- `[vx] plugin '${plugin.name}' threw in ${hook}; disabled for this run: ${err instanceof Error ? err.message : String(err)}`,
646
- )
647
- }
665
+ const onFail = (err: unknown): void => fail(hook, err)
648
666
  const dispose = bus.subscribe((event) => {
649
- if (disabled.has(plugin.name)) return
667
+ if (disabled.has(plugin)) return
650
668
  // Not awaited: the bus is synchronous and plugin work runs off the
651
669
  // critical path. A handler's rejection is caught like its throw.
652
670
  let ret: unknown
@@ -681,10 +699,10 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
681
699
  break
682
700
  }
683
701
  } catch (err) {
684
- fail(err)
702
+ onFail(err)
685
703
  return
686
704
  }
687
- if (ret instanceof Promise) ret.catch(fail)
705
+ if (ret instanceof Promise) ret.catch(onFail)
688
706
  })
689
707
  disposers.push(dispose)
690
708
  },
@@ -693,6 +711,9 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
693
711
  try {
694
712
  await plugin.setup(ctx)
695
713
  } catch (err) {
714
+ // Earlier plugins' subscriptions too: the caller never gets the
715
+ // disposer, and a bus that outlives the run would keep them.
716
+ for (const d of disposers) d()
696
717
  throw new PluginSetupError(
697
718
  plugin,
698
719
  `plugin '${plugin.name}' failed in setup: ${err instanceof Error ? err.message : String(err)}`,
@@ -368,8 +368,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
368
368
  ? options.projects.filter((p) => projects.has(p))
369
369
  : [...projects.keys()]
370
370
 
371
- const requested = expandRequested(tasks, candidateProjects, projects)
372
- let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects)
371
+ // A bare name skips the default `build`: it runs nothing, and matched,
372
+ // `vx run build` in a project declaring none ran 0 tasks, exit 0 (X-102).
373
+ // A dependant's `^build` still reaches it, and `lib#build` names it.
374
+ const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
375
+ let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
373
376
  if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
374
377
  // A name a loaded project declares is no typo, whether or not the load
375
378
  // was whole: an unaffected dependency loaded for the closure declared
@@ -750,14 +753,20 @@ async function declaredNowhere(
750
753
  return undeclaredIn(unresolved, all.projects)
751
754
  }
752
755
 
753
- /** The bare names in `unresolved` no project in `projects` declares; `a#b` forms pass through. */
756
+ /**
757
+ * The bare names in `unresolved` no project in `projects` declares; `a#b`
758
+ * forms pass through. Every project has a `build`, its default one if
759
+ * nothing declares it, and a bare name does not select that (X-102).
760
+ */
754
761
  function undeclaredIn(
755
762
  unresolved: readonly string[],
756
763
  projects: LoadedProjects['projects'],
757
764
  ): string[] {
758
765
  const declared = new Set<string>()
759
766
  for (const p of projects.values()) {
760
- for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
767
+ for (const [t, task] of Object.entries(p.config.tasks ?? {})) {
768
+ if (!isDefaultBuild(task)) declared.add(t)
769
+ }
761
770
  }
762
771
  return unresolved.filter((t) => t.includes('#') || !declared.has(t))
763
772
  }
@@ -18,6 +18,7 @@ import { isGroupTask, type TaskOutcome } from '../graph/index.js'
18
18
  import { tallyOutcomes, type Tally } from './tally.js'
19
19
  import type { FlakyFinding } from './failure-mode.js'
20
20
  import { isCacheHit } from './telemetry.js'
21
+ import { ranNoCache } from './events.js'
21
22
 
22
23
  export interface SummarizeArgs {
23
24
  /** Empty string → default path; otherwise the explicit file path. */
@@ -92,7 +93,7 @@ function taskEntry(o: TaskOutcome, flaky?: FlakyFinding): SummaryTaskJson {
92
93
  // the flag a consumer computing a hit rate cannot tell a miss from a
93
94
  // task that could never hit (its `hash` is still set — dependents fold
94
95
  // it). Present only when true, so every other row is byte-identical.
95
- ...(o.node.config.cache === undefined ? { noCache: true } : {}),
96
+ ...(ranNoCache(o) ? { noCache: true } : {}),
96
97
  // Present only when the run proved the task flaky: this exact key has
97
98
  // both passed and failed on record (this run counted), or the task
98
99
  // needed a retry. A consumer gating on `failed` can tell a break from a
@@ -6,7 +6,13 @@ import type { ProjectEntry } from '../workspace/index.js'
6
6
  import { loadWorkspace, unreachedHint, unreachedPackages } from '../workspace/index.js'
7
7
  import { realpathSync } from 'node:fs'
8
8
  import path from 'node:path'
9
- import { Cache, type CacheLayer, type CachePolicy, stopRuntimeProbes } from '../cache/index.js'
9
+ import {
10
+ Cache,
11
+ type CacheLayer,
12
+ type CachePolicy,
13
+ cachesNothing,
14
+ stopRuntimeProbes,
15
+ } from '../cache/index.js'
10
16
  import { VERSION } from '../version.js'
11
17
  import {
12
18
  resetSandbox,
@@ -44,6 +50,7 @@ import {
44
50
  secretMask,
45
51
  } from '../util/index.js'
46
52
  import { keyedProjects } from './keyed-projects.js'
53
+ import { isDefaultBuild } from './projects.js'
47
54
  import { prepareSandbox } from './sandbox-request.js'
48
55
  import type { OutputDirSnapshot, SaveFacts } from './miss-save.js'
49
56
  import { admitTasks, taintTracker } from './admission.js'
@@ -1017,6 +1024,7 @@ async function runOnBus(
1017
1024
  // is one probe of the failed-row index.
1018
1025
  const flaky: FlakyFinding[] = []
1019
1026
  const historyDb = prepared.localCache.dbHandle()
1027
+ const cacheOff = cachesNothing(policy)
1020
1028
  const judgeFlaky = (o: TaskOutcome): void => {
1021
1029
  const candidates = flakyCandidates([o])
1022
1030
  if (candidates.length === 0) return
@@ -1044,6 +1052,7 @@ async function runOnBus(
1044
1052
  log.taskStart?.(node)
1045
1053
  },
1046
1054
  onFinish: (o) => {
1055
+ if (cacheOff) o.cacheOff = true
1047
1056
  taint.settled(o)
1048
1057
  judgeFlaky(o)
1049
1058
  log.taskComplete(o.node, o)
@@ -1346,6 +1355,8 @@ async function runOnBus(
1346
1355
  // task's own facts (flaky, blocked) ride its row. Only a kept server's
1347
1356
  // own output follows.
1348
1357
  if (options.summaryTable === true) for (const line of formatOutcomeTable(list)) log.status(line)
1358
+ const above = options.beforeFooter?.(list, ok)
1359
+ if (above) log.status(above.replace(/\n$/, ''))
1349
1360
  for (const line of formatRunSummary(list, totalMs, colors, runContext)) log.status(line)
1350
1361
 
1351
1362
  // Edge case the summary already reported: the user requested a
@@ -1430,7 +1441,9 @@ async function runOnBus(
1430
1441
  await summarize(ok && first.code === 0, final)
1431
1442
  if (historyAfterWait) {
1432
1443
  recordHistory(() => {
1433
- const local = Cache.inspect(prepared.cacheDir)
1444
+ // A writer's open: another vx version may have reset the index
1445
+ // to its schema during the session, and a mismatch resets silently.
1446
+ const local = new Cache(prepared.cacheDir, undefined, prepared.workspaceRoot)
1434
1447
  try {
1435
1448
  local.recordRunBundle(recordsOf(final, ok && first.code === 0))
1436
1449
  } finally {
@@ -1632,7 +1645,7 @@ async function initHint(prepared: {
1632
1645
  // Single-project mode with packages the root's missing `workspaces`
1633
1646
  // never reaches: `vx init` would find no scripts either (item 248).
1634
1647
  const unreached = await unreachedPackages(await loadWorkspace(prepared.workspaceRoot))
1635
- if (unreached.length > 0) return ` ${unreachedHint(unreached)}`
1648
+ if (unreached.length > 0) return ` ${unreachedHint(unreached, prepared.workspaceRoot)}`
1636
1649
  return ' No package declares a vx.config — run `vx init` to write one per package from its package.json scripts.'
1637
1650
  }
1638
1651
 
@@ -1649,8 +1662,11 @@ function didYouMean(
1649
1662
  elsewhere: readonly string[] = [],
1650
1663
  ): string {
1651
1664
  const tasksOf = (p: ProjectEntry | undefined): string[] => Object.keys(p?.config.tasks ?? {})
1665
+ // What a bare name can select: a default `build` is no match (X-102).
1652
1666
  const allTasks = new Set<string>()
1653
- for (const p of projects.values()) for (const t of tasksOf(p)) allTasks.add(t)
1667
+ for (const p of projects.values()) {
1668
+ for (const t of tasksOf(p)) if (!isDefaultBuild(p.config.tasks![t])) allTasks.add(t)
1669
+ }
1654
1670
  // A Set: two typos of the same task hint it once, not once per typo.
1655
1671
  const hints = new Set<string>()
1656
1672
  // A typo past two edits named nothing to pick from (M-56): with no near
@@ -271,7 +271,7 @@ export async function sandboxRequestFor(
271
271
  // could copy a key into an output the cache shares (L-41).
272
272
  baseDenyRead: [
273
273
  ...(process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot]),
274
- ...credentialStores(workspaceRoot),
274
+ ...(await credentialStores(workspaceRoot, config.allowWrite)),
275
275
  ],
276
276
  // …but only denials INSIDE the project are worth reporting. A task
277
277
  // bumping into the wall is the sandbox working, not a finding: the
@@ -464,11 +464,13 @@ async function prepareOutputsForBind(
464
464
  await mkdir(abs, { recursive: true }).catch(() => undefined)
465
465
  continue
466
466
  }
467
- if (hasWildcard || g.endsWith('/')) {
468
- const abs = path.join(projectDir, hasWildcard ? grantPrefix(g) : g)
469
- await mkdir(abs, { recursive: true })
470
- } else {
471
- const abs = path.join(projectDir, g)
467
+ const dir = hasWildcard || g.endsWith('/')
468
+ const abs = path.join(projectDir, hasWildcard ? grantPrefix(g) : g)
469
+ try {
470
+ if (dir) {
471
+ await mkdir(abs, { recursive: true })
472
+ continue
473
+ }
472
474
  // Whatever is already there is what the task meant — a grant on the
473
475
  // project dir itself is a directory, and touching it as a file is
474
476
  // an EISDIR, not a missing bind. `lstat`, and an exclusive create: a
@@ -478,11 +480,33 @@ async function prepareOutputsForBind(
478
480
  await mkdir(path.dirname(abs), { recursive: true })
479
481
  await writeFile(abs, '', { flag: 'wx' })
480
482
  placeholders.push({ path: abs, mtimeMs: (await lstat(abs)).mtimeMs })
483
+ } catch (err) {
484
+ await sweepPlaceholders(placeholders)
485
+ const code = (err as NodeJS.ErrnoException).code
486
+ if (code !== 'EEXIST' && code !== 'ENOTDIR') throw err
487
+ // A file stands where the grant needs a directory: `out.txt/` over a
488
+ // file, or `a.txt/x/*.js`. It reached the user as an internal error.
489
+ const blocker = await fileOnPath(projectDir, dir ? abs : path.dirname(abs))
490
+ throw new UserError(
491
+ `exec.sandbox.allow.write: "${g}" needs a directory at ${blocker}, and a file is ` +
492
+ `there — remove the file, or grant a path beside it`,
493
+ )
481
494
  }
482
495
  }
483
496
  return placeholders
484
497
  }
485
498
 
499
+ /** The first path from `from` down to `to` that exists and is not a directory. */
500
+ async function fileOnPath(from: string, to: string): Promise<string> {
501
+ let at = from
502
+ for (const part of path.relative(from, to).split(path.sep)) {
503
+ at = path.join(at, part)
504
+ const st = await lstat(at).catch(() => undefined)
505
+ if (st !== undefined && !st.isDirectory()) return at
506
+ }
507
+ return to
508
+ }
509
+
486
510
  /**
487
511
  * The walls a project's grants stop at: its nested projects, the
488
512
  * repository and vx's directory (item 1010). A read grant around one is
@@ -589,13 +613,35 @@ const CREDENTIAL_STORES = [
589
613
  '.npmrc',
590
614
  '.yarnrc.yml',
591
615
  '.pypirc',
616
+ // Registry and service tokens of the same kind: Bun's global bunfig
617
+ // (`[install.scopes]` tokens) at either of its homes, Cargo's, RubyGems'.
618
+ '.bunfig.toml',
619
+ '.config/.bunfig.toml',
620
+ '.cargo/credentials',
621
+ '.cargo/credentials.toml',
622
+ '.gem/credentials',
623
+ '.config/hub',
624
+ '.config/containers/auth.json',
625
+ '.terraform.d/credentials.tfrc.json',
626
+ '.vault-token',
627
+ '.pgpass',
592
628
  ]
593
629
 
594
630
  /** The stores present on this host, learned once per home. */
595
631
  let presentStores: { home: string; paths: string[] } | undefined
596
632
 
597
- /** The credential stores to deny, less any that holds the workspace. */
598
- function credentialStores(workspaceRoot: string): string[] {
633
+ /**
634
+ * The credential stores to deny, less any that holds the workspace, and
635
+ * punched around the task's write grants: a grant names a store as a read
636
+ * does. Denied whole, the store's mask landed on the grant's bind (a file
637
+ * grant binds its directory on Linux), so `write: ['~/.npmrc']` wrote to
638
+ * /dev/null with exit 0, and `~/.aws/config` left `~/.aws/credentials`
639
+ * readable. A grant above a store does not name it, so it stays denied.
640
+ */
641
+ async function credentialStores(
642
+ workspaceRoot: string,
643
+ writes: readonly string[],
644
+ ): Promise<string[]> {
599
645
  const home = homedir()
600
646
  if (presentStores?.home !== home) {
601
647
  presentStores = {
@@ -603,9 +649,19 @@ function credentialStores(workspaceRoot: string): string[] {
603
649
  paths: CREDENTIAL_STORES.map((rel) => path.join(home, rel)).filter((p) => existsSync(p)),
604
650
  }
605
651
  }
606
- return presentStores.paths.filter(
652
+ const stores = presentStores.paths.filter(
607
653
  (p) => workspaceRoot !== p && !workspaceRoot.startsWith(p + path.sep),
608
654
  )
655
+ return (await Promise.all(stores.map((p) => denyAround(toRealPath(p), writes)))).flat()
656
+ }
657
+
658
+ /** `dir` denied, or its entries no write grant lies under or at when one is inside it. */
659
+ async function denyAround(dir: string, writes: readonly string[]): Promise<string[]> {
660
+ const inside = writes.filter((w) => atOrUnder(w, dir))
661
+ if (inside.length === 0) return [dir]
662
+ if (inside.includes(dir)) return []
663
+ const entries = await readdir(dir).catch(() => [])
664
+ return (await Promise.all(entries.map((e) => denyAround(path.join(dir, e), inside)))).flat()
609
665
  }
610
666
 
611
667
  /** `~/x` against the user's home; anything else unchanged. */
@@ -21,22 +21,22 @@ export interface SaveLane {
21
21
 
22
22
  export function createSaveLane(cap: number, onError: (err: unknown) => void): SaveLane {
23
23
  const queue: Array<{ save: () => Promise<void>; settle: () => void }> = []
24
- const running = new Set<Promise<void>>()
24
+ let running = 0
25
25
  const start = (save: () => Promise<void>, settle: () => void): void => {
26
- const p = save()
26
+ void save()
27
27
  .catch(onError)
28
28
  .then(() => {
29
- running.delete(p)
29
+ running--
30
30
  settle()
31
31
  const next = queue.shift()
32
32
  if (next !== undefined) start(next.save, next.settle)
33
33
  })
34
- running.add(p)
34
+ running++
35
35
  }
36
36
  return {
37
37
  defer(save) {
38
38
  return new Promise<void>((settle) => {
39
- if (running.size < cap) start(save, settle)
39
+ if (running < cap) start(save, settle)
40
40
  else queue.push({ save, settle })
41
41
  })
42
42
  },
@@ -6,7 +6,7 @@
6
6
  import type { TaskOutcome } from '../graph/index.js'
7
7
  import { paint, type ColorSupport } from './colors.js'
8
8
  import { tallyOutcomes } from './tally.js'
9
- import { outcomeLabel, projectOutcome } from './events.js'
9
+ import { outcomeLabel, projectOutcome, ranNoCache } from './events.js'
10
10
  import { isGroupTask } from '../graph/index.js'
11
11
  import { formatElapsed } from '../util/index.js'
12
12
 
@@ -365,9 +365,7 @@ export function formatRunSummary(
365
365
  // distinction — it never consulted the cache, so it is not a miss.
366
366
  const noCache = outcomes.filter(
367
367
  (o) =>
368
- !isGroupTask(o.node) &&
369
- (o.status === 'success' || o.status === 'failed') &&
370
- o.node.config.cache === undefined,
368
+ !isGroupTask(o.node) && (o.status === 'success' || o.status === 'failed') && ranNoCache(o),
371
369
  ).length
372
370
  const t = tallyOutcomes(outcomes)
373
371
  // Spread over executed tasks only — `success`/`failed` statuses ran
@@ -481,6 +481,15 @@ export interface TelemetrySource {
481
481
  /** How often a running task's process tree is sampled. */
482
482
  const SAMPLE_MS = 1000
483
483
 
484
+ /** Call `fn` every `ms` until the returned stop is called. */
485
+ type Every = (ms: number, fn: () => Promise<void>) => () => void
486
+
487
+ const everyInterval: Every = (ms, fn) => {
488
+ const timer = setInterval(() => void fn(), ms)
489
+ timer.unref()
490
+ return () => clearInterval(timer)
491
+ }
492
+
484
493
  const DEFAULT_KINDS: ReadonlyArray<TelemetryRecord['kind']> = [
485
494
  'run.start',
486
495
  'task.start',
@@ -508,6 +517,8 @@ export function createTelemetrySource(args: {
508
517
  owners?: ReadonlyMap<TelemetrySink, string>
509
518
  /** The run's task graph, to see a group dependency through to the tasks behind it. */
510
519
  nodes?: ReadonlyMap<string, TaskNode>
520
+ /** The sampler's clock; a test ticks it by hand instead of waiting on the wall. */
521
+ every?: Every
511
522
  }): TelemetrySource {
512
523
  const { sinks, run, warn } = args
513
524
  const runId = run.runId
@@ -543,12 +554,13 @@ export function createTelemetrySource(args: {
543
554
  // only while one runs; a tick still reading when the next is due is not
544
555
  // doubled.
545
556
  const tracked = new Map<string, number>()
546
- let timer: ReturnType<typeof setInterval> | undefined
557
+ const every = args.every ?? everyInterval
558
+ let stopEvery: (() => void) | undefined
547
559
  let sampling = false
548
560
  let ended = false
549
561
  const stopTimer = (): void => {
550
- if (timer !== undefined) clearInterval(timer)
551
- timer = undefined
562
+ stopEvery?.()
563
+ stopEvery = undefined
552
564
  }
553
565
  const untrack = (taskId: string, pid: number): void => {
554
566
  if (tracked.get(taskId) === pid) tracked.delete(taskId)
@@ -587,10 +599,7 @@ export function createTelemetrySource(args: {
587
599
  const track = (taskId: string, pid: number): (() => void) => {
588
600
  if (ended) return () => {}
589
601
  tracked.set(taskId, pid)
590
- if (timer === undefined) {
591
- timer = setInterval(() => void tick(), SAMPLE_MS)
592
- timer.unref()
593
- }
602
+ stopEvery ??= every(SAMPLE_MS, tick)
594
603
  return () => untrack(taskId, pid)
595
604
  }
596
605
 
package/src/util/paths.ts CHANGED
@@ -198,6 +198,12 @@ export function taskGlob(pattern: string): Bun.Glob {
198
198
  // A `]` with no `[` before it is already literal to `Bun.Glob`.
199
199
  const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
200
200
  const glob = new Bun.Glob(source)
201
+ const alternatives = braceAlternatives(pattern)
202
+ if (alternatives.length > 1) {
203
+ const any = anyTaskGlob(alternatives)
204
+ glob.match = (rel: string): boolean => any(rel)
205
+ return glob
206
+ }
201
207
  const re = regExpSource(pattern)
202
208
  if (re === null) return glob
203
209
  const compiled = new RegExp(`^${re}$`, 'u')
@@ -216,8 +222,13 @@ export function taskGlob(pattern: string): Bun.Glob {
216
222
  * RegExp for the lot when every pattern has one, else a glob each. An input
217
223
  * file was matched against a dozen globs (its positives, the always-ignored
218
224
  * list, its negatives) one native call at a time.
225
+ *
226
+ * A brace is expanded first, never matched by `Bun.Glob`: its match reads a
227
+ * `**` that ends an alternative as one name, so `{src/**,lib/**}` took
228
+ * `src/a.ts` and never `src/deep/a.ts`, and an edit there was a stale hit.
219
229
  */
220
- export function anyTaskGlob(patterns: readonly string[]): (rel: string) => boolean {
230
+ export function anyTaskGlob(rawPatterns: readonly string[]): (rel: string) => boolean {
231
+ const patterns = rawPatterns.flatMap(braceAlternatives)
221
232
  if (patterns.length === 0) return () => false
222
233
  const sources = patterns.map(regExpSource)
223
234
  const globs = patterns.map(taskGlob)
@@ -339,6 +350,15 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
339
350
  * is left to the glob.
340
351
  */
341
352
  export function slashBraceExpansions(pattern: string): string[] {
353
+ return expandBraces(pattern, true)
354
+ }
355
+
356
+ /** `pattern` with every brace group of two or more alternatives expanded. */
357
+ function braceAlternatives(pattern: string): string[] {
358
+ return expandBraces(pattern, false)
359
+ }
360
+
361
+ function expandBraces(pattern: string, slashOnly: boolean): string[] {
342
362
  if (pattern.includes('\\')) return [pattern]
343
363
  for (let open = pattern.indexOf('{'); open !== -1; open = pattern.indexOf('{', open + 1)) {
344
364
  let depth = 0
@@ -352,14 +372,14 @@ export function slashBraceExpansions(pattern: string): string[] {
352
372
  }
353
373
  if (close === -1) return [pattern]
354
374
  const body = pattern.slice(open + 1, close)
355
- if (cuts.length === 0 || !body.includes('/')) continue
375
+ if (cuts.length === 0 || (slashOnly && !body.includes('/'))) continue
356
376
  const head = pattern.slice(0, open)
357
377
  const tail = pattern.slice(close + 1)
358
378
  const bounds = [open, ...cuts, close]
359
379
  const out: string[] = []
360
380
  for (let k = 0; k + 1 < bounds.length; k++) {
361
381
  const alt = pattern.slice(bounds[k]! + 1, bounds[k + 1])
362
- out.push(...slashBraceExpansions(head + alt + tail))
382
+ out.push(...expandBraces(head + alt + tail, slashOnly))
363
383
  }
364
384
  return [...new Set(out)]
365
385
  }
@@ -20,9 +20,9 @@
20
20
  // the lockfile moves, which the workspace fingerprint already covers.
21
21
  // A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
22
22
  // from disk (D-27).
23
- // - Descend only through files owned by NO project, or by a ROOT project
23
+ // - Descend only through files owned by NO project, by a ROOT project
24
24
  // (whose own files are the shared tooling that is otherwise unowned,
25
- // D-41). A config reaching into another project (say a site's
25
+ // D-41), or by the importing file's own project. A config reaching into another project (say a site's
26
26
  // `vx.config.ts` importing `../core/src/index.ts`) records that edge
27
27
  // and STOPS there — following it would drag substantially all of that
28
28
  // project's `src/` into the closure, and the containment channel
@@ -486,16 +486,18 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
486
486
  if (entry === null) continue
487
487
  const { file, source } = entry
488
488
  const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
489
+ const from = ownerOf(file, dirToName)
489
490
  for (const target of scanLocalImports(source, path.dirname(file), loader, tsconfigs)) {
490
491
  if (!target.startsWith(workspaceRoot + path.sep)) continue
491
492
  if (target.split(path.sep).includes('node_modules')) continue
492
493
  const list = importedBy.get(target)
493
494
  if (list) list.push(file)
494
495
  else importedBy.set(target, [file])
495
- // Descend ONLY through unowned files, or the root project's own:
496
- // see the header.
496
+ // Descend ONLY through unowned files, the root project's own, or
497
+ // the importer's own project's (a config split into `./tasks.mjs`
498
+ // that imports shared tooling): see the header.
497
499
  const owner = ownerOf(target, dirToName)
498
- if (owner === undefined || owner === rootOwner) frontier.push(target)
500
+ if (owner === undefined || owner === rootOwner || owner === from) frontier.push(target)
499
501
  }
500
502
  }
501
503
  }
@@ -144,6 +144,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
144
144
  ) {
145
145
  throw new UserError(`${configPath}: \`cacheDir\` is only whitespace — name a directory`)
146
146
  }
147
+ // No path can carry one: the mkdir's argument error was reported as an
148
+ // unwritable workspace.
149
+ if (typeof config.cacheDir === 'string' && config.cacheDir.includes('\0')) {
150
+ throw new UserError(`${configPath}: \`cacheDir\` holds a NUL, which no path can carry`)
151
+ }
147
152
  if (config.timeout !== undefined) {
148
153
  if (
149
154
  typeof config.timeout !== 'number' ||
@@ -156,8 +161,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
156
161
  }
157
162
  if (config.cacheRetention !== undefined) validateRetention(config.cacheRetention, configPath)
158
163
  if (config.affectedBase !== undefined) {
159
- // A leading '-' would reach git as an option, not a ref.
160
- if (typeof config.affectedBase !== 'string' || !/^[^-\s]\S*$/.test(config.affectedBase)) {
164
+ // A leading '-' would reach git as an option, not a ref; a NUL fails the
165
+ // spawn with an argument error naming nothing in this file.
166
+ if (
167
+ typeof config.affectedBase !== 'string' ||
168
+ !/^[^-\s\0][^\s\0]*$/.test(config.affectedBase)
169
+ ) {
161
170
  throw new UserError(`${configPath}: \`affectedBase\` must be a git ref like 'origin/main'`)
162
171
  }
163
172
  }
@@ -463,6 +472,8 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
463
472
  `(got "${wild}") — list explicit env var names instead`,
464
473
  )
465
474
  }
475
+ for (const n of passThrough as string[])
476
+ assertShellName(n, `${where}.exec.env.passThrough`)
466
477
  }
467
478
  const secret = (env as { secret?: unknown }).secret
468
479
  if (
@@ -489,6 +500,7 @@ export function validateProjectConfig(config: ProjectConfig, configPath: string)
489
500
  `${where}.exec.env.define: ${JSON.stringify(k)} is not an env var name (non-empty, no '=' or NUL)`,
490
501
  )
491
502
  }
503
+ assertShellName(k, `${where}.exec.env.define`)
492
504
  if (typeof val !== 'string' || val.includes('\0')) {
493
505
  throw new UserError(`${where}.exec.env.define.${k} must be a string with no NUL`)
494
506
  }
@@ -1350,8 +1362,19 @@ function specForm(spec: string): SpecForm {
1350
1362
  * A name an environment can hold. An `=` splits at the first one, so
1351
1363
  * `define: { 'A=B': 'x' }` gave the child `A` with the value `B=x`; `''`
1352
1364
  * was dropped; a NUL failed the spawn with a hint about exit 127 (item
1353
- * 999). Refused at load, where the config is named.
1365
+ * 999). And the task runs under `sh -c`, whose dash (Linux) drops a
1366
+ * variable whose name is no shell identifier: `my.var` reached a task
1367
+ * under macOS's bash and nothing on Linux. Refused at load, where the
1368
+ * config is named.
1354
1369
  */
1370
+ function assertShellName(name: string, field: string): void {
1371
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
1372
+ throw new UserError(
1373
+ `${field}: ${JSON.stringify(name)} is not a shell variable name ([A-Za-z_][A-Za-z0-9_]*); sh would drop it before the task runs`,
1374
+ )
1375
+ }
1376
+ }
1377
+
1355
1378
  function isEnvName(name: unknown): name is string {
1356
1379
  return typeof name === 'string' && name.length > 0 && !name.includes('=') && !name.includes('\0')
1357
1380
  }