@vzn/vx 0.0.507 → 0.0.509

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.507",
3
+ "version": "0.0.509",
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.507",
25
- "@vzn/vx-linux-arm64": "0.0.507",
26
- "@vzn/vx-darwin-x64": "0.0.507",
27
- "@vzn/vx-darwin-arm64": "0.0.507"
24
+ "@vzn/vx-linux-x64": "0.0.509",
25
+ "@vzn/vx-linux-arm64": "0.0.509",
26
+ "@vzn/vx-darwin-x64": "0.0.509",
27
+ "@vzn/vx-darwin-arm64": "0.0.509"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -33,6 +33,7 @@ export { ABSENT_INPUT, absentOr, CACHE_VERSION } from './key-fold.js'
33
33
  export {
34
34
  cleanOutputs,
35
35
  cleanWorkspaceOutputs,
36
+ declaresInput,
36
37
  resolveInputs,
37
38
  resolveOutputs,
38
39
  resolveWorkspaceOutputs,
@@ -19,7 +19,7 @@
19
19
  import path from 'node:path'
20
20
  import { lstatSync, readdirSync, realpathSync, rmdirSync, rmSync } from 'node:fs'
21
21
  import { rm, rmdir } from 'node:fs/promises'
22
- import type { CacheInputs } from '../config.js'
22
+ import type { CacheConfig, CacheInputs } from '../config.js'
23
23
  import {
24
24
  asTrees,
25
25
  isExecutableMissing,
@@ -1124,6 +1124,61 @@ function filesPlan(
1124
1124
  return plan
1125
1125
  }
1126
1126
 
1127
+ /**
1128
+ * Whether a changed path is one of a cached task's declared file inputs:
1129
+ * `projectRel` (the path relative to the project that owns it) against
1130
+ * `files`, `workspaceRel` (relative to the workspace root) against
1131
+ * `workspaceFiles`; null skips that half, by the globs, `!` exclusions and own-output takebacks
1132
+ * the key resolves. `--affected` seeds a task with it (owner, 2026-10-04).
1133
+ */
1134
+ export function declaresInput(
1135
+ cache: CacheConfig,
1136
+ projectRel: string | null,
1137
+ workspaceRel: string | null,
1138
+ ): boolean {
1139
+ if (projectRel !== null) {
1140
+ const plan = filesPlan(cache.inputs.files, cache.outputs.files)
1141
+ if (plan !== null && inPlan(plan, projectRel)) return true
1142
+ }
1143
+ const ws = cache.inputs.workspaceFiles
1144
+ if (workspaceRel === null || ws === undefined || ws.length === 0) return false
1145
+ const matcher = workspaceMatcher(ws, cache.outputs.workspaceFiles ?? [])
1146
+ return matcher(workspaceRel)
1147
+ }
1148
+
1149
+ function inPlan(plan: FilesPlan, rel: string): boolean {
1150
+ let input = plan.verdicts.get(rel)
1151
+ if (input === undefined) {
1152
+ input =
1153
+ plan.positiveGlobs.some((g) => g.match(rel)) &&
1154
+ !plan.excludeGlobs.some((g) => g.match(rel)) &&
1155
+ !plan.ownOutput(rel)
1156
+ plan.verdicts.set(rel, input)
1157
+ }
1158
+ return input
1159
+ }
1160
+
1161
+ const workspaceMatchers = new Map<string, (rel: string) => boolean>()
1162
+
1163
+ function workspaceMatcher(
1164
+ decl: readonly string[],
1165
+ ownOutputs: readonly string[],
1166
+ ): (rel: string) => boolean {
1167
+ const key = JSON.stringify([decl, ownOutputs])
1168
+ let m = workspaceMatchers.get(key)
1169
+ if (m !== undefined) return m
1170
+ const { positive, negative } = splitNegations(decl)
1171
+ const positiveGlobs = asTrees(positive).map(globFor)
1172
+ const excludeGlobs = [...ALWAYS_IGNORE, ...asTrees(negative)].map(globFor)
1173
+ const ownOutput = outputMatcher(ownOutputs, globFor)
1174
+ m = (rel) =>
1175
+ positiveGlobs.some((g) => g.match(rel)) &&
1176
+ !excludeGlobs.some((g) => g.match(rel)) &&
1177
+ !ownOutput(rel)
1178
+ workspaceMatchers.set(key, m)
1179
+ return m
1180
+ }
1181
+
1127
1182
  async function resolveFiles(args: ResolveFilesArgs): Promise<string[]> {
1128
1183
  const plan = filesPlan(args.files, args.ownOutputs)
1129
1184
  if (plan === null) return []
package/src/cli/run.ts CHANGED
@@ -470,6 +470,7 @@ export async function resolveRunOptions(
470
470
  // drops app from either side (item 955 had put it first for that). First
471
471
  // it stays, so a message naming the filters names it first.
472
472
  const filterStrings = [...parsed.filters]
473
+ let affectedFilter: string | undefined
473
474
  if (parsed.affected !== undefined) {
474
475
  const root = await findWorkspaceRoot(cwd)
475
476
  let base = parsed.affected
@@ -484,7 +485,8 @@ export async function resolveRunOptions(
484
485
  return { error: err.message }
485
486
  }
486
487
  }
487
- filterStrings.unshift(`...[${base}]`)
488
+ affectedFilter = `...[${base}]`
489
+ filterStrings.unshift(affectedFilter)
488
490
  }
489
491
 
490
492
  // Project scope applies to bare task names only. Anchored entries
@@ -495,13 +497,19 @@ export async function resolveRunOptions(
495
497
  let staged: RunOptions['staged']
496
498
  let discovered: RunOptions['discovered']
497
499
  let selectedByDiff = false
500
+ let affected: RunOptions['affected']
498
501
  if (bareTasks.length === 0) {
499
502
  projects = undefined
500
503
  } else if (filterStrings.length > 0) {
501
- const resolved = await resolveFilters(cwd, filterStrings, {
502
- ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
503
- ...(parsed.frozen ? { frozen: true } : {}),
504
- })
504
+ const resolved = await resolveFilters(
505
+ cwd,
506
+ filterStrings,
507
+ {
508
+ ...(parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}),
509
+ ...(parsed.frozen ? { frozen: true } : {}),
510
+ },
511
+ affectedFilter,
512
+ )
505
513
  if ('error' in resolved) return { error: resolved.error }
506
514
  if ('empty' in resolved) {
507
515
  // "Nothing changed" is a clean exit for the BARE tasks the filter
@@ -519,6 +527,7 @@ export async function resolveRunOptions(
519
527
  // instead of evaluating and staging them a second time.
520
528
  staged = resolved.staged
521
529
  discovered = resolved.discovered
530
+ affected = resolved.affected
522
531
  }
523
532
  } else if (parsed.all) {
524
533
  projects = undefined
@@ -563,6 +572,7 @@ export async function resolveRunOptions(
563
572
  }
564
573
  if (projects !== undefined) opts.projects = projects
565
574
  if (selectedByDiff) opts.selectedByDiff = true
575
+ if (affected !== undefined) opts.affected = affected
566
576
  if (staged !== undefined) opts.staged = staged
567
577
  if (discovered !== undefined) opts.discovered = discovered
568
578
  if (parsed.retries !== undefined) opts.retries = parsed.retries
package/src/cli/select.ts CHANGED
@@ -6,6 +6,8 @@
6
6
  import path from 'node:path'
7
7
  import fs from 'node:fs'
8
8
  import {
9
+ affectedChanges,
10
+ type AffectedChanges,
9
11
  affectedProjects,
10
12
  refIsHead,
11
13
  applyFilters,
@@ -29,6 +31,7 @@ import {
29
31
  claimedAffected,
30
32
  fingerprintClaims,
31
33
  gitOfDiscovery,
34
+ isDefaultBuild,
32
35
  keepDiscoveryGraph,
33
36
  } from '../orchestrator/index.js'
34
37
  import {
@@ -177,6 +180,8 @@ export type FilterResolution =
177
180
  staged?: ReadonlyMap<string, ProjectEntry>
178
181
  /** The discovery this pass made, for the run to reuse (`RunOptions.discovered`). */
179
182
  discovered: { root: string; projects: ProjectMeta[] }
183
+ /** The `affected` filter's diff, for the run to seed its tasks from (`RunOptions.affected`). */
184
+ affected?: AffectedChanges
180
185
  }
181
186
  | { error: string }
182
187
  | { empty: string }
@@ -224,6 +229,8 @@ export async function resolveFilters(
224
229
  cwd: string,
225
230
  raw: string[],
226
231
  load: CliLoadOptions = {},
232
+ /** The raw filter `--affected` became: its diff is kept per path for the run. */
233
+ affected?: string,
227
234
  ): Promise<FilterResolution> {
228
235
  const root = await findWorkspaceRoot(cwd)
229
236
  const projects = await loadWorkspaceProjects(cwd)
@@ -273,19 +280,27 @@ export async function resolveFilters(
273
280
  // applyFilters pass runs. One spawn per distinct ref — usually
274
281
  // there's only one anyway.
275
282
  const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
283
+ let changes: AffectedChanges | undefined
276
284
  for (const f of parsed) {
277
285
  if (f.gitSince === undefined) continue
278
286
  try {
279
- const names = await affectedProjects({
287
+ const args = {
280
288
  workspaceRoot: root,
281
289
  since: f.gitSince,
282
290
  projects,
283
- workspaceGlobOwners: (changed) =>
291
+ workspaceGlobOwners: (changed: readonly string[]) =>
284
292
  workspaceGlobOwners(root, projects, changed, load, stagedOnce),
285
293
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
286
294
  taskEdges: async () => edges ?? taskEdgesFrom(await stagedOnce()),
287
295
  ...(git !== undefined ? { untracked: async () => (await git.start()).untracked } : {}),
288
- })
296
+ }
297
+ let names: Set<string>
298
+ if (f.raw === affected) {
299
+ changes = await affectedChanges(args)
300
+ names = changes.projects
301
+ } else {
302
+ names = await affectedProjects(args)
303
+ }
289
304
  affectedByFilter.set(f, names)
290
305
  } catch (err) {
291
306
  const msg = err instanceof Error ? err.message : String(err)
@@ -366,6 +381,7 @@ export async function resolveFilters(
366
381
  byDiff: parsed.some((f) => !f.negate && f.gitSince !== undefined),
367
382
  ...(staged !== undefined ? { staged } : {}),
368
383
  discovered: { root, projects },
384
+ ...(changes !== undefined ? { affected: changes } : {}),
369
385
  }
370
386
  }
371
387
 
@@ -392,7 +408,9 @@ export async function pickTask(
392
408
  for (const meta of projects) {
393
409
  const config = staged.get(meta.name)?.config
394
410
  if (config === undefined) continue
395
- const taskNames = Object.keys(config.tasks ?? {}).sort()
411
+ const taskNames = Object.keys(config.tasks ?? {})
412
+ .filter((t) => !isDefaultBuild(config.tasks?.[t]))
413
+ .sort()
396
414
  for (const t of taskNames) {
397
415
  const desc = config.tasks?.[t]?.description
398
416
  entries.push({ project: meta.name, task: t, ...(desc ? { description: desc } : {}) })
@@ -4,7 +4,7 @@
4
4
  // does it. It stays a refusal: a verb that runs whatever task shares its
5
5
  // name would change meaning the day a plugin declares that verb.
6
6
 
7
- import { nxProjectTarget, projectNamed } from '../orchestrator/index.js'
7
+ import { isDefaultBuild, nxProjectTarget, projectNamed } from '../orchestrator/index.js'
8
8
  import { findWorkspaceRoot, loadWorkspace } from '../workspace/index.js'
9
9
  import { nearest } from '../util/index.js'
10
10
  import { findCwdProject } from './select.js'
@@ -66,7 +66,8 @@ export async function taskNamesHere(cwd: string): Promise<string[] | null> {
66
66
  const names = new Set<string>()
67
67
  for (const p of projects.values())
68
68
  if (own === null || p.name === own)
69
- for (const t of Object.keys(p.config.tasks ?? {})) names.add(t)
69
+ for (const [t, task] of Object.entries(p.config.tasks ?? {}))
70
+ if (!isDefaultBuild(task)) names.add(t)
70
71
  return [...names].sort()
71
72
  }
72
73
 
package/src/index.ts CHANGED
@@ -65,6 +65,8 @@ export { run, planRun, prepareRun } from './orchestrator/index.js'
65
65
  export { LocalHistoryProvider } from './orchestrator/index.js'
66
66
  export type { HistoryProvider, HistoryTable, TaskHistory } from './orchestrator/index.js'
67
67
  export type { PreparedRun } from './orchestrator/index.js'
68
+ // `RunOptions.affected`: what `--affected`'s diff changed, per project.
69
+ export type { AffectedChanges } from './workspace/index.js'
68
70
  // `planRun` returns these, and an embedder that cannot NAME a return type
69
71
  // cannot hold it: `run` and `prepareRun` both had theirs here from the
70
72
  // start, so the omission read as "planRun is not really public" while
@@ -0,0 +1,113 @@
1
+ // `--affected` per task (owner, 2026-10-04). The diff seeds the tasks it
2
+ // reaches; a requested task runs when its `dependsOn` closure holds one.
3
+ // So a change reaches another project only along a task edge: `web#test`
4
+ // behind `^build` runs when `ui#build` (or what it depends on) is reached,
5
+ // and a spec edit `ui#build`'s inputs leave out stops at `ui`'s own tasks.
6
+
7
+ import { declaresInput } from '../cache/index.js'
8
+ import {
9
+ compileTaskPattern,
10
+ isGroupTask,
11
+ isTaskPattern,
12
+ parseDependencySpec,
13
+ type TaskNode,
14
+ } from '../graph/index.js'
15
+ import {
16
+ type AffectedChanges,
17
+ type PackageGraph,
18
+ PROJECT_CONFIG_FILENAMES,
19
+ type ProjectEntry,
20
+ } from '../workspace/index.js'
21
+
22
+ /** Every file in this set re-keys each of its project's tasks, whatever their inputs. */
23
+ const KEYED_BY_EVERY_TASK = new Set(['package.json', ...PROJECT_CONFIG_FILENAMES])
24
+
25
+ /**
26
+ * The projects every task of which the change reaches: the whole ones the
27
+ * diff named, and those holding a changed path no cached task of theirs
28
+ * declares as an input. vx cannot prove such a path re-keys nothing (a
29
+ * `project` plugin may read it), so it reaches the whole project, as it did
30
+ * before selection was per task.
31
+ */
32
+ function wholeProjects(
33
+ changes: AffectedChanges,
34
+ projects: ReadonlyMap<string, ProjectEntry>,
35
+ ): Set<string> {
36
+ const whole = new Set(changes.whole)
37
+ for (const [name, rels] of changes.paths) {
38
+ if (whole.has(name)) continue
39
+ const caches = Object.values(projects.get(name)?.config.tasks ?? {}).flatMap((t) =>
40
+ t.exec !== undefined && t.cache !== undefined ? [t.cache] : [],
41
+ )
42
+ const unclaimed = (rel: string): boolean =>
43
+ KEYED_BY_EVERY_TASK.has(rel) || !caches.some((c) => declaresInput(c, rel, null))
44
+ if (rels.some(unclaimed)) whole.add(name)
45
+ }
46
+ return whole
47
+ }
48
+
49
+ /**
50
+ * The task ids of `ids` whose closure the change reaches, in their order.
51
+ * A group seeds nothing (it runs nothing) unless it is keyed, as the default
52
+ * `build` is; an uncached task seeds whenever its project changed; a cached
53
+ * one when a changed path is one of its declared inputs. A `^name` edge the
54
+ * graph passes through a package it loaded no config for reaches it as the
55
+ * default `build` would: any change there reaches the task.
56
+ */
57
+ export function affectedRoots(
58
+ nodes: ReadonlyMap<string, TaskNode>,
59
+ ids: readonly string[],
60
+ changes: AffectedChanges,
61
+ projects: ReadonlyMap<string, ProjectEntry>,
62
+ packageGraph: PackageGraph,
63
+ ): string[] {
64
+ const whole = wholeProjects(changes, projects)
65
+ // The builder's `^name` walk (task-graph.ts), keeping only the packages
66
+ // it passes through: a holder's own node is already in `deps`.
67
+ const passesChanged = (n: TaskNode): boolean => {
68
+ for (const raw of n.config.dependsOn ?? []) {
69
+ const spec = parseDependencySpec(raw)
70
+ if (spec.kind !== 'deps' || spec.negated) continue
71
+ const re = isTaskPattern(spec.task) ? compileTaskPattern(spec.task) : null
72
+ const holds = (name: string): boolean => {
73
+ const tasks = Object.keys(projects.get(name)?.config.tasks ?? {})
74
+ return re === null ? tasks.includes(spec.task) : tasks.some((t) => re.test(t))
75
+ }
76
+ const visited = new Set([n.projectName])
77
+ const frontier = [...packageGraph.directDeps(n.projectName)]
78
+ while (frontier.length > 0) {
79
+ const target = frontier.pop()!
80
+ if (visited.has(target)) continue
81
+ visited.add(target)
82
+ if (holds(target)) continue
83
+ if (changes.projects.has(target)) return true
84
+ frontier.push(...packageGraph.directDeps(target))
85
+ }
86
+ }
87
+ return false
88
+ }
89
+ const seeded = (n: TaskNode): boolean => {
90
+ if (!changes.projects.has(n.projectName)) return false
91
+ const cache = n.config.cache
92
+ // A group runs nothing; only the default `build` (projects.ts) is keyed.
93
+ if (cache === undefined) return !isGroupTask(n)
94
+ if (whole.has(n.projectName)) return true
95
+ if ((changes.paths.get(n.projectName) ?? []).some((rel) => declaresInput(cache, rel, null))) {
96
+ return true
97
+ }
98
+ return changes.changed.some((rel) => declaresInput(cache, null, rel))
99
+ }
100
+ const reached = new Map<string, boolean>()
101
+ const reaches = (id: string): boolean => {
102
+ const known = reached.get(id)
103
+ if (known !== undefined) return known
104
+ // The graph is acyclic (the builder refuses a cycle); the mark only
105
+ // stops a diamond being walked twice.
106
+ reached.set(id, false)
107
+ const n = nodes.get(id)
108
+ const hit = n !== undefined && (seeded(n) || passesChanged(n) || n.deps.some(reaches))
109
+ reached.set(id, hit)
110
+ return hit
111
+ }
112
+ return ids.filter(reaches)
113
+ }
@@ -15,7 +15,7 @@
15
15
 
16
16
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
17
17
  import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
18
- import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
18
+ import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
19
19
  import { filterUpstreamHashes, keyedDeps } from './upstream.js'
20
20
 
21
21
  export interface KeyExcludedArgs {
@@ -53,8 +53,7 @@ export async function keyExcludedDependencies(args: KeyExcludedArgs): Promise<vo
53
53
  Promise.all(ids.map(async (id) => synthetic(nodeOf(id), await keys.get(id)!)))
54
54
  const derive = async (node: TaskNode): Promise<string | undefined> => {
55
55
  const upstream = await outcomes(fullDeps(node))
56
- if (isGroupTask(node)) return computeGroupHash(upstream)
57
- return computeTaskHash({
56
+ return (isGroupTask(node) ? computeGroupKey : computeTaskHash)({
58
57
  node,
59
58
  upstream,
60
59
  workspaceRoot: args.workspaceRoot,
@@ -72,7 +72,7 @@ export { restoreHit, type RestoreHitArgs } from './hit-restore.js'
72
72
  import type { DeferredOutputs } from './deferred-outputs.js'
73
73
  import type { Logger } from './logger.js'
74
74
  import {
75
- computeGroupHash,
75
+ computeGroupKey,
76
76
  computeTaskHash,
77
77
  describeTaskInputs,
78
78
  type HashCache,
@@ -249,14 +249,25 @@ export async function executeTask(args: ExecuteArgs): Promise<TaskOutcome> {
249
249
  * from upstream outcomes so downstream cache keys still cascade
250
250
  * through us.
251
251
  */
252
- function executeGroupTask(args: ExecuteArgs): TaskOutcome {
252
+ async function executeGroupTask(args: ExecuteArgs): Promise<TaskOutcome> {
253
253
  const wallclockNs = process.hrtime.bigint() - args.runStartHrTimeNs
254
+ const hash = await computeGroupKey({
255
+ node: args.node,
256
+ upstream: args.upstream,
257
+ workspaceRoot: args.workspaceRoot,
258
+ workspaceFingerprint: args.workspaceFingerprint,
259
+ cache: args.cache,
260
+ forwardArgs: args.forwardArgs,
261
+ nestedProjectDirs: args.nestedProjectDirs,
262
+ ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
263
+ ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
264
+ })
254
265
  return {
255
266
  node: args.node,
256
267
  status: 'success',
257
268
  exitCode: 0,
258
269
  durationMs: 0,
259
- hash: computeGroupHash(args.upstream),
270
+ hash,
260
271
  // What this group stands for. A dependent expands it to describe the
261
272
  // real tasks in its input closure — see `TaskOutcome.groupUpstream`.
262
273
  groupUpstream: args.upstream,
@@ -6,6 +6,7 @@ export { prepareRun, type PreparedRun } from './prepare.js'
6
6
  export {
7
7
  discoverProjects,
8
8
  gitOfDiscovery,
9
+ isDefaultBuild,
9
10
  keepDiscoveryGraph,
10
11
  loadProjects,
11
12
  loadResolvedProjects,
@@ -16,8 +16,9 @@ import { keyedDeps, selectFoldedDeps, type FoldCandidate } from './upstream.js'
16
16
  * The fold relation is the hash path's, computed on the graph:
17
17
  * - an exec task folds its dependencies as its own `cache.inputs.tasks`
18
18
  * selects them (absent, or no `cache` at all, means all);
19
- * - a group folds every dependency (`computeGroupHash`) and folds no
20
- * files of its own;
19
+ * - a group folds every dependency (`computeGroupKey`) and folds no
20
+ * files of its own, but for the default `build`, which folds every
21
+ * file of its project as a task does;
21
22
  * - a persistent task is keyed as a task with no `cache` (A-17).
22
23
  *
23
24
  * Every exec task reached contributes its project, cached or not: a key
@@ -56,7 +57,7 @@ export function keyedProjects(
56
57
  stack.pop()
57
58
  const out = new Set<string>()
58
59
  for (const { node: dep } of deps) {
59
- if (!isGroupTask(dep)) out.add(dep.projectDir)
60
+ if (!isGroupTask(dep) || dep.config.cache !== undefined) out.add(dep.projectDir)
60
61
  for (const dir of below.get(dep.id)!) out.add(dir)
61
62
  }
62
63
  below.set(node.id, out)
@@ -83,7 +84,7 @@ function folded(node: TaskNode, nodeOf: (id: string) => TaskNode): FoldCandidate
83
84
  for (const { node: dep } of node.excludedUpstream ?? []) {
84
85
  candidates.push({ node: dep, unit: foldUnit(dep) })
85
86
  }
86
- if (isGroupTask(node)) return candidates
87
+ if (isGroupTask(node) && node.config.cache === undefined) return candidates
87
88
  return selectFoldedDeps(candidates, node.config.cache?.inputs?.tasks, node.projectName, node.id)
88
89
  }
89
90
 
@@ -94,5 +95,7 @@ function folded(node: TaskNode, nodeOf: (id: string) => TaskNode): FoldCandidate
94
95
  * with no id of its own, so two groups over the same members hash alike.
95
96
  */
96
97
  function foldUnit(node: TaskNode): string {
97
- return isGroupTask(node) ? `group|${[...node.deps].sort().join('|')}` : `task|${node.id}`
98
+ return isGroupTask(node) && node.config.cache === undefined
99
+ ? `group|${[...node.deps].sort().join('|')}`
100
+ : `task|${node.id}`
98
101
  }
@@ -8,7 +8,7 @@ import type { TaskOutcome } from '../graph/index.js'
8
8
  import type { EventBus } from './events.js'
9
9
  import type { TelemetrySink } from './telemetry.js'
10
10
  import type { Logger } from './logger.js'
11
- import type { ProjectEntry, ProjectMeta } from '../workspace/index.js'
11
+ import type { AffectedChanges, ProjectEntry, ProjectMeta } from '../workspace/index.js'
12
12
 
13
13
  export interface RunOptions {
14
14
  cwd: string
@@ -29,6 +29,12 @@ export interface RunOptions {
29
29
  * `vx run test --affected` red (item 1024).
30
30
  */
31
31
  selectedByDiff?: boolean
32
+ /**
33
+ * `--affected`'s diff: a bare requested task runs only when the change
34
+ * reaches its `dependsOn` closure (`affected-tasks.ts`; owner,
35
+ * 2026-10-04). `projects` is the candidate set it narrows.
36
+ */
37
+ affected?: AffectedChanges
32
38
  /**
33
39
  * Configs a selection pass in this same process already loaded and
34
40
  * staged with the same `cacheDir` and `frozen` (the CLI walks every
@@ -15,7 +15,7 @@ import type { CacheLayer, CachePolicy, GitFilesCache } from '../cache/index.js'
15
15
  import { FULL_CACHE_POLICY } from '../cache/index.js'
16
16
  import { isGroupTask, runGraph, type TaskNode, type TaskOutcome } from '../graph/index.js'
17
17
  import type { HistoryProvider } from './history.js'
18
- import { computeGroupHash, computeTaskHash } from './task-hash.js'
18
+ import { computeGroupKey, computeTaskHash } from './task-hash.js'
19
19
  import { keyUpstream } from './upstream.js'
20
20
 
21
21
  export type CacheStatus =
@@ -130,12 +130,7 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
130
130
  concurrency: 1,
131
131
  execute: async (node, live) => {
132
132
  const upstream = keyUpstream(node, live)
133
- if (isGroupTask(node)) {
134
- cacheStatusById.set(node.id, 'group')
135
- return planOutcome(node, computeGroupHash(upstream))
136
- }
137
-
138
- const hash = await computeTaskHash({
133
+ const hashArgs = {
139
134
  node,
140
135
  upstream,
141
136
  workspaceRoot: args.workspaceRoot,
@@ -145,7 +140,13 @@ export async function plan(args: PlanArgs): Promise<RunPlan> {
145
140
  nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
146
141
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
147
142
  ...(args.hashCache !== undefined ? { hashCache: args.hashCache } : {}),
148
- })
143
+ }
144
+ if (isGroupTask(node)) {
145
+ cacheStatusById.set(node.id, 'group')
146
+ return planOutcome(node, await computeGroupKey(hashArgs))
147
+ }
148
+
149
+ const hash = await computeTaskHash(hashArgs)
149
150
 
150
151
  // Prediction keys off READS: a no-read policy re-executes every task,
151
152
  // so nothing is probed. Whether that run is a miss or no cache at all
@@ -65,6 +65,7 @@ import {
65
65
  loadWorkspacePlugins,
66
66
  type LoadedProjects,
67
67
  } from './projects.js'
68
+ import { affectedRoots } from './affected-tasks.js'
68
69
  import { keyExcludedDependencies } from './excluded-keys.js'
69
70
  import { FingerprintWatch } from './fingerprint-watch.js'
70
71
  import type { VxPlugin } from './plugin.js'
@@ -159,7 +160,9 @@ export interface PreparedRun {
159
160
  * returns NOT-ok.
160
161
  * - `'none-affected'` — as `'no-tasks-declared'`, but the scope
161
162
  * came from a diff and every name is declared somewhere else in the
162
- * workspace: nothing changed that runs them, a clean outcome.
163
+ * workspace: nothing changed that runs them, a clean outcome. Also
164
+ * when `--affected`'s diff reaches no requested task's `dependsOn`
165
+ * closure (`affected-tasks.ts`).
163
166
  * - `'empty-graph'` — `requested` was non-empty but the
164
167
  * graph builder still produced no nodes. Defensive; unreachable
165
168
  * under current `buildTaskGraph` semantics.
@@ -458,36 +461,36 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
458
461
 
459
462
  // Empty-cases bookkeeping. We still construct the cache + fingerprint
460
463
  // so the caller's try/finally pattern can close it uniformly.
461
- if (requested.length === 0) {
462
- return {
463
- workspaceRoot,
464
- workspaceConfig,
465
- plugins,
466
- cacheDir,
467
- cache,
468
- localCache,
469
- hasRemoteLayer,
470
- cachePolicy: policy,
471
- priorities: new Map(),
472
- nodes: new Map(),
473
- keyOnly: new Map(),
474
- unresolvedTasks,
475
- declaredElsewhere,
476
- projects,
477
- hintProjects,
478
- anyProjectConfig: projectsWithConfigs.length > 0,
479
- workspaceFingerprint,
480
- fingerprintWatch,
481
- nestedDirsByProject,
482
- gitFilesCache: new GitFilesCache(),
483
- hashCache,
484
- workspaceProjectCount: projectMetas.length,
485
- empty:
486
- options.selectedByDiff === true && unresolvedTasks.length === 0
487
- ? 'none-affected'
488
- : 'no-tasks-declared',
489
- }
490
- }
464
+ const emptyRun = (reason?: 'none-affected'): PreparedRun => ({
465
+ workspaceRoot,
466
+ workspaceConfig,
467
+ plugins,
468
+ cacheDir,
469
+ cache,
470
+ localCache,
471
+ hasRemoteLayer,
472
+ cachePolicy: policy,
473
+ priorities: new Map(),
474
+ nodes: new Map(),
475
+ keyOnly: new Map(),
476
+ unresolvedTasks,
477
+ declaredElsewhere,
478
+ projects,
479
+ hintProjects,
480
+ anyProjectConfig: projectsWithConfigs.length > 0,
481
+ workspaceFingerprint,
482
+ fingerprintWatch,
483
+ nestedDirsByProject,
484
+ gitFilesCache: new GitFilesCache(),
485
+ hashCache,
486
+ workspaceProjectCount: projectMetas.length,
487
+ empty:
488
+ reason ??
489
+ (options.selectedByDiff === true && unresolvedTasks.length === 0
490
+ ? 'none-affected'
491
+ : 'no-tasks-declared'),
492
+ })
493
+ if (requested.length === 0) return emptyRun()
491
494
 
492
495
  // The whole graph, whatever `--exclude-dependencies` says: the stages
493
496
  // below shape and key the tasks it drops as a full run would, because
@@ -496,15 +499,30 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
496
499
  // by a config the scope left out, so the builder hands it back instead of
497
500
  // refusing it, and the rest decide.
498
501
  const unproven: Array<[taskId: string, name: string]> = []
499
- const nodes = buildTaskGraph({
500
- projects,
501
- packageGraph,
502
- requested,
503
- workspaceRoot,
504
- ...(projects.size < projectsWithConfigs.length
505
- ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
506
- : {}),
507
- })
502
+ const graphOf = (req: typeof requested) =>
503
+ buildTaskGraph({
504
+ projects,
505
+ packageGraph,
506
+ requested: req,
507
+ workspaceRoot,
508
+ ...(projects.size < projectsWithConfigs.length
509
+ ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
510
+ : {}),
511
+ })
512
+ let nodes = graphOf(requested)
513
+ // `--affected` keeps a bare request only when the diff reaches its
514
+ // closure; a `pkg#task` the user named runs as it always does.
515
+ if (options.affected !== undefined) {
516
+ const named = new Set(tasks.filter((t) => t.includes('#')))
517
+ const ids = requested.map((r) => `${r.project}#${r.task}`)
518
+ const reached = new Set(affectedRoots(nodes, ids, options.affected, projects, packageGraph))
519
+ const kept = requested.filter((_r, i) => named.has(ids[i]!) || reached.has(ids[i]!))
520
+ if (kept.length === 0) return emptyRun('none-affected')
521
+ if (kept.length < requested.length) {
522
+ unproven.length = 0
523
+ nodes = graphOf(kept)
524
+ }
525
+ }
508
526
  if (unproven.length > 0) {
509
527
  await refuseUndeclaredDeps(unproven, () =>
510
528
  loadProjects({ ...loadArgs, seeds: 'all', closure: false, staged: projects }),
@@ -5,7 +5,7 @@
5
5
  // wrote a `vx.config.ts`.
6
6
 
7
7
  import path from 'node:path'
8
- import type { ProjectConfig } from '../config.js'
8
+ import type { ProjectConfig, TaskConfig } from '../config.js'
9
9
  import {
10
10
  frozenProjectConfigs,
11
11
  loadProjectConfigs,
@@ -283,7 +283,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
283
283
  (plugin) => validateProjectConfig(config, `${where} (after plugin '${plugin.name}')`),
284
284
  )
285
285
  }
286
- const named = rootName === undefined ? config : rootSpelled(config, rootName)
286
+ const built = withDefaultBuild(config)
287
+ const named = rootName === undefined ? built : rootSpelled(built, rootName)
287
288
  projects.set(meta.name, { name: meta.name, dir: meta.dir, config: named })
288
289
  for (const name of crossDepProjects(named)) {
289
290
  if (graph !== null) considerWithDeps(graph, name)
@@ -294,6 +295,32 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
294
295
  return { projects, configured }
295
296
  }
296
297
 
298
+ /**
299
+ * The `build` a project gets when neither its config nor a plugin declared
300
+ * one (owner, 2026-10-04): a group behind `^build`, keyed on every file of
301
+ * the project. A package consumed as source then carries its files to every
302
+ * dependent behind `^build`, in the key and in `--affected`, and the chain
303
+ * through it to deeper builds holds. The one keyed group: a config cannot
304
+ * declare `cache` on a group (config-schema.ts), so nothing else is.
305
+ */
306
+ const DEFAULT_BUILD: TaskConfig = Object.freeze({
307
+ dependsOn: Object.freeze(['^build']),
308
+ cache: Object.freeze({
309
+ inputs: Object.freeze({ files: Object.freeze(['**']) }),
310
+ outputs: Object.freeze({ files: Object.freeze([]) }),
311
+ }),
312
+ }) as unknown as TaskConfig
313
+
314
+ /** The default `build`, which a menu of the tasks a user declared leaves out. */
315
+ export function isDefaultBuild(task: TaskConfig | undefined): boolean {
316
+ return task === DEFAULT_BUILD
317
+ }
318
+
319
+ function withDefaultBuild(config: ProjectConfig): ProjectConfig {
320
+ if (config.tasks?.['build'] !== undefined) return config
321
+ return { ...config, tasks: { ...config.tasks, build: DEFAULT_BUILD } }
322
+ }
323
+
297
324
  /**
298
325
  * `config` with Turbo's `//#task` (in `dependsOn` and `cache.inputs.tasks`)
299
326
  * spelled `<root>#task`; the same object when it holds none. A copy, not
@@ -385,7 +385,19 @@ async function runOnBus(
385
385
  return { ok: false, outcomes: [], refused }
386
386
  }
387
387
  if (prepared.empty === 'none-affected') {
388
- log.status(`No affected project declares task(s): ${options.tasks.join(', ')}.`)
388
+ // A changed project declares the task, so `--affected`'s task walk is
389
+ // what left nothing (affected-tasks.ts), not the scope.
390
+ const inScope = new Set(options.projects ?? [])
391
+ const reachedNone =
392
+ options.affected !== undefined &&
393
+ [...prepared.projects.values()].some(
394
+ (p) => inScope.has(p.name) && options.tasks.some((t) => p.config.tasks?.[t] !== undefined),
395
+ )
396
+ log.status(
397
+ reachedNone
398
+ ? `Nothing affected: the change reaches no ${options.tasks.join(', ')} task.`
399
+ : `No affected project declares task(s): ${options.tasks.join(', ')}.`,
400
+ )
389
401
  await teardown()
390
402
  prepared.cache.close()
391
403
  return { ok: true, outcomes: [] }
@@ -15,7 +15,7 @@ import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
15
15
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
16
16
  import { isLiteralPattern, normalizeGlob, relPosix } from '../util/index.js'
17
17
  import { commandWriteReach, mayWriteFingerprint, undeclaredWriteReach } from './sandbox-request.js'
18
- import { computeGroupHash, computeTaskHash, type HashCache } from './task-hash.js'
18
+ import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
19
19
  import { filterUpstreamHashes, keyUpstream } from './upstream.js'
20
20
 
21
21
  export interface DeriveStableKeysArgs {
@@ -208,7 +208,20 @@ export async function deriveStableKeys(args: DeriveStableKeysArgs): Promise<Stab
208
208
  // dependents that filter inputs.tasks through the group still
209
209
  // cascade (and forward their producers, above). They inherit
210
210
  // instability from any unstable member.
211
- keyById.set(id, computeGroupHash(upstream))
211
+ keyById.set(
212
+ id,
213
+ await computeGroupKey({
214
+ node,
215
+ upstream,
216
+ workspaceRoot: args.workspaceRoot,
217
+ workspaceFingerprint: args.workspaceFingerprint,
218
+ cache: args.cache,
219
+ forwardArgs: args.forwardArgs,
220
+ nestedProjectDirs: args.nestedDirsByProject.get(node.projectName) ?? [],
221
+ gitFilesCache: args.gitFilesCache,
222
+ hashCache: args.hashCache,
223
+ }),
224
+ )
212
225
  if (node.deps.some((d) => unstableById.has(d))) unstableById.add(id)
213
226
  continue
214
227
  }
@@ -100,7 +100,7 @@ export interface ComputeHashArgs {
100
100
  * Caller is responsible for handling the group-task case before
101
101
  * calling this — groups have no `exec` and no `cache.inputs`, so the
102
102
  * key-derivation steps below would all fall back to defaults.
103
- * `computeGroupHash` is exported for that purpose.
103
+ * `computeGroupKey` is exported for that purpose.
104
104
  */
105
105
  export async function computeTaskHash(args: ComputeHashArgs): Promise<string> {
106
106
  const end = span('task hash')
@@ -380,7 +380,8 @@ function hashTaskConfig(cfg: TaskConfig, hashCache?: HashCache): string {
380
380
  * in place. Its one behavioural case is a config with NO `exec`, where
381
381
  * the spread would add `exec: {}` — and nothing reaches here that way,
382
382
  * proven by throwing on a no-exec config and running the whole suite:
383
- * a group takes `computeGroupHash` instead. `timeout`/`retries` stay folded — their keys are
383
+ * a group takes `computeGroupKey`, and the one keyed group (the default
384
+ * `build`) declares no `remote`. `timeout`/`retries` stay folded — their keys are
384
385
  * distinct by design (see the decision log); stripping them retroactively
385
386
  * would bump CACHE_VERSION.
386
387
  */
@@ -390,6 +391,16 @@ function hashableConfig(cfg: TaskConfig): unknown {
390
391
  return { ...cfg, exec: execRest }
391
392
  }
392
393
 
394
+ /**
395
+ * A group's key: its upstream, and for a keyed group — the default
396
+ * `build` (projects.ts), the one group with `cache` — its own inputs too,
397
+ * the way a task's key folds them.
398
+ */
399
+ export async function computeGroupKey(args: ComputeHashArgs): Promise<string> {
400
+ if (args.node.config.cache === undefined) return computeGroupHash(args.upstream)
401
+ return computeTaskHash(args)
402
+ }
403
+
393
404
  /**
394
405
  * Derive a stable hash for a group task (no `exec`) from its upstream
395
406
  * outcomes. Lets downstream tasks that filter `inputs.tasks` to
package/src/util/index.ts CHANGED
@@ -23,6 +23,7 @@ export { clampInt, formatElapsed, MAX_TIMEOUT_MS, parseDecimalInt } from './num.
23
23
  export {
24
24
  asTrees,
25
25
  BUN_GLOB_WILDCARDS,
26
+ EXTGLOB,
26
27
  grantPrefix,
27
28
  isLiteralPattern,
28
29
  GLOB_WILDCARDS,
package/src/util/paths.ts CHANGED
@@ -179,6 +179,16 @@ export const GLOB_WILDCARDS = /[*?{}]/
179
179
  */
180
180
  export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
181
181
 
182
+ /**
183
+ * An extglob group: `!(…)`, `@(…)`, `+(…)`, `*(…)`, `?(…)`. npm, yarn,
184
+ * Nx and Turbo read it; `Bun.Glob` has none, and reads the group as
185
+ * literal text or as a plain wildcard. A task input `src/@(x|y).ts` keyed
186
+ * no file, so an edit to `src/x.ts` replayed a stale hit, and a member
187
+ * glob `packages/!(x)` took in x (turborepo#3766). Every glob vx hands
188
+ * `Bun.Glob` from a config refuses it.
189
+ */
190
+ export const EXTGLOB = /[!@+*?]\(/
191
+
182
192
  /**
183
193
  * Compile a task glob for `Bun.Glob`, with every bare bracket escaped so it
184
194
  * matches itself (item 667). Every task glob is compiled here and nowhere
@@ -15,6 +15,7 @@ import {
15
15
  UserError,
16
16
  gitSpawnRefusal,
17
17
  isExecutableMissing,
18
+ relPosix,
18
19
  taskGlob,
19
20
  } from '../util/index.js'
20
21
  import { LOCKFILE_NAME } from './lockfile.js'
@@ -123,6 +124,30 @@ export interface FingerprintClaims {
123
124
  * or `<since>` doesn't resolve to a commit.
124
125
  */
125
126
  export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
127
+ return (await affectedChanges(args, false)).projects
128
+ }
129
+
130
+ /**
131
+ * What `--affected` seeds its tasks from (owner, 2026-10-04): the changed
132
+ * projects, each one's changed paths relative to it, and the projects a
133
+ * change reaches as a whole — every task in one is affected whatever its
134
+ * inputs say, because the reason was no path of its own (a lockfile claim,
135
+ * a manifest edge at the base, a config import, a nested repository, a
136
+ * workspace-wide file).
137
+ */
138
+ export interface AffectedChanges {
139
+ projects: Set<string>
140
+ /** Workspace-relative changed paths. */
141
+ changed: readonly string[]
142
+ /** Project → its changed paths, relative to the project's directory. */
143
+ paths: ReadonlyMap<string, readonly string[]>
144
+ whole: ReadonlySet<string>
145
+ }
146
+
147
+ export async function affectedChanges(
148
+ args: AffectedArgs,
149
+ perTask = true,
150
+ ): Promise<AffectedChanges> {
126
151
  // Turbo's CI spelling, `[origin/main...HEAD]`, is the base alone here:
127
152
  // vx already diffs from the merge base (three dots' meaning), and the
128
153
  // working tree it diffs to holds HEAD (D-117). Its two-dot `[A..HEAD]`
@@ -226,9 +251,11 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
226
251
  // says per project, so selection asks the plugin the same question, with
227
252
  // the bytes at the base ref and in the working tree. Its answer is
228
253
  // unioned with the path-owned projects below; only "cannot tell" widens.
229
- if (await workspaceConfigChanged(args.workspaceRoot, changed)) {
230
- return new Set(args.projects.map((p) => p.name))
254
+ const everything = (): AffectedChanges => {
255
+ const all = new Set(args.projects.map((p) => p.name))
256
+ return { projects: all, changed, paths: new Map(), whole: all }
231
257
  }
258
+ if (await workspaceConfigChanged(args.workspaceRoot, changed)) return everything()
232
259
  const fingerprintChanged = changed.filter((p) => FINGERPRINT_SET.has(p))
233
260
  // A patch bun.lock names folds into every key, core's fingerprint and
234
261
  // `bun()`'s claim alike, so its edit widens as a lockfile edit does. A
@@ -237,7 +264,7 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
237
264
  const lock = await bytesOrNull(path.join(args.workspaceRoot, 'bun.lock'))
238
265
  if (lock !== null) {
239
266
  const patches = new Set(bunPatchFiles(lock))
240
- if (changed.some((p) => patches.has(p))) return new Set(args.projects.map((p) => p.name))
267
+ if (changed.some((p) => patches.has(p))) return everything()
241
268
  }
242
269
  }
243
270
  const claimedOwned = new Set<string>()
@@ -256,15 +283,13 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
256
283
  }
257
284
  if (fingerprintChanged.length > 0) {
258
285
  for (const file of fingerprintChanged) {
259
- if (claims === undefined || !claims.files.has(file)) {
260
- return new Set(args.projects.map((p) => p.name))
261
- }
286
+ if (claims === undefined || !claims.files.has(file)) return everything()
262
287
  const answer = await claims.affected({
263
288
  file,
264
289
  before: await gitBytesAt(args.workspaceRoot, base, file),
265
290
  after: await bytesOrNull(path.join(args.workspaceRoot, file)),
266
291
  })
267
- if (answer === undefined) return new Set(args.projects.map((p) => p.name))
292
+ if (answer === undefined) return everything()
268
293
  for (const name of answer) claimedOwned.add(name)
269
294
  }
270
295
  }
@@ -274,7 +299,24 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
274
299
  args.projects.map(async (p) => [p.dir, await realpath(p.dir).catch(() => p.dir)] as const),
275
300
  ),
276
301
  )
277
- const owned = projectsContaining(args.workspaceRoot, changed, args.projects, realDirs)
302
+ const paths = new Map<string, string[]>()
303
+ const whole = new Set<string>(claimedOwned)
304
+ const owned = projectsContaining(
305
+ args.workspaceRoot,
306
+ changed,
307
+ args.projects,
308
+ realDirs,
309
+ perTask
310
+ ? {
311
+ path: (name, rel) => {
312
+ const list = paths.get(name)
313
+ if (list) list.push(rel)
314
+ else paths.set(name, [rel])
315
+ },
316
+ repo: (name) => whole.add(name),
317
+ }
318
+ : undefined,
319
+ )
278
320
  for (const name of claimedOwned) owned.add(name)
279
321
 
280
322
  // A manifest edit can drop an edge today's graph no longer shows: a
@@ -293,10 +335,14 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
293
335
  args.projects,
294
336
  args.taskEdges,
295
337
  )
296
- if (dependents === undefined) return new Set(args.projects.map((p) => p.name))
297
- for (const name of dependents) owned.add(name)
338
+ if (dependents === undefined) return everything()
339
+ for (const name of dependents) {
340
+ owned.add(name)
341
+ whole.add(name)
342
+ }
298
343
  for (const name of parentsOfNewNested(args.workspaceRoot, atBase, args.projects)) {
299
344
  owned.add(name)
345
+ whole.add(name)
300
346
  }
301
347
  }
302
348
 
@@ -306,19 +352,23 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
306
352
  // runs on the FULL changed set, not just the paths no project owns: the
307
353
  // common shape is a config reaching into ANOTHER project
308
354
  // (`../../src/index.ts`), whose target is owned.
355
+ // Per task, a project that owns the changed file still needs to know its
356
+ // config imported it: that re-keys every task, whatever their inputs.
309
357
  for (const name of await configImportOwners({
310
358
  workspaceRoot: args.workspaceRoot,
311
359
  projects: args.projects,
312
360
  changed,
313
- skip: owned,
361
+ skip: perTask ? new Set() : owned,
314
362
  realDirs,
315
363
  })) {
316
364
  owned.add(name)
365
+ whole.add(name)
317
366
  }
318
367
 
319
- if (changed.length === 0 || args.workspaceGlobOwners === undefined) return owned
368
+ const done = (): AffectedChanges => ({ projects: owned, changed, paths, whole })
369
+ if (changed.length === 0 || args.workspaceGlobOwners === undefined) return done()
320
370
  for (const name of await args.workspaceGlobOwners(changed)) owned.add(name)
321
- return owned
371
+ return done()
322
372
  }
323
373
 
324
374
  /**
@@ -767,6 +817,8 @@ function projectsContaining(
767
817
  changedRelPaths: readonly string[],
768
818
  projects: readonly ProjectMeta[],
769
819
  realDirs: ReadonlyMap<string, string>,
820
+ /** Told each owned path relative to its project, and each nested repository's projects. */
821
+ on?: { path(name: string, rel: string): void; repo(name: string): void },
770
822
  ): Set<string> {
771
823
  // Index projects by their (canonical) dir, then for each changed path walk
772
824
  // its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
@@ -798,6 +850,7 @@ function projectsContaining(
798
850
  const name = dirToName.get(dir)
799
851
  if (name !== undefined) {
800
852
  owned.add(name)
853
+ on?.path(name, relPosix(dir, path.resolve(workspaceRoot, rel)))
801
854
  hit = true
802
855
  break
803
856
  }
@@ -817,7 +870,11 @@ function projectsContaining(
817
870
  const abs = path.resolve(workspaceRoot, rel)
818
871
  if (isDirectory(abs)) {
819
872
  const prefix = abs + path.sep
820
- for (const [dir, name] of dirToName) if (dir.startsWith(prefix)) owned.add(name)
873
+ for (const [dir, name] of dirToName) {
874
+ if (!dir.startsWith(prefix)) continue
875
+ owned.add(name)
876
+ on?.repo(name)
877
+ }
821
878
  }
822
879
  }
823
880
  return owned
@@ -23,6 +23,7 @@ import {
23
23
  parseSize,
24
24
  UserError,
25
25
  BUN_GLOB_WILDCARDS,
26
+ EXTGLOB,
26
27
  } from '../util/index.js'
27
28
  import { nonJsonMessage, nonJsonPaths } from './json-data.js'
28
29
 
@@ -1066,6 +1067,24 @@ function assertNoForeignToken(glob: string, where: string): void {
1066
1067
  throw new UserError(`${where}: "${glob}" holds ${shown}, ${meaning}`)
1067
1068
  }
1068
1069
  }
1070
+ // A leading `!` is vx's negation, so `!(group)/**` takes back a route group.
1071
+ const body = glob.startsWith('!') ? glob.slice(1) : glob
1072
+ const at = body.search(EXTGLOB)
1073
+ if (at !== -1) {
1074
+ // `@(a|b)` of plain names is exactly `{a,b}`, and `@(a)` is `a`.
1075
+ const group =
1076
+ body[at - 1] === '\\' ? null : /^@\(([^()|]+(?:\|[^()|]+)*)\)/.exec(body.slice(at))
1077
+ const names = group === null ? [] : group[1]!.split('|')
1078
+ const rewrite =
1079
+ group === null || BUN_GLOB_WILDCARDS.test(group[1]!)
1080
+ ? 'braces ("{a,b}") or one entry per alternative'
1081
+ : `"${glob.replace(group[0], names.length === 1 ? names[0]! : `{${names.join(',')}}`)}"`
1082
+ throw new UserError(
1083
+ `${where}: "${glob}" is an extglob, which vx's glob engine reads as literal text or a ` +
1084
+ `plain wildcard, never as the alternatives it lists, so the cache key would miss the ` +
1085
+ `files it names. Write ${rewrite} instead.`,
1086
+ )
1087
+ }
1069
1088
  }
1070
1089
 
1071
1090
  /**
@@ -2,6 +2,8 @@
2
2
  // docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
3
3
 
4
4
  export {
5
+ affectedChanges,
6
+ type AffectedChanges,
5
7
  affectedProjects,
6
8
  defaultAffectedBase,
7
9
  type FingerprintClaims,
@@ -4,6 +4,7 @@ import path from 'node:path'
4
4
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
5
5
  import {
6
6
  BUN_GLOB_WILDCARDS,
7
+ EXTGLOB,
7
8
  isOutOfFds,
8
9
  isPermissionError,
9
10
  relPosix,
@@ -323,16 +324,6 @@ function assertGlobList(value: unknown, file: string, field: string): string[] {
323
324
  return value as string[]
324
325
  }
325
326
 
326
- /**
327
- * An extglob group: `!(…)`, `@(…)`, `+(…)`, `*(…)`, `?(…)`. npm (minimatch)
328
- * and yarn (micromatch) read `packages/!(x)` as "every directory in
329
- * packages/ but x"; `Bun.Glob` has no extglob, and its scan read the
330
- * segment as a wildcard, so the excluded package became a project
331
- * (turborepo#3766) while its match read the same text literally. vx cannot
332
- * honour the pattern, so it refuses it rather than read it either way.
333
- */
334
- const EXTGLOB = /[!@+*?]\(/
335
-
336
327
  function extglobRefusal(pattern: string, file: string, field: string): UserError {
337
328
  const head = `${file}: \`${field}\` entry "${pattern}" is an extglob, which vx's glob engine does not read`
338
329
  // The common shape, a whole segment excluding plain names, has an exact