@vzn/vx 0.0.410 → 0.0.412

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/README.md CHANGED
@@ -34,11 +34,11 @@ Same graph, commands and concurrency: [how it is measured](https://vznjs.github.
34
34
 
35
35
  <!-- bench:end -->
36
36
 
37
- ## Why it is faster
37
+ ## What vx is
38
38
 
39
- - **Nothing boots per task:** no daemon, no Node process; vx spawns your command.
40
- - **No source-file reads to hash:** on a clean tree, keys come from git's index; each project's `package.json` and the lockfile are still read.
41
- - **One pass:** one git walk per run; each stat and lookup happens once.
39
+ A task runner and build cache for JavaScript monorepos.
40
+ Each task is one shell command with declared inputs and outputs; vx runs
41
+ them in dependency order and replays results from a content-addressed cache.
42
42
 
43
43
  ## Install
44
44
 
@@ -103,6 +103,12 @@ start, not a way to run the repo. The numbers above are native vx
103
103
  config. `@vzn/vx-migrate`'s first npm publish is pending.
104
104
  [Migration guide](https://vznjs.github.io/vx/guides/migrate/).
105
105
 
106
+ ## Why it is faster
107
+
108
+ - **Nothing boots per task:** no daemon, no Node process; vx spawns your command.
109
+ - **No source-file reads to hash:** on a clean tree, keys come from git's index; each project's `package.json` and the lockfile are still read.
110
+ - **One pass:** one git walk per run; each stat and lookup happens once.
111
+
106
112
  ## More than speed
107
113
 
108
114
  - **A cache you can trust:** TypeScript configs are evaluated before
@@ -111,22 +117,21 @@ config. `@vzn/vx-migrate`'s first npm publish is pending.
111
117
  - **Sandboxed tasks:** opt in per task; a workspace file the task did
112
118
  not declare is out of its reach, so it cannot poison the cache.
113
119
  - **Clean exits:** Ctrl-C reaps each task's process group.
114
- - **Plugins at every stage:** remote cache, remote execution, telemetry,
120
+ - **Plugins at every stage** (14 hooks, `commands` included): remote cache, remote execution, telemetry,
115
121
  CLI verbs. Core ships none and needs none.
116
122
  - **Free:** MIT. No paywall, no cloud, no account.
117
123
 
118
124
  ## How it compares
119
125
 
120
- | | vx | Turborepo | Nx |
121
- | ------------------------- | ----------------------------------------- | ------------------------------ | ---------------- |
122
- | Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) |
123
- | Output ownership | **Strict**: wiped before exec and restore | Additive (stale files survive) | Additive |
124
- | Clean-tree hashing | **No source reads** (git index OIDs) | git OIDs | re-hash / daemon |
125
- | Daemon required for speed | **No** | No (`turbo run` has none) | Yes |
126
- | Per-task sandbox | **Yes**: kernel-level, opt-in | No | No |
127
- | Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) |
128
- | OTel CI/CD spans | **Yes**: `otel()` plugin, no OTel SDK | No | Paid |
129
- | Install | **Single binary**; no Bun, Node optional | npm + Node | npm + Node |
126
+ | | vx | Turborepo | Nx |
127
+ | ------------------ | ----------------------------------------- | ------------------------------ | ---------------- |
128
+ | Config | TypeScript, evaluated into the cache key | JSON (static) | JSON (static) |
129
+ | Output ownership | **Strict**: wiped before exec and restore | Additive (stale files survive) | Additive |
130
+ | Clean-tree hashing | **No source reads** (git index OIDs) | git OIDs | re-hash / daemon |
131
+ | Daemon | **None** | None for `turbo run` | On by default |
132
+ | Per-task sandbox | **Yes**: kernel-level, opt-in | No | No |
133
+ | Plugin API | **Yes**: every pipeline stage | No | Yes (TS-tied) |
134
+ | Install | **Single binary**; no Bun, Node optional | npm + Node | npm + Node |
130
135
 
131
136
  Every gap, and when another tool is the better pick:
132
137
  [comparison](packages/vx/docs/comparison.md) ·
@@ -157,13 +162,6 @@ Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
157
162
  vx. The schema may still change; 1.0's promise:
158
163
  [versioning-1.0.md](packages/vx/docs/design/versioning-1.0.md).
159
164
 
