@vzn/vx 0.0.639 → 0.0.641

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 (88) hide show
  1. package/README.md +1 -1
  2. package/docs/caching.md +158 -106
  3. package/docs/cli.md +80 -45
  4. package/docs/execution.md +11 -10
  5. package/docs/features.md +9 -8
  6. package/docs/patterns.md +1 -1
  7. package/docs/schema.md +252 -190
  8. package/docs/security.md +5 -4
  9. package/launcher.sh +9 -2
  10. package/package.json +5 -5
  11. package/schemas/show.json +14 -14
  12. package/skills/vx/SKILL.md +4 -3
  13. package/src/bin.ts +8 -6
  14. package/src/cache/archive.ts +248 -41
  15. package/src/cache/cache.ts +46 -19
  16. package/src/cache/file-hashes.ts +7 -12
  17. package/src/cache/fs-clock.ts +62 -0
  18. package/src/cache/git-inputs.ts +2 -2
  19. package/src/cache/index.ts +6 -0
  20. package/src/cache/inputs.ts +228 -111
  21. package/src/cache/key-fold.ts +23 -3
  22. package/src/cache/layer.ts +15 -8
  23. package/src/cache/layered-cache.ts +8 -7
  24. package/src/cache/linked-inputs.ts +87 -0
  25. package/src/cache/scoped-cache.ts +8 -2
  26. package/src/cli/foreign-flags.ts +1 -1
  27. package/src/cli/help.ts +7 -1
  28. package/src/cli/init.ts +23 -11
  29. package/src/cli/last.ts +24 -1
  30. package/src/cli/plugin-templates.ts +2 -2
  31. package/src/cli/select.ts +16 -6
  32. package/src/cli/show.ts +9 -6
  33. package/src/cli/upgrade.ts +132 -21
  34. package/src/cli/watch-filter.ts +23 -12
  35. package/src/cli/watch-fs.ts +2 -1
  36. package/src/cli/watch-judge.ts +49 -5
  37. package/src/cli/watch-set.ts +18 -8
  38. package/src/cli/watch.ts +74 -2
  39. package/src/cli/why.ts +5 -3
  40. package/src/config.ts +19 -15
  41. package/src/exec/executor.ts +4 -4
  42. package/src/graph/dependency-spec.ts +2 -2
  43. package/src/graph/folds.ts +236 -0
  44. package/src/graph/index.ts +8 -1
  45. package/src/graph/scheduler.ts +41 -15
  46. package/src/graph/task-graph.ts +135 -73
  47. package/src/handoff.ts +5 -0
  48. package/src/index.ts +2 -2
  49. package/src/orchestrator/affected-tasks.ts +20 -19
  50. package/src/orchestrator/download-policy.ts +16 -22
  51. package/src/orchestrator/excluded-keys.ts +4 -5
  52. package/src/orchestrator/execute-task.ts +29 -25
  53. package/src/orchestrator/hit-restore.ts +101 -27
  54. package/src/orchestrator/index.ts +1 -0
  55. package/src/orchestrator/keyed-projects.ts +12 -51
  56. package/src/orchestrator/local-shortcircuit.ts +9 -1
  57. package/src/orchestrator/persistent-task.ts +3 -3
  58. package/src/orchestrator/placement.ts +2 -2
  59. package/src/orchestrator/plan.ts +1 -1
  60. package/src/orchestrator/plugin-host.ts +66 -27
  61. package/src/orchestrator/plugin.ts +12 -2
  62. package/src/orchestrator/prepare.ts +12 -5
  63. package/src/orchestrator/projects.ts +7 -10
  64. package/src/orchestrator/run-report.ts +145 -46
  65. package/src/orchestrator/run-steps.ts +57 -0
  66. package/src/orchestrator/run.ts +76 -44
  67. package/src/orchestrator/sandbox-fix.ts +1 -1
  68. package/src/orchestrator/sandbox-request.ts +2 -2
  69. package/src/orchestrator/stable-keys.ts +59 -26
  70. package/src/orchestrator/steps.ts +26 -6
  71. package/src/orchestrator/task-hash.ts +31 -9
  72. package/src/orchestrator/telemetry-host.ts +3 -3
  73. package/src/orchestrator/telemetry.ts +9 -4
  74. package/src/orchestrator/upstream.ts +4 -181
  75. package/src/report/framed-output.ts +1 -1
  76. package/src/report/logger.ts +5 -2
  77. package/src/report/status-line.ts +7 -3
  78. package/src/report/summary.ts +6 -4
  79. package/src/settings.ts +11 -17
  80. package/src/util/index.ts +3 -0
  81. package/src/util/paths.ts +39 -5
  82. package/src/workspace/affected.ts +81 -2
  83. package/src/workspace/config-eval.ts +2 -2
  84. package/src/workspace/config-imports.ts +1 -1
  85. package/src/workspace/config-schema.ts +142 -143
  86. package/src/workspace/lockfile.ts +1 -1
  87. package/src/workspace/migration.ts +19 -3
  88. package/src/workspace/project-loader.ts +5 -4
