@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.
Files changed (138) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +135 -124
  3. package/index.ts +10 -0
  4. package/launcher.mjs +10 -11
  5. package/package.json +11 -7
  6. package/src/bin.ts +14 -4
  7. package/src/cache/archive.ts +659 -0
  8. package/src/cache/cache.ts +769 -1101
  9. package/src/cache/chained-cache.ts +194 -0
  10. package/src/cache/config-evals.ts +89 -0
  11. package/src/cache/file-hashes.ts +262 -0
  12. package/src/cache/git-inputs.ts +637 -0
  13. package/src/cache/index.ts +24 -5
  14. package/src/cache/inputs.ts +373 -456
  15. package/src/cache/layer.ts +604 -0
  16. package/src/cache/layered-cache.ts +96 -43
  17. package/src/cache/output-index.ts +224 -0
  18. package/src/cache/policy.ts +84 -0
  19. package/src/cache/run-history.ts +147 -0
  20. package/src/cache/tar-stream.ts +358 -0
  21. package/src/cache/zstd.ts +160 -0
  22. package/src/cli/cache.ts +75 -21
  23. package/src/cli/completions.ts +104 -0
  24. package/src/cli/core-alias.ts +25 -0
  25. package/src/cli/help.ts +195 -128
  26. package/src/cli/index.ts +116 -46
  27. package/src/cli/info.ts +122 -63
  28. package/src/cli/init.ts +75 -0
  29. package/src/cli/last.ts +172 -0
  30. package/src/cli/lock.ts +2 -1
  31. package/src/cli/plan-format.ts +72 -6
  32. package/src/cli/plugin-commands.ts +103 -0
  33. package/src/cli/run.ts +245 -271
  34. package/src/cli/select.ts +332 -0
  35. package/src/cli/show.ts +144 -69
  36. package/src/cli/upgrade.ts +9 -7
  37. package/src/cli/watch.ts +858 -110
  38. package/src/cli/why.ts +223 -0
  39. package/src/cli/workspace-config.ts +126 -0
  40. package/src/config.ts +187 -150
  41. package/src/exec/env.ts +18 -1
  42. package/src/exec/executor.ts +224 -0
  43. package/src/exec/index.ts +24 -2
  44. package/src/exec/local-executor.ts +41 -0
  45. package/src/exec/runner.ts +166 -40
  46. package/src/exec/sandbox-binds.ts +182 -0
  47. package/src/exec/sandbox-paths.ts +50 -0
  48. package/src/exec/sandbox-runtime.ts +642 -260
  49. package/src/exec/sandbox-violations.ts +280 -0
  50. package/src/graph/index.ts +4 -10
  51. package/src/graph/scheduler.ts +232 -203
  52. package/src/graph/task-graph.ts +222 -8
  53. package/src/index.ts +146 -153
  54. package/src/orchestrator/admission.ts +137 -0
  55. package/src/orchestrator/deferred-outputs.ts +147 -0
  56. package/src/orchestrator/doctor.ts +255 -0
  57. package/src/orchestrator/download-policy.ts +171 -0
  58. package/src/orchestrator/events.ts +72 -8
  59. package/src/orchestrator/execute-task.ts +374 -549
  60. package/src/orchestrator/failure-mode.ts +202 -0
  61. package/src/orchestrator/framed-output.ts +126 -69
  62. package/src/orchestrator/history.ts +126 -59
  63. package/src/orchestrator/hit-restore.ts +221 -0
  64. package/src/orchestrator/index.ts +77 -93
  65. package/src/orchestrator/local-shortcircuit.ts +22 -0
  66. package/src/orchestrator/lockfile-claim.ts +281 -0
  67. package/src/orchestrator/logger.ts +208 -35
  68. package/src/orchestrator/metrics.ts +102 -1388
  69. package/src/orchestrator/miss-save.ts +169 -0
  70. package/src/orchestrator/options.ts +55 -34
  71. package/src/orchestrator/persistent.ts +79 -0
  72. package/src/orchestrator/placement.ts +182 -0
  73. package/src/orchestrator/plan.ts +144 -1
  74. package/src/orchestrator/plugin-host.ts +357 -120
  75. package/src/orchestrator/plugin.ts +298 -58
  76. package/src/orchestrator/prepare.ts +202 -106
  77. package/src/orchestrator/projects.ts +259 -0
  78. package/src/orchestrator/remote-prefetch.ts +12 -8
  79. package/src/orchestrator/run-artifacts.ts +63 -16
  80. package/src/orchestrator/run-context.ts +167 -29
  81. package/src/orchestrator/run-records.ts +149 -0
  82. package/src/orchestrator/run-report.ts +74 -47
  83. package/src/orchestrator/run.ts +534 -417
  84. package/src/orchestrator/sandbox-request.ts +243 -0
  85. package/src/orchestrator/save-lane.ts +47 -0
  86. package/src/orchestrator/signals.ts +111 -0
  87. package/src/orchestrator/stable-keys.ts +129 -31
  88. package/src/orchestrator/status-line.ts +49 -9
  89. package/src/orchestrator/summary.ts +90 -26
  90. package/src/orchestrator/tally.ts +78 -35
  91. package/src/orchestrator/task-hash.ts +111 -15
  92. package/src/orchestrator/task-log-buffer.ts +288 -0
  93. package/src/orchestrator/telemetry-host.ts +34 -6
  94. package/src/orchestrator/telemetry.ts +182 -36
  95. package/src/orchestrator/upstream.ts +72 -31
  96. package/src/util/cgroup.ts +160 -0
  97. package/src/util/edit-distance.ts +70 -0
  98. package/src/util/errors.ts +15 -1
  99. package/src/util/index.ts +15 -2
  100. package/src/util/num.ts +37 -0
  101. package/src/util/paths.ts +64 -0
  102. package/src/util/settle.ts +67 -0
  103. package/src/util/size.ts +5 -1
  104. package/src/util/tail.ts +66 -0
  105. package/src/util/timing.ts +63 -0
  106. package/src/util/verbs.ts +39 -0
  107. package/src/workspace/affected.ts +250 -23
  108. package/src/workspace/config-cache.ts +313 -0
  109. package/src/workspace/config-eval.ts +217 -0
  110. package/src/workspace/config-imports.ts +162 -0
  111. package/src/workspace/config-schema.ts +837 -0
  112. package/src/workspace/filter.ts +65 -10
  113. package/src/workspace/fingerprint.ts +53 -4
  114. package/src/workspace/index.ts +42 -3
  115. package/src/workspace/lockfile.ts +25 -9
  116. package/src/workspace/migrate-scripts.ts +134 -0
  117. package/src/workspace/migration.ts +314 -0
  118. package/src/workspace/nested-dirs.ts +21 -7
  119. package/src/workspace/package-graph.ts +114 -41
  120. package/src/workspace/project-loader.ts +224 -450
  121. package/src/workspace/workspace.ts +298 -72
  122. package/src/cache/cas-backend.ts +0 -97
  123. package/src/cache/digest.ts +0 -47
  124. package/src/cache/tar.ts +0 -343
  125. package/src/cli/backend.ts +0 -23
  126. package/src/cli/mcp-rpc.ts +0 -292
  127. package/src/cli/mcp.ts +0 -92
  128. package/src/cli/migrate-nx.ts +0 -450
  129. package/src/cli/migrate-turbo.ts +0 -364
  130. package/src/cli/migrate.ts +0 -219
  131. package/src/orchestrator/devframe-surface.ts +0 -74
  132. package/src/orchestrator/predict.ts +0 -106
  133. package/src/orchestrator/protocol.ts +0 -156
  134. package/src/orchestrator/resources.ts +0 -59
  135. package/src/orchestrator/run-state.ts +0 -145
  136. package/src/orchestrator/verify.ts +0 -164
  137. package/src/orchestrator/wire-render.ts +0 -92
  138. package/src/orchestrator/wire.ts +0 -212
@@ -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. The diff is `<since>...HEAD` plus working-tree
5
- // changes (uncommitted edits), so it captures everything you've
6
- // touched on the current branch.
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 proc = Bun.spawn({
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
- cmd: ['git', 'diff', '--no-renames', '--name-only', args.since],
40
- cwd: args.workspaceRoot,
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 diff failed (exit ${exit}): ${stderr.trim()}`)
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 !== 0) {
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 ref "${ref}" did not resolve. Pass a branch or commit you have locally.`,
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 out = new Set<string>()
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
- out.add(name)
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 out
344
+ return { owned, orphans }
118
345
  }