@vzn/vx 0.0.612 → 0.0.614

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.612",
3
+ "version": "0.0.614",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.612",
25
- "@vzn/vx-linux-arm64": "0.0.612",
26
- "@vzn/vx-darwin-x64": "0.0.612",
27
- "@vzn/vx-darwin-arm64": "0.0.612"
24
+ "@vzn/vx-linux-x64": "0.0.614",
25
+ "@vzn/vx-linux-arm64": "0.0.614",
26
+ "@vzn/vx-darwin-x64": "0.0.614",
27
+ "@vzn/vx-darwin-arm64": "0.0.614"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/help.ts CHANGED
@@ -210,7 +210,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
210
210
  " vx show <pkg>#<task> Print a single task's resolved config.",
211
211
  ' vx show <task> That task in every project declaring it.',
212
212
  " --filter <pattern> The list (or a task's projects) as `vx run --filter` selects.",
213
- ' --affected[=<ref>] Only what `vx run --affected` selects: changed and dependents.',
213
+ " --affected[=<ref>] A task's projects `vx run <task> --affected` runs; no task:",
214
+ ' the changed projects and their dependents.',
214
215
  ' --format <fmt> pretty (default) | json.',
215
216
  ' vx info Workspace doctor: vx/bun/git versions, project +',
216
217
  ' task counts, cache dir/entries/size, recent runs,',
