@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.
Files changed (90) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -1
  3. package/launcher.mjs +84 -0
  4. package/package.json +44 -3
  5. package/src/bin.ts +25 -0
  6. package/src/cache/cache.ts +1832 -0
  7. package/src/cache/cas-backend.ts +97 -0
  8. package/src/cache/digest.ts +47 -0
  9. package/src/cache/index.ts +29 -0
  10. package/src/cache/inputs.ts +833 -0
  11. package/src/cache/layered-cache.ts +335 -0
  12. package/src/cache/remote-cache.ts +251 -0
  13. package/src/cache/tar.ts +343 -0
  14. package/src/cli/backend.ts +23 -0
  15. package/src/cli/cache.ts +101 -0
  16. package/src/cli/format.ts +15 -0
  17. package/src/cli/help.ts +123 -0
  18. package/src/cli/index.ts +85 -0
  19. package/src/cli/info.ts +83 -0
  20. package/src/cli/lock.ts +135 -0
  21. package/src/cli/mcp-rpc.ts +292 -0
  22. package/src/cli/mcp.ts +92 -0
  23. package/src/cli/migrate-nx.ts +450 -0
  24. package/src/cli/migrate-turbo.ts +364 -0
  25. package/src/cli/migrate.ts +219 -0
  26. package/src/cli/plan-format.ts +148 -0
  27. package/src/cli/run.ts +661 -0
  28. package/src/cli/show.ts +201 -0
  29. package/src/cli/upgrade.ts +103 -0
  30. package/src/cli/watch.ts +287 -0
  31. package/src/config.ts +421 -0
  32. package/src/exec/env.ts +74 -0
  33. package/src/exec/index.ts +13 -0
  34. package/src/exec/runner.ts +470 -0
  35. package/src/exec/sandbox-runtime.ts +578 -0
  36. package/src/graph/dependency-spec.ts +69 -0
  37. package/src/graph/index.ts +18 -0
  38. package/src/graph/scheduler.ts +482 -0
  39. package/src/graph/task-graph.ts +325 -0
  40. package/src/index.ts +197 -0
  41. package/src/orchestrator/colors.ts +63 -0
  42. package/src/orchestrator/devframe-surface.ts +74 -0
  43. package/src/orchestrator/events.ts +277 -0
  44. package/src/orchestrator/execute-task.ts +883 -0
  45. package/src/orchestrator/framed-output.ts +461 -0
  46. package/src/orchestrator/history.ts +148 -0
  47. package/src/orchestrator/index.ts +163 -0
  48. package/src/orchestrator/local-shortcircuit.ts +147 -0
  49. package/src/orchestrator/logger.ts +526 -0
  50. package/src/orchestrator/metrics.ts +1576 -0
  51. package/src/orchestrator/options.ts +176 -0
  52. package/src/orchestrator/plan.ts +129 -0
  53. package/src/orchestrator/plugin-host.ts +190 -0
  54. package/src/orchestrator/plugin.ts +283 -0
  55. package/src/orchestrator/predict.ts +106 -0
  56. package/src/orchestrator/prepare.ts +297 -0
  57. package/src/orchestrator/protocol.ts +142 -0
  58. package/src/orchestrator/remote-cache-setup.ts +60 -0
  59. package/src/orchestrator/remote-prefetch.ts +81 -0
  60. package/src/orchestrator/run-artifacts.ts +111 -0
  61. package/src/orchestrator/run-context.ts +205 -0
  62. package/src/orchestrator/run-report.ts +132 -0
  63. package/src/orchestrator/run-state.ts +145 -0
  64. package/src/orchestrator/run.ts +842 -0
  65. package/src/orchestrator/stable-keys.ts +177 -0
  66. package/src/orchestrator/status-line.ts +287 -0
  67. package/src/orchestrator/summary.ts +291 -0
  68. package/src/orchestrator/tally.ts +69 -0
  69. package/src/orchestrator/task-hash.ts +238 -0
  70. package/src/orchestrator/telemetry-host.ts +80 -0
  71. package/src/orchestrator/telemetry.ts +350 -0
  72. package/src/orchestrator/upstream.ts +93 -0
  73. package/src/orchestrator/verify.ts +141 -0
  74. package/src/orchestrator/wire-render.ts +92 -0
  75. package/src/orchestrator/wire.ts +212 -0
  76. package/src/util/errors.ts +11 -0
  77. package/src/util/hash.ts +25 -0
  78. package/src/util/index.ts +7 -0
  79. package/src/util/paths.ts +16 -0
  80. package/src/util/ulid.ts +15 -0
  81. package/src/version.ts +10 -0
  82. package/src/workspace/affected.ts +118 -0
  83. package/src/workspace/filter.ts +155 -0
  84. package/src/workspace/fingerprint.ts +41 -0
  85. package/src/workspace/index.ts +27 -0
  86. package/src/workspace/lockfile.ts +118 -0
  87. package/src/workspace/nested-dirs.ts +34 -0
  88. package/src/workspace/package-graph.ts +148 -0
  89. package/src/workspace/project-loader.ts +470 -0
  90. 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
+ }
@@ -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
+ }
@@ -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'