@vzn/vx 0.0.0 → 0.0.12
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 +21 -0
- package/README.md +332 -1
- package/launcher.mjs +84 -0
- package/package.json +44 -3
- package/src/bin.ts +25 -0
- package/src/cache/cache.ts +1832 -0
- package/src/cache/cas-backend.ts +97 -0
- package/src/cache/digest.ts +47 -0
- package/src/cache/index.ts +29 -0
- package/src/cache/inputs.ts +833 -0
- package/src/cache/layered-cache.ts +335 -0
- package/src/cache/remote-cache.ts +251 -0
- package/src/cache/tar.ts +343 -0
- package/src/cli/backend.ts +23 -0
- package/src/cli/cache.ts +101 -0
- package/src/cli/format.ts +15 -0
- package/src/cli/help.ts +123 -0
- package/src/cli/index.ts +85 -0
- package/src/cli/info.ts +83 -0
- package/src/cli/lock.ts +135 -0
- package/src/cli/mcp-rpc.ts +292 -0
- package/src/cli/mcp.ts +92 -0
- package/src/cli/migrate-nx.ts +450 -0
- package/src/cli/migrate-turbo.ts +364 -0
- package/src/cli/migrate.ts +219 -0
- package/src/cli/plan-format.ts +148 -0
- package/src/cli/run.ts +661 -0
- package/src/cli/show.ts +201 -0
- package/src/cli/upgrade.ts +103 -0
- package/src/cli/watch.ts +287 -0
- package/src/config.ts +421 -0
- package/src/exec/env.ts +74 -0
- package/src/exec/index.ts +13 -0
- package/src/exec/runner.ts +470 -0
- package/src/exec/sandbox-runtime.ts +578 -0
- package/src/graph/dependency-spec.ts +69 -0
- package/src/graph/index.ts +18 -0
- package/src/graph/scheduler.ts +482 -0
- package/src/graph/task-graph.ts +325 -0
- package/src/index.ts +197 -0
- package/src/orchestrator/colors.ts +63 -0
- package/src/orchestrator/devframe-surface.ts +74 -0
- package/src/orchestrator/events.ts +277 -0
- package/src/orchestrator/execute-task.ts +883 -0
- package/src/orchestrator/framed-output.ts +461 -0
- package/src/orchestrator/history.ts +148 -0
- package/src/orchestrator/index.ts +163 -0
- package/src/orchestrator/local-shortcircuit.ts +147 -0
- package/src/orchestrator/logger.ts +526 -0
- package/src/orchestrator/metrics.ts +1576 -0
- package/src/orchestrator/options.ts +176 -0
- package/src/orchestrator/plan.ts +129 -0
- package/src/orchestrator/plugin-host.ts +190 -0
- package/src/orchestrator/plugin.ts +283 -0
- package/src/orchestrator/predict.ts +106 -0
- package/src/orchestrator/prepare.ts +297 -0
- package/src/orchestrator/protocol.ts +142 -0
- package/src/orchestrator/remote-cache-setup.ts +60 -0
- package/src/orchestrator/remote-prefetch.ts +81 -0
- package/src/orchestrator/run-artifacts.ts +111 -0
- package/src/orchestrator/run-context.ts +205 -0
- package/src/orchestrator/run-report.ts +132 -0
- package/src/orchestrator/run-state.ts +145 -0
- package/src/orchestrator/run.ts +842 -0
- package/src/orchestrator/stable-keys.ts +177 -0
- package/src/orchestrator/status-line.ts +287 -0
- package/src/orchestrator/summary.ts +291 -0
- package/src/orchestrator/tally.ts +69 -0
- package/src/orchestrator/task-hash.ts +238 -0
- package/src/orchestrator/telemetry-host.ts +80 -0
- package/src/orchestrator/telemetry.ts +350 -0
- package/src/orchestrator/upstream.ts +93 -0
- package/src/orchestrator/verify.ts +141 -0
- package/src/orchestrator/wire-render.ts +92 -0
- package/src/orchestrator/wire.ts +212 -0
- package/src/util/errors.ts +11 -0
- package/src/util/hash.ts +25 -0
- package/src/util/index.ts +7 -0
- package/src/util/paths.ts +16 -0
- package/src/util/ulid.ts +15 -0
- package/src/version.ts +10 -0
- package/src/workspace/affected.ts +118 -0
- package/src/workspace/filter.ts +155 -0
- package/src/workspace/fingerprint.ts +41 -0
- package/src/workspace/index.ts +27 -0
- package/src/workspace/lockfile.ts +118 -0
- package/src/workspace/nested-dirs.ts +34 -0
- package/src/workspace/package-graph.ts +148 -0
- package/src/workspace/project-loader.ts +470 -0
- package/src/workspace/workspace.ts +176 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
// JSON-RPC 2.0 envelope + OTel-LogRecord payload — the wire spec
|
|
2
|
+
// committed by docs/design/wire-protocol-2026-06.md. Lives alongside
|
|
3
|
+
// the existing protocol.ts (t-discriminated ServerMessage/ClientMessage)
|
|
4
|
+
// so vx serve can speak both formats during the transition.
|
|
5
|
+
//
|
|
6
|
+
// One envelope, three transports (WS / SSE / NDJSON), four channels
|
|
7
|
+
// (vx:events / vx:state / vx:rpc / vx:submit). Every external consumer
|
|
8
|
+
// that already speaks JSON-RPC works against vx out of the box.
|
|
9
|
+
//
|
|
10
|
+
// Pure types + a small adapter pair. No transport here; the Hono
|
|
11
|
+
// router in src/cli/serve.ts wires the byte frames.
|
|
12
|
+
|
|
13
|
+
import type { RunRequest, RunResult, ServerMessage, ClientMessage } from './protocol.js'
|
|
14
|
+
import type { WireEvent as InternalWireEvent } from './events.js'
|
|
15
|
+
|
|
16
|
+
/** Protocol version returned by GET /version. */
|
|
17
|
+
export const WIRE_PROTOCOL_VERSION = '1.0'
|
|
18
|
+
|
|
19
|
+
/** The four channels we expose; clients pick the subset they care about. */
|
|
20
|
+
export const WIRE_CHANNELS = ['vx:events', 'vx:state', 'vx:rpc', 'vx:submit'] as const
|
|
21
|
+
export type WireChannel = (typeof WIRE_CHANNELS)[number]
|
|
22
|
+
|
|
23
|
+
/** JSON-RPC 2.0 method names emitted by the server (notifications). */
|
|
24
|
+
export type ServerNotificationMethod = 'events.append' | 'state.patch'
|
|
25
|
+
|
|
26
|
+
/** JSON-RPC 2.0 method names dispatched from the client (request/response). */
|
|
27
|
+
export type ClientRequestMethod = 'state.snapshot' | 'submit.run' | `rpc.${string}`
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The JSON-RPC 2.0 envelope. One of four shapes per the spec.
|
|
31
|
+
*
|
|
32
|
+
* - request: { jsonrpc, id, method, params? }
|
|
33
|
+
* - response: { jsonrpc, id, result }
|
|
34
|
+
* - error: { jsonrpc, id, error: { code, message, data? } }
|
|
35
|
+
* - notification: { jsonrpc, method, params? }
|
|
36
|
+
*/
|
|
37
|
+
export type Envelope = Request | Response | ErrorResponse | Notification
|
|
38
|
+
|
|
39
|
+
export interface Request {
|
|
40
|
+
jsonrpc: '2.0'
|
|
41
|
+
id: number | string
|
|
42
|
+
method: string
|
|
43
|
+
params?: unknown
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Response {
|
|
47
|
+
jsonrpc: '2.0'
|
|
48
|
+
id: number | string
|
|
49
|
+
result: unknown
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ErrorResponse {
|
|
53
|
+
jsonrpc: '2.0'
|
|
54
|
+
id: number | string
|
|
55
|
+
error: { code: number; message: string; data?: unknown }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface Notification {
|
|
59
|
+
jsonrpc: '2.0'
|
|
60
|
+
method: string
|
|
61
|
+
params?: unknown
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** JSON-RPC 2.0 error codes used by vx. The spec reserves -32000..-32099 for impl-defined. */
|
|
65
|
+
export const ENVELOPE_ERRORS = {
|
|
66
|
+
PARSE_ERROR: -32700,
|
|
67
|
+
INVALID_REQUEST: -32600,
|
|
68
|
+
METHOD_NOT_FOUND: -32601,
|
|
69
|
+
INVALID_PARAMS: -32602,
|
|
70
|
+
INTERNAL_ERROR: -32603,
|
|
71
|
+
USER_ERROR: -32000, // a vx UserError — clean message, no stack
|
|
72
|
+
TASK_HASH_UNKNOWN: -32001,
|
|
73
|
+
RUN_NOT_FOUND: -32002,
|
|
74
|
+
UNAUTHORIZED: -32003,
|
|
75
|
+
RATE_LIMITED: -32004,
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
/** Build a request envelope. */
|
|
79
|
+
export function makeRequest(id: number | string, method: string, params?: unknown): Request {
|
|
80
|
+
const req: Request = { jsonrpc: '2.0', id, method }
|
|
81
|
+
if (params !== undefined) req.params = params
|
|
82
|
+
return req
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Build a notification envelope. */
|
|
86
|
+
export function makeNotification(method: string, params?: unknown): Notification {
|
|
87
|
+
const note: Notification = { jsonrpc: '2.0', method }
|
|
88
|
+
if (params !== undefined) note.params = params
|
|
89
|
+
return note
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Build a success response envelope. */
|
|
93
|
+
export function makeResponse(id: number | string, result: unknown): Response {
|
|
94
|
+
return { jsonrpc: '2.0', id, result }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Build an error response envelope. */
|
|
98
|
+
export function makeError(
|
|
99
|
+
id: number | string,
|
|
100
|
+
code: number,
|
|
101
|
+
message: string,
|
|
102
|
+
data?: unknown,
|
|
103
|
+
): ErrorResponse {
|
|
104
|
+
const err: ErrorResponse = { jsonrpc: '2.0', id, error: { code, message } }
|
|
105
|
+
if (data !== undefined) err.error.data = data
|
|
106
|
+
return err
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Type-guard: does a parsed object look like an Envelope? */
|
|
110
|
+
export function isEnvelope(value: unknown): value is Envelope {
|
|
111
|
+
if (value === null || typeof value !== 'object') return false
|
|
112
|
+
const v = value as { jsonrpc?: unknown }
|
|
113
|
+
return v.jsonrpc === '2.0'
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Type-guard for a request envelope. */
|
|
117
|
+
export function isRequest(env: Envelope): env is Request {
|
|
118
|
+
return 'method' in env && 'id' in env
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Type-guard for a notification envelope (request without id). */
|
|
122
|
+
export function isNotification(env: Envelope): env is Notification {
|
|
123
|
+
return 'method' in env && !('id' in env)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
// Bidirectional adapters between the legacy t-discriminated wire and the
|
|
128
|
+
// JSON-RPC envelope. Lets vx serve accept both formats on the same WS
|
|
129
|
+
// endpoint during the transition: parse one, fall back to the other.
|
|
130
|
+
// ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Project a ServerMessage to an envelope. Events become `events.append`
|
|
134
|
+
* notifications; results become `submit.run` responses; errors become
|
|
135
|
+
* error responses. The distributed coordinator messages map to the
|
|
136
|
+
* `coord.*` namespace in a distribution plugin's own adapters.
|
|
137
|
+
*/
|
|
138
|
+
export function serverMessageToEnvelope(msg: ServerMessage, id?: number | string): Envelope {
|
|
139
|
+
switch (msg.t) {
|
|
140
|
+
case 'event':
|
|
141
|
+
return makeNotification('events.append', msg.event)
|
|
142
|
+
case 'result':
|
|
143
|
+
// submit.run's response. id required from the caller.
|
|
144
|
+
return makeResponse(id ?? 0, msg.result)
|
|
145
|
+
case 'error':
|
|
146
|
+
return makeError(id ?? 0, ENVELOPE_ERRORS.USER_ERROR, msg.message)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Project an envelope back to a ServerMessage where one exists. Returns
|
|
152
|
+
* null for envelopes that don't have a mapping (e.g. state.snapshot
|
|
153
|
+
* responses — pure JSON-RPC, no submitter form).
|
|
154
|
+
*/
|
|
155
|
+
export function envelopeToServerMessage(env: Envelope): ServerMessage | null {
|
|
156
|
+
if (isNotification(env) && env.method === 'events.append') {
|
|
157
|
+
return { t: 'event', event: env.params as InternalWireEvent }
|
|
158
|
+
}
|
|
159
|
+
if ('result' in env) {
|
|
160
|
+
return { t: 'result', result: env.result as RunResult }
|
|
161
|
+
}
|
|
162
|
+
if ('error' in env) {
|
|
163
|
+
return { t: 'error', message: env.error.message }
|
|
164
|
+
}
|
|
165
|
+
return null
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Project a ClientMessage to an envelope. `run` becomes a `submit.run` request. */
|
|
169
|
+
export function clientMessageToEnvelope(msg: ClientMessage, id?: number | string): Envelope {
|
|
170
|
+
return makeRequest(id ?? 1, 'submit.run', msg.request)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Project an envelope back to a ClientMessage. */
|
|
174
|
+
export function envelopeToClientMessage(env: Envelope): ClientMessage | null {
|
|
175
|
+
if (isRequest(env) && env.method === 'submit.run') {
|
|
176
|
+
return { t: 'run', request: env.params as RunRequest }
|
|
177
|
+
}
|
|
178
|
+
return null
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---------------------------------------------------------------------------
|
|
182
|
+
// Transport encoders — single bus, one envelope, three byte framings.
|
|
183
|
+
// Helpers exposed for the Hono mounts in src/cli/serve.ts.
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
/** Encode an envelope for a WebSocket frame (one JSON object per frame). */
|
|
187
|
+
export function encodeForWS(env: Envelope): string {
|
|
188
|
+
return JSON.stringify(env)
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Encode an envelope as an SSE event block. Each block is one
|
|
193
|
+
* `data: <json>\n\n` chunk so a `curl` or browser EventSource gets it
|
|
194
|
+
* cleanly.
|
|
195
|
+
*/
|
|
196
|
+
export function encodeForSSE(env: Envelope): string {
|
|
197
|
+
return `data: ${JSON.stringify(env)}\n\n`
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Encode an envelope as a NDJSON line. One envelope per line. */
|
|
201
|
+
export function encodeForNDJSON(env: Envelope): string {
|
|
202
|
+
return JSON.stringify(env) + '\n'
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Parse a UTF-8 string back to an envelope (and validate the shape). */
|
|
206
|
+
export function decodeEnvelope(raw: string): Envelope {
|
|
207
|
+
const value = JSON.parse(raw) as unknown
|
|
208
|
+
if (!isEnvelope(value)) {
|
|
209
|
+
throw new Error(`not a JSON-RPC 2.0 envelope: ${raw.slice(0, 100)}`)
|
|
210
|
+
}
|
|
211
|
+
return value
|
|
212
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Shared error types used across modules to distinguish user-input
|
|
2
|
+
// failures (clean message only) from internal bugs (full stack trace).
|
|
3
|
+
//
|
|
4
|
+
// `bin.ts` consults `instanceof UserError` to decide what to print.
|
|
5
|
+
|
|
6
|
+
export class UserError extends Error {
|
|
7
|
+
constructor(message: string) {
|
|
8
|
+
super(message)
|
|
9
|
+
this.name = 'UserError'
|
|
10
|
+
}
|
|
11
|
+
}
|
package/src/util/hash.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Shared non-cryptographic hash helper. xxHash3 is ~5× faster than
|
|
2
|
+
// SHA-256 on modern x86 and produces enough entropy (64 bits) for our
|
|
3
|
+
// uses: cache-key derivation, file content fingerprinting, config-
|
|
4
|
+
// load module cache-busting. None of those need collision resistance
|
|
5
|
+
// against an adversary; they just need uniqueness across honest input.
|
|
6
|
+
//
|
|
7
|
+
// Hex output is a fixed 16-char string. Turbo uses the same shape
|
|
8
|
+
// (xxh64 -> hex(to_be_bytes(u64))) — short enough to live in
|
|
9
|
+
// filenames, recognizable when grepping logs.
|
|
10
|
+
//
|
|
11
|
+
// `Bun.hash.xxHash3` has no streaming Hasher API but takes a seed —
|
|
12
|
+
// we use that seed to chain multiple updates: each `xxh3(part, prev)`
|
|
13
|
+
// folds `part` into the running digest. Equivalent to the old
|
|
14
|
+
// `CryptoHasher.update(...).update(...).digest()` pattern, without
|
|
15
|
+
// allocating an intermediate buffer.
|
|
16
|
+
|
|
17
|
+
/** xxHash3 bigint output. Use as the seed for the next chain step. */
|
|
18
|
+
export function xxh3(input: string | Uint8Array, seed: bigint = 0n): bigint {
|
|
19
|
+
return Bun.hash.xxHash3(input, seed)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** xxHash3 hex-encoded, fixed 16 chars (zero-padded). */
|
|
23
|
+
export function xxh3hex(input: string | Uint8Array, seed: bigint = 0n): string {
|
|
24
|
+
return xxh3(input, seed).toString(16).padStart(16, '0')
|
|
25
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Module contract. Cross-module imports must come through here; see
|
|
2
|
+
// docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
|
|
3
|
+
|
|
4
|
+
export { UserError } from './errors.js'
|
|
5
|
+
export { xxh3, xxh3hex } from './hash.js'
|
|
6
|
+
export { relPosix } from './paths.js'
|
|
7
|
+
export { ulid } from './ulid.js'
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
const IS_POSIX = path.sep === '/'
|
|
4
|
+
|
|
5
|
+
/** Convert a path to POSIX-style for cross-platform stable cache keys. */
|
|
6
|
+
export function toPosix(p: string): string {
|
|
7
|
+
// Fast path: on Linux/macOS the separator is already '/'. This
|
|
8
|
+
// function is called for every input file on every cache-key
|
|
9
|
+
// derivation; skipping the split+join is free perf on the dominant
|
|
10
|
+
// dev platform.
|
|
11
|
+
return IS_POSIX ? p : p.split(path.sep).join('/')
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function relPosix(from: string, to: string): string {
|
|
15
|
+
return toPosix(path.relative(from, to))
|
|
16
|
+
}
|
package/src/util/ulid.ts
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// Run-id generator. Thin wrapper over `Bun.randomUUIDv7()` so the
|
|
2
|
+
// `runId` column on `runs` carries a timestamp-ordered identifier —
|
|
3
|
+
// `vx stats --since` queries can range-scan without an index on the
|
|
4
|
+
// time column, and the IDs lexicographically sort by creation time
|
|
5
|
+
// for grouping.
|
|
6
|
+
//
|
|
7
|
+
// UUIDv7 layout: 48-bit ms-epoch timestamp + 74 bits of randomness
|
|
8
|
+
// across the remaining bytes (RFC 9562). Standard format, hex with
|
|
9
|
+
// hyphens, 36 chars. Previously a hand-rolled 26-char Crockford-
|
|
10
|
+
// base32 ULID — Bun's built-in covers the same guarantees with zero
|
|
11
|
+
// custom code.
|
|
12
|
+
|
|
13
|
+
export function ulid(): string {
|
|
14
|
+
return Bun.randomUUIDv7()
|
|
15
|
+
}
|
package/src/version.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// Leaf module so orchestrator/cli can read the version without
|
|
2
|
+
// importing the public façade (index.ts imports the orchestrator —
|
|
3
|
+
// that edge must stay one-directional).
|
|
4
|
+
//
|
|
5
|
+
// Single source of truth is package.json — Bun resolves the JSON
|
|
6
|
+
// import natively (and inlines it under `bun build --compile`), so
|
|
7
|
+
// a release bump can never drift from what the banner prints.
|
|
8
|
+
import pkg from '../package.json'
|
|
9
|
+
|
|
10
|
+
export const VERSION: string = pkg.version
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// `--affected` — git-relative project selection.
|
|
2
|
+
//
|
|
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.
|
|
11
|
+
|
|
12
|
+
import path from 'node:path'
|
|
13
|
+
import { UserError } from '../util/index.js'
|
|
14
|
+
import { LOCKFILE_NAME } from './lockfile.js'
|
|
15
|
+
import type { ProjectMeta } from './workspace.js'
|
|
16
|
+
|
|
17
|
+
export interface AffectedArgs {
|
|
18
|
+
workspaceRoot: string
|
|
19
|
+
/** Git ref / commit / branch to compare against. Required. */
|
|
20
|
+
since: string
|
|
21
|
+
projects: readonly ProjectMeta[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Return the set of project names whose files changed between
|
|
26
|
+
* `<since>` and the current working tree. Errors if `git` is missing
|
|
27
|
+
* or `<since>` doesn't resolve to a commit.
|
|
28
|
+
*/
|
|
29
|
+
export async function affectedProjects(args: AffectedArgs): Promise<Set<string>> {
|
|
30
|
+
await verifyRef(args.workspaceRoot, args.since)
|
|
31
|
+
|
|
32
|
+
const proc = Bun.spawn({
|
|
33
|
+
// `--no-renames` is crucial for project-affected detection: with
|
|
34
|
+
// git's auto rename-detection on (the default in modern git), a
|
|
35
|
+
// cross-project `git mv` collapses to a single rename entry that
|
|
36
|
+
// surfaces only the destination path. We'd then miss flagging the
|
|
37
|
+
// source project as affected. Treating renames as delete+add gives
|
|
38
|
+
// us both halves so both projects get correctly marked.
|
|
39
|
+
cmd: ['git', 'diff', '--no-renames', '--name-only', args.since],
|
|
40
|
+
cwd: args.workspaceRoot,
|
|
41
|
+
stdout: 'pipe',
|
|
42
|
+
stderr: 'pipe',
|
|
43
|
+
})
|
|
44
|
+
const stdout = await new Response(proc.stdout).text()
|
|
45
|
+
const stderr = await new Response(proc.stderr).text()
|
|
46
|
+
const exit = await proc.exited
|
|
47
|
+
if (exit !== 0) {
|
|
48
|
+
throw new UserError(`git diff failed (exit ${exit}): ${stderr.trim()}`)
|
|
49
|
+
}
|
|
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)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Resolve the default base for `--affected` with no explicit value.
|
|
59
|
+
* Tries the remote's HEAD branch first (`origin/main`, `origin/master`,
|
|
60
|
+
* etc.), then falls back to `HEAD~1` which always exists once there
|
|
61
|
+
* are at least two commits.
|
|
62
|
+
*/
|
|
63
|
+
export async function defaultAffectedBase(workspaceRoot: string): Promise<string> {
|
|
64
|
+
const probe = Bun.spawnSync({
|
|
65
|
+
cmd: ['git', 'symbolic-ref', '--short', '-q', 'refs/remotes/origin/HEAD'],
|
|
66
|
+
cwd: workspaceRoot,
|
|
67
|
+
stdout: 'pipe',
|
|
68
|
+
stderr: 'ignore',
|
|
69
|
+
})
|
|
70
|
+
const out = new TextDecoder().decode(probe.stdout).trim()
|
|
71
|
+
if (probe.exitCode === 0 && out.length > 0) return out
|
|
72
|
+
return 'HEAD~1'
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
async function verifyRef(workspaceRoot: string, ref: string): Promise<void> {
|
|
76
|
+
const proc = Bun.spawnSync({
|
|
77
|
+
cmd: ['git', 'rev-parse', '--verify', '--quiet', ref],
|
|
78
|
+
cwd: workspaceRoot,
|
|
79
|
+
stdout: 'pipe',
|
|
80
|
+
stderr: 'pipe',
|
|
81
|
+
})
|
|
82
|
+
if (proc.exitCode !== 0) {
|
|
83
|
+
throw new UserError(
|
|
84
|
+
`git ref "${ref}" did not resolve. Pass a branch or commit you have locally.`,
|
|
85
|
+
)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function projectsContaining(
|
|
90
|
+
workspaceRoot: string,
|
|
91
|
+
changedRelPaths: readonly string[],
|
|
92
|
+
projects: readonly ProjectMeta[],
|
|
93
|
+
): Set<string> {
|
|
94
|
+
// Index projects by their (canonical) dir, then for each changed path walk
|
|
95
|
+
// its ancestor dirs bottom-up until one is a project dir. The FIRST hit is
|
|
96
|
+
// the DEEPEST containing project — so a nested project still wins over its
|
|
97
|
+
// parent, exactly as the prior longest-dir sort did — but this is
|
|
98
|
+
// O(files · path-depth) instead of O(files · projects): independent of the
|
|
99
|
+
// project count, which is what a big --affected diff on a 1000-project repo
|
|
100
|
+
// pays for.
|
|
101
|
+
const dirToName = new Map<string, string>()
|
|
102
|
+
for (const p of projects) dirToName.set(p.dir, p.name)
|
|
103
|
+
const out = new Set<string>()
|
|
104
|
+
for (const rel of changedRelPaths) {
|
|
105
|
+
let dir = path.resolve(workspaceRoot, rel)
|
|
106
|
+
for (;;) {
|
|
107
|
+
const name = dirToName.get(dir)
|
|
108
|
+
if (name !== undefined) {
|
|
109
|
+
out.add(name)
|
|
110
|
+
break
|
|
111
|
+
}
|
|
112
|
+
const parent = path.dirname(dir)
|
|
113
|
+
if (parent === dir) break // reached the filesystem root
|
|
114
|
+
dir = parent
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return out
|
|
118
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// Filter DSL — pnpm-style selectors for `-F / --filter`.
|
|
2
|
+
//
|
|
3
|
+
// <pattern> name glob (e.g. foo, @scope/*)
|
|
4
|
+
// ./<dir> packages whose dir is at or under <dir> (relative to workspace root)
|
|
5
|
+
// {<dir>} same as ./<dir>
|
|
6
|
+
// <pattern>... pattern + its transitive workspace dependencies
|
|
7
|
+
// ...<pattern> pattern + its transitive workspace dependents
|
|
8
|
+
// <pattern>^... only the transitive deps of pattern (excluding the matched package)
|
|
9
|
+
// !<pattern> exclude packages matching pattern from the selection
|
|
10
|
+
// [<since>] projects affected since the given git ref
|
|
11
|
+
// (Turbo-style; resolved upstream of applyFilters via
|
|
12
|
+
// `affectedProjects` since it needs FS + git access)
|
|
13
|
+
//
|
|
14
|
+
// Filters are evaluated in order. If any include filter is present, the base
|
|
15
|
+
// set is empty and matched/expanded packages are added. If only excludes are
|
|
16
|
+
// given, the base set is "all projects" and excluded packages are removed.
|
|
17
|
+
|
|
18
|
+
import path from 'node:path'
|
|
19
|
+
import type { PackageGraph } from './package-graph.js'
|
|
20
|
+
import type { ProjectMeta } from './workspace.js'
|
|
21
|
+
|
|
22
|
+
export interface ParsedFilter {
|
|
23
|
+
raw: string
|
|
24
|
+
negate: boolean
|
|
25
|
+
withDeps: boolean
|
|
26
|
+
withDependents: boolean
|
|
27
|
+
onlyDeps: boolean
|
|
28
|
+
isPath: boolean
|
|
29
|
+
/** Glob pattern (for name match) or absolute path (for path match). */
|
|
30
|
+
matcher: string
|
|
31
|
+
/**
|
|
32
|
+
* When non-undefined, this filter is a git-relative `[<since>]`
|
|
33
|
+
* selector. The matcher field is unused; the CLI resolves the ref
|
|
34
|
+
* to a concrete set of project names before calling applyFilters.
|
|
35
|
+
*/
|
|
36
|
+
gitSince?: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function parseFilter(raw: string, workspaceRoot: string): ParsedFilter {
|
|
40
|
+
let s = raw
|
|
41
|
+
const negate = s.startsWith('!')
|
|
42
|
+
if (negate) s = s.slice(1)
|
|
43
|
+
|
|
44
|
+
const withDependents = s.startsWith('...')
|
|
45
|
+
if (withDependents) s = s.slice(3)
|
|
46
|
+
|
|
47
|
+
let onlyDeps = false
|
|
48
|
+
let withDeps = false
|
|
49
|
+
if (s.endsWith('^...')) {
|
|
50
|
+
onlyDeps = true
|
|
51
|
+
s = s.slice(0, -4)
|
|
52
|
+
} else if (s.endsWith('...')) {
|
|
53
|
+
withDeps = true
|
|
54
|
+
s = s.slice(0, -3)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// `[<since>]` git-relative selector. Suffix walks already ran above,
|
|
58
|
+
// so `[main]...` parses as `[main]` with withDeps=true.
|
|
59
|
+
if (s.startsWith('[') && s.endsWith(']')) {
|
|
60
|
+
return {
|
|
61
|
+
raw,
|
|
62
|
+
negate,
|
|
63
|
+
withDeps,
|
|
64
|
+
withDependents,
|
|
65
|
+
onlyDeps,
|
|
66
|
+
isPath: false,
|
|
67
|
+
matcher: '',
|
|
68
|
+
gitSince: s.slice(1, -1),
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let isPath = false
|
|
73
|
+
let matcher = s
|
|
74
|
+
if (s.startsWith('./') || s === '.') {
|
|
75
|
+
isPath = true
|
|
76
|
+
matcher = path.resolve(workspaceRoot, s)
|
|
77
|
+
} else if (s.startsWith('{') && s.endsWith('}')) {
|
|
78
|
+
isPath = true
|
|
79
|
+
matcher = path.resolve(workspaceRoot, s.slice(1, -1))
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { raw, negate, withDeps, withDependents, onlyDeps, isPath, matcher }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function matchProjects(
|
|
86
|
+
filter: ParsedFilter,
|
|
87
|
+
projects: ProjectMeta[],
|
|
88
|
+
affectedByFilter: Map<ParsedFilter, Set<string>> | undefined,
|
|
89
|
+
): string[] {
|
|
90
|
+
// `[<since>]` selectors are pre-resolved by the caller (the parser
|
|
91
|
+
// is pure; git access happens upstream). Use the provided set as
|
|
92
|
+
// the match set for this filter.
|
|
93
|
+
if (filter.gitSince !== undefined) {
|
|
94
|
+
return [...(affectedByFilter?.get(filter) ?? new Set())]
|
|
95
|
+
}
|
|
96
|
+
const out: string[] = []
|
|
97
|
+
if (filter.isPath) {
|
|
98
|
+
const prefix = filter.matcher + path.sep
|
|
99
|
+
for (const p of projects) {
|
|
100
|
+
if (p.dir === filter.matcher || p.dir.startsWith(prefix)) out.push(p.name)
|
|
101
|
+
}
|
|
102
|
+
return out
|
|
103
|
+
}
|
|
104
|
+
if (!filter.matcher.includes('*')) {
|
|
105
|
+
for (const p of projects) {
|
|
106
|
+
if (p.name === filter.matcher) out.push(p.name)
|
|
107
|
+
}
|
|
108
|
+
return out
|
|
109
|
+
}
|
|
110
|
+
const glob = new Bun.Glob(filter.matcher)
|
|
111
|
+
for (const p of projects) {
|
|
112
|
+
if (glob.match(p.name)) out.push(p.name)
|
|
113
|
+
}
|
|
114
|
+
return out
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export interface ApplyFiltersOptions {
|
|
118
|
+
filters: ParsedFilter[]
|
|
119
|
+
projects: ProjectMeta[]
|
|
120
|
+
graph: PackageGraph
|
|
121
|
+
/**
|
|
122
|
+
* Pre-resolved affected-project sets for each `[<since>]` filter.
|
|
123
|
+
* The caller (CLI / programmatic embedder) runs the git work and
|
|
124
|
+
* stuffs results in this map before calling applyFilters, which
|
|
125
|
+
* stays pure + sync.
|
|
126
|
+
*/
|
|
127
|
+
affectedByFilter?: Map<ParsedFilter, Set<string>>
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
|
|
131
|
+
const allNames = opts.projects.map((p) => p.name)
|
|
132
|
+
const hasInclude = opts.filters.some((f) => !f.negate)
|
|
133
|
+
const selected = new Set<string>(hasInclude ? [] : allNames)
|
|
134
|
+
|
|
135
|
+
for (const f of opts.filters) {
|
|
136
|
+
const matched = matchProjects(f, opts.projects, opts.affectedByFilter)
|
|
137
|
+
const expanded = new Set<string>()
|
|
138
|
+
for (const name of matched) {
|
|
139
|
+
if (!f.onlyDeps) expanded.add(name)
|
|
140
|
+
if (f.withDeps || f.onlyDeps) {
|
|
141
|
+
for (const d of opts.graph.transitiveDeps(name)) expanded.add(d)
|
|
142
|
+
}
|
|
143
|
+
if (f.withDependents) {
|
|
144
|
+
for (const d of opts.graph.transitiveDependents(name)) expanded.add(d)
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (f.negate) {
|
|
148
|
+
for (const name of expanded) selected.delete(name)
|
|
149
|
+
} else {
|
|
150
|
+
for (const name of expanded) selected.add(name)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return selected
|
|
155
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { xxh3 } from '../util/index.js'
|
|
3
|
+
|
|
4
|
+
// Every package-manager lockfile we know about, plus the workspace
|
|
5
|
+
// definition files. Whichever ones exist get folded into the
|
|
6
|
+
// fingerprint; missing ones are skipped. Hashed in declaration order
|
|
7
|
+
// for determinism — adding a new entry here changes the fingerprint
|
|
8
|
+
// for projects that have that file present, which is fine: that's the
|
|
9
|
+
// whole point of bumping CACHE_VERSION when the fingerprint surface
|
|
10
|
+
// expands.
|
|
11
|
+
const WORKSPACE_FINGERPRINT_FILES = [
|
|
12
|
+
// Lockfiles
|
|
13
|
+
'pnpm-lock.yaml',
|
|
14
|
+
'package-lock.json',
|
|
15
|
+
'npm-shrinkwrap.json',
|
|
16
|
+
'yarn.lock',
|
|
17
|
+
'bun.lock',
|
|
18
|
+
'bun.lockb',
|
|
19
|
+
// Workspace definition (pnpm); package.json is hashed per-project
|
|
20
|
+
// via projectPackageJsonHash, not here.
|
|
21
|
+
'pnpm-workspace.yaml',
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* One hash for the workspace as a whole, derived from whichever
|
|
26
|
+
* lockfile + workspace-definition files exist at the root. Folded
|
|
27
|
+
* into every task's cache key so any lockfile bump (`pnpm update`,
|
|
28
|
+
* `npm install`, `bun install`, …) or a workspace-shape change
|
|
29
|
+
* invalidates every cached entry. Coarse but correct.
|
|
30
|
+
*/
|
|
31
|
+
export async function computeWorkspaceFingerprint(workspaceRoot: string): Promise<string> {
|
|
32
|
+
let h = 0n
|
|
33
|
+
for (const f of WORKSPACE_FINGERPRINT_FILES) {
|
|
34
|
+
const full = path.join(workspaceRoot, f)
|
|
35
|
+
const file = Bun.file(full)
|
|
36
|
+
if (!(await file.exists())) continue
|
|
37
|
+
h = xxh3(`${f}\0`, h)
|
|
38
|
+
h = xxh3(await file.bytes(), h)
|
|
39
|
+
}
|
|
40
|
+
return h.toString(16).padStart(16, '0')
|
|
41
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Module contract. Cross-module imports must come through here; see
|
|
2
|
+
// docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
|
|
3
|
+
|
|
4
|
+
export { affectedProjects, defaultAffectedBase } from './affected.js'
|
|
5
|
+
export { applyFilters, parseFilter } from './filter.js'
|
|
6
|
+
export { computeWorkspaceFingerprint } from './fingerprint.js'
|
|
7
|
+
export { computeNestedProjectDirs } from './nested-dirs.js'
|
|
8
|
+
export { buildPackageGraph, type PackageGraph } from './package-graph.js'
|
|
9
|
+
export {
|
|
10
|
+
frozenProjectConfig,
|
|
11
|
+
LOCKFILE_NAME,
|
|
12
|
+
LOCKFILE_VERSION,
|
|
13
|
+
lockfilePath,
|
|
14
|
+
readLockfile,
|
|
15
|
+
writeLockfile,
|
|
16
|
+
type Lockfile,
|
|
17
|
+
type LockfileEntry,
|
|
18
|
+
} from './lockfile.js'
|
|
19
|
+
export { loadProjectConfig, loadWorkspaceConfig } from './project-loader.js'
|
|
20
|
+
export {
|
|
21
|
+
findWorkspaceRoot,
|
|
22
|
+
listProjects,
|
|
23
|
+
loadWorkspace,
|
|
24
|
+
resolveCacheDir,
|
|
25
|
+
type ProjectEntry,
|
|
26
|
+
type ProjectMeta,
|
|
27
|
+
} from './workspace.js'
|