@vzn/vx 0.0.590 → 0.0.594

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 (55) hide show
  1. package/launcher.cjs +9 -2
  2. package/package.json +5 -5
  3. package/src/cache/archive.ts +16 -5
  4. package/src/cache/cache.ts +41 -11
  5. package/src/cache/git-inputs.ts +14 -9
  6. package/src/cache/inputs.ts +47 -17
  7. package/src/cache/tar-stream.ts +3 -1
  8. package/src/cli/cache.ts +3 -5
  9. package/src/cli/foreign-flags.ts +33 -4
  10. package/src/cli/help.ts +22 -5
  11. package/src/cli/info.ts +4 -6
  12. package/src/cli/init.ts +3 -3
  13. package/src/cli/last.ts +49 -20
  14. package/src/cli/lock.ts +3 -3
  15. package/src/cli/run.ts +65 -37
  16. package/src/cli/select.ts +14 -2
  17. package/src/cli/show.ts +5 -6
  18. package/src/cli/upgrade.ts +2 -3
  19. package/src/cli/watch-filter.ts +7 -6
  20. package/src/cli/watch-fs.ts +17 -1
  21. package/src/cli/watch.ts +22 -4
  22. package/src/cli/why.ts +4 -13
  23. package/src/cli/workspace-config.ts +1 -1
  24. package/src/exec/env.ts +3 -5
  25. package/src/exec/runner.ts +28 -7
  26. package/src/exec/sandbox-deny-scan.ts +2 -2
  27. package/src/exec/sandbox-runtime.ts +69 -19
  28. package/src/exec/sandbox-violations.ts +23 -9
  29. package/src/graph/scheduler.ts +13 -3
  30. package/src/graph/task-graph.ts +3 -2
  31. package/src/index.ts +3 -0
  32. package/src/orchestrator/affected-tasks.ts +34 -10
  33. package/src/orchestrator/doctor.ts +2 -1
  34. package/src/orchestrator/execute-task.ts +14 -3
  35. package/src/orchestrator/hit-restore.ts +3 -2
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/lockfile-claim.ts +2 -3
  38. package/src/orchestrator/logger.ts +4 -1
  39. package/src/orchestrator/miss-save.ts +10 -3
  40. package/src/orchestrator/placement.ts +17 -3
  41. package/src/orchestrator/plugin-host.ts +16 -0
  42. package/src/orchestrator/plugin.ts +9 -13
  43. package/src/orchestrator/projects.ts +18 -9
  44. package/src/orchestrator/run-context.ts +2 -2
  45. package/src/orchestrator/run-lock.ts +14 -4
  46. package/src/orchestrator/run.ts +42 -8
  47. package/src/orchestrator/sandbox-request.ts +6 -10
  48. package/src/workspace/affected.ts +17 -11
  49. package/src/workspace/config-eval.ts +4 -0
  50. package/src/workspace/config-imports.ts +1 -1
  51. package/src/workspace/config-schema.ts +38 -7
  52. package/src/workspace/filter.ts +5 -4
  53. package/src/workspace/migrate-scripts.ts +22 -5
  54. package/src/workspace/project-loader.ts +17 -2
  55. package/src/workspace/workspace.ts +40 -4
package/src/cli/info.ts CHANGED
@@ -5,12 +5,12 @@
5
5
  // too; this file parses the flags and renders the rows.
6
6
 
7
7
  import { collectInfo, type FlakyTask, type InfoFacts } from '../orchestrator/index.js'
8
- import { flagHint, refusedWord, seeHelp } from './help.js'
8
+ import { flagHint, formatValue, refusedWord, seeHelp } from './help.js'
9
9
  import { namedCacheDir, parseCacheDirFlag, warnToStderr } from './workspace-config.js'
10
10
  import { formatBytes } from './format.js'
11
11
  import { MIN_BUN } from '../util/index.js'
12
12
 