160
- | Surface | Maturity |
161
- | -------------------------------------------------- | -------------------- |
162
- | Core task runner + caching | **production-ready** |
163
- | Plugin pipeline (14 hooks, `commands` included) | **shippable** |
164
- | `vx init` / `@vzn/vx-migrate` (scripts; Turbo, Nx) | **shippable** |
165
- | First-party plugins (table above) | **shippable** |
166
-
167
165
  ## Contributing
168
166
 
169
167
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.410",
3
+ "version": "0.0.412",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.410",
25
- "@vzn/vx-linux-arm64": "0.0.410",
26
- "@vzn/vx-darwin-x64": "0.0.410",
27
- "@vzn/vx-darwin-arm64": "0.0.410"
24
+ "@vzn/vx-linux-x64": "0.0.412",
25
+ "@vzn/vx-linux-arm64": "0.0.412",
26
+ "@vzn/vx-darwin-x64": "0.0.412",
27
+ "@vzn/vx-darwin-arm64": "0.0.412"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -456,6 +456,7 @@ export async function loadProjectConfigs(
456
456
  // More than one evaluation in flight: a change seen after one load may
457
457
  // be another's.
458
458
  let overlapping = false
459
+ let tainted = false
459
460
  const loadOne = async (entry: (typeof prepared)[number]): Promise<Loaded> => {
460
461
  const { configPath, cacheKey } = entry
461
462
  // A fast key that missed: the closure is stale or the file changed.
@@ -500,7 +501,7 @@ export async function loadProjectConfigs(
500
501
  const changed = repeat
501
502
  ? []
502
503
  : [
503
- ...restoreBuiltins(builtins),
504
+ ...restoreBuiltins(builtins, overlapping),
504
505
  ...restoreEnv(env),
505
506
  ...restoreCwd(cwd),
506
507
  ...restoreUmask(umask),
@@ -585,20 +586,30 @@ export async function loadProjectConfigs(
585
586
  ...restoreUmask(umask),
586
587
  ]
587
588
  : []
588
- if (first?.failed instanceof ChangedInRound) {
589
+ // Overlapping loads check most built-ins only here, so a change one
590
+ // config made may have broken another's load: it is refused first, and
591
+ // nothing the round evaluated is stored.
592
+ const changedInRound =
593
+ first?.failed instanceof ChangedInRound
594
+ ? first.failed.changed
595
+ : overlapping && changed.length > 0
596
+ ? changed
597
+ : undefined
598
+ if (changedInRound !== undefined) {
599
+ tainted = true
589
600
  for (const i of misses) {
590
601
  const configPath = prepared[i]!.configPath
591
602
  const own = await builtinsChangedBy(configPath)
592
603
  if (own.length > 0) throw builtinsChanged(own, configPath)
593
604
  }
594
- throw builtinsChanged(first.failed.changed)
605
+ throw builtinsChanged(changedInRound)
595
606
  }
596
607
  if (first !== undefined) throw first.failed
597
608
  if (changed.length > 0) throw builtinsChanged(changed)
598
609
  return results.map((r) => (r as Loaded).config)
599
610
  } finally {
600
611
  endRound()
601
- if (store !== undefined) {
612
+ if (store !== undefined && !tainted) {
602
613
  if (evals.length > 0) {
603
614
  if (store.putConfigEvals !== undefined) store.putConfigEvals(evals)
604
615
  else for (const [k, json] of evals) store.putConfigEval(k, json)
@@ -655,10 +666,11 @@ interface OwnProperties {
655
666
 
656
667
  interface BuiltinSnapshot {
657
668
  readonly prims: Primitives
658
- readonly props: readonly OwnProperties[]
669
+ /** Undefined for a watched name the snapshot leaves out. */
670
+ readonly props: readonly (OwnProperties | undefined)[]
659
671
  }
660
672
 
661
- function builtinSnapshot(): BuiltinSnapshot {
673
+ function builtinSnapshot(leaveOut?: string): BuiltinSnapshot {
662
674
  const prims: Primitives = {
663
675
  ownKeys: Reflect.ownKeys,
664
676
  descriptorOf: Object.getOwnPropertyDescriptor,
@@ -668,8 +680,12 @@ function builtinSnapshot(): BuiltinSnapshot {
668
680
  same: Object.is,
669
681
  keyName: String,
670
682
  }
671
- const props: OwnProperties[] = []
683
+ const props: (OwnProperties | undefined)[] = []
672
684
  for (let i = 0; i < WATCHED_BUILTINS.length; i++) {
685
+ if (WATCHED_BUILTINS[i]![0] === leaveOut) {
686
+ props[i] = undefined
687
+ continue
688
+ }
673
689
  const proto = WATCHED_BUILTINS[i]![1]
674
690
  const keys = prims.ownKeys(proto)
675
691
  const descriptors: PropertyDescriptor[] = []
@@ -727,15 +743,36 @@ function builtinsChanged(changed: readonly string[], configPath?: string): UserE
727
743
  )
728
744
  }
729
745
 
746
+ /**
747
+ * The built-ins checked after each of several overlapping loads: the ones
748
+ * the loader itself reads through between loads (its promises, maps, sets,
749
+ * JSON and keys). The rest are checked once, at the round's end: all of
750
+ * them after every load was ~73 µs a config, a quarter of a cold load of
751
+ * 1,000 (2026-10-03). A lone load checks them all. By index, decided at
752
+ * module load: a lookup through a `Set` is one a config can replace (D-124).
753
+ */
754
+ const EVERY_LOAD: readonly boolean[] = WATCHED_BUILTINS.map(([name]) =>
755
+ [
756
+ 'Object.prototype',
757
+ 'JSON',
758
+ 'Promise.prototype',
759
+ 'Map.prototype',
760
+ 'Set.prototype',
761
+ 'Bun.hash',
762
+ ].includes(name),
763
+ )
764
+
730
765
  /** Puts back what changed since `before`, naming each property it put back. */
731
- function restoreBuiltins(before: BuiltinSnapshot | undefined): string[] {
766
+ function restoreBuiltins(before: BuiltinSnapshot | undefined, everyLoadOnly = false): string[] {
732
767
  const changed: string[] = []
733
768
  if (before === undefined) return changed
734
769
  const p = before.prims
735
770
  for (let i = 0; i < WATCHED_BUILTINS.length; i++) {
736
771
  const name = WATCHED_BUILTINS[i]![0]
772
+ if (everyLoadOnly && !EVERY_LOAD[i]) continue
737
773
  const proto = WATCHED_BUILTINS[i]![1]
738
- const was = before.props[i]!
774
+ const was = before.props[i]
775
+ if (was === undefined) continue
739
776
  const keys = p.ownKeys(proto)
740
777
  if (unchanged(p, proto, keys, was)) continue
741
778
  // Slow path, a change only: a linear lookup keeps it off `Map`.
@@ -890,7 +927,29 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
890
927
  const since = workspaceLoadedAt.get(configPath)
891
928
  if (since !== undefined) await refuseStaleWorkspaceImports(configPath, since)
892
929
  const startedAt = Date.now()
893
- const mod = (await loadDefaultExport(configPath, 'Workspace', bytes)) as WorkspaceConfig
930
+ // The same guard a project config's first load has: the workspace config
931
+ // runs in this process too, and its `Object.prototype.exec` ran in a
932
+ // project's task under a key that never saw it (D-126). Its globals are
933
+ // left out: it loads first in every run, filtered or not, so none
934
+ // depends on what else loaded (D-122's case), and plugins' tests and
935
+ // tools hand state through them.
936
+ const builtins = builtinSnapshot('globalThis')
937
+ const env = { ...process.env }
938
+ const cwd = process.cwd()
939
+ const umask = currentUmask()
940
+ let mod: WorkspaceConfig
941
+ try {
942
+ mod = (await loadDefaultExport(configPath, 'Workspace', bytes)) as WorkspaceConfig
943
+ } finally {
944
+ const changed = [
945
+ ...restoreBuiltins(builtins),
946
+ ...restoreEnv(env),
947
+ ...restoreCwd(cwd),
948
+ ...restoreUmask(umask),
949
+ ]
950
+ // eslint-disable-next-line no-unsafe-finally -- the refusal outranks the load's own error
951
+ if (changed.length > 0) throw builtinsChanged(changed, configPath)
952
+ }
894
953
  // Checked again once awaited, as a project config is: a Promise default
895
954
  // passed the first check, and `Promise.resolve(null)` crashed the
896
955
  // validator with a stack while `Promise.resolve(42)` loaded as no