@vzn/vx 0.0.652 → 0.0.653
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/README.md +2 -2
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +1 -0
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +25 -3
- package/src/cache/archive.ts +196 -31
- package/src/cache/cache.ts +863 -225
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +22 -5
- package/src/cache/index.ts +10 -1
- package/src/cache/inputs.ts +573 -152
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +23 -10
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +50 -10
- package/src/cache/schema.ts +42 -9
- package/src/cli/cache.ts +147 -34
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +25 -2
- package/src/cli/index.ts +38 -7
- package/src/cli/info.ts +4 -3
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +184 -41
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +1 -1
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +63 -18
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +72 -8
- package/src/cli/watch-set.ts +6 -4
- package/src/cli/watch.ts +179 -25
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +9 -0
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +76 -15
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-runtime.ts +134 -37
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +173 -38
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +327 -141
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +15 -5
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +1 -0
- package/src/history/metrics.ts +189 -69
- package/src/index.ts +2 -1
- package/src/orchestrator/admission.ts +13 -9
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +41 -12
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +3 -3
- package/src/orchestrator/excluded-keys.ts +14 -2
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +564 -123
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +136 -87
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +25 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +33 -53
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +31 -17
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +81 -15
- package/src/orchestrator/plan.ts +37 -15
- package/src/orchestrator/plugin-host.ts +232 -108
- package/src/orchestrator/plugin.ts +53 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +43 -14
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +12 -10
- package/src/orchestrator/run-report.ts +2 -1
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +443 -107
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +286 -59
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +20 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +17 -9
- package/src/orchestrator/task-artifacts.ts +15 -2
- package/src/orchestrator/task-hash.ts +187 -30
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +80 -12
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +20 -3
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +165 -157
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +107 -30
- package/src/report/plain-output.ts +13 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +57 -14
- package/src/settings.ts +42 -6
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +17 -3
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +50 -6
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +16 -0
- package/src/util/size.ts +12 -2
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +126 -25
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +10 -1
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +46 -25
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/cli/run.ts
CHANGED
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
shellQuote,
|
|
10
10
|
type RunOptions,
|
|
11
11
|
} from '../orchestrator/index.js'
|
|
12
|
-
import type
|
|
12
|
+
import { type ContinueMode, isKeyFailed } from '../graph/index.js'
|
|
13
13
|
import {
|
|
14
14
|
applyCacheFlags,
|
|
15
15
|
type CacheFlags,
|
|
@@ -18,7 +18,13 @@ import {
|
|
|
18
18
|
gitRefusal,
|
|
19
19
|
parseCachePolicy,
|
|
20
20
|
} from '../cache/index.js'
|
|
21
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
type AffectedFilter,
|
|
23
|
+
affectedFilterFor,
|
|
24
|
+
findCwdSelection,
|
|
25
|
+
pickTask,
|
|
26
|
+
resolveFilters,
|
|
27
|
+
} from './select.js'
|
|
22
28
|
import { nxTargetHint, taskNamesHere, undeclaredTasks } from './task-verb.js'
|
|
23
29
|
import { envFlagOn, hasControl, parseDecimalInt } from '../util/index.js'
|
|
24
30
|
import * as ui from '../ui/index.js'
|
|
@@ -408,7 +414,7 @@ async function scopeFilters(
|
|
|
408
414
|
parsed: RunArgs,
|
|
409
415
|
cwd: string,
|
|
410
416
|
): Promise<
|
|
411
|
-
{ filterStrings: string[];
|
|
417
|
+
{ filterStrings: string[]; affected?: AffectedFilter } | { error: string; code?: string }
|
|
412
418
|
> {
|
|
413
419
|
// `--affected[=<base>]` is sugar for `--filter '...[<base>]'`: the
|
|
414
420
|
// changed projects AND their dependents — what a CI gate must run, what
|
|
@@ -421,14 +427,21 @@ async function scopeFilters(
|
|
|
421
427
|
// drops app from either side (item 955 had put it first for that). First
|
|
422
428
|
// it stays, so a message naming the filters names it first.
|
|
423
429
|
const filterStrings = [...parsed.filters]
|
|
424
|
-
let
|
|
430
|
+
let affected: AffectedFilter | undefined
|
|
425
431
|
if (parsed.affected !== undefined) {
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
432
|
+
// With --all given, "run --all" is advice already taken.
|
|
433
|
+
const f = await affectedFilterFor(
|
|
434
|
+
cwd,
|
|
435
|
+
parsed.affected,
|
|
436
|
+
parsed.all
|
|
437
|
+
? 'drop --affected, --all runs every project (the cache skips what did not change)'
|
|
438
|
+
: undefined,
|
|
439
|
+
)
|
|
440
|
+
if ('error' in f) return f
|
|
441
|
+
affected = f
|
|
442
|
+
filterStrings.unshift(f.filter)
|
|
430
443
|
}
|
|
431
|
-
return { filterStrings, ...(
|
|
444
|
+
return { filterStrings, ...(affected !== undefined ? { affected } : {}) }
|
|
432
445
|
}
|
|
433
446
|
|
|
434
447
|
/**
|
|
@@ -477,10 +490,10 @@ export async function resolveRunOptions(
|
|
|
477
490
|
|
|
478
491
|
const scope = await scopeFilters(parsed, cwd)
|
|
479
492
|
if ('error' in scope) return scope
|
|
480
|
-
const { filterStrings, affectedFilter } = scope
|
|
493
|
+
const { filterStrings, affected: affectedFilter } = scope
|
|
481
494
|
// `...[<base>]`: the base alone.
|
|
482
495
|
if (scopeOut !== undefined && affectedFilter !== undefined)
|
|
483
|
-
scopeOut.affectedBase = affectedFilter.slice(4, -1)
|
|
496
|
+
scopeOut.affectedBase = affectedFilter.filter.slice(4, -1)
|
|
484
497
|
|
|
485
498
|
// Project scope applies to bare task names only. Anchored entries
|
|
486
499
|
// (pkg#task) resolve directly to their own project regardless.
|
|
@@ -611,17 +624,47 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
611
624
|
// anchored pkg#task; the rest of the pipeline treats it like any
|
|
612
625
|
// explicit anchored positional.
|
|
613
626
|
if (tasks.length === 0) {
|
|
627
|
+
const load = parsed.cacheDir !== undefined ? { cacheDir: parsed.cacheDir } : {}
|
|
628
|
+
// `--filter` / `--affected` scope the menu and the list, as they scope a
|
|
629
|
+
// bare task: the menu listed every project and the anchored pick ran
|
|
630
|
+
// outside it; the list named the cwd's tasks.
|
|
631
|
+
const selection = async (): Promise<
|
|
632
|
+
{ only?: ReadonlySet<string> } | { error: string; code?: string } | { empty: string }
|
|
633
|
+
> => {
|
|
634
|
+
const scope = await scopeFilters(parsed, cwd)
|
|
635
|
+
if ('error' in scope) return scope
|
|
636
|
+
if (scope.filterStrings.length === 0) return {}
|
|
637
|
+
const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affected)
|
|
638
|
+
if ('error' in selected || 'empty' in selected) return selected
|
|
639
|
+
return { only: new Set(selected.names) }
|
|
640
|
+
}
|
|
614
641
|
// A JSON answer is for a program, which cannot pick from a menu: an
|
|
615
642
|
// agent on a pty waited at the picker forever.
|
|
616
643
|
const forProgram = parsed.dry === 'json'
|
|
617
644
|
if (!process.stdin.isTTY || forProgram) {
|
|
618
|
-
const
|
|
619
|
-
|
|
620
|
-
const
|
|
645
|
+
const scoped = await selection()
|
|
646
|
+
if ('error' in scoped) return refuse('run', args, scoped.error, scoped.code ?? 'VX_E_USAGE')
|
|
647
|
+
const asked =
|
|
648
|
+
'empty' in scoped ? undefined : (scoped.only ?? (parsed.all ? 'all' : undefined))
|
|
649
|
+
const here = 'empty' in scoped ? null : await taskNamesHere(cwd, asked)
|
|
650
|
+
const names = here?.names ?? []
|
|
651
|
+
const shown = names.slice(0, TASKS_SHOWN)
|
|
652
|
+
const more = names.length - shown.length
|
|
653
|
+
const list = `${shown.join(', ')}${more > 0 ? ` and ${more} more` : ''}`
|
|
654
|
+
// Outside a project a bare name is refused without --all: the list
|
|
655
|
+
// said "tasks here", and `vx run build` then said "not inside a project".
|
|
621
656
|
const tasksHere =
|
|
622
|
-
|
|
623
|
-
?
|
|
624
|
-
:
|
|
657
|
+
'empty' in scoped
|
|
658
|
+
? scoped.empty
|
|
659
|
+
: shown.length === 0
|
|
660
|
+
? 'vx run <task>, e.g. vx run build'
|
|
661
|
+
: asked === 'all'
|
|
662
|
+
? `every project's tasks: ${list}`
|
|
663
|
+
: asked !== undefined
|
|
664
|
+
? `the selected projects' tasks: ${list}`
|
|
665
|
+
: here?.inProject === false
|
|
666
|
+
? `not inside a project, the workspace's tasks with --all: ${list}`
|
|
667
|
+
: `tasks here: ${list}`
|
|
625
668
|
return refuse(
|
|
626
669
|
'run',
|
|
627
670
|
args,
|
|
@@ -635,23 +678,13 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
635
678
|
sayLine('error', refusal.message, 'vx run')
|
|
636
679
|
return 1
|
|
637
680
|
}
|
|
638
|
-
const
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
let only: ReadonlySet<string> | undefined
|
|
644
|
-
if (scope.filterStrings.length > 0) {
|
|
645
|
-
const selected = await resolveFilters(cwd, scope.filterStrings, load, scope.affectedFilter)
|
|
646
|
-
if ('error' in selected)
|
|
647
|
-
return refuse('run', args, selected.error, selected.code ?? 'VX_E_USAGE')
|
|
648
|
-
if ('empty' in selected) {
|
|
649
|
-
sayNotice('info', 'vx run', selected.empty)
|
|
650
|
-
return 0
|
|
651
|
-
}
|
|
652
|
-
only = new Set(selected.names)
|
|
681
|
+
const scoped = await selection()
|
|
682
|
+
if ('error' in scoped) return refuse('run', args, scoped.error, scoped.code ?? 'VX_E_USAGE')
|
|
683
|
+
if ('empty' in scoped) {
|
|
684
|
+
sayNotice('info', 'vx run', scoped.empty)
|
|
685
|
+
return 0
|
|
653
686
|
}
|
|
654
|
-
const chosen = await pickTask(cwd, {}, load, only)
|
|
687
|
+
const chosen = await pickTask(cwd, {}, load, scoped.only)
|
|
655
688
|
if (chosen === 'interrupted') return 130
|
|
656
689
|
if (!chosen) return 1
|
|
657
690
|
picked = `${chosen.project}#${chosen.task}`
|
|
@@ -713,7 +746,8 @@ export async function runCmd(args: readonly string[]): Promise<number> {
|
|
|
713
746
|
} else {
|
|
714
747
|
ui.print('out', planReport(plan))
|
|
715
748
|
}
|
|
716
|
-
|
|
749
|
+
// The run fails a task whose key cannot be derived; its plan does too.
|
|
750
|
+
return isKeyFailed(plan) ? 1 : 0
|
|
717
751
|
}
|
|
718
752
|
|
|
719
753
|
// A run executes in THIS process, always. Where an individual task's
|
package/src/cli/select.ts
CHANGED
|
@@ -25,10 +25,10 @@ import {
|
|
|
25
25
|
type ProjectMeta,
|
|
26
26
|
readLockfile,
|
|
27
27
|
} from '../workspace/index.js'
|
|
28
|
-
import type { ProjectConfig } from '../config.js'
|
|
28
|
+
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
29
29
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
30
30
|
import { isDefaultBuild, parseDependencySpec } from '../graph/index.js'
|
|
31
|
-
import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
|
|
31
|
+
import { declaresInput, lazyGitEnumeration, workspaceFilesReachInto } from '../cache/index.js'
|
|
32
32
|
import {
|
|
33
33
|
isUserError,
|
|
34
34
|
listed,
|
|
@@ -45,7 +45,6 @@ import {
|
|
|
45
45
|
claimedExtraFiles,
|
|
46
46
|
claimedDigests,
|
|
47
47
|
fingerprintClaims,
|
|
48
|
-
gitOfDiscovery,
|
|
49
48
|
hasHook,
|
|
50
49
|
keepDiscoveryGraph,
|
|
51
50
|
} from '../orchestrator/index.js'
|
|
@@ -77,17 +76,53 @@ export async function workspaceGlobOwners(
|
|
|
77
76
|
nested: readonly string[] = [],
|
|
78
77
|
/** `AffectedChanges.dirLinks`. */
|
|
79
78
|
dirLinks: ReadonlySet<string> = new Set(),
|
|
79
|
+
/** `AffectedChanges.untracked`. */
|
|
80
|
+
untracked: ReadonlySet<string> = new Set(),
|
|
80
81
|
): Promise<string[]> {
|
|
81
|
-
|
|
82
|
+
// The changed paths a `workspaceFiles` list matches, asked once per
|
|
83
|
+
// distinct list rather than once per task: tasks × changed paths was
|
|
84
|
+
// 14.5 s on 8000 projects with 64k changed paths. A tracked match selects
|
|
85
|
+
// any task declaring the list; an untracked one only a task whose own
|
|
86
|
+
// outputs it is not, so those are kept for the per-task check.
|
|
87
|
+
const byList = new Map<string, { tracked: boolean; loose: string[] }>()
|
|
88
|
+
const matchesOf = (ws: readonly string[]) => {
|
|
89
|
+
const key = ws.join('\0')
|
|
90
|
+
let found = byList.get(key)
|
|
91
|
+
if (found !== undefined) return found
|
|
92
|
+
found = { tracked: false, loose: [] }
|
|
93
|
+
const probe = { inputs: { files: [], workspaceFiles: ws } } as unknown as TaskConfig
|
|
94
|
+
for (const rel of changed) {
|
|
95
|
+
const loose = untracked.has(rel)
|
|
96
|
+
if (!declaresInput(probe, null, rel, dirLinks.has(rel))) continue
|
|
97
|
+
if (!loose) {
|
|
98
|
+
found.tracked = true
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
found.loose.push(rel)
|
|
102
|
+
}
|
|
103
|
+
byList.set(key, found)
|
|
104
|
+
return found
|
|
105
|
+
}
|
|
106
|
+
const declaresMatch = (config: ProjectConfig, projectAbs: string): boolean => {
|
|
107
|
+
let projectDir: string | undefined
|
|
82
108
|
for (const task of Object.values(config.tasks ?? {})) {
|
|
83
|
-
|
|
109
|
+
const ws = task.inputs?.workspaceFiles
|
|
110
|
+
if (ws === undefined || ws.length === 0) continue
|
|
111
|
+
const input = (rel: string): boolean => {
|
|
112
|
+
const loose = untracked.has(rel)
|
|
113
|
+
if (loose) projectDir ??= relPosix(root, projectAbs)
|
|
114
|
+
return declaresInput(task, null, rel, dirLinks.has(rel), loose, projectDir)
|
|
115
|
+
}
|
|
116
|
+
// A hand-edited lock entry that is no list is asked path by path.
|
|
117
|
+
const found = Array.isArray(ws) ? matchesOf(ws) : { tracked: changed.some(input), loose: [] }
|
|
118
|
+
if (found.tracked || found.loose.some(input)) return true
|
|
84
119
|
if (nested.some((dir) => workspaceFilesReachInto(task, dir))) return true
|
|
85
120
|
}
|
|
86
121
|
return false
|
|
87
122
|
}
|
|
88
123
|
try {
|
|
89
124
|
const staged = await stagedLoad()
|
|
90
|
-
return [...staged.values()].filter((p) => declaresMatch(p.config)).map((p) => p.name)
|
|
125
|
+
return [...staged.values()].filter((p) => declaresMatch(p.config, p.dir)).map((p) => p.name)
|
|
91
126
|
} catch {
|
|
92
127
|
// Fall through to the per-file sweep.
|
|
93
128
|
}
|
|
@@ -102,7 +137,7 @@ export async function workspaceGlobOwners(
|
|
|
102
137
|
const dir = lockKey(root, meta.dir)
|
|
103
138
|
if (!Object.hasOwn(lock.projects, dir)) continue
|
|
104
139
|
try {
|
|
105
|
-
if (declaresMatch(lock.projects[dir]!.config)) owners.push(meta.name)
|
|
140
|
+
if (declaresMatch(lock.projects[dir]!.config, meta.dir)) owners.push(meta.name)
|
|
106
141
|
} catch {
|
|
107
142
|
// A hand-edited entry that will not read cannot be shown to declare a
|
|
108
143
|
// matching glob; surfacing it here would fail a run the load tolerates.
|
|
@@ -287,6 +322,12 @@ function refusalOf(err: unknown): Refusal {
|
|
|
287
322
|
: { error: err.message }
|
|
288
323
|
}
|
|
289
324
|
|
|
325
|
+
/** The filter `--affected` became; `env` is the `VX_AFFECTED_BASE` value that gave its base. */
|
|
326
|
+
export interface AffectedFilter {
|
|
327
|
+
readonly filter: string
|
|
328
|
+
readonly env?: string
|
|
329
|
+
}
|
|
330
|
+
|
|
290
331
|
/**
|
|
291
332
|
* The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
|
|
292
333
|
* projects and their dependents. An empty base is `VX_AFFECTED_BASE`'s,
|
|
@@ -298,9 +339,10 @@ export async function affectedFilterFor(
|
|
|
298
339
|
affected: string,
|
|
299
340
|
/** What the verb does instead on the base branch (`defaultAffectedBase`). */
|
|
300
341
|
instead?: string,
|
|
301
|
-
): Promise<
|
|
342
|
+
): Promise<AffectedFilter | Refusal> {
|
|
302
343
|
const root = await findWorkspaceRoot(cwd)
|
|
303
|
-
|
|
344
|
+
const env = affected === '' ? (settingEnv('affectedBase') as string | undefined) : undefined
|
|
345
|
+
let base = affected || (env ?? '')
|
|
304
346
|
if (base === '') {
|
|
305
347
|
const ws = await loadCliWorkspace(root)
|
|
306
348
|
try {
|
|
@@ -312,42 +354,98 @@ export async function affectedFilterFor(
|
|
|
312
354
|
return refusalOf(err)
|
|
313
355
|
}
|
|
314
356
|
}
|
|
315
|
-
return `...[${base}]`
|
|
357
|
+
return env === undefined ? { filter: `...[${base}]` } : { filter: `...[${base}]`, env }
|
|
316
358
|
}
|
|
317
359
|
|
|
318
360
|
export async function resolveFilters(
|
|
319
361
|
cwd: string,
|
|
320
362
|
raw: string[],
|
|
321
363
|
load: CliLoadOptions = {},
|
|
322
|
-
/** The
|
|
323
|
-
affected?:
|
|
364
|
+
/** The filter `--affected` became: its diff is kept per path for the run. */
|
|
365
|
+
affected?: AffectedFilter,
|
|
324
366
|
/** The verb a warning names: `vx show` and `vx watch` select as a run does. */
|
|
325
367
|
verb: 'run' | 'show' | 'watch' = 'run',
|
|
326
368
|
): Promise<FilterResolution> {
|
|
327
369
|
const root = await findWorkspaceRoot(cwd)
|
|
328
|
-
const projects = await loadWorkspaceProjects(cwd, load)
|
|
329
370
|
let parsed: ReturnType<typeof parseFilter>[]
|
|
330
371
|
try {
|
|
331
372
|
parsed = raw.map((r) => parseFilter(r, root))
|
|
332
373
|
} catch (err) {
|
|
374
|
+
await loadWorkspaceProjects(cwd, load)
|
|
333
375
|
return refusalOf(err)
|
|
334
376
|
}
|
|
377
|
+
// A filter that diffs against git reads its untracked files from the
|
|
378
|
+
// run's whole-tree walk (one walk where it spawned its own), and the run
|
|
379
|
+
// reuses it with the discovery (I-26). The walk needs only the root, so
|
|
380
|
+
// it starts before the workspace config and discovery, not after them.
|
|
381
|
+
const git = parsed.some((f) => f.gitSince !== undefined) ? lazyGitEnumeration(root) : undefined
|
|
382
|
+
void git?.start()
|
|
383
|
+
const discovering = discoverCliProjects(root, load, git)
|
|
384
|
+
discovering.catch(() => {})
|
|
335
385
|
// `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
|
|
336
386
|
const walksGraph = parsed.some(
|
|
337
387
|
(f) => f.withDeps || f.withDependents || f.onlyDeps || f.sinceViaDeps === true,
|
|
338
388
|
)
|
|
339
|
-
// A filter that diffs against git starts the run's whole-tree walk now:
|
|
340
|
-
// the diff reads its untracked files from it (one walk where it spawned
|
|
341
|
-
// its own), and the run reuses it with the discovery (I-26).
|
|
342
|
-
const git = parsed.some((f) => f.gitSince !== undefined) ? gitOfDiscovery(projects) : undefined
|
|
343
|
-
void git?.start()
|
|
344
389
|
// Every reader of the staged configs in this pass — the `pkg#task`
|
|
345
390
|
// edge walk, the `workspaceFiles` owners of a changed path — shares
|
|
346
391
|
// ONE load, and the run reuses it (`RunOptions.staged`): the `project`
|
|
347
392
|
// stage runs once per project per run.
|
|
348
393
|
let stagedPromise: Promise<Map<string, ProjectEntry>> | undefined
|
|
349
394
|
const stagedOnce = (): Promise<Map<string, ProjectEntry>> =>
|
|
350
|
-
(stagedPromise ??= loadCliProjects(root, projects, 'all', load))
|
|
395
|
+
(stagedPromise ??= discovering.then((projects) => loadCliProjects(root, projects, 'all', load)))
|
|
396
|
+
// A scoped enumeration lists only its projects: null asks git.
|
|
397
|
+
const enumeration = async () => (git === undefined ? undefined : await git.start())
|
|
398
|
+
// Each `[<since>]` diff starts now, before discovery ends: merge-base and
|
|
399
|
+
// diff need only the root and the ref, so they overlap the workspace
|
|
400
|
+
// config, discovery and the config load, where they waited on all three.
|
|
401
|
+
// One after another, as a shallow clone's fetches must be; each is
|
|
402
|
+
// awaited in filter order below, so a refusal comes where it came.
|
|
403
|
+
const diffs = new Map<(typeof parsed)[number], Promise<AffectedChanges | Set<string>>>()
|
|
404
|
+
let previous: Promise<unknown> | undefined
|
|
405
|
+
for (const f of parsed) {
|
|
406
|
+
if (f.gitSince === undefined) continue
|
|
407
|
+
const args: Parameters<typeof affectedChanges>[0] = {
|
|
408
|
+
workspaceRoot: root,
|
|
409
|
+
since: f.gitSince,
|
|
410
|
+
projects: discovering,
|
|
411
|
+
workspaceGlobOwners: async (
|
|
412
|
+
changed: readonly string[],
|
|
413
|
+
nested: readonly string[],
|
|
414
|
+
dirLinks: ReadonlySet<string>,
|
|
415
|
+
untracked: ReadonlySet<string>,
|
|
416
|
+
) =>
|
|
417
|
+
workspaceGlobOwners(
|
|
418
|
+
root,
|
|
419
|
+
await discovering,
|
|
420
|
+
changed,
|
|
421
|
+
load,
|
|
422
|
+
stagedOnce,
|
|
423
|
+
nested,
|
|
424
|
+
dirLinks,
|
|
425
|
+
untracked,
|
|
426
|
+
),
|
|
427
|
+
fingerprintClaims: async () => workspaceFingerprintClaims(root, await discovering, load),
|
|
428
|
+
untracked: async () => (await enumeration())?.untracked ?? null,
|
|
429
|
+
links: async () => {
|
|
430
|
+
const g = await enumeration()
|
|
431
|
+
if (g === undefined || !g.scopes.includes('.')) return null
|
|
432
|
+
const out: string[] = []
|
|
433
|
+
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
434
|
+
return out
|
|
435
|
+
},
|
|
436
|
+
flagged: async () => {
|
|
437
|
+
const g = await enumeration()
|
|
438
|
+
return g !== undefined && g.scopes.includes('.') ? g.flagged : null
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
const diff = (): Promise<AffectedChanges | Set<string>> =>
|
|
442
|
+
f.raw === affected?.filter ? affectedChanges(args) : affectedProjects(args)
|
|
443
|
+
const started = previous === undefined ? diff() : previous.then(diff)
|
|
444
|
+
started.catch(() => {})
|
|
445
|
+
diffs.set(f, started)
|
|
446
|
+
previous = started
|
|
447
|
+
}
|
|
448
|
+
const projects = await discovering
|
|
351
449
|
let edges: Map<string, string[]> | undefined
|
|
352
450
|
if (walksGraph) {
|
|
353
451
|
try {
|
|
@@ -367,50 +465,34 @@ export async function resolveFilters(
|
|
|
367
465
|
}
|
|
368
466
|
}
|
|
369
467
|
const graph = buildPackageGraph(projects, edges)
|
|
370
|
-
// The graph a run reusing this discovery builds is this one when
|
|
371
|
-
// edge
|
|
372
|
-
|
|
468
|
+
// The graph a run reusing this discovery builds is this one when the task
|
|
469
|
+
// edges added none: each is a discovered edge already, or names no project.
|
|
470
|
+
const known = new Set(projects.map((p) => p.name))
|
|
471
|
+
if (
|
|
472
|
+
edges === undefined ||
|
|
473
|
+
projects.every((p) =>
|
|
474
|
+
(edges.get(p.name) ?? []).every((t) => !known.has(t) || p.deps?.includes(t) === true),
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
keepDiscoveryGraph(projects, graph)
|
|
373
478
|
|
|
374
|
-
//
|
|
375
|
-
// applyFilters pass runs.
|
|
376
|
-
// there's only one anyway.
|
|
479
|
+
// Every `[<since>]` filter's diff, started above, is in before the pure
|
|
480
|
+
// applyFilters pass runs.
|
|
377
481
|
const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
|
|
482
|
+
// The diffs that named a change, though maybe in no project: a root file
|
|
483
|
+
// no task declares. Then the tree is not clean against HEAD.
|
|
484
|
+
const changedAny = new Set<(typeof parsed)[number]>()
|
|
378
485
|
let changes: AffectedChanges | undefined
|
|
379
|
-
for (const f of
|
|
380
|
-
if (f.gitSince === undefined) continue
|
|
486
|
+
for (const [f, diff] of diffs) {
|
|
381
487
|
try {
|
|
382
|
-
const
|
|
383
|
-
workspaceRoot: root,
|
|
384
|
-
since: f.gitSince,
|
|
385
|
-
projects,
|
|
386
|
-
workspaceGlobOwners: (
|
|
387
|
-
changed: readonly string[],
|
|
388
|
-
nested: readonly string[],
|
|
389
|
-
dirLinks: ReadonlySet<string>,
|
|
390
|
-
) => workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested, dirLinks),
|
|
391
|
-
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
392
|
-
...(git !== undefined
|
|
393
|
-
? {
|
|
394
|
-
untracked: async () => (await git.start()).untracked,
|
|
395
|
-
// A scoped enumeration lists only its projects: null asks git.
|
|
396
|
-
links: async () => {
|
|
397
|
-
const g = await git.start()
|
|
398
|
-
if (!g.scopes.includes('.')) return null
|
|
399
|
-
const out: string[] = []
|
|
400
|
-
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
401
|
-
return out
|
|
402
|
-
},
|
|
403
|
-
flagged: async () => {
|
|
404
|
-
const g = await git.start()
|
|
405
|
-
return g.scopes.includes('.') ? g.flagged : null
|
|
406
|
-
},
|
|
407
|
-
}
|
|
408
|
-
: {}),
|
|
409
|
-
}
|
|
488
|
+
const answer = await diff
|
|
410
489
|
let names: Set<string>
|
|
411
|
-
if (
|
|
412
|
-
|
|
490
|
+
if (answer instanceof Set) {
|
|
491
|
+
names = answer
|
|
492
|
+
} else {
|
|
493
|
+
changes = answer
|
|
413
494
|
names = changes.projects
|
|
495
|
+
if (changes.changed.length > 0 || changes.nested !== undefined) changedAny.add(f)
|
|
414
496
|
// A `graph` plugin may tie any task to a changed path (an edge, an
|
|
415
497
|
// input), and the run alone builds that graph: every project is a
|
|
416
498
|
// candidate, and the run keeps the tasks its final graph reaches.
|
|
@@ -420,12 +502,18 @@ export async function resolveFilters(
|
|
|
420
502
|
) {
|
|
421
503
|
names = new Set(projects.map((p) => p.name))
|
|
422
504
|
}
|
|
423
|
-
} else {
|
|
424
|
-
names = await affectedProjects(args)
|
|
425
505
|
}
|
|
426
506
|
affectedByFilter.set(f, names)
|
|
427
507
|
} catch (err) {
|
|
428
|
-
|
|
508
|
+
const refusal = refusalOf(err)
|
|
509
|
+
// A base the variable gave was reported as if typed.
|
|
510
|
+
if (
|
|
511
|
+
affected?.env !== undefined &&
|
|
512
|
+
f.raw === affected.filter &&
|
|
513
|
+
refusal.code === 'VX_E_AFFECTED_BASE'
|
|
514
|
+
)
|
|
515
|
+
return { ...refusal, error: `VX_AFFECTED_BASE=${affected.env}: ${refusal.error}` }
|
|
516
|
+
return refusal
|
|
429
517
|
}
|
|
430
518
|
}
|
|
431
519
|
|
|
@@ -482,10 +570,14 @@ export async function resolveFilters(
|
|
|
482
570
|
// the branch under test) can never mark anything affected, and this
|
|
483
571
|
// exit-0 note was the only sign (CI persona, 2026-09-16). Say so.
|
|
484
572
|
const self = includes.map((f) => headBase(root, f.gitSince!)).find((b) => b !== undefined)
|
|
573
|
+
// `HEAD` asked by name checks uncommitted work: "HEAD is HEAD itself"
|
|
574
|
+
// read as nonsense for a valid question.
|
|
485
575
|
const hint =
|
|
486
|
-
self === undefined
|
|
576
|
+
self === undefined || (self === 'HEAD' && includes.some((f) => changedAny.has(f)))
|
|
487
577
|
? ''
|
|
488
|
-
:
|
|
578
|
+
: self === 'HEAD'
|
|
579
|
+
? ' — the working tree has no change against HEAD; to compare commits, use the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)'
|
|
580
|
+
: ` — ${self} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
|
|
489
581
|
return { empty: `nothing affected since ${refs}${hint}` }
|
|
490
582
|
}
|
|
491
583
|
// The diff matched and a walk or an exclusion left nothing: still the
|
|
@@ -507,7 +599,11 @@ export async function resolveFilters(
|
|
|
507
599
|
// app, and `--affected` a `...[<base>]` nobody typed.
|
|
508
600
|
const hint =
|
|
509
601
|
(await unlockedHint(root, parsed, unmatched)) ?? didYouMean(unmatched, projects, tags)
|
|
510
|
-
|
|
602
|
+
// Quoted, as the warning below quotes it: bare, `apq...` and the hint's
|
|
603
|
+
// full stop read `apq....` in the flat message `--format json` carries.
|
|
604
|
+
return {
|
|
605
|
+
error: `no projects matched filter(s): ${unmatched.map((f) => `"${f}"`).join(', ')}${hint}`,
|
|
606
|
+
}
|
|
511
607
|
}
|
|
512
608
|
// Something matched, so the run proceeds; a pattern that matched nothing
|
|
513
609
|
// alongside it is still worth a line — it is probably a typo.
|
|
@@ -520,7 +616,7 @@ export async function resolveFilters(
|
|
|
520
616
|
// Each include is a union: what `--filter other` selected runs its tasks
|
|
521
617
|
// whether or not the `--affected` diff reaches them, and an exclude
|
|
522
618
|
// still removes it (X-10).
|
|
523
|
-
const others = parsed.filter((f) => f.negate || f.raw !== affected)
|
|
619
|
+
const others = parsed.filter((f) => f.negate || f.raw !== affected?.filter)
|
|
524
620
|
const outright =
|
|
525
621
|
changes !== undefined && others.some((f) => !f.negate)
|
|
526
622
|
? [
|