@vzn/vx 0.0.94 → 0.0.95

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.94",
3
+ "version": "0.0.95",
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.94",
21
- "@vzn/vx-linux-arm64": "0.0.94",
22
- "@vzn/vx-darwin-x64": "0.0.94",
23
- "@vzn/vx-darwin-arm64": "0.0.94"
20
+ "@vzn/vx-linux-x64": "0.0.95",
21
+ "@vzn/vx-linux-arm64": "0.0.95",
22
+ "@vzn/vx-darwin-x64": "0.0.95",
23
+ "@vzn/vx-darwin-arm64": "0.0.95"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -0,0 +1,274 @@
1
+ // `vx watch`'s event filters: which paths never start a cycle (the ignore
2
+ // rules, git-ignored paths, the root arm's filter) and which files reshape
3
+ // the watched set. Pure decisions over paths; the loop acts on them.
4
+
5
+ import path from 'node:path'
6
+ import {
7
+ executablePath,
8
+ isLiteralPattern,
9
+ normalizeGlob,
10
+ staticPrefix,
11
+ taskGlob,
12
+ } from '../util/index.js'
13
+ import { asTrees } from '../cache/index.js'
14
+ import {
15
+ PROJECT_CONFIG_FILENAMES,
16
+ WORKSPACE_CONFIG_FILENAMES,
17
+ WORKSPACE_FINGERPRINT_FILES,
18
+ } from '../workspace/index.js'
19
+ import { IGNORED_SEGMENTS } from './watch-fs.js'
20
+
21
+ const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
22
+
23
+ /**
24
+ * True for a path the ignore filter drops. Every project dir is watched
25
+ * RECURSIVELY, so without this a `bun install` would re-run the graph on every
26
+ * file it writes, and vx's own `.vx/cache` writes would trigger a cycle that
27
+ * writes to `.vx/cache` again.
28
+ *
29
+ * Segment-wise, not prefix-wise: `node_modules` is ignored wherever it appears
30
+ * in the path, not only at the root.
31
+ */
32
+ export function isIgnoredWatchPath(rel: string): boolean {
33
+ const segments = rel.split(path.sep)
34
+ if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
35
+ if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
36
+ return false
37
+ }
38
+
39
+ /**
40
+ * The ignore predicate a watcher applies, closed over the run's ACTUAL cache
41
+ * directory.
42
+ *
43
+ * `IGNORED_SEGMENTS` covers the `.vx/cache` default, but `cacheDir` is a
44
+ * shipped `defineWorkspace` field: point it anywhere else and vx's own cache
45
+ * writes land in a watched subtree, so every cycle triggers the next one.
46
+ * Measured on a relocated cache under a recursive root watcher: ONE edit kicked
47
+ * it and the loop then ran **22 more times during 6 seconds of total silence**
48
+ * (~3.7 re-runs/second, forever) where the default `.vx` cache settles at 0.
49
+ * A hard-coded literal cannot see a configured path — the resolved one can.
50
+ */
51
+ export function makeWatchIgnore(
52
+ cacheDir: string,
53
+ outputs: ReadonlyMap<string, readonly string[]> = new Map(),
54
+ inputs: ReadonlyMap<string, readonly string[]> = new Map(),
55
+ ): (base: string, filename: string) => boolean {
56
+ const cacheAbs = path.resolve(cacheDir)
57
+ // A task's own outputs are not edits: without this every cycle that
58
+ // writes `dist/` (or `out.txt`) re-runs once more, reporting
59
+ // "up-to-date" for the trouble. Matched under the directory the globs
60
+ // are relative to, whichever watcher delivered the event.
61
+ // The directory that HOLDS an output tree is the task's too: `dist/**`
62
+ // does not match `dist`, and since the clean before a miss prunes an
63
+ // emptied `dist` (2026-09-10) the task re-creates it, which the watcher
64
+ // reports as a change to `dist` itself — a second cycle per edit,
65
+ // reporting "up-to-date". The literal prefix of each glob (`dist` for
66
+ // `dist/**`, `build/out` for `build/out/*.js`; nothing for `*.js`) and
67
+ // every ancestor of it under the dir are output containers.
68
+ // A literal entry means the file or its whole tree — the resolver's own
69
+ // rule (`asTrees`), so the tree's files never count as edits.
70
+ const declared = [...outputs].map(
71
+ ([dir, globs]) =>
72
+ [
73
+ path.resolve(dir),
74
+ asTrees(globs).map((g) => taskGlob(g)),
75
+ globs.map(outputContainer).filter((c) => c !== ''),
76
+ ] as const,
77
+ )
78
+ // A path some task takes as an INPUT is never an output to ignore, even
79
+ // when another task declares it one: an in-place formatter declaring
80
+ // `src/**` hid every `src` edit from a `build` watched beside it, and no
81
+ // cycle ran (item 946). Negations are not consulted — they only narrow,
82
+ // and a path they would exclude costs one cache-hit cycle.
83
+ const read = [...inputs].map(
84
+ ([dir, globs]) =>
85
+ [
86
+ path.resolve(dir),
87
+ asTrees(globs.filter((g) => !g.startsWith('!'))).map((g) => taskGlob(g)),
88
+ ] as const,
89
+ )
90
+ const isInput = (abs: string): boolean =>
91
+ read.some(([dir, globs]) => {
92
+ if (!abs.startsWith(dir + path.sep)) return false
93
+ const rel = abs
94
+ .slice(dir.length + 1)
95
+ .split(path.sep)
96
+ .join('/')
97
+ return globs.some((g) => g.match(rel))
98
+ })
99
+ return (base, filename) => {
100
+ if (isIgnoredWatchPath(filename)) return true
101
+ const abs = path.resolve(base, filename)
102
+ if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
103
+ if (isInput(abs)) return false
104
+ for (const [dir, globs, containers] of declared) {
105
+ if (!abs.startsWith(dir + path.sep)) continue
106
+ const rel = abs
107
+ .slice(dir.length + 1)
108
+ .split(path.sep)
109
+ .join('/')
110
+ if (globs.some((g) => g.match(rel))) return true
111
+ if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
112
+ }
113
+ return false
114
+ }
115
+ }
116
+
117
+ /**
118
+ * The literal directory a glob's matches live under (`''` when the glob
119
+ * starts with a pattern, or negates). A literal entry is a file or its
120
+ * whole tree (schema: literal → tree), so the entry itself is the
121
+ * container; a pattern's is `staticPrefix` — the same rule the sandbox
122
+ * baseline and the deferral gate read.
123
+ */
124
+ function outputContainer(raw: string): string {
125
+ const glob = normalizeGlob(raw)
126
+ if (glob.startsWith('!')) return ''
127
+ if (isLiteralPattern(glob)) return glob.replace(/\/+$/, '')
128
+ const prefix = staticPrefix(glob)
129
+ return prefix === '.' || prefix === '/' ? '' : prefix
130
+ }
131
+
132
+ /**
133
+ * The subset of `paths` git ignores, asked once per judgement (one
134
+ * `git check-ignore` per debounce window that has candidates, never per
135
+ * event). A git-ignored path is invisible to every cache key — inputs
136
+ * are tracked + untracked-not-ignored — so a cycle it starts can change
137
+ * nothing, and a task that writes one on every run (a pid file, a
138
+ * timestamped log, `.next/trace`) made the loop re-run itself forever
139
+ * (2026-09-16: 29 cycles in 8 s from one edit). A TRACKED file that
140
+ * matches a pattern is not reported, by git's own rule, so it stays an
141
+ * edit. Outside a repository (exit 128) nothing is ignored, as before.
142
+ */
143
+ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set<string> {
144
+ const ignored = new Set<string>()
145
+ if (paths.length === 0) return ignored
146
+ let proc: ReturnType<typeof Bun.spawnSync>
147
+ try {
148
+ proc = Bun.spawnSync({
149
+ cmd: [executablePath('git'), 'check-ignore', '-z', '--stdin'],
150
+ cwd: workspaceRoot,
151
+ stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
152
+ stdout: 'pipe',
153
+ stderr: 'ignore',
154
+ })
155
+ } catch {
156
+ // No git on PATH: nothing is ignored, and the loop keeps judging —
157
+ // the initial run already said what vx requires.
158
+ return ignored
159
+ }
160
+ // 0: some ignored; 1: none. Anything else is git refusing (not a
161
+ // repository, a path inside a nested one): no path is ignored.
162
+ if (proc.exitCode !== 0) return ignored
163
+ for (const p of new TextDecoder().decode(proc.stdout).split('\0'))
164
+ if (p.length > 0) ignored.add(p)
165
+ return ignored
166
+ }
167
+
168
+ /**
169
+ * Under the recursive root watcher, the events that can move a key. The
170
+ * watcher hears every write in the workspace; a key can see three kinds
171
+ * of path and no other: a file inside a project's directory (its own
172
+ * `inputs.files`, or its outputs, which the ignore filter drops next), a
173
+ * workspace fingerprint file at the root, and a match of a declared
174
+ * `inputs.workspaceFiles` glob. Everything else — a log written at the
175
+ * root, a `coverage/` or `.turbo/` tree, an editor's scratch file — is
176
+ * dropped before the trigger, so it costs no cycle. Before this rule the
177
+ * one Turbo idiom that puts every repo on this watcher
178
+ * (`globalDependencies` → `workspaceFiles`) made `vx watch … > build.log`
179
+ * inside the repo a loop that never settled: each cycle's output grew the
180
+ * log, the log was an event, the event was a cycle. Negated globs are not
181
+ * consulted: a `!` only narrows, and an event it would have excluded costs
182
+ * one cache-hit cycle, which is the wrong direction to be clever in.
183
+ */
184
+ export function makeRootEventFilter(
185
+ workspaceRoot: string,
186
+ projectDirs: readonly string[],
187
+ workspaceInputs: readonly string[],
188
+ claimedRootFiles: ReadonlySet<string> = new Set(),
189
+ ): (filename: string) => boolean {
190
+ const dirs = projectDirs.map((d) => path.resolve(d))
191
+ const globs = workspaceInputs
192
+ .map(normalizeGlob)
193
+ .filter((g) => !g.startsWith('!'))
194
+ .map((g) => taskGlob(g))
195
+ return (filename: string): boolean => {
196
+ const rel = filename.split(path.sep).join('/')
197
+ // The depth test is a READING AID, not a guard: both predicates below
198
+ // are exact membership in a set of BARE names, so a `rel` carrying a
199
+ // slash can never be in one. Measured with it removed (item 502) over
200
+ // `nested/bun.lock`, `./bun.lock`, `a/b/c/pnpm-workspace.yaml` and
201
+ // eight more: every answer identical. The two table rows that look
202
+ // like they pin it — `nested/pnpm-lock.yaml → false` and
203
+ // `nested/vx.workspace.ts → false` — pass either way; what they
204
+ // actually pin is that the predicates stay exact rather than becoming
205
+ // a basename or suffix match, which is the change that WOULD make
206
+ // this line load-bearing.
207
+ if (
208
+ !rel.includes('/') &&
209
+ (isWorkspaceFingerprintFile(rel) ||
210
+ isWorkspaceConfigFile(rel) ||
211
+ rel === 'package.json' ||
212
+ claimedRootFiles.has(rel))
213
+ ) {
214
+ return true
215
+ }
216
+ const abs = path.resolve(workspaceRoot, filename)
217
+ for (const d of dirs) if (abs === d || abs.startsWith(d + path.sep)) return true
218
+ for (const g of globs) if (g.match(rel)) return true
219
+ return false
220
+ }
221
+ }
222
+
223
+ /**
224
+ * A file whose edit can change what the loop watches: a `package.json`
225
+ * (a dependency added under `--filter` widens the closure), a project
226
+ * config (a task that starts declaring `workspaceFiles` needs the root
227
+ * watcher, a new output must stop being an event) or the workspace config
228
+ * (a plugin's `project` stage). Until item 891 only a member directory
229
+ * coming or going re-read the set, so each of these waited for a restart
230
+ * while the loop looked alive. A root fingerprint file too, for
231
+ * `pnpm-workspace.yaml`: a glob added there was a cycle that ran the new
232
+ * packages and watched none of them (item 1018).
233
+ */
234
+ const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
235
+
236
+ export function shapesWatchedSet(filename: string): boolean {
237
+ const base = path.basename(filename)
238
+ return (
239
+ base === 'package.json' ||
240
+ isWorkspaceFingerprintFile(base) ||
241
+ PROJECT_CONFIGS.has(base) ||
242
+ isWorkspaceConfigFile(base)
243
+ )
244
+ }
245
+
246
+ export function isWorkspaceFingerprintFile(name: string): boolean {
247
+ return FINGERPRINT_FILES.has(name)
248
+ }
249
+
250
+ /**
251
+ * The workspace config is the one root file that shapes a run without
252
+ * being any task's input — its plugins, `config` stage, concurrency, cache
253
+ * dir — and a cycle re-evaluates it (its import is keyed on its bytes).
254
+ * Until 2026-09-10 neither root arm listened for it: a plugin added under
255
+ * `vx watch` waited for a restart while the loop looked alive.
256
+ */
257
+ const WORKSPACE_CONFIGS: ReadonlySet<string> = new Set(WORKSPACE_CONFIG_FILENAMES)
258
+
259
+ export function isWorkspaceConfigFile(name: string): boolean {
260
+ return WORKSPACE_CONFIGS.has(name)
261
+ }
262
+
263
+ /**
264
+ * True for a root file that re-keys EVERY task, so a change to one must trigger
265
+ * a cycle even though it lives in no project dir.
266
+ *
267
+ * Reads the SHARED constant the fingerprint itself walks — this used to be a
268
+ * third hand-rolled copy of that list, which is precisely the drift the
269
+ * `--affected` wave exported the constant to prevent. A name added there but
270
+ * not here would re-key every task while `vx watch` silently never re-ran on
271
+ * it: the loop looks alive, and the one edit that invalidates the whole
272
+ * workspace is the one it ignores.
273
+ */
274
+ const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
package/src/cli/watch.ts CHANGED
@@ -14,15 +14,7 @@
14
14
 
