@vzn/vx 0.0.646 → 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 +29 -22
- 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 +112 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +18 -2
- package/src/bin.ts +53 -12
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +268 -53
- package/src/cache/git-inputs.ts +219 -87
- package/src/cache/index.ts +3 -2
- package/src/cache/inputs.ts +460 -78
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +49 -9
- package/src/cache/layered-cache.ts +123 -58
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +19 -3
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +90 -14
- package/src/cache/run-history.ts +70 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +25 -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/docs.ts +42 -16
- package/src/cli/help.ts +58 -19
- package/src/cli/index.ts +79 -33
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +186 -31
- package/src/cli/last.ts +439 -45
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +70 -14
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +109 -34
- package/src/cli/select.ts +143 -46
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +151 -49
- 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 +154 -10
- package/src/exec/index.ts +6 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +3 -1
- package/src/exec/runner.ts +253 -54
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +211 -24
- 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 +270 -60
- 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 +167 -61
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +90 -30
- package/src/index.ts +30 -13
- 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 +4 -1
- package/src/orchestrator/execute-task.ts +130 -38
- 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 +1 -1
- package/src/orchestrator/hit-restore.ts +217 -119
- package/src/orchestrator/index.ts +4 -3
- package/src/orchestrator/local-floor.ts +119 -25
- package/src/orchestrator/local-shortcircuit.ts +28 -4
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +42 -8
- 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 +84 -29
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +185 -17
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +214 -42
- package/src/orchestrator/plugin.ts +113 -37
- package/src/orchestrator/prepare.ts +168 -30
- 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 +149 -13
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -29
- 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 +331 -88
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +70 -20
- 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 +90 -26
- package/src/orchestrator/task-hash.ts +78 -13
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +7 -1
- package/src/orchestrator/telemetry-host.ts +22 -2
- package/src/orchestrator/telemetry.ts +53 -21
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +31 -9
- package/src/report/framed-output.ts +141 -50
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +170 -179
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +81 -53
- package/src/report/summary.ts +114 -32
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +7 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +4 -2
- 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 +21 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +91 -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 +61 -3
- 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 +17 -11
- 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 -1
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +69 -18
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +226 -37
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
|
@@ -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'
|
|
@@ -168,6 +177,14 @@ export interface PreparedRun {
|
|
|
168
177
|
* mistaken for a workspace vx was never set up in.
|
|
169
178
|
*/
|
|
170
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
|
|
171
188
|
/** The claimed root files, and whether a task has since rewritten one (fingerprint-watch.ts). */
|
|
172
189
|
fingerprintWatch: FingerprintWatch
|
|
173
190
|
nestedDirsByProject: Map<string, string[]>
|
|
@@ -335,7 +352,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
335
352
|
const asked =
|
|
336
353
|
options.cache ?? applyCacheFlags(workspacePolicy ?? FULL_CACHE_POLICY, options.cacheFlags)
|
|
337
354
|
// A laptop reads the shared cache and does not write it unless a policy
|
|
338
|
-
// names the remote (
|
|
355
|
+
// names the remote (2026-10-03; Security F1, 2026-10-14): a key
|
|
339
356
|
// holds only declared inputs, so a laptop's write could be replayed on CI.
|
|
340
357
|
// A workspace policy naming the remote asks to write it, as `--cache-policy` does.
|
|
341
358
|
const readOnly =
|
|
@@ -368,7 +385,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
368
385
|
|
|
369
386
|
// Configs load FROM .vx/projects.lock.json with no evaluation and no check of
|
|
370
387
|
// the configs (`vx lock --check` re-evaluates them; a run does not):
|
|
371
|
-
// only `vx lock` evaluates (
|
|
388
|
+
// only `vx lock` evaluates (2026-10-14).
|
|
372
389
|
// See docs/design/config-lock-2026-06.md.
|
|
373
390
|
// Read once, and only when a config is to be read from it: the CLI's
|
|
374
391
|
// selection pass may have staged every config (`options.staged`), and
|
|
@@ -412,6 +429,12 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
412
429
|
// name, never by count: a config-less project counts as loaded, so a
|
|
413
430
|
// scope holding one hid an unloaded declarer (X-129, X-182).
|
|
414
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)
|
|
415
438
|
|
|
416
439
|
// Boundary geometry considers every workspace project, loaded or not,
|
|
417
440
|
// config or not: `--affected` gives a changed path to the deepest project
|
|
@@ -429,7 +452,20 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
429
452
|
// A dependant's `^build` still reaches it, and `lib#build` names it.
|
|
430
453
|
const requested = expandRequested(tasks, candidateProjects, projects, isDefaultBuild)
|
|
431
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])]
|
|
432
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
|
+
)
|
|
433
469
|
if (options.selectedByDiff === true && unresolvedTasks.some((t) => !t.includes('#'))) {
|
|
434
470
|
// A name a loaded project declares is no typo, whether or not the load
|
|
435
471
|
// was whole: an unaffected dependency loaded for the closure declared
|
|
@@ -497,7 +533,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
497
533
|
})
|
|
498
534
|
}
|
|
499
535
|
// An injected remote is no plugin's: nothing reads refs for it.
|
|
500
|
-
const refs = options.remoteCache ? [] : cacheRefs(workspaceRoot, baseBranch)
|
|
536
|
+
const refs = options.remoteCache ? [] : cacheRefs(workspaceRoot, baseBranch, branchFacts.refs)
|
|
501
537
|
try {
|
|
502
538
|
cache = options.remoteCache
|
|
503
539
|
? new LayeredCache(localCache, remotes, options.uploadConcurrency)
|
|
@@ -570,6 +606,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
570
606
|
projects,
|
|
571
607
|
hintProjects,
|
|
572
608
|
anyProjectConfig: projectsWithConfigs.length > 0,
|
|
609
|
+
taskless,
|
|
610
|
+
unlockedConfig: () => unlockedConfigOf(projectsWithConfigs, projectMetas, workspaceRoot),
|
|
573
611
|
fingerprintWatch,
|
|
574
612
|
nestedDirsByProject,
|
|
575
613
|
gitFilesCache: new GitFilesCache(),
|
|
@@ -590,21 +628,38 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
590
628
|
// by a config the scope left out, so the builder hands it back instead of
|
|
591
629
|
// refusing it, and the rest decide.
|
|
592
630
|
// A run never checks the graph rules: `vx lock` and `vx lock --check`
|
|
593
|
-
// do (
|
|
631
|
+
// do (2026-10-14). `exclusiveOutputs: false` still asks the walk
|
|
594
632
|
// for its addition marks.
|
|
595
633
|
const graphRules: GraphRules = { ...runRules(workspaceConfig), locked: true }
|
|
596
634
|
const unproven: Array<[taskId: string, name: string]> = []
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
}
|
|
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
|
+
}
|
|
608
663
|
let nodes = graphOf(requested)
|
|
609
664
|
const hasGraphHook = hasHook(plugins, 'graph')
|
|
610
665
|
const graphStage = (): Promise<void> =>
|
|
@@ -691,6 +746,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
691
746
|
// `prepare (graph)` row until 2026-09-10.
|
|
692
747
|
mark('build graph')
|
|
693
748
|
const gitFilesCache = new GitFilesCache()
|
|
749
|
+
gitFilesCache.vxDirs = withinRoot(workspaceRoot, cacheDir)
|
|
694
750
|
// Bulk-populate via a single `git ls-files` at the workspace root —
|
|
695
751
|
// partitions the output by project. Avoids one fork+exec per project
|
|
696
752
|
// (~5-10ms each on Linux; the dominant cold-start cost on big
|
|
@@ -747,6 +803,13 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
747
803
|
}
|
|
748
804
|
refuseFiltersThroughGroups(nodes)
|
|
749
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
|
|
750
813
|
let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
|
|
751
814
|
if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
|
|
752
815
|
const split = excludeDependencies(nodes, exclude)
|
|
@@ -792,6 +855,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
792
855
|
warn: warnOf(log),
|
|
793
856
|
localCache,
|
|
794
857
|
branch: branch(),
|
|
858
|
+
refs,
|
|
795
859
|
},
|
|
796
860
|
cacheFor,
|
|
797
861
|
run,
|
|
@@ -823,6 +887,8 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
|
|
|
823
887
|
projects,
|
|
824
888
|
hintProjects,
|
|
825
889
|
anyProjectConfig: projectsWithConfigs.length > 0,
|
|
890
|
+
taskless,
|
|
891
|
+
unlockedConfig: () => unlockedConfigOf(projectsWithConfigs, projectMetas, workspaceRoot),
|
|
826
892
|
fingerprintWatch,
|
|
827
893
|
nestedDirsByProject,
|
|
828
894
|
gitFilesCache,
|
|
@@ -897,7 +963,7 @@ async function refuseUndeclaredDeps(
|
|
|
897
963
|
for (const t of Object.keys(p.config.tasks ?? {})) declared.add(t)
|
|
898
964
|
}
|
|
899
965
|
for (const [id, name] of unproven) {
|
|
900
|
-
if (!declared.has(name)) throw undeclaredDepsError(id, name)
|
|
966
|
+
if (!declared.has(name)) throw undeclaredDepsError(id, name, declared)
|
|
901
967
|
}
|
|
902
968
|
}
|
|
903
969
|
|
|
@@ -911,9 +977,11 @@ function pruneToClosure(nodes: Map<string, TaskNode>, roots: ReadonlySet<string>
|
|
|
911
977
|
const stack = [...roots]
|
|
912
978
|
while (stack.length > 0) {
|
|
913
979
|
const id = stack.pop()!
|
|
914
|
-
|
|
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
|
|
915
983
|
keep.add(id)
|
|
916
|
-
stack.push(...
|
|
984
|
+
stack.push(...node.deps)
|
|
917
985
|
}
|
|
918
986
|
for (const [id, n] of nodes) {
|
|
919
987
|
if (!keep.has(id)) nodes.delete(id)
|
|
@@ -944,16 +1012,30 @@ export function workspaceCachePolicy(
|
|
|
944
1012
|
}
|
|
945
1013
|
}
|
|
946
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
|
+
|
|
947
1029
|
/**
|
|
948
1030
|
* A `project#build` naming the default `build` of a project the lock holds
|
|
949
|
-
* 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
|
|
950
1032
|
* one that runs nothing: ran, `c#build` was 0 tasks, exit 0, where a
|
|
951
1033
|
* renamed task gets "run `vx lock`" (Growth, lock drift).
|
|
952
1034
|
*/
|
|
953
1035
|
function refuseUnlockedConfig(
|
|
954
1036
|
tasks: readonly string[],
|
|
955
1037
|
projects: ReadonlyMap<string, ProjectEntry>,
|
|
956
|
-
metas: ReadonlyArray<{ name: string; configPath?: string | null }>,
|
|
1038
|
+
metas: ReadonlyArray<{ name: string; dir: string; configPath?: string | null }>,
|
|
957
1039
|
workspaceRoot: string,
|
|
958
1040
|
): void {
|
|
959
1041
|
for (const spec of tasks) {
|
|
@@ -961,17 +1043,55 @@ function refuseUnlockedConfig(
|
|
|
961
1043
|
if (at < 0) continue
|
|
962
1044
|
const name = spec.slice(0, at)
|
|
963
1045
|
const entry = projects.get(name)
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
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)
|
|
968
1075
|
if (config === undefined) continue
|
|
969
1076
|
throw new UserError(
|
|
970
|
-
`${spec}: ${relPosix(workspaceRoot,
|
|
1077
|
+
`${spec}: ${relPosix(workspaceRoot, config)} is not in the lock yet — run \`vx lock\``,
|
|
1078
|
+
'VX_E_LOCK',
|
|
971
1079
|
)
|
|
972
1080
|
}
|
|
973
1081
|
}
|
|
974
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
|
+
|
|
975
1095
|
/**
|
|
976
1096
|
* Each remote's `open`, in declared order, for the ones the policy lets read
|
|
977
1097
|
* or write. One at a time: two that ask on the terminal must not interleave.
|
|
@@ -996,10 +1116,28 @@ export async function openRemotes(
|
|
|
996
1116
|
} catch (err) {
|
|
997
1117
|
entry.read = false
|
|
998
1118
|
entry.write = false
|
|
999
|
-
const e = err instanceof Error ? err : new Error(
|
|
1119
|
+
const e = err instanceof Error ? err : new Error(thrownMessage(err))
|
|
1000
1120
|
entry.onError?.(e)
|
|
1001
1121
|
closed = true
|
|
1002
1122
|
}
|
|
1003
1123
|
}
|
|
1004
1124
|
return closed && !remotes.some((r) => r.read || r.write)
|
|
1005
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
|
}
|