@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.
Files changed (77) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +85 -11
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +12 -3
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +4 -6
  11. package/src/cache/layered-cache.ts +96 -10
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/plugin-cache.ts +23 -16
  14. package/src/cache/remote-lane.ts +25 -8
  15. package/src/cache/run-store.ts +4 -1
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +74 -237
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +6 -8
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/plugin-commands.ts +11 -6
  23. package/src/cli/run.ts +14 -15
  24. package/src/cli/select.ts +9 -9
  25. package/src/cli/workspace-config.ts +8 -2
  26. package/src/config.ts +7 -16
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/history/history.ts +43 -33
  31. package/src/index.ts +14 -0
  32. package/src/orchestrator/affected-tasks.ts +23 -2
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +10 -2
  36. package/src/orchestrator/index.ts +5 -0
  37. package/src/orchestrator/lockfile-claim.ts +54 -8
  38. package/src/orchestrator/miss-save.ts +27 -17
  39. package/src/orchestrator/options.ts +9 -1
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +38 -4
  43. package/src/orchestrator/plugin.ts +49 -5
  44. package/src/orchestrator/prepare.ts +141 -22
  45. package/src/orchestrator/projects.ts +25 -6
  46. package/src/orchestrator/run-context.ts +80 -0
  47. package/src/orchestrator/run.ts +38 -15
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +52 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +53 -13
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +8 -2
  58. package/src/settings.ts +11 -11
  59. package/src/util/errors.ts +1 -1
  60. package/src/util/hidden-prompt.ts +74 -0
  61. package/src/util/index.ts +12 -2
  62. package/src/util/paths.ts +33 -14
  63. package/src/util/secrets.ts +150 -0
  64. package/src/workspace/affected.ts +182 -75
  65. package/src/workspace/config-schema.ts +81 -19
  66. package/src/workspace/index.ts +3 -0
  67. package/src/workspace/project-loader.ts +1 -0
  68. package/src/workspace/workspace.ts +17 -11
  69. package/docs/caching.md +0 -2376
  70. package/docs/cli.md +0 -3285
  71. package/docs/execution.md +0 -676
  72. package/docs/features.md +0 -178
  73. package/docs/patterns.md +0 -219
  74. package/docs/schema.md +0 -2468
  75. package/docs/security.md +0 -253
  76. package/src/cli/docs-corpus.ts +0 -21
  77. 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
- projects: readonly ProjectMeta[]
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(args.projects.map((p) => p.name))
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 patch bun.lock names folds into the key of the plugin that claims
307
- // bun.lock (`bun()`; the lockfile records no hash of it), so its edit
308
- // widens as a lockfile edit does. A patch the lockfile no longer names
309
- // moved bun.lock itself.
310
- if (changed.length > 0) {
311
- const lock = await bytesOrNull(path.join(args.workspaceRoot, 'bun.lock'))
312
- if (lock !== null) {
313
- const patches = new Set(bunPatchFiles(lock))
314
- const patch = changed.find((p) => patches.has(p))
315
- if (patch !== undefined && (await loadClaims())?.files.has('bun.lock')) {
316
- return everything(patch)
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
- if (answer === undefined) return everything(file)
331
- for (const name of answer) if (!claimedOwned.has(name)) claimedOwned.set(name, file)
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
- args.projects.map(async (p) => [p.dir, await realpath(p.dir).catch(() => p.dir)] as const),
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
- args.projects,
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, args.projects)) {
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 await configImportOwners({
380
- workspaceRoot: args.workspaceRoot,
381
- projects: args.projects,
382
- changed,
383
- skip: perTask ? new Set() : owned,
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
- * Resolve the default base for `--affected` with no explicit value.
633
- * Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
634
- * etc.), then `origin/main`, `origin/master`, `main`, `master`, then
635
- * falls back to `HEAD~1`. A clone with no `origin/HEAD` and
636
- * no parent commit — a CI checkout at `fetch-depth: 1` — has no base at
637
- * all, and says so here rather than failing on a ref nobody typed.
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(workspaceRoot: string): Promise<string> {
640
- const probe = spawnGitSync(
641
- ['symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
642
- workspaceRoot,
643
- 'ignore',
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
- '--affected has no base here: origin/HEAD is not set (or names a branch that is gone) and HEAD has no parent to compare ' +
663
- 'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0) or name the ' +
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 'HEAD~1'
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
- const TRUNKS = ['origin/main', 'origin/master', 'main', 'master']
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
- /** The first of `TRUNKS` that exists and is not HEAD's commit, in one spawn. */
693
- function trunkBase(workspaceRoot: string): string | undefined {
694
- const proc = spawnGitSync(
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) %(objectname)',
804
+ '--format=%(refname:short)',
698
805
  '--end-of-options',
699
- ...TRUNKS.map((t) => (t.startsWith('origin/') ? `refs/remotes/${t}` : `refs/heads/${t}`)),
700
- ],
701
- workspaceRoot,
702
- 'ignore',
703
- )
704
- if (proc.exitCode !== 0) return undefined
705
- const at = new Map(
706
- new TextDecoder()
707
- .decode(proc.stdout)
708
- .split('\n')
709
- .filter((l) => l !== '')
710
- .map((l) => l.split(' ') as [string, string]),
711
- )
712
- const head = revParse(workspaceRoot, 'HEAD')
713
- return TRUNKS.find((t) => at.has(t) && at.get(t) !== head)
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 'pr-123'"
47
+ "must be a scope name of letters, digits and . _ - / @ : (at most 128), like 'vznjs/vx:feat-x'"
48
48
 
49
- /** `cache.scope`'s rule, shared with `VX_CACHE_SCOPE`. */
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.@/-]{1,128}$/.test(v)
51
+ return typeof v === 'string' && /^[\w.@/:-]{1,128}$/.test(v)
52
52
  }
53
53
 
54
- /** Why a `VX_CACHE_SCOPE` or `--cache-scope` value is refused, in `cache.scope`'s words. */
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, scope }; a task caches through its own \`cache\` in its project's vx.config`,
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.run?.affectedBase
201
- // A leading '-' would reach git as an option, not a ref; a NUL fails the
202
- // spawn with an argument error naming nothing in this file.
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 { files?: unknown; affected?: unknown } | null
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: '`run.affectedBase`',
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`,
@@ -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,
@@ -640,6 +640,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
640
640
  'plugin',
641
641
  'randomUUIDv7',
642
642
  'resolveSync',
643
+ 'secrets',
643
644
  'serve',
644
645
  'sleep',
645
646
  'sleepSync',
@@ -188,14 +188,19 @@ export function linkDiscoveredEdges(
188
188
  [edge.reach, reach],
189
189
  ] as const) {
190
190
  for (const target of kind) {
191
- const dir = path.resolve(root, target)
192
- const rel = relPosix(root, dir)
193
- const where = `plugin '${edge.by}' gave project "${edge.meta.name}" an edge to ${target}`
194
- if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
195
- throw new UserError(`${where}: outside the workspace root`)
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. `by` names the
421
- * plugin in a refusal.
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: readonly ProjectMeta[],
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.find((p) => p.dir === dir)
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 }