@vzn/vx 0.0.636 → 0.0.638

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.
Files changed (151) hide show
  1. package/README.md +39 -11
  2. package/docs/caching.md +246 -213
  3. package/docs/cli.md +791 -705
  4. package/docs/execution.md +68 -76
  5. package/docs/features.md +47 -42
  6. package/docs/patterns.md +26 -25
  7. package/docs/schema.md +392 -205
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +4 -4
  16. package/schemas/show.json +5 -3
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +28 -13
  19. package/src/bin.ts +30 -9
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +124 -53
  22. package/src/cache/chained-cache.ts +4 -0
  23. package/src/cache/git-inputs.ts +277 -63
  24. package/src/cache/index.ts +15 -1
  25. package/src/cache/inputs.ts +102 -36
  26. package/src/cache/key-fold.ts +8 -7
  27. package/src/cache/layer.ts +15 -18
  28. package/src/cache/layered-cache.ts +20 -102
  29. package/src/cache/plugin-cache.ts +248 -0
  30. package/src/cache/policy.ts +32 -27
  31. package/src/cache/run-history.ts +104 -14
  32. package/src/cache/run-store.ts +77 -0
  33. package/src/cache/schema.ts +58 -31
  34. package/src/cache/scoped-cache.ts +237 -0
  35. package/src/cli/cache.ts +173 -34
  36. package/src/cli/completions.ts +11 -7
  37. package/src/cli/core-alias.ts +1 -1
  38. package/src/cli/foreign-flags.ts +13 -12
  39. package/src/cli/help.ts +110 -70
  40. package/src/cli/index.ts +67 -24
  41. package/src/cli/info.ts +43 -27
  42. package/src/cli/init.ts +115 -407
  43. package/src/cli/last.ts +157 -9
  44. package/src/cli/lock.ts +356 -122
  45. package/src/cli/plan-format.ts +7 -5
  46. package/src/cli/plugin-commands.ts +81 -14
  47. package/src/cli/plugin-templates.ts +11 -11
  48. package/src/cli/run.ts +116 -160
  49. package/src/cli/select.ts +50 -47
  50. package/src/cli/show.ts +83 -59
  51. package/src/cli/task-verb.ts +6 -13
  52. package/src/cli/upgrade.ts +415 -149
  53. package/src/cli/watch-cycle.ts +9 -2
  54. package/src/cli/watch-filter.ts +39 -61
  55. package/src/cli/watch-fs.ts +29 -16
  56. package/src/cli/watch-judge.ts +18 -4
  57. package/src/cli/watch-set.ts +16 -42
  58. package/src/cli/watch.ts +227 -230
  59. package/src/cli/why.ts +38 -18
  60. package/src/cli/workspace-config.ts +33 -70
  61. package/src/config.ts +169 -70
  62. package/src/exec/env.ts +1 -31
  63. package/src/exec/executor.ts +1 -5
  64. package/src/exec/kill-tree.ts +2 -1
  65. package/src/exec/sandbox-runtime.ts +12 -1
  66. package/src/exec/sandbox-violations.ts +9 -1
  67. package/src/graph/dependency-spec.ts +1 -1
  68. package/src/graph/index.ts +1 -0
  69. package/src/graph/priorities.ts +1 -1
  70. package/src/graph/scheduler.ts +27 -7
  71. package/src/graph/task-graph.ts +76 -24
  72. package/src/handoff.ts +169 -0
  73. package/src/index.ts +35 -58
  74. package/src/orchestrator/affected-tasks.ts +15 -8
  75. package/src/orchestrator/command-windows.ts +44 -0
  76. package/src/orchestrator/deferred-outputs.ts +12 -10
  77. package/src/orchestrator/doctor.ts +98 -110
  78. package/src/orchestrator/download-policy.ts +3 -3
  79. package/src/orchestrator/events.ts +19 -3
  80. package/src/orchestrator/excluded-keys.ts +0 -2
  81. package/src/orchestrator/execute-task.ts +147 -69
  82. package/src/orchestrator/failure-mode.ts +19 -10
  83. package/src/orchestrator/fingerprint-watch.ts +18 -15
  84. package/src/orchestrator/forecast.ts +10 -2
  85. package/src/orchestrator/framed-output.ts +9 -5
  86. package/src/orchestrator/graph-stage.ts +5 -5
  87. package/src/orchestrator/history.ts +126 -30
  88. package/src/orchestrator/hit-restore.ts +15 -3
  89. package/src/orchestrator/index.ts +20 -5
  90. package/src/orchestrator/local-shortcircuit.ts +2 -1
  91. package/src/orchestrator/lockfile-claim.ts +79 -20
  92. package/src/orchestrator/logger.ts +80 -41
  93. package/src/orchestrator/metrics.ts +78 -9
  94. package/src/orchestrator/miss-save.ts +3 -0
  95. package/src/orchestrator/options.ts +42 -29
  96. package/src/orchestrator/output-host.ts +72 -0
  97. package/src/orchestrator/plan.ts +6 -8
  98. package/src/orchestrator/plugin-host.ts +100 -66
  99. package/src/orchestrator/plugin.ts +173 -67
  100. package/src/orchestrator/prepare.ts +163 -78
  101. package/src/orchestrator/projects.ts +105 -99
  102. package/src/orchestrator/remote-prefetch.ts +11 -6
  103. package/src/orchestrator/run-context.ts +25 -6
  104. package/src/orchestrator/run-failures.ts +17 -8
  105. package/src/orchestrator/run-lock.ts +1 -1
  106. package/src/orchestrator/run-records.ts +1 -1
  107. package/src/orchestrator/run-report.ts +9 -9
  108. package/src/orchestrator/run-settings.ts +48 -0
  109. package/src/orchestrator/run.ts +244 -119
  110. package/src/orchestrator/sandbox-fix.ts +39 -20
  111. package/src/orchestrator/sandbox-request.ts +84 -13
  112. package/src/orchestrator/signals.ts +1 -1
  113. package/src/orchestrator/stable-keys.ts +13 -11
  114. package/src/orchestrator/status-line.ts +49 -7
  115. package/src/orchestrator/summary.ts +115 -106
  116. package/src/orchestrator/task-hash.ts +12 -83
  117. package/src/orchestrator/telemetry.ts +4 -4
  118. package/src/settings.ts +428 -0
  119. package/src/{orchestrator → util}/colors.ts +5 -0
  120. package/src/util/config-frame.ts +31 -2
  121. package/src/util/errors.ts +33 -1
  122. package/src/util/essential-env.ts +31 -0
  123. package/src/util/hide-env.ts +107 -0
  124. package/src/util/index.ts +16 -3
  125. package/src/util/paths.ts +10 -0
  126. package/src/util/refusal-notice.ts +111 -0
  127. package/src/util/report.ts +31 -0
  128. package/src/util/settle.ts +49 -30
  129. package/src/util/size.ts +23 -4
  130. package/src/util/timing.ts +3 -2
  131. package/src/util/ui.ts +186 -0
  132. package/src/util/ulid.ts +1 -1
  133. package/src/util/verbs.ts +0 -15
  134. package/src/workspace/affected.ts +84 -232
  135. package/src/workspace/config-eval.ts +15 -20
  136. package/src/workspace/config-imports.ts +78 -3
  137. package/src/workspace/config-schema.ts +209 -268
  138. package/src/workspace/dependency-rules.ts +65 -0
  139. package/src/workspace/filter.ts +2 -2
  140. package/src/workspace/fingerprint.ts +11 -121
  141. package/src/workspace/index.ts +20 -27
  142. package/src/workspace/load-reads.ts +4 -4
  143. package/src/workspace/lockfile.ts +96 -140
  144. package/src/workspace/migration.ts +307 -215
  145. package/src/workspace/package-graph.ts +30 -216
  146. package/src/workspace/project-loader.ts +80 -285
  147. package/src/workspace/repo-id.ts +2 -1
  148. package/src/workspace/workspace.ts +363 -831
  149. package/src/cache/config-evals.ts +0 -130
  150. package/src/workspace/config-cache.ts +0 -661
  151. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -15,6 +15,8 @@ export interface WatchCycle {
15
15
  /** Stop the run as a SIGTERM would; watch keeps going. */
16
16
  interrupt(): void
17
17
  readonly interrupted: boolean
18
+ /** The run's task count, once it has started. */
19
+ readonly tasks: number | undefined
18
20
  }
