@vzn/vx 0.0.645 → 0.0.648
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 +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
|
@@ -7,10 +7,18 @@
|
|
|
7
7
|
// closes at the bottom of execution, `planRun()` does so via
|
|
8
8
|
// try/finally around its plan() call.
|
|
9
9
|
|
|
10
|
-
import { existsSync } from 'node:fs'
|
|
10
|
+
import { existsSync, realpathSync } from 'node:fs'
|
|
11
11
|
import path from 'node:path'
|
|
12
12
|
import type { WorkspaceConfig } from '../config.js'
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
beginRun,
|
|
15
|
+
mark,
|
|
16
|
+
nearest,
|
|
17
|
+
relPosix,
|
|
18
|
+
type SecretBroker,
|
|
19
|
+
thrownMessage,
|
|
20
|
+
UserError,
|
|
21
|
+
} from '../util/index.js'
|
|
14
22
|
import {
|
|
15
23
|
Cache,
|
|
16
24
|
type CacheLayer,
|
|
@@ -40,6 +48,7 @@ import {
|
|
|
40
48
|
findWorkspaceRoot,
|
|
41
49
|
configuredBaseBranch,
|
|
42
50
|
type LoadReads,
|
|
51
|
+
lockDigest,
|
|
43
52
|
PROJECT_CONFIG_FILENAMES,
|
|
44
53
|
readClaimedFiles,
|
|
45
54
|
resolveCacheDir,
|
|
@@ -47,10 +56,10 @@ import {
|
|
|
47
56
|
type ProjectEntry,
|
|
48
57
|
} from '../workspace/index.js'
|
|
49
58
|
import {
|
|
50
|
-
buildTaskGraph,
|
|
51
59
|
excludeDependencies,
|
|
52
60
|
expandRequested,
|
|
53
61
|
type GraphRules,
|
|
62
|
+
isDefaultBuild,
|
|
54
63
|
isGroupTask,
|
|
55
64
|
refuseFiltersThroughGroups,
|
|
56
65
|
type TaskNode,
|
|
@@ -70,7 +79,6 @@ import {
|
|
|
70
79
|
discoverProjects,
|
|
71
80
|
gitOfDiscovery,
|
|
72
81
|
graphOfDiscovery,
|
|
73
|
-
isDefaultBuild,
|
|
74
82
|
loadProjects,
|
|
75
83
|
loadWorkspacePlugins,
|
|
76
84
|
lockOfDiscovery,
|
|
@@ -82,6 +90,7 @@ import { applyGraphStage } from './graph-stage.js'
|
|
|
82
90
|
import { keyExcludedDependencies } from './excluded-keys.js'
|
|
83
91
|
import { FingerprintWatch } from './fingerprint-watch.js'
|
|
84
92
|
import type { VxPlugin } from './plugin.js'
|
|
93
|
+
import { memoGraph, type GraphMemo } from './graph-memo.js'
|
|
85
94
|
import { askIgnoredOutputs, gatedOutputs } from './stable-keys.js'
|
|
86
95
|
import { cacheRefs, currentBranch, resolveCacheScope } from './run-context.js'
|
|
87
96
|
import { createHashCache, type HashCache } from './task-hash.js'
|
|
@@ -116,6 +125,8 @@ export interface PreparedRun {
|
|
|
116
125
|
cacheScope: string | undefined
|
|
117
126
|
/** The run's remotes in declared order, as the policy gates each; `openRemotes` turns one off. */
|
|
118
127
|
remotes: ResolvedRemote[]
|
|
128
|
+
/** The refs the run reads, in order (`CacheContext.refs`). */
|
|
129
|
+
cacheRefs: readonly string[]
|
|
119
130
|
/**
|
|
120
131
|
* Scheduling priorities from plugins' `schedule` stage (task id → weight,
|
|
121
132
|
* merged over the structural baseline by the scheduler). Empty when no
|
|
@@ -166,6 +177,14 @@ export interface PreparedRun {
|
|
|
166
177
|
* mistaken for a workspace vx was never set up in.
|
|
167
178
|
*/
|
|
168
179
|
anyProjectConfig: boolean
|
|
180
|
+
/** A workspace project that declares no task: no config, and nothing configured reaches it. */
|
|
181
|
+
taskless: (name: string) => boolean
|
|
182
|
+
/**
|
|
183
|
+
* With no config in the lock, one a directory holds anyway (root-relative):
|
|
184
|
+
* written after `vx lock`, so the fix is `vx lock`, not `vx init`. Asked
|
|
185
|
+
* only by a refusal, so a run that goes on pays no stat.
|
|
186
|
+
*/
|
|
187
|
+
unlockedConfig: () => string | undefined
|
|
169
188
|
/** The claimed root files, and whether a task has since rewritten one (fingerprint-watch.ts). */
|
|
170
189
|
fingerprintWatch: FingerprintWatch
|
|
171
190
|
nestedDirsByProject: Map<string, string[]>
|
|
@@ -333,7 +352,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
333
352
|
const asked =
|
|
334
353
|
options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
|
|
335
354
|
// A laptop reads the shared cache and does not write it unless a policy
|
|
336
|
-
// names the remote (
|
|
355
|
+
// names the remote (2026-10-03; Security F1, 2026-10-14): a key
|
|
337
356
|
// holds only declared inputs, so a laptop's write could be replayed on CI.
|
|
338
357
|
// A workspace policy naming the remote asks to write it, as `--cache-policy` does.
|
|
339
358
|
const readOnly =
|
|
@@ -366,7 +385,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
366
385
|
|
|
367
386
|
// Configs load FROM .vx/projects.lock.json with no evaluation and no check of
|
|
368
387
|
// the configs (`vx lock --check` re-evaluates them; a run does not):
|
|
369
|
-
// only `vx lock` evaluates (
|
|
388
|
+
// only `vx lock` evaluates (2026-10-14).
|
|
370
389
|
// See docs/design/config-lock-2026-06.md.
|
|
371
390
|
// Read once, and only when a config is to be read from it: the CLI's
|
|
372
391
|
// selection pass may have staged every config (`options.staged`), and
|
|
@@ -410,6 +429,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
410
429
|
// name, never by count: a config-less project counts as loaded, so a
|
|
411
430
|
// scope holding one hid an unloaded declarer (X-129, X-182).
|
|
412
431
|
const partialLoad = projectsWithConfigs.some((m) => !projects.has(m.name))
|
|
432
|
+
// Asked on a refusal only: a scope never loads a config-less project
|
|
433
|
+
// nothing configured reaches.
|
|
434
|
+
const taskless = (name: string): boolean =>
|
|
435
|
+
!projects.has(name) &&
|
|
436
|
+
projectMetas.some((m) => m.name === name) &&
|
|
437
|
+
!projectsWithConfigs.some((m) => m.name === name)
|
|
413
438
|
|
|
414
439
|
// Boundary geometry considers every workspace project, loaded or not,
|
|
415
440
|
// config or not: `--affected` gives a changed path to the deepest project
|
|
@@ -427,7 +452,20 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
427
452
|
// A dependant's `^build` still reaches it, and `lib#build` names it.
|
|
428
453
|
const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
|
|
429
454
|
let unresolvedTasks = unresolvedRequests(tasks, candidateProjects, projects, isDefaultBuild)
|
|
455
|
+
// A diff that changed only config-less projects loads none of them: no
|
|
456
|
+
// candidate to ask, so every bare name is still to judge, or a typo ran
|
|
457
|
+
// nothing, exit 0.
|
|
458
|
+
if (options.selectedByDiff === true && candidateProjects.length === 0)
|
|
459
|
+
unresolvedTasks = [...new Set([...tasks.filter((t) => !t.includes('#')), ...unresolvedTasks])]
|
|
430
460
|
refuseUnlockedConfig(tasks, projects, projectMetas, workspaceRoot)
|
|
461
|
+
// A scope that loaded no candidate judged no bare name: each is unresolved.
|
|
462
|
+
if (options.projects !== undefined && options.selectedByDiff !== true)
|
|
463
|
+
refuseUnlockedScope(
|
|
464
|
+
candidateProjects.length === 0 ? tasks : unresolvedTasks,
|
|
465
|
+
options.projects,
|
|
466
|
+
projectMetas,
|
|
467
|
+
workspaceRoot,
|
|
468
|
+
)
|
|
431
469
|
if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
|
|
432
470
|
// A name a loaded project declares is no typo, whether or not the load
|
|
433
471
|
// was whole: an unaffected dependency loaded for the closure declared
|
|
@@ -494,6 +532,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
494
532
|
onError: (err) => warnOf(log)(`[vx] remote cache: ${err.message}`),
|
|
495
533
|
})
|
|
496
534
|
}
|
|
535
|
+
// An injected remote is no plugin's: nothing reads refs for it.
|
|
536
|
+
const refs = options.remoteCache ? [] : cacheRefs(workspaceRoot, baseBranch, branchFacts.refs)
|
|
497
537
|
try {
|
|
498
538
|
cache = options.remoteCache
|
|
499
539
|
? new LayeredCache(localCache, remotes, options.uploadConcurrency)
|
|
@@ -502,7 +542,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
502
542
|
cacheDir,
|
|
503
543
|
warn: warnOf(log),
|
|
504
544
|
scope: cacheScope,
|
|
505
|
-
refs
|
|
545
|
+
refs,
|
|
506
546
|
localCache,
|
|
507
547
|
policy,
|
|
508
548
|
remotes,
|
|
@@ -554,6 +594,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
554
594
|
cachePolicy: policy,
|
|
555
595
|
cacheScope,
|
|
556
596
|
remotes,
|
|
597
|
+
cacheRefs: refs,
|
|
557
598
|
keepRunEntry: undefined,
|
|
558
599
|
priorities: () => Promise.resolve(new Map()),
|
|
559
600
|
branch,
|
|
@@ -565,6 +606,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
565
606
|
projects,
|
|
566
607
|
hintProjects,
|
|
567
608
|
anyProjectConfig: projectsWithConfigs.length > 0,
|
|
609
|
+
taskless,
|
|
610
|
+
unlockedConfig: () => unlockedConfigOf(projectsWithConfigs, projectMetas, workspaceRoot),
|
|
568
611
|
fingerprintWatch,
|
|
569
612
|
nestedDirsByProject,
|
|
570
613
|
gitFilesCache: new GitFilesCache(),
|
|
@@ -585,21 +628,38 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
585
628
|
// by a config the scope left out, so the builder hands it back instead of
|
|
586
629
|
// refusing it, and the rest decide.
|
|
587
630
|
// A run never checks the graph rules: `vx lock` and `vx lock --check`
|
|
588
|
-
// do (
|
|
631
|
+
// do (2026-10-14). `exclusiveOutputs: false` still asks the walk
|
|
589
632
|
// for its addition marks.
|
|
590
633
|
const graphRules: GraphRules = { ...runRules(workspaceConfig), locked: true }
|
|
591
634
|
const unproven: Array<[taskId: string, name: string]> = []
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
}
|
|
635
|
+
// Every config is the lock's only when it was read and no `project`
|
|
636
|
+
// plugin edited one (graph-memo.ts).
|
|
637
|
+
const lock =
|
|
638
|
+
hashCache.folds !== undefined &&
|
|
639
|
+
options.staged === undefined &&
|
|
640
|
+
!allStaged &&
|
|
641
|
+
!hasHook(plugins, 'project')
|
|
642
|
+
? lockDigest(await readLock())
|
|
643
|
+
: undefined
|
|
644
|
+
const graphMemo: GraphMemo | undefined =
|
|
645
|
+
lock === undefined ? undefined : { folds: hashCache.folds!, lock, metas: projectMetas }
|
|
646
|
+
const graphOf = (req: typeof requested) => {
|
|
647
|
+
const built = memoGraph(
|
|
648
|
+
{
|
|
649
|
+
projects,
|
|
650
|
+
packageGraph,
|
|
651
|
+
requested: req,
|
|
652
|
+
workspaceRoot,
|
|
653
|
+
rules: graphRules,
|
|
654
|
+
...(partialLoad
|
|
655
|
+
? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
|
|
656
|
+
: {}),
|
|
657
|
+
},
|
|
658
|
+
graphMemo,
|
|
659
|
+
)
|
|
660
|
+
hashCache.graph = built.token
|
|
661
|
+
return built.nodes
|
|
662
|
+
}
|
|
603
663
|
let nodes = graphOf(requested)
|
|
604
664
|
const hasGraphHook = hasHook(plugins, 'graph')
|
|
605
665
|
const graphStage = (): Promise<void> =>
|
|
@@ -686,6 +746,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
686
746
|
// `prepare (graph)` row until 2026-09-10.
|
|
687
747
|
mark('build graph')
|
|
688
748
|
const gitFilesCache = new GitFilesCache()
|
|
749
|
+
gitFilesCache.vxDirs = withinRoot(workspaceRoot, cacheDir)
|
|
689
750
|
// Bulk-populate via a single `git ls-files` at the workspace root —
|
|
690
751
|
// partitions the output by project. Avoids one fork+exec per project
|
|
691
752
|
// (~5-10ms each on Linux; the dominant cold-start cost on big
|
|
@@ -742,6 +803,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
742
803
|
}
|
|
743
804
|
refuseFiltersThroughGroups(nodes)
|
|
744
805
|
const exclude = options.excludeDependencies
|
|
806
|
+
// The nodes are what the graph token names only while nothing edited them.
|
|
807
|
+
if (
|
|
808
|
+
hasGraphHook ||
|
|
809
|
+
hasHook(plugins, 'key') ||
|
|
810
|
+
(exclude !== undefined && (exclude === 'all' || exclude.length > 0))
|
|
811
|
+
)
|
|
812
|
+
hashCache.graph = undefined
|
|
745
813
|
let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
|
|
746
814
|
if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
|
|
747
815
|
const split = excludeDependencies(nodes, exclude)
|
|
@@ -787,6 +855,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
787
855
|
warn: warnOf(log),
|
|
788
856
|
localCache,
|
|
789
857
|
branch: branch(),
|
|
858
|
+
refs,
|
|
790
859
|
},
|
|
791
860
|
cacheFor,
|
|
792
861
|
run,
|
|
@@ -804,6 +873,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
804
873
|
cachePolicy: policy,
|
|
805
874
|
cacheScope,
|
|
806
875
|
remotes,
|
|
876
|
+
cacheRefs: refs,
|
|
807
877
|
priorities,
|
|
808
878
|
branch,
|
|
809
879
|
cacheFor,
|
|
@@ -817,6 +887,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
817
887
|
projects,
|
|
818
888
|
hintProjects,
|
|
819
889
|
anyProjectConfig: projectsWithConfigs.length > 0,
|
|
890
|
+
taskless,
|
|
891
|
+
unlockedConfig: () => unlockedConfigOf(projectsWithConfigs, projectMetas, workspaceRoot),
|
|
820
892
|
fingerprintWatch,
|
|
821
893
|
nestedDirsByProject,
|
|
822
894
|
gitFilesCache,
|
|
@@ -891,7 +963,7 @@ async function refuseUndeclaredDeps(
|
|
|
891
963
|
for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
|
|
892
964
|
}
|
|
893
965
|
for (const [id, name] of unproven) {
|
|
894
|
-
if (!declared.has(name)) throw undeclaredDepsError(id, name)
|
|
966
|
+
if (!declared.has(name)) throw undeclaredDepsError(id, name, declared)
|
|
895
967
|
}
|
|
896
968
|
}
|
|
897
969
|
|
|
@@ -905,9 +977,11 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
|
|
|
905
977
|
const stack = [...roots]
|
|
906
978
|
while (stack.length > 0) {
|
|
907
979
|
const id = stack.pop()!
|
|
908
|
-
|
|
980
|
+
// A `pkg#task` the user named is kept, though a hook may have dropped it.
|
|
981
|
+
const node = nodes.get(id)
|
|
982
|
+
if (node === undefined || keep.has(id)) continue
|
|
909
983
|
keep.add(id)
|
|
910
|
-
stack.push(...
|
|
984
|
+
stack.push(...node.deps)
|
|
911
985
|
}
|
|
912
986
|
for (const [id, n] of nodes) {
|
|
913
987
|
if (!keep.has(id)) nodes.delete(id)
|
|
@@ -915,7 +989,7 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
|
|
|
915
989
|
}
|
|
916
990
|
}
|
|
917
991
|
|
|
918
|
-
const namesRemote = (spec: string | undefined): boolean =>
|
|
992
|
+
export const namesRemote = (spec: string | undefined): boolean =>
|
|
919
993
|
spec !== undefined && /remote:[rw]/.test(spec)
|
|
920
994
|
|
|
921
995
|
/**
|
|
@@ -923,7 +997,9 @@ const namesRemote = (spec: string | undefined): boolean =>
|
|
|
923
997
|
* since the grammar lives in the cache module the workspace module may not
|
|
924
998
|
* import. Undefined when it names none.
|
|
925
999
|
*/
|
|
926
|
-
function workspaceCachePolicy(
|
|
1000
|
+
export function workspaceCachePolicy(
|
|
1001
|
+
config: WorkspaceConfig | null | undefined,
|
|
1002
|
+
): CachePolicy | undefined {
|
|
927
1003
|
const spec = config?.cache?.policy
|
|
928
1004
|
if (spec === undefined) return undefined
|
|
929
1005
|
try {
|
|
@@ -936,16 +1012,30 @@ function workspaceCachePolicy(config: WorkspaceConfig | null | undefined): Cache
|
|
|
936
1012
|
}
|
|
937
1013
|
}
|
|
938
1014
|
|
|
1015
|
+
/** A config no locked project has, looked for only when the lock holds none. */
|
|
1016
|
+
function unlockedConfigOf(
|
|
1017
|
+
configured: readonly unknown[],
|
|
1018
|
+
metas: ReadonlyArray<{ dir: string }>,
|
|
1019
|
+
workspaceRoot: string,
|
|
1020
|
+
): string | undefined {
|
|
1021
|
+
if (configured.length > 0) return undefined
|
|
1022
|
+
for (const m of metas) {
|
|
1023
|
+
const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(m.dir, n)))
|
|
1024
|
+
if (config !== undefined) return relPosix(workspaceRoot, path.join(m.dir, config))
|
|
1025
|
+
}
|
|
1026
|
+
return undefined
|
|
1027
|
+
}
|
|
1028
|
+
|
|
939
1029
|
/**
|
|
940
1030
|
* A `project#build` naming the default `build` of a project the lock holds
|
|
941
|
-
* no config for, while its directory holds one, asks for that config's task, not
|
|
1031
|
+
* no config for (or no project at all), while its directory holds one, asks for that config's task, not
|
|
942
1032
|
* one that runs nothing: ran, `c#build` was 0 tasks, exit 0, where a
|
|
943
1033
|
* renamed task gets "run `vx lock`" (Growth, lock drift).
|
|
944
1034
|
*/
|
|
945
1035
|
function refuseUnlockedConfig(
|
|
946
1036
|
tasks: readonly string[],
|
|
947
1037
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
948
|
-
metas: ReadonlyArray<{ name: string; configPath?: string | null }>,
|
|
1038
|
+
metas: ReadonlyArray<{ name: string; dir: string; configPath?: string | null }>,
|
|
949
1039
|
workspaceRoot: string,
|
|
950
1040
|
): void {
|
|
951
1041
|
for (const spec of tasks) {
|
|
@@ -953,17 +1043,55 @@ function refuseUnlockedConfig(
|
|
|
953
1043
|
if (at < 0) continue
|
|
954
1044
|
const name = spec.slice(0, at)
|
|
955
1045
|
const entry = projects.get(name)
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
1046
|
+
// No entry: a package the lock holds no project for, whose config came after it.
|
|
1047
|
+
if (entry !== undefined && !isDefaultBuild(entry.config.tasks?.[spec.slice(at + 1)])) continue
|
|
1048
|
+
const config = unlockedConfig(name, metas)
|
|
1049
|
+
if (config === undefined) continue
|
|
1050
|
+
throw new UserError(
|
|
1051
|
+
`${spec}: ${relPosix(workspaceRoot, config)} is not in the lock yet — run \`vx lock\``,
|
|
1052
|
+
'VX_E_LOCK',
|
|
1053
|
+
)
|
|
1054
|
+
}
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
/**
|
|
1058
|
+
* The same for a bare name the selection (the cwd's project, `--filter`)
|
|
1059
|
+
* left unresolved: run from a member whose config came after the lock, it
|
|
1060
|
+
* said only projects outside the selection declare the task — pass
|
|
1061
|
+
* `--all`, which skips that member too.
|
|
1062
|
+
*/
|
|
1063
|
+
function refuseUnlockedScope(
|
|
1064
|
+
unresolved: readonly string[],
|
|
1065
|
+
selected: readonly string[],
|
|
1066
|
+
metas: ReadonlyArray<{ name: string; dir: string; configPath?: string | null }>,
|
|
1067
|
+
workspaceRoot: string,
|
|
1068
|
+
): void {
|
|
1069
|
+
const spec = unresolved.find((t) => !t.includes('#'))
|
|
1070
|
+
if (spec === undefined) return
|
|
1071
|
+
// The selection, not the loaded projects: a scope loads no config-less
|
|
1072
|
+
// member nothing configured reaches, and that is the one to ask about.
|
|
1073
|
+
for (const name of selected) {
|
|
1074
|
+
const config = unlockedConfig(name, metas)
|
|
960
1075
|
if (config === undefined) continue
|
|
961
1076
|
throw new UserError(
|
|
962
|
-
`${spec}: ${relPosix(workspaceRoot,
|
|
1077
|
+
`${spec}: ${relPosix(workspaceRoot, config)} is not in the lock yet — run \`vx lock\``,
|
|
1078
|
+
'VX_E_LOCK',
|
|
963
1079
|
)
|
|
964
1080
|
}
|
|
965
1081
|
}
|
|
966
1082
|
|
|
1083
|
+
/** The config file in a project's directory the lock holds none for. */
|
|
1084
|
+
function unlockedConfig(
|
|
1085
|
+
name: string,
|
|
1086
|
+
metas: ReadonlyArray<{ name: string; dir: string; configPath?: string | null }>,
|
|
1087
|
+
): string | undefined {
|
|
1088
|
+
const meta = metas.find((m) => m.name === name)
|
|
1089
|
+
if (meta === undefined) return undefined
|
|
1090
|
+
if (typeof meta.configPath === 'string' && meta.configPath !== '') return undefined
|
|
1091
|
+
const config = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(meta.dir, n)))
|
|
1092
|
+
return config === undefined ? undefined : path.join(meta.dir, config)
|
|
1093
|
+
}
|
|
1094
|
+
|
|
967
1095
|
/**
|
|
968
1096
|
* Each remote's `open`, in declared order, for the ones the policy lets read
|
|
969
1097
|
* or write. One at a time: two that ask on the terminal must not interleave.
|
|
@@ -988,10 +1116,28 @@ export async function openRemotes(
|
|
|
988
1116
|
} catch (err) {
|
|
989
1117
|
entry.read = false
|
|
990
1118
|
entry.write = false
|
|
991
|
-
const e = err instanceof Error ? err : new Error(
|
|
1119
|
+
const e = err instanceof Error ? err : new Error(thrownMessage(err))
|
|
992
1120
|
entry.onError?.(e)
|
|
993
1121
|
closed = true
|
|
994
1122
|
}
|
|
995
1123
|
}
|
|
996
1124
|
return closed && !remotes.some((r) => r.read || r.write)
|
|
997
1125
|
}
|
|
1126
|
+
|
|
1127
|
+
/**
|
|
1128
|
+
* `dir` spelled under `root` as the run spells the root, when it lies
|
|
1129
|
+
* there; compared through links, so a root reached by a symlink (macOS's
|
|
1130
|
+
* `/var/folders`) still finds a cache directory written canonical.
|
|
1131
|
+
*/
|
|
1132
|
+
function withinRoot(root: string, dir: string): string[] {
|
|
1133
|
+
const real = (p: string): string => {
|
|
1134
|
+
try {
|
|
1135
|
+
return realpathSync(p)
|
|
1136
|
+
} catch {
|
|
1137
|
+
return path.resolve(p)
|
|
1138
|
+
}
|
|
1139
|
+
}
|
|
1140
|
+
const rel = path.relative(real(root), real(dir))
|
|
1141
|
+
const outside = rel === '' || rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)
|
|
1142
|
+
return outside ? [] : [path.join(root, rel)]
|
|
1143
|
+
}
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { stat } from 'node:fs/promises'
|
|
8
8
|
import path from 'node:path'
|
|
9
|
-
import type { ProjectConfig
|
|
9
|
+
import type { ProjectConfig } from '../config.js'
|
|
10
10
|
import {
|
|
11
11
|
checkProjectNames,
|
|
12
12
|
frozenProjectConfigs,
|
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
resolveCacheDir,
|
|
34
34
|
validateWorkspace,
|
|
35
35
|
} from '../workspace/index.js'
|
|
36
|
-
import { parseDependencySpec } from '../graph/index.js'
|
|
36
|
+
import { DEFAULT_BUILD, parseDependencySpec } from '../graph/index.js'
|
|
37
37
|
import { applyConfigHooks, applyDiscoverHooks, applyProjectHooks, hasHook } from './plugin-host.js'
|
|
38
38
|
import type { BranchFacts, VxPlugin } from './plugin.js'
|
|
39
39
|
|
|
@@ -79,14 +79,35 @@ export async function loadWorkspacePlugins(
|
|
|
79
79
|
branch.scope = facts.scope
|
|
80
80
|
delete branch.trusted
|
|
81
81
|
}
|
|
82
|
-
if (facts.base !== undefined)
|
|
82
|
+
if (facts.base !== undefined) {
|
|
83
|
+
// Read into `--affected`'s `...[<base>]` filter.
|
|
84
|
+
if (typeof facts.base !== 'string' || !/^[^\s\0[\]-][^\s\0[\]]*$/.test(facts.base))
|
|
85
|
+
throw new UserError('setBranch: base must be a commit or a ref')
|
|
86
|
+
branch.base = facts.base
|
|
87
|
+
}
|
|
88
|
+
if (facts.refs !== undefined) {
|
|
89
|
+
if (!Array.isArray(facts.refs) || facts.refs.some((r) => !isRef(r)))
|
|
90
|
+
throw new UserError('setBranch: refs must be a list of refs, each starting "refs/"')
|
|
91
|
+
branch.refs = [...new Set(facts.refs)]
|
|
92
|
+
}
|
|
83
93
|
}
|
|
84
|
-
await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn, setBranch }, (plugin) =>
|
|
85
|
-
|
|
86
|
-
|
|
94
|
+
await applyConfigHooks(plugins, workspaceConfig, { workspaceRoot, warn, setBranch }, (plugin) => {
|
|
95
|
+
const at = `.vx/workspace (after plugin '${plugin.name}')`
|
|
96
|
+
validateWorkspace(workspaceConfig, at)
|
|
97
|
+
// The run takes `plugins` from the file: an edited list validated and was dropped.
|
|
98
|
+
const now = workspaceConfig.plugins
|
|
99
|
+
if (now?.length !== list?.length || now?.some((p, i) => p !== list![i])) {
|
|
100
|
+
throw new UserError(
|
|
101
|
+
`${at}: \`plugins\` is fixed before the config stage, so an edit to it would change nothing — declare plugins in .vx/workspace`,
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
})
|
|
87
105
|
return { workspaceConfig, plugins, branch }
|
|
88
106
|
}
|
|
89
107
|
|
|
108
|
+
const isRef = (r: unknown): boolean =>
|
|
109
|
+
typeof r === 'string' && r.startsWith('refs/') && !/[\s\0]/.test(r)
|
|
110
|
+
|
|
90
111
|
/**
|
|
91
112
|
* The enumeration a discovery's `discover` hooks shared, by the array it
|
|
92
113
|
* returned: a run handed that array (`RunOptions.discovered`, the CLI's
|
|
@@ -176,7 +197,8 @@ interface LoadProjectsBase {
|
|
|
176
197
|
projectMetas: readonly ProjectMeta[]
|
|
177
198
|
/**
|
|
178
199
|
* Projects whose configs must load — `'all'`, or names (unknown and
|
|
179
|
-
* config-less ones are ignored
|
|
200
|
+
* config-less ones are ignored, unless a configured project's closure
|
|
201
|
+
* reaches them, as in the `'all'` load). With `closure`, each seed's transitive
|
|
180
202
|
* package dependencies load too, which bounds `^task` frontier
|
|
181
203
|
* expansion; a `pkg#task` dependsOn entry pulls its project in either
|
|
182
204
|
* way, since the package graph cannot see the cross form.
|
|
@@ -268,7 +290,17 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
268
290
|
for (const dep of packageGraph.transitiveDeps(name)) consider(dep)
|
|
269
291
|
}
|
|
270
292
|
const seedAll = (): Iterable<string> => configured.map((m) => m.name)
|
|
271
|
-
|
|
293
|
+
// A named config-less seed loads only where the `'all'` load would load
|
|
294
|
+
// it: a configured project's closure reaches it. Seeded as itself, it got
|
|
295
|
+
// a default build `vx show` listed nowhere and no run of the workspace had
|
|
296
|
+
// (`vx run lone#build` ran a group no dependant reaches).
|
|
297
|
+
const configuredNames = new Set(configured.map((m) => m.name))
|
|
298
|
+
const seedable = (name: string): boolean =>
|
|
299
|
+
configuredNames.has(name) ||
|
|
300
|
+
(graph !== null &&
|
|
301
|
+
metaByName.has(name) &&
|
|
302
|
+
graph.transitiveDependents(name).some((d) => configuredNames.has(d)))
|
|
303
|
+
const seeds = args.seeds === 'all' ? seedAll() : [...args.seeds].filter(seedable)
|
|
272
304
|
for (const seed of seeds) consider(seed)
|
|
273
305
|
if (graph !== null && pending.length < metaByName.size) {
|
|
274
306
|
for (const seed of args.seeds === 'all' ? seedAll() : seeds) {
|
|
@@ -304,7 +336,8 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
304
336
|
const present = await Promise.all(withFile.map((m) => Bun.file(m.configPath).exists()))
|
|
305
337
|
const gone = withFile.find((_, i) => !present[i])
|
|
306
338
|
if (gone !== undefined) {
|
|
307
|
-
|
|
339
|
+
// `.` read as a sentence's end: the notice cut the line there.
|
|
340
|
+
const rel = relPosix(workspaceRoot, gone.dir) || 'the workspace root project'
|
|
308
341
|
const named = gone.name === pathName(workspaceRoot, gone.dir) ? '' : ` ("${gone.name}")`
|
|
309
342
|
const file = relPosix(gone.dir, gone.configPath)
|
|
310
343
|
// exists() says false for a file behind a directory this user may not
|
|
@@ -314,11 +347,15 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
314
347
|
(err: NodeJS.ErrnoException) =>
|
|
315
348
|
err.code === 'EACCES' || err.code === 'EPERM' ? err.code : null,
|
|
316
349
|
)
|
|
317
|
-
throw
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
350
|
+
throw denied !== null
|
|
351
|
+
? new UserError(
|
|
352
|
+
`${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is not readable by this user (${denied})`,
|
|
353
|
+
'VX_E_FS',
|
|
354
|
+
)
|
|
355
|
+
: new UserError(
|
|
356
|
+
`${LOCKFILE_NAME} locks ${rel}${named}, but its ${file} is gone — run \`vx lock\``,
|
|
357
|
+
'VX_E_LOCK',
|
|
358
|
+
)
|
|
322
359
|
}
|
|
323
360
|
const loaded =
|
|
324
361
|
withFile.length === 0 ? [] : frozenProjectConfigs(await lock(), withFile, workspaceRoot)
|
|
@@ -341,6 +378,7 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
341
378
|
// the loader accepts from a user, and the refusal names both the
|
|
342
379
|
// config file and the plugin whose edit broke it.
|
|
343
380
|
const where = meta.configPath ?? `${meta.name} (no config file)`
|
|
381
|
+
const name = config.name
|
|
344
382
|
await applyProjectHooks(
|
|
345
383
|
plugins,
|
|
346
384
|
config,
|
|
@@ -353,12 +391,17 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
353
391
|
projects: args.projectMetas,
|
|
354
392
|
lock: args.lock === true,
|
|
355
393
|
},
|
|
356
|
-
(plugin) =>
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
)
|
|
394
|
+
(plugin) => {
|
|
395
|
+
const at = `${where} (after plugin '${plugin.name}')`
|
|
396
|
+
validateProjectConfig(config, at, meta.configPath)
|
|
397
|
+
// Projects are named and edged before any config loads: a new
|
|
398
|
+
// `name` here validated, and the project kept its old one.
|
|
399
|
+
if (config.name !== name) {
|
|
400
|
+
throw new UserError(
|
|
401
|
+
`${at}: project "${meta.name}" is named before the project stage, so \`name: ${JSON.stringify(config.name)}\` cannot rename it — a plugin names a project in its \`discover\` hook`,
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
},
|
|
362
405
|
)
|
|
363
406
|
}
|
|
364
407
|
const built = withDefaultBuild(config)
|
|
@@ -373,24 +416,6 @@ export async function loadProjects(args: LoadProjectsArgs): Promise<LoadedProjec
|
|
|
373
416
|
return { projects, configured }
|
|
374
417
|
}
|
|
375
418
|
|
|
376
|
-
/**
|
|
377
|
-
* The `build` a project gets when neither its config nor a plugin declared
|
|
378
|
-
* one (owner, 2026-10-04): a group behind `^build`, keyed on every file of
|
|
379
|
-
* the project. A package consumed as source then carries its files to every
|
|
380
|
-
* dependent behind `^build`, in the key and in `--affected`, and the chain
|
|
381
|
-
* through it to deeper builds holds. A keyed group, as a config may
|
|
382
|
-
* declare one (config-schema.ts).
|
|
383
|
-
*/
|
|
384
|
-
const DEFAULT_BUILD: TaskConfig = Object.freeze({
|
|
385
|
-
dependsOn: Object.freeze(['^build']),
|
|
386
|
-
inputs: Object.freeze({ files: Object.freeze(['**']) }),
|
|
387
|
-
})
|
|
388
|
-
|
|
389
|
-
/** The default `build`, which a menu of the tasks a user declared leaves out. */
|
|
390
|
-
export function isDefaultBuild(task: TaskConfig | undefined): boolean {
|
|
391
|
-
return task === DEFAULT_BUILD
|
|
392
|
-
}
|
|
393
|
-
|
|
394
419
|
function withDefaultBuild(config: ProjectConfig): ProjectConfig {
|
|
395
420
|
if (config.tasks?.['build'] !== undefined) return config
|
|
396
421
|
return { ...config, tasks: { ...config.tasks, build: DEFAULT_BUILD } }
|
|
@@ -432,7 +457,7 @@ function crossDepProjects(config: ProjectConfig): string[] {
|
|
|
432
457
|
// the offending task. Here it just contributes no project.
|
|
433
458
|
if (!raw.includes('#')) continue
|
|
434
459
|
try {
|
|
435
|
-
const spec = parseDependencySpec(raw)
|
|
460
|
+
const spec = parseDependencySpec(raw, 'dependsOn')
|
|
436
461
|
if (spec.kind === 'cross') out.push(spec.project)
|
|
437
462
|
} catch {
|
|
438
463
|
continue
|
|
@@ -475,7 +500,7 @@ export function lockReader(workspaceRoot: string): () => Promise<Lockfile> {
|
|
|
475
500
|
let read: Promise<Lockfile> | undefined
|
|
476
501
|
return () =>
|
|
477
502
|
(read ??= readLockfile(workspaceRoot).then((lock) => {
|
|
478
|
-
if (lock === null) throw new UserError(NO_LOCK)
|
|
503
|
+
if (lock === null) throw new UserError(NO_LOCK, 'VX_E_LOCK')
|
|
479
504
|
return lock
|
|
480
505
|
}))
|
|
481
506
|
}
|