@vzn/vx 0.0.508 → 0.0.510
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 +5 -5
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +56 -1
- package/src/cli/run.ts +15 -5
- package/src/cli/select.ts +22 -4
- package/src/cli/task-verb.ts +3 -2
- package/src/index.ts +2 -0
- package/src/orchestrator/affected-tasks.ts +113 -0
- package/src/orchestrator/excluded-keys.ts +2 -3
- package/src/orchestrator/execute-task.ts +14 -3
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +8 -5
- package/src/orchestrator/options.ts +7 -1
- package/src/orchestrator/plan.ts +9 -8
- package/src/orchestrator/prepare.ts +58 -40
- package/src/orchestrator/projects.ts +29 -2
- package/src/orchestrator/run.ts +13 -1
- package/src/orchestrator/stable-keys.ts +15 -2
- package/src/orchestrator/task-hash.ts +13 -2
- package/src/workspace/affected.ts +71 -14
- package/src/workspace/index.ts +2 -0
- package/src/workspace/migration.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vzn/vx",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.510",
|
|
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.
|
|
25
|
-
"@vzn/vx-linux-arm64": "0.0.
|
|
26
|
-
"@vzn/vx-darwin-x64": "0.0.
|
|
27
|
-
"@vzn/vx-darwin-arm64": "0.0.
|
|
24
|
+
"@vzn/vx-linux-x64": "0.0.510",
|
|
25
|
+
"@vzn/vx-linux-arm64": "0.0.510",
|
|
26
|
+
"@vzn/vx-darwin-x64": "0.0.510",
|
|
27
|
+
"@vzn/vx-darwin-arm64": "0.0.510"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
30
|
"@anthropic-ai/sandbox-runtime": "^0.0.76"
|
package/src/cache/index.ts
CHANGED
package/src/cache/inputs.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
502
|
-
|
|
503
|
-
|
|
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
|
|
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 ?? {})
|
|
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 } : {}) })
|
package/src/cli/task-verb.ts
CHANGED
|
@@ -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.
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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,
|
|
@@ -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 (`
|
|
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)
|
|
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
|
package/src/orchestrator/plan.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
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
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
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
|
|
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
|
package/src/orchestrator/run.ts
CHANGED
|
@@ -385,7 +385,19 @@ async function runOnBus(
|
|
|
385
385
|
return { ok: false, outcomes: [], refused }
|
|
386
386
|
}
|
|
387
387
|
if (prepared.empty === 'none-affected') {
|
|
388
|
-
|
|
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 {
|
|
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(
|
|
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
|
-
* `
|
|
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 `
|
|
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
|
|
@@ -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
|
-
|
|
230
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
297
|
-
for (const name of dependents)
|
|
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
|
-
|
|
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
|
|
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)
|
|
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
|
package/src/workspace/index.ts
CHANGED
|
@@ -597,7 +597,7 @@ function renderConfigFile(
|
|
|
597
597
|
// runs the vx binary without the package installed. `mjs` is the same
|
|
598
598
|
// object with nothing to erase.
|
|
599
599
|
const lines: string[] = [
|
|
600
|
-
`// Generated by \`${verb}\` from ${source}. Review the TODO(vx-migrate) comments
|
|
600
|
+
`// Generated by \`${verb}\` from ${source}.${p.tasks.some((t) => t.todos.length > 0) ? ' Review the TODO(vx-migrate) comments.' : ''}`,
|
|
601
601
|
]
|
|
602
602
|
if (format === 'ts') lines.push("import type { ProjectConfig } from '@vzn/vx/config'")
|
|
603
603
|
if (p.importLines.length > 0) lines.push(...p.importLines)
|