@vzn/vx 0.0.16 → 0.0.21
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/LICENSE +1 -1
- package/README.md +135 -124
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +11 -7
- package/src/bin.ts +14 -4
- package/src/cache/archive.ts +659 -0
- package/src/cache/cache.ts +769 -1101
- package/src/cache/chained-cache.ts +194 -0
- package/src/cache/config-evals.ts +89 -0
- package/src/cache/file-hashes.ts +262 -0
- package/src/cache/git-inputs.ts +637 -0
- package/src/cache/index.ts +24 -5
- package/src/cache/inputs.ts +373 -456
- package/src/cache/layer.ts +604 -0
- package/src/cache/layered-cache.ts +96 -43
- package/src/cache/output-index.ts +224 -0
- package/src/cache/policy.ts +84 -0
- package/src/cache/run-history.ts +147 -0
- package/src/cache/tar-stream.ts +358 -0
- package/src/cache/zstd.ts +160 -0
- package/src/cli/cache.ts +75 -21
- package/src/cli/completions.ts +104 -0
- package/src/cli/core-alias.ts +25 -0
- package/src/cli/help.ts +195 -128
- package/src/cli/index.ts +116 -46
- package/src/cli/info.ts +122 -63
- package/src/cli/init.ts +75 -0
- package/src/cli/last.ts +172 -0
- package/src/cli/lock.ts +2 -1
- package/src/cli/plan-format.ts +72 -6
- package/src/cli/plugin-commands.ts +103 -0
- package/src/cli/run.ts +245 -271
- package/src/cli/select.ts +332 -0
- package/src/cli/show.ts +144 -69
- package/src/cli/upgrade.ts +9 -7
- package/src/cli/watch.ts +858 -110
- package/src/cli/why.ts +223 -0
- package/src/cli/workspace-config.ts +126 -0
- package/src/config.ts +187 -150
- package/src/exec/env.ts +18 -1
- package/src/exec/executor.ts +224 -0
- package/src/exec/index.ts +24 -2
- package/src/exec/local-executor.ts +41 -0
- package/src/exec/runner.ts +166 -40
- package/src/exec/sandbox-binds.ts +182 -0
- package/src/exec/sandbox-paths.ts +50 -0
- package/src/exec/sandbox-runtime.ts +642 -260
- package/src/exec/sandbox-violations.ts +280 -0
- package/src/graph/index.ts +4 -10
- package/src/graph/scheduler.ts +232 -203
- package/src/graph/task-graph.ts +222 -8
- package/src/index.ts +146 -153
- package/src/orchestrator/admission.ts +137 -0
- package/src/orchestrator/deferred-outputs.ts +147 -0
- package/src/orchestrator/doctor.ts +255 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +72 -8
- package/src/orchestrator/execute-task.ts +374 -549
- package/src/orchestrator/failure-mode.ts +202 -0
- package/src/orchestrator/framed-output.ts +126 -69
- package/src/orchestrator/history.ts +126 -59
- package/src/orchestrator/hit-restore.ts +221 -0
- package/src/orchestrator/index.ts +77 -93
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/lockfile-claim.ts +281 -0
- package/src/orchestrator/logger.ts +208 -35
- package/src/orchestrator/metrics.ts +102 -1388
- package/src/orchestrator/miss-save.ts +169 -0
- package/src/orchestrator/options.ts +55 -34
- package/src/orchestrator/persistent.ts +79 -0
- package/src/orchestrator/placement.ts +182 -0
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +357 -120
- package/src/orchestrator/plugin.ts +298 -58
- package/src/orchestrator/prepare.ts +202 -106
- package/src/orchestrator/projects.ts +259 -0
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/run-artifacts.ts +63 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-records.ts +149 -0
- package/src/orchestrator/run-report.ts +74 -47
- package/src/orchestrator/run.ts +534 -417
- package/src/orchestrator/sandbox-request.ts +243 -0
- package/src/orchestrator/save-lane.ts +47 -0
- package/src/orchestrator/signals.ts +111 -0
- package/src/orchestrator/stable-keys.ts +129 -31
- package/src/orchestrator/status-line.ts +49 -9
- package/src/orchestrator/summary.ts +90 -26
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +111 -15
- package/src/orchestrator/task-log-buffer.ts +288 -0
- package/src/orchestrator/telemetry-host.ts +34 -6
- package/src/orchestrator/telemetry.ts +182 -36
- package/src/orchestrator/upstream.ts +72 -31
- package/src/util/cgroup.ts +160 -0
- package/src/util/edit-distance.ts +70 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +15 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +64 -0
- package/src/util/settle.ts +67 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/util/verbs.ts +39 -0
- package/src/workspace/affected.ts +250 -23
- package/src/workspace/config-cache.ts +313 -0
- package/src/workspace/config-eval.ts +217 -0
- package/src/workspace/config-imports.ts +162 -0
- package/src/workspace/config-schema.ts +837 -0
- package/src/workspace/filter.ts +65 -10
- package/src/workspace/fingerprint.ts +53 -4
- package/src/workspace/index.ts +42 -3
- package/src/workspace/lockfile.ts +25 -9
- package/src/workspace/migrate-scripts.ts +134 -0
- package/src/workspace/migration.ts +314 -0
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +114 -41
- package/src/workspace/project-loader.ts +224 -450
- package/src/workspace/workspace.ts +298 -72
- package/src/cache/cas-backend.ts +0 -97
- package/src/cache/digest.ts +0 -47
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/cli/migrate-nx.ts +0 -450
- package/src/cli/migrate-turbo.ts +0 -364
- package/src/cli/migrate.ts +0 -219
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/resources.ts +0 -59
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/verify.ts +0 -164
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
package/src/util/tail.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Bounded head-evicting tail for one stream of one still-running task.
|
|
2
|
+
//
|
|
3
|
+
// A PERSISTENT task (dev server, watcher, daemon) is unbounded by
|
|
4
|
+
// construction: unlike a one-shot command, nothing about it terminating
|
|
5
|
+
// bounds how much it writes. Anything that accumulates its output therefore
|
|
6
|
+
// needs a cap, and the cap has to be honest — a truncated log that reads as
|
|
7
|
+
// complete is worse than one that says what it lost.
|
|
8
|
+
//
|
|
9
|
+
// `orchestrator/logger.ts` is the sole holder: it registers a tail per
|
|
10
|
+
// persistent task at taskStart and keeps one for the rest of the run, so a
|
|
11
|
+
// single mechanism covers both the pre-ready window and everything after.
|
|
12
|
+
// `exec/runner.ts` used to keep a SECOND pre-ready copy fed from the same
|
|
13
|
+
// chunks that nothing ever read; it was deleted rather than kept in sync,
|
|
14
|
+
// since two copies of this rule is how they drift — one grew a bound and
|
|
15
|
+
// the other did not, which is the defect this file was extracted to close
|
|
16
|
+
// (a never-ready `readyWhen` grew vx's heap ~100 MiB/s with the 64 KiB cap
|
|
17
|
+
// sitting unused, because it was only ever engaged once the task became
|
|
18
|
+
// ready). It stays in `util` because `exec` cannot import `orchestrator`,
|
|
19
|
+
// so a future second holder on that side has somewhere to import from.
|
|
20
|
+
|
|
21
|
+
/** Per-stream cap on retained output for a task that may never end. */
|
|
22
|
+
export const PERSISTENT_TAIL_CHARS = 64 * 1024
|
|
23
|
+
|
|
24
|
+
export interface Tail {
|
|
25
|
+
chunks: string[]
|
|
26
|
+
chars: number
|
|
27
|
+
/** Characters evicted from the head. Non-zero means the tail is partial. */
|
|
28
|
+
dropped: number
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function createTail(): Tail {
|
|
32
|
+
return { chunks: [], chars: 0, dropped: 0 }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function appendTail(t: Tail, chunk: string, limit = PERSISTENT_TAIL_CHARS): void {
|
|
36
|
+
if (chunk.length === 0) return
|
|
37
|
+
t.chunks.push(chunk)
|
|
38
|
+
t.chars += chunk.length
|
|
39
|
+
// Evict WHOLE chunks from the head — no concatenation until the flush
|
|
40
|
+
// joins once, so a chatty server costs one array push per chunk.
|
|
41
|
+
while (t.chars > limit && t.chunks.length > 1) {
|
|
42
|
+
const gone = t.chunks.shift()!
|
|
43
|
+
t.chars -= gone.length
|
|
44
|
+
t.dropped += gone.length
|
|
45
|
+
}
|
|
46
|
+
// A single chunk over the cap is the one place we copy.
|
|
47
|
+
if (t.chars > limit) {
|
|
48
|
+
const only = t.chunks[0]!
|
|
49
|
+
const keep = only.slice(only.length - limit)
|
|
50
|
+
t.dropped += only.length - keep.length
|
|
51
|
+
t.chunks[0] = keep
|
|
52
|
+
t.chars = keep.length
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Join the retained text. The tail keeps its chunks — see `resetTail`. */
|
|
57
|
+
export function tailText(t: Tail): string {
|
|
58
|
+
return t.chunks.length === 1 ? t.chunks[0]! : t.chunks.join('')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Drop everything retained, keeping the tail usable for a new phase. */
|
|
62
|
+
export function resetTail(t: Tail): void {
|
|
63
|
+
t.chunks.length = 0
|
|
64
|
+
t.chars = 0
|
|
65
|
+
t.dropped = 0
|
|
66
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
// Stage timing for the run path, printed to stderr when `VX_TIMING` is set.
|
|
2
|
+
//
|
|
3
|
+
// The CPU profiler attributes a tight loop's cost unreliably and hides
|
|
4
|
+
// where an `await` waited; a stage table answers "where did the warm run
|
|
5
|
+
// go?" directly, and costs one boolean check per mark when off. Marks are
|
|
6
|
+
// cumulative from process start; the table shows each stage's own share.
|
|
7
|
+
|
|
8
|
+
const enabled = process.env.VX_TIMING !== undefined && process.env.VX_TIMING !== ''
|
|
9
|
+
const t0 = Bun.nanoseconds()
|
|
10
|
+
const marks: Array<[label: string, ns: number]> = []
|
|
11
|
+
|
|
12
|
+
/** Record the end of a stage. No-op unless `VX_TIMING` is set. */
|
|
13
|
+
export function mark(label: string): void {
|
|
14
|
+
if (!enabled) return
|
|
15
|
+
marks.push([label, Bun.nanoseconds() - t0])
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const spans = new Map<string, [count: number, ns: number]>()
|
|
19
|
+
const noop = (): void => {}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Time one occurrence of a repeated operation (a per-task probe, a restore):
|
|
23
|
+
* `const end = span('probe'); …; end()`. Accumulated by label and printed
|
|
24
|
+
* under the stage table. Returns a shared no-op when off, so the hot path
|
|
25
|
+
* allocates nothing.
|
|
26
|
+
*/
|
|
27
|
+
export function span(label: string): () => void {
|
|
28
|
+
if (!enabled) return noop
|
|
29
|
+
const start = Bun.nanoseconds()
|
|
30
|
+
return () => {
|
|
31
|
+
const cur = spans.get(label)
|
|
32
|
+
const ns = Bun.nanoseconds() - start
|
|
33
|
+
if (cur === undefined) spans.set(label, [1, ns])
|
|
34
|
+
else {
|
|
35
|
+
cur[0]++
|
|
36
|
+
cur[1] += ns
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Print the stage table (once, at the end of a run). No-op unless enabled. */
|
|
42
|
+
export function printTimings(): void {
|
|
43
|
+
if (!enabled || marks.length === 0) return
|
|
44
|
+
const width = Math.max(...marks.map(([l]) => l.length))
|
|
45
|
+
let prev = 0
|
|
46
|
+
const lines = ['[vx timing] stage'.padEnd(width + 14) + ' own cumulative']
|
|
47
|
+
for (const [label, ns] of marks) {
|
|
48
|
+
const own = (ns - prev) / 1e6
|
|
49
|
+
lines.push(
|
|
50
|
+
` ${label.padEnd(width)} ${own.toFixed(1).padStart(6)}ms ${(ns / 1e6).toFixed(1).padStart(8)}ms`,
|
|
51
|
+
)
|
|
52
|
+
prev = ns
|
|
53
|
+
}
|
|
54
|
+
if (spans.size > 0) {
|
|
55
|
+
lines.push('[vx timing] accumulated total count')
|
|
56
|
+
for (const [label, [count, ns]] of [...spans].sort((a, b) => b[1][1] - a[1][1])) {
|
|
57
|
+
lines.push(
|
|
58
|
+
` ${label.padEnd(24)} ${(ns / 1e6).toFixed(1).padStart(8)}ms ${String(count).padStart(6)}`,
|
|
59
|
+
)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
process.stderr.write(lines.join('\n') + '\n')
|
|
63
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The verbs the CLI dispatcher (`cli/index.ts`) owns; `stats` is a
|
|
3
|
+
* deprecated alias of `info` and stays out of hints but is still matched
|
|
4
|
+
* first. Lives in util rather than cli because the workspace VALIDATOR
|
|
5
|
+
* refuses a plugin verb that names one of these — a verb core matches
|
|
6
|
+
* first could never run — and the workspace module cannot import cli.
|
|
7
|
+
*/
|
|
8
|
+
export const CORE_VERBS = [
|
|
9
|
+
'run',
|
|
10
|
+
'watch',
|
|
11
|
+
'cache',
|
|
12
|
+
'lock',
|
|
13
|
+
'init',
|
|
14
|
+
'upgrade',
|
|
15
|
+
'show',
|
|
16
|
+
'info',
|
|
17
|
+
'why',
|
|
18
|
+
'last',
|
|
19
|
+
'completions',
|
|
20
|
+
'help',
|
|
21
|
+
'version',
|
|
22
|
+
] as const
|
|
23
|
+
|
|
24
|
+
/** Every word the dispatcher matches before consulting plugins. */
|
|
25
|
+
export const DISPATCHED_VERBS: readonly string[] = [...CORE_VERBS, 'stats']
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Verbs core owned once and a package owns now. Not core verbs: a plugin
|
|
29
|
+
* may declare them, and the dispatcher prints the
|
|
30
|
+
* pointer only when no declared plugin claimed the name.
|
|
31
|
+
*/
|
|
32
|
+
export const MOVED_VERBS: Readonly<Record<string, string>> = {
|
|
33
|
+
migrate:
|
|
34
|
+
'vx migrate moved to @vzn/vx-migrate: run `bunx @vzn/vx-migrate` (turbo.json or an Nx ' +
|
|
35
|
+
'project graph → vx.config.ts); `vx init` reads package.json scripts',
|
|
36
|
+
prune:
|
|
37
|
+
'vx prune was removed (2026-09-11): the Docker-subset verb is not shipped; copy the ' +
|
|
38
|
+
'workspace and `--filter` the build instead',
|
|
39
|
+
}
|
|
@@ -1,17 +1,16 @@
|
|
|
1
1
|
// `--affected` — git-relative project selection.
|
|
2
2
|
//
|
|
3
3
|
// Resolves the set of project names whose files have changed since a
|
|
4
|
-
// given git ref
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// We use `git diff --name-only <since>` which by default compares the
|
|
9
|
-
// `<since>` commit to the working tree — i.e. commits-from-`<since>`
|
|
10
|
-
// + index + unstaged. Matches Turbo's `[<since>]` filter semantics.
|
|
4
|
+
// given git ref: `git diff --name-only <merge-base(since, HEAD)>`, which
|
|
5
|
+
// compares that commit to the working tree — commits on this branch +
|
|
6
|
+
// index + unstaged — so it captures everything you touched and nothing
|
|
7
|
+
// the base branch moved on with. Matches Turbo's `[<since>]` semantics.
|
|
11
8
|
|
|
12
9
|
import path from 'node:path'
|
|
13
10
|
import { UserError } from '../util/index.js'
|
|
14
11
|
import { LOCKFILE_NAME } from './lockfile.js'
|
|
12
|
+
import { configImportOwners } from './config-imports.js'
|
|
13
|
+
import { WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
|
|
15
14
|
import type { ProjectMeta } from './workspace.js'
|
|
16
15
|
|
|
17
16
|
export interface AffectedArgs {
|
|
@@ -19,6 +18,39 @@ export interface AffectedArgs {
|
|
|
19
18
|
/** Git ref / commit / branch to compare against. Required. */
|
|
20
19
|
since: string
|
|
21
20
|
projects: readonly ProjectMeta[]
|
|
21
|
+
/**
|
|
22
|
+
* Which projects declare a `cache.inputs.workspaceFiles` glob matching one of
|
|
23
|
+
* these workspace-relative paths.
|
|
24
|
+
*
|
|
25
|
+
* A workspace-anchored glob is the documented escape hatch for shared files
|
|
26
|
+
* that belong to NO project, so mapping changed paths to project directories
|
|
27
|
+
* structurally cannot see it — the path resolves to no project and the run
|
|
28
|
+
* selects nothing for a change that re-keyed the task.
|
|
29
|
+
*
|
|
30
|
+
* Answering needs the RESOLVED configs, which selection runs before loading;
|
|
31
|
+
* that ordering is what left this open. It is resolved by asking only when
|
|
32
|
+
* the question can matter: this is called ONLY with paths that belong to no
|
|
33
|
+
* project, so a run whose every change is inside a project — the common case
|
|
34
|
+
* — never invokes it and pays nothing.
|
|
35
|
+
*/
|
|
36
|
+
workspaceGlobOwners?: (orphanPaths: readonly string[]) => Promise<Iterable<string>>
|
|
37
|
+
/**
|
|
38
|
+
* The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
|
|
39
|
+
* answer for a change to one. Resolved lazily: loading the workspace
|
|
40
|
+
* file costs an evaluation, and a diff that touches no fingerprint file
|
|
41
|
+
* — the common one — never needs it.
|
|
42
|
+
*/
|
|
43
|
+
fingerprintClaims?: () => Promise<FingerprintClaims>
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface FingerprintClaims {
|
|
47
|
+
readonly files: ReadonlySet<string>
|
|
48
|
+
/** Project names a change to a claimed file affects; `undefined` = all. */
|
|
49
|
+
affected(change: {
|
|
50
|
+
file: string
|
|
51
|
+
before: Uint8Array | null
|
|
52
|
+
after: Uint8Array | null
|
|
53
|
+
}): Promise<ReadonlySet<string> | undefined>
|
|
22
54
|
}
|
|
23
55
|
|
|
24
56
|
/**
|
|
@@ -27,17 +59,188 @@ export interface AffectedArgs {
|
|
|
27
59
|
* or `<since>` doesn't resolve to a commit.
|
|
28
60
|
*/
|
|
29
61
|
export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
|
|
62
|
+
// The base reaches git as an argument, never through a shell, so `$(…)`
|
|
63
|
+
// is opaque — but an option-like value is not: `--output=<path>` is a
|
|
64
|
+
// real `git diff` option and an arbitrary file write. This is a security
|
|
65
|
+
// boundary, so it is a check that knows it is one, before any spawn, and
|
|
66
|
+
// every git call below also ends its options (`--end-of-options`) so a
|
|
67
|
+
// second caller cannot lose the guard by accident.
|
|
68
|
+
if (args.since.length === 0 || args.since.startsWith('-')) {
|
|
69
|
+
throw new UserError(
|
|
70
|
+
`git ref "${args.since}" is not a ref: a base cannot be empty or start with "-".`,
|
|
71
|
+
)
|
|
72
|
+
}
|
|
30
73
|
await verifyRef(args.workspaceRoot, args.since)
|
|
74
|
+
// Diff from the MERGE BASE of `since` and HEAD, not from `since` itself:
|
|
75
|
+
// on a branch whose base has moved on, `git diff <base>` reports every
|
|
76
|
+
// file OTHER people changed on the base (over-selection that defeats a
|
|
77
|
+
// CI `--affected`), and hides your own edit when the base later landed
|
|
78
|
+
// byte-identical content. Turbo and Nx both diff from the merge base;
|
|
79
|
+
// when there is none (unrelated histories, a detached probe) the ref
|
|
80
|
+
// itself is the base, as before.
|
|
81
|
+
const base = await mergeBase(args.workspaceRoot, args.since)
|
|
31
82
|
|
|
32
|
-
const
|
|
83
|
+
const [diffed, untracked] = await Promise.all([
|
|
33
84
|
// `--no-renames` is crucial for project-affected detection: with
|
|
34
85
|
// git's auto rename-detection on (the default in modern git), a
|
|
35
86
|
// cross-project `git mv` collapses to a single rename entry that
|
|
36
87
|
// surfaces only the destination path. We'd then miss flagging the
|
|
37
88
|
// source project as affected. Treating renames as delete+add gives
|
|
38
89
|
// us both halves so both projects get correctly marked.
|
|
39
|
-
|
|
40
|
-
cwd
|
|
90
|
+
//
|
|
91
|
+
// `--relative` prints paths relative to the cwd (the workspace root), NOT
|
|
92
|
+
// the git repo root. Without it, when the workspace root is a SUBDIR of the
|
|
93
|
+
// git repo, `git diff` emits repo-root-relative paths (`code/pkg/x`) that
|
|
94
|
+
// `path.resolve(workspaceRoot, …)` mangles into `<root>/code/…`, matching no
|
|
95
|
+
// project → the project is silently NOT flagged affected. `--relative` also
|
|
96
|
+
// (correctly) drops changes ABOVE the workspace, which belong to no project.
|
|
97
|
+
// No-op when the workspace root IS the git root.
|
|
98
|
+
//
|
|
99
|
+
// `-z` because git otherwise C-quotes and octal-escapes any path with a
|
|
100
|
+
// non-ASCII / `"` / `\` character — the quoted string then resolves to no
|
|
101
|
+
// project, while the cache-input enumeration (which uses `-z`) sees the
|
|
102
|
+
// real name and re-keys the task. The two surfaces must agree.
|
|
103
|
+
gitPaths(args.workspaceRoot, [
|
|
104
|
+
'diff',
|
|
105
|
+
'--no-renames',
|
|
106
|
+
'--relative',
|
|
107
|
+
'--name-only',
|
|
108
|
+
'-z',
|
|
109
|
+
'--end-of-options',
|
|
110
|
+
base,
|
|
111
|
+
]),
|
|
112
|
+
// `git diff` never reports untracked-but-not-ignored files, but input
|
|
113
|
+
// enumeration does (`git ls-files --cached --others --exclude-standard`),
|
|
114
|
+
// so a brand-new source file changes a task's cache key. Union it in or
|
|
115
|
+
// `--affected` skips a package that genuinely has new work.
|
|
116
|
+
gitPaths(args.workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z']),
|
|
117
|
+
])
|
|
118
|
+
|
|
119
|
+
// vx-lock.json (workspace-root metadata) is excluded like a gitignored
|
|
120
|
+
// file: re-running `vx lock` must not mark every project affected.
|
|
121
|
+
const changed = [...diffed, ...untracked].filter((s) => s !== LOCKFILE_NAME)
|
|
122
|
+
|
|
123
|
+
// A lockfile or workspace-definition change re-keys EVERY task, because the
|
|
124
|
+
// workspace fingerprint folds those files into every cache key. Mapping
|
|
125
|
+
// changed paths to project directories cannot see that — they sit at the
|
|
126
|
+
// root and belong to no project — so `--affected` selected ZERO projects for
|
|
127
|
+
// a change that invalidates the entire cache. `vx run test --affected` after
|
|
128
|
+
// a `pnpm update` exited 0 having run nothing, which is precisely the
|
|
129
|
+
// failure `docs/cli.md` warns against in the sentence it states as a
|
|
130
|
+
// principle: "input hashing sees it, so `--affected` must too."
|
|
131
|
+
//
|
|
132
|
+
// Selection is not hashed, so widening it here changes no cache key.
|
|
133
|
+
//
|
|
134
|
+
// A file a plugin CLAIMS is the exception: the key folds what the plugin
|
|
135
|
+
// says per project, so selection asks the plugin the same question, with
|
|
136
|
+
// the bytes at the base ref and in the working tree. Its answer is
|
|
137
|
+
// unioned with the path-owned projects below; only "cannot tell" widens.
|
|
138
|
+
const fingerprintChanged = changed.filter((p) => FINGERPRINT_SET.has(p))
|
|
139
|
+
const claimedOwned = new Set<string>()
|
|
140
|
+
if (fingerprintChanged.length > 0) {
|
|
141
|
+
const claims = args.fingerprintClaims === undefined ? undefined : await args.fingerprintClaims()
|
|
142
|
+
for (const file of fingerprintChanged) {
|
|
143
|
+
if (claims === undefined || !claims.files.has(file)) {
|
|
144
|
+
return new Set(args.projects.map((p) => p.name))
|
|
145
|
+
}
|
|
146
|
+
const answer = await claims.affected({
|
|
147
|
+
file,
|
|
148
|
+
before: await gitBytesAt(args.workspaceRoot, base, file),
|
|
149
|
+
after: await bytesOrNull(path.join(args.workspaceRoot, file)),
|
|
150
|
+
})
|
|
151
|
+
if (answer === undefined) return new Set(args.projects.map((p) => p.name))
|
|
152
|
+
for (const name of answer) claimedOwned.add(name)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const { owned, orphans } = projectsContaining(args.workspaceRoot, changed, args.projects)
|
|
157
|
+
for (const name of claimedOwned) owned.add(name)
|
|
158
|
+
|
|
159
|
+
// THIRD CHANNEL: a project whose `vx.config.*` IMPORTS a changed file.
|
|
160
|
+
// Resolved-config hashing folds those values into the key, so the same
|
|
161
|
+
// sentence above applies — input hashing sees it, so selection must. This
|
|
162
|
+
// runs on the FULL changed set, not just `orphans`: the common shape is a
|
|
163
|
+
// config reaching into ANOTHER project (`../../src/index.ts`), whose target
|
|
164
|
+
// is owned and therefore never an orphan at all.
|
|
165
|
+
for (const name of await configImportOwners({
|
|
166
|
+
workspaceRoot: args.workspaceRoot,
|
|
167
|
+
projects: args.projects,
|
|
168
|
+
changed,
|
|
169
|
+
skip: owned,
|
|
170
|
+
})) {
|
|
171
|
+
owned.add(name)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (orphans.length === 0 || args.workspaceGlobOwners === undefined) return owned
|
|
175
|
+
for (const name of await args.workspaceGlobOwners(orphans)) owned.add(name)
|
|
176
|
+
return owned
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Does any `cache.inputs.workspaceFiles` entry in `globs` match `rel`?
|
|
181
|
+
*
|
|
182
|
+
* Mirrors `resolveWorkspaceFiles`' partition: a leading `!` is an EXCLUDE, and
|
|
183
|
+
* with no positive glob nothing matches. Paths are workspace-root-relative and
|
|
184
|
+
* POSIX-separated, which is the form both git enumeration and the glob
|
|
185
|
+
* resolver already speak.
|
|
186
|
+
*/
|
|
187
|
+
export function workspaceGlobsMatch(globs: readonly string[], rel: string): boolean {
|
|
188
|
+
const positive: Bun.Glob[] = []
|
|
189
|
+
const negative: Bun.Glob[] = []
|
|
190
|
+
for (const entry of globs) {
|
|
191
|
+
if (entry.startsWith('!')) negative.push(new Bun.Glob(entry.slice(1)))
|
|
192
|
+
else positive.push(new Bun.Glob(entry))
|
|
193
|
+
}
|
|
194
|
+
if (positive.length === 0) return false
|
|
195
|
+
if (!positive.some((g) => g.match(rel))) return false
|
|
196
|
+
return !negative.some((g) => g.match(rel))
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The fingerprint files as workspace-root-relative paths. Read from the same
|
|
201
|
+
* constant the fingerprint itself walks, so a new lockfile format cannot be
|
|
202
|
+
* taught to one surface and not the other.
|
|
203
|
+
*/
|
|
204
|
+
const FINGERPRINT_SET: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* A root file's bytes at `ref`, or null when the ref has no such file.
|
|
208
|
+
* `./` anchors the path to the cwd (the workspace root) rather than the
|
|
209
|
+
* repository root, for a workspace that is a subdirectory of its repo.
|
|
210
|
+
*/
|
|
211
|
+
async function gitBytesAt(
|
|
212
|
+
workspaceRoot: string,
|
|
213
|
+
ref: string,
|
|
214
|
+
file: string,
|
|
215
|
+
): Promise<Uint8Array | null> {
|
|
216
|
+
const proc = Bun.spawn({
|
|
217
|
+
cmd: ['git', 'show', '--end-of-options', `${ref}:./${file}`],
|
|
218
|
+
cwd: workspaceRoot,
|
|
219
|
+
stdout: 'pipe',
|
|
220
|
+
stderr: 'pipe',
|
|
221
|
+
})
|
|
222
|
+
const [bytes, stderr, exit] = await Promise.all([
|
|
223
|
+
new Response(proc.stdout).bytes(),
|
|
224
|
+
new Response(proc.stderr).text(),
|
|
225
|
+
proc.exited,
|
|
226
|
+
])
|
|
227
|
+
if (exit === 0) return bytes
|
|
228
|
+
// "exists on disk but not in <ref>" and "path does not exist in <ref>"
|
|
229
|
+
// are the file being absent at the ref; anything else is git failing.
|
|
230
|
+
if (/does not exist in|exists on disk, but not in/.test(stderr)) return null
|
|
231
|
+
throw new UserError(`git show ${ref}:./${file} failed (exit ${exit}): ${stderr.trim()}`)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function bytesOrNull(file: string): Promise<Uint8Array | null> {
|
|
235
|
+
const f = Bun.file(file)
|
|
236
|
+
return (await f.exists()) ? await f.bytes() : null
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Run a NUL-separated path-listing git command from the workspace root. */
|
|
240
|
+
async function gitPaths(workspaceRoot: string, cmd: string[]): Promise<string[]> {
|
|
241
|
+
const proc = Bun.spawn({
|
|
242
|
+
cmd: ['git', ...cmd],
|
|
243
|
+
cwd: workspaceRoot,
|
|
41
244
|
stdout: 'pipe',
|
|
42
245
|
stderr: 'pipe',
|
|
43
246
|
})
|
|
@@ -45,13 +248,9 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
|
|
|
45
248
|
const stderr = await new Response(proc.stderr).text()
|
|
46
249
|
const exit = await proc.exited
|
|
47
250
|
if (exit !== 0) {
|
|
48
|
-
throw new UserError(`git
|
|
251
|
+
throw new UserError(`git ${cmd[0]} failed (exit ${exit}): ${stderr.trim()}`)
|
|
49
252
|
}
|
|
50
|
-
|
|
51
|
-
// vx-lock.json (workspace-root metadata) is excluded like a gitignored
|
|
52
|
-
// file: re-running `vx lock` must not mark every project affected.
|
|
53
|
-
const changed = stdout.split('\n').filter((s) => s.length > 0 && s !== LOCKFILE_NAME)
|
|
54
|
-
return projectsContaining(args.workspaceRoot, changed, args.projects)
|
|
253
|
+
return stdout.split('\0').filter((s) => s.length > 0)
|
|
55
254
|
}
|
|
56
255
|
|
|
57
256
|
/**
|
|
@@ -72,25 +271,47 @@ export async function defaultAffectedBase(workspaceRoot: string): Promise<string
|
|
|
72
271
|
return 'HEAD~1'
|
|
73
272
|
}
|
|
74
273
|
|
|
274
|
+
/** `git merge-base <ref> HEAD`, or `ref` itself when the two share no ancestor. */
|
|
275
|
+
async function mergeBase(workspaceRoot: string, ref: string): Promise<string> {
|
|
276
|
+
const proc = Bun.spawn({
|
|
277
|
+
cmd: ['git', 'merge-base', '--end-of-options', ref, 'HEAD'],
|
|
278
|
+
cwd: workspaceRoot,
|
|
279
|
+
stdout: 'pipe',
|
|
280
|
+
stderr: 'pipe',
|
|
281
|
+
})
|
|
282
|
+
const [out, exit] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
283
|
+
const sha = out.trim()
|
|
284
|
+
return exit === 0 && sha.length > 0 ? sha : ref
|
|
285
|
+
}
|
|
286
|
+
|
|
75
287
|
async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
76
288
|
const proc = Bun.spawnSync({
|
|
77
|
-
cmd: ['git', 'rev-parse', '--verify', '--quiet', ref],
|
|
289
|
+
cmd: ['git', 'rev-parse', '--verify', '--quiet', '--end-of-options', ref],
|
|
78
290
|
cwd: workspaceRoot,
|
|
79
291
|
stdout: 'pipe',
|
|
80
292
|
stderr: 'pipe',
|
|
81
293
|
})
|
|
82
|
-
if (proc.exitCode
|
|
294
|
+
if (proc.exitCode === 0) return
|
|
295
|
+
// `--verify --quiet` exits 1 for "that ref does not exist" and 128 for
|
|
296
|
+
// "git could not run here at all" (not a repository, corrupt objects,
|
|
297
|
+
// permissions). Reporting the second as a ref problem sends you hunting
|
|
298
|
+
// for a branch name when the real fault is the repository — so only exit
|
|
299
|
+
// 1 gets the ref message; anything else surfaces what git actually said.
|
|
300
|
+
const stderr = new TextDecoder().decode(proc.stderr).trim()
|
|
301
|
+
if (proc.exitCode !== 1) {
|
|
83
302
|
throw new UserError(
|
|
84
|
-
`git
|
|
303
|
+
`git rev-parse failed (exit ${proc.exitCode}) in ${workspaceRoot}` +
|
|
304
|
+
(stderr.length > 0 ? `: ${stderr}` : ''),
|
|
85
305
|
)
|
|
86
306
|
}
|
|
307
|
+
throw new UserError(`git ref "${ref}" did not resolve. Pass a branch or commit you have locally.`)
|
|
87
308
|
}
|
|
88
309
|
|
|
89
310
|
function projectsContaining(
|
|
90
311
|
workspaceRoot: string,
|
|
91
312
|
changedRelPaths: readonly string[],
|
|
92
313
|
projects: readonly ProjectMeta[],
|
|
93
|
-
): Set<string
|
|
314
|
+
): { owned: Set<string>; orphans: string[] } {
|
|
94
315
|
// Index projects by their (canonical) dir, then for each changed path walk
|
|
95
316
|
// its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
|
|
96
317
|
// the DEEPEST containing project — so a nested project still wins over its
|
|
@@ -100,19 +321,25 @@ function projectsContaining(
|
|
|
100
321
|
// pays for.
|
|
101
322
|
const dirToName = new Map<string, string>()
|
|
102
323
|
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
103
|
-
const
|
|
324
|
+
const owned = new Set<string>()
|
|
325
|
+
// Paths that belong to no project. Only these can reach a task through a
|
|
326
|
+
// workspace-anchored glob, so they are the exact set worth asking about.
|
|
327
|
+
const orphans: string[] = []
|
|
104
328
|
for (const rel of changedRelPaths) {
|
|
105
329
|
let dir = path.resolve(workspaceRoot, rel)
|
|
330
|
+
let hit = false
|
|
106
331
|
for (;;) {
|
|
107
332
|
const name = dirToName.get(dir)
|
|
108
333
|
if (name !== undefined) {
|
|
109
|
-
|
|
334
|
+
owned.add(name)
|
|
335
|
+
hit = true
|
|
110
336
|
break
|
|
111
337
|
}
|
|
112
338
|
const parent = path.dirname(dir)
|
|
113
339
|
if (parent === dir) break // reached the filesystem root
|
|
114
340
|
dir = parent
|
|
115
341
|
}
|
|
342
|
+
if (!hit) orphans.push(rel)
|
|
116
343
|
}
|
|
117
|
-
return
|
|
344
|
+
return { owned, orphans }
|
|
118
345
|
}
|