@vzn/vx 0.0.91 → 0.0.92

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
@@ -88,6 +88,8 @@ rm -rf packages/*/dist
88
88
  npx vx run build --all # restored from the cache, not rebuilt
89
89
  ```
90
90
 
91
+ <img src="https://vznjs.github.io/vx/demo.svg" width="760" alt="vx run ci --all on examples/basic: cold, three tasks run; again, three up-to-date.">
92
+
91
93
  Or copy [`examples/basic`](examples/basic), a two-package starter CI
92
94
  runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/) ·
93
95
  [Configure](https://vznjs.github.io/vx/guides/configure/) ·
@@ -112,6 +114,10 @@ When you want configs of your own, one command writes them:
112
114
  bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json or an Nx graph
113
115
  ```
114
116
 
117
+ The written configs derive the same cache keys, so what `turbo()` cached
118
+ still hits: [`examples/turbo`](examples/turbo) runs both steps on every
119
+ commit.
120
+
115
121
  `turboCache()` and `nxCache()` keep the remote cache you have (Turbo's
116
122
  `/v8/artifacts` API, Vercel's included, or Nx's `/v1/cache`). What each
117
123
  Turbo and Nx feature is in vx, pinned by a test:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.91",
3
+ "version": "0.0.92",
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.91",
21
- "@vzn/vx-linux-arm64": "0.0.91",
22
- "@vzn/vx-darwin-x64": "0.0.91",
23
- "@vzn/vx-darwin-arm64": "0.0.91"
20
+ "@vzn/vx-linux-x64": "0.0.92",
21
+ "@vzn/vx-linux-arm64": "0.0.92",
22
+ "@vzn/vx-darwin-x64": "0.0.92",
23
+ "@vzn/vx-darwin-arm64": "0.0.92"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/init.ts CHANGED
@@ -5,6 +5,7 @@
5
5
 
6
6
  import path from 'node:path'
7
7
  import { flagHint, seeHelp } from './help.js'
8
+ import { isUserError, UserError } from '../util/index.js'
8
9
  import {
9
10
  applyMigration,
10
11
  findWorkspaceRoot,
@@ -42,7 +43,16 @@ export async function initCmd(args: readonly string[]): Promise<number> {
42
43
  return 1
43
44
  }
44
45
  const reads: LoadReads = new Map()
45
- const root = await findWorkspaceRoot(process.cwd(), reads)
46
+ // "a workspace from nowhere" still needs a package.json to start from,
47
+ // and the lookup's own refusal named no next step.
48
+ const root = await findWorkspaceRoot(process.cwd(), reads).catch((err: unknown) => {
49
+ if (isUserError(err) && err.message.startsWith('Could not find a workspace root')) {
50
+ throw new UserError(
51
+ 'vx init: no package.json here or in any parent directory; create one (`bun init` or `npm init -y`) and run vx init again',
52
+ )
53
+ }
54
+ throw err
55
+ })
46
56
  const metas = await listProjects(await loadWorkspace(root, reads))
47
57
  // `init` reads scripts only; a runner's own config beside them is the
48
58
  // richer source (dependsOn, inputs, outputs) and was ignored without a
package/src/cli/select.ts CHANGED
@@ -413,9 +413,20 @@ function didYouMeanProject(
413
413
  projects: Iterable<{ name: string }>,
414
414
  ): string {
415
415
  const names = [...projects].map((p) => p.name)
416
+ // A scoped name typed without its scope (`vx-mcp` for `@vzn/vx-mcp`) is
417
+ // many edits from the whole name and none from the part after the `/`.
418
+ const byBare = new Map<string, string[]>()
419
+ for (const n of names) {
420
+ const bare = n.slice(n.indexOf('/') + 1)
421
+ if (bare !== n) byBare.set(bare, [...(byBare.get(bare) ?? []), n])
422
+ }
416
423
  for (const pattern of unmatched) {
417
- const best = nearest(pattern.replace(/^!|\.\.\.$|^\.\.\.|\^/g, ''), names)
424
+ const typed = pattern.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
425
+ const best = nearest(typed, names)
418
426
  if (best !== undefined) return `. Did you mean ${best}?`
427
+ const bare = byBare.has(typed) ? typed : nearest(typed, byBare.keys())
428
+ const scoped = bare === undefined ? undefined : byBare.get(bare)
429
+ if (scoped?.length === 1) return `. Did you mean ${scoped[0]}?`
419
430
  }
420
431
  return ''
421
432
  }
package/src/cli/show.ts CHANGED
@@ -118,22 +118,25 @@ export async function showCmd(args: readonly string[]): Promise<number> {
118
118
  const task = declaredTask(config, taskName)
119
119
  if (task === undefined) {
120
120
  throw new UserError(
121
- `unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}))}`,
121
+ `unknown task: "${meta.name}#${taskName}"${suggest(taskName, Object.keys(config?.tasks ?? {}), `${meta.name}#`)}`,
122
122
  )
123
123
  }
124
124
  process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
125
125
  return 0
126
126
  }
127
127
 
128
- /** Near misses by edit distance, plus partial names in either direction. */
129
- function suggest(query: string, candidates: readonly string[]): string {
128
+ /**
129
+ * Near misses by edit distance, plus partial names in either direction.
130
+ * `prefix` makes each a spec the user can paste (`app#build`, not `build`).
131
+ */
132
+ function suggest(query: string, candidates: readonly string[], prefix = ''): string {
130
133
  const q = query.toLowerCase()
131
134
  const near = new Set(nearMatches(query, candidates))
132
135
  for (const c of candidates) {
133
136
  const n = c.toLowerCase()
134
137
  if (n.includes(q) || q.includes(n)) near.add(c)
135
138
  }
136
- return near.size > 0 ? ` — did you mean ${[...near].join(', ')}?` : ''
139
+ return near.size > 0 ? ` — did you mean ${[...near].map((n) => prefix + n).join(', ')}?` : ''
137
140
  }
138
141
 
139
142
  function projectDir(root: string, meta: ProjectMeta): string {
@@ -0,0 +1,248 @@
1
+ // `vx watch`'s file-system side: the OS watcher and the proof it delivers,
2
+ // the stat poller that stands in where it cannot, and the clock both are
3
+ // judged against. Nothing here knows about tasks, cycles or the cache.
4
+
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+
8
+ /** Paths whose changes never trigger a re-run. */
9
+ export const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
10
+
11
+ /**
12
+ * The file `armWatcher` writes under a watched directory to prove the
13
+ * watcher delivers. Intercepted by name before any other handling, so it
14
+ * can never trigger a cycle, and removed before "watching" is printed.
15
+ */
16
+ export const WATCH_PROBE = '.vx-watch-probe'
17
+
18
+ /** How long a watcher gets to report its own probe before the loop goes on without proof. */
19
+ export const WATCH_PROBE_TIMEOUT_MS = 2_000
20
+ /** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
21
+ const WATCH_CLOCK_STAMP = '.vx-watch-clock'
22
+
23
+ /** Anything the loop needs to shut down at exit. */
24
+ export interface WatchHandle {
25
+ close(): void
26
+ }
27
+
28
+ export interface ArmedWatcher {
29
+ watcher: fs.FSWatcher
30
+ /** Resolves `true` once the watcher reported the probe, `false` on timeout. */
31
+ ready: Promise<boolean>
32
+ }
33
+
34
+ /** How often the fallback re-walks a watched tree. */
35
+ export const POLL_INTERVAL_MS = 250
36
+
37
+ /**
38
+ * Directory names the fallback never descends into WHEN no better filter is
39
+ * supplied. The justification is that `makeWatchIgnore` already drops their
40
+ * EVENTS, so the walk buys nothing — a poller pays for it either way, and
41
+ * `node_modules` is the difference between a cheap fallback and one that
42
+ * re-stats 40 000 files four times a second.
43
+ *
44
+ * That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
45
+ * It used to carry a fourth name, `dist`, which the justification does NOT
46
+ * cover: `dist` is dropped only when a project DECLARES it as an output, and
47
+ * a project that does not declare it had its `dist/` sources silently
48
+ * invisible to `vx watch` on every host the poller exists for — a macOS
49
+ * sandbox, a network mount, a container bind — while the native watcher
50
+ * delivered them. Two watchers disagreeing about what an edit is (item 482).
51
+ * The real per-project answer is the caller's `skipDir`, below.
52
+ */
53
+ const POLL_SKIP = new Set(IGNORED_SEGMENTS)
54
+
55
+ /**
56
+ * A watcher built from `stat`, for when the OS one cannot deliver.
57
+ *
58
+ * `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
59
+ * `com.apple.FSEvents`; inside a sandbox that does not grant it the call
60
+ * SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
61
+ * 0 non-recursive, against 3 and 2 for the same writes outside — while
62
+ * `fs.watchFile` polling delivered in both). A network filesystem or a
63
+ * container bind mount fails the same way. Polling is slower and coarser,
64
+ * and it is the difference between `vx watch` working there and silently
65
+ * doing nothing.
66
+ */
67
+ export function pollWatcher(
68
+ dir: string,
69
+ recursive: boolean,
70
+ onEvent: (filename: string) => void,
71
+ intervalMs = POLL_INTERVAL_MS,
72
+ /**
73
+ * Directories not worth descending into, by their path relative to `dir`.
74
+ * The watch loop passes its own event filter, so the poller skips exactly
75
+ * what the filter would drop anyway — every declared output container, not
76
+ * a hard-coded name. The default covers the unconditional segments alone.
77
+ */
78
+ skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
79
+ ): WatchHandle {
80
+ let previous = new Map<string, number>()
81
+ let first = true
82
+ const scan = (): void => {
83
+ const current = new Map<string, number>()
84
+ const walk = (abs: string, rel: string): void => {
85
+ let entries: fs.Dirent[]
86
+ try {
87
+ entries = fs.readdirSync(abs, { withFileTypes: true })
88
+ } catch {
89
+ return // vanished or unreadable: its files simply stop appearing
90
+ }
91
+ for (const e of entries) {
92
+ if (e.name === WATCH_PROBE) continue
93
+ const childRel = rel === '' ? e.name : `${rel}/${e.name}`
94
+ if (e.isDirectory()) {
95
+ if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
96
+ continue
97
+ }
98
+ if (!e.isFile()) continue
99
+ try {
100
+ // The later of the two clocks, as `modifiedBefore` reads them: a
101
+ // replacement that carries the old file's mtime (`cp -p`, `rsync
102
+ // -a`, `mv` of a file stamped the same) moved nothing under mtime
103
+ // alone, and the poller never ran it where the native watcher did.
104
+ // A rename or a write moves ctime, and no process can set it.
105
+ const st = fs.statSync(path.join(abs, e.name))
106
+ current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
107
+ } catch {
108
+ // raced with a delete; the next scan settles it
109
+ }
110
+ }
111
+ }
112
+ walk(dir, '')
113
+ if (!first) {
114
+ for (const [rel, mtime] of current) {
115
+ if (previous.get(rel) !== mtime) onEvent(rel)
116
+ }
117
+ for (const rel of previous.keys()) {
118
+ if (!current.has(rel)) onEvent(rel)
119
+ }
120
+ }
121
+ previous = current
122
+ first = false
123
+ }
124
+ scan()
125
+ // Deliberately NOT unref'd: once the native watcher is closed this timer
126
+ // is the only thing keeping `vx watch` alive.
127
+ const timer = setInterval(scan, intervalMs)
128
+ return {
129
+ close(): void {
130
+ clearInterval(timer)
131
+ },
132
+ }
133
+ }
134
+
135
+ /**
136
+ * `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
137
+ * FSEvents stream that another thread schedules AFTER the call returns, and
138
+ * a change landing in that gap is never delivered — MEASURED 2026-09-03: a
139
+ * write made immediately after `fs.watch` was lost 5 times in 30 under CPU
140
+ * load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
141
+ * width, so no pause is the answer and no timeout on the waiting side ever
142
+ * was (the e2e flake this closes had one of 45 s). A probe file written
143
+ * under the watcher and waited for is: once ITS event arrives, the stream
144
+ * is live for everything after it.
145
+ *
146
+ * `onEvent` never sees the probe (create or unlink), and the probe is
147
+ * removed before `ready` resolves.
148
+ */
149
+ export function armWatcher(
150
+ dir: string,
151
+ recursive: boolean,
152
+ onEvent: (filename: string) => void,
153
+ timeoutMs = WATCH_PROBE_TIMEOUT_MS,
154
+ ): ArmedWatcher {
155
+ let markReady: (ok: boolean) => void = () => {}
156
+ const seen = new Promise<boolean>((resolve) => {
157
+ markReady = resolve
158
+ })
159
+ const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
160
+ if (filename == null || typeof filename !== 'string') return
161
+ // An event naming the watched directory itself (macOS reports the
162
+ // directory a write landed in as its own item) carries nothing a key
163
+ // can see; the write's own event names the file.
164
+ if (filename === '' || filename === '.') return
165
+ if (filename === WATCH_PROBE) {
166
+ markReady(true)
167
+ return
168
+ }
169
+ // Another arm's probe, seen by this recursive watcher under a nested
170
+ // directory (a nested project's, a member base inside a project): no
171
+ // key sees it, and passing it on ran a cycle, and restarted a dev
172
+ // server, with no edit made (item 1016). Only the watcher's own probe
173
+ // proves this stream live.
174
+ if (path.basename(filename) === WATCH_PROBE) return
175
+ onEvent(filename)
176
+ })
177
+ const probe = path.join(dir, WATCH_PROBE)
178
+ const ready = (async (): Promise<boolean> => {
179
+ // The probe is subject to the very race it detects: a write that lands
180
+ // in the gap is lost like any other (1 in 20 under a full gate's load,
181
+ // measured 2026-09-03, with every delivered event under 60 ms). So it is
182
+ // re-written on a short backoff until its event arrives — the first write
183
+ // after the stream goes live is the one that proves it.
184
+ const deadline = Date.now() + timeoutMs
185
+ let ok = false
186
+ let step = 50
187
+ while (!ok) {
188
+ try {
189
+ fs.writeFileSync(probe, String(Date.now()))
190
+ } catch {
191
+ break // an unwritable dir gets no proof; the watcher is kept
192
+ }
193
+ const remaining = deadline - Date.now()
194
+ if (remaining <= 0) break
195
+ const pause = new Promise<boolean>((resolve) => {
196
+ setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
197
+ })
198
+ ok = await Promise.race([seen, pause])
199
+ step = Math.min(step * 2, 400)
200
+ }
201
+ try {
202
+ fs.unlinkSync(probe)
203
+ } catch {
204
+ // already gone
205
+ }
206
+ return ok
207
+ })()
208
+ return { watcher, ready }
209
+ }
210
+
211
+ /** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
212
+ export function modifiedBefore(abs: string, t: number): boolean {
213
+ try {
214
+ // The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
215
+ // carry a file's OLD mtime onto the new one, and judged by mtime alone
216
+ // such an edit was "before the arm" and never ran (item 945). No
217
+ // process can set a ctime, and a rename or a write moves it.
218
+ const st = fs.statSync(abs)
219
+ return Math.max(st.mtimeMs, st.ctimeMs) < t
220
+ } catch {
221
+ return false
222
+ }
223
+ }
224
+
225
+ /**
226
+ * "Now" as the filesystem will stamp the next write, not as `Date.now()`
227
+ * reads it. A file's mtime comes from the kernel's coarse clock, which
228
+ * runs up to a tick behind the fine clock `Date.now()` reads — measured
229
+ * on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
230
+ * mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
231
+ * more under CPU load, where the tick is skipped. An `armedAt` from the
232
+ * fine clock then judged an edit made right after the ready line as
233
+ * "modified before the arm" and dropped it (the watch e2e flake, three
234
+ * shard runs that day; traced: raw event, trigger, `same=true`). A stamp
235
+ * read off a file written here is on the mtime clock itself, and every
236
+ * later write's mtime is at or after it.
237
+ */
238
+ export function fsClockNow(dir: string): number {
239
+ const stamp = path.join(dir, WATCH_CLOCK_STAMP)
240
+ try {
241
+ fs.writeFileSync(stamp, '')
242
+ const at = fs.statSync(stamp).mtimeMs
243
+ fs.unlinkSync(stamp)
244
+ return at
245
+ } catch {
246
+ return Date.now()
247
+ }
248
+ }
package/src/cli/watch.ts CHANGED
@@ -50,12 +50,20 @@ import {
50
50
  import type { ProjectConfig } from '../config.js'
51
51
  import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
52
52
  import { taskEdges, taskEdgesFrom } from './select.js'
53
+ import {
54
+ armWatcher,
55
+ fsClockNow,
56
+ IGNORED_SEGMENTS,
57
+ modifiedBefore,
58
+ pollWatcher,
59
+ POLL_INTERVAL_MS,
60
+ WATCH_PROBE_TIMEOUT_MS,
61
+ type WatchHandle,
62
+ } from './watch-fs.js'
53
63
 
54
64
  /** Wait this long after the last filesystem event before re-running. */
55
65
  const DEBOUNCE_MS = 150
56
66
 
57
- /** Paths whose changes never trigger a re-run. */
58
- const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
59
67
  const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
60
68
 
61
69
  /**
@@ -167,13 +175,6 @@ function outputContainer(raw: string): string {
167
175
  return prefix === '.' || prefix === '/' ? '' : prefix
168
176
  }
169
177
 
170
- /**
171
- * The file `armWatcher` writes under a watched directory to prove the
172
- * watcher delivers. Intercepted by name before any other handling, so it
173
- * can never trigger a cycle, and removed before "watching" is printed.
174
- */
175
- export const WATCH_PROBE = '.vx-watch-probe'
176
-
177
178
  /**
178
179
  * The subset of `paths` git ignores, asked once per judgement (one
179
180
  * `git check-ignore` per debounce window that has candidates, never per
@@ -210,199 +211,6 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
210
211
  return ignored
211
212
  }
212
213
 
213
- /** How long a watcher gets to report its own probe before the loop goes on without proof. */
214
- const WATCH_PROBE_TIMEOUT_MS = 2_000
215
- /** The file `fsClockNow` writes and removes, under the cache dir the watchers ignore. */
216
- const WATCH_CLOCK_STAMP = '.vx-watch-clock'
217
-
218
- /** Anything the loop needs to shut down at exit. */
219
- export interface WatchHandle {
220
- close(): void
221
- }
222
-
223
- export interface ArmedWatcher {
224
- watcher: fs.FSWatcher
225
- /** Resolves `true` once the watcher reported the probe, `false` on timeout. */
226
- ready: Promise<boolean>
227
- }
228
-
229
- /** How often the fallback re-walks a watched tree. */
230
- const POLL_INTERVAL_MS = 250
231
-
232
- /**
233
- * Directory names the fallback never descends into WHEN no better filter is
234
- * supplied. The justification is that `makeWatchIgnore` already drops their
235
- * EVENTS, so the walk buys nothing — a poller pays for it either way, and
236
- * `node_modules` is the difference between a cheap fallback and one that
237
- * re-stats 40 000 files four times a second.
238
- *
239
- * That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
240
- * It used to carry a fourth name, `dist`, which the justification does NOT
241
- * cover: `dist` is dropped only when a project DECLARES it as an output, and
242
- * a project that does not declare it had its `dist/` sources silently
243
- * invisible to `vx watch` on every host the poller exists for — a macOS
244
- * sandbox, a network mount, a container bind — while the native watcher
245
- * delivered them. Two watchers disagreeing about what an edit is (item 482).
246
- * The real per-project answer is the caller's `skipDir`, below.
247
- */
248
- const POLL_SKIP = new Set(IGNORED_SEGMENTS)
249
-
250
- /**
251
- * A watcher built from `stat`, for when the OS one cannot deliver.
252
- *
253
- * `fs.watch` on macOS is FSEvents, which needs `mach-lookup` on
254
- * `com.apple.FSEvents`; inside a sandbox that does not grant it the call
255
- * SUCCEEDS and then never fires (measured 2026-09-05: 0 events recursive,
256
- * 0 non-recursive, against 3 and 2 for the same writes outside — while
257
- * `fs.watchFile` polling delivered in both). A network filesystem or a
258
- * container bind mount fails the same way. Polling is slower and coarser,
259
- * and it is the difference between `vx watch` working there and silently
260
- * doing nothing.
261
- */
262
- export function pollWatcher(
263
- dir: string,
264
- recursive: boolean,
265
- onEvent: (filename: string) => void,
266
- intervalMs = POLL_INTERVAL_MS,
267
- /**
268
- * Directories not worth descending into, by their path relative to `dir`.
269
- * The watch loop passes its own event filter, so the poller skips exactly
270
- * what the filter would drop anyway — every declared output container, not
271
- * a hard-coded name. The default covers the unconditional segments alone.
272
- */
273
- skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
274
- ): WatchHandle {
275
- let previous = new Map<string, number>()
276
- let first = true
277
- const scan = (): void => {
278
- const current = new Map<string, number>()
279
- const walk = (abs: string, rel: string): void => {
280
- let entries: fs.Dirent[]
281
- try {
282
- entries = fs.readdirSync(abs, { withFileTypes: true })
283
- } catch {
284
- return // vanished or unreadable: its files simply stop appearing
285
- }
286
- for (const e of entries) {
287
- if (e.name === WATCH_PROBE) continue
288
- const childRel = rel === '' ? e.name : `${rel}/${e.name}`
289
- if (e.isDirectory()) {
290
- if (recursive && !skipDir(childRel)) walk(path.join(abs, e.name), childRel)
291
- continue
292
- }
293
- if (!e.isFile()) continue
294
- try {
295
- // The later of the two clocks, as `modifiedBefore` reads them: a
296
- // replacement that carries the old file's mtime (`cp -p`, `rsync
297
- // -a`, `mv` of a file stamped the same) moved nothing under mtime
298
- // alone, and the poller never ran it where the native watcher did.
299
- // A rename or a write moves ctime, and no process can set it.
300
- const st = fs.statSync(path.join(abs, e.name))
301
- current.set(childRel, Math.max(st.mtimeMs, st.ctimeMs))
302
- } catch {
303
- // raced with a delete; the next scan settles it
304
- }
305
- }
306
- }
307
- walk(dir, '')
308
- if (!first) {
309
- for (const [rel, mtime] of current) {
310
- if (previous.get(rel) !== mtime) onEvent(rel)
311
- }
312
- for (const rel of previous.keys()) {
313
- if (!current.has(rel)) onEvent(rel)
314
- }
315
- }
316
- previous = current
317
- first = false
318
- }
319
- scan()
320
- // Deliberately NOT unref'd: once the native watcher is closed this timer
321
- // is the only thing keeping `vx watch` alive.
322
- const timer = setInterval(scan, intervalMs)
323
- return {
324
- close(): void {
325
- clearInterval(timer)
326
- },
327
- }
328
- }
329
-
330
- /**
331
- * `fs.watch` plus proof of delivery. On macOS a recursive watcher is an
332
- * FSEvents stream that another thread schedules AFTER the call returns, and
333
- * a change landing in that gap is never delivered — MEASURED 2026-09-03: a
334
- * write made immediately after `fs.watch` was lost 5 times in 30 under CPU
335
- * load (0 in 30 idle, 0 in 30 after a 50 ms pause). The gap has no fixed
336
- * width, so no pause is the answer and no timeout on the waiting side ever
337
- * was (the e2e flake this closes had one of 45 s). A probe file written
338
- * under the watcher and waited for is: once ITS event arrives, the stream
339
- * is live for everything after it.
340
- *
341
- * `onEvent` never sees the probe (create or unlink), and the probe is
342
- * removed before `ready` resolves.
343
- */
344
- export function armWatcher(
345
- dir: string,
346
- recursive: boolean,
347
- onEvent: (filename: string) => void,
348
- timeoutMs = WATCH_PROBE_TIMEOUT_MS,
349
- ): ArmedWatcher {
350
- let markReady: (ok: boolean) => void = () => {}
351
- const seen = new Promise<boolean>((resolve) => {
352
- markReady = resolve
353
- })
354
- const watcher = fs.watch(dir, { recursive, persistent: true }, (_event, filename) => {
355
- if (filename == null || typeof filename !== 'string') return
356
- // An event naming the watched directory itself (macOS reports the
357
- // directory a write landed in as its own item) carries nothing a key
358
- // can see; the write's own event names the file.
359
- if (filename === '' || filename === '.') return
360
- if (filename === WATCH_PROBE) {
361
- markReady(true)
362
- return
363
- }
364
- // Another arm's probe, seen by this recursive watcher under a nested
365
- // directory (a nested project's, a member base inside a project): no
366
- // key sees it, and passing it on ran a cycle, and restarted a dev
367
- // server, with no edit made (item 1016). Only the watcher's own probe
368
- // proves this stream live.
369
- if (path.basename(filename) === WATCH_PROBE) return
370
- onEvent(filename)
371
- })
372
- const probe = path.join(dir, WATCH_PROBE)
373
- const ready = (async (): Promise<boolean> => {
374
- // The probe is subject to the very race it detects: a write that lands
375
- // in the gap is lost like any other (1 in 20 under a full gate's load,
376
- // measured 2026-09-03, with every delivered event under 60 ms). So it is
377
- // re-written on a short backoff until its event arrives — the first write
378
- // after the stream goes live is the one that proves it.
379
- const deadline = Date.now() + timeoutMs
380
- let ok = false
381
- let step = 50
382
- while (!ok) {
383
- try {
384
- fs.writeFileSync(probe, String(Date.now()))
385
- } catch {
386
- break // an unwritable dir gets no proof; the watcher is kept
387
- }
388
- const remaining = deadline - Date.now()
389
- if (remaining <= 0) break
390
- const pause = new Promise<boolean>((resolve) => {
391
- setTimeout(() => resolve(false), Math.min(step, remaining)).unref()
392
- })
393
- ok = await Promise.race([seen, pause])
394
- step = Math.min(step * 2, 400)
395
- }
396
- try {
397
- fs.unlinkSync(probe)
398
- } catch {
399
- // already gone
400
- }
401
- return ok
402
- })()
403
- return { watcher, ready }
404
- }
405
-
406
214
  /**
407
215
  * The run flags a watch loop cannot honour, as the refusal line it prints; null
408
216
  * when there is none. `WATCH_REFUSED_FLAGS` (help.ts) is the same list for
@@ -1516,45 +1324,6 @@ export function pendingAfterCycle(
1516
1324
  return [abs, label]
1517
1325
  }
1518
1326
 
1519
- /** True when `abs` was last modified before `t` (epoch ms); false when it cannot be read. */
1520
- export function modifiedBefore(abs: string, t: number): boolean {
1521
- try {
1522
- // The later of the two clocks: `mv`, `cp -p`, `rsync -a` and `tar x`
1523
- // carry a file's OLD mtime onto the new one, and judged by mtime alone
1524
- // such an edit was "before the arm" and never ran (item 945). No
1525
- // process can set a ctime, and a rename or a write moves it.
1526
- const st = fs.statSync(abs)
1527
- return Math.max(st.mtimeMs, st.ctimeMs) < t
1528
- } catch {
1529
- return false
1530
- }
1531
- }
1532
-
1533
- /**
1534
- * "Now" as the filesystem will stamp the next write, not as `Date.now()`
1535
- * reads it. A file's mtime comes from the kernel's coarse clock, which
1536
- * runs up to a tick behind the fine clock `Date.now()` reads — measured
1537
- * on the Linux bench box 2026-09-11: 2 of 3,000 tight writes carried an
1538
- * mtime 5.8 ms EARLIER than a `Date.now()` taken before the write, and
1539
- * more under CPU load, where the tick is skipped. An `armedAt` from the
1540
- * fine clock then judged an edit made right after the ready line as
1541
- * "modified before the arm" and dropped it (the watch e2e flake, three
1542
- * shard runs that day; traced: raw event, trigger, `same=true`). A stamp
1543
- * read off a file written here is on the mtime clock itself, and every
1544
- * later write's mtime is at or after it.
1545
- */
1546
- export function fsClockNow(dir: string): number {
1547
- const stamp = path.join(dir, WATCH_CLOCK_STAMP)
1548
- try {
1549
- fs.writeFileSync(stamp, '')
1550
- const at = fs.statSync(stamp).mtimeMs
1551
- fs.unlinkSync(stamp)
1552
- return at
1553
- } catch {
1554
- return Date.now()
1555
- }
1556
- }
1557
-
1558
1327
  /**
1559
1328
  * The directory entries under a package glob's directory that can be
1560
1329
  * members: directories (or links) whose name is not dotted and not
@@ -323,7 +323,15 @@ export function buildAdmission(
323
323
  for (const plugin of answering) {
324
324
  if (broken.has(plugin)) continue
325
325
  try {
326
- if (plugin.admit!(task, ctx) !== false) continue
326
+ const verdict: unknown = plugin.admit!(task, ctx)
327
+ // An `async admit` answers a Promise: truthy, so it admitted every
328
+ // task and its policy never ran, and its rejection was unhandled and
329
+ // ended the run with a stack (H-16). Named once, admitting after.
330
+ if (verdict instanceof Promise) {
331
+ verdict.catch(() => {})
332
+ throw new Error('returned a Promise; admit is synchronous')
333
+ }
334
+ if (verdict !== false) continue
327
335
  if (running.size > 0) return false
328
336
  if (!overridden.has(plugin)) {
329
337
  overridden.add(plugin)
@@ -130,8 +130,9 @@ export interface VxPlugin {
130
130
  * Admission over the worker count: asked for every task about to start
131
131
  * on this machine, with the tasks running here right now. Return `false`
132
132
  * to hold it until something finishes (it is asked again then). Asked
133
- * many times per run, so it must be cheap and synchronous; a throw is
134
- * reported once and the plugin admits from then on — a policy never
133
+ * many times per run, so it must be cheap and synchronous; a throw, or
134
+ * an answer that is a Promise, is reported once and the plugin admits
135
+ * from then on — a policy never
135
136
  * breaks a run. Restore-tier hits and tasks on an executor pool hold no
136
137
  * local resources and are never asked. When several plugins answer, all
137
138
  * must admit. Core keeps no notion of what a task needs: a plugin that
@@ -144,7 +145,8 @@ export interface VxPlugin {
144
145
 
145
146
  /**
146
147
  * Verbs this plugin adds to the `vx` CLI, keyed by name. Consulted only
147
- * for a verb core does not know — core's own verbs always win — and only
148
+ * for a verb core does not know — a verb named like a core verb is
149
+ * refused at load — and only
148
150
  * when the cwd is inside a workspace that declares the plugin. `vx help`
149
151
  * lists them under "Plugin commands". A verb's exit code is the process
150
152
  * exit code; a thrown `UserError` prints cleanly, like core's own.
@@ -348,6 +350,18 @@ export type PluginHookName =
348
350
  | 'onRunStatus'
349
351
  | 'onRunEnd'
350
352
 
353
+ // A Record so the compiler holds it to `PluginHookName` both ways: a name
354
+ // missing here, or one the type lacks, does not compile.
355
+ const HOOK_NAMES: Record<PluginHookName, true> = {
356
+ onRunStart: true,
357
+ onTaskStart: true,
358
+ onTaskStdout: true,
359
+ onTaskStderr: true,
360
+ onTaskComplete: true,
361
+ onRunStatus: true,
362
+ onRunEnd: true,
363
+ }
364
+
351
365
  export interface PluginHookHandlers {
352
366
  onRunStart: (info: RunStartInfo) => void | Promise<void>
353
367
  onTaskStart: (node: TaskNode) => void | Promise<void>
@@ -508,6 +522,13 @@ export async function installPlugins(args: InstallPluginsArgs): Promise<() => vo
508
522
  },
509
523
  },
510
524
  on(hook, handler) {
525
+ // A misspelt name ('taskComplete') subscribed a handler that never
526
+ // ran, and no word of it; a config file has no type-checker (H-16).
527
+ if (!Object.hasOwn(HOOK_NAMES, hook)) {
528
+ throw new Error(
529
+ `ctx.on: unknown hook '${String(hook)}' (one of ${Object.keys(HOOK_NAMES).join(', ')})`,
530
+ )
531
+ }
511
532
  const fail = (err: unknown): void => {
512
533
  if (disabled.has(plugin.name)) return
513
534
  disabled.add(plugin.name)
@@ -116,6 +116,15 @@ function upstreamBuildOnWorker(tasks: GeneratedTask[]): void {
116
116
  at = Array.isArray(deps) ? byName.get(String(deps[0])) : undefined
117
117
  }
118
118
  if (at === undefined || at.name === 'build' || at.task === null || at.task === undefined) return
119
+ // The worker IS `build`'s work, and so is every group on the way to it:
120
+ // a `typecheck` behind `build: npm run typecheck` waited for `build`,
121
+ // and the run refused the cycle (D-16).
122
+ for (const name of [...seen, at.name]) {
123
+ const t = byName.get(name)!.task!
124
+ if (name !== 'build' && Array.isArray(t['dependsOn'])) {
125
+ t['dependsOn'] = (t['dependsOn'] as string[]).filter((d) => d !== 'build')
126
+ }
127
+ }
119
128
  const deps = Array.isArray(at.task['dependsOn']) ? (at.task['dependsOn'] as string[]) : []
120
129
  if (!deps.includes('^build')) at.task['dependsOn'] = ['^build', ...deps]
121
130
  if (!at.todos.includes(CACHE_TODO)) at.todos.push(CACHE_TODO)