19
21
 
20
22
  /** `onWaiting` hears each task event after which the cycle waits on readiness alone. */
@@ -23,21 +25,26 @@ export function watchCycle(opts: RunOptions, stop: AbortSignal, onWaiting: () =>
23
25
  const bus = createEventBus()
24
26
  // id → persistent; a group task has no process and holds nothing up.
25
27
  const active = new Map<string, boolean>()
28
+ let tasks: number | undefined
26
29
  const waitsOnReadiness = (): boolean =>
27
30
  active.size > 0 && [...active.values()].every((persistent) => persistent)
28
31
  bus.subscribe((e) => {
29
- if (e.kind === 'task:start' && e.node.config.exec !== undefined)
32
+ if (e.kind === 'run:start') tasks = e.info.total
33
+ else if (e.kind === 'task:start' && e.node.config.exec !== undefined)
30
34
  active.set(e.node.id, e.node.config.exec.persistent !== undefined)
31
35
  else if (e.kind === 'task:complete') active.delete(e.node.id)
32
36
  else return
33
37
  if (waitsOnReadiness()) onWaiting()
34
38
  })
35
39
  return {
36
- opts: { ...opts, bus, signal: AbortSignal.any([stop, own.signal]) },
40
+ opts: { ...opts, bus, footer: false, signal: AbortSignal.any([stop, own.signal]) },
37
41
  waitsOnReadiness,
38
42
  interrupt: () => own.abort('SIGTERM'),
39
43
  get interrupted() {
40
44
  return own.signal.aborted
41
45
  },
46
+ get tasks() {
47
+ return tasks
48
+ },
42
49
  }
43
50
  }
