@vzn/vx 0.0.118 → 0.0.120

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
@@ -38,7 +38,7 @@ burns 35 s of CPU in vx, 73 s in Turborepo and 114 minutes in Nx; a
38
38
  fully cached run replays the graph in 510 ms.
39
39
  <!-- bench:end -->
40
40
 
41
- Measured on twelve real Turborepo, Nx and moon repos too: [tried on real repos](#tried-on-real-repos).
41
+ Measured on fourteen real Turborepo, Nx, moon, wireit and lage repos too: [tried on real repos](#tried-on-real-repos).
42
42
 
43
43
  ## Install
44
44
 
@@ -145,25 +145,27 @@ Turbo and Nx feature is in vx, pinned by a test:
145
145
 
146
146
  ## Tried on real repos
147
147
 
148
- Twelve public monorepos, each run with its own `turbo.json`, Nx graph or moon config
148
+ Fourteen public monorepos, each run with its own `turbo.json`, Nx graph, moon, wireit or lage config
149
149
  and nothing rewritten, against the tool it ships with. Both tools on the same machine and
150
150
  tasks per row, medians; vx first, theirs second. Where the other tool
151
151
  wins, the row says so.
152
152
 
153
- | Repo | Against | Cold build | Restore outputs | Nothing to do |
154
- | --------------------------------------------------------------------- | ------- | --------------------- | --------------- | ------------------- |
155
- | [solidjs/solid](https://github.com/solidjs/solid) | Turbo | 40.6 s / 45.5 s | 66 ms / 127 ms | 51 ms / 95 ms |
156
- | [withastro/astro](https://github.com/withastro/astro) | Turbo | 48.0 s / 57.9 s | 478 ms / 1.43 s | 249 ms / 1.38 s |
157
- | [payloadcms/payload](https://github.com/payloadcms/payload) | Turbo | 126.9 s / 127.5 s | 3.44 s / 3.46 s | 256 ms / **237 ms** |
158
- | [medusajs/medusa](https://github.com/medusajs/medusa) | Turbo | 308 s / 315 s | 3.86 s / 7.16 s | 947 ms / 3.29 s |
159
- | [n8n-io/n8n](https://github.com/n8n-io/n8n) | Turbo | 137.2 s / **133.7 s** | 8.27 s / 12.5 s | 842 ms / 1.36 s |
160
- | [calcom/cal.com](https://github.com/calcom/cal.com) | Turbo | 245.7 s / 250.7 s | 17.4 s / 19.9 s | 14.7 s / 18.5 s |
161
- | [TanStack/query](https://github.com/TanStack/query) | Nx | 47.4 s / 55.1 s | 656 ms / 1.98 s | 174 ms / 1.88 s |
162
- | [strapi/strapi](https://github.com/strapi/strapi) | Nx | 238.2 s / 245.7 s | 1.61 s / 3.94 s | 341 ms / 4.02 s |
163
- | [novuhq/novu](https://github.com/novuhq/novu) | Nx | 291.2 s / 299.4 s | 3.10 s / 9.05 s | 655 ms / 8.55 s |
164
- | [TanStack/router](https://github.com/TanStack/router) | Nx | 199.9 s / 226.7 s | 1.00 s / 3.33 s | 505 ms / 3.21 s |
165
- | [refinedev/refine](https://github.com/refinedev/refine) | Nx | 96.2 s / 105.9 s | 1.06 s / 1.19 s | 303 ms / 1.16 s |
166
- | [kindspells/astro-shield](https://github.com/kindspells/astro-shield) | moon | 2.64 s / 3.78 s | 125 ms / 2.46 s | 111 ms / 2.37 s |
153
+ | Repo | Against | Cold build | Restore outputs | Nothing to do |
154
+ | ----------------------------------------------------------------------- | ------- | --------------------- | --------------- | ------------------- |
155
+ | [solidjs/solid](https://github.com/solidjs/solid) | Turbo | 40.6 s / 45.5 s | 66 ms / 127 ms | 51 ms / 95 ms |
156
+ | [withastro/astro](https://github.com/withastro/astro) | Turbo | 48.0 s / 57.9 s | 478 ms / 1.43 s | 249 ms / 1.38 s |
157
+ | [payloadcms/payload](https://github.com/payloadcms/payload) | Turbo | 126.9 s / 127.5 s | 3.44 s / 3.46 s | 256 ms / **237 ms** |
158
+ | [medusajs/medusa](https://github.com/medusajs/medusa) | Turbo | 308 s / 315 s | 3.86 s / 7.16 s | 947 ms / 3.29 s |
159
+ | [n8n-io/n8n](https://github.com/n8n-io/n8n) | Turbo | 137.2 s / **133.7 s** | 8.27 s / 12.5 s | 842 ms / 1.36 s |
160
+ | [calcom/cal.com](https://github.com/calcom/cal.com) | Turbo | 245.7 s / 250.7 s | 17.4 s / 19.9 s | 14.7 s / 18.5 s |
161
+ | [TanStack/query](https://github.com/TanStack/query) | Nx | 47.4 s / 55.1 s | 656 ms / 1.98 s | 174 ms / 1.88 s |
162
+ | [strapi/strapi](https://github.com/strapi/strapi) | Nx | 238.2 s / 245.7 s | 1.61 s / 3.94 s | 341 ms / 4.02 s |
163
+ | [novuhq/novu](https://github.com/novuhq/novu) | Nx | 291.2 s / 299.4 s | 3.10 s / 9.05 s | 655 ms / 8.55 s |
164
+ | [TanStack/router](https://github.com/TanStack/router) | Nx | 199.9 s / 226.7 s | 1.00 s / 3.33 s | 505 ms / 3.21 s |
165
+ | [refinedev/refine](https://github.com/refinedev/refine) | Nx | 96.2 s / 105.9 s | 1.06 s / 1.19 s | 303 ms / 1.16 s |
166
+ | [kindspells/astro-shield](https://github.com/kindspells/astro-shield) | moon | 2.64 s / 3.78 s | 125 ms / 2.46 s | 111 ms / 2.37 s |
167
+ | [FormidableLabs/spectacle](https://github.com/FormidableLabs/spectacle) | wireit | 21.5 s / 22.2 s | 394 ms / 574 ms | 387 ms / 593 ms |
168
+ | [microsoft/lage](https://github.com/microsoft/lage) | lage | 32.4 s / **29.4 s** | 730 ms / 1.70 s | 284 ms / 1.56 s |
167
169
 
168
170
  **Bold** marks the other tool winning. Versions, revisions, scopes and
169
171
  harness per repo: [benchmarks](packages/vx/docs/benchmarks.md) (astro
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.118",
3
+ "version": "0.0.120",
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.118",
21
- "@vzn/vx-linux-arm64": "0.0.118",
22
- "@vzn/vx-darwin-x64": "0.0.118",
23
- "@vzn/vx-darwin-arm64": "0.0.118"
20
+ "@vzn/vx-linux-x64": "0.0.120",
21
+ "@vzn/vx-linux-arm64": "0.0.120",
22
+ "@vzn/vx-darwin-x64": "0.0.120",
23
+ "@vzn/vx-darwin-arm64": "0.0.120"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -23,8 +23,8 @@
23
23
  // hook is usually `rimraf dist`); and a script that is nothing but
24
24
  // `<pm> run <other>` becomes a GROUP over `<other>`, so the graph sees the
25
25
  // dependency instead of a package-manager subprocess it cannot cache.
26
- // Yarn 2+ runs no such hooks (probed: `yarn run build` printed BUILD alone),
27
- // so under it `prebuild` is a script like any other (D-31).
26
+ // Where the manager runs no such hooks (Yarn 2+, D-31; npm or pnpm told
27
+ // not to, D-33) `prebuild` is a script like any other.
28
28
 
29
29
  import { existsSync, readFileSync } from 'node:fs'
30
30
  import path from 'node:path'
@@ -95,39 +95,90 @@ const OWN_COMMANDS: Readonly<Record<string, ReadonlySet<string>>> = {
95
95
  }
96
96
 
97
97
  /**
98
- * Whether the package manager that owns `dir` is Yarn 2+: the nearest
99
- * `packageManager: yarn@<2+>`, or the nearest lockfile being a Berry
100
- * `yarn.lock` (it opens with `__metadata:`). Another lockfile, or none,
101
- * is a manager that runs `pre` / `post` hooks.
98
+ * Whether the package manager that owns `dir` runs `pre` / `post` hooks
99
+ * around a script. The manager is the nearest `packageManager` field or
100
+ * lockfile, and its settings are read beside it, as each reads them for a
101
+ * member too. Probed with a `prebuild` / `build` / `postbuild` trio: Yarn
102
+ * 2+ runs none (D-31); npm runs none under `ignore-scripts=true` in
103
+ * `.npmrc`; pnpm runs none under `enable-pre-post-scripts=false` there or
104
+ * `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
105
+ * Yarn 1 run them whatever those say. No manager found is npm's default.
102
106
  */
103
- function isYarnBerry(dir: string, memo: Map<string, boolean>): boolean {
107
+ function runsScriptHooks(dir: string, memo: Map<string, boolean>): boolean {
104
108
  const known = memo.get(dir)
105
109
  if (known !== undefined) return known
106
- let answer: boolean | undefined
107
- const manifest = path.join(dir, 'package.json')
110
+ const read = (f: string): string | undefined => {
111
+ try {
112
+ return readFileSync(path.join(dir, f), 'utf8')
113
+ } catch {
114
+ return undefined
115
+ }
116
+ }
117
+ let manager: string | undefined
108
118
  try {
109
- const pm = (JSON.parse(readFileSync(manifest, 'utf8')) as { packageManager?: unknown })
119
+ const pm = (JSON.parse(read('package.json') ?? '') as { packageManager?: unknown })
110
120
  .packageManager
111
- if (typeof pm === 'string') answer = /^yarn@([2-9]|\d{2,})/.test(pm)
121
+ if (typeof pm === 'string')
122
+ manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
112
123
  } catch {}
113
- if (answer === undefined) {
114
- const lock = path.join(dir, 'yarn.lock')
115
- if (existsSync(lock)) answer = readFileSync(lock, 'utf8').includes('\n__metadata:')
116
- else if (
117
- ['package-lock.json', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb'].some((f) =>
118
- existsSync(path.join(dir, f)),
119
- )
120
- )
121
- answer = false
124
+ if (manager === undefined) {
125
+ const yarnLock = read('yarn.lock')
126
+ if (yarnLock !== undefined) manager = yarnLock.includes('\n__metadata:') ? 'berry' : 'yarn'
127
+ else if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) manager = 'pnpm'
128
+ else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
129
+ else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
122
130
  }
123
- if (answer === undefined) {
131
+ let answer: boolean
132
+ if (manager === undefined) {
124
133
  const up = path.dirname(dir)
125
- answer = up === dir ? false : isYarnBerry(up, memo)
134
+ answer = up === dir ? true : runsScriptHooks(up, memo)
135
+ } else {
136
+ const npmrc = read('.npmrc') ?? ''
137
+ const set = (key: string, value: string): boolean =>
138
+ new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
139
+ answer =
140
+ manager === 'berry'
141
+ ? false
142
+ : manager === 'npm'
143
+ ? !set('ignore-scripts', 'true')
144
+ : manager === 'pnpm'
145
+ ? !set('enable-pre-post-scripts', 'false') &&
146
+ !/^enablePrePostScripts:\s*false\s*$/m.test(read('pnpm-workspace.yaml') ?? '')
147
+ : true
126
148
  }
127
149
  memo.set(dir, answer)
128
150
  return answer
129
151
  }
130
152
 
153
+ const MANIFEST_IMPORT = "import pkg from './package.json' with { type: 'json' }"
154
+
155
+ /**
156
+ * The `$npm_*` variables a command reads. Every manager sets them for a
157
+ * script (probed: npm, pnpm, bun and yarn printed the version, the name
158
+ * and the script's name) and vx sets none, so a migrated `echo
159
+ * $npm_package_version` printed nothing (D-34). The name and version are
160
+ * read from the manifest at evaluation, so a bump reaches them; the event
161
+ * is the script's own name, which a folded hook does not share.
162
+ */
163
+ function npmEnv(
164
+ command: string,
165
+ script: string,
166
+ folded: boolean,
167
+ ): { define: Record<string, unknown>; unset: string[]; readsManifest: boolean } {
168
+ const define: Record<string, unknown> = {}
169
+ const unset: string[] = []
170
+ let readsManifest = false
171
+ for (const [, v] of command.matchAll(/\$\{?(npm_[A-Za-z0-9_]+)/g)) {
172
+ if (v! in define || unset.includes(v!)) continue
173
+ if (v === 'npm_package_name' || v === 'npm_package_version') {
174
+ define[v] = { raw: v === 'npm_package_name' ? 'pkg.name' : 'pkg.version' }
175
+ readsManifest = true
176
+ } else if (v === 'npm_lifecycle_event' && !folded) define[v] = script
177
+ else unset.push(v!)
178
+ }
179
+ return { define, unset, readsManifest }
180
+ }
181
+
131
182
  function scriptsOf(meta: ProjectMeta): Record<string, unknown> {
132
183
  // package.json is a boundary: `scripts` is whatever the file holds. A
133
184
  // string or an array would enumerate its indices as script names.
@@ -178,10 +229,10 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
178
229
 
179
230
  export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
180
231
  const projects: GeneratedProject[] = []
181
- const berry = new Map<string, boolean>()
232
+ const hookMemo = new Map<string, boolean>()
182
233
  for (const meta of metas) {
183
234
  const scripts = scriptsOf(meta)
184
- const runsHooks = !isYarnBerry(meta.dir, berry)
235
+ const runsHooks = runsScriptHooks(meta.dir, hookMemo)
185
236
  const runnable = Object.keys(scripts).filter(
186
237
  (n) => typeof scripts[n] === 'string' && scripts[n] !== '',
187
238
  )
@@ -205,6 +256,7 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
205
256
  return hookOf === null || !has(hookOf[2]!) || LIFECYCLE.test(hookOf[2]!)
206
257
  }
207
258
  const tasks: GeneratedTask[] = []
259
+ let readsManifest = false
208
260
  for (const name of names) {
209
261
  if (!isTask(name)) continue
210
262
 
@@ -239,9 +291,18 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
239
291
  `npm ran ${hooks.map((h) => `\`${h}\``).join(' and ')} around this script without being asked; folded into the command in that order`,
240
292
  )
241
293
  }
242
- const task: Record<string, unknown> = { exec: { command } }
294
+ const exec: Record<string, unknown> = { command }
295
+ const npm = npmEnv(command, name, hooks.length > 0)
296
+ if (Object.keys(npm.define).length > 0) exec['env'] = { define: npm.define }
297
+ if (npm.readsManifest) readsManifest = true
298
+ for (const v of npm.unset) {
299
+ todos.push(
300
+ `the script reads $${v}, which the package manager sets and vx does not: define it under exec.env.define or drop it`,
301
+ )
302
+ }
303
+ const task: Record<string, unknown> = { exec }
243
304
  if (PERSISTENT_TASK_NAMES.has(name)) {
244
- task['exec'] = { command, persistent: {} }
305
+ exec['persistent'] = {}
245
306
  todos.push(PERSISTENT_TODO)
246
307
  }
247
308
  if (name === 'build') {
@@ -262,7 +323,10 @@ export function migrateScripts(metas: readonly ProjectMeta[]): MigrationPlan {
262
323
  })
263
324
  }
264
325
  upstreamBuildOnWorker(tasks)
265
- if (tasks.length > 0) projects.push({ name: meta.name, dir: meta.dir, importLines: [], tasks })
326
+ if (tasks.length > 0) {
327
+ const importLines = readsManifest ? [MANIFEST_IMPORT] : []
328
+ projects.push({ name: meta.name, dir: meta.dir, importLines, tasks })
329
+ }
266
330
  }
267
331
  return {
268
332
  headerNotes: [
@@ -345,8 +345,9 @@ export async function loadProjectConfigs(
345
345
  const hit = cacheKey === null ? undefined : hits.get(cacheKey)
346
346
  // Stored AFTER validation, so a hit needs none; the key covers every
347
347
  // byte the evaluation could have read.
348
- if (hit !== undefined) {
349
- out.push(JSON.parse(hit) as ProjectConfig)
348
+ const cached = hit === undefined ? undefined : storedConfig(hit)
349
+ if (cached !== undefined) {
350
+ out.push(cached)
350
351
  continue
351
352
  }
352
353
  // A fast key that missed: the closure is stale or the file changed.
@@ -365,9 +366,10 @@ export async function loadProjectConfigs(
365
366
  })
366
367
  key = keyed?.key ?? null
367
368
  closure = keyed !== null && keyed.indexable ? keyed.closure : undefined
368
- const slowHit = key === null ? null : (store!.getConfigEval(key) ?? null)
369
- if (slowHit !== null) {
370
- out.push(JSON.parse(slowHit) as ProjectConfig)
369
+ const slowRow = key === null ? null : (store!.getConfigEval(key) ?? null)
370
+ const slowHit = slowRow === null ? undefined : storedConfig(slowRow)
371
+ if (slowHit !== undefined) {
372
+ out.push(slowHit)
371
373
  if (closure !== undefined) learnedClosures.push([configPath, closure])
372
374
  continue
373
375
  }
@@ -488,3 +490,20 @@ export async function loadWorkspaceConfig(root: string): Promise<WorkspaceConfig
488
490
  }
489
491
  return null
490
492
  }
493
+
494
+ /**
495
+ * A stored evaluation, or undefined when the row is not one: a row cut
496
+ * short (a crash mid-write, a bad disk) failed every later run with a
497
+ * `SyntaxError` stack from this loader until the cache was wiped
498
+ * (fuzzed, L-17). As a miss it is evaluated again and the row replaced.
499
+ */
500
+ function storedConfig(json: string): ProjectConfig | undefined {
501
+ try {
502
+ const v: unknown = JSON.parse(json)
503
+ return typeof v === 'object' && v !== null && !Array.isArray(v)
504
+ ? (v as ProjectConfig)
505
+ : undefined
506
+ } catch {
507
+ return undefined
508
+ }
509
+ }