@@ -23,6 +23,7 @@ import {
23
23
  parseDependencySpec,
24
24
  type DependencySpec,
25
25
  } from './dependency-spec.js'
26
+ import { foldedDeps, isGroupTask } from './folds.js'
26
27
  import type { TaskOutcome } from './scheduler.js'
27
28
 
28
29
  // Re-exported so existing importers keep working while the type's home
@@ -100,23 +101,9 @@ export function taskId(project: string, task: string): string {
100
101
  // graph is where every reader of a task id looks for it.
101
102
  export { splitTaskId } from '../util/index.js'
102
103
 
103
- /**
104
- * A task is a "group" if it has no `exec` — it exists only to chain
105
- * `dependsOn` (an umbrella for `vx run ci`). Group tasks:
106
- * - never spawn a process,
107
- * - never read/write the cache (it's a config error to declare one),
108
- * - never appear in the run summary or `runs` analytics table,
109
- * - render no framed block in the live output.
110
- * Six call sites used to repeat `node.config.exec === undefined`;
111
- * centralising the predicate prevents that check from drifting.
112
- */
113
- export function isGroupTask(node: TaskNode): boolean {
114
- return node.config.exec === undefined
115
- }
116
-
117
- /** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `cache`. */
104
+ /** A keyed group: the default `build` (orchestrator/projects.ts), or a config's group with `inputs`. */
118
105
  function isKeyedGroup(task: TaskConfig): boolean {
119
- return task.exec === undefined && task.cache !== undefined
106
+ return task.exec === undefined && task.inputs !== undefined
120
107
  }
121
108
 
122
109
  /**
@@ -511,7 +498,7 @@ export function buildTaskGraph(options: BuildGraphOptions): Map<string, TaskNode
511
498
 
512
499
  // dependsOn is about which tasks to ADD to the graph, not which
513
500
  // to filter. BARE wildcards ("*"/"^*" = "everything upstream") and
514
- // negation aren't meaningful here — they're cache.inputs.tasks
501
+ // negation aren't meaningful here — they're inputs.tasks
515
502
  // operations. PARTIAL patterns (`build.*`, `^build.*`) are legal:
516
503
  // they name a namespace of tasks to add (Nx 19.5 parity).
517
504
  if (spec.kind === 'wildcardSelf' || spec.kind === 'wildcardDeps') {
@@ -789,7 +776,7 @@ export function checkGraph(
789
776
  // Under a lock an exclusive graph has no overlap to mark; a non-exclusive
790
777
  // one still needs the addition marks the walk derives.
791
778
  if (!locked || !exclusive) detectOutputCollisions(nodes, workspaceRoot, exclusive)
792
- if (rules?.upfrontKeys !== false && !locked) detectInputOverlaps(nodes, workspaceRoot)
779
+ if (!locked) detectInputOverlaps(nodes, workspaceRoot)
793
780
  }
794
781
 
795
782
  /**
@@ -1497,10 +1484,11 @@ export function outputTakenBack(output: string, negatives: readonly string[]): b
1497
1484
  */
1498
1485
  function refuseSelfClean(node: TaskNode): void {
1499
1486
  const cache = node.config.cache
1500
- if (cache === undefined) return
1487
+ const declared = node.config.inputs
1488
+ if (cache === undefined || declared === undefined) return
1501
1489
  for (const field of ['files', 'workspaceFiles'] as const) {
1502
- if (!coversAny(cache.inputs[field], cache.outputs[field])) continue
1503
- const inputs = splitNegations(cache.inputs[field] ?? [])
1490
+ if (!coversAny(declared[field], cache.outputs[field])) continue
1491
+ const inputs = splitNegations(declared[field] ?? [])
1504
1492
  const out = splitNegations(cache.outputs[field] ?? [])
1505
1493
  const outputs = out.positive
1506
1494
  if (outputs.length === 0) continue
@@ -1519,7 +1507,7 @@ function refuseSelfClean(node: TaskNode): void {
1519
1507
  if (outputTakenBack(gi, out.negative)) continue
1520
1508
  if (!outputTakenBack(gi, near)) continue
1521
1509
  throw new UserError(
1522
- `${node.id}: every file ${JSON.stringify(gi)} in cache.inputs.${field} selects is also ` +
1510
+ `${node.id}: every file ${JSON.stringify(gi)} in inputs.${field} selects is also ` +
1523
1511
  `its own output — vx removes a task's outputs before it runs, so the task would ` +
1524
1512
  `delete its own sources. A task that rewrites files in place (a formatter) declares ` +
1525
1513
  `no outputs.`,
@@ -1574,13 +1562,14 @@ interface Side {
1574
1562
  }
1575
1563
 
1576
1564
  /**
1577
- * `run.rules.upfrontKeys` (X-54): refuse a task whose input globs can match
1578
- * another task's declared outputs. Such a key reads bytes a producer writes
1579
- * this run, so it is not known until the producer ran (`stable-keys.ts`
1580
- * keeps it waiting); refused, every key reads only what no task writes and
1581
- * can be derived before anything runs (M, 2026-10-07: "hash ahead of time,
1582
- * not waterfall"). A dependency's own key already cascades through
1583
- * `dependsOn`, so the bytes it wrote add nothing a key needs.
1565
+ * Refuse a task whose input globs can match another task's declared
1566
+ * outputs (X-54; no opt-out, owner 2026-10-16). Such a key reads bytes a
1567
+ * producer writes this run, so it is not known until the producer ran;
1568
+ * refused, every key reads only what no task writes and can be derived
1569
+ * before anything runs (M, 2026-10-07: "hash ahead of time, not
1570
+ * waterfall"). A dependency's own key already cascades through
1571
+ * `dependsOn`, so the bytes it wrote add nothing a key needs; the one
1572
+ * exception is `refuseOverlaps`'s unfolded dependency.
1584
1573
  *
1585
1574
  * Proven overlaps only (`outputsOverlap`), as for outputs: a pair the rule
1586
1575
  * cannot decide passes, and the stability gate still keeps it waiting. The
@@ -1612,9 +1601,10 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1612
1601
  }
1613
1602
  for (const n of nodes.values()) {
1614
1603
  const cache = n.config.cache
1615
- if (cache === undefined) continue
1616
- const outFiles = positives(cache.outputs.files)
1617
- const outWs = positives(cache.outputs.workspaceFiles ?? [])
1604
+ const inputs = n.config.inputs
1605
+ if (inputs === undefined) continue
1606
+ const outFiles = positives(cache?.outputs.files ?? [])
1607
+ const outWs = positives(cache?.outputs.workspaceFiles ?? [])
1618
1608
  if (outFiles.length > 0) {
1619
1609
  const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
1620
1610
  bucket(n.projectName).push({ node: n, globs: outFiles, reads: false, anchor })
@@ -1626,10 +1616,11 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1626
1616
  wsWriters.push(side)
1627
1617
  }
1628
1618
  if (isGroupTask(n)) continue
1629
- const inFiles = positives(cache.inputs.files)
1630
- const inWs = positives(cache.inputs.workspaceFiles ?? [])
1619
+ const inFiles = positives(inputs.files)
1620
+ const inWs = positives(inputs.workspaceFiles ?? [])
1631
1621
  if (inFiles.length > 0) {
1632
- bucket(n.projectName).push({ node: n, globs: inFiles, reads: true })
1622
+ const anchor = workspaceRoot === undefined ? undefined : relPosix(workspaceRoot, n.projectDir)
1623
+ bucket(n.projectName).push({ node: n, globs: inFiles, reads: true, anchor })
1633
1624
  filesReaders.push(n)
1634
1625
  projectReaders = true
1635
1626
  }
@@ -1654,7 +1645,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1654
1645
  const sides = [...wsWriters]
1655
1646
  for (const node of filesReaders) {
1656
1647
  const rel = relPosix(workspaceRoot, node.projectDir)
1657
- const globs = positives(node.config.cache!.inputs.files).map((g) =>
1648
+ const globs = positives(node.config.inputs!.files).map((g) =>
1658
1649
  rel === '' ? g : `${rel}/${g}`,
1659
1650
  )
1660
1651
  sides.push({ node, globs, reads: true, rel })
@@ -1668,7 +1659,7 @@ function detectInputOverlaps(nodes: Map<string, TaskNode>, workspaceRoot?: strin
1668
1659
  }
1669
1660
  }
1670
1661
  if (rootReaders) readsRootedOutputs(memo, found, rooted, filesWriters, workspaceRoot)
1671
- refuseUnlessIgnored(found, workspaceRoot)
1662
+ refuseOverlaps(found, nodes, workspaceRoot)
1672
1663
  }
1673
1664
 
1674
1665
  function readsRootedOutputs(
@@ -1696,33 +1687,98 @@ function readsRootedOutputs(
1696
1687
  }
1697
1688
  }
1698
1689
 
1699
- /** Each overlapped output, root-relative, and the refusal it meets; the first pair found per output. */
1700
- type Found = Map<string, () => UserError>
1690
+ /** One reader of another task's output: the reader's glob's fixed prefix, root-relative. */
1691
+ interface Overlap {
1692
+ reader: TaskNode
1693
+ writer: TaskNode
1694
+ prefix: string
1695
+ refuse: () => UserError
1696
+ }
1697
+
1698
+ /** Each overlapped output, root-relative, and every reader of it. */
1699
+ type Found = Map<string, Overlap[]>
1701
1700
 
1702
1701
  /**
1703
- * An output git ignores is no overlap (M, 2026-10-15): a key reads only what
1704
- * git lists, so such an output never reaches it. Asked of git once, for
1705
- * every overlapped output together; an output git cannot speak for (no work
1706
- * tree, a glob no few paths stand for) is refused as before. The answer
1707
- * holds for the lock; a run's stability gate asks git again for itself.
1702
+ * Which overlaps stand. A reader may read the outputs of a task in its
1703
+ * `dependsOn` closure whose key its own does not fold (it sets
1704
+ * `inputs.tasks` and leaves the producer out, also through the tasks it
1705
+ * folds): its key waits for the producer and hashes the bytes (owner,
1706
+ * 2026-10-16). Otherwise an output git ignores whole is no overlap (M,
1707
+ * 2026-10-15), a key reading only what git lists, unless the reader's
1708
+ * glob starts in an ignored path (`dist/**`): naming it is asking for it.
1709
+ * Git is asked once, for every output and prefix together; one it cannot
1710
+ * speak for is refused. The answer holds for the lock; a run's stability
1711
+ * gate asks git again for itself.
1708
1712
  */
1709
- function refuseUnlessIgnored(found: Found, workspaceRoot: string | undefined): void {
1713
+ function refuseOverlaps(
1714
+ found: Found,
1715
+ nodes: ReadonlyMap<string, TaskNode>,
1716
+ workspaceRoot: string | undefined,
1717
+ ): void {
1710
1718
  if (found.size === 0) return
1711
- if (workspaceRoot !== undefined) {
1712
- const asks = [...found.keys()].map(ignoreProbes)
1713
- const paths = asks.flatMap((a) => a?.paths ?? [])
1714
- const ignored = checkIgnoreSync(workspaceRoot, paths)
1715
- let listed: string[] | null | undefined
1716
- const pass = wholeIgnored(found.keys(), ignored, (g) => {
1717
- listed ??= listedSync(workspaceRoot, [
1718
- ...new Set(asks.flatMap((a) => (a?.deep === true ? [a.dir] : []))),
1719
- ])
1720
- return listed === null || listed.some((f) => taskGlob(g).match(f))
1721
- })
1722
- for (const [g, refuse] of found) if (!pass.has(g)) throw refuse()
1723
- return
1719
+ const reads = dependencyReads(nodes)
1720
+ const left = new Map<string, Overlap[]>()
1721
+ for (const [g, list] of found) {
1722
+ const refused = list.filter((o) => !reads(o.reader, o.writer))
1723
+ if (refused.length > 0) left.set(g, refused)
1724
+ }
1725
+ if (left.size === 0) return
1726
+ if (workspaceRoot === undefined) throw left.values().next().value![0]!.refuse()
1727
+ const asks = [...left.keys()].map(ignoreProbes)
1728
+ const prefixes = new Set<string>()
1729
+ for (const list of left.values()) {
1730
+ for (const o of list) if (o.prefix !== '') prefixes.add(o.prefix).add(`${o.prefix}/`)
1731
+ }
1732
+ const ignored = checkIgnoreSync(workspaceRoot, [
1733
+ ...asks.flatMap((a) => a?.paths ?? []),
1734
+ ...prefixes,
1735
+ ])
1736
+ let listed: string[] | null | undefined
1737
+ const pass = wholeIgnored(left.keys(), ignored, (g) => {
1738
+ listed ??= listedSync(workspaceRoot, [
1739
+ ...new Set(asks.flatMap((a) => (a?.deep === true ? [a.dir] : []))),
1740
+ ])
1741
+ return listed === null || listed.some((f) => taskGlob(g).match(f))
1742
+ })
1743
+ const named = (o: Overlap): boolean =>
1744
+ o.prefix !== '' && ignored !== null && (ignored.has(o.prefix) || ignored.has(`${o.prefix}/`))
1745
+ for (const [g, list] of left) {
1746
+ for (const o of list) if (!pass.has(g) || named(o)) throw o.refuse()
1724
1747
  }
1725
- throw found.values().next().value!()
1748
+ }
1749
+
1750
+ /**
1751
+ * Whether a reader may read a writer's outputs: the writer is in the
1752
+ * reader's `dependsOn` closure and not in its folded closure. Each closure
1753
+ * walked once per reader.
1754
+ */
1755
+ function dependencyReads(
1756
+ nodes: ReadonlyMap<string, TaskNode>,
1757
+ ): (reader: TaskNode, writer: TaskNode) => boolean {
1758
+ const nodeOf = (id: string): TaskNode => nodes.get(id)!
1759
+ const deps = new Map<string, Set<string>>()
1760
+ const folded = new Map<string, Set<string>>()
1761
+ const closure = (
1762
+ memo: Map<string, Set<string>>,
1763
+ root: TaskNode,
1764
+ next: (n: TaskNode) => Iterable<TaskNode>,
1765
+ ): Set<string> => {
1766
+ let out = memo.get(root.id)
1767
+ if (out !== undefined) return out
1768
+ out = new Set()
1769
+ const stack = [...next(root)]
1770
+ while (stack.length > 0) {
1771
+ const n = stack.pop()!
1772
+ if (out.has(n.id)) continue
1773
+ out.add(n.id)
1774
+ stack.push(...next(n))
1775
+ }
1776
+ memo.set(root.id, out)
1777
+ return out
1778
+ }
1779
+ return (reader, writer) =>
1780
+ closure(deps, reader, (n) => n.deps.flatMap((d) => nodes.get(d) ?? [])).has(writer.id) &&
1781
+ !closure(folded, reader, (n) => foldedDeps(n, nodeOf).map((c) => c.node)).has(writer.id)
1726
1782
  }
1727
1783
 
1728
1784
  /**
@@ -1745,16 +1801,15 @@ function takenBack(
1745
1801
  ): boolean {
1746
1802
  let tb = memo.bySide.get(reader)
1747
1803
  if (tb === undefined) {
1748
- const cache = reader.node.config.cache!
1804
+ const inputs = reader.node.config.inputs!
1805
+ const cache = reader.node.config.cache
1749
1806
  // A reader rebased to the root (X-135) speaks in its project's terms.
1750
1807
  const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
1751
1808
  // What the reader's key never reads: its `!` entries and its own outputs.
1752
1809
  const list = [
1810
+ ...splitNegations(field === 'files' ? inputs.files : (inputs.workspaceFiles ?? [])).negative,
1753
1811
  ...splitNegations(
1754
- field === 'files' ? cache.inputs.files : (cache.inputs.workspaceFiles ?? []),
1755
- ).negative,
1756
- ...splitNegations(
1757
- field === 'files' ? cache.outputs.files : (cache.outputs.workspaceFiles ?? []),
1812
+ field === 'files' ? (cache?.outputs.files ?? []) : (cache?.outputs.workspaceFiles ?? []),
1758
1813
  ).positive,
1759
1814
  ].map((g) => at + g)
1760
1815
  const key = list.join('\0')
@@ -1777,28 +1832,35 @@ function readsOutputs(
1777
1832
  if (x.reads === y.reads || x.node === y.node) return
1778
1833
  const [reader, writer] = x.reads ? [x, y] : [y, x]
1779
1834
  const at = reader.rel === undefined || reader.rel === '' ? '' : `${reader.rel}/`
1835
+ const readerAt = reader.anchor === undefined || reader.anchor === '' ? '' : `${reader.anchor}/`
1780
1836
  for (const go of writer.globs) {
1781
1837
  const rooted =
1782
1838
  writer.anchor === undefined || writer.anchor === '' ? go : `${writer.anchor}/${go}`
1783
- if (found.has(rooted) || takenBack(memo, reader, field, go)) continue
1839
+ let list = found.get(rooted)
1840
+ if (list?.some((o) => o.reader === reader.node) === true) continue
1841
+ if (takenBack(memo, reader, field, go)) continue
1784
1842
  for (const gi of reader.globs) {
1785
1843
  if (!outputsOverlap(gi, go)) continue
1786
1844
  const shown =
1787
1845
  writer.rel === undefined || writer.rel === '' ? go : go.slice(writer.rel.length + 1)
1788
1846
  const out = at !== '' && go.startsWith(at) ? go.slice(at.length) : go
1789
- found.set(
1790
- rooted,
1791
- () =>
1847
+ const prefix = staticPrefix(readerAt + gi)
1848
+ if (list === undefined) found.set(rooted, (list = []))
1849
+ list.push({
1850
+ reader: reader.node,
1851
+ writer: writer.node,
1852
+ prefix: prefix === '.' ? '' : prefix,
1853
+ refuse: () =>
1792
1854
  new UserError(
1793
- `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in cache.inputs.${field}, which matches ` +
1855
+ `${reader.node.id} reads ${JSON.stringify(gi.slice(at.length))} in inputs.${field}, which matches ` +
1794
1856
  `${writer.node.id}'s output ${JSON.stringify(shown)} — a task's key must not read ` +
1795
- `another task's outputs (the dependency's key already cascades through dependsOn). ` +
1857
+ `the outputs of a task it does not depend on, or whose key it already folds. ` +
1796
1858
  `Exclude it: add ${JSON.stringify(`!${out}`)} to ${reader.node.id}'s ` +
1797
- `cache.inputs.${field}, or set run.rules.upfrontKeys: false in .vx/workspace.ts to ` +
1798
- `let it wait for its producer.`,
1859
+ `inputs.${field}, or depend on ${writer.node.id} and leave it out of ` +
1860
+ `${reader.node.id}'s inputs.tasks, so its key hashes those bytes instead.`,
1799
1861
  'VX_E_INPUT_IS_OUTPUT',
1800
1862
  ),
1801
- )
1863
+ })
1802
1864
  break
1803
1865
  }
1804
1866
  }
package/src/handoff.ts CHANGED
@@ -5,6 +5,11 @@
5
5
  // walk runs before anything else loads, through `Bun.file(…).exists()`:
6
6
  // importing `node:fs` alone was ~1.1 ms of a 4 ms start (2026-10-13).
7
7
 
8
+ /** `vx upgrade -g`: moves only the global vx, so it never hands off (owner, 2026-10-16). */
9
+ export function upgradesOnlyItself(argv: readonly string[]): boolean {
10
+ return argv[0] === 'upgrade' && argv.some((a) => a === '-g' || a === '--global')
11
+ }
12
+
8
13
  /**
9
14
  * The project-local vx to run instead of this one, or null: the workspace
10
15
  * root's ({@link workspaceFile}) `node_modules/@vzn/vx`, run through its
package/src/index.ts CHANGED
@@ -22,7 +22,7 @@ export { machineMemoryBytes, machineParallelism } from './util/index.js'
22
22
  // How core reads a task glob — its spelling, and which characters are
23
23
  // wildcards (`[` `]` are not; item 667) — for a plugin that turns declared
24
24
  // outputs into something else (`@vzn/vx-reapi`'s literal `output_paths`).
25
- export { isLiteralPattern, normalizeGlob } from './util/index.js'
25
+ export { isLiteralPattern, normalizeGlob, restorableOutput } from './util/index.js'
26
26
 
27
27
  // Schema types and helpers (used by user vx.config files and presets).
28
28
  export type {
@@ -37,7 +37,7 @@ export type {
37
37
  ExecConfig,
38
38
  ExecEnv,
39
39
  CacheConfig,
40
- CacheInputs,
40
+ InputsConfig,
41
41
  CacheOutputs,
42
42
  InputStability,
43
43
  SandboxConfig,
@@ -33,7 +33,7 @@ const KEYED_BY_EVERY_TASK: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAME
33
33
 
34
34
  /**
35
35
  * The projects every task of which the change reaches: the whole ones the
36
- * diff named, and those holding a changed path no cached task of theirs
36
+ * diff named, and those holding a changed path no task of theirs
37
37
  * declares as an input. vx cannot prove such a path re-keys nothing (a
38
38
  * `project` plugin may read it), so it reaches the whole project, as it did
39
39
  * before selection was per task.
@@ -42,15 +42,15 @@ function wholeProjects(
42
42
  changes: AffectedChanges,
43
43
  projects: ReadonlyMap<string, ProjectEntry>,
44
44
  ): Map<string, string | undefined> {
45
- // Each whole project, with the path no cached task claims when one did it.
45
+ // Each whole project, with the path no task claims when one did it.
46
46
  const whole = new Map<string, string | undefined>([...changes.whole].map((n) => [n, undefined]))
47
47
  for (const [name, rels] of changes.paths) {
48
48
  if (whole.has(name)) continue
49
- const caches = Object.values(projects.get(name)?.config.tasks ?? {}).flatMap((t) =>
50
- t.exec !== undefined && t.cache !== undefined ? [t.cache] : [],
49
+ const keyed = Object.values(projects.get(name)?.config.tasks ?? {}).filter(
50
+ (t) => t.exec !== undefined && t.inputs !== undefined,
51
51
  )
52
52
  const unclaimed = (rel: string): boolean =>
53
- KEYED_BY_EVERY_TASK.has(rel) || !caches.some((c) => declaresInput(c, rel, null))
53
+ KEYED_BY_EVERY_TASK.has(rel) || !keyed.some((t) => declaresInput(t, rel, null))
54
54
  const rel = rels.find(unclaimed)
55
55
  if (rel !== undefined) whole.set(name, rel)
56
56
  }
@@ -60,8 +60,8 @@ function wholeProjects(
60
60
  /**
61
61
  * Why `--affected` kept a requested task (`vx run --affected --dry`):
62
62
  * `input`, a changed `file` is one of its declared inputs; `project`, its
63
- * project changed and the task reads it whole (an uncached task, a path no
64
- * cached task declares, or no single `file`: a lockfile claim, a manifest
63
+ * project changed and the task reads it whole (a task with no inputs, a
64
+ * path no task declares, or no single `file`: a lockfile claim, a manifest
65
65
  * edge, a config import); `workspace`, a workspace-wide `file` (the
66
66
  * workspace config, a root file a plugin claims) reached every task of its
67
67
  * project; `package`, a `^` edge passes through `project`, a
@@ -86,9 +86,9 @@ export interface AffectedExplain {
86
86
  /**
87
87
  * The task ids of `ids` whose closure the change reaches, in their order.
88
88
  * A group seeds nothing (it runs nothing) unless it is keyed, as the default
89
- * `build` is; an uncached task seeds when a changed path lies in its project
90
- * (or the whole project is reached); a cached one when a changed path is one
91
- * of its declared inputs. A `^name` edge the
89
+ * `build` is; a task with no inputs seeds when a changed path lies in its
90
+ * project (or the whole project is reached); one with inputs when a changed
91
+ * path is one of them. A `^name` edge the
92
92
  * graph passes through a package it loaded no config for reaches it as the
93
93
  * default `build` would: any change there reaches the task.
94
94
  */
@@ -139,14 +139,15 @@ export function affectedRoots(
139
139
  return false
140
140
  }
141
141
  const seeded = (n: TaskNode): boolean => {
142
- const cache = n.config.cache
142
+ const task = n.config
143
+ const keyed = task.inputs !== undefined
143
144
  // Asked of every node, not only the changed projects' (the
144
145
  // `workspaceFiles` owners): a `graph` hook may have given the glob.
145
- if (cache !== undefined) {
146
- const file = changes.changed.find((rel) => declaresInput(cache, null, rel))
146
+ if (keyed) {
147
+ const file = changes.changed.find((rel) => declaresInput(task, null, rel))
147
148
  const dir =
148
149
  file === undefined
149
- ? (changes.nested ?? []).find((d) => workspaceFilesReachInto(cache, d))
150
+ ? (changes.nested ?? []).find((d) => workspaceFilesReachInto(task, d))
150
151
  : undefined
151
152
  if (file !== undefined || dir !== undefined) {
152
153
  seeds?.set(n.id, { kind: 'input', file: (file ?? dir)! })
@@ -155,10 +156,10 @@ export function affectedRoots(
155
156
  }
156
157
  if (!changes.projects.has(n.projectName)) return false
157
158
  const rels = changes.paths.get(n.projectName) ?? []
158
- // A group runs nothing; only the default `build` (projects.ts) is keyed.
159
- // An uncached task reads its project: a root file another task of it
160
- // declares (`workspaceFiles`) is no change there.
161
- if (cache === undefined) {
159
+ // An unkeyed group runs nothing. A task with no inputs may read anything
160
+ // in its project: a root file another task of it declares
161
+ // (`workspaceFiles`) is no change there.
162
+ if (!keyed) {
162
163
  const hit = !isGroupTask(n) && (whole.has(n.projectName) || changes.paths.has(n.projectName))
163
164
  if (hit) seeds?.set(n.id, inProject(n.projectName, rels[0]))
164
165
  return hit
@@ -167,7 +168,7 @@ export function affectedRoots(
167
168
  seeds?.set(n.id, inProject(n.projectName, whole.get(n.projectName)))
168
169
  return true
169
170
  }
170
- const rel = rels.find((r) => declaresInput(cache, r, null))
171
+ const rel = rels.find((r) => declaresInput(task, r, null))
171
172
  if (rel === undefined) return false
172
173
  if (seeds !== null) seeds.set(n.id, { ...inProject(n.projectName, rel), kind: 'input' })
173
174
  return true
@@ -19,7 +19,7 @@ export type DownloadMode = 'eager' | 'deferred' | 'never'
19
19
  * The channel this closes is narrow and specific. A dependent's key folds
20
20
  * an upstream's KEY, never its output content (pure-input transitive
21
21
  * hashing), so deferral cannot move a dependent's key that way. The one
22
- * real channel is a task whose `cache.inputs` globs can MATCH a producer's
22
+ * real channel is a task whose `inputs` globs can MATCH a producer's
23
23
  * declared outputs on disk: then its key differs by whether the bytes
24
24
  * arrived.
25
25
  *
@@ -30,7 +30,7 @@ export type DownloadMode = 'eager' | 'deferred' | 'never'
30
30
  * `--cache-download=none` with nothing to defer. Comparing the globs' static
31
31
  * prefixes answers the question actually being asked, and stays
32
32
  * conservative in every direction that matters: a leading wildcard yields
33
- * `.` and reaches everything, a cacheable task with no declared `files`
33
+ * `.` and reaches everything, a task with empty `inputs.files`
34
34
  * is treated as reading its whole project, and `workspaceFiles` on either
35
35
  * side ignores project boundaries — all three force ineligible.
36
36
  */
@@ -44,10 +44,10 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
44
44
  if (list) list.push({ taskId: n.id, prefixes, via })
45
45
  else readersByProject.set(n.projectName, [{ taskId: n.id, prefixes, via }])
46
46
  }
47
- // A task with no `cache` block keys on every file in its project, and a
48
- // cached task that depends on it folds that key: deferring a producer
49
- // there moved both keys with the transfer flag. Only such a task is a
50
- // reader — one nothing cached folds keys nothing that lasts.
47
+ // A task with no `cache` block keys on its inputs, and a cached task that
48
+ // depends on it folds that key: deferring a producer there moved both
49
+ // keys with the transfer flag. Only such a task is a reader — one nothing
50
+ // cached folds keys nothing that lasts.
51
51
  const folded = new Set<string>()
52
52
  const stack = [...nodes.values()]
53
53
  .filter((n) => n.config.cache !== undefined)
@@ -60,14 +60,11 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
60
60
  let workspaceReader: string | undefined
61
61
  let runtimeReader: string | undefined
62
62
  for (const n of nodes.values()) {
63
- const cache = n.config.cache
64
- if (cache === undefined) {
65
- if (folded.has(n.id) && !isGroupTask(n) && n.config.exec?.persistent === undefined) {
66
- addReader(n, ['.'], 'as a task with no cache block that a cached task folds')
67
- }
68
- continue
69
- }
70
- if ((cache.inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
63
+ const inputs = n.config.inputs
64
+ // A task with no inputs has no key to read; an uncached one with inputs
65
+ // is read only for the cached tasks that fold its key.
66
+ if (inputs === undefined || (n.config.cache === undefined && !folded.has(n.id))) continue
67
+ if ((inputs?.workspaceFiles?.length ?? 0) > 0) workspaceReader ??= n.id
71
68
  // A `runtime` input is a SHELL COMMAND whose reads are unknowable —
72
69
  // the same reason vx refuses to infer inputs by tracing. It can `cat` a
73
70
  // producer's output (or path-escape its project to do it), and its
@@ -77,17 +74,14 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
77
74
  // is exactly what such a command would sample. Nothing defers in a run
78
75
  // that declares one — worse than nothing would be a key that moves with
79
76
  // a transfer flag.
80
- if (
81
- (cache.inputs?.runtime?.length ?? 0) > 0 ||
82
- (cache.inputs?.workspaceRuntime?.length ?? 0) > 0
83
- ) {
77
+ if ((inputs?.runtime?.length ?? 0) > 0 || (inputs?.workspaceRuntime?.length ?? 0) > 0) {
84
78
  runtimeReader ??= n.id
85
79
  }
86
- const files = cache.inputs?.files
80
+ const files = inputs?.files
87
81
  addReader(
88
82
  n,
89
83
  files === undefined || files.length === 0 ? ['.'] : files.map(staticPrefix),
90
- 'with cache.inputs.files',
84
+ 'with inputs.files',
91
85
  )
92
86
  }
93
87
 
@@ -105,14 +99,14 @@ export function deferralEligibility(nodes: Map<string, TaskNode>): Map<string, s
105
99
  if (runtimeReader !== undefined) {
106
100
  ineligible.set(
107
101
  n.id,
108
- `${runtimeReader} declares a cache.inputs.runtime command, whose reads cannot be bounded`,
102
+ `${runtimeReader} declares a inputs.runtime command, whose reads cannot be bounded`,
109
103
  )
110
104
  continue
111
105
  }
112
106
  if (workspaceReader !== undefined) {
113
107
  ineligible.set(
114
108
  n.id,
115
- `${workspaceReader} declares cache.inputs.workspaceFiles, which can read any project`,
109
+ `${workspaceReader} declares inputs.workspaceFiles, which can read any project`,
116
110
  )
117
111
  continue
118
112
  }
@@ -14,9 +14,9 @@
14
14
  // full run looks up.
15
15
 
16
16
  import type { CacheLayer, GitFilesCache } from '../cache/index.js'
17
- import { isGroupTask, type TaskNode, type TaskOutcome } from '../graph/index.js'
17
+ import { isGroupTask, keyedDeps, type TaskNode, type TaskOutcome } from '../graph/index.js'
18
18
  import { computeGroupKey, computeTaskHash, type HashCache } from './task-hash.js'
19
- import { filterUpstreamHashes, keyedDeps, keyedOutcome } from './upstream.js'
19
+ import { filterUpstreamHashes, keyedOutcome } from './upstream.js'
20
20
 
21
21
  export interface KeyExcludedArgs {
22
22
  /** The scheduled graph, after `excludeDependencies`. */
@@ -124,7 +124,7 @@ export function excludedTaint(nodes: ReadonlyMap<string, TaskNode>): {
124
124
 
125
125
  /**
126
126
  * Whether `node`'s key folds a dropped dependency's key: a group folds all
127
- * of its dependencies, an exec task what its `cache.inputs.tasks` selects.
127
+ * of its dependencies, an exec task what its `inputs.tasks` selects.
128
128
  * A task with `tasks: []` folds none, so its entry claims nothing about a
129
129
  * dependency and it saves as usual.
130
130
  */
@@ -133,7 +133,6 @@ function foldsExcludedKey(node: TaskNode): boolean {
133
133
  if (excluded === undefined) return false
134
134
  if (isGroupTask(node)) return true
135
135
  return (
136
- filterUpstreamHashes(excluded, node.config.cache?.inputs?.tasks, node.projectName, node.id)
137
- .length > 0
136
+ filterUpstreamHashes(excluded, node.config.inputs?.tasks, node.projectName, node.id).length > 0
138
137
  )
139
138
  }