15
15
  import fs from 'node:fs'
16
16
  import path from 'node:path'
17
- import {
18
- executablePath,
19
- isLiteralPattern,
20
- normalizeGlob,
21
- staticPrefix,
22
- taskGlob,
23
- xxh3,
24
- } from '../util/index.js'
25
- import { asTrees } from '../cache/index.js'
17
+ import { xxh3 } from '../util/index.js'
26
18
  import { parseRunArgs, resolveRunOptions, type RunArgs } from './run.js'
27
19
  import {
28
20
  fingerprintClaims,
@@ -40,20 +32,26 @@ import {
40
32
  loadWorkspace,
41
33
  LOCKFILE_NAME,
42
34
  memberBaseDirs,
43
- PROJECT_CONFIG_FILENAMES,
44
35
  WORKSPACE_CONFIG_FILENAMES,
45
36
  configImports,
46
- WORKSPACE_FINGERPRINT_FILES,
47
37
  type ProjectEntry,
48
38
  type ProjectMeta,
49
39
  } from '../workspace/index.js'
50
40
  import type { ProjectConfig } from '../config.js'
51
41
  import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
52
42
  import { taskEdges, taskEdgesFrom } from './select.js'
43
+ import {
44
+ gitIgnored,
45
+ isIgnoredWatchPath,
46
+ isWorkspaceConfigFile,
47
+ isWorkspaceFingerprintFile,
48
+ makeRootEventFilter,
49
+ makeWatchIgnore,
50
+ shapesWatchedSet,
51
+ } from './watch-filter.js'
53
52
  import {
54
53
  armWatcher,
55
54
  fsClockNow,
56
- IGNORED_SEGMENTS,
57
55
  modifiedBefore,
58
56
  pollWatcher,
59
57
  POLL_INTERVAL_MS,
@@ -64,153 +62,6 @@ import {
64
62
  /** Wait this long after the last filesystem event before re-running. */
65
63
  const DEBOUNCE_MS = 150
66
64
 
67
- const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
68
-
69
- /**
70
- * True for a path the ignore filter drops. Every project dir is watched
71
- * RECURSIVELY, so without this a `bun install` would re-run the graph on every
72
- * file it writes, and vx's own `.vx/cache` writes would trigger a cycle that
73
- * writes to `.vx/cache` again.
74
- *
75
- * Segment-wise, not prefix-wise: `node_modules` is ignored wherever it appears
76
- * in the path, not only at the root.
77
- */
78
- export function isIgnoredWatchPath(rel: string): boolean {
79
- const segments = rel.split(path.sep)
80
- if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
81
- if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
82
- return false
83
- }
84
-
85
- /**
86
- * The ignore predicate a watcher applies, closed over the run's ACTUAL cache
87
- * directory.
88
- *
89
- * `IGNORED_SEGMENTS` covers the `.vx/cache` default, but `cacheDir` is a
90
- * shipped `defineWorkspace` field: point it anywhere else and vx's own cache
91
- * writes land in a watched subtree, so every cycle triggers the next one.
92
- * Measured on a relocated cache under a recursive root watcher: ONE edit kicked
93
- * it and the loop then ran **22 more times during 6 seconds of total silence**
94
- * (~3.7 re-runs/second, forever) where the default `.vx` cache settles at 0.
95
- * A hard-coded literal cannot see a configured path — the resolved one can.
96
- */
97
- export function makeWatchIgnore(
98
- cacheDir: string,
99
- outputs: ReadonlyMap<string, readonly string[]> = new Map(),
100
- inputs: ReadonlyMap<string, readonly string[]> = new Map(),
101
- ): (base: string, filename: string) => boolean {
102
- const cacheAbs = path.resolve(cacheDir)
103
- // A task's own outputs are not edits: without this every cycle that
104
- // writes `dist/` (or `out.txt`) re-runs once more, reporting
105
- // "up-to-date" for the trouble. Matched under the directory the globs
106
- // are relative to, whichever watcher delivered the event.
107
- // The directory that HOLDS an output tree is the task's too: `dist/**`
108
- // does not match `dist`, and since the clean before a miss prunes an
109
- // emptied `dist` (2026-09-10) the task re-creates it, which the watcher
110
- // reports as a change to `dist` itself — a second cycle per edit,
111
- // reporting "up-to-date". The literal prefix of each glob (`dist` for
112
- // `dist/**`, `build/out` for `build/out/*.js`; nothing for `*.js`) and
113
- // every ancestor of it under the dir are output containers.
114
- // A literal entry means the file or its whole tree — the resolver's own
115
- // rule (`asTrees`), so the tree's files never count as edits.
116
- const declared = [...outputs].map(
117
- ([dir, globs]) =>
118
- [
119
- path.resolve(dir),
120
- asTrees(globs).map((g) => taskGlob(g)),
121
- globs.map(outputContainer).filter((c) => c !== ''),
122
- ] as const,
123
- )
124
- // A path some task takes as an INPUT is never an output to ignore, even
125
- // when another task declares it one: an in-place formatter declaring
126
- // `src/**` hid every `src` edit from a `build` watched beside it, and no
127
- // cycle ran (item 946). Negations are not consulted — they only narrow,
128
- // and a path they would exclude costs one cache-hit cycle.
129
- const read = [...inputs].map(
130
- ([dir, globs]) =>
131
- [
132
- path.resolve(dir),
133
- asTrees(globs.filter((g) => !g.startsWith('!'))).map((g) => taskGlob(g)),
134
- ] as const,
135
- )
136
- const isInput = (abs: string): boolean =>
137
- read.some(([dir, globs]) => {
138
- if (!abs.startsWith(dir + path.sep)) return false
139
- const rel = abs
140
- .slice(dir.length + 1)
141
- .split(path.sep)
142
- .join('/')
143
- return globs.some((g) => g.match(rel))
144
- })
145
- return (base, filename) => {
146
- if (isIgnoredWatchPath(filename)) return true
147
- const abs = path.resolve(base, filename)
148
- if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
149
- if (isInput(abs)) return false
150
- for (const [dir, globs, containers] of declared) {
151
- if (!abs.startsWith(dir + path.sep)) continue
152
- const rel = abs
153
- .slice(dir.length + 1)
154
- .split(path.sep)
155
- .join('/')
156
- if (globs.some((g) => g.match(rel))) return true
157
- if (containers.some((c) => c === rel || c.startsWith(`${rel}/`))) return true
158
- }
159
- return false
160
- }
161
- }
162
-
163
- /**
164
- * The literal directory a glob's matches live under (`''` when the glob
165
- * starts with a pattern, or negates). A literal entry is a file or its
166
- * whole tree (schema: literal → tree), so the entry itself is the
167
- * container; a pattern's is `staticPrefix` — the same rule the sandbox
168
- * baseline and the deferral gate read.
169
- */
170
- function outputContainer(raw: string): string {
171
- const glob = normalizeGlob(raw)
172
- if (glob.startsWith('!')) return ''
173
- if (isLiteralPattern(glob)) return glob.replace(/\/+$/, '')
174
- const prefix = staticPrefix(glob)
175
- return prefix === '.' || prefix === '/' ? '' : prefix
176
- }
177
-
178
- /**
179
- * The subset of `paths` git ignores, asked once per judgement (one
180
- * `git check-ignore` per debounce window that has candidates, never per
181
- * event). A git-ignored path is invisible to every cache key — inputs
182
- * are tracked + untracked-not-ignored — so a cycle it starts can change
183
- * nothing, and a task that writes one on every run (a pid file, a
184
- * timestamped log, `.next/trace`) made the loop re-run itself forever
185
- * (2026-09-16: 29 cycles in 8 s from one edit). A TRACKED file that
186
- * matches a pattern is not reported, by git's own rule, so it stays an
187
- * edit. Outside a repository (exit 128) nothing is ignored, as before.
188
- */
189
- export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set<string> {
190
- const ignored = new Set<string>()
191
- if (paths.length === 0) return ignored
192
- let proc: ReturnType<typeof Bun.spawnSync>
193
- try {
194
- proc = Bun.spawnSync({
195
- cmd: [executablePath('git'), 'check-ignore', '-z', '--stdin'],
196
- cwd: workspaceRoot,
197
- stdin: Buffer.from(paths.map((p) => `${p}\0`).join('')),
198
- stdout: 'pipe',
199
- stderr: 'ignore',
200
- })
201
- } catch {
202
- // No git on PATH: nothing is ignored, and the loop keeps judging —
203
- // the initial run already said what vx requires.
204
- return ignored
205
- }
206
- // 0: some ignored; 1: none. Anything else is git refusing (not a
207
- // repository, a path inside a nested one): no path is ignored.
208
- if (proc.exitCode !== 0) return ignored
209
- for (const p of new TextDecoder().decode(proc.stdout).split('\0'))
210
- if (p.length > 0) ignored.add(p)
211
- return ignored
212
- }
213
-
214
65
  /**
215
66
  * The run flags a watch loop cannot honour, as the refusal line it prints; null
216
67
  * when there is none. `WATCH_REFUSED_FLAGS` (help.ts) is the same list for
@@ -555,61 +406,6 @@ export async function sweepConfigs(
555
406
  return result(null)
556
407
  }
557
408
 
558
- /**
559
- * Under the recursive root watcher, the events that can move a key. The
560
- * watcher hears every write in the workspace; a key can see three kinds
561
- * of path and no other: a file inside a project's directory (its own
562
- * `inputs.files`, or its outputs, which the ignore filter drops next), a
563
- * workspace fingerprint file at the root, and a match of a declared
564
- * `inputs.workspaceFiles` glob. Everything else — a log written at the
565
- * root, a `coverage/` or `.turbo/` tree, an editor's scratch file — is
566
- * dropped before the trigger, so it costs no cycle. Before this rule the
567
- * one Turbo idiom that puts every repo on this watcher
568
- * (`globalDependencies` → `workspaceFiles`) made `vx watch … > build.log`
569
- * inside the repo a loop that never settled: each cycle's output grew the
570
- * log, the log was an event, the event was a cycle. Negated globs are not
571
- * consulted: a `!` only narrows, and an event it would have excluded costs
572
- * one cache-hit cycle, which is the wrong direction to be clever in.
573
- */
574
- export function makeRootEventFilter(
575
- workspaceRoot: string,
576
- projectDirs: readonly string[],
577
- workspaceInputs: readonly string[],
578
- claimedRootFiles: ReadonlySet<string> = new Set(),
579
- ): (filename: string) => boolean {
580
- const dirs = projectDirs.map((d) => path.resolve(d))
581
- const globs = workspaceInputs
582
- .map(normalizeGlob)
583
- .filter((g) => !g.startsWith('!'))
584
- .map((g) => taskGlob(g))
585
- return (filename: string): boolean => {
586
- const rel = filename.split(path.sep).join('/')
587
- // The depth test is a READING AID, not a guard: both predicates below
588
- // are exact membership in a set of BARE names, so a `rel` carrying a
589
- // slash can never be in one. Measured with it removed (item 502) over
590
- // `nested/bun.lock`, `./bun.lock`, `a/b/c/pnpm-workspace.yaml` and
591
- // eight more: every answer identical. The two table rows that look
592
- // like they pin it — `nested/pnpm-lock.yaml → false` and
593
- // `nested/vx.workspace.ts → false` — pass either way; what they
594
- // actually pin is that the predicates stay exact rather than becoming
595
- // a basename or suffix match, which is the change that WOULD make
596
- // this line load-bearing.
597
- if (
598
- !rel.includes('/') &&
599
- (isWorkspaceFingerprintFile(rel) ||
600
- isWorkspaceConfigFile(rel) ||
601
- rel === 'package.json' ||
602
- claimedRootFiles.has(rel))
603
- ) {
604
- return true
605
- }
606
- const abs = path.resolve(workspaceRoot, filename)
607
- for (const d of dirs) if (abs === d || abs.startsWith(d + path.sep)) return true
608
- for (const g of globs) if (g.match(rel)) return true
609
- return false
610
- }
611
- }
612
-
613
409
  interface WatchLoopArgs {
614
410
  opts: RunOptions
615
411
  /** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
@@ -662,29 +458,6 @@ interface Rediscovered {
662
458
  packageDirs: ReadonlySet<string>
663
459
  }
664
460
 
665
- const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
666
-
667
- /**
668
- * A file whose edit can change what the loop watches: a `package.json`
669
- * (a dependency added under `--filter` widens the closure), a project
670
- * config (a task that starts declaring `workspaceFiles` needs the root
671
- * watcher, a new output must stop being an event) or the workspace config
672
- * (a plugin's `project` stage). Until item 891 only a member directory
673
- * coming or going re-read the set, so each of these waited for a restart
674
- * while the loop looked alive. A root fingerprint file too, for
675
- * `pnpm-workspace.yaml`: a glob added there was a cycle that ran the new
676
- * packages and watched none of them (item 1018).
677
- */
678
- function shapesWatchedSet(filename: string): boolean {
679
- const base = path.basename(filename)
680
- return (
681
- base === 'package.json' ||
682
- isWorkspaceFingerprintFile(base) ||
683
- PROJECT_CONFIGS.has(base) ||
684
- isWorkspaceConfigFile(base)
685
- )
686
- }
687
-
688
461
  const CLOSED: WatchHandle = { close() {} }
689
462
 
690
463
  async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
@@ -1350,33 +1123,3 @@ function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
1350
1123
  for (const name of a) if (!b.has(name)) return false
1351
1124
  return true
1352
1125
  }
1353
-
1354
- /**
1355
- * True for a root file that re-keys EVERY task, so a change to one must trigger
1356
- * a cycle even though it lives in no project dir.
1357
- *
1358
- * Reads the SHARED constant the fingerprint itself walks — this used to be a
1359
- * third hand-rolled copy of that list, which is precisely the drift the
1360
- * `--affected` wave exported the constant to prevent. A name added there but
1361
- * not here would re-key every task while `vx watch` silently never re-ran on
1362
- * it: the loop looks alive, and the one edit that invalidates the whole
1363
- * workspace is the one it ignores.
1364
- */
1365
- const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
1366
-
1367
- function isWorkspaceFingerprintFile(name: string): boolean {
1368
- return FINGERPRINT_FILES.has(name)
1369
- }
1370
-
1371
- /**
1372
- * The workspace config is the one root file that shapes a run without
1373
- * being any task's input — its plugins, `config` stage, concurrency, cache
1374
- * dir — and a cycle re-evaluates it (its import is keyed on its bytes).
1375
- * Until 2026-09-10 neither root arm listened for it: a plugin added under
1376
- * `vx watch` waited for a restart while the loop looked alive.
1377
- */
1378
- const WORKSPACE_CONFIGS: ReadonlySet<string> = new Set(WORKSPACE_CONFIG_FILENAMES)
1379
-
1380
- function isWorkspaceConfigFile(name: string): boolean {
1381
- return WORKSPACE_CONFIGS.has(name)
1382
- }