@vzn/vx 0.0.94 → 0.0.96
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 +5 -5
- package/src/cache/tar-stream.ts +4 -2
- package/src/cli/show.ts +1 -1
- package/src/cli/watch-filter.ts +274 -0
- package/src/cli/watch.ts +14 -268
- package/src/cli/workspace-config.ts +20 -8
- package/src/exec/sandbox-runtime.ts +13 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.96",
|
|
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.
|
|
21
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
22
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
23
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
20
|
+
"@vzn/vx-linux-x64": "0.0.96",
|
|
21
|
+
"@vzn/vx-linux-arm64": "0.0.96",
|
|
22
|
+
"@vzn/vx-darwin-x64": "0.0.96",
|
|
23
|
+
"@vzn/vx-darwin-arm64": "0.0.96"
|
|
24
24
|
},
|
|
25
25
|
"dependencies": {
|
|
26
26
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/tar-stream.ts
CHANGED
|
@@ -174,8 +174,10 @@ export async function* tarEntries(stream: ReadableStream<Uint8Array>): AsyncGene
|
|
|
174
174
|
const size = pendingSize ?? octal(h, 124, 12)
|
|
175
175
|
const mtimeMs = octal(h, 136, 12) * 1000
|
|
176
176
|
const padded = Math.ceil(size / BLOCK) * BLOCK
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
// POSIX `ustar\0` only: old GNU's `ustar ` keeps atime and ctime at
|
|
178
|
+
// 345, and `-G` fills them, so a prefix read there named every entry
|
|
179
|
+
// `<atime>…/<name>`.
|
|
180
|
+
const prefix = field(h, 257, 6) === 'ustar' ? field(h, 345, 155) : ''
|
|
179
181
|
const rawName = field(h, 0, 100)
|
|
180
182
|
let name = pendingPath ?? (prefix ? `${prefix}/${rawName}` : rawName)
|
|
181
183
|
pendingPath = undefined
|
package/src/cli/show.ts
CHANGED
|
@@ -83,7 +83,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
83
83
|
const bareTask = projectName !== undefined && taskName === undefined && !byName.has(projectName)
|
|
84
84
|
const scope = projectName === undefined || bareTask ? 'all' : [projectName]
|
|
85
85
|
|
|
86
|
-
const projects = await loadCliProjects(root, metas, scope)
|
|
86
|
+
const projects = await loadCliProjects(root, metas, scope, { noCreate: true })
|
|
87
87
|
|
|
88
88
|
if (parsed.target === undefined) {
|
|
89
89
|
process.stdout.write(renderList(root, metas, projects, parsed.format))
|
|
@@ -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,16 +14,9 @@
|
|
|
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'
|
|
19
|
+
import { seeHelp } from './help.js'
|
|
27
20
|
import {
|
|
28
21
|
fingerprintClaims,
|
|
29
22
|
forwardedSignal,
|
|
@@ -40,20 +33,26 @@ import {
|
|
|
40
33
|
loadWorkspace,
|
|
41
34
|
LOCKFILE_NAME,
|
|
42
35
|
memberBaseDirs,
|
|
43
|
-
PROJECT_CONFIG_FILENAMES,
|
|
44
36
|
WORKSPACE_CONFIG_FILENAMES,
|
|
45
37
|
configImports,
|
|
46
|
-
WORKSPACE_FINGERPRINT_FILES,
|
|
47
38
|
type ProjectEntry,
|
|
48
39
|
type ProjectMeta,
|
|
49
40
|
} from '../workspace/index.js'
|
|
50
41
|
import type { ProjectConfig } from '../config.js'
|
|
51
42
|
import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
|
|
52
43
|
import { taskEdges, taskEdgesFrom } from './select.js'
|
|
44
|
+
import {
|
|
45
|
+
gitIgnored,
|
|
46
|
+
isIgnoredWatchPath,
|
|
47
|
+
isWorkspaceConfigFile,
|
|
48
|
+
isWorkspaceFingerprintFile,
|
|
49
|
+
makeRootEventFilter,
|
|
50
|
+
makeWatchIgnore,
|
|
51
|
+
shapesWatchedSet,
|
|
52
|
+
} from './watch-filter.js'
|
|
53
53
|
import {
|
|
54
54
|
armWatcher,
|
|
55
55
|
fsClockNow,
|
|
56
|
-
IGNORED_SEGMENTS,
|
|
57
56
|
modifiedBefore,
|
|
58
57
|
pollWatcher,
|
|
59
58
|
POLL_INTERVAL_MS,
|
|
@@ -64,153 +63,6 @@ import {
|
|
|
64
63
|
/** Wait this long after the last filesystem event before re-running. */
|
|
65
64
|
const DEBOUNCE_MS = 150
|
|
66
65
|
|
|
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
66
|
/**
|
|
215
67
|
* The run flags a watch loop cannot honour, as the refusal line it prints; null
|
|
216
68
|
* when there is none. `WATCH_REFUSED_FLAGS` (help.ts) is the same list for
|
|
@@ -245,7 +97,9 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
|
|
|
245
97
|
return 1
|
|
246
98
|
}
|
|
247
99
|
if (parsed.tasks.length === 0) {
|
|
248
|
-
process.stderr.write(
|
|
100
|
+
process.stderr.write(
|
|
101
|
+
`vx watch: missing task name (vx watch <task>, e.g. vx watch build)${seeHelp('watch')}\n`,
|
|
102
|
+
)
|
|
249
103
|
return 1
|
|
250
104
|
}
|
|
251
105
|
|
|
@@ -555,61 +409,6 @@ export async function sweepConfigs(
|
|
|
555
409
|
return result(null)
|
|
556
410
|
}
|
|
557
411
|
|
|
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
412
|
interface WatchLoopArgs {
|
|
614
413
|
opts: RunOptions
|
|
615
414
|
/** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
|
|
@@ -662,29 +461,6 @@ interface Rediscovered {
|
|
|
662
461
|
packageDirs: ReadonlySet<string>
|
|
663
462
|
}
|
|
664
463
|
|
|
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
464
|
const CLOSED: WatchHandle = { close() {} }
|
|
689
465
|
|
|
690
466
|
async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
|
|
@@ -1350,33 +1126,3 @@ function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
|
|
|
1350
1126
|
for (const name of a) if (!b.has(name)) return false
|
|
1351
1127
|
return true
|
|
1352
1128
|
}
|
|
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
|
-
}
|
|
@@ -88,6 +88,12 @@ export interface CliLoadOptions {
|
|
|
88
88
|
cacheDir?: string
|
|
89
89
|
/** `--frozen`: configs come from `vx-lock.json`, not evaluation. */
|
|
90
90
|
frozen?: boolean
|
|
91
|
+
/**
|
|
92
|
+
* A reading verb (`vx show`) makes nothing on disk (item 900): with no
|
|
93
|
+
* cache directory yet, it evaluates without the cache rather than
|
|
94
|
+
* create one.
|
|
95
|
+
*/
|
|
96
|
+
noCreate?: boolean
|
|
91
97
|
}
|
|
92
98
|
|
|
93
99
|
/**
|
|
@@ -113,8 +119,11 @@ export async function loadCliProjects(
|
|
|
113
119
|
// glob could select live what the run then treats otherwise.
|
|
114
120
|
const lock = opts.frozen === true ? await readLockfile(workspaceRoot) : null
|
|
115
121
|
if (opts.frozen === true && lock === null) throw new UserError(FROZEN_WITHOUT_LOCK)
|
|
116
|
-
const cache =
|
|
117
|
-
|
|
122
|
+
const cache =
|
|
123
|
+
opts.noCreate === true && !existsSync(cacheDir)
|
|
124
|
+
? null
|
|
125
|
+
: new Cache(cacheDir, { read: true, write: true }, workspaceRoot)
|
|
126
|
+
if (cache !== null) noteSchemaReset(cache, warnToStderr)
|
|
118
127
|
try {
|
|
119
128
|
const loaded = await loadProjects({
|
|
120
129
|
workspaceRoot,
|
|
@@ -125,15 +134,18 @@ export async function loadCliProjects(
|
|
|
125
134
|
seeds: scope,
|
|
126
135
|
closure: false,
|
|
127
136
|
lock,
|
|
128
|
-
evalCache:
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
evalCache:
|
|
138
|
+
cache === null
|
|
139
|
+
? undefined
|
|
140
|
+
: {
|
|
141
|
+
store: cache,
|
|
142
|
+
workspaceRoot,
|
|
143
|
+
workspaceFingerprint: await computeWorkspaceFingerprint(workspaceRoot),
|
|
144
|
+
},
|
|
133
145
|
warn: warnToStderr,
|
|
134
146
|
})
|
|
135
147
|
return loaded.projects
|
|
136
148
|
} finally {
|
|
137
|
-
cache
|
|
149
|
+
cache?.close()
|
|
138
150
|
}
|
|
139
151
|
}
|
|
@@ -678,15 +678,19 @@ export function resolveSandboxConfig(
|
|
|
678
678
|
r.weakerNetworkIsolation = cfg.weakerNetworkIsolation
|
|
679
679
|
}
|
|
680
680
|
if (cfg.ignore !== undefined) {
|
|
681
|
-
// Relative patterns anchor at the project dir
|
|
682
|
-
//
|
|
681
|
+
// Relative patterns anchor at the project dir, `~` ones at the home
|
|
682
|
+
// directory, as a grant's are (a `~` pattern kept as written matched no
|
|
683
|
+
// recorded path). A pattern is not a path, but its literal head is: both
|
|
683
684
|
// producers record where a denial LANDS, so the head is canonicalized
|
|
684
685
|
// like every other side of the policy. Anchored at a project reached
|
|
685
686
|
// through a link (macOS's `/var`), no pattern matched and the denial it
|
|
686
687
|
// named failed the task (B-2).
|
|
687
688
|
const anchor = (pat: string): string => {
|
|
688
|
-
|
|
689
|
-
|
|
689
|
+
const abs = pat.startsWith('~')
|
|
690
|
+
? path.join(os.homedir(), pat.slice(1))
|
|
691
|
+
: path.isAbsolute(pat)
|
|
692
|
+
? pat
|
|
693
|
+
: path.join(projectDir, pat)
|
|
690
694
|
const wild = abs.search(BUN_GLOB_WILDCARDS)
|
|
691
695
|
const head = wild === -1 ? abs : abs.slice(0, abs.lastIndexOf(path.sep, wild)) || path.sep
|
|
692
696
|
return toRealPath(head) + abs.slice(head.length)
|
|
@@ -906,10 +910,11 @@ function ownGroupCommand(
|
|
|
906
910
|
// the tracer attached, so one it inherited (the watcher, SRT's network
|
|
907
911
|
// bridges) that exits first sent the command on untraced, and under
|
|
908
912
|
// `--seccomp-bpf` its `execve` failed ENOSYS. A fresh fork has none.
|
|
909
|
-
//
|
|
910
|
-
// cannot trap what it was started ignoring:
|
|
911
|
-
//
|
|
912
|
-
//
|
|
913
|
+
// POSIX lets an async list start with SIGINT and SIGQUIT ignored, and a
|
|
914
|
+
// shell cannot trap what it was started ignoring: bash 5.2 ignores them
|
|
915
|
+
// for a backgrounded simple command (the first cut here, whose task's
|
|
916
|
+
// `trap … INT` never fired) but not for a brace group, so the `trap -`
|
|
917
|
+
// is for a bash that does; SRT's bash may put them back (dash may not).
|
|
913
918
|
const tracer = [
|
|
914
919
|
executablePath('strace'),
|
|
915
920
|
'-DD',
|