@vzn/vx 0.0.132 → 0.0.134

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.132",
3
+ "version": "0.0.134",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.132",
21
- "@vzn/vx-linux-arm64": "0.0.132",
22
- "@vzn/vx-darwin-x64": "0.0.132",
23
- "@vzn/vx-darwin-arm64": "0.0.132"
20
+ "@vzn/vx-linux-x64": "0.0.134",
21
+ "@vzn/vx-linux-arm64": "0.0.134",
22
+ "@vzn/vx-darwin-x64": "0.0.134",
23
+ "@vzn/vx-darwin-arm64": "0.0.134"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
@@ -96,11 +96,25 @@ export function formatPlanText(plan: RunPlan): string {
96
96
  return lines.join('\n') + '\n'
97
97
  }
98
98
 
99
+ /** One task of `vx run --dry=json`: the wire, stated by `schemas/plan.json`. */
100
+ export interface PlanTaskJson {
101
+ id: string
102
+ project: string
103
+ task: string
104
+ hash: string
105
+ cacheStatus: CacheStatus
106
+ deps: readonly string[]
107
+ p50Ms?: number
108
+ executor?: string
109
+ download?: 'deferred'
110
+ description?: string
111
+ }
112
+
99
113
  export function formatPlanJson(plan: RunPlan): string {
100
114
  return (
101
115
  JSON.stringify(
102
116
  {
103
- tasks: plan.tasks.map((t) => ({
117
+ tasks: plan.tasks.map((t): PlanTaskJson => ({
104
118
  id: t.node.id,
105
119
  project: t.node.projectName,
106
120
  task: t.node.taskName,
@@ -6,7 +6,7 @@
6
6
  // selection the hash path applies (`selectFoldedDeps`).
7
7
 
8
8
  import { isGroupTask, type TaskNode } from '../graph/index.js'
9
- import { selectFoldedDeps, type FoldCandidate } from './upstream.js'
9
+ import { keyedDeps, selectFoldedDeps, type FoldCandidate } from './upstream.js'
10
10
 
11
11
  /**
12
12
  * A run's keyed-set lookup: for a task, the directories of every project
@@ -31,7 +31,9 @@ import { selectFoldedDeps, type FoldCandidate } from './upstream.js'
31
31
  */
32
32
  export function keyedProjects(
33
33
  nodes: ReadonlyMap<string, TaskNode>,
34
+ keyOnly: ReadonlyMap<string, TaskNode> = new Map(),
34
35
  ): (node: TaskNode) => ReadonlySet<string> {
36
+ const nodeOf = (id: string): TaskNode => nodes.get(id) ?? keyOnly.get(id)!
35
37
  const below = new Map<string, ReadonlySet<string>>()
36
38
  // Post-order on an explicit stack: a fold is as deep as the graph, and a
37
39
  // recursion per edge threw `RangeError` at the 50,000 the builder takes
@@ -47,7 +49,7 @@ export function keyedProjects(
47
49
  continue
48
50
  }
49
51
  if (deps === undefined) {
50
- frame[1] = folded(node, nodes)
52
+ frame[1] = folded(node, nodeOf)
51
53
  for (const { node: dep } of frame[1]) if (!below.has(dep.id)) stack.push([dep, undefined])
52
54
  continue
53
55
  }
@@ -63,11 +65,22 @@ export function keyedProjects(
63
65
  }
64
66
  }
65
67
 
66
- /** The dependencies `node`'s key folds, per the hash path's rules above. */
67
- function folded(node: TaskNode, nodes: ReadonlyMap<string, TaskNode>): FoldCandidate[] {
68
+ /**
69
+ * The dependencies `node`'s key folds, per the hash path's rules above, as
70
+ * `keyUpstream` hands them to the key: the scheduled ones less the edges
71
+ * `--exclude-dependencies` left for order alone, plus the dropped ones it
72
+ * keys all the same (`excludedUpstream`), whose own dependencies the walk
73
+ * reads from the run's `keyOnly` map. `node.deps` held the order-only
74
+ * edges and lacked the dropped ones: the sandbox granted a sibling the key
75
+ * does not answer for.
76
+ */
77
+ function folded(node: TaskNode, nodeOf: (id: string) => TaskNode): FoldCandidate[] {
68
78
  const candidates: FoldCandidate[] = []
69
- for (const id of node.deps) {
70
- const dep = nodes.get(id)!
79
+ for (const id of keyedDeps(node)) {
80
+ const dep = nodeOf(id)
81
+ candidates.push({ node: dep, unit: foldUnit(dep) })
82
+ }
83
+ for (const { node: dep } of node.excludedUpstream ?? []) {
71
84
  candidates.push({ node: dep, unit: foldUnit(dep) })
72
85
  }
73
86
  if (isGroupTask(node)) return candidates
@@ -89,6 +89,11 @@ export interface PreparedRun {
89
89
  */
90
90
  priorities: ReadonlyMap<string, number>
91
91
  nodes: Map<string, TaskNode>
92
+ /**
93
+ * The tasks `--exclude-dependencies` took out of the schedule: keyed as a
94
+ * full run keys them, never run. Empty without the flag.
95
+ */
96
+ keyOnly: ReadonlyMap<string, TaskNode>
92
97
  /**
93
98
  * Requested task specs that matched NO project — a typo, or a stray
94
99
  * positional (the value of an `=`-only flag written with a space).
@@ -391,6 +396,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
391
396
  hasRemoteLayer,
392
397
  priorities: new Map(),
393
398
  nodes: new Map(),
399
+ keyOnly: new Map(),
394
400
  unresolvedTasks,
395
401
  projects,
396
402
  anyProjectConfig: projectsWithConfigs.length > 0,
@@ -469,8 +475,11 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
469
475
  await applyKeyHooks(plugins, nodes, { workspaceRoot, cacheDir, warn: (m) => log.status(m) })
470
476
  }
471
477
  const exclude = options.excludeDependencies
478
+ let keyOnly: ReadonlyMap<string, TaskNode> = new Map()
472
479
  if (exclude !== undefined && (exclude === 'all' || exclude.length > 0)) {
473
- const { keyOnly, dropped } = excludeDependencies(nodes, exclude)
480
+ const split = excludeDependencies(nodes, exclude)
481
+ keyOnly = split.keyOnly
482
+ const dropped = split.dropped
474
483
  if (dropped.size > 0) {
475
484
  await keyExcludedDependencies({
476
485
  nodes,
@@ -506,6 +515,7 @@ export async function prepareRun(options: RunOptions, log: Logger): Promise<Prep
506
515
  hasRemoteLayer,
507
516
  priorities,
508
517
  nodes,
518
+ keyOnly,
509
519
  unresolvedTasks,
510
520
  projects,
511
521
  anyProjectConfig: projectsWithConfigs.length > 0,
@@ -618,7 +618,7 @@ async function runOnBus(
618
618
  }
619
619
 
620
620
  const sandboxArmer = prepareSandbox(nodes.values())
621
- const keyed = keyedProjects(nodes)
621
+ const keyed = keyedProjects(nodes, prepared.keyOnly)
622
622
  const outputDirSnapshots: OutputDirSnapshot[] = []
623
623
  // Saves run off the execution slot, twice the cap at once (memory:
624
624
  // each pack holds an artifact's bytes); a failed save is a miss next
@@ -20,12 +20,13 @@
20
20
  // the lockfile moves, which the workspace fingerprint already covers.
21
21
  // A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
22
22
  // from disk (D-27).
23
- // - Descend only through files owned by NO project. A config reaching into
24
- // another project (say a site's `vx.config.ts` importing
25
- // `../core/src/index.ts`) records that edge and STOPS there — following
26
- // it would drag substantially all of that project's `src/` into the
27
- // closure, and the containment channel already selects the project that
28
- // owns it.
23
+ // - Descend only through files owned by NO project, or by a ROOT project
24
+ // (whose own files are the shared tooling that is otherwise unowned,
25
+ // D-41). A config reaching into another project (say a site's
26
+ // `vx.config.ts` importing `../core/src/index.ts`) records that edge
27
+ // and STOPS there — following it would drag substantially all of that
28
+ // project's `src/` into the closure, and the containment channel
29
+ // already selects the project that owns it.
29
30
 
30
31
  import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
31
32
  import { realpath } from 'node:fs/promises'
@@ -452,6 +453,9 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
452
453
  )
453
454
  if (roots.size === 0) return selected
454
455
  const dirToName = await realDirIndex(a.projects, a.realDirs)
456
+ // A root project owns every file no member owns: the shared tooling the
457
+ // walk descends through when the root is no project (D-41).
458
+ const rootOwner = dirToName.get(workspaceRoot)
455
459
 
456
460
  // target → the files that import it. Reversed up front so one BFS from the
457
461
  // changed set answers every root at once, instead of a walk per root.
@@ -484,8 +488,10 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
484
488
  const list = importedBy.get(target)
485
489
  if (list) list.push(file)
486
490
  else importedBy.set(target, [file])
487
- // Descend ONLY through unowned files — see the header.
488
- if (ownerOf(target, dirToName) === undefined) frontier.push(target)
491
+ // Descend ONLY through unowned files, or the root project's own:
492
+ // see the header.
493
+ const owner = ownerOf(target, dirToName)
494
+ if (owner === undefined || owner === rootOwner) frontier.push(target)
489
495
  }
490
496
  }
491
497
  }