@vzn/vx 0.0.90 → 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,7 +1,7 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.90",
4
- "description": "An open, extensible monorepo task runner.",
3
+ "version": "0.0.92",
4
+ "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
7
7
  ".": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.90",
21
- "@vzn/vx-linux-arm64": "0.0.90",
22
- "@vzn/vx-darwin-x64": "0.0.90",
23
- "@vzn/vx-darwin-arm64": "0.0.90"
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"
@@ -36,16 +36,20 @@
36
36
  "type": "git",
37
37
  "url": "git+https://github.com/vznjs/vx.git"
38
38
  },
39
- "homepage": "https://github.com/vznjs/vx#readme",
39
+ "homepage": "https://vznjs.github.io/vx/",
40
40
  "bugs": "https://github.com/vznjs/vx/issues",
41
41
  "license": "MIT",
42
42
  "keywords": [
43
+ "vx",
43
44
  "monorepo",
44
45
  "task-runner",
45
- "build",
46
+ "build-cache",
47
+ "build-system",
46
48
  "cache",
47
- "bun",
48
49
  "turborepo",
49
- "nx"
50
+ "nx",
51
+ "bun",
52
+ "workspaces",
53
+ "ci"
50
54
  ]
51
55
  }
@@ -40,6 +40,7 @@ import {
40
40
  span,
41
41
  splitTaskId,
42
42
  taskGlob,
43
+ xxh3hex,
43
44
  } from '../util/index.js'