package/src/cli/select.ts CHANGED
@@ -360,6 +360,10 @@ export async function resolveFilters(
360
360
 
361
361
  const unmatched: string[] = []
362
362
  const emptyWalks: string[] = []
363
+ // The `[<since>]` includes whose diff itself matched nothing: only then is
364
+ // "nothing affected" the fact. A diff that matched, then lost every
365
+ // project to an exclusion or an empty walk, says which (X-146).
366
+ const emptyDiffs = new Set<(typeof parsed)[number]>()
363
367
  const selected = applyFilters({
364
368
  filters: parsed,
365
369
  projects,
@@ -371,11 +375,12 @@ export async function resolveFilters(
371
375
  // matched nothing is worth flagging as a probable typo.
372
376
  onNoMatch: (f) => {
373
377
  if (f.gitSince === undefined) unmatched.push(f.raw)
378
+ else emptyDiffs.add(f)
374
379
  },
375
380
  // A pattern that matched, with nothing on the walk it asked for: the
376
381
  // fact to say, where "no projects matched" read as a typo (item 1030).
377
382
  onEmptyWalk: (f, matched) => {
378
- if (f.negate || f.gitSince !== undefined) return
383
+ if (f.negate) return
379
384
  const which = matched.join(', ')
380
385
  emptyWalks.push(
381
386
  f.onlyDeps
@@ -389,28 +394,35 @@ export async function resolveFilters(
389
394
  // commit must not red `vx run … --affected=origin/main`. Only report an
390
395
  // error when the user named something concrete that failed to resolve.
391
396
  const includes = parsed.filter((f) => !f.negate)
397
+ // Every pattern matched and an exclusion took them all back: "no
398
+ // projects matched filter(s): one, !one" read as a typo of `one`.
399
+ const negations = parsed.filter((f) => f.negate).map((f) => f.raw)
400
+ const excluded = `${negations.join(', ')} excluded every project the other filters matched`
392
401
  if (includes.length > 0 && includes.every((f) => f.gitSince !== undefined)) {
393
- const refs = includes.map((f) => f.gitSince).join(', ')
394
- // A base that IS HEAD (a single-branch clone whose `origin/HEAD` is
395
- // the branch under test) can never mark anything affected, and this
396
- // exit-0 note was the only sign (CI persona, 2026-09-16). Say so.
397
- const self = includes.find((f) => refIsHead(root, f.gitSince!))
398
- const hint =
399
- self === undefined
400
- ? ''
401
- : ` — ${self.gitSince} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
402
- return { empty: `nothing affected since ${refs}${hint}` }
402
+ if (includes.every((f) => emptyDiffs.has(f))) {
403
+ const refs = includes.map((f) => f.gitSince).join(', ')
404
+ // A base that IS HEAD (a single-branch clone whose `origin/HEAD` is
405
+ // the branch under test) can never mark anything affected, and this
406
+ // exit-0 note was the only sign (CI persona, 2026-09-16). Say so.
407
+ const self = includes.find((f) => refIsHead(root, f.gitSince!))
408
+ const hint =
409
+ self === undefined
410
+ ? ''
411
+ : ` — ${self.gitSince} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
412
+ return { empty: `nothing affected since ${refs}${hint}` }
413
+ }
414
+ // The diff matched and a walk or an exclusion left nothing: still the
415
+ // exit-0 outcome (`--affected --filter '!docs'` on a docs-only change
416
+ // must not red CI), said as what happened (X-146).
417
+ return {
418
+ empty: `no projects selected: ${emptyWalks.length > 0 ? emptyWalks.join('; ') : excluded}`,
419
+ }
403
420
  }
404
421
  if (unmatched.length === 0 && emptyWalks.length > 0) {
405
422
  return { error: `no projects selected: ${emptyWalks.join('; ')}` }
406
423
  }
407
- // Every pattern matched and an exclusion took them all back: "no
408
- // projects matched filter(s): one, !one" read as a typo of `one`.
409
- const negations = parsed.filter((f) => f.negate).map((f) => f.raw)
410
424
  if (unmatched.length === 0 && negations.length > 0) {
411
- return {
412
- error: `no projects selected: ${negations.join(', ')} excluded every project the other filters matched`,
413
- }
425
+ return { error: `no projects selected: ${excluded}` }
414
426
  }
415
427
  // One line, not a warning per pattern and then an error saying the same:
416
428
  // the patterns are in the error, and the nearest project name is the
package/src/cli/show.ts CHANGED
@@ -9,12 +9,19 @@
9
9
  import path from 'node:path'
10
10
  import type { ProjectConfig, TaskConfig } from '../config.js'
11
11
  import { declaredTask } from '../graph/index.js'
12
- import { isDefaultBuild } from '../orchestrator/index.js'
12
+ import { affectedTaskProjects, isDefaultBuild } from '../orchestrator/index.js'
13
13
  import { flagHint, formatValue, seeHelp } from './help.js'
14
14
  import { listed, nearMatches, relPosix, secretMask, UserError } from '../util/index.js'
15
15
  import { affectedFilterFor, resolveFilters } from './select.js'
16
- import { discoverCliProjects, loadCliProjects } from './workspace-config.js'
17
16
  import {
17
+ discoverCliProjects,
18
+ loadCliProjects,
19
+ loadCliWorkspace,
20
+ warnToStderr,
21
+ } from './workspace-config.js'
22
+ import {
23
+ type AffectedChanges,
24
+ buildPackageGraph,
18
25
  findWorkspaceRoot,
19
26
  type LoadReads,
20
27
  loadWorkspace,
@@ -124,6 +131,9 @@ export async function showCmd(args: readonly string[]): Promise<number> {
124
131
  // `turbo ls --filter` / `nx show projects --affected`: the list, and a
125
132
  // task's projects, narrowed by the selection `vx run` would make.
126
133
  let selected: Set<string> | undefined
134
+ // A task's projects under `--affected` are the ones whose task the run
135
+ // keeps: the change reaches another project only along a task edge.
136
+ let byDiff: { changes: AffectedChanges; outright?: readonly string[] | undefined } | undefined
127
137
  if (parsed.filters.length > 0 || parsed.affected !== undefined) {
128
138
  if (scope !== 'all') {
129
139
  throw new UserError(
@@ -142,6 +152,8 @@ export async function showCmd(args: readonly string[]): Promise<number> {
142
152
  if ('error' in r) throw new UserError(`vx show: ${r.error}`)
143
153
  if ('empty' in r) process.stderr.write(`vx show: ${r.empty}\n`)
144
154
  selected = new Set('empty' in r ? [] : r.names)
155
+ if ('names' in r && r.affected !== undefined)
156
+ byDiff = { changes: r.affected, outright: r.outright }
145
157
  }
146
158
  const inSelection = (name: string): boolean => selected === undefined || selected.has(name)
147
159
 
@@ -171,7 +183,27 @@ export async function showCmd(args: readonly string[]): Promise<number> {
171
183
  `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`,
172
184
  )
173
185
  }
174
- const shown = declaring.filter((p) => inSelection(p.name))
186
+ let shown = declaring.filter((p) => inSelection(p.name))
187
+ if (byDiff !== undefined) {
188
+ const ws = await loadCliWorkspace(root)
189
+ const kept = new Set(
190
+ await affectedTaskProjects({
191
+ task: projectName!,
192
+ candidates: shown.map((p) => p.name),
193
+ changes: byDiff.changes,
194
+ outright: byDiff.outright,
195
+ projects,
196
+ packageGraph: buildPackageGraph(metas),
197
+ projectMetas: metas,
198
+ plugins: ws.plugins,
199
+ workspaceRoot: root,
200
+ cacheDir: ws.cacheDir,
201
+ rules: ws.workspaceConfig?.rules,
202
+ warn: warnToStderr,
203
+ }),
204
+ )
205
+ shown = shown.filter((p) => kept.has(p.name))
206
+ }
175
207
  process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format))
176
208
  return 0
177
209
  }
@@ -4,9 +4,12 @@
4
4
  // behind `^build` runs when `ui#build` (or what it depends on) is reached,
5
5
  // and a spec edit `ui#build`'s inputs leave out stops at `ui`'s own tasks.
6
6
 
7
+ import type { WorkspaceRules } from '../config.js'
7
8
  import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
8
9
  import {
10
+ buildTaskGraph,
9
11
  compileTaskPattern,
12
+ expandRequested,
10
13
  isGroupTask,
11
14
  isTaskPattern,
12
15
  parseDependencySpec,
@@ -17,7 +20,12 @@ import {
17
20
  type PackageGraph,
18
21
  PROJECT_CONFIG_FILENAMES,
19
22
  type ProjectEntry,
23
+ type ProjectMeta,
20
24
  } from '../workspace/index.js'
25
+ import { applyGraphStage } from './graph-stage.js'
26
+ import { hasHook } from './plugin-host.js'
27
+ import type { VxPlugin } from './plugin.js'
28
+ import { isDefaultBuild } from './projects.js'
21
29
 
22
30
  /** Every file in this set re-keys each of its project's tasks, whatever their inputs. */
23
31
  const KEYED_BY_EVERY_TASK = new Set(['package.json', ...PROJECT_CONFIG_FILENAMES])
@@ -146,3 +154,60 @@ export function affectedRoots(
146
154
  }
147
155
  return ids.filter(reaches)
148
156
  }
157
+
158
+ /**
159
+ * The requested tasks `--affected` keeps, in their order: one the user
160
+ * named (`pkg#task`), one in a project another include selected outright
161
+ * (X-10), and one whose closure the change reaches.
162
+ */
163
+ export function keptByAffected<R extends { project: string; task: string }>(
164
+ nodes: ReadonlyMap<string, TaskNode>,
165
+ requested: readonly R[],
166
+ changes: AffectedChanges,
167
+ projects: ReadonlyMap<string, ProjectEntry>,
168
+ packageGraph: PackageGraph,
169
+ keep: { named?: ReadonlySet<string>; outright?: ReadonlySet<string> } = {},
170
+ ): R[] {
171
+ const ids = requested.map((r) => `${r.project}#${r.task}`)
172
+ const reached = new Set(affectedRoots(nodes, ids, changes, projects, packageGraph))
173
+ return requested.filter(
174
+ (r, i) =>
175
+ keep.named?.has(ids[i]!) === true ||
176
+ keep.outright?.has(r.project) === true ||
177
+ reached.has(ids[i]!),
178
+ )
179
+ }
180
+
181
+ /**
182
+ * The projects whose `task` a `vx run <task> --affected` over `candidates`
183
+ * keeps (`vx show <task> --affected`): the run's graph, `graph` stage and
184
+ * selection, without opening a cache or keying a task.
185
+ */
186
+ export async function affectedTaskProjects(args: {
187
+ task: string
188
+ candidates: readonly string[]
189
+ changes: AffectedChanges
190
+ outright?: readonly string[] | undefined
191
+ projects: Map<string, ProjectEntry>
192
+ packageGraph: PackageGraph
193
+ projectMetas: readonly ProjectMeta[]
194
+ plugins: readonly VxPlugin[]
195
+ workspaceRoot: string
196
+ cacheDir: string
197
+ rules?: WorkspaceRules | undefined
198
+ warn: (message: string) => void
199
+ }): Promise<string[]> {
200
+ const requested = expandRequested([args.task], args.candidates, args.projects, isDefaultBuild)
201
+ const nodes = buildTaskGraph({
202
+ projects: args.projects,
203
+ packageGraph: args.packageGraph,
204
+ requested,
205
+ workspaceRoot: args.workspaceRoot,
206
+ rules: args.rules,
207
+ })
208
+ if (hasHook(args.plugins, 'graph')) await applyGraphStage(args.plugins, nodes, args)
209
+ const kept = keptByAffected(nodes, requested, args.changes, args.projects, args.packageGraph, {
210
+ outright: new Set(args.outright),
211
+ })
212
+ return kept.map((r) => r.project)
213
+ }
@@ -0,0 +1,48 @@
1
+ // The `graph` stage as a run applies it, shared by `prepareRun` and the
2
+ // CLI's `--affected` task selection (`vx show <task> --affected`), so both
3
+ // select over the graph the hooks leave.
4
+
5
+ import type { WorkspaceRules } from '../config.js'
6
+ import type { TaskNode } from '../graph/index.js'
7
+ import { type ProjectMeta, validateProjectConfig } from '../workspace/index.js'
8
+ import { applyGraphHooks } from './plugin-host.js'
9
+ import type { VxPlugin } from './plugin.js'
10
+ import { isDefaultBuild } from './projects.js'
11
+
12
+ export async function applyGraphStage(
13
+ plugins: readonly VxPlugin[],
14
+ nodes: Map<string, TaskNode>,
15
+ args: {
16
+ workspaceRoot: string
17
+ cacheDir: string
18
+ projectMetas: readonly ProjectMeta[]
19
+ warn: (message: string) => void
20
+ rules?: WorkspaceRules | undefined
21
+ },
22
+ ): Promise<void> {
23
+ // A hook edits each node's config in place, as `project` does, so the
24
+ // edit is held to what the loader accepts from a user: a misspelled
25
+ // `exec` field ran as an empty command and failed with no reason.
26
+ const configPaths = new Map(args.projectMetas.map((m) => [m.name, m.configPath]))
27
+ await applyGraphHooks(
28
+ plugins,
29
+ nodes,
30
+ {
31
+ workspaceRoot: args.workspaceRoot,
32
+ cacheDir: args.cacheDir,
33
+ warn: args.warn,
34
+ requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
35
+ },
36
+ (plugin) => {
37
+ for (const n of nodes.values()) {
38
+ if (isDefaultBuild(n.config)) continue
39
+ const where = configPaths.get(n.projectName) ?? `${n.projectName} (no config file)`
40
+ validateProjectConfig(
41
+ { tasks: { [n.taskName]: n.config } },
42
+ `${where} (after plugin '${plugin.name}')`,
43
+ )
44
+ }
45
+ },
46
+ args.rules,
47
+ )
48
+ }
@@ -3,6 +3,7 @@
3
3
 
4
4
  export { run, planRun, nxProjectTarget, projectNamed } from './run.js'
5
5
  export { prepareRun, type PreparedRun } from './prepare.js'
6
+ export { affectedTaskProjects } from './affected-tasks.js'
6
7
  export {
7
8
  discoverProjects,
8
9
  gitOfDiscovery,
@@ -34,7 +34,16 @@ function describeValue(v: unknown): string {
34
34
  if (v === null) return 'null'
35
35
  if (Array.isArray(v)) return 'an array'
36
36
  const t = typeof v
37
- return t === 'object' ? 'an object' : `a ${t}`
37
+ if (t !== 'object') return `a ${t}`
38
+ const proto: unknown = Object.getPrototypeOf(v)
39
+ if (proto === Object.prototype || proto === null) return 'an object'
40
+ return `a ${(v as object).constructor?.name || 'class instance'}`
41
+ }
42
+
43
+ function isRecord(v: unknown): v is Record<string, unknown> {
44
+ if (typeof v !== 'object' || v === null) return false
45
+ const proto: unknown = Object.getPrototypeOf(v)
46
+ return proto === Object.prototype || proto === null
38
47
  }
39
48
 
40
49
  /** Every method `CacheLayer` requires (the optional ones are probed with `?.`). */
@@ -197,7 +206,8 @@ export async function applyProjectHooks(
197
206
  * against the LAST plugin that ran: usually the one whose edit made it so,
198
207
  * but an earlier plugin's edit that a later one left in place is blamed on
199
208
  * the later one, since the structure is not checked between plugins.
200
- * Each node's task config is (`afterEach`), like the `project` stage's.
209
+ * Each node's task config is re-validated after EACH plugin (`afterEach`),
210
+ * like the `project` stage's.
201
211
  */
202
212
  export async function applyGraphHooks(
203
213
  plugins: readonly VxPlugin[],
@@ -271,8 +281,10 @@ export async function applyKeyHooks(
271
281
  if (material === undefined) continue
272
282
  // `Object.entries` over a string yields its characters as string
273
283
  // values, so a plugin returning `'v22'` used to fold parts named
274
- // '0', '1', '2' into every key — silently, and permanently.
275
- if (typeof material !== 'object' || material === null || Array.isArray(material)) {
284
+ // '0', '1', '2' into every key — silently, and permanently. A Map or a
285
+ // class instance keeps its data off own enumerable keys, so
286
+ // `Object.entries` read it as no material at all: a silent miskey.
287
+ if (!isRecord(material)) {
276
288
  throw new UserError(
277
289
  `plugin '${plugin.name}' failed in key: returned ${describeValue(material)}, not a record of string values`,
278
290
  )
@@ -39,7 +39,6 @@ import {
39
39
  resolveCacheDir,
40
40
  resolveStoreRoot,
41
41
  type ProjectEntry,
42
- validateProjectConfig,
43
42
  } from '../workspace/index.js'
44
43
  import {
45
44
  buildTaskGraph,
@@ -51,7 +50,6 @@ import {
51
50
  unresolvedRequests,
52
51
  } from '../graph/index.js'
53
52
  import {
54
- applyGraphHooks,
55
53
  applyKeyHooks,
56
54
  applyScheduleHooks,
57
55
  fingerprintClaims,
@@ -68,7 +66,8 @@ import {
68
66
  loadWorkspacePlugins,
69
67
  type LoadedProjects,
70
68
  } from './projects.js'
71
- import { affectedRoots } from './affected-tasks.js'
69
+ import { keptByAffected } from './affected-tasks.js'
70
+ import { applyGraphStage } from './graph-stage.js'
72
71
  import { keyExcludedDependencies } from './excluded-keys.js'
73
72
  import { FingerprintWatch } from './fingerprint-watch.js'
74
73
  import type { VxPlugin } from './plugin.js'
@@ -527,33 +526,14 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
527
526
  })
528
527
  let nodes = graphOf(requested)
529
528
  const hasGraphHook = hasHook(plugins, 'graph')
530
- const graphStage = async (): Promise<void> => {
531
- // A hook edits each node's config in place, as `project` does, so the
532
- // edit is held to what the loader accepts from a user: a misspelled
533
- // `exec` field ran as an empty command and failed with no reason.
534
- const configPaths = new Map(projectMetas.map((m) => [m.name, m.configPath]))
535
- await applyGraphHooks(
536
- plugins,
537
- nodes,
538
- {
539
- workspaceRoot,
540
- cacheDir,
541
- warn: (m) => log.status(m),
542
- requested: [...nodes.values()].filter((n) => n.requested).map((n) => n.id),
543
- },
544
- (plugin) => {
545
- for (const n of nodes.values()) {
546
- if (isDefaultBuild(n.config)) continue
547
- const where = configPaths.get(n.projectName) ?? `${n.projectName} (no config file)`
548
- validateProjectConfig(
549
- { tasks: { [n.taskName]: n.config } },
550
- `${where} (after plugin '${plugin.name}')`,
551
- )
552
- }
553
- },
554
- workspaceConfig?.rules,
555
- )
556
- }
529
+ const graphStage = (): Promise<void> =>
530
+ applyGraphStage(plugins, nodes, {
531
+ workspaceRoot,
532
+ cacheDir,
533
+ projectMetas,
534
+ warn: (m) => log.status(m),
535
+ rules: workspaceConfig?.rules,
536
+ })
557
537
  // `--affected` selects over the FINAL graph: an edge or an input a
558
538
  // `graph` hook adds moves a key, so the hooks run first, over every
559
539
  // candidate, and the selection prunes what they left.
@@ -563,16 +543,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
563
543
  if (options.affected !== undefined) {
564
544
  const before = hooksFirst ? new Set(nodes.keys()) : null
565
545
  if (hooksFirst) await graphStage()
566
- const named = new Set(tasks.filter((t) => t.includes('#')))
567
- const outright = new Set(options.selectedOutright)
568
- const ids = requested.map((r) => `${r.project}#${r.task}`)
569
- const reached = new Set(affectedRoots(nodes, ids, options.affected, projects, packageGraph))
570
- const kept = requested.filter(
571
- (r, i) => named.has(ids[i]!) || outright.has(r.project) || reached.has(ids[i]!),
572
- )
546
+ const kept = keptByAffected(nodes, requested, options.affected, projects, packageGraph, {
547
+ named: new Set(tasks.filter((t) => t.includes('#'))),
548
+ outright: new Set(options.selectedOutright),
549
+ })
573
550
  if (kept.length === 0) return emptyRun('none-affected')
574
551
  if (before !== null) {
575
- const asked = new Set(ids)
552
+ const asked = new Set(requested.map((r) => `${r.project}#${r.task}`))
576
553
  // What a hook added or asked for is the plugin's choice, kept as it
577
554
  // would be on a run without the diff.
578
555
  const roots = [
@@ -254,12 +254,16 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
254
254
  }
255
255
  for (const cap of PLUGIN_FUNCTION_HOOKS) {
256
256
  if (plug[cap] !== undefined && typeof plug[cap] !== 'function') {
257
- throw new UserError(`${configPath}: \`plugins[${i}].${cap}\` must be a function`)
257
+ throw new UserError(
258
+ `${configPath}: \`plugins[${i}].${cap}\` of plugin '${pkg}' must be a function`,
259
+ )
258
260
  }
259
261
  }
260
262
  if (plug.commands !== undefined) {
261
263
  if (plug.commands === null || typeof plug.commands !== 'object') {
262
- throw new UserError(`${configPath}: \`plugins[${i}].commands\` must be an object`)
264
+ throw new UserError(
265
+ `${configPath}: \`plugins[${i}].commands\` of plugin '${pkg}' must be an object`,
266
+ )
263
267
  }
264
268
  // A list of commands loaded as the verbs `0`, `1`, … that help listed.
265
269
  if (Array.isArray(plug.commands)) {
@@ -276,7 +280,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
276
280
  typeof c.description !== 'string'
277
281
  ) {
278
282
  throw new UserError(
279
- `${configPath}: \`plugins[${i}].commands.${verb}\` must be { description: string, run: function }`,
283
+ `${configPath}: \`plugins[${i}].commands.${verb}\` of plugin '${pkg}' must be { description: string, run: function }`,
280
284
  )
281
285
  }
282
286
  // A verb the dispatcher would never reach is refused, not left
@@ -287,10 +291,12 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
287
291
  // package: two plugins one package exports (`bun()` and `pnpm()`
288
292
  // both carry `prune`) are one owner, and the first declared runs.
289
293
  // `vx --version` and `vx ''` never reach a plugin: a flag is
290
- // core's, and an empty word is no verb (X-21).
291
- if (verb.trim() === '' || verb.startsWith('-')) {
294
+ // core's, and an empty word is no verb (X-21). `vx completions`
295
+ // writes verbs unquoted into a shell script, so a space or a `$(`
296
+ // in one would break or inject into the user's rc.
297
+ if (!/^[A-Za-z0-9][A-Za-z0-9:._-]*$/.test(verb)) {
292
298
  throw new UserError(
293
- `${configPath}: plugin '${plug.name}' declares command '${verb}', which no command line reaches — a verb is a word, not a flag or empty`,
299
+ `${configPath}: plugin '${plug.name}' declares command '${verb}', which is not a word — a verb is a letter or digit, then letters, digits, ':', '.', '_' or '-'`,
294
300
  )
295
301
  }
296
302
  if ((CORE_VERBS as readonly string[]).includes(verb)) {
@@ -317,7 +323,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
317
323
  typeof claim.affected !== 'function'
318
324
  ) {
319
325
  throw new UserError(
320
- `${configPath}: \`plugins[${i}].fingerprint\` must be { files: [name, …], affected: function }`,
326
+ `${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function }`,
321
327
  )
322
328
  }
323
329
  for (const file of claim.files as unknown[]) {
@@ -357,7 +363,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
357
363
  // — an empty `{ name }` object is a no-op authoring mistake.
358
364
  if (PLUGIN_HOOKS.every((hook) => plug[hook] === undefined)) {
359
365
  throw new UserError(
360
- `${configPath}: \`plugins[${i}]\` must contribute at least one of ${PLUGIN_HOOKS.join('/')}`,
366
+ `${configPath}: \`plugins[${i}]\` (plugin '${pkg}') must contribute at least one of ${PLUGIN_HOOKS.join('/')}`,
361
367
  )
362
368
  }
363
369
  }