13
- export interface InfoArgs {
13
+ interface InfoArgs {
14
14
  format: 'pretty' | 'json'
15
15
  /** `--cache-dir`: report on the cache a run with the same flag uses. */
16
16
  cacheDir?: string
@@ -22,10 +22,8 @@ export function parseInfoArgs(args: readonly string[]): InfoArgs {
22
22
  for (let i = 0; i < args.length; i++) {
23
23
  const a = args[i]
24
24
  if (a === '--format' || a?.startsWith('--format=')) {
25
- const v = a === '--format' ? args[++i] : a.slice('--format='.length)
26
- if (v !== 'pretty' && v !== 'json') {
27
- return { ...out, error: `--format must be pretty or json${seeHelp('info')}` }
28
- }
25
+ const v = formatValue(a === '--format' ? args[++i] : a.slice('--format='.length), 'info')
26
+ if (typeof v === 'object') return { ...out, ...v }
29
27
  out.format = v
30
28
  continue
31
29
  }
package/src/cli/init.ts CHANGED
@@ -11,7 +11,7 @@ import { mkdir, unlink } from 'node:fs/promises'
11
11
  import path from 'node:path'
12
12
  import { flagHint, seeHelp } from './help.js'
13
13
  import { PLUGIN_TEMPLATES } from './plugin-templates.js'
14
- import { isUserError, UserError } from '../util/index.js'
14
+ import { isUserError, relPosix, UserError } from '../util/index.js'
15
15
  import {
16
16
  applyMigration,
17
17
  discoverProjects,
@@ -22,7 +22,7 @@ import {
22
22
  WORKSPACE_CONFIG_FILENAMES,
23
23
  } from '../workspace/index.js'
24
24
 
25
- export interface InitArgs {
25
+ interface InitArgs {
26
26
  dry: boolean
27
27
  force: boolean
28
28
  /** `vx.config.mjs` instead of `.ts` — see `ApplyMigrationArgs.format`. */
@@ -379,7 +379,7 @@ function namelessNotes(root: string, dirs: readonly string[]): string[] {
379
379
  Object.values(scripts).some((v) => typeof v === 'string' && v !== '')
380
380
  )
381
381
  })
382
- .map((dir) => path.relative(root, dir).split(path.sep).join('/'))
382
+ .map((dir) => relPosix(root, dir))
383
383
  .sort()
384
384
  // The root's own manifest is the empty relative path: a nameless
385
385
  // single-package repo read `not mapped: — its package.json…` (M-54).
package/src/cli/last.ts CHANGED
@@ -1,4 +1,4 @@
1
- // `vx last [runId]` — replay a recorded run's summary from the terminal,
1
+ // `vx last [RUNID]` — replay a recorded run's summary from the terminal,
2
2
  // without re-executing anything. Comparison gap #12: with the dashboard's
3
3
  // run-detail page gone (the 2026-08-23 cloud removal), the local run
4
4
  // history in cache.db is the only replay surface, and this verb reads it.
@@ -7,7 +7,7 @@
7
7
  import type { Database } from 'bun:sqlite'
8
8
  import { Cache } from '../cache/index.js'
9
9
  import { formatBytes } from './format.js'
10
- import { flagHint, seeHelp } from './help.js'
10
+ import { flagHint, formatValue, seeHelp } from './help.js'
11
11
  import {
12
12
  exitSignal,
13
13
  getInvocation,
@@ -70,16 +70,8 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
70
70
  continue
71
71
  }
72
72
  if (a === '--format' || a.startsWith('--format=')) {
73
- const fv = a === '--format' ? args[++i] : a.slice(9)
74
- if (fv !== 'pretty' && fv !== 'json') {
75
- return {
76
- ...out,
77
- error:
78
- fv === undefined || fv === ''
79
- ? '--format requires a value (pretty | json)'
80
- : `invalid --format: ${fv} (expected pretty | json)`,
81
- }
82
- }
73
+ const fv = formatValue(a === '--format' ? args[++i] : a.slice(9), 'last')
74
+ if (typeof fv === 'object') return { ...out, ...fv }
83
75
  out.format = fv
84
76
  continue
85
77
  }
@@ -241,6 +233,47 @@ function hitsLine(
241
233
  return `${hits} (${inv.upToDateCount} up-to-date, ${restored} restored${layers})`
242
234
  }
243
235
 
236
+ /**
237
+ * `vx last --list`'s rows, in columns: the counts vary with what a run
238
+ * hit and failed, so unpadded they pushed each run's duration and command
239
+ * to a different column and the list could not be scanned down.
240
+ */
241
+ export function formatRunList(
242
+ runs: readonly {
243
+ inv: Pick<
244
+ InvocationDetail,
245
+ | 'exitOk'
246
+ | 'startedAt'
247
+ | 'taskCount'
248
+ | 'failedCount'
249
+ | 'totalDurationMs'
250
+ | 'command'
251
+ | 'hitCount'
252
+ | 'upToDateCount'
253
+ | 'restoredLocalCount'
254
+ | 'restoredRemoteCount'
255
+ >
256
+ id: string
257
+ }[],
258
+ ): string[] {
259
+ const rows = runs.map(({ inv, id }) => ({
260
+ head: `${inv.exitOk ? 'ok ' : 'FAILED'} ${fmtWhen(inv.startedAt)}`,
261
+ id,
262
+ counts:
263
+ `${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}` +
264
+ (inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''),
265
+ ms: fmtMs(inv.totalDurationMs),
266
+ command: inv.command,
267
+ }))
268
+ const idW = Math.max(...rows.map((r) => r.id.length))
269
+ const countsW = Math.max(...rows.map((r) => r.counts.length))
270
+ const msW = Math.max(...rows.map((r) => r.ms.length))
271
+ return rows.map(
272
+ (r) =>
273
+ `${r.head} ${r.id.padEnd(idW)} ${r.counts.padEnd(countsW)} ${r.ms.padStart(msW)} $ ${r.command}`,
274
+ )
275
+ }
276
+
244
277
  export async function lastCmd(args: readonly string[]): Promise<number> {
245
278
  const parsed = parseLastArgs(args)
246
279
  if (parsed.error !== undefined) throw new UserError(`vx last: ${parsed.error}`)
@@ -269,14 +302,10 @@ export async function lastCmd(args: readonly string[]): Promise<number> {
269
302
  )
270
303
  return 0
271
304
  }
272
- for (const inv of invocations) {
273
- const verdict = inv.exitOk ? 'ok ' : 'FAILED'
274
- process.stdout.write(
275
- `${verdict} ${fmtWhen(inv.startedAt)} ${shortRunId(db, inv.runId)} ` +
276
- `${inv.taskCount} task${inv.taskCount === 1 ? '' : 's'} · ${hitsLine(inv)}` +
277
- `${inv.failedCount > 0 ? ` · ${inv.failedCount} failed` : ''} · ${fmtMs(inv.totalDurationMs)} $ ${inv.command}\n`,
278
- )
279
- }
305
+ const lines = formatRunList(
306
+ invocations.map((inv) => ({ inv, id: shortRunId(db, inv.runId) })),
307
+ )
308
+ process.stdout.write(`${lines.join('\n')}\n`)
280
309
  return 0
281
310
  }
282
311
 
package/src/cli/lock.ts CHANGED
@@ -20,7 +20,7 @@ import {
20
20
  type ProjectMeta,
21
21
  } from '../workspace/index.js'
22
22
 
23
- export interface LockArgs {
23
+ interface LockArgs {
24
24
  check: boolean
25
25
  error?: string
26
26
  }
@@ -99,7 +99,7 @@ async function writeLock(root: string, metas: ConfiguredMeta[], bare: number): P
99
99
  await writeLockfile(root, lock)
100
100
  const n = metas.length
101
101
  process.stdout.write(
102
- `vx: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
102
+ `vx lock: locked ${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}\n`,
103
103
  )
104
104
  return 0
105
105
  }
@@ -199,6 +199,6 @@ async function checkLock(root: string, metas: ConfiguredMeta[], bare: number): P
199
199
  const n = metas.length
200
200
  const audited = `${n} project${n === 1 ? '' : 's'}`
201
201
  const skipped = bare === 0 ? '' : `; ${bare} without a vx.config not audited`
202
- process.stdout.write(`vx: lock is up to date (${audited}${skipped})\n`)
202
+ process.stdout.write(`vx lock --check: up to date (${audited}${skipped})\n`)
203
203
  return 0
204
204
  }
package/src/cli/run.ts CHANGED
@@ -428,37 +428,11 @@ function onCI(v: string | undefined): boolean {
428
428
  return v !== undefined && v !== '' && v !== '0' && v !== 'false'
429
429
  }
430
430
 
431
- /**
432
- * Resolve parsed `vx run` argv into the `RunOptions` the orchestrator
433
- * consumes. Shared between `runCmd` and `watchCmd` so both subcommands
434
- * honor the same selection semantics (cwd / `--all` / `--filter` /
435
- * `--affected` / anchored positionals).
436
- *
437
- * Returns the resolved options, an error message, or `nothingSelected` —
438
- * the selection legitimately resolved to zero projects (nothing changed
439
- * since the `--affected` base), which is a clean exit, not a failure. The
440
- * caller prefixes messages with its subcommand name (`vx run: <msg>` /
441
- * `vx watch: <msg>`).
442
- *
443
- * Assumes the caller has already populated `parsed.tasks` (e.g. via
444
- * the interactive picker for `vx run`).
445
- */
446
- export async function resolveRunOptions(
431
+ /** The filters a run's scope comes from: `--filter`s, with `--affected` as its `...[<base>]`. */
432
+ async function scopeFilters(
447
433
  parsed: RunArgs,
448
434
  cwd: string,
449
- tasks: readonly string[],
450
- ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
451
- for (const t of tasks) {
452
- const idx = t.indexOf('#')
453
- if (idx >= 0) {
454
- const project = t.slice(0, idx)
455
- const task = t.slice(idx + 1)
456
- if (!project || !task) {
457
- return { error: `invalid pkg#task: ${t}` }
458
- }
459
- }
460
- }
461
-
435
+ ): Promise<{ filterStrings: string[]; affectedFilter?: string } | { error: string }> {
462
436
  // `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
463
437
  // changed projects AND their dependents — what a CI gate must run, what
464
438
  // the flag's name says, what the guides promised while the sugar was the
@@ -488,6 +462,43 @@ export async function resolveRunOptions(
488
462
  affectedFilter = `...[${base}]`
489
463
  filterStrings.unshift(affectedFilter)
490
464
  }
465
+ return { filterStrings, ...(affectedFilter !== undefined ? { affectedFilter } : {}) }
466
+ }
467
+
468
+ /**
469
+ * Resolve parsed `vx run` argv into the `RunOptions` the orchestrator
470
+ * consumes. Shared between `runCmd` and `watchCmd` so both subcommands
471
+ * honor the same selection semantics (cwd / `--all` / `--filter` /
472
+ * `--affected` / anchored positionals).
473
+ *
474
+ * Returns the resolved options, an error message, or `nothingSelected` —
475
+ * the selection legitimately resolved to zero projects (nothing changed
476
+ * since the `--affected` base), which is a clean exit, not a failure. The
477
+ * caller prefixes messages with its subcommand name (`vx run: <msg>` /
478
+ * `vx watch: <msg>`).
479
+ *
480
+ * Assumes the caller has already populated `parsed.tasks` (e.g. via
481
+ * the interactive picker for `vx run`).
482
+ */
483
+ export async function resolveRunOptions(
484
+ parsed: RunArgs,
485
+ cwd: string,
486
+ tasks: readonly string[],
487
+ ): Promise<RunOptions | { error: string } | { nothingSelected: string }> {
488
+ for (const t of tasks) {
489
+ const idx = t.indexOf('#')
490
+ if (idx >= 0) {
491
+ const project = t.slice(0, idx)
492
+ const task = t.slice(idx + 1)
493
+ if (!project || !task) {
494
+ return { error: `invalid pkg#task: ${t}` }
495
+ }
496
+ }
497
+ }
498
+
499
+ const scope = await scopeFilters(parsed, cwd)
500
+ if ('error' in scope) return scope
501
+ const { filterStrings, affectedFilter } = scope
491
502
 
492
503
  // Project scope applies to bare task names only. Anchored entries
493
504
  // (pkg#task) resolve directly to their own project regardless.
@@ -625,14 +636,31 @@ export async function runCmd(args: readonly string[]): Promise<number> {
625
636
  )
626
637
  return 1
627
638
  }
628
- const picked = await pickTask(
629
- cwd,
630
- {},
631
- {
632
- ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
633
- ...(parsed.frozen ? { frozen: true } : {}),
634
- },
635
- )
639
+ const load = {
640
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
641
+ ...(parsed.frozen ? { frozen: true } : {}),
642
+ }
643
+ // `--filter` / `--affected` scope the menu, as they scope a bare task:
644
+ // the menu listed every project and the anchored pick ran outside it.
645
+ const scope = await scopeFilters(parsed, cwd)
646
+ if ('error' in scope) {
647
+ process.stderr.write(`vx run: ${scope.error}\n`)
648
+ return 1
649
+ }
650
+ let only: ReadonlySet<string> | undefined
651
+ if (scope.filterStrings.length > 0) {
652
+ const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
653
+ if ('error' in selected) {
654
+ process.stderr.write(`vx run: ${selected.error}\n`)
655
+ return 1
656
+ }
657
+ if ('empty' in selected) {
658
+ process.stderr.write(`vx run: ${selected.empty}\n`)
659
+ return 0
660
+ }
661
+ only = new Set(selected.names)
662
+ }
663
+ const picked = await pickTask(cwd, {}, load, only)
636
664
  if (picked === 'interrupted') return 130
637
665
  if (!picked) return 1
638
666
  tasks = [`${picked.project}#${picked.task}`]
package/src/cli/select.ts CHANGED
@@ -243,7 +243,10 @@ export async function resolveFilters(
243
243
  } catch (err) {
244
244
  return { error: err instanceof Error ? err.message : String(err) }
245
245
  }
246
- const walksGraph = parsed.some((f) => f.withDeps || f.withDependents || f.onlyDeps)
246
+ // `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
247
+ const walksGraph = parsed.some(
248
+ (f) => f.withDeps || f.withDependents || f.onlyDeps || f.sinceViaDeps === true,
249
+ )
247
250
  // A filter that diffs against git starts the run's whole-tree walk now:
248
251
  // the diff reads its untracked files from it (one walk where it spawned
249
252
  // its own), and the run reuses it with the discovery (I-26).
@@ -422,7 +425,7 @@ export async function resolveFilters(
422
425
  }
423
426
  }
424
427
 
425
- export interface PickedTask {
428
+ interface PickedTask {
426
429
  project: string
427
430
  task: string
428
431
  description?: string
@@ -436,6 +439,8 @@ export async function pickTask(
436
439
  cwd: string,
437
440
  io: { input?: NodeJS.ReadableStream; output?: NodeJS.WritableStream } = {},
438
441
  load: CliLoadOptions = {},
442
+ /** The projects `--filter` / `--affected` selected; every project when absent. */
443
+ only?: ReadonlySet<string>,
439
444
  ): Promise<PickedTask | null | 'interrupted'> {
440
445
  const projects = await loadWorkspaceProjects(cwd)
441
446
  // The staged load: a task a `project` plugin gave a config-less package
@@ -443,6 +448,7 @@ export async function pickTask(
443
448
  const staged = await loadCliProjects(await findWorkspaceRoot(cwd), projects, 'all', load)
444
449
  const entries: PickedTask[] = []
445
450
  for (const meta of projects) {
451
+ if (only !== undefined && !only.has(meta.name)) continue
446
452
  const config = staged.get(meta.name)?.config
447
453
  if (config === undefined) continue
448
454
  const taskNames = Object.keys(config.tasks ?? {})
@@ -453,6 +459,12 @@ export async function pickTask(
453
459
  entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
454
460
  }
455
461
  }
462
+ if (entries.length === 0 && only !== undefined) {
463
+ process.stderr.write(
464
+ `vx run: no tasks declared in the selected projects (${[...only].sort().join(', ')})\n`,
465
+ )
466
+ return null
467
+ }
456
468
  if (entries.length === 0) {
457
469
  process.stderr.write(
458
470
  'vx run: no tasks declared in any project; declare one under `tasks` in a vx.config, or run `vx init` to write them from package.json scripts\n',
package/src/cli/show.ts CHANGED
@@ -10,7 +10,7 @@ import path from 'node:path'
10
10
  import type { ProjectConfig, TaskConfig } from '../config.js'
11
11
  import { declaredTask } from '../graph/index.js'
12
12
  import { isDefaultBuild } from '../orchestrator/index.js'
13
- import { flagHint, seeHelp } from './help.js'
13
+ import { flagHint, formatValue, seeHelp } from './help.js'
14
14
  import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
15
15
  import { discoverCliProjects, loadCliProjects } from './workspace-config.js'
16
16
  import {
@@ -21,7 +21,7 @@ import {
21
21
  type ProjectMeta,
22
22
  } from '../workspace/index.js'
23
23
 
24
- export interface ShowArgs {
24
+ interface ShowArgs {
25
25
  target?: string
26
26
  format: 'pretty' | 'json'
27
27
  error?: string
@@ -47,10 +47,9 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
47
47
  else out.target = a
48
48
 
49
49
  if (format !== undefined) {
50
- if (format !== 'pretty' && format !== 'json') {
51
- return { ...out, error: '--format must be pretty or json' }
52
- }
53
- out.format = format
50
+ const fv = formatValue(format, 'show')
51
+ if (typeof fv === 'object') return { ...out, ...fv }
52
+ out.format = fv
54
53
  }
55
54
  }
56
55
  return out
@@ -1,4 +1,4 @@
1
- // `vx upgrade [tag]` — self-update the compiled binary in place.
1
+ // `vx upgrade [TAG]` — self-update the compiled binary in place.
2
2
  // Asks the GitHub release API for the asset of this os/arch and the
3
3
  // SHA-256 digest it publishes, downloads the asset, verifies the digest,
4
4
  // writes next to the current executable, atomic rename over it. The
@@ -299,8 +299,7 @@ export async function upgradeCmd(args: readonly string[]): Promise<number> {
299
299
  }
300
300
  if (!isCompiledBinary()) {
301
301
  throw new UserError(
302
- 'vx upgrade only works for the compiled binary. ' +
303
- 'You are running from source — use git pull instead. ' +
302
+ 'vx upgrade: only the compiled binary upgrades itself; this vx runs from source — use git pull ' +
304
303
  '(npm installs update with: npm install -g @vzn/vx@latest)',
305
304
  )
306
305
  }
@@ -185,7 +185,7 @@ export function gitIgnored(workspaceRoot: string, paths: readonly string[]): Set
185
185
  }
186
186
  // Each record: source, line, pattern, path. No source: no pattern
187
187
  // matched; a `!` pattern: re-included. Either way not ignored.
188
- const fields = new TextDecoder().decode(proc.stdout).split('\0')
188
+ const fields = new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')
189
189
  const records = Math.floor(fields.length / 4)
190
190
  for (let i = 0; i < records; i++) {
191
191
  const [source, , pattern, p] = fields.slice(i * 4, i * 4 + 4)
@@ -232,10 +232,11 @@ export function makeRootEventFilter(
232
232
  fenced: (ownDir: string, abs: string) => boolean = () => false,
233
233
  ): (filename: string) => boolean {
234
234
  const dirs = projectDirs.map((d) => path.resolve(d))
235
- const globs = workspaceInputs
236
- .map(normalizeGlob)
237
- .filter((g) => !g.startsWith('!'))
238
- .map((g) => taskGlob(g))
235
+ // A literal is its tree, as the key resolves it: `shared` matched only
236
+ // the directory's own event and no edit under it ran a cycle (WD-2).
237
+ const globs = asTrees(workspaceInputs.filter((g) => !normalizeGlob(g).startsWith('!'))).map((g) =>
238
+ taskGlob(g),
239
+ )
239
240
  return (filename: string): boolean => {
240
241
  const rel = filename.split(path.sep).join('/')
241
242
  // The depth test is a READING AID, not a guard: both predicates below
@@ -359,7 +360,7 @@ export function gitFiles(workspaceRoot: string): Set<string> | undefined {
359
360
  }
360
361
  if (proc.exitCode !== 0) return undefined
361
362
  const files = new Set<string>()
362
- for (const p of new TextDecoder().decode(proc.stdout).split('\0')) {
363
+ for (const p of new TextDecoder('utf-8', { ignoreBOM: true }).decode(proc.stdout).split('\0')) {
363
364
  if (p.length === 0) continue
364
365
  // An untracked nested repository is listed as `dir/`.
365
366
  let abs = path.join(workspaceRoot, p.endsWith('/') ? p.slice(0, -1) : p)
@@ -237,6 +237,12 @@ export function armWatcher(
237
237
  */
238
238
  function treeWatcher(root: string, listener: (filename: string) => void): WatchHandle {
239
239
  const watchers = new Map<string, fs.FSWatcher>()
240
+ // Which directory each watch holds: inotify follows the inode, so a name
241
+ // removed and made again before its event is handled stats as a
242
+ // directory still, and the watch on the deleted one heard nothing more.
243
+ // The birth time too: a freed inode number goes to the next directory.
244
+ const held = new Map<string, string>()
245
+ const idOf = (st: fs.Stats): string => `${st.dev}:${st.ino}:${st.birthtimeMs}`
240
246
  let closed = false
241
247
  let armed = false
242
248
  let warned = false
@@ -249,6 +255,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
249
255
  if (key !== rel && !key.startsWith(rel + '/')) continue
250
256
  w.close()
251
257
  watchers.delete(key)
258
+ held.delete(key)
252
259
  }
253
260
  }
254
261
  const watchDir = (rel: string, report: boolean): void => {
@@ -269,7 +276,10 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
269
276
  return
270
277
  }
271
278
  if (!st.isDirectory()) return
272
- if (watchers.has(child)) return
279
+ if (watchers.has(child)) {
280
+ if (held.get(child) === idOf(st)) return
281
+ drop(child)
282
+ }
273
283
  watchDir(child, true)
274
284
  })
275
285
  } catch (err) {
@@ -289,6 +299,11 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
289
299
  }
290
300
  w.on('error', () => drop(rel))
291
301
  watchers.set(rel, w)
302
+ try {
303
+ held.set(rel, idOf(fs.statSync(abs)))
304
+ } catch {
305
+ held.set(rel, '')
306
+ }
292
307
  let entries: fs.Dirent[]
293
308
  try {
294
309
  entries = fs.readdirSync(abs, { withFileTypes: true })
@@ -314,6 +329,7 @@ function treeWatcher(root: string, listener: (filename: string) => void): WatchH
314
329
  closed = true
315
330
  for (const w of watchers.values()) w.close()
316
331
  watchers.clear()
332
+ held.clear()
317
333
  },
318
334
  }
319
335
  }
package/src/cli/watch.ts CHANGED
@@ -55,6 +55,12 @@ function sayCannot(what: string, err: unknown): void {
55
55
 
56
56
  /** Wait this long after the last filesystem event before re-running. */
57
57
  const DEBOUNCE_MS = 150
58
+ /**
59
+ * ...but no longer than this after the first: a writer that never pauses
60
+ * for a window (a dev server logging into its project every 50 ms) reset
61
+ * the timer forever, and an edit made meanwhile never ran.
62
+ */
63
+ const DEBOUNCE_MAX_MS = 1_000
58
64
 
59
65
  /**
60
66
  * The run flags a watch loop cannot honour, as the refusal line it prints; null
@@ -365,11 +371,15 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
365
371
  /** The cycle in flight, so the stop path can wait for its teardown before resolving. */
366
372
  let inFlight: Promise<void> = Promise.resolve()
367
373
  let debounceTimer: ReturnType<typeof setTimeout> | null = null
374
+ let windowOpened = 0
368
375
 
369
376
  const trigger = (label: string, abs: string): void => {
370
377
  if (!changes.pending.has(abs)) changes.pending.set(abs, label)
371
378
  if (running) return
372
- if (debounceTimer) clearTimeout(debounceTimer)
379
+ if (debounceTimer) {
380
+ if (Date.now() - windowOpened >= DEBOUNCE_MAX_MS) return
381
+ clearTimeout(debounceTimer)
382
+ } else windowOpened = Date.now()
373
383
  debounceTimer = setTimeout(() => {
374
384
  debounceTimer = null
375
385
  if (running) return
@@ -491,7 +501,9 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
491
501
  const handle = arm(proj.dir, true, (filename) => {
492
502
  const abs = path.join(proj.dir, filename)
493
503
  if (isIgnoredPath(proj.dir, filename) || fenced(proj.dir, abs)) return
494
- if (shapesWatchedSet(filename)) reread = true
504
+ // A module the config imports from inside the project is the config
505
+ // too: its edit can add an output or a `workspaceFiles` input.
506
+ if (shapesWatchedSet(filename) || configImportFiles.includes(abs)) reread = true
495
507
  trigger(`${proj.name} ${filename}`, abs)
496
508
  })
497
509
  perProject.set(proj.dir, handle)
@@ -512,8 +524,14 @@ async function runWatchLoop(args: WatchLoopArgs): Promise<number> {
512
524
  // / .git / .vx and declared outputs out of what remains.
513
525
  arm(workspaceRoot, true, (filename) => {
514
526
  if (!matters(filename) || isIgnoredPath(workspaceRoot, filename)) return
515
- if (shapesWatchedSet(filename) || filename === LOCKFILE_NAME) reread = true
516
- trigger(`root ${filename}`, path.join(workspaceRoot, filename))
527
+ const abs = path.join(workspaceRoot, filename)
528
+ if (
529
+ shapesWatchedSet(filename) ||
530
+ filename === LOCKFILE_NAME ||
531
+ configImportFiles.includes(abs)
532
+ )
533
+ reread = true
534
+ trigger(`root ${filename}`, abs)
517
535
  })
518
536
  : // The root itself, non-recursive, beside the per-project arms, so
519
537
  // lockfile + pnpm-workspace.yaml edits trigger re-runs even when
package/src/cli/why.ts CHANGED
@@ -7,7 +7,7 @@
7
7
 
8
8
  import path from 'node:path'
9
9
  import { Cache } from '../cache/index.js'
10
- import { flagHint, seeHelp } from './help.js'
10
+ import { flagHint, formatValue, seeHelp } from './help.js'
11
11
  import { splitTaskId } from '../graph/index.js'
12
12
  import {
13
13
  cacheKeyDiff,
@@ -70,17 +70,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
70
70
  continue
71
71
  }
72
72
  if (a === '--format' || a.startsWith('--format=')) {
73
- const fv = a === '--format' ? args[++i] : a.slice(9)
74
- if (fv !== 'pretty' && fv !== 'json') {
75
- // An omitted value reads as empty, not as the literal word "undefined".
76
- return {
77
- ...out,
78
- error:
79
- fv === undefined || fv === ''
80
- ? '--format requires a value (pretty | json)'
81
- : `invalid --format: ${fv} (expected pretty | json)`,
82
- }
83
- }
73
+ const fv = formatValue(a === '--format' ? args[++i] : a.slice(9), 'why')
74
+ if (typeof fv === 'object') return { ...out, ...fv }
84
75
  out.format = fv
85
76
  continue
86
77
  }
@@ -235,7 +226,7 @@ export async function whyCmd(args: readonly string[]): Promise<number> {
235
226
  const why = whyDidThisRerun(db, runId, taskId)
236
227
  if (!why.found) {
237
228
  throw new UserError(
238
- `vx why: run ${runId} has no row for ${taskId}; \`vx last --list\` shows the recorded runs, \`vx last <runId>\` what one ran`,
229
+ `vx why: run ${runId} has no row for ${taskId}; \`vx last --list\` shows the recorded runs, \`vx last RUNID\` what one ran`,
239
230
  )
240
231
  }
241
232
  const diff = cacheKeyDiff(db, runId, taskId)
@@ -23,7 +23,7 @@ import {
23
23
  resolveStoreRoot,
24
24
  } from '../workspace/index.js'
25
25
 
26
- export interface CliWorkspace {
26
+ interface CliWorkspace {
27
27
  workspaceConfig: WorkspaceConfig | null
28
28
  plugins: readonly VxPlugin[]
29
29
  cacheDir: string
package/src/exec/env.ts CHANGED
@@ -125,11 +125,9 @@ export function packageManagerPath(root: string): string | null {
125
125
  if (known !== undefined) return known
126
126
  let name: string | undefined
127
127
  try {
128
- const field = (
129
- JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8')) as {
130
- packageManager?: unknown
131
- }
132
- ).packageManager
128
+ // A byte-order mark is stripped, as npm and discovery strip it.
129
+ const manifest = readFileSync(path.join(root, 'package.json'), 'utf8').replace(/^\uFEFF/, '')
130
+ const field = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
133
131
  if (typeof field === 'string') name = MANAGERS.find((m) => field.startsWith(`${m}@`))
134
132
  } catch {}
135
133
  name ??= LOCKFILES.find(([f]) => existsSync(path.join(root, f)))?.[1]