44
45
  import {
45
46
  ArchiveSecurityError,
@@ -655,7 +656,7 @@ export class Cache implements CacheLayer {
655
656
  this.files = new FileHashStore(this.db, cacheDir, this.write, repoDir)
656
657
  this.configEvals = new ConfigEvalTable(this.db, { read: this.read, write: this.write })
657
658
  this.outputs = new OutputIndex(this.db)
658
- this.history = new RunHistory(this.db)
659
+ this.history = new RunHistory(this.db, (v) => this.digestValue(v))
659
660
 
660
661
  // A CACHE_VERSION bump keeps the index but moves every key, so the run
661
662
  // after an upgrade misses everything. Roadmap 3.3: that is announced,
@@ -753,9 +754,47 @@ export class Cache implements CacheLayer {
753
754
  input,
754
755
  (f) => this.hashFile(f),
755
756
  (f) => this.relFor(input.workspaceRoot, f),
757
+ (v) => this.digestValue(v),
756
758
  )
757
759
  }
758
760
 
761
+ private valueSalt: string | undefined
762
+
763
+ /**
764
+ * The digest a value-bearing row holds (an env value, a runtime output,
765
+ * args after `--`): xxh3 under a random salt this store keeps. `vx why`,
766
+ * its JSON and MCP's `whyDidThisRerun` print it, and an unkeyed xxh3 in
767
+ * a public CI log let anyone confirm or brute-force a short secret
768
+ * (L-4). Salted, a digest still says whether a value changed, and says
769
+ * nothing without this cache's `schema_meta`. Asked for on a miss only.
770
+ */
771
+ private digestValue(value: string): string {
772
+ this.valueSalt ??= this.loadValueSalt()
773
+ return xxh3hex(`${this.valueSalt}\0${value}`)
774
+ }
775
+
776
+ private loadValueSalt(): string {
777
+ const read = (): string | undefined =>
778
+ (
779
+ this.db.prepare("SELECT value FROM schema_meta WHERE key = 'value_salt'").get() as {
780
+ value: string
781
+ } | null
782
+ )?.value
783
+ const found = read()
784
+ if (found !== undefined) return found
785
+ const salt = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('hex')
786
+ // A store this process may not write keeps the salt for the process:
787
+ // nothing it digests is persisted.
788
+ if (this.writeBlocked !== null || this.inspecting) return salt
789
+ // Two processes opening one new store: the first salt stands.
790
+ this.db
791
+ .prepare(
792
+ "INSERT INTO schema_meta(key, value) VALUES ('value_salt', ?) ON CONFLICT(key) DO NOTHING",
793
+ )
794
+ .run(salt)
795
+ return read() ?? salt
796
+ }
797
+
759
798
  // ctx is accepted but ignored — the local layer reads metadata from
760
799
  // the entries row. It's part of the contract so LayeredCache can
761
800
  // route metadata to `ingest()` on remote-hit without a separate API.
@@ -99,6 +99,12 @@ export async function foldKey(
99
99
  input: CacheKeyInput,
100
100
  hashFile: (file: string) => Promise<string>,
101
101
  relOf: (file: string) => string,
102
+ /**
103
+ * The digest a value-bearing capture row holds. `Cache` keys it with a
104
+ * salt of its own (L-4); the default is for a caller that persists and
105
+ * prints nothing.
106
+ */
107
+ digestValue: (value: string) => string = xxh3hex,
102
108
  ): Promise<string> {
103
109
  // Seed-chained xxHash3: each step folds one field into the
104
110
  // running digest via `xxh3(part, prevDigest)`. Equivalent to the
@@ -124,16 +130,18 @@ export async function foldKey(
124
130
  // DIGEST of each component, never the raw value. For value-bearing kinds
125
131
  // (env, runtime, ws-runtime, forward) the payload is a secret or
126
132
  // sensitive string (API keys via cache.inputs.env, runtime-command
127
- // output, args after `--`), so we push `xxh3hex(v)` — the diff consumer
128
- // only needs to know whether a component CHANGED, which a digest
129
- // preserves losslessly. cache.db must never hold plaintext secrets at
130
- // rest. The cache KEY (`h`) folds the plaintext separately below and is
133
+ // output, args after `--`), so we push `digestValue(v)` — the diff
134
+ // consumer only needs to know whether a component CHANGED, which a
135
+ // digest preserves losslessly. cache.db must never hold plaintext
136
+ // secrets at rest, and `vx why` prints the digest, so it is salted: an
137
+ // unkeyed 64-bit xxh3 in a public CI log gave a short secret away to a
138
+ // brute force (L-4). The cache KEY (`h`) folds the plaintext separately below and is
131
139
  // unaffected.
132
140
  const forwarded = input.forwardArgs ?? []
133
141
  h = xxh3(`forward-args:${forwarded.length}`, h)
134
142
  for (const a of forwarded) h = xxh3(a, h)
135
143
  if (cap && forwarded.length > 0) {
136
- cap.push({ kind: 'forward', name: 'argv', hash: xxh3hex(JSON.stringify(forwarded)) })
144
+ cap.push({ kind: 'forward', name: 'argv', hash: digestValue(JSON.stringify(forwarded)) })
137
145
  }
138
146
 
139
147
  h = xxh3(`env-values:${input.envValues.length}`, h)
@@ -145,20 +153,20 @@ export async function foldKey(
145
153
  for (const [n, v] of input.envValues) h = xxh3(v === undefined ? n : `${n}\0${v}`, h)
146
154
  if (cap)
147
155
  for (const [n, v] of input.envValues)
148
- cap.push({ kind: 'env', name: n, hash: v === undefined ? 'unset' : xxh3hex(v) })
156
+ cap.push({ kind: 'env', name: n, hash: v === undefined ? 'unset' : digestValue(v) })
149
157
 
150
158
  const runtimeValues = input.runtimeValues ?? []
151
159
  h = xxh3(`runtime-values:${runtimeValues.length}`, h)
152
160
  for (const [c, o] of runtimeValues) h = xxh3(`${c}\0${o}`, h)
153
161
  if (cap)
154
- for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash: xxh3hex(o) })
162
+ for (const [c, o] of runtimeValues) cap.push({ kind: 'runtime', name: c, hash: digestValue(o) })
155
163
 
156
164
  const wsRuntimeValues = input.workspaceRuntimeValues ?? []
157
165
  h = xxh3(`ws-runtime-values:${wsRuntimeValues.length}`, h)
158
166
  for (const [c, o] of wsRuntimeValues) h = xxh3(`${c}\0${o}`, h)
159
167
  if (cap)
160
168
  for (const [c, o] of wsRuntimeValues)
161
- cap.push({ kind: 'ws-runtime', name: c, hash: xxh3hex(o) })
169
+ cap.push({ kind: 'ws-runtime', name: c, hash: digestValue(o) })
162
170
 
163
171
  const upstream = [...input.upstreamHashes].sort()
164
172
  h = xxh3(`upstream:${upstream.length}`, h)
@@ -174,7 +182,7 @@ export async function foldKey(
174
182
  h = xxh3(`plugin:${pluginParts.length}`, h)
175
183
  for (const [n, v] of pluginParts) h = xxh3(`${n}\0${v}`, h)
176
184
  if (cap) {
177
- for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: xxh3hex(v) })
185
+ for (const [n, v] of pluginParts) cap.push({ kind: 'plugin', name: n, hash: digestValue(v) })
178
186
  }
179
187
  }