@@ -5,6 +5,7 @@
5
5
  import path from 'node:path'
6
6
  import {
7
7
  executablePath,
8
+ gitEnv,
8
9
  isLiteralPattern,
9
10
  normalizeGlob,
10
11
  outputMatcher,
@@ -13,14 +14,10 @@ import {
13
14
  taskGlob,
14
15
  } from '../util/index.js'
15
16
  import { asTrees } from '../cache/index.js'
16
- import {
17
- PROJECT_CONFIG_FILENAMES,
18
- WORKSPACE_CONFIG_FILENAMES,
19
- WORKSPACE_FINGERPRINT_FILES,
20
- } from '../workspace/index.js'
21
- import { IGNORED_SEGMENTS } from './watch-fs.js'
17
+ import { PROJECT_CONFIG_FILENAMES, WORKSPACE_CONFIG_FILENAMES } from '../workspace/index.js'
18
+ import { isIgnoredDir } from './watch-fs.js'
22
19
 
23
- const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
20
+ const IGNORED_SUFFIXES = ['~']
24
21
 
25
22
  /**
26
23
  * True for a path the ignore filter drops. Every project dir is watched
@@ -33,16 +30,22 @@ const IGNORED_SUFFIXES = ['.tsbuildinfo', '~']
33
30
  */
34
31
  export function isIgnoredWatchPath(rel: string): boolean {
35
32
  const segments = rel.split(path.sep)
36
- if (segments.some((s) => IGNORED_SEGMENTS.includes(s))) return true
33
+ for (let i = 1; i <= segments.length; i++) if (isIgnoredDir(segments.slice(0, i))) return true
37
34
  if (IGNORED_SUFFIXES.some((suf) => rel.endsWith(suf))) return true
38
- return false
35
+ const name = segments[segments.length - 1]!
36
+ // A restore's own temporary file, never an input (`ALWAYS_IGNORE`):
37
+ // renamed away before the judgement, it read as a deletion and ran one
38
+ // more cycle where an uncached task makes ignored paths count.
39
+ if (name.startsWith('.vx-tmp-')) return true
40
+ // `vx lock`'s review copy: no input, so a re-lock is no cycle.
41
+ return name === 'vx.lock.json'
39
42
  }
40
43
 
41
44
  /**
42
45
  * The ignore predicate a watcher applies, closed over the run's ACTUAL cache
43
46
  * directory.
44
47
  *
45
- * `IGNORED_SEGMENTS` covers the `.vx/cache` default, but `cacheDir` is a
48
+ * `isIgnoredDir` covers the `.vx/cache` default, but `cacheDir` is a
46
49
  * shipped `defineWorkspace` field: point it anywhere else and vx's own cache
47
50
  * writes land in a watched subtree, so every cycle triggers the next one.
48
51
  * Measured on a relocated cache under a recursive root watcher: ONE edit kicked
@@ -54,6 +57,8 @@ export function makeWatchIgnore(
54
57
  cacheDir: string,
55
58
  outputs: ReadonlyMap<string, readonly string[]> = new Map(),
56
59
  inputs: ReadonlyMap<string, ReadonlyArray<readonly string[]>> = new Map(),
60
+ /** What git tracked at the arm (`gitFiles`): never an output, so an edit there is one. */
61
+ tracked?: ReadonlySet<string>,
57
62
  ): (base: string, filename: string) => boolean {
58
63
  // Watchers report under the real root; a cache dir named through a link
59
64
  // (`VX_CACHE_DIR`, macOS `/var` -> `/private/var`) is matched as its target.
@@ -122,6 +127,14 @@ export function makeWatchIgnore(
122
127
  const abs = path.resolve(base, filename)
123
128
  if (abs === cacheAbs || abs.startsWith(cacheAbs + path.sep)) return true
124
129
  if (isInput(abs)) return false
130
+ // Core never cleans or saves a file git tracks and keys it under the
131
+ // task's own output glob (2026-10-14); a submodule is listed as its
132
+ // gitlink alone, so a path under a tracked one counts.
133
+ if (tracked !== undefined)
134
+ for (let p = abs; p.length > 1; p = path.dirname(p)) {
135
+ if (tracked.has(p)) return false
136
+ if (p === path.dirname(p)) break
137
+ }
125
138
  for (const [dir, isOutput, containers] of declared) {
126
139
  if (!abs.startsWith(dir + path.sep)) continue
127
140
  const rel = abs
@@ -191,6 +204,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
191
204
  try {
192
205
  proc = Bun.spawnSync({
193
206
  cmd: [executablePath('git'), 'check-ignore', '-v', '-n', '-z', '--stdin'],
207
+ env: gitEnv(),
194
208
  cwd: workspaceRoot,
195
209
  stdin: Buffer.from(rest.map((p) => `${p}\0`).join('')),
196
210
  stdout: 'pipe',
@@ -215,6 +229,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
215
229
  inWorkTree ??=
216
230
  Bun.spawnSync({
217
231
  cmd: [executablePath('git'), 'rev-parse', '--is-inside-work-tree'],
232
+ env: gitEnv(),
218
233
  cwd: workspaceRoot,
219
234
  stdout: 'ignore',
220
235
  stderr: 'ignore',
@@ -231,7 +246,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
231
246
  * watcher hears every write in the workspace; a key can see three kinds
232
247
  * of path and no other: a file inside a project's directory (its own
233
248
  * `inputs.files`, or its outputs, which the ignore filter drops next), a
234
- * workspace fingerprint file at the root, and a match of a declared
249
+ * root file a plugin claims, and a match of a declared
235
250
  * `inputs.workspaceFiles` glob. Everything else — a log written at the
236
251
  * root, a `coverage/` or `.turbo/` tree, an editor's scratch file — is
237
252
  * dropped before the trigger, so it costs no cycle. Before this rule the
@@ -257,24 +272,10 @@ export function makeRootEventFilter(
257
272
  )
258
273
  return (filename: string): boolean => {
259
274
  const rel = filename.split(path.sep).join('/')
260
- // The depth test is a READING AID, not a guard: both predicates below
261
- // are exact membership in a set of BARE names, so a `rel` carrying a
262
- // slash can never be in one. Measured with it removed (item 502) over
263
- // `nested/bun.lock`, `./bun.lock`, `a/b/c/pnpm-workspace.yaml` and
264
- // eight more: every answer identical. The two table rows that look
265
- // like they pin it — `nested/pnpm-lock.yaml → false` and
266
- // `nested/vx.workspace.ts → false` — pass either way; what they
267
- // actually pin is that the predicates stay exact rather than becoming
268
- // a basename or suffix match, which is the change that WOULD make
269
- // this line load-bearing.
270
- if (
271
- !rel.includes('/') &&
272
- (isWorkspaceFingerprintFile(rel) ||
273
- isWorkspaceConfigFile(rel) ||
274
- rel === 'package.json' ||
275
- rel === '.gitignore' ||
276
- claimedRootFiles.has(rel))
277
- ) {
275
+ // Exact membership, not a basename match: `nested/pnpm-lock.yaml` and
276
+ // `nested/.vx/workspace.ts` are no root file. The workspace config and
277
+ // a claimed file carry their `.vx/` prefix.
278
+ if (isWorkspaceConfigFile(rel) || claimedRootFiles.has(rel) || rel === '.gitignore') {
278
279
  return true
279
280
  }
280
281
  const abs = path.resolve(workspaceRoot, filename)
@@ -305,30 +306,19 @@ export function makeFence(fenceDirs: readonly string[]): (ownDir: string, abs: s
305
306
  }
306
307
 
307
308
  /**
308
- * A file whose edit can change what the loop watches: a `package.json`
309
- * (a dependency added under `--filter` widens the closure), a project
310
- * config (a task that starts declaring `workspaceFiles` needs the root
311
- * watcher, a new output must stop being an event) or the workspace config
312
- * (a plugin's `project` stage). Until item 891 only a member directory
313
- * coming or going re-read the set, so each of these waited for a restart
314
- * while the loop looked alive. A root fingerprint file too, for
315
- * `pnpm-workspace.yaml`: a glob added there was a cycle that ran the new
316
- * packages and watched none of them (item 1018).
309
+ * A file whose edit can change what the loop watches: a project config (a
310
+ * task that starts declaring `workspaceFiles` needs the root watcher, a
311
+ * new output must stop being an event) or the workspace config (a
312
+ * plugin's `project` stage). Until item 891 each of these waited for a
313
+ * restart while the loop looked alive. The lock and a file a plugin
314
+ * claims re-read the set as well (the caller asks: the projects come from
315
+ * the lock, a lockfile plugin's `discover` names and edges from its file).
317
316
  */
318
317
  const PROJECT_CONFIGS: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
319
318
 
320
319
  export function shapesWatchedSet(filename: string): boolean {
321
320
  const base = path.basename(filename)
322
- return (
323
- base === 'package.json' ||
324
- isWorkspaceFingerprintFile(base) ||
325
- PROJECT_CONFIGS.has(base) ||
326
- isWorkspaceConfigFile(base)
327
- )
328
- }
329
-
330
- export function isWorkspaceFingerprintFile(name: string): boolean {
331
- return FINGERPRINT_FILES.has(name)
321
+ return PROJECT_CONFIGS.has(base) || isWorkspaceConfigFile(filename.split(path.sep).join('/'))
332
322
  }
333
323
 
334
324
  /**
@@ -344,19 +334,6 @@ export function isWorkspaceConfigFile(name: string): boolean {
344
334
  return WORKSPACE_CONFIGS.has(name)
345
335
  }
346
336
 
347
- /**
348
- * True for a root file that re-keys EVERY task, so a change to one must trigger
349
- * a cycle even though it lives in no project dir.
350
- *
351
- * Reads the SHARED constant the fingerprint itself walks — this used to be a
352
- * third hand-rolled copy of that list, which is precisely the drift the
353
- * `--affected` wave exported the constant to prevent. A name added there but
354
- * not here would re-key every task while `vx watch` silently never re-ran on
355
- * it: the loop looks alive, and the one edit that invalidates the whole
356
- * workspace is the one it ignores.
357
- */
358
- const FINGERPRINT_FILES: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
359
-
360
337
  /**
361
338
  * Paths as git spells them under `root`: a project reached through a
362
339
  * symlink below the root (`packages/x -> ../shared/x`) is watched at the
@@ -410,6 +387,7 @@ export function gitFiles(
410
387
  '--others',
411
388
  '--exclude-standard',
412
389
  ],
390
+ env: gitEnv(),
413
391
  cwd: workspaceRoot,
414
392
  stdout: 'pipe',
415
393
  stderr: 'ignore',
@@ -5,8 +5,19 @@
5
5
  import fs from 'node:fs'
6
6
  import path from 'node:path'
7
7
 
8
- /** Paths whose changes never trigger a re-run. */
9
- export const IGNORED_SEGMENTS = ['node_modules', '.git', '.vx']
8
+ /** Directories whose changes never trigger a re-run, wherever they appear. */
9
+ export const IGNORED_SEGMENTS = ['node_modules', '.git']
10
+
11
+ /**
12
+ * True for a directory, as `/`-separated segments, the watchers skip: an
13
+ * `IGNORED_SEGMENTS` name, or vx's machine-local `.vx/cache`. The
14
+ * workspace config and the lock beside it in `.vx` are watched.
15
+ */
16
+ export function isIgnoredDir(segments: readonly string[]): boolean {
17
+ const last = segments.length - 1
18
+ if (IGNORED_SEGMENTS.includes(segments[last]!)) return true
19
+ return segments[last] === 'cache' && segments[last - 1] === '.vx'
20
+ }
10
21
 
11
22
  /**
12
23
  * The file `armWatcher` writes under a watched directory to prove the
@@ -41,7 +52,7 @@ const POLL_INTERVAL_MS = 250
41
52
  * `node_modules` is the difference between a cheap fallback and one that
42
53
  * re-stats 40 000 files four times a second.
43
54
  *
44
- * That justification is exactly `IGNORED_SEGMENTS`, so this IS that set.
55
+ * That justification is exactly `isIgnoredDir`, so this IS that test.
45
56
  * It used to carry a fourth name, `dist`, which the justification does NOT
46
57
  * cover: `dist` is dropped only when a project DECLARES it as an output, and
47
58
  * a project that does not declare it had its `dist/` sources silently
@@ -50,7 +61,6 @@ const POLL_INTERVAL_MS = 250
50
61
  * delivered them. Two watchers disagreeing about what an edit is (item 482).
51
62
  * The real per-project answer is the caller's `skipDir`, below.
52
63
  */
53
- const POLL_SKIP = new Set(IGNORED_SEGMENTS)
54
64
 
55
65
  /**
56
66
  * A watcher built from `stat`, for when the OS one cannot deliver.
@@ -75,7 +85,7 @@ export function pollWatcher(
75
85
  * what the filter would drop anyway — every declared output container, not
76
86
  * a hard-coded name. The default covers the unconditional segments alone.
77
87
  */
78
- skipDir: (rel: string) => boolean = (rel) => POLL_SKIP.has(path.basename(rel)),
88
+ skipDir: (rel: string) => boolean = (rel) => isIgnoredDir(rel.split('/')),
79
89
  ): WatchHandle {
80
90
  let previous = new Map<string, string>()
81
91
  let first = true
@@ -275,7 +285,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
275
285
  if (name == null || typeof name !== 'string' || name === '' || name === '.') return
276
286
  const child = rel === '' ? name : `${rel}/${name}`
277
287
  listener(child)
278
- if (IGNORED_SEGMENTS.includes(name)) return
288
+ if (isIgnoredDir(child.split('/'))) return
279
289
  let st: fs.Stats
280
290
  try {
281
291
  st = fs.statSync(path.join(abs, name))
@@ -321,7 +331,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
321
331
  for (const e of entries) {
322
332
  const child = rel === '' ? e.name : `${rel}/${e.name}`
323
333
  if (e.isDirectory()) {
324
- if (!IGNORED_SEGMENTS.includes(e.name)) watchDir(child, report)
334
+ if (!isIgnoredDir(child.split('/'))) watchDir(child, report)
325
335
  } else if (report) listener(child)
326
336
  }
327
337
  }
@@ -389,25 +399,28 @@ export const CLOSED: WatchHandle = { close() {} }
389
399
  /**
390
400
  * Every watcher `vx watch` arms. Each OS watcher proves delivery or is
391
401
  * swapped for the poller in its slot; an OS watch limit (ENOSPC, EMFILE)
392
- * goes straight to the poller; `VX_WATCH_POLL=1` polls from the start.
402
+ * goes straight to the poller; `watch.poll` (`--poll`, `VX_WATCH_POLL`)
403
+ * polls from the start.
393
404
  * `skip` is what the poller leaves unsampled, asked on each walk.
394
405
  */
395
406
  export class WatcherPool {
396
407
  private readonly watchers: WatchHandle[] = []
397
408
  private readonly proofs: Promise<void>[] = []
398
- // `VX_WATCH_POLL=1` skips the OS watcher entirely. Where it is known not
399
- // to work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
400
- // network mount, a container bind — the attempt costs a denied syscall
401
- // and a two-second wait before the fallback takes over anyway.
402
- private readonly forcePoll = (process.env['VX_WATCH_POLL'] ?? '') !== ''
403
409
  // The stop closes the pool while a cycle may still be re-arming: an arm
404
410
  // after it, or a proof that fails because it closed the watcher, would
405
411
  // leave a live watcher or poller nothing ever closes.
406
412
  private closed = false
407
413
 
408
- constructor(private readonly skip: (dir: string, rel: string) => boolean) {
409
- if (this.forcePoll)
410
- process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (VX_WATCH_POLL)\n`)
414
+ // `forcePoll` skips the OS watcher entirely. Where it is known not to
415
+ // work — a sandbox with no `machLookup` for `com.apple.FSEvents`, a
416
+ // network mount, a container bind — the attempt costs a denied syscall
417
+ // and a two-second wait before the fallback takes over anyway.
418
+ constructor(
419
+ private readonly skip: (dir: string, rel: string) => boolean,
420
+ private readonly forcePoll = false,
421
+ ) {
422
+ if (forcePoll)
423
+ process.stderr.write(`vx watch: polling every ${POLL_INTERVAL_MS} ms (watch.poll)\n`)
411
424
  }
412
425
 
413
426
  private poll(dir: string, recursive: boolean, onEvent: (filename: string) => void): WatchHandle {
@@ -161,7 +161,12 @@ export class ChangeJudge {
161
161
  spell !== undefined && this.ctx.held() && this.ctx.trackedAtArm?.has(spell(abs)) !== true
162
162
  if (this.lastCycle === undefined && !open) return false
163
163
  try {
164
- const m = fs.statSync(abs).mtimeMs
164
+ // The later of the two clocks: a cache hit restores an output with
165
+ // the mtime it was saved with, and read by mtime alone the restore
166
+ // was the user's edit and ran one more cycle (solid, M 2026-10-15).
167
+ // The restore's write moves the ctime.
168
+ const st = fs.statSync(abs)
169
+ const m = Math.max(st.mtimeMs, st.ctimeMs)
165
170
  const start = this.lastCycle?.start ?? this.ctx.armedAt
166
171
  return m >= start && (open || m <= this.lastCycle!.end)
167
172
  } catch {
@@ -195,22 +200,31 @@ export class ChangeJudge {
195
200
  // `sed -i` saving through a temporary file fires that file first, and
196
201
  // the cycle was announced by a name already renamed away.
197
202
  let gone: [label: string, abs: string] | undefined
203
+ // "+N more" counts the changes of the winner's kind: an edit's count
204
+ // leaves out the editor's temporary file renamed away.
205
+ let live = 0
206
+ let goneCount = 0
198
207
  for (const [p, l] of this.pending) {
199
208
  if (ignored.has(p) && !editForUncached(p)) continue
200
- if (this.sameState(p, ignored.has(p), spell) || first !== undefined) continue
209
+ if (this.sameState(p, ignored.has(p), spell)) continue
201
210
  if (!fs.existsSync(p)) {
211
+ goneCount++
202
212
  gone ??= [l, p]
203
213
  continue
204
214
  }
215
+ live++
216
+ if (first !== undefined) continue
205
217
  first = l
206
218
  firstAbs = p
207
219
  }
208
220
  if (first === undefined && gone !== undefined) [first, firstAbs] = gone
209
221
  this.pending.clear()
222
+ const more = (live > 0 ? live : goneCount) - 1
223
+ const named = more > 0 ? `${first} (+${more} more)` : first
210
224
  const byServer = firstAbs !== undefined && this.ctx.held()
211
225
  if (firstAbs === undefined || !this.writtenDuringLastCycle(firstAbs, spell)) {
212
226
  this.streak.n = 0
213
- return first
227
+ return named
214
228
  }
215
229
  this.streak.n = this.streak.abs === firstAbs ? this.streak.n + 1 : 1
216
230
  this.streak.abs = firstAbs
@@ -222,6 +236,6 @@ export class ChangeJudge {
222
236
  : `vx watch: ${first} has started 3 cycles in a row, written by the cycle before each — a task rewrites it every run. Declare it in cache.outputs (an output never starts a cycle) or add it to .gitignore (a git-ignored path never does); until then every run re-runs.\n`,
223
237
  )
224
238
  }
225
- return first
239
+ return named
226
240
  }
227
241
  }
@@ -3,13 +3,13 @@
3
3
  // directories a package glob can grow. Read at start and again whenever an
4
4
  // edit reshapes the set; the loop in watch.ts decides when.
5
5
 
6
- import fs from 'node:fs'
7
6
  import path from 'node:path'
8
7
  import type { ProjectConfig } from '../config.js'
9
8
  import {
10
9
  buildPackageGraph,
10
+ lockKey,
11
11
  configImports,
12
- loadProjectConfig,
12
+ readLockfile,
13
13
  WORKSPACE_CONFIG_FILENAMES,
14
14
  type ProjectEntry,
15
15
  type ProjectMeta,
@@ -79,10 +79,9 @@ export interface ConfigSweep {
79
79
  * declared outputs, so their writes are not taken for edits. The run
80
80
  * path's load (`loadProjects`): the plugin `project` stage applies, so an
81
81
  * output a plugin gave a config-less package is ignored like a declared
82
- * one, and a pure config is served from its cached evaluation rather than
83
- * re-evaluated in a worker. A config that fails to load is out of this
82
+ * one. Configs come from the lock. A load that fails is out of this
84
83
  * concern's scope — the run that just happened already said so, or will —
85
- * and the sweep falls back to the files that do load, one by one.
84
+ * and the sweep falls back to the lock's entries, one by one.
86
85
  */
87
86
  export async function sweepConfigs(
88
87
  projects: readonly ProjectMeta[],
@@ -175,16 +174,18 @@ export async function sweepConfigs(
175
174
  foldAll()
176
175
  return result(staged)
177
176
  }
178
- await Promise.all(
179
- projects.map(async (p) => {
180
- if (p.configPath === null) return
181
- try {
182
- fold(p.dir, await loadProjectConfig(p.configPath))
183
- } catch {
184
- // broken config — out of this concern's scope
185
- }
186
- }),
187
- )
177
+ // The lock's entries one by one: a project it does not hold, or a
178
+ // hand-edited entry, is out of this concern's scope.
179
+ const lock = await readLockfile(workspaceRoot).catch(() => null)
180
+ for (const p of projects) {
181
+ const dir = lockKey(workspaceRoot, p.dir)
182
+ if (p.configPath === null || lock === null || !Object.hasOwn(lock.projects, dir)) continue
183
+ try {
184
+ fold(p.dir, lock.projects[dir]!.config)
185
+ } catch {
186
+ // malformed entry
187
+ }
188
+ }
188
189
  foldAll()
189
190
  return result(null)
190
191
  }
@@ -218,30 +219,3 @@ function reachableNames(
218
219
  }
219
220
  return [...names].some((n) => n.includes('*')) ? null : names
220
221
  }
221
-
222
- /**
223
- * The directory entries under a package glob's directory that can be
224
- * members: directories (or links) whose name is not dotted and not
225
- * `node_modules`, the same rule discovery applies. A watcher on that
226
- * directory reacts only when this set changes.
227
- */
228
- export function memberEntries(base: string): ReadonlySet<string> {
229
- const out = new Set<string>()
230
- let entries: fs.Dirent[]
231
- try {
232
- entries = fs.readdirSync(base, { withFileTypes: true })
233
- } catch {
234
- return out
235
- }
236
- for (const e of entries) {
237
- if (e.name.startsWith('.') || e.name === 'node_modules') continue
238
- if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
239
- }
240
- return out
241
- }
242
-
243
- export function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
244
- if (a.size !== b.size) return false
245
- for (const name of a) if (!b.has(name)) return false
246
- return true
247
- }