@vzn/vx 0.0.641 → 0.0.643
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/cache/archive.ts +525 -104
- package/src/cache/cache.ts +85 -11
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +12 -3
- package/src/cache/inputs.ts +169 -79
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +4 -6
- package/src/cache/layered-cache.ts +96 -10
- package/src/cache/output-index.ts +25 -4
- package/src/cache/plugin-cache.ts +23 -16
- package/src/cache/remote-lane.ts +25 -8
- package/src/cache/run-store.ts +4 -1
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +74 -237
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +6 -8
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/plugin-commands.ts +11 -6
- package/src/cli/run.ts +14 -15
- package/src/cli/select.ts +9 -9
- package/src/cli/workspace-config.ts +8 -2
- package/src/config.ts +7 -16
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/history/history.ts +43 -33
- package/src/index.ts +14 -0
- package/src/orchestrator/affected-tasks.ts +23 -2
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +10 -2
- package/src/orchestrator/index.ts +5 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/options.ts +9 -1
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +38 -4
- package/src/orchestrator/plugin.ts +49 -5
- package/src/orchestrator/prepare.ts +141 -22
- package/src/orchestrator/projects.ts +25 -6
- package/src/orchestrator/run-context.ts +80 -0
- package/src/orchestrator/run.ts +38 -15
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +52 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +53 -13
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +8 -2
- package/src/settings.ts +11 -11
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +12 -2
- package/src/util/paths.ts +33 -14
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +182 -75
- package/src/workspace/config-schema.ts +81 -19
- package/src/workspace/index.ts +3 -0
- package/src/workspace/project-loader.ts +1 -0
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2376
- package/docs/cli.md +0 -3285
- package/docs/execution.md +0 -676
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2468
- package/docs/security.md +0 -253
- package/src/cli/docs-corpus.ts +0 -21
- package/src/cli/docs-text.d.ts +0 -5
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
gitSpawnRefusal,
|
|
15
15
|
isExecutableMissing,
|
|
16
16
|
isInstalledPath,
|
|
17
|
+
isUserError,
|
|
17
18
|
notAWorkTree,
|
|
18
19
|
relPosix,
|
|
19
20
|
UserError,
|
|
@@ -73,7 +74,14 @@ export interface AffectedArgs {
|
|
|
73
74
|
workspaceRoot: string
|
|
74
75
|
/** Git ref / commit / branch to compare against. Required. */
|
|
75
76
|
since: string
|
|
76
|
-
|
|
77
|
+
/** A promise when the caller is still discovering them: the git calls start first. */
|
|
78
|
+
projects: readonly ProjectMeta[] | Promise<readonly ProjectMeta[]>
|
|
79
|
+
/**
|
|
80
|
+
* False skips the scan of what each `vx.config` imports (every config
|
|
81
|
+
* read): the cache scope's split, where a wrong answer costs a miss,
|
|
82
|
+
* never a stale hit, and the scan was most of a 1,000-project diff.
|
|
83
|
+
*/
|
|
84
|
+
configImports?: false
|
|
77
85
|
/**
|
|
78
86
|
* Which projects declare a `inputs.workspaceFiles` glob matching one of
|
|
79
87
|
* these workspace-relative paths.
|
|
@@ -122,7 +130,10 @@ export interface FingerprintClaims {
|
|
|
122
130
|
file: string
|
|
123
131
|
before: Uint8Array | null
|
|
124
132
|
after: Uint8Array | null
|
|
133
|
+
extras?: ReadonlyMap<string, { before: Uint8Array | null; after: Uint8Array | null }>
|
|
125
134
|
}): Promise<ReadonlySet<string> | undefined>
|
|
135
|
+
/** The files a claimed file names that its claimant keys too; absent = it reads none. */
|
|
136
|
+
extraFiles?(file: string, bytes: Uint8Array): readonly string[] | undefined
|
|
126
137
|
}
|
|
127
138
|
|
|
128
139
|
/**
|
|
@@ -252,6 +263,8 @@ export async function affectedChanges(
|
|
|
252
263
|
gitPaths(args.workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z']))(),
|
|
253
264
|
])
|
|
254
265
|
|
|
266
|
+
const projects = await args.projects
|
|
267
|
+
|
|
255
268
|
// .vx/projects.lock.json (workspace-root metadata) is excluded like a gitignored
|
|
256
269
|
// file: re-running `vx lock` must not mark every project affected.
|
|
257
270
|
// An untracked file under `node_modules` is an install, which the input
|
|
@@ -288,7 +301,7 @@ export async function affectedChanges(
|
|
|
288
301
|
//
|
|
289
302
|
// Selection is not hashed, so widening it here changes no cache key.
|
|
290
303
|
const everything = (file: string): AffectedChanges => {
|
|
291
|
-
const all = new Set(
|
|
304
|
+
const all = new Set(projects.map((p) => p.name))
|
|
292
305
|
const wholeBy = new Map([...all].map((n) => [n, file]))
|
|
293
306
|
return { projects: all, changed, paths: new Map(), whole: all, wholeBy }
|
|
294
307
|
}
|
|
@@ -303,37 +316,84 @@ export async function affectedChanges(
|
|
|
303
316
|
let claims: FingerprintClaims | undefined
|
|
304
317
|
const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
|
|
305
318
|
(claims ??= await args.fingerprintClaims?.())
|
|
306
|
-
// A
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
319
|
+
// A file a claimed lockfile names that its claimant keys but the
|
|
320
|
+
// lockfile does not pin (`FingerprintClaim.extraFiles`: bun.lock's
|
|
321
|
+
// patches, a `file:`, `link:` or `portal:` directory
|
|
322
|
+
// a manager copies or links) is part of
|
|
323
|
+
// that lockfile: a change to one, or under one, is a lockfile change the
|
|
324
|
+
// claimant answers with it. Only a path no project below the root owns
|
|
325
|
+
// can be one, so a diff inside the projects never loads the claims.
|
|
326
|
+
const extrasBy = new Map<
|
|
327
|
+
string,
|
|
328
|
+
Map<string, { before: Uint8Array | null; after: Uint8Array | null }>
|
|
329
|
+
>()
|
|
330
|
+
const rootDir = path.resolve(args.workspaceRoot)
|
|
331
|
+
const loose = changed.filter((rel) => {
|
|
332
|
+
const abs = path.join(rootDir, rel)
|
|
333
|
+
return !projects.some((p) => p.dir !== rootDir && abs.startsWith(p.dir + path.sep))
|
|
334
|
+
})
|
|
335
|
+
const extraClaims = loose.length > 0 ? await loadClaims() : undefined
|
|
336
|
+
for (const file of extraClaims?.files ?? []) {
|
|
337
|
+
const after = await bytesOrNull(path.join(args.workspaceRoot, file))
|
|
338
|
+
const before = await gitBytesAt(args.workspaceRoot, base, file)
|
|
339
|
+
const namedAfter = after === null ? [] : extraClaims!.extraFiles?.(file, after)
|
|
340
|
+
const named = new Set([
|
|
341
|
+
...(before === null ? [] : (extraClaims!.extraFiles?.(file, before) ?? [])),
|
|
342
|
+
...(namedAfter ?? []),
|
|
343
|
+
])
|
|
344
|
+
const extras = new Map<string, { before: Uint8Array | null; after: Uint8Array | null }>()
|
|
345
|
+
for (const rel of named) {
|
|
346
|
+
const at = path.posix.normalize(rel).replace(/\/+$/, '')
|
|
347
|
+
if (loose.includes(at)) {
|
|
348
|
+
extras.set(rel, {
|
|
349
|
+
before: await gitBytesAt(args.workspaceRoot, base, at),
|
|
350
|
+
after: await bytesOrNull(path.join(args.workspaceRoot, at)),
|
|
351
|
+
})
|
|
352
|
+
continue
|
|
353
|
+
}
|
|
354
|
+
// A directory's sides are not its bytes: a file under it changed,
|
|
355
|
+
// so they differ, and only the projects reaching it move.
|
|
356
|
+
const under = loose.filter((p) => p.startsWith(`${at}/`))
|
|
357
|
+
if (under.length > 0) {
|
|
358
|
+
extras.set(rel, { before: null, after: new TextEncoder().encode(under.join('\0')) })
|
|
317
359
|
}
|
|
318
360
|
}
|
|
361
|
+
if (extras.size > 0) extrasBy.set(file, extras)
|
|
362
|
+
else if (file === 'bun.lock' && after !== null && namedAfter === undefined) {
|
|
363
|
+
// A bun.lock claimant that names no file still folds the patches
|
|
364
|
+
// bun.lock names (the lockfile records no hash of them): it cannot
|
|
365
|
+
// say which projects one reaches, so its edit widens.
|
|
366
|
+
const patches = new Set(bunPatchFiles(after))
|
|
367
|
+
const patch = loose.find((p) => patches.has(p))
|
|
368
|
+
if (patch !== undefined) return everything(patch)
|
|
369
|
+
}
|
|
319
370
|
}
|
|
320
371
|
const claimedOwned = new Map<string, string>()
|
|
321
372
|
const rootNamesChanged = changed.filter((p) => !p.includes('/'))
|
|
373
|
+
for (const file of extrasBy.keys()) {
|
|
374
|
+
if (!rootNamesChanged.includes(file)) rootNamesChanged.push(file)
|
|
375
|
+
}
|
|
322
376
|
const claimed = rootNamesChanged.length > 0 ? await loadClaims() : undefined
|
|
323
377
|
for (const file of rootNamesChanged) {
|
|
324
378
|
if (claimed === undefined || !claimed.files.has(file)) continue
|
|
379
|
+
const extras = extrasBy.get(file)
|
|
325
380
|
const answer = await claimed.affected({
|
|
326
381
|
file,
|
|
327
382
|
before: await gitBytesAt(args.workspaceRoot, base, file),
|
|
328
383
|
after: await bytesOrNull(path.join(args.workspaceRoot, file)),
|
|
384
|
+
...(extras !== undefined ? { extras } : {}),
|
|
329
385
|
})
|
|
330
|
-
|
|
331
|
-
|
|
386
|
+
const why =
|
|
387
|
+
extras === undefined || changed.includes(file)
|
|
388
|
+
? file
|
|
389
|
+
: path.posix.normalize([...extras.keys()][0]!).replace(/\/+$/, '')
|
|
390
|
+
if (answer === undefined) return everything(why)
|
|
391
|
+
for (const name of answer) if (!claimedOwned.has(name)) claimedOwned.set(name, why)
|
|
332
392
|
}
|
|
333
393
|
|
|
334
394
|
const realDirs = new Map(
|
|
335
395
|
await Promise.all(
|
|
336
|
-
|
|
396
|
+
projects.map(async (p) => [p.dir, await realpath(p.dir).catch(() => p.dir)] as const),
|
|
337
397
|
),
|
|
338
398
|
)
|
|
339
399
|
const paths = new Map<string, string[]>()
|
|
@@ -342,7 +402,7 @@ export async function affectedChanges(
|
|
|
342
402
|
args.workspaceRoot,
|
|
343
403
|
changed,
|
|
344
404
|
nested,
|
|
345
|
-
|
|
405
|
+
projects,
|
|
346
406
|
realDirs,
|
|
347
407
|
perTask
|
|
348
408
|
? {
|
|
@@ -362,7 +422,7 @@ export async function affectedChanges(
|
|
|
362
422
|
const configs = changed.filter((rel) => CONFIG_NAMES.has(path.posix.basename(rel)))
|
|
363
423
|
if (configs.length > 0) {
|
|
364
424
|
const atBase = await gitBlobsAt(args.workspaceRoot, base, configs)
|
|
365
|
-
for (const name of parentsOfNewNested(args.workspaceRoot, atBase,
|
|
425
|
+
for (const name of parentsOfNewNested(args.workspaceRoot, atBase, projects)) {
|
|
366
426
|
owned.add(name)
|
|
367
427
|
whole.add(name)
|
|
368
428
|
}
|
|
@@ -376,12 +436,14 @@ export async function affectedChanges(
|
|
|
376
436
|
// (`../../src/index.ts`), whose target is owned.
|
|
377
437
|
// Per task, a project that owns the changed file still needs to know its
|
|
378
438
|
// config imported it: that re-keys every task, whatever their inputs.
|
|
379
|
-
for (const name of
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
439
|
+
for (const name of args.configImports === false
|
|
440
|
+
? []
|
|
441
|
+
: await configImportOwners({
|
|
442
|
+
workspaceRoot: args.workspaceRoot,
|
|
443
|
+
projects: projects,
|
|
444
|
+
changed,
|
|
445
|
+
skip: perTask ? new Set() : owned,
|
|
446
|
+
})) {
|
|
385
447
|
owned.add(name)
|
|
386
448
|
whole.add(name)
|
|
387
449
|
}
|
|
@@ -629,43 +691,35 @@ async function gitDiffPaths(
|
|
|
629
691
|
}
|
|
630
692
|
|
|
631
693
|
/**
|
|
632
|
-
*
|
|
633
|
-
*
|
|
634
|
-
*
|
|
635
|
-
*
|
|
636
|
-
*
|
|
637
|
-
*
|
|
694
|
+
* The base a bare `--affected` compares with: the merge base of HEAD and
|
|
695
|
+
* the base branch (`origin/<baseBranch>`, or the local branch in a clone
|
|
696
|
+
* with no remote; its tip when they share no history). On the base branch
|
|
697
|
+
* itself there is none (owner, 2026-10-17): what a push to main changed
|
|
698
|
+
* is the last good commit's to say (`--affected=<sha>`), and `HEAD~1`
|
|
699
|
+
* guessed it wrong after any multi-commit push.
|
|
638
700
|
*/
|
|
639
|
-
export async function defaultAffectedBase(
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
)
|
|
645
|
-
const out = new TextDecoder().decode(probe.stdout).trim()
|
|
646
|
-
// An origin/HEAD naming a branch the remote deleted (a pruned fetch keeps
|
|
647
|
-
// the symref) is no base either: it failed as `git ref "origin/master" did
|
|
648
|
-
// not resolve`, a ref nobody typed, where an unset one falls back.
|
|
649
|
-
if (probe.exitCode === 0 && out.length > 0 && revParse(workspaceRoot, out) !== undefined) {
|
|
650
|
-
return out
|
|
651
|
-
}
|
|
652
|
-
// No origin/HEAD: `git remote add` + fetch, as actions/checkout does, sets
|
|
653
|
-
// none, and `HEAD~1` saw only a feature branch's last commit where Turbo
|
|
654
|
-
// and Nx compare with `main` (D-93). The usual trunk names, remote first;
|
|
655
|
-
// one that IS HEAD (a push to main) falls through to `HEAD~1` as before.
|
|
656
|
-
const trunk = trunkBase(workspaceRoot)
|
|
657
|
-
if (trunk !== undefined) return trunk
|
|
658
|
-
if (revParse(workspaceRoot, 'HEAD~1') === undefined) {
|
|
701
|
+
export async function defaultAffectedBase(
|
|
702
|
+
workspaceRoot: string,
|
|
703
|
+
baseBranch: string | undefined,
|
|
704
|
+
): Promise<string> {
|
|
705
|
+
const found = await baseBranchRef(workspaceRoot, baseBranch)
|
|
706
|
+
if (found === undefined) {
|
|
659
707
|
const noHistory = noHistoryRefusal(workspaceRoot)
|
|
660
708
|
if (noHistory !== undefined) throw noHistory
|
|
661
709
|
throw new UserError(
|
|
662
|
-
|
|
663
|
-
'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0)
|
|
664
|
-
'base: --affected=origin/main',
|
|
710
|
+
`--affected has no base here: ${baseBranch === undefined ? 'no origin/HEAD, main or master' : `no ${baseBranch} or origin/${baseBranch}`} ` +
|
|
711
|
+
'to compare with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0), ' +
|
|
712
|
+
'set `baseBranch`, or name the base: --affected=origin/main',
|
|
713
|
+
'VX_E_AFFECTED_BASE',
|
|
714
|
+
)
|
|
715
|
+
}
|
|
716
|
+
if (found.onIt) {
|
|
717
|
+
throw new UserError(
|
|
718
|
+
`--affected has no default on the base branch (${found.branch}): name the last good commit, --affected=<sha>`,
|
|
665
719
|
'VX_E_AFFECTED_BASE',
|
|
666
720
|
)
|
|
667
721
|
}
|
|
668
|
-
return
|
|
722
|
+
return found.ref
|
|
669
723
|
}
|
|
670
724
|
|
|
671
725
|
/**
|
|
@@ -687,30 +741,83 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
|
|
|
687
741
|
return undefined
|
|
688
742
|
}
|
|
689
743
|
|
|
690
|
-
|
|
744
|
+
/**
|
|
745
|
+
* What a branch changed, for which of its tasks read its cache scope
|
|
746
|
+
* (owner, 2026-10-17): from `base` (a plugin's: a pull request's base
|
|
747
|
+
* commit), else from the merge base with the base branch. No base, or
|
|
748
|
+
* one git cannot resolve (a CI checkout at `fetch-depth: 1`), is no
|
|
749
|
+
* change: every task reads the default key, which costs hits, never
|
|
750
|
+
* correctness, since a key must still match.
|
|
751
|
+
*/
|
|
752
|
+
export async function branchChanges(
|
|
753
|
+
workspaceRoot: string,
|
|
754
|
+
base: string | undefined,
|
|
755
|
+
baseBranch: string | undefined,
|
|
756
|
+
projects: Promise<readonly ProjectMeta[]>,
|
|
757
|
+
/** The run's own enumeration, so the diff walks nothing a second time. */
|
|
758
|
+
walked: Pick<AffectedArgs, 'untracked' | 'links'> = {},
|
|
759
|
+
): Promise<AffectedChanges> {
|
|
760
|
+
const none: AffectedChanges = {
|
|
761
|
+
projects: new Set(),
|
|
762
|
+
changed: [],
|
|
763
|
+
paths: new Map(),
|
|
764
|
+
whole: new Set(),
|
|
765
|
+
}
|
|
766
|
+
const since = base ?? (await baseBranchRef(workspaceRoot, baseBranch))?.ref
|
|
767
|
+
if (since === undefined) return none
|
|
768
|
+
try {
|
|
769
|
+
return await affectedChanges({
|
|
770
|
+
workspaceRoot,
|
|
771
|
+
since,
|
|
772
|
+
projects,
|
|
773
|
+
configImports: false,
|
|
774
|
+
...walked,
|
|
775
|
+
})
|
|
776
|
+
} catch (err) {
|
|
777
|
+
if (!isUserError(err)) throw err
|
|
778
|
+
return none
|
|
779
|
+
}
|
|
780
|
+
}
|
|
691
781
|
|
|
692
|
-
/**
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
782
|
+
/**
|
|
783
|
+
* The base branch as a ref: `origin/<name>`, else the local branch (a
|
|
784
|
+
* clone with no remote), and whether HEAD is on it. The name is the
|
|
785
|
+
* configured one, else `origin/HEAD`'s, else `main`, else `master`. Its
|
|
786
|
+
* spawns run at once, off the event loop.
|
|
787
|
+
*/
|
|
788
|
+
async function baseBranchRef(
|
|
789
|
+
workspaceRoot: string,
|
|
790
|
+
configured: string | undefined,
|
|
791
|
+
): Promise<{ ref: string; branch: string; onIt: boolean } | undefined> {
|
|
792
|
+
const text = async (args: string[]): Promise<string> => {
|
|
793
|
+
const proc = spawnGit(args, workspaceRoot)
|
|
794
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
795
|
+
return exit === 0 ? out.trim() : ''
|
|
796
|
+
}
|
|
797
|
+
const names = configured === undefined ? ['main', 'master'] : [configured]
|
|
798
|
+
const [remoteHead, refs, head] = await Promise.all([
|
|
799
|
+
configured === undefined
|
|
800
|
+
? text(['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'])
|
|
801
|
+
: Promise.resolve(''),
|
|
802
|
+
text([
|
|
696
803
|
'for-each-ref',
|
|
697
|
-
'--format=%(refname:short)
|
|
804
|
+
'--format=%(refname:short)',
|
|
698
805
|
'--end-of-options',
|
|
699
|
-
...
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
)
|
|
712
|
-
const
|
|
713
|
-
return
|
|
806
|
+
...(configured === undefined
|
|
807
|
+
? ['refs/remotes/origin']
|
|
808
|
+
: [`refs/remotes/origin/${configured}`]),
|
|
809
|
+
...names.map((n) => `refs/heads/${n}`),
|
|
810
|
+
]),
|
|
811
|
+
text(['symbolic-ref', '--short', '-q', 'HEAD']),
|
|
812
|
+
])
|
|
813
|
+
const have = new Set(refs.split('\n'))
|
|
814
|
+
const at = (branch: string, ref: string) => ({ ref, branch, onIt: head === branch })
|
|
815
|
+
if (remoteHead.startsWith('origin/') && have.has(remoteHead)) {
|
|
816
|
+
return at(remoteHead.slice('origin/'.length), remoteHead)
|
|
817
|
+
}
|
|
818
|
+
for (const n of names) if (have.has(`origin/${n}`)) return at(n, `origin/${n}`)
|
|
819
|
+
for (const n of names) if (have.has(n)) return at(n, n)
|
|
820
|
+
return undefined
|
|
714
821
|
}
|
|
715
822
|
|
|
716
823
|
/**
|
|
@@ -44,14 +44,39 @@ import { nonJsonMessage, nonJsonPaths } from './json-data.js'
|
|
|
44
44
|
// declared no plugins and ran the workspace bare, `cacheDirectory` left the
|
|
45
45
|
// cache where it was — a config that loads and quietly does nothing it says.
|
|
46
46
|
const CACHE_SCOPE_RULE =
|
|
47
|
-
"must be a scope name of letters, digits and . _ - / @ (at most 128), like '
|
|
47
|
+
"must be a scope name of letters, digits and . _ - / @ : (at most 128), like 'vznjs/vx:feat-x'"
|
|
48
48
|
|
|
49
|
-
/**
|
|
49
|
+
/** A cache scope's rule, for `VX_CACHE_SCOPE` and a plugin's `setBranch`. */
|
|
50
50
|
export function isCacheScope(v: unknown): v is string {
|
|
51
|
-
return typeof v === 'string' && /^[\w
|
|
51
|
+
return typeof v === 'string' && /^[\w.@/:-]{1,128}$/.test(v)
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
/**
|
|
54
|
+
/**
|
|
55
|
+
* A branch's cache scope, `<owner>/<repo>:<branch>`: what CI writes for a
|
|
56
|
+
* branch and a laptop on it reads (owner, 2026-10-17). The repository's
|
|
57
|
+
* identity, not a remote's local name, so a clone whose `origin` is a fork
|
|
58
|
+
* names the fork. A name may hold characters a scope may not; each
|
|
59
|
+
* becomes `_`.
|
|
60
|
+
*/
|
|
61
|
+
const BASE_BRANCH_RULE = "must be a branch name like 'main'"
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A branch name git takes after `origin/`: a leading '-' would reach git
|
|
65
|
+
* as an option, and whitespace, `..`, `~^:?*[\\` are no ref's.
|
|
66
|
+
*/
|
|
67
|
+
function isBaseBranch(value: unknown): value is string {
|
|
68
|
+
return (
|
|
69
|
+
typeof value === 'string' &&
|
|
70
|
+
/^[^-\s\0~^:?*[\\][^\s\0~^:?*[\\]*$/.test(value) &&
|
|
71
|
+
!value.includes('..')
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function branchCacheScope(repo: string, branch: string): string {
|
|
76
|
+
return `${repo.toLowerCase()}:${branch}`.replace(/[^\w.@/:-]/g, '_').slice(0, 128)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Why a `VX_CACHE_SCOPE` value is refused. */
|
|
55
80
|
export function cacheScopeError(source = 'VX_CACHE_SCOPE'): string {
|
|
56
81
|
return `${source} ${CACHE_SCOPE_RULE}`
|
|
57
82
|
}
|
|
@@ -61,6 +86,15 @@ export function cacheScopeEnvError(): UserError {
|
|
|
61
86
|
return new UserError(cacheScopeError())
|
|
62
87
|
}
|
|
63
88
|
|
|
89
|
+
/** `baseBranch`: `VX_BASE_BRANCH`, else the workspace's; undefined lets git name it. */
|
|
90
|
+
export function configuredBaseBranch(workspace: WorkspaceConfig | null): string | undefined {
|
|
91
|
+
const env = settingEnv('baseBranch')
|
|
92
|
+
if (env !== undefined && !isBaseBranch(env)) {
|
|
93
|
+
throw new UserError(`VX_BASE_BRANCH ${BASE_BRANCH_RULE}`, 'VX_E_USAGE')
|
|
94
|
+
}
|
|
95
|
+
return (env as string | undefined) ?? workspace?.baseBranch
|
|
96
|
+
}
|
|
97
|
+
|
|
64
98
|
const STRATEGIES = ['immutable', 'mutable', 'unchecked'] as const
|
|
65
99
|
const SEVERITIES = ['warn', 'error'] as const
|
|
66
100
|
const INPUT_STABILITY_FIELDS = new Set(['strategy', 'severity'])
|
|
@@ -98,7 +132,7 @@ export function inputStabilityEnv(): InputStability {
|
|
|
98
132
|
}
|
|
99
133
|
}
|
|
100
134
|
|
|
101
|
-
const WORKSPACE_FIELDS = new Set<string>([...SETTING_GROUPS, 'plugins'])
|
|
135
|
+
const WORKSPACE_FIELDS = new Set<string>([...SETTING_GROUPS, 'baseBranch', 'plugins'])
|
|
102
136
|
|
|
103
137
|
/**
|
|
104
138
|
* `.vx/workspace`'s settings, as `src/settings.ts` lists them: each group's
|
|
@@ -126,7 +160,7 @@ function validateSettingsAt(value: unknown, at: string, configPath: string): voi
|
|
|
126
160
|
if (at === 'cache')
|
|
127
161
|
// Turbo's and Nx's workspace `cache: true` is a task's own `cache` in vx (D-98).
|
|
128
162
|
throw new UserError(
|
|
129
|
-
`${where} must be an object like { dir, policy
|
|
163
|
+
`${where} must be an object like { dir, policy }; a task caches through its own \`cache\` in its project's vx.config`,
|
|
130
164
|
)
|
|
131
165
|
throw new UserError(`${where} must be an object (fields: ${[...fields].join(', ')})`)
|
|
132
166
|
}
|
|
@@ -197,18 +231,12 @@ function validateSettingsExtra(config: WorkspaceConfig, configPath: string): voi
|
|
|
197
231
|
// unwritable workspace.
|
|
198
232
|
throw new UserError(`${configPath}: \`cache.dir\` holds a NUL, which no path can carry`)
|
|
199
233
|
}
|
|
200
|
-
const base = config.
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
if (base !== undefined && !/^[^-\s\0][^\s\0]*$/.test(base)) {
|
|
204
|
-
throw new UserError(`${configPath}: \`run.affectedBase\` must be a git ref like 'origin/main'`)
|
|
234
|
+
const base = config.baseBranch
|
|
235
|
+
if (base !== undefined && !isBaseBranch(base)) {
|
|
236
|
+
throw new UserError(`${configPath}: \`baseBranch\` ${BASE_BRANCH_RULE}`)
|
|
205
237
|
}
|
|
206
238
|
// `policy`'s grammar is the cache module's (`parseCachePolicy`), which
|
|
207
239
|
// this module may not import: the run parses it before any task (prepare.ts).
|
|
208
|
-
const scope = config.cache?.scope
|
|
209
|
-
if (scope !== undefined && !isCacheScope(scope)) {
|
|
210
|
-
throw new UserError(`${configPath}: \`cache.scope\` ${CACHE_SCOPE_RULE}`)
|
|
211
|
-
}
|
|
212
240
|
if (config.cache?.retention !== undefined) validateRetention(config.cache.retention, configPath)
|
|
213
241
|
}
|
|
214
242
|
|
|
@@ -378,16 +406,21 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
378
406
|
}
|
|
379
407
|
}
|
|
380
408
|
if (plug.fingerprint !== undefined) {
|
|
381
|
-
const claim = plug.fingerprint as {
|
|
409
|
+
const claim = plug.fingerprint as {
|
|
410
|
+
files?: unknown
|
|
411
|
+
affected?: unknown
|
|
412
|
+
extraFiles?: unknown
|
|
413
|
+
} | null
|
|
382
414
|
if (
|
|
383
415
|
claim === null ||
|
|
384
416
|
typeof claim !== 'object' ||
|
|
385
417
|
!Array.isArray(claim.files) ||
|
|
386
418
|
claim.files.length === 0 ||
|
|
387
|
-
typeof claim.affected !== 'function'
|
|
419
|
+
typeof claim.affected !== 'function' ||
|
|
420
|
+
(claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function')
|
|
388
421
|
) {
|
|
389
422
|
throw new UserError(
|
|
390
|
-
`${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function }`,
|
|
423
|
+
`${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function }`,
|
|
391
424
|
)
|
|
392
425
|
}
|
|
393
426
|
for (const file of claim.files as unknown[]) {
|
|
@@ -770,6 +803,7 @@ export function validateProjectConfig(
|
|
|
770
803
|
}
|
|
771
804
|
assertPosixGlob(g, `${where}.inputs.files`, 'project')
|
|
772
805
|
assertNoForeignToken(g, `${where}.inputs.files`)
|
|
806
|
+
assertInputGlob(g, `${where}.inputs.files`)
|
|
773
807
|
if (namesDirItself(g)) {
|
|
774
808
|
throw new UserError(
|
|
775
809
|
`${where}.inputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
|
|
@@ -1097,7 +1131,7 @@ const FOREIGN_FIELDS: ReadonlyMap<ReadonlySet<string>, Readonly<Record<string, s
|
|
|
1097
1131
|
'a cache plugin in `plugins` (`turboCache()` or `nxCache()` from `@vzn/vx-migrate`)',
|
|
1098
1132
|
parallel: '`run.concurrency`',
|
|
1099
1133
|
cacheDirectory: '`cache.dir`',
|
|
1100
|
-
defaultBase: '`
|
|
1134
|
+
defaultBase: '`baseBranch`',
|
|
1101
1135
|
tasksRunnerOptions: 'a cache plugin in `plugins` (`nxCache()` from `@vzn/vx-migrate`)',
|
|
1102
1136
|
globalPassThroughEnv: '`exec.env.passThrough` on the tasks it passes to',
|
|
1103
1137
|
// pnpm-workspace.yaml's, Lerna's and the package.json's own keys
|
|
@@ -1333,6 +1367,33 @@ function assertPosixGlob(glob: string, where: string, base: string): void {
|
|
|
1333
1367
|
)
|
|
1334
1368
|
}
|
|
1335
1369
|
|
|
1370
|
+
const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* What every run would refuse, or silently key nothing for, refused at
|
|
1374
|
+
* `vx lock` instead. `Bun.Glob` reads `{b}` as a brace of one alternative,
|
|
1375
|
+
* so `src/{b}.ts` matches `src/b.ts` and never a file named `{b}.ts`:
|
|
1376
|
+
* neither reading is sure to be the one meant, so both spellings are
|
|
1377
|
+
* named. A path under `.git` is never listed by git, so it keyed nothing.
|
|
1378
|
+
*/
|
|
1379
|
+
function assertInputGlob(glob: string, where: string): void {
|
|
1380
|
+
const m = ONE_ALTERNATIVE_BRACE.exec(glob)
|
|
1381
|
+
if (m !== null) {
|
|
1382
|
+
const inner = m[0].slice(1, -1)
|
|
1383
|
+
throw new UserError(
|
|
1384
|
+
`${where}: "${glob}" holds a brace with one alternative, which matches ` +
|
|
1385
|
+
`"${inner}" and never a name holding "${m[0]}". Write "${glob.replace(m[0], inner)}", ` +
|
|
1386
|
+
`or "${glob.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
|
|
1387
|
+
)
|
|
1388
|
+
}
|
|
1389
|
+
if (glob.replace(/^!/, '').split('/').includes('.git')) {
|
|
1390
|
+
throw new UserError(
|
|
1391
|
+
`${where}: "${glob}" names a path under .git, which git never lists, so it keys nothing — ` +
|
|
1392
|
+
`inputs are the repository's files`,
|
|
1393
|
+
)
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1336
1397
|
/**
|
|
1337
1398
|
* `!!x` INVERTS the input set instead of double-negating it.
|
|
1338
1399
|
*
|
|
@@ -1565,6 +1626,7 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
|
|
|
1565
1626
|
}
|
|
1566
1627
|
assertPosixGlob(g, where, 'workspace-root')
|
|
1567
1628
|
assertNoForeignToken(g, where)
|
|
1629
|
+
if (negation) assertInputGlob(g, where)
|
|
1568
1630
|
if (namesDirItself(g)) {
|
|
1569
1631
|
throw new UserError(
|
|
1570
1632
|
`${where}: "${g}" names the workspace root itself and selects nothing — use "**" for everything under it`,
|
package/src/workspace/index.ts
CHANGED
|
@@ -6,6 +6,7 @@ export {
|
|
|
6
6
|
type AffectedChanges,
|
|
7
7
|
affectedProjects,
|
|
8
8
|
defaultAffectedBase,
|
|
9
|
+
branchChanges,
|
|
9
10
|
type FingerprintClaims,
|
|
10
11
|
refIsHead,
|
|
11
12
|
} from './affected.js'
|
|
@@ -35,8 +36,10 @@ export {
|
|
|
35
36
|
WORKSPACE_CONFIG_FILENAMES,
|
|
36
37
|
} from './project-loader.js'
|
|
37
38
|
export {
|
|
39
|
+
branchCacheScope,
|
|
38
40
|
cacheScopeEnvError,
|
|
39
41
|
cacheScopeError,
|
|
42
|
+
configuredBaseBranch,
|
|
40
43
|
inputStabilityEnv,
|
|
41
44
|
isCacheScope,
|
|
42
45
|
taskNameProblem,
|
|
@@ -188,14 +188,19 @@ export function linkDiscoveredEdges(
|
|
|
188
188
|
[edge.reach, reach],
|
|
189
189
|
] as const) {
|
|
190
190
|
for (const target of kind) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
191
|
+
// Every project lies inside the root (`discoveredProject` refuses
|
|
192
|
+
// one that does not), so a target naming one needs no other check:
|
|
193
|
+
// a plugin hands project dirs as they are, absolute, and resolving
|
|
194
|
+
// and judging each of 5,000 edges was ~15 ms at 1,000 projects.
|
|
195
|
+
const name = byDir.get(target) ?? byDir.get(path.resolve(root, target))
|
|
196
|
+
if (name === undefined) {
|
|
197
|
+
const rel = relPosix(root, path.resolve(root, target))
|
|
198
|
+
const where = `plugin '${edge.by}' gave project "${edge.meta.name}" an edge to ${target}`
|
|
199
|
+
if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
|
|
200
|
+
throw new UserError(`${where}: outside the workspace root`)
|
|
201
|
+
}
|
|
202
|
+
throw new UserError(`${where}: not a project`)
|
|
196
203
|
}
|
|
197
|
-
const name = byDir.get(dir)
|
|
198
|
-
if (name === undefined) throw new UserError(`${where}: not a project`)
|
|
199
204
|
let set = into.get(edge.meta)
|
|
200
205
|
if (set === undefined) into.set(edge.meta, (set = new Set()))
|
|
201
206
|
set.add(name)
|
|
@@ -417,12 +422,13 @@ export function lockedProjects(
|
|
|
417
422
|
* The project a plugin's `discover` entry names: a new meta (`added`) for a
|
|
418
423
|
* directory that is not a project yet, else the project already there,
|
|
419
424
|
* renamed when its name was its path's. A config's `name` wins over the
|
|
420
|
-
* plugin's; a name another plugin gave is not changed. `
|
|
421
|
-
*
|
|
425
|
+
* plugin's; a name another plugin gave is not changed. `known` is the
|
|
426
|
+
* projects by directory: a scan per entry was quadratic, 106 ms of a 5,000
|
|
427
|
+
* project warm run. `by` names the plugin in a refusal.
|
|
422
428
|
*/
|
|
423
429
|
export async function discoveredProject(
|
|
424
430
|
root: string,
|
|
425
|
-
known:
|
|
431
|
+
known: ReadonlyMap<string, ProjectMeta>,
|
|
426
432
|
entry: {
|
|
427
433
|
readonly dir: string
|
|
428
434
|
readonly name?: string
|
|
@@ -456,7 +462,7 @@ export async function discoveredProject(
|
|
|
456
462
|
throw new UserError(`${where}${named} at ${dir}: outside the workspace root`)
|
|
457
463
|
}
|
|
458
464
|
const shown = rel === '' ? '.' : rel
|
|
459
|
-
const meta = known.
|
|
465
|
+
const meta = known.get(dir)
|
|
460
466
|
if (meta !== undefined) {
|
|
461
467
|
const from = namedBy.get(meta)
|
|
462
468
|
if (name === undefined || name === meta.name || from === 'config') return { meta, added: false }
|