180
188
 
@@ -4,14 +4,17 @@
4
4
  // its statements over the store's handle; `Cache` delegates.
5
5
 
6
6
  import type { Database, SQLQueryBindings } from 'bun:sqlite'
7
- import { xxh3hex } from '../util/index.js'
8
7
  import type { InvocationRecord, RunRecord } from './layer.js'
9
8
 
10
9
  export class RunHistory {
11
10
  private readonly insertRun: ReturnType<Database['prepare']>
12
11
  private readonly insertInvocation: ReturnType<Database['prepare']>
13
12
 
14
- constructor(private readonly db: Database) {
13
+ constructor(
14
+ private readonly db: Database,
15
+ /** The store's salted value digest (L-4). */
16
+ private readonly digestValue: (value: string) => string,
17
+ ) {
15
18
  this.insertRun = this.db.prepare(`
16
19
  INSERT INTO runs(
17
20
  hash, project, task, status, exit_code, duration_ms, forward_args,
@@ -37,13 +40,13 @@ export class RunHistory {
37
40
  }
38
41
 
39
42
  recordRun(run: RunRecord): void {
40
- this.insertRun.run(...bindRun(run))
43
+ this.insertRun.run(...bindRun(run, this.digestValue))
41
44
  }
42
45
 
43
46
  recordRuns(runs: readonly RunRecord[]): void {
44
47
  if (runs.length === 0) return
45
48
  if (runs.length === 1) {
46
- this.insertRun.run(...bindRun(runs[0]!))
49
+ this.insertRun.run(...bindRun(runs[0]!, this.digestValue))
47
50
  return
48
51
  }
49
52
  // `bun:sqlite`'s `transaction()` returns a callable that wraps the
@@ -51,7 +54,7 @@ export class RunHistory {
51
54
  // run that's one fsync instead of 200.
52
55
  const insert = this.insertRun
53
56
  const tx = this.db.transaction((batch: readonly RunRecord[]) => {
54
- for (const r of batch) insert.run(...bindRun(r))
57
+ for (const r of batch) insert.run(...bindRun(r, this.digestValue))
55
58
  })
56
59
  tx(runs)
57
60
  }
@@ -66,7 +69,7 @@ export class RunHistory {
66
69
  const insertRun = this.insertRun
67
70
  const insertInvocation = this.insertInvocation
68
71
  this.db.transaction(() => {
69
- for (const r of bundle.runs) insertRun.run(...bindRun(r))
72
+ for (const r of bundle.runs) insertRun.run(...bindRun(r, this.digestValue))
70
73
  insertInvocation.run(...bindInvocation(bundle.invocation))
71
74
  })()
72
75
  }
@@ -89,7 +92,7 @@ export class RunHistory {
89
92
  * `insertRun` prepared statement (21 columns). Shared between the
90
93
  * single and batched record paths.
91
94
  */
92
- function bindRun(run: RunRecord): SQLQueryBindings[] {
95
+ function bindRun(run: RunRecord, digestValue: (v: string) => string): SQLQueryBindings[] {
93
96
  return [
94
97
  // The ONE place the no-key sentinel is applied, so the column's
95
98
  // NOT NULL invariant can't be violated from a call site.
@@ -102,7 +105,7 @@ function bindRun(run: RunRecord): SQLQueryBindings[] {
102
105
  // A digest, as entry_inputs keeps: args after `--` carry tokens
103
106
  // (`--token=…`), and cache.db holds no plaintext secret at rest; nothing
104
107
  // reads the column but for whether the args changed (item 1091).
105
- run.forwardArgs ? xxh3hex(JSON.stringify(run.forwardArgs)) : null,
108
+ run.forwardArgs ? digestValue(JSON.stringify(run.forwardArgs)) : null,
106
109
  run.startedAt,
107
110
  run.endedAt,
108
111
  run.runId ?? null,
package/src/cli/cache.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { existsSync } from 'node:fs'
2
2
  import { Cache, noteSchemaReset } from '../cache/index.js'
3
- import { seeHelp } from './help.js'
3
+ import { flagHint, seeHelp } from './help.js'
4
4
  import { nearest, parseDuration, parseSize } from '../util/index.js'
5
5
  import { acquireRunLock } from '../orchestrator/index.js'
6
6
  import { findWorkspaceRoot } from '../workspace/index.js'
@@ -112,7 +112,10 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
112
112
  out.dryRun = true
113
113
  } else {
114
114
  const cd = parseCacheDirFlag(args, i)
115
- if (cd === null) return { error: `unknown argument: ${a}${seeHelp('cache')}` }
115
+ if (cd === null)
116
+ return {
117
+ error: `unknown argument: ${a}${flagHint('cache prune', a ?? '')}${seeHelp('cache')}`,
118
+ }
116
119
  if ('error' in cd) return { error: cd.error }
117
120
  out.cacheDir = cd.cacheDir
118
121
  i = cd.next
package/src/cli/help.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { nearest } from '../util/index.js'
2
+
1
3
  export function printHelp(pluginCommands: readonly string[] = [], verb?: string): void {
2
4
  process.stdout.write(verb === undefined ? helpText(pluginCommands) : verbHelpText(verb))
3
5
  }
@@ -188,6 +190,26 @@ export function seeHelp(verb: string): string {
188
190
  return ` (see \`vx ${verb} --help\`)`
189
191
  }
190
192
 
193
+ /**
194
+ * `(did you mean --concurrency?)` for a flag within two edits of one `verb`
195
+ * accepts; empty for anything else (a positional included). A third edit
196
+ * is allowed only between flags that share their first five characters,
197
+ * so `--retries` reaches `--retry` while `--zzz` does not reach `--all`.
198
+ */
199
+ export function flagHint(verb: string, arg: string): string {
200
+ if (!arg.startsWith('-')) return ''
201
+ const name = arg.replace(/=.*$/, '')
202
+ const flags = acceptedFlags(verb)
203
+ const best =
204
+ nearest(name, flags) ??
205
+ nearest(
206
+ name,
207
+ flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
208
+ 3,
209
+ )
210
+ return best === undefined ? '' : ` (did you mean ${best}?)`
211
+ }
212
+
191
213
  /**
192
214
  * The documented `--flags` of one verb, read from the help text's
193
215
  * sections headed `… (for <verb>):` so there is no second list to drift —
package/src/cli/info.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  // too; this file parses the flags and renders the rows.
6
6
 
7
7
  import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
8
- import { seeHelp } from './help.js'
8
+ import { flagHint, seeHelp } from './help.js'
9
9
  import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
10
10
  import { formatBytes } from './format.js'
11
11
  import { MIN_BUN } from '../util/index.js'
@@ -36,7 +36,7 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
36
36
  i = cd.next
37
37
  continue
38
38
  }
39
- return { ...out, error: `unknown argument: ${a}${seeHelp('info')}` }
39
+ return { ...out, error: `unknown argument: ${a}${flagHint('info', a ?? '')}${seeHelp('info')}` }
40
40
  }
41
41
  return out
42
42
  }
package/src/cli/init.ts CHANGED
@@ -4,7 +4,8 @@
4
4
  // the scripts is the richer source, and `@vzn/vx-migrate` maps it.
5
5
 
6
6
  import path from 'node:path'
7
- import { seeHelp } from './help.js'
7
+ import { flagHint, seeHelp } from './help.js'
8
+ import { isUserError, UserError } from '../util/index.js'
8
9
  import {
9
10
  applyMigration,
10
11
  findWorkspaceRoot,
@@ -28,7 +29,8 @@ export function parseInitArgs(args: readonly string[]): InitArgs {
28
29
  if (a === '--dry') out.dry = true
29
30
  else if (a === '--force') out.force = true
30
31
  else if (a === '--mjs') out.mjs = true
31
- else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('init')}` }
32
+ else if (a.startsWith('-'))
33
+ return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
32
34
  else return { ...out, error: `unexpected argument: ${a}` }
33
35
  }
34
36
  return out
@@ -41,7 +43,16 @@ export async function initCmd(args: readonly string[]): Promise<number> {
41
43
  return 1
42
44
  }
43
45
  const reads: LoadReads = new Map()
44
- 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
+ })
45
56
  const metas = await listProjects(await loadWorkspace(root, reads))
46
57
  // `init` reads scripts only; a runner's own config beside them is the
47
58
  // richer source (dependsOn, inputs, outputs) and was ignored without a
package/src/cli/last.ts CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { Cache, noteSchemaReset } from '../cache/index.js'
8
8
  import { formatBytes } from './format.js'
9
- import { seeHelp } from './help.js'
9
+ import { flagHint, seeHelp } from './help.js'
10
10
  import {
11
11
  exitSignal,
12
12
  getInvocation,
@@ -62,7 +62,8 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
62
62
  i = cd.next
63
63
  continue
64
64
  }
65
- if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('last')}` }
65
+ if (a.startsWith('-'))
66
+ return { ...out, error: `unknown flag: ${a}${flagHint('last', a)}${seeHelp('last')}` }
66
67
  if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
67
68
  out.runId = a
68
69
  }
package/src/cli/lock.ts CHANGED
@@ -3,7 +3,7 @@
3
3
  // current environment. Design: docs/design/config-lock-2026-06.md.
4
4
 
5
5
  import type { ProjectConfig } from '../config.js'
6
- import { seeHelp } from './help.js'
6
+ import { flagHint, seeHelp } from './help.js'
7
7
  import { relPosix, xxh3hex } from '../util/index.js'
8
8
  import {
9
9
  findWorkspaceRoot,
@@ -29,7 +29,11 @@ export function parseLockArgs(args: readonly string[]): LockArgs {
29
29
  const out: LockArgs = { check: false }
30
30
  for (const a of args) {
31
31
  if (a === '--check') out.check = true
32
- else return { check: false, error: `unknown argument: ${a}${seeHelp('lock')}` }
32
+ else
33
+ return {
34
+ check: false,
35
+ error: `unknown argument: ${a}${flagHint('lock', a)}${seeHelp('lock')}`,
36
+ }
33
37
  }
34
38
  return out
35
39
  }
package/src/cli/run.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { appendFileSync, mkdirSync } from 'node:fs'
2
2
  import path from 'node:path'
3
- import { documentedFlags, seeHelp } from './help.js'
3
+ import { flagHint, seeHelp } from './help.js'
4
4
  import { defaultAffectedBase, findWorkspaceRoot } from '../workspace/index.js'
5
5
  import {
6
6
  planRun,
@@ -15,13 +15,7 @@ import {
15
15
  import type { ContinueMode } from '../graph/index.js'
16
16
  import { type CachePolicy, FULL_CACHE_POLICY, parseCachePolicy } from '../cache/index.js'
17
17
  import { findCwdProject, pickTask, resolveFilters } from './select.js'
18
- import {
19
- MAX_TIMEOUT_MS,
20
- isUserError,
21
- parseDecimalInt,
22
- nearest,
23
- machineParallelism,
24
- } from '../util/index.js'
18
+ import { MAX_TIMEOUT_MS, isUserError, parseDecimalInt, machineParallelism } from '../util/index.js'
25
19
  import { formatGraphDot, formatPlanJson, formatPlanText } from './plan-format.js'
26
20
 
27
21
  export interface RunArgs {
@@ -343,7 +337,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
343
337
  }
344
338
  out.report = fmt
345
339
  } else if (a !== undefined && a.startsWith('-')) {
346
- return { ...out, error: `unknown flag: ${a}${didYouMeanFlag(a)}${seeHelp('run')}` }
340
+ return { ...out, error: `unknown flag: ${a}${flagHint('run', a)}${seeHelp('run')}` }
347
341
  } else if (a !== undefined) {
348
342
  out.tasks.push(a)
349
343
  }
@@ -695,20 +689,3 @@ function printSummary(summary: RunResult): void {
695
689
  function formatRow(o: OutcomeView): { task: string; status: string; duration: string } {
696
690
  return { task: o.taskId, status: outcomeLabel(o), duration: `${o.durationMs}ms` }
697
691
  }
698
-
699
- /** `(did you mean --concurrency?)` for a flag within two edits of a documented one. */
700
- function didYouMeanFlag(flag: string): string {
701
- const name = flag.replace(/=.*$/, '')
702
- const flags = documentedFlags('run')
703
- // The usual two edits; a third only between flags that share their
704
- // first five characters, so `--retries` reaches `--retry` (the i/y)
705
- // while `--zzz` does not reach `--all` — a plain three-edit budget did.
706
- const best =
707
- nearest(name, flags) ??
708
- nearest(
709
- name,
710
- flags.filter((f) => f.slice(0, 5) === name.slice(0, 5)),
711
- 3,
712
- )
713
- return best === undefined ? '' : ` (did you mean ${best}?)`
714
- }
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
@@ -8,7 +8,7 @@
8
8
 
9
9
  import type { ProjectConfig, TaskConfig } from '../config.js'
10
10
  import { declaredTask } from '../graph/index.js'
11
- import { seeHelp } from './help.js'
11
+ import { flagHint, seeHelp } from './help.js'
12
12
  import { nearMatches, relPosix, UserError } from '../util/index.js'
13
13
  import { loadCliProjects } from './workspace-config.js'
14
14
  import {
@@ -33,7 +33,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
33
33
  let format: string | undefined
34
34
  if (a === '--format') format = args[++i] ?? ''
35
35
  else if (a.startsWith('--format=')) format = a.slice('--format='.length)
36
- else if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('show')}` }
36
+ else if (a.startsWith('-'))
37
+ return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
37
38
  else if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
38
39
  else out.target = a
39
40
 
@@ -117,22 +118,25 @@ export async function showCmd(args: readonly string[]): Promise<number> {
117
118
  const task = declaredTask(config, taskName)
118
119
  if (task === undefined) {
119
120
  throw new UserError(
120
- `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}#`)}`,
121
122
  )
122
123
  }
123
124
  process.stdout.write(renderTask(meta.name, dir, taskName, task, parsed.format))
124
125
  return 0
125
126
  }
