@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.
- package/README.md +1 -1
- package/docs/caching.md +158 -106
- package/docs/cli.md +80 -45
- package/docs/execution.md +11 -10
- package/docs/features.md +9 -8
- package/docs/patterns.md +1 -1
- package/docs/schema.md +252 -190
- package/docs/security.md +5 -4
- package/launcher.sh +9 -2
- package/package.json +5 -5
- package/schemas/show.json +14 -14
- package/skills/vx/SKILL.md +4 -3
- package/src/bin.ts +8 -6
- package/src/cache/archive.ts +248 -41
- package/src/cache/cache.ts +46 -19
- package/src/cache/file-hashes.ts +7 -12
- package/src/cache/fs-clock.ts +62 -0
- package/src/cache/git-inputs.ts +2 -2
- package/src/cache/index.ts +6 -0
- package/src/cache/inputs.ts +228 -111
- package/src/cache/key-fold.ts +23 -3
- package/src/cache/layer.ts +15 -8
- package/src/cache/layered-cache.ts +8 -7
- package/src/cache/linked-inputs.ts +87 -0
- package/src/cache/scoped-cache.ts +8 -2
- package/src/cli/foreign-flags.ts +1 -1
- package/src/cli/help.ts +7 -1
- package/src/cli/init.ts +23 -11
- package/src/cli/last.ts +24 -1
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/select.ts +16 -6
- package/src/cli/show.ts +9 -6
- package/src/cli/upgrade.ts +132 -21
- package/src/cli/watch-filter.ts +23 -12
- package/src/cli/watch-fs.ts +2 -1
- package/src/cli/watch-judge.ts +49 -5
- package/src/cli/watch-set.ts +18 -8
- package/src/cli/watch.ts +74 -2
- package/src/cli/why.ts +5 -3
- package/src/config.ts +19 -15
- package/src/exec/executor.ts +4 -4
- package/src/graph/dependency-spec.ts +2 -2
- package/src/graph/folds.ts +236 -0
- package/src/graph/index.ts +8 -1
- package/src/graph/scheduler.ts +41 -15
- package/src/graph/task-graph.ts +135 -73
- package/src/handoff.ts +5 -0
- package/src/index.ts +2 -2
- package/src/orchestrator/affected-tasks.ts +20 -19
- package/src/orchestrator/download-policy.ts +16 -22
- package/src/orchestrator/excluded-keys.ts +4 -5
- package/src/orchestrator/execute-task.ts +29 -25
- package/src/orchestrator/hit-restore.ts +101 -27
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/keyed-projects.ts +12 -51
- package/src/orchestrator/local-shortcircuit.ts +9 -1
- package/src/orchestrator/persistent-task.ts +3 -3
- package/src/orchestrator/placement.ts +2 -2
- package/src/orchestrator/plan.ts +1 -1
- package/src/orchestrator/plugin-host.ts +66 -27
- package/src/orchestrator/plugin.ts +12 -2
- package/src/orchestrator/prepare.ts +12 -5
- package/src/orchestrator/projects.ts +7 -10
- package/src/orchestrator/run-report.ts +145 -46
- package/src/orchestrator/run-steps.ts +57 -0
- package/src/orchestrator/run.ts +76 -44
- package/src/orchestrator/sandbox-fix.ts +1 -1
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/stable-keys.ts +59 -26
- package/src/orchestrator/steps.ts +26 -6
- package/src/orchestrator/task-hash.ts +31 -9
- package/src/orchestrator/telemetry-host.ts +3 -3
- package/src/orchestrator/telemetry.ts +9 -4
- package/src/orchestrator/upstream.ts +4 -181
- package/src/report/framed-output.ts +1 -1
- package/src/report/logger.ts +5 -2
- package/src/report/status-line.ts +7 -3
- package/src/report/summary.ts +6 -4
- package/src/settings.ts +11 -17
- package/src/util/index.ts +3 -0
- package/src/util/paths.ts +39 -5
- package/src/workspace/affected.ts +81 -2
- package/src/workspace/config-eval.ts +2 -2
- package/src/workspace/config-imports.ts +1 -1
- package/src/workspace/config-schema.ts +142 -143
- package/src/workspace/lockfile.ts +1 -1
- package/src/workspace/migration.ts +19 -3
- package/src/workspace/project-loader.ts +5 -4
package/src/graph/task-graph.ts
CHANGED
|
@@ -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.
|
|
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
|
|
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 (
|
|
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
|
-
|
|
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(
|
|
1503
|
-
const inputs = splitNegations(
|
|
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
|
|
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
|
-
*
|
|
1578
|
-
*
|
|
1579
|
-
* this run, so it is not known until the producer ran
|
|
1580
|
-
*
|
|
1581
|
-
*
|
|
1582
|
-
*
|
|
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
|
-
|
|
1616
|
-
|
|
1617
|
-
const
|
|
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(
|
|
1630
|
-
const inWs = positives(
|
|
1619
|
+
const inFiles = positives(inputs.files)
|
|
1620
|
+
const inWs = positives(inputs.workspaceFiles ?? [])
|
|
1631
1621
|
if (inFiles.length > 0) {
|
|
1632
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
/**
|
|
1700
|
-
|
|
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
|
-
*
|
|
1704
|
-
*
|
|
1705
|
-
*
|
|
1706
|
-
*
|
|
1707
|
-
*
|
|
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
|
|
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
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
const
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
for (const
|
|
1723
|
-
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
1790
|
-
|
|
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
|
|
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
|
-
`
|
|
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
|
-
`
|
|
1798
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
50
|
-
t.exec !== undefined && t.
|
|
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) || !
|
|
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 (
|
|
64
|
-
*
|
|
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;
|
|
90
|
-
* (or the whole project is reached);
|
|
91
|
-
*
|
|
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
|
|
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 (
|
|
146
|
-
const file = changes.changed.find((rel) => declaresInput(
|
|
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(
|
|
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
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
if (
|
|
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(
|
|
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 `
|
|
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
|
|
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
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
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
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 =
|
|
80
|
+
const files = inputs?.files
|
|
87
81
|
addReader(
|
|
88
82
|
n,
|
|
89
83
|
files === undefined || files.length === 0 ? ['.'] : files.map(staticPrefix),
|
|
90
|
-
'with
|
|
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
|
|
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
|
|
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,
|
|
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 `
|
|
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.
|
|
137
|
-
.length > 0
|
|
136
|
+
filterUpstreamHashes(excluded, node.config.inputs?.tasks, node.projectName, node.id).length > 0
|
|
138
137
|
)
|
|
139
138
|
}
|