@vzn/vx 0.0.577 → 0.0.579

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.577",
3
+ "version": "0.0.579",
4
4
  "description": "The fastest task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -21,10 +21,10 @@
21
21
  "node": ">=18"
22
22
  },
23
23
  "optionalDependencies": {
24
- "@vzn/vx-linux-x64": "0.0.577",
25
- "@vzn/vx-linux-arm64": "0.0.577",
26
- "@vzn/vx-darwin-x64": "0.0.577",
27
- "@vzn/vx-darwin-arm64": "0.0.577"
24
+ "@vzn/vx-linux-x64": "0.0.579",
25
+ "@vzn/vx-linux-arm64": "0.0.579",
26
+ "@vzn/vx-darwin-x64": "0.0.579",
27
+ "@vzn/vx-darwin-arm64": "0.0.579"
28
28
  },
29
29
  "dependencies": {
30
30
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -12,6 +12,7 @@ import {
12
12
  executablePath,
13
13
  gitSpawnRefusal,
14
14
  isInstalledPath,
15
+ notAWorkTree,
15
16
  xxh3hex,
16
17
  } from '../util/index.js'
17
18
  import { FILE_HASH_RACY_MS, racyWindowMs } from './layer.js'
@@ -457,11 +458,7 @@ export function runGitLsFiles(cwd: string): GitLsResult {
457
458
  if (proc.exitCode !== 0) {
458
459
  // Exit 128 = not a git work tree; other non-zero = git failure.
459
460
  // Either way we can't enumerate inputs reliably.
460
- const stderr = new TextDecoder().decode(proc.stderr).trim()
461
- throw new UserError(
462
- `vx requires git: ${cwd} is not inside a git work tree. ` +
463
- `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
464
- )
461
+ throw notAWorkTree(cwd, new TextDecoder().decode(proc.stderr).trim())
465
462
  }
466
463
  const { text, undecodable } = decodeGitZ(proc.stdout)
467
464
  const parsed = parseLsFilesOutput(text.length === 0 ? [] : text.split('\0'), undecodable)
package/src/config.ts CHANGED
@@ -46,6 +46,12 @@ export interface WorkspaceConfig {
46
46
  * folded into a cache key.
47
47
  */
48
48
  cacheScope?: string
49
+ /**
50
+ * Graph rules a run checks before it schedules anything. Each is on unless
51
+ * set to `false`; turning one off allows a shape vx handles correctly but
52
+ * more slowly. Never folded into a cache key.
53
+ */
54
+ rules?: WorkspaceRules
49
55
  /**
50
56
  * Plugins registered for this workspace, consulted in this order once
51
57
  * per `vx run`: each fills any of the pipeline stages and capabilities
@@ -55,6 +61,17 @@ export interface WorkspaceConfig {
55
61
  plugins?: readonly Plugin[]
56
62
  }
57
63
 
64
+ /** `WorkspaceConfig.rules`. */
65
+ export interface WorkspaceRules {
66
+ /**
67
+ * Refuse two tasks whose declared outputs overlap even when a `dependsOn`
68
+ * edge orders them. Off, the dependant adds to its upstream's tree and
69
+ * owns only what its run added (`caching.md` § Additive outputs). Two
70
+ * overlapping tasks with no edge are refused either way.
71
+ */
72
+ exclusiveOutputs?: boolean
73
+ }
74
+
58
75
  /**
59
76
  * The runtime brand `definePlugin` stamps on a plugin: the name of the
60
77
  * package it was defined in. The workspace loader accepts a plugin only
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path'
2
- import type { ProjectConfig, TaskConfig } from '../config.js'
2
+ import type { ProjectConfig, TaskConfig, WorkspaceRules } from '../config.js'
3
3
  import {
4
4
  asTrees,
5
5
  isLiteralPattern,
@@ -267,6 +267,8 @@ export interface BuildGraphOptions {
267
267
  * with other root-anchored ones.
268
268
  */
269
269
  workspaceRoot?: string
270
+ /** The workspace's `rules`; each is on unless set to `false`. */
271
+ rules?: WorkspaceRules | undefined
270
272
  }
271
273
 
272
274
  /**
@@ -519,7 +521,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
519
521
  }
520
522
  }
521
523
 
522
- checkGraph(nodes, options.workspaceRoot)
524
+ checkGraph(nodes, options.workspaceRoot, options.rules)
523
525
  return nodes
524
526
  }
525
527
 
@@ -534,7 +536,11 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
534
536
  * marks are derived here, so they are cleared first and follow the edges
535
537
  * the graph has now.
536
538
  */
537
- export function checkGraph(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
539
+ export function checkGraph(
540
+ nodes: Map<string, TaskNode>,
541
+ workspaceRoot?: string,
542
+ rules?: WorkspaceRules,
543
+ ): void {
538
544
  for (const [key, node] of nodes) {
539
545
  if (node.id !== key) {
540
546
  throw new Error(`the task ${node.id} is stored under '${key}', not its own id`)
@@ -548,7 +554,7 @@ export function checkGraph(nodes: Map<string, TaskNode>, workspaceRoot?: string)
548
554
  delete node.outputsAddedToBy
549
555
  }
550
556
  detectCycle(nodes)
551
- detectOutputCollisions(nodes, workspaceRoot)
557
+ detectOutputCollisions(nodes, workspaceRoot, rules?.exclusiveOutputs !== false)
552
558
  }
553
559
 
554
560
  /**
@@ -770,7 +776,11 @@ function covers(tree: string, glob: string): boolean {
770
776
  * project boundaries by design, so ANY two tasks can. No cache key changes —
771
777
  * this only refuses a graph that was already destroying files.
772
778
  */
773
- function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: string): void {
779
+ function detectOutputCollisions(
780
+ nodes: Map<string, TaskNode>,
781
+ workspaceRoot: string | undefined,
782
+ exclusive: boolean,
783
+ ): void {
774
784
  // Does `from` reach `to` through deps? Asked only for a colliding pair,
775
785
  // so the walk is rare; memoised per source across the detector's calls.
776
786
  const reachMemo = new Map<string, Set<string>>()
@@ -834,14 +844,14 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
834
844
  for (const [i, j] of overlapCandidates(bucket, filesOf)) {
835
845
  const a = bucket[i]!
836
846
  const b = bucket[j]!
837
- collide(a, b, filesOf(a), filesOf(b), 'files', reaches)
847
+ collide(a, b, filesOf(a), filesOf(b), 'files', reaches, exclusive)
838
848
  }
839
849
  }
840
850
  if (wsDeclarers.length >= 2) {
841
851
  for (const [i, j] of overlapCandidates(wsDeclarers, wsFilesOf)) {
842
852
  const a = wsDeclarers[i]!
843
853
  const b = wsDeclarers[j]!
844
- collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches)
854
+ collide(a, b, wsFilesOf(a), wsFilesOf(b), 'workspaceFiles', reaches, exclusive)
845
855
  }
846
856
  }
847
857
  // A root-anchored output that reaches into ANOTHER project's `files`
@@ -870,7 +880,7 @@ function detectOutputCollisions(nodes: Map<string, TaskNode>, workspaceRoot?: st
870
880
  const y = entries[j]!
871
881
  if ((x.rel === undefined) === (y.rel === undefined) || x.node === y.node) continue
872
882
  const [ws, files] = x.rel === undefined ? [x, y] : [y, x]
873
- collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches)
883
+ collideAcross(ws.node, ws.globs, files.node, files.globs, files.rel!, reaches, exclusive)
874
884
  }
875
885
  }
876
886
 
@@ -889,11 +899,20 @@ function collideAcross(
889
899
  rebased: readonly string[],
890
900
  rel: string,
891
901
  reaches: (from: string, to: string) => boolean,
902
+ exclusive: boolean,
892
903
  ): void {
893
904
  if (neverWritesLocally(ws) || neverWritesLocally(files)) return
894
905
  for (const ga of wsGlobs) {
895
906
  for (const gb of rebased) {
896
907
  if (!outputsOverlap(ga, gb)) continue
908
+ const head =
909
+ `${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
910
+ `inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
911
+ `cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
912
+ `cache-hit restore, so whichever of these runs second DELETES the other's output`
913
+ if (exclusive && (reaches(files.id, ws.id) || reaches(ws.id, files.id))) {
914
+ throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
915
+ }
897
916
  if (reaches(files.id, ws.id)) {
898
917
  ;(files.addsToOutputsOf ??= []).push(ws.id)
899
918
  ;(ws.outputsAddedToBy ??= []).push(...rebased)
@@ -910,12 +929,8 @@ function collideAcross(
910
929
  }
911
930
  }
912
931
  throw new UserError(
913
- `${ws.id} declares the output ${JSON.stringify(ga)} in cache.outputs.workspaceFiles, ` +
914
- `inside ${files.id}'s ${JSON.stringify(gb.slice(rel === '' ? 0 : rel.length + 1))} in ` +
915
- `cache.outputs.files — vx cleans a task's declared outputs before it runs and before a ` +
916
- `cache-hit restore, so whichever of these runs second DELETES the other's output and ` +
917
- `the run still reports success. Give each task its own output path, or make one ` +
918
- `depend on the other.`,
932
+ `${head} and the run still reports success. Give each task its own output path` +
933
+ (exclusive ? '.' : ', or make one depend on the other.'),
919
934
  )
920
935
  }
921
936
  }
@@ -1039,6 +1054,15 @@ function neverWritesLocally(n: TaskNode): boolean {
1039
1054
  return n.config.exec?.remote === 'only'
1040
1055
  }
1041
1056
 
1057
+ /**
1058
+ * The way out of an edge-ordered overlap under `rules.exclusiveOutputs`: an
1059
+ * ordered pair is correct (the addition shape, item 588), only slower to
1060
+ * clean and restore, so the rule may be turned off.
1061
+ */
1062
+ const EXCLUSIVE_OUTPUTS_FIX =
1063
+ 'Give each task its own output path, or set rules: { exclusiveOutputs: false } in ' +
1064
+ "vx.workspace.ts to let a dependant add to its upstream's outputs."
1065
+
1042
1066
  function collide(
1043
1067
  a: TaskNode,
1044
1068
  b: TaskNode,
@@ -1046,17 +1070,26 @@ function collide(
1046
1070
  bGlobs: readonly string[] | undefined,
1047
1071
  field: 'files' | 'workspaceFiles',
1048
1072
  reaches: (from: string, to: string) => boolean,
1073
+ exclusive: boolean,
1049
1074
  ): void {
1050
1075
  if (neverWritesLocally(a) || neverWritesLocally(b)) return
1051
1076
  for (const ga of aGlobs ?? []) {
1052
1077
  for (const gb of bGlobs ?? []) {
1053
1078
  if (!outputsOverlap(ga, gb)) continue
1079
+ const head =
1080
+ `${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
1081
+ (ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
1082
+ ` in cache.outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
1083
+ `before a cache-hit restore, so whichever of these runs second DELETES the other's ` +
1084
+ `output`
1054
1085
  // An overlap WITH an edge is the addition shape (item 588): the
1055
1086
  // dependant runs after its upstream and adds to that tree, so the
1056
1087
  // order is fixed and the dependant's own set can be told apart from
1057
- // what it found. Marked on both, and the pair is allowed. Without an
1058
- // edge the two run in either order, and the refusal below stands.
1088
+ // what it found. Marked on both, and the pair is allowed when
1089
+ // `rules.exclusiveOutputs` is off (X-53). Without an edge the two run
1090
+ // in either order, and the refusal below stands.
1059
1091
  const [up, down] = reaches(b.id, a.id) ? [a, b] : reaches(a.id, b.id) ? [b, a] : []
1092
+ if (up !== undefined && exclusive) throw new UserError(`${head}. ${EXCLUSIVE_OUTPUTS_FIX}`)
1060
1093
  if (up !== undefined && down !== undefined) {
1061
1094
  const downGlobs = splitNegations(
1062
1095
  (field === 'files'
@@ -1068,11 +1101,7 @@ function collide(
1068
1101
  return
1069
1102
  }
1070
1103
  throw new UserError(
1071
- `${a.id} and ${b.id} both declare the output ${JSON.stringify(ga)}` +
1072
- (ga === gb ? '' : ` / ${JSON.stringify(gb)}`) +
1073
- ` in cache.outputs.${field} — vx cleans a task's declared outputs before it runs and ` +
1074
- `before a cache-hit restore, so whichever of these runs second DELETES the other's ` +
1075
- `output and the run still reports success. Give each task its own output path.`,
1104
+ `${head} and the run still reports success. Give each task its own output path.`,
1076
1105
  )
1077
1106
  }
1078
1107
  }
@@ -10,7 +10,7 @@
10
10
  import { ChainedCache, type CacheLayer } from '../cache/index.js'
11
11
  import { localExecutor, type TaskExecutor } from '../exec/index.js'
12
12
  import { settleWithin, teardownTimeoutMs, UserError } from '../util/index.js'
13
- import type { ProjectConfig, WorkspaceConfig } from '../config.js'
13
+ import type { ProjectConfig, WorkspaceConfig, WorkspaceRules } from '../config.js'
14
14
  import { checkGraph, type TaskNode } from '../graph/index.js'
15
15
  import type {
16
16
  CacheContext,
@@ -178,6 +178,7 @@ export async function applyGraphHooks(
178
178
  ctx: GraphHookContext,
179
179
  /** Runs after EACH plugin's edit, so a refused task config names the plugin that wrote it. */
180
180
  afterEach?: (plugin: VxPlugin) => void,
181
+ rules?: WorkspaceRules,
181
182
  ): Promise<void> {
182
183
  let last: VxPlugin | undefined
183
184
  for (const plugin of plugins) {
@@ -188,7 +189,7 @@ export async function applyGraphHooks(
188
189
  last = plugin
189
190
  }
190
191
  if (last === undefined) return
191
- await safe(last, 'graph', () => checkGraph(nodes, ctx.workspaceRoot))
192
+ await safe(last, 'graph', () => checkGraph(nodes, ctx.workspaceRoot, rules))
192
193
  }
193
194
 
194
195
  /**
@@ -515,6 +515,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
515
515
  packageGraph,
516
516
  requested: req,
517
517
  workspaceRoot,
518
+ rules: workspaceConfig?.rules,
518
519
  ...(projects.size < projectsWithConfigs.length
519
520
  ? { undeclaredDeps: (id: string, name: string) => void unproven.push([id, name]) }
520
521
  : {}),
@@ -628,6 +629,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
628
629
  )
629
630
  }
630
631
  },
632
+ workspaceConfig?.rules,
631
633
  )
632
634
  }
633
635
  // Args after `--` go to requested commands only; with none among them
@@ -98,6 +98,18 @@ export function gitSpawnRefusal(cwd: string): UserError {
98
98
  )
99
99
  }
100
100
 
101
+ /**
102
+ * The one refusal for a directory git does not track, wherever vx needed
103
+ * it: `--affected` named a missing base there, as a shallow clone, while
104
+ * a plain run already said this (X-52).
105
+ */
106
+ export function notAWorkTree(cwd: string, stderr = ''): UserError {
107
+ return new UserError(
108
+ `vx requires git: ${cwd} is not inside a git work tree. ` +
109
+ `Run 'git init' in your workspace root.${stderr ? ` (git: ${stderr})` : ''}`,
110
+ )
111
+ }
112
+
101
113
  export const TMPDIR_HINT = 'point TMPDIR at a writable directory'
102
114
 
103
115
  /**
package/src/util/index.ts CHANGED
@@ -15,6 +15,7 @@ export {
15
15
  gitSpawnRefusal,
16
16
  isExecutableMissing,
17
17
  isOutOfFds,
18
+ notAWorkTree,
18
19
  OUT_OF_FDS_HINT,
19
20
  } from './errors.js'
20
21
  export { xxh3, xxh3hex } from './hash.js'
@@ -15,6 +15,7 @@ import {
15
15
  gitSpawnRefusal,
16
16
  isExecutableMissing,
17
17
  isInstalledPath,
18
+ notAWorkTree,
18
19
  relPosix,
19
20
  } from '../util/index.js'
20
21
  import { LOCKFILE_NAME } from './lockfile.js'
@@ -649,6 +650,8 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
649
650
  const trunk = trunkBase(workspaceRoot)
650
651
  if (trunk !== undefined) return trunk
651
652
  if (revParse(workspaceRoot, 'HEAD~1') === undefined) {
653
+ const noHistory = noHistoryRefusal(workspaceRoot)
654
+ if (noHistory !== undefined) throw noHistory
652
655
  throw new UserError(
653
656
  '--affected has no base here: origin/HEAD is not set (or names a branch that is gone) and HEAD has no parent to compare ' +
654
657
  'with — a shallow clone? Fetch history (actions/checkout: fetch-depth: 0) or name the ' +
@@ -658,6 +661,24 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
658
661
  return 'HEAD~1'
659
662
  }
660
663
 
664
+ /**
665
+ * Why a base cannot resolve before any ref is to blame: no work tree, or
666
+ * no commit yet. Asked only once a base has failed, so a run that finds
667
+ * one spawns nothing more (X-52).
668
+ */
669
+ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
670
+ const tree = spawnGitSync(['rev-parse', '--is-inside-work-tree'], workspaceRoot, 'pipe')
671
+ if (tree.exitCode !== 0) {
672
+ return notAWorkTree(workspaceRoot, new TextDecoder().decode(tree.stderr).trim())
673
+ }
674
+ if (revParse(workspaceRoot, 'HEAD') === undefined) {
675
+ return new UserError(
676
+ '--affected has no base here: this repository has no commit yet. Commit first, or run without --affected.',
677
+ )
678
+ }
679
+ return undefined
680
+ }
681
+
661
682
  const TRUNKS = ['origin/main', 'origin/master', 'main', 'master']
662
683
 
663
684
  /** The first of `TRUNKS` that exists and is not HEAD's commit, in one spawn. */
@@ -752,6 +773,8 @@ async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
752
773
  // 1 gets the ref message; anything else surfaces what git actually said.
753
774
  const stderr = new TextDecoder().decode(proc.stderr).trim()
754
775
  if (proc.exitCode !== 1) {
776
+ const noHistory = noHistoryRefusal(workspaceRoot)
777
+ if (noHistory !== undefined) throw noHistory
755
778
  throw new UserError(
756
779
  `git rev-parse failed (exit ${proc.exitCode}) in ${workspaceRoot}` +
757
780
  (stderr.length > 0 ? `: ${stderr}` : ''),
@@ -54,9 +54,25 @@ const WORKSPACE_FIELDS = new Set([
54
54
  'cacheRetention',
55
55
  'affectedBase',
56
56
  'cacheScope',
57
+ 'rules',
57
58
  'plugins',
58
59
  ])
59
60
 
61
+ const RULE_FIELDS = new Set(['exclusiveOutputs'])
62
+
63
+ function validateRules(rules: unknown, configPath: string): void {
64
+ const where = `${configPath}: \`rules\``
65
+ if (rules === null || typeof rules !== 'object' || Array.isArray(rules)) {
66
+ throw new UserError(`${where} must be { exclusiveOutputs?: boolean }`)
67
+ }
68
+ assertKnownFields(rules as Record<string, unknown>, RULE_FIELDS, where)
69
+ for (const [name, value] of Object.entries(rules)) {
70
+ if (value !== undefined && typeof value !== 'boolean') {
71
+ throw new UserError(`${where}.${name} must be true or false`)
72
+ }
73
+ }
74
+ }
75
+
60
76
  const RETENTION_FIELDS = new Set(['olderThan', 'maxSize'])
61
77
 
62
78
  function validateRetention(retention: unknown, configPath: string): void {
@@ -142,6 +158,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
142
158
  if (config.cacheScope !== undefined && !isCacheScope(config.cacheScope)) {
143
159
  throw new UserError(`${configPath}: \`cacheScope\` ${CACHE_SCOPE_RULE}`)
144
160
  }
161
+ if (config.rules !== undefined) validateRules(config.rules, configPath)
145
162
  if (config.plugins !== undefined) {
146
163
  if (!Array.isArray(config.plugins)) {
147
164
  throw new UserError(`${configPath}: \`plugins\` must be an array of plugin objects`)