126
127
 
127
- /** Near misses by edit distance, plus partial names in either direction. */
128
- 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 {
129
133
  const q = query.toLowerCase()
130
134
  const near = new Set(nearMatches(query, candidates))
131
135
  for (const c of candidates) {
132
136
  const n = c.toLowerCase()
133
137
  if (n.includes(q) || q.includes(n)) near.add(c)
134
138
  }
135
- return near.size > 0 ? ` — did you mean ${[...near].join(', ')}?` : ''
139
+ return near.size > 0 ? ` — did you mean ${[...near].map((n) => prefix + n).join(', ')}?` : ''
136
140
  }
137
141
 
138
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
package/src/cli/why.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  // no config evaluation, no re-hash.
7
7
 
8
8
  import { Cache, noteSchemaReset } from '../cache/index.js'
9
- import { seeHelp } from './help.js'
9
+ import { flagHint, seeHelp } from './help.js'
10
10
  import { splitTaskId } from '../graph/index.js'
11
11
  import {
12
12
  cacheKeyDiff,
@@ -83,7 +83,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
83
83
  i = cd.next
84
84
  continue
85
85
  }
86
- if (a.startsWith('-')) return { ...out, error: `unknown flag: ${a}${seeHelp('why')}` }
86
+ if (a.startsWith('-'))
87
+ return { ...out, error: `unknown flag: ${a}${flagHint('why', a)}${seeHelp('why')}` }
87
88
  if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
88
89
  out.target = a
89
90
  }
@@ -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)
@@ -340,6 +340,19 @@ export function dependsOnSiblingOutputs(
340
340
  const outputs = sameProject.outputsOf.get(node.projectName) ?? []
341
341
  if (inputs === undefined || outputs.length === 0) return true
342
342
  if (workspaceInputsReach(inputs, outputs.map(literalPrefix))) return true
343
+ // Nor where it WRITES inside another task's tree: restored ahead of
344
+ // that producer, its restore raced the producer's clean and extract
345
+ // (M-5; invariant 2 of docs/design/overlapping-outputs-2026-09.md).
346
+ // `outputsOf` holds this task's own globs too; set them aside once.
347
+ const own = cache.outputs.files
348
+ if (own.length > 0) {
349
+ const others = [...outputs]
350
+ for (const g of own) {
351
+ const i = others.indexOf(g)
352
+ if (i !== -1) others.splice(i, 1)
353
+ }
354
+ if (others.length > 0 && workspaceInputsReach(own, others.map(literalPrefix))) return true
355
+ }
343
356
  }
344
357
  // A root-anchored output is boundary-IGNORING by design, so it can land
345
358
  // inside THIS task's own project dir — where an ordinary project-relative
@@ -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)