@vzn/vx 0.0.28 → 0.0.30

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.28",
3
+ "version": "0.0.30",
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.28",
21
- "@vzn/vx-linux-arm64": "0.0.28",
22
- "@vzn/vx-darwin-x64": "0.0.28",
23
- "@vzn/vx-darwin-arm64": "0.0.28"
20
+ "@vzn/vx-linux-x64": "0.0.30",
21
+ "@vzn/vx-linux-arm64": "0.0.30",
22
+ "@vzn/vx-darwin-x64": "0.0.30",
23
+ "@vzn/vx-darwin-arm64": "0.0.30"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/run.ts CHANGED
@@ -461,6 +461,7 @@ export async function resolveRunOptions(
461
461
  const anchoredTasks = tasks.filter((t) => t.includes('#'))
462
462
  let projects: string[] | undefined
463
463
  let staged: RunOptions['staged']
464
+ let selectedByDiff = false
464
465
  if (bareTasks.length === 0) {
465
466
  projects = undefined
466
467
  } else if (filterStrings.length > 0) {
@@ -480,6 +481,7 @@ export async function resolveRunOptions(
480
481
  projects = []
481
482
  } else {
482
483
  projects = resolved.names
484
+ selectedByDiff = resolved.byDiff
483
485
  // The graph walk staged every config; the run reuses those entries
484
486
  // instead of evaluating and staging them a second time.
485
487
  staged = resolved.staged
@@ -515,6 +517,7 @@ export async function resolveRunOptions(
515
517
  opts.excludeDependencies = parsed.excludeDependencies
516
518
  }
517
519
  if (projects !== undefined) opts.projects = projects
520
+ if (selectedByDiff) opts.selectedByDiff = true
518
521
  if (staged !== undefined) opts.staged = staged
519
522
  if (parsed.retries !== undefined) opts.retries = parsed.retries
520
523
  if (parsed.timeout !== undefined) opts.timeout = parsed.timeout
@@ -582,6 +585,10 @@ export async function runCmd(args: readonly string[]): Promise<number> {
582
585
  return 1
583
586
  }
584
587
  if (plan.tasks.length === 0) {
588
+ if (opts.selectedByDiff === true) {
589
+ process.stderr.write(`vx run: no affected project declares task(s): ${tasks.join(', ')}.\n`)
590
+ return 0
591
+ }
585
592
  process.stderr.write(`vx run: no projects declare task(s): ${tasks.join(', ')}.\n`)
586
593
  return 1
587
594
  }
package/src/cli/select.ts CHANGED
@@ -127,6 +127,8 @@ export async function findCwdProject(cwd: string): Promise<string | null> {
127
127
  export type FilterResolution =
128
128
  | {
129
129
  names: string[]
130
+ /** An include filter was a git diff (`[ref]`, `--affected`): `RunOptions.selectedByDiff`. */
131
+ byDiff: boolean
130
132
  /** The staged load the graph walk needed, for the run to reuse (`RunOptions.staged`). */
131
133
  staged?: ReadonlyMap<string, ProjectEntry>
132
134
  }
@@ -269,7 +271,11 @@ export async function resolveFilters(
269
271
  staged = undefined
270
272
  }
271
273
  }
272
- return { names: [...selected].sort(), ...(staged !== undefined ? { staged } : {}) }
274
+ return {
275
+ names: [...selected].sort(),
276
+ byDiff: parsed.some((f) => !f.negate && f.gitSince !== undefined),
277
+ ...(staged !== undefined ? { staged } : {}),
278
+ }
273
279
  }
274
280
 
275
281
  export interface PickedTask {
@@ -20,6 +20,15 @@ export interface RunOptions {
20
20
  */
21
21
  tasks: readonly string[]
22
22
  projects?: string[]
23
+ /**
24
+ * `projects` came from a git diff (`--affected`, a `[ref]` filter), so
25
+ * which projects hold a bare task name depends on what changed: a name
26
+ * the changed projects do not declare is not a typo when the workspace
27
+ * declares it elsewhere, and a run with nothing left to do is clean.
28
+ * Judged against the scope, a docs-only commit turned
29
+ * `vx run test --affected` red (item 1024).
30
+ */
31
+ selectedByDiff?: boolean
23
32
  /**
24
33
  * Configs a selection pass in this same process already loaded and
25
34
  * staged with the same `cacheDir` and `frozen` (the CLI walks every
@@ -277,7 +277,10 @@ export async function applyScheduleHooks(
277
277
  * `admit` stage, built once per run into the predicate the scheduler asks
278
278
  * at every local dispatch: every plugin that answers must admit. A plugin
279
279
  * that throws is reported once and admits from then on — a policy never
280
- * breaks a run — so the predicate is never the reason a task hangs.
280
+ * breaks a run — and one that refuses while nothing local is running is
281
+ * overridden with a word, since only a completion asks again: that
282
+ * refusal stalled the run for good (item 1023). So the predicate is never
283
+ * the reason a task hangs.
281
284
  * Undefined when no plugin answers, which keeps the scheduler on its
282
285
  * count-only path.
283
286
  */
@@ -290,6 +293,7 @@ export function buildAdmission(
290
293
  const answering = plugins.filter((p) => p.admit !== undefined)
291
294
  if (answering.length === 0) return undefined
292
295
  const broken = new Set<VxPlugin>()
296
+ const overridden = new Set<VxPlugin>()
293
297
  return (id, running) => {
294
298
  // The scheduler asks only about ids in this same map, so the lookup
295
299
  // always lands: an unknown-id arm here was deleted with the whole core
@@ -302,7 +306,14 @@ export function buildAdmission(
302
306
  for (const plugin of answering) {
303
307
  if (broken.has(plugin)) continue
304
308
  try {
305
- if (plugin.admit!(task, ctx) === false) return false
309
+ if (plugin.admit!(task, ctx) !== false) continue
310
+ if (running.size > 0) return false
311
+ if (!overridden.has(plugin)) {
312
+ overridden.add(plugin)
313
+ warn(
314
+ `plugin '${plugin.name}' refused ${id} in admit with nothing running; admitting it, since no completion would ask again`,
315
+ )
316
+ }
306
317
  } catch (err) {
307
318
  broken.add(plugin)
308
319
  const m = err instanceof Error ? err.message : String(err)
@@ -131,11 +131,14 @@ export interface PreparedRun {
131
131
  * user's task names were resolved against `projects`. Typically
132
132
  * a typo'd task name; `run()` treats this as a CI footgun and
133
133
  * returns NOT-ok.
134
+ * - `'none-affected'` — as `'no-tasks-declared'`, but the scope
135
+ * came from a diff and every name is declared somewhere else in the
136
+ * workspace: nothing changed that runs them, a clean outcome.
134
137
  * - `'empty-graph'` — `requested` was non-empty but the
135
138
  * graph builder still produced no nodes. Defensive; unreachable
136
139
  * under current `buildTaskGraph` semantics.
137
140
  */
138
- empty: null | 'no-tasks-declared' | 'empty-graph'
141
+ empty: null | 'no-tasks-declared' | 'none-affected' | 'empty-graph'
139
142
  }
140
143
 
141
144
  /**
@@ -286,7 +289,16 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
286
289
  : [...projects.keys()]
287
290
 
288
291
  const requested = expandRequested(options.tasks, candidateProjects, projects)
289
- const unresolvedTasks = unresolvedRequests(options.tasks, candidateProjects, projects)
292
+ let unresolvedTasks = unresolvedRequests(options.tasks, candidateProjects, projects)
293
+ if (
294
+ options.selectedByDiff === true &&
295
+ projects.size < projectsWithConfigs.length &&
296
+ unresolvedTasks.some((t) => !t.includes('#'))
297
+ ) {
298
+ unresolvedTasks = await declaredNowhere(unresolvedTasks, () =>
299
+ loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
300
+ )
301
+ }
290
302
 
291
303
  // Cache seam precedence: an EXPLICITLY injected remote layer
292
304
  // (RunOptions.remoteCache — a distribution agent or daemon that already
@@ -369,7 +381,10 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
369
381
  gitFilesCache,
370
382
  hashCache,
371
383
  workspaceProjectCount: projectMetas.length,
372
- empty: 'no-tasks-declared',
384
+ empty:
385
+ options.selectedByDiff === true && unresolvedTasks.length === 0
386
+ ? 'none-affected'
387
+ : 'no-tasks-declared',
373
388
  }
374
389
  }
375
390
 
@@ -460,6 +475,29 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
460
475
  }
461
476
  }
462
477
 
478
+ /**
479
+ * The names in `unresolved` no project in the whole workspace declares: an
480
+ * anchored `pkg#task` stays as it is, a bare name leaves when a config the
481
+ * diff's scope left out declares it. `load` evaluates those configs; one
482
+ * failing to load leaves every name as it was, so the guard still speaks.
483
+ */
484
+ async function declaredNowhere(
485
+ unresolved: readonly string[],
486
+ load: () => Promise<LoadedProjects>,
487
+ ): Promise<string[]> {
488
+ let all: LoadedProjects
489
+ try {
490
+ all = await load()
491
+ } catch {
492
+ return [...unresolved]
493
+ }
494
+ const declared = new Set<string>()
495
+ for (const p of all.projects.values()) {
496
+ for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
497
+ }
498
+ return unresolved.filter((t) => t.includes('#') || !declared.has(t))
499
+ }
500
+
463
501
  /**
464
502
  * Refuses the first `^name` in `unproven` that no project in the whole
465
503
  * workspace declares. `load` evaluates the configs a scoped run left out;
@@ -259,6 +259,12 @@ async function runOnBus(
259
259
  prepared.cache.close()
260
260
  return { ok: false, outcomes: [] }
261
261
  }
262
+ if (prepared.empty === 'none-affected') {
263
+ log.status(`No affected project declares task(s): ${options.tasks.join(', ')}.`)
264
+ await teardown()
265
+ prepared.cache.close()
266
+ return { ok: true, outcomes: [] }
267
+ }
262
268
  if (prepared.empty !== null) {
263
269
  // `no-tasks-declared` is almost always a typo in CI; we surface
264
270
  // a clear message and return NOT-ok so the script exits 1.