@vzn/vx 0.0.118 → 0.0.121

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.121",
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.121",
21
+ "@vzn/vx-linux-arm64": "0.0.121",
22
+ "@vzn/vx-darwin-x64": "0.0.121",
23
+ "@vzn/vx-darwin-arm64": "0.0.121"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -32,7 +32,7 @@ export interface ArmedWatcher {
32
32
  }
33
33
 
34
34
  /** How often the fallback re-walks a watched tree. */
35
- export const POLL_INTERVAL_MS = 250
35
+ const POLL_INTERVAL_MS = 250
36
36
 
37
37
  /**
38
38
  * Directory names the fallback never descends into WHEN no better filter is
@@ -246,3 +246,94 @@ export function fsClockNow(dir: string): number {
246
246
  return Date.now()
247
247
  }
248
248
  }
249
+
250
+ /** A handle that watches nothing: a slot a drop emptied, an arm not made. */
251
+ export const CLOSED: WatchHandle = { close() {} }
252
+
253
+ /**
254
+ * Every watcher `vx watch` arms. Each OS watcher proves delivery or is
255
+ * swapped for the poller in its slot; an OS watch limit (ENOSPC, EMFILE)
256
+ * goes straight to the poller; `VX_WATCH_POLL=1` polls from the start.
257
+ * `skip` is what the poller leaves unsampled, asked on each walk.
258
+ */
259
+ export class WatcherPool {
260
+ private readonly watchers: WatchHandle[] = []
261
+ private readonly proofs: Promise<void>[] = []
262
+ // `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
263
+ // to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
264
+ // network mount, a container bind — the attempt costs a denied syscall
265
+ // and a two-second wait before the fallback takes over anyway.
266
+ private readonly forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
267
+
268
+ constructor(private readonly skip: (dir: string, rel: string) => boolean) {
269
+ if (this.forcePoll)
270
+ process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
271
+ }
272
+
273
+ private poll(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
274
+ return pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, (rel) => this.skip(dir, rel))
275
+ }
276
+
277
+ // By slot, not by handle: an OS watcher that never proves delivery is
278
+ // swapped for a poller in place, and a drop must close what is there.
279
+ arm(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
280
+ const watchers = this.watchers
281
+ const at = watchers.length
282
+ const handle: WatchHandle = {
283
+ close: () => {
284
+ watchers[at]?.close()
285
+ watchers[at] = CLOSED
286
+ },
287
+ }
288
+ if (this.forcePoll) {
289
+ watchers.push(this.poll(dir, recursive, onEvent))
290
+ return handle
291
+ }
292
+ let armed: ArmedWatcher
293
+ try {
294
+ armed = armWatcher(dir, recursive, onEvent)
295
+ } catch (err) {
296
+ // The OS's watch limit, not the directory: the loop said "watching"
297
+ // and never fired. The poller needs no watch slot.
298
+ const code = (err as NodeJS.ErrnoException).code
299
+ if (code !== 'ENOSPC' && code !== 'EMFILE') throw err
300
+ watchers.push(this.poll(dir, recursive, onEvent))
301
+ process.stderr.write(
302
+ `vx watch: ${dir}: the OS watch limit is reached (${code}); polling every ${POLL_INTERVAL_MS} ms instead — raise it (Linux: sysctl fs.inotify.max_user_watches) to watch natively\n`,
303
+ )
304
+ return handle
305
+ }
306
+ watchers.push(armed.watcher)
307
+ this.proofs.push(
308
+ armed.ready.then((ok) => {
309
+ if (ok) return
310
+ armed.watcher.close()
311
+ // Dropped by a rearm before its proof settled: nothing to swap in.
312
+ if (watchers[at] !== armed.watcher) return
313
+ // The watcher never proved delivery, so it is not one: an FSEvents
314
+ // stream the OS refused, a filesystem that reports nothing. Swap in
315
+ // the poller rather than run a loop that silently never fires.
316
+ watchers[at] = this.poll(dir, recursive, onEvent)
317
+ process.stderr.write(
318
+ `vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
319
+ )
320
+ }),
321
+ )
322
+ return handle
323
+ }
324
+
325
+ /** Settles once every arm so far has proved delivery or fallen back. */
326
+ async proved(): Promise<void> {
327
+ await Promise.all(this.proofs)
328
+ }
329
+
330
+ closeAll(): void {
331
+ for (const w of this.watchers) {
332
+ try {
333
+ w.close()
334
+ } catch {
335
+ // ignore
336
+ }
337
+ }
338
+ }
339
+ }
package/src/cli/watch.ts CHANGED
@@ -41,14 +41,7 @@ import {
41
41
  makeWatchIgnore,
42
42
  shapesWatchedSet,
43
43
  } from './watch-filter.js'
44
- import {
45
- armWatcher,
46
- fsClockNow,
47
- pollWatcher,
48
- POLL_INTERVAL_MS,
49
- WATCH_PROBE_TIMEOUT_MS,
50
- type WatchHandle,
51
- } from './watch-fs.js'
44
+ import { CLOSED, fsClockNow, type WatchHandle, WatcherPool } from './watch-fs.js'
52
45
  import { ChangeJudge } from './watch-judge.js'
53
46
  import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
54
47
 
@@ -313,8 +306,6 @@ interface Rediscovered {
313
306
  packageDirs: ReadonlySet<string>
314
307
  }
315
308
 
316
- const CLOSED: WatchHandle = { close() {} }
317
-
318
309
  async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
319
310
  const { opts, stop, workspaceRoot, projects, cacheDir } = args
320
311
  // The watched set as of the last cycle: `rearm` replaces these after an
@@ -414,73 +405,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
414
405
  /** Since the last cycle, a member came or went, or a file that shapes the watched set changed (`shapesWatchedSet`). */
415
406
  let reread = false
416
407
 
417
- const watchers: WatchHandle[] = []
418
- const proofs: Promise<void>[] = []
419
- // `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
420
- // to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
421
- // network mount, a container bind — the attempt costs a denied syscall
422
- // and a two-second wait before the fallback takes over anyway.
423
- const forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
424
- if (forcePoll)
425
- process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
426
408
  // The poller skips exactly what the event filter would drop: the
427
409
  // unconditional segments AND this run's declared output containers. Read
428
410
  // through `isIgnoredPath` rather than captured, because `rearm` replaces
429
411
  // the filter when the selection changes.
430
- const skipUnder =
431
- (dir: string) =>
432
- (rel: string): boolean =>
433
- isIgnoredPath(dir, rel)
434
- // By slot, not by handle: an OS watcher that never proves delivery is
435
- // swapped for a poller in place, and a drop must close what is there.
436
- const arm = (
437
- dir: string,
438
- recursive: boolean,
439
- onEvent: (filename: string) => void,
440
- ): WatchHandle => {
441
- const at = watchers.length
442
- const handle: WatchHandle = {
443
- close: () => {
444
- watchers[at]?.close()
445
- watchers[at] = CLOSED
446
- },
447
- }
448
- if (forcePoll) {
449
- watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
450
- return handle
451
- }
452
- let armed: ReturnType<typeof armWatcher>
453
- try {
454
- armed = armWatcher(dir, recursive, onEvent)
455
- } catch (err) {
456
- // The OS's watch limit, not the directory: the loop said "watching"
457
- // and never fired. The poller needs no watch slot.
458
- const code = (err as NodeJS.ErrnoException).code
459
- if (code !== 'ENOSPC' && code !== 'EMFILE') throw err
460
- watchers.push(pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir)))
461
- process.stderr.write(
462
- `vx watch: ${dir}: the OS watch limit is reached (${code}); polling every ${POLL_INTERVAL_MS} ms instead — raise it (Linux: sysctl fs.inotify.max_user_watches) to watch natively\n`,
463
- )
464
- return handle
465
- }
466
- watchers.push(armed.watcher)
467
- proofs.push(
468
- armed.ready.then((ok) => {
469
- if (ok) return
470
- armed.watcher.close()
471
- // Dropped by a rearm before its proof settled: nothing to swap in.
472
- if (watchers[at] !== armed.watcher) return
473
- // The watcher never proved delivery, so it is not one: an FSEvents
474
- // stream the OS refused, a filesystem that reports nothing. Swap in
475
- // the poller rather than run a loop that silently never fires.
476
- watchers[at] = pollWatcher(dir, recursive, onEvent, POLL_INTERVAL_MS, skipUnder(dir))
477
- process.stderr.write(
478
- `vx watch: ${dir}: no OS watch events within ${WATCH_PROBE_TIMEOUT_MS} ms; polling every ${POLL_INTERVAL_MS} ms instead\n`,
479
- )
480
- }),
481
- )
482
- return handle
483
- }
412
+ const pool = new WatcherPool((dir, rel) => isIgnoredPath(dir, rel))
413
+ const arm = (dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle =>
414
+ pool.arm(dir, recursive, onEvent)
484
415
 
485
416
  /** The instant the watchers go live, on the mtime clock (see `fsClockNow`): a path last modified before it is the initial run's, not an edit. */
486
417
  const armedAt = fsClockNow(cacheDir)
@@ -704,7 +635,7 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
704
635
  armImports()
705
636
  // A new arm proves delivery like the first ones: an edit in the new
706
637
  // package right after this cycle is seen, not lost in the gap.
707
- await Promise.all(proofs)
638
+ await pool.proved()
708
639
  process.stdout.write(`${watchingLine(next.projects.length)}\n`)
709
640
  }
710
641
 
@@ -763,19 +694,13 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
763
694
 
764
695
  // "watching" is a promise that an edit from now on is seen; every
765
696
  // watcher has proved (or been given 2 s to prove) delivery first.
766
- await Promise.all(proofs)
697
+ await pool.proved()
767
698
 
768
699
  process.stdout.write(`\n${watchingLine(projects.length)}; press Ctrl+C to stop\n`)
769
700
 
770
701
  return await new Promise<number>((resolve) => {
771
702
  const cleanup = async (): Promise<void> => {
772
- for (const w of watchers) {
773
- try {
774
- w.close()
775
- } catch {
776
- // ignore
777
- }
778
- }
703
+ pool.closeAll()
779
704
  if (debounceTimer) clearTimeout(debounceTimer)
780
705
  // The aborted cycle is tearing its children down; resolve only once
781
706
  // it has returned, so the process never exits over a live child.
@@ -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
+ }