@vzn/vx 0.0.34 → 0.0.35

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.34",
3
+ "version": "0.0.35",
4
4
  "description": "An open, extensible monorepo task runner.",
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.34",
21
- "@vzn/vx-linux-arm64": "0.0.34",
22
- "@vzn/vx-darwin-x64": "0.0.34",
23
- "@vzn/vx-darwin-arm64": "0.0.34"
20
+ "@vzn/vx-linux-x64": "0.0.35",
21
+ "@vzn/vx-linux-arm64": "0.0.35",
22
+ "@vzn/vx-darwin-x64": "0.0.35",
23
+ "@vzn/vx-darwin-arm64": "0.0.35"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/select.ts CHANGED
@@ -202,7 +202,12 @@ export async function resolveFilters(
202
202
  ): Promise<FilterResolution> {
203
203
  const root = await findWorkspaceRoot(cwd)
204
204
  const projects = await loadWorkspaceProjects(cwd)
205
- const parsed = raw.map((r) => parseFilter(r, root))
205
+ let parsed: ReturnType<typeof parseFilter>[]
206
+ try {
207
+ parsed = raw.map((r) => parseFilter(r, root))
208
+ } catch (err) {
209
+ return { error: err instanceof Error ? err.message : String(err) }
210
+ }
206
211
  const walksGraph = parsed.some((f) => f.withDeps || f.withDependents || f.onlyDeps)
207
212
  // Every reader of the staged configs in this pass — the `pkg#task`
208
213
  // edge walk, the `workspaceFiles` owners of a changed path — shares
@@ -244,6 +249,7 @@ export async function resolveFilters(
244
249
  }
245
250
 
246
251
  const unmatched: string[] = []
252
+ const emptyWalks: string[] = []
247
253
  const selected = applyFilters({
248
254
  filters: parsed,
249
255
  projects,
@@ -255,6 +261,17 @@ export async function resolveFilters(
255
261
  onNoMatch: (f) => {
256
262
  if (f.gitSince === undefined) unmatched.push(f.raw)
257
263
  },
264
+ // A pattern that matched, with nothing on the walk it asked for: the
265
+ // fact to say, where "no projects matched" read as a typo (item 1030).
266
+ onEmptyWalk: (f, matched) => {
267
+ if (f.negate || f.gitSince !== undefined) return
268
+ const which = matched.join(', ')
269
+ emptyWalks.push(
270
+ f.onlyDeps
271
+ ? `filter "${f.raw}" matched ${which}, which depends on no project`
272
+ : `filter "${f.raw}" matched ${which}, and no project depends on it`,
273
+ )
274
+ },
258
275
  })
259
276
  if (selected.size === 0) {
260
277
  // "Nothing changed" is a legitimate outcome, not a typo — a docs-only
@@ -273,6 +290,9 @@ export async function resolveFilters(
273
290
  : ` — ${self.gitSince} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
274
291
  return { empty: `nothing affected since ${refs}${hint}` }
275
292
  }
293
+ if (unmatched.length === 0 && emptyWalks.length > 0) {
294
+ return { error: `no projects selected: ${emptyWalks.join('; ')}` }
295
+ }
276
296
  // One line, not a warning per pattern and then an error saying the same:
277
297
  // the patterns are in the error, and the nearest project name is the
278
298
  // hint a typo needs.
@@ -51,6 +51,7 @@ import {
51
51
  fingerprintClaims,
52
52
  hasHook,
53
53
  resolveCache,
54
+ teardownPlugins,
54
55
  } from './plugin-host.js'
55
56
  import { loadProjects, loadWorkspacePlugins, type LoadedProjects } from './projects.js'
56
57
  import { keyExcludedDependencies } from './excluded-keys.js'
@@ -335,33 +336,152 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
335
336
  // layer's own truthful answer; `LayeredCache` sets it, a bare `Cache`
336
337
  // doesn't, and a third-party layer opts in when it really has a remote.
337
338
  const hasRemoteLayer = cache.hasRemote === true
339
+ // From here the plugins' factories have run and the cache is open: a
340
+ // throw (a stage hook, a `^name` nobody declares, a cycle, an unknown
341
+ // exclude name) closed neither and tore nothing down, once per failed
342
+ // cycle under `vx watch` (item 1029). run() owns both only once this
343
+ // returns.
344
+ try {
345
+ const gitFilesCache = new GitFilesCache()
346
+ // Bulk-populate via a single `git ls-files` at the workspace root —
347
+ // partitions the output by project. Avoids one fork+exec per project
348
+ // (~5-10ms each on Linux; the dominant cold-start cost on big
349
+ // monorepos). When any loaded task declares inputs.workspaceFiles,
350
+ // the enumeration must see every file from the root (no pathspec
351
+ // scoping) and additionally stores a workspace-wide partition.
352
+ const usesWorkspaceInputs = [...projects.values()].some((p) =>
353
+ Object.values(p.config.tasks ?? {}).some(
354
+ (t) => (t.cache?.inputs.workspaceFiles?.length ?? 0) > 0,
355
+ ),
356
+ )
357
+ const projectDirs = [...projects.values()].map((p) => p.dir)
358
+ const enumeration = await (earlyGit ??
359
+ startGitEnumeration(
360
+ workspaceRoot,
361
+ gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
362
+ ))
363
+ applyGitEnumeration(enumeration, workspaceRoot, projectDirs, gitFilesCache, usesWorkspaceInputs)
364
+ mark('git enumeration')
365
+ const hashCache = createHashCache()
366
+ const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
338
367
 
339
- const gitFilesCache = new GitFilesCache()
340
- // Bulk-populate via a single `git ls-files` at the workspace root —
341
- // partitions the output by project. Avoids one fork+exec per project
342
- // (~5-10ms each on Linux; the dominant cold-start cost on big
343
- // monorepos). When any loaded task declares inputs.workspaceFiles,
344
- // the enumeration must see every file from the root (no pathspec
345
- // scoping) and additionally stores a workspace-wide partition.
346
- const usesWorkspaceInputs = [...projects.values()].some((p) =>
347
- Object.values(p.config.tasks ?? {}).some(
348
- (t) => (t.cache?.inputs.workspaceFiles?.length ?? 0) > 0,
349
- ),
350
- )
351
- const projectDirs = [...projects.values()].map((p) => p.dir)
352
- const enumeration = await (earlyGit ??
353
- startGitEnumeration(
354
- workspaceRoot,
355
- gitPathspecs(workspaceRoot, projectDirs, usesWorkspaceInputs),
356
- ))
357
- applyGitEnumeration(enumeration, workspaceRoot, projectDirs, gitFilesCache, usesWorkspaceInputs)
358
- mark('git enumeration')
359
- const hashCache = createHashCache()
360
- const fingerprintWatch = new FingerprintWatch(workspaceRoot, fingerprints, fingerprintsAt)
368
+ // Empty-cases bookkeeping. We still construct the cache + fingerprint
369
+ // so the caller's try/finally pattern can close it uniformly.
370
+ if (requested.length === 0) {
371
+ return {
372
+ workspaceRoot,
373
+ workspaceConfig,
374
+ plugins,
375
+ cacheDir,
376
+ cache,
377
+ localCache,
378
+ hasRemoteLayer,
379
+ priorities: new Map(),
380
+ nodes: new Map(),
381
+ unresolvedTasks,
382
+ projects,
383
+ anyProjectConfig: projectsWithConfigs.length > 0,
384
+ workspaceFingerprint,
385
+ fingerprintWatch,
386
+ nestedDirsByProject,
387
+ gitFilesCache,
388
+ hashCache,
389
+ workspaceProjectCount: projectMetas.length,
390
+ empty:
391
+ options.selectedByDiff === true && unresolvedTasks.length === 0
392
+ ? 'none-affected'
393
+ : 'no-tasks-declared',
394
+ }
395
+ }
396
+
397
+ // The whole graph, whatever `--exclude-dependencies` says: the stages
398
+ // below shape and key the tasks it drops as a full run would, because
399
+ // a dropped task is still keyed (excluded-keys.ts). A scoped load is not
400
+ // the whole workspace: a `^name` nothing loaded declares may be declared
401
+ // by a config the scope left out, so the builder hands it back instead of
402
+ // refusing it, and the rest decide.
403
+ const unproven: Array<[taskId: string, name: string]> = []
404
+ const nodes = buildTaskGraph({
405
+ projects,
406
+ packageGraph,
407
+ requested,
408
+ ...(projects.size < projectsWithConfigs.length
409
+ ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
410
+ : {}),
411
+ })
412
+ if (unproven.length > 0) {
413
+ await refuseUndeclaredDeps(unproven, () =>
414
+ loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
415
+ )
416
+ }
417
+ // An `--exclude-dependencies` name no project declares drops nothing:
418
+ // `=biuld` planned the whole chain and ran it, exit 0 (item 1026). Every
419
+ // other name the user types must resolve; so must this one. After the
420
+ // graph, so a config error (`^biuld` nobody declares) is named first.
421
+ const excludeNames = options.excludeDependencies
422
+ if (Array.isArray(excludeNames) && excludeNames.length > 0) {
423
+ const declared = new Set<string>()
424
+ for (const p of projects.values())
425
+ for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
426
+ let unknown = excludeNames.filter((n) => !declared.has(n))
427
+ if (unknown.length > 0 && projects.size < projectsWithConfigs.length) {
428
+ unknown = await declaredNowhere(unknown, () =>
429
+ loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
430
+ )
431
+ }
432
+ if (unknown.length > 0) {
433
+ cache.close()
434
+ const hints = new Set(unknown.flatMap((n) => nearest(n, declared) ?? []))
435
+ const hint = hints.size === 0 ? '' : ` Did you mean ${[...hints].join(', ')}?`
436
+ throw new UserError(
437
+ `--exclude-dependencies names a task no project declares: ${unknown.join(', ')}.${hint}`,
438
+ )
439
+ }
440
+ }
441
+ // The graph is built; what follows is the plugins' (graph, key,
442
+ // schedule). Two rows, so a plugin's key stage reads as its own cost
443
+ // and not as graph building — a lockfile plugin's 1000 stats per run
444
+ // hid inside one `prepare (graph)` row until 2026-09-10.
445
+ mark('build graph')
446
+ if (hasHook(plugins, 'graph')) {
447
+ await applyGraphHooks(plugins, nodes, {
448
+ workspaceRoot,
449
+ cacheDir,
450
+ warn: (m) => log.status(m),
451
+ requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
452
+ })
453
+ }
454
+ if (hasHook(plugins, 'key')) {
455
+ await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
456
+ }
457
+ const exclude = options.excludeDependencies
458
+ if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
459
+ const { keyOnly, dropped } = excludeDependencies(nodes, exclude)
460
+ if (dropped.size > 0) {
461
+ await keyExcludedDependencies({
462
+ nodes,
463
+ keyOnly,
464
+ dropped,
465
+ cache,
466
+ workspaceRoot,
467
+ workspaceFingerprint,
468
+ forwardArgs: options.forwardArgs,
469
+ nestedDirsByProject,
470
+ gitFilesCache,
471
+ hashCache,
472
+ })
473
+ }
474
+ }
475
+ let priorities: ReadonlyMap<string, number> = new Map()
476
+ if (hasHook(plugins, 'schedule')) {
477
+ priorities = await applyScheduleHooks(plugins, nodes, {
478
+ workspaceRoot,
479
+ cacheDir,
480
+ warn: (m) => log.status(m),
481
+ localCache,
482
+ })
483
+ }
361
484
 
362
- // Empty-cases bookkeeping. We still construct the cache + fingerprint
363
- // so the caller's try/finally pattern can close it uniformly.
364
- if (requested.length === 0) {
365
485
  return {
366
486
  workspaceRoot,
367
487
  workspaceConfig,
@@ -370,8 +490,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
370
490
  cache,
371
491
  localCache,
372
492
  hasRemoteLayer,
373
- priorities: new Map(),
374
- nodes: new Map(),
493
+ priorities,
494
+ nodes,
375
495
  unresolvedTasks,
376
496
  projects,
377
497
  anyProjectConfig: projectsWithConfigs.length > 0,
@@ -381,121 +501,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
381
501
  gitFilesCache,
382
502
  hashCache,
383
503
  workspaceProjectCount: projectMetas.length,
384
- empty:
385
- options.selectedByDiff === true && unresolvedTasks.length === 0
386
- ? 'none-affected'
387
- : 'no-tasks-declared',
388
- }
389
- }
390
-
391
- // The whole graph, whatever `--exclude-dependencies` says: the stages
392
- // below shape and key the tasks it drops as a full run would, because
393
- // a dropped task is still keyed (excluded-keys.ts). A scoped load is not
394
- // the whole workspace: a `^name` nothing loaded declares may be declared
395
- // by a config the scope left out, so the builder hands it back instead of
396
- // refusing it, and the rest decide.
397
- const unproven: Array<[taskId: string, name: string]> = []
398
- const nodes = buildTaskGraph({
399
- projects,
400
- packageGraph,
401
- requested,
402
- ...(projects.size < projectsWithConfigs.length
403
- ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
404
- : {}),
405
- })
406
- if (unproven.length > 0) {
407
- await refuseUndeclaredDeps(unproven, () =>
408
- loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
409
- )
410
- }
411
- // An `--exclude-dependencies` name no project declares drops nothing:
412
- // `=biuld` planned the whole chain and ran it, exit 0 (item 1026). Every
413
- // other name the user types must resolve; so must this one. After the
414
- // graph, so a config error (`^biuld` nobody declares) is named first.
415
- const excludeNames = options.excludeDependencies
416
- if (Array.isArray(excludeNames) && excludeNames.length > 0) {
417
- const declared = new Set<string>()
418
- for (const p of projects.values())
419
- for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
420
- let unknown = excludeNames.filter((n) => !declared.has(n))
421
- if (unknown.length > 0 && projects.size < projectsWithConfigs.length) {
422
- unknown = await declaredNowhere(unknown, () =>
423
- loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
424
- )
504
+ empty: nodes.size === 0 ? 'empty-graph' : null,
425
505
  }
426
- if (unknown.length > 0) {
427
- cache.close()
428
- const hints = new Set(unknown.flatMap((n) => nearest(n, declared) ?? []))
429
- const hint = hints.size === 0 ? '' : ` Did you mean ${[...hints].join(', ')}?`
430
- throw new UserError(
431
- `--exclude-dependencies names a task no project declares: ${unknown.join(', ')}.${hint}`,
432
- )
433
- }
434
- }
435
- // The graph is built; what follows is the plugins' (graph, key,
436
- // schedule). Two rows, so a plugin's key stage reads as its own cost
437
- // and not as graph building — a lockfile plugin's 1000 stats per run
438
- // hid inside one `prepare (graph)` row until 2026-09-10.
439
- mark('build graph')
440
- if (hasHook(plugins, 'graph')) {
441
- await applyGraphHooks(plugins, nodes, {
442
- workspaceRoot,
443
- cacheDir,
444
- warn: (m) => log.status(m),
445
- requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
446
- })
447
- }
448
- if (hasHook(plugins, 'key')) {
449
- await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
450
- }
451
- const exclude = options.excludeDependencies
452
- if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
453
- const { keyOnly, dropped } = excludeDependencies(nodes, exclude)
454
- if (dropped.size > 0) {
455
- await keyExcludedDependencies({
456
- nodes,
457
- keyOnly,
458
- dropped,
459
- cache,
460
- workspaceRoot,
461
- workspaceFingerprint,
462
- forwardArgs: options.forwardArgs,
463
- nestedDirsByProject,
464
- gitFilesCache,
465
- hashCache,
466
- })
467
- }
468
- }
469
- let priorities: ReadonlyMap<string, number> = new Map()
470
- if (hasHook(plugins, 'schedule')) {
471
- priorities = await applyScheduleHooks(plugins, nodes, {
472
- workspaceRoot,
473
- cacheDir,
474
- warn: (m) => log.status(m),
475
- localCache,
476
- })
477
- }
478
-
479
- return {
480
- workspaceRoot,
481
- workspaceConfig,
482
- plugins,
483
- cacheDir,
484
- cache,
485
- localCache,
486
- hasRemoteLayer,
487
- priorities,
488
- nodes,
489
- unresolvedTasks,
490
- projects,
491
- anyProjectConfig: projectsWithConfigs.length > 0,
492
- workspaceFingerprint,
493
- fingerprintWatch,
494
- nestedDirsByProject,
495
- gitFilesCache,
496
- hashCache,
497
- workspaceProjectCount: projectMetas.length,
498
- empty: nodes.size === 0 ? 'empty-graph' : null,
506
+ } catch (err) {
507
+ await teardownPlugins(plugins, (m) => log.status(m))
508
+ cache.close()
509
+ throw err
499
510
  }
500
511
  }
501
512
 
@@ -17,7 +17,7 @@
17
17
  // given, the base set is "all projects" and excluded packages are removed.
18
18
 
19
19
  import path from 'node:path'
20
- import { BUN_GLOB_WILDCARDS } from '../util/index.js'
20
+ import { BUN_GLOB_WILDCARDS, UserError } from '../util/index.js'
21
21
  import type { PackageGraph } from './package-graph.js'
22
22
  import type { ProjectMeta } from './workspace.js'
23
23
 
@@ -82,6 +82,12 @@ export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
82
82
  }
83
83
  }
84
84
 
85
+ // `...`, `!`, `^...`: the operators with no project between them. An
86
+ // empty name glob matched nothing and was hinted "Did you mean a?", and
87
+ // an empty exclude (`!$UNSET`) excluded nothing, so every project ran
88
+ // with one warning line (item 1030).
89
+ if (s === '') throw new UserError(`filter "${raw}" names no project`)
90
+
85
91
  let isPath = false
86
92
  let matcher = s
87
93
  let pathGlob: Bun.Glob | undefined
@@ -191,6 +197,12 @@ export interface ApplyFiltersOptions {
191
197
  * several filters silently under-selects.
192
198
  */
193
199
  onNoMatch?: (filter: ParsedFilter) => void
200
+ /**
201
+ * Called once per filter whose pattern matched projects but whose walk
202
+ * selected none (`...^c` for a `c` nothing depends on), with what it
203
+ * matched. Reported as a no-match, it read as a typo (item 1030).
204
+ */
205
+ onEmptyWalk?: (filter: ParsedFilter, matched: readonly string[]) => void
194
206
  }
195
207
 
196
208
  export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
@@ -216,6 +228,7 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
216
228
  for (const d of opts.graph.transitiveDependents(name)) expanded.add(d)
217
229
  }
218
230
  }
231
+ if (matched.length > 0 && expanded.size === 0) opts.onEmptyWalk?.(f, matched)
219
232
  if (f.negate) excludes.push(expanded)
220
233
  else for (const name of expanded) selected.add(name)
221
234
  }