@vzn/vx 0.0.613 → 0.0.615

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.613",
3
+ "version": "0.0.615",
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.613",
25
- "@vzn/vx-linux-arm64": "0.0.613",
26
- "@vzn/vx-darwin-x64": "0.0.613",
27
- "@vzn/vx-darwin-arm64": "0.0.613"
24
+ "@vzn/vx-linux-x64": "0.0.615",
25
+ "@vzn/vx-linux-arm64": "0.0.615",
26
+ "@vzn/vx-darwin-x64": "0.0.615",
27
+ "@vzn/vx-darwin-arm64": "0.0.615"
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
  }
@@ -2,6 +2,7 @@ import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
2
2
  import {
3
3
  asTrees,
4
4
  isLiteralPattern,
5
+ normalizeGlob,
5
6
  relPosix,
6
7
  splitNegations,
7
8
  staticPrefix,
@@ -1152,6 +1153,64 @@ function collideAcross(
1152
1153
  */
1153
1154
  const GLOB_HEAD_END = /[*?{}\\!]/
1154
1155
 
1156
+ /** `firstSegment`'s answer for a glob that can start anywhere. */
1157
+ const ANYWHERE = ''
1158
+
1159
+ /** Where a glob's first segment ends: a separator, or anything `GLOB_HEAD_END` stops at. */
1160
+ const FIRST_SEGMENT_END = /[*?{}\\!/]/
1161
+
1162
+ const firstSegments = new Map<string, string>()
1163
+
1164
+ /**
1165
+ * The first path segment every path a glob (or its `asTrees` twin) can
1166
+ * match starts with, or `ANYWHERE`. A task glob matches whole paths, so
1167
+ * two globs with different first segments select disjoint sets, and none
1168
+ * of `outputsOverlap`'s three cases can hold for them. Memoised by the
1169
+ * raw string: a pure function, and one glob (`src/**`) recurs in every
1170
+ * project.
1171
+ */
1172
+ function firstSegment(raw: string): string {
1173
+ let seg = firstSegments.get(raw)
1174
+ if (seg !== undefined) return seg
1175
+ const g = normalizeGlob(raw)
1176
+ const cut = g.search(FIRST_SEGMENT_END)
1177
+ seg = cut === -1 ? g : g[cut] === '/' ? g.slice(0, cut) : ANYWHERE
1178
+ if (seg === '.' || seg === '..') seg = ANYWHERE
1179
+ firstSegments.set(raw, seg)
1180
+ return seg
1181
+ }
1182
+
1183
+ /**
1184
+ * False only when no pair `overlapCandidates` could return can overlap:
1185
+ * every glob starts in a named segment and no two members (on different
1186
+ * sides, given `sideOf`) share one. The per-project buckets are nearly
1187
+ * all of this shape (`src/**` read, `dist/**` written), and building the
1188
+ * indexes for each cost the warm run's graph stage tens of milliseconds
1189
+ * at 1,090 projects (X-148).
1190
+ */
1191
+ function mayOverlap<T>(
1192
+ tasks: readonly T[],
1193
+ globsOf: (n: T) => readonly string[] | undefined,
1194
+ sideOf?: (n: T) => boolean,
1195
+ ): boolean {
1196
+ // Per segment: the first member seen (one side) or the sides seen as a mask.
1197
+ const seen = new Map<string, number>()
1198
+ for (let i = 0; i < tasks.length; i++) {
1199
+ const mark = sideOf === undefined ? i : sideOf(tasks[i]!) ? 2 : 1
1200
+ for (const g of globsOf(tasks[i]!) ?? []) {
1201
+ const seg = firstSegment(g)
1202
+ if (seg === ANYWHERE) return true
1203
+ const was = seen.get(seg)
1204
+ if (was === undefined) seen.set(seg, mark)
1205
+ else if (sideOf === undefined) {
1206
+ if (was !== i) return true
1207
+ } else if ((was | mark) === 3) return true
1208
+ else seen.set(seg, was | mark)
1209
+ }
1210
+ }
1211
+ return false
1212
+ }
1213
+
1155
1214
  /**
1156
1215
  * The pairs `[i, j]`, `i < j`, of `tasks` whose declared outputs CAN
1157
1216
  * overlap, ascending by `i` then `j`: a superset of the pairs
@@ -1181,6 +1240,7 @@ function overlapCandidates<T>(
1181
1240
  globsOf: (n: T) => readonly string[] | undefined,
1182
1241
  sideOf?: (n: T) => boolean,
1183
1242
  ): Array<[number, number]> {
1243
+ if (!mayOverlap(tasks, globsOf, sideOf)) return []
1184
1244
  const sides = sideOf === undefined ? 1 : 2
1185
1245
  // The index a member of side `s` looks up: its own side's, or the other's.
1186
1246
  const across = (s: number): number => (sides === 1 ? s : 1 - s)
@@ -1356,6 +1416,7 @@ function refuseSelfClean(node: TaskNode): void {
1356
1416
  const cache = node.config.cache
1357
1417
  if (cache === undefined) return
1358
1418
  for (const field of ['files', 'workspaceFiles'] as const) {
1419
+ if (!coversAny(cache.inputs[field], cache.outputs[field])) continue
1359
1420
  const inputs = splitNegations(cache.inputs[field] ?? [])
1360
1421
  const out = splitNegations(cache.outputs[field] ?? [])
1361
1422
  const outputs = out.positive
@@ -1384,6 +1445,39 @@ function refuseSelfClean(node: TaskNode): void {
1384
1445
  }
1385
1446
  }
1386
1447
 
1448
+ /**
1449
+ * False only when no positive output can cover a positive input: each
1450
+ * starts in a named first segment and no input shares one with an output
1451
+ * (`firstSegment`). The common task reads `src/**` and writes `dist/**`.
1452
+ */
1453
+ function coversAny(
1454
+ inputs: readonly string[] | undefined,
1455
+ outputs: readonly string[] | undefined,
1456
+ ): boolean {
1457
+ if (inputs === undefined || outputs === undefined) return false
1458
+ let anyOutput = false
1459
+ for (const go of outputs) {
1460
+ if (go.startsWith('!')) continue
1461
+ anyOutput = true
1462
+ if (firstSegment(go) === ANYWHERE) return true
1463
+ }
1464
+ if (!anyOutput) return false
1465
+ for (const gi of inputs) {
1466
+ if (gi.startsWith('!')) continue
1467
+ const seg = firstSegment(gi)
1468
+ if (seg === ANYWHERE) return true
1469
+ for (const go of outputs) if (!go.startsWith('!') && firstSegment(go) === seg) return true
1470
+ }
1471
+ return false
1472
+ }
1473
+
1474
+ /** A glob list's positive entries: the list itself when it takes nothing back. */
1475
+ function positives(globs: readonly string[] | undefined): readonly string[] {
1476
+ if (globs === undefined) return []
1477
+ for (const g of globs) if (g.startsWith('!')) return splitNegations(globs).positive
1478
+ return globs
1479
+ }
1480
+
1387
1481
  /** One task's inputs (`reads`) or outputs in one namespace, for `detectInputOverlaps`. */
1388
1482
  interface Side {
1389
1483
  node: TaskNode
@@ -1431,8 +1525,8 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1431
1525
  for (const n of nodes.values()) {
1432
1526
  const cache = n.config.cache
1433
1527
  if (cache === undefined) continue
1434
- const outFiles = splitNegations(cache.outputs.files).positive
1435
- const outWs = splitNegations(cache.outputs.workspaceFiles ?? []).positive
1528
+ const outFiles = positives(cache.outputs.files)
1529
+ const outWs = positives(cache.outputs.workspaceFiles ?? [])
1436
1530
  if (outFiles.length > 0) {
1437
1531
  bucket(n.projectName).push({ node: n, globs: outFiles, reads: false })
1438
1532
  filesWriters.push(n)
@@ -1443,8 +1537,8 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1443
1537
  wsWriters.push(side)
1444
1538
  }
1445
1539
  if (isGroupTask(n)) continue
1446
- const inFiles = splitNegations(cache.inputs.files).positive
1447
- const inWs = splitNegations(cache.inputs.workspaceFiles ?? []).positive
1540
+ const inFiles = positives(cache.inputs.files)
1541
+ const inWs = positives(cache.inputs.workspaceFiles ?? [])
1448
1542
  if (inFiles.length > 0) {
1449
1543
  bucket(n.projectName).push({ node: n, globs: inFiles, reads: true })
1450
1544
  filesReaders.push(n)
@@ -1471,7 +1565,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1471
1565
  const sides = [...wsWriters]
1472
1566
  for (const node of filesReaders) {
1473
1567
  const rel = relPosix(workspaceRoot, node.projectDir)
1474
- const globs = splitNegations(node.config.cache!.inputs.files).positive.map((g) =>
1568
+ const globs = positives(node.config.cache!.inputs.files).map((g) =>
1475
1569
  rel === '' ? g : `${rel}/${g}`,
1476
1570
  )
1477
1571
  sides.push({ node, globs, reads: true, rel })
@@ -1488,7 +1582,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1488
1582
  if (workspaceRoot !== undefined) {
1489
1583
  for (const node of filesWriters) {
1490
1584
  const rel = relPosix(workspaceRoot, node.projectDir)
1491
- const globs = splitNegations(node.config.cache!.outputs.files).positive.map((g) =>
1585
+ const globs = positives(node.config.cache!.outputs.files).map((g) =>
1492
1586
  rel === '' ? g : `${rel}/${g}`,
1493
1587
  )
1494
1588
  rooted.push({ node, globs, reads: false, rel })
@@ -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,
@@ -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 = [