@vzn/vx 0.0.16 → 0.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +66 -89
- package/index.ts +10 -0
- package/launcher.mjs +10 -11
- package/package.json +19 -5
- package/plugins/local-cache/index.ts +2 -0
- package/plugins/local-executor/index.ts +2 -0
- package/plugins/schedule-history/index.ts +2 -0
- package/src/bin.ts +2 -2
- package/src/cache/archive.ts +557 -0
- package/src/cache/cache.ts +929 -250
- package/src/cache/cas-backend.ts +20 -2
- package/src/cache/chained-cache.ts +202 -0
- package/src/cache/digest.ts +0 -15
- package/src/cache/index.ts +13 -3
- package/src/cache/inputs.ts +445 -41
- package/src/cache/layered-cache.ts +88 -30
- package/src/cache/tar-stream.ts +358 -0
- package/src/cli/cache.ts +36 -10
- package/src/cli/help.ts +167 -128
- package/src/cli/index.ts +74 -43
- package/src/cli/info.ts +30 -0
- package/src/cli/last.ts +140 -0
- package/src/cli/migrate-emit.ts +21 -0
- package/src/cli/migrate-nx.ts +23 -0
- package/src/cli/migrate-scripts.ts +127 -0
- package/src/cli/migrate-turbo.ts +43 -6
- package/src/cli/migrate.ts +119 -33
- package/src/cli/plan-format.ts +58 -3
- package/src/cli/plugin-commands.ts +96 -0
- package/src/cli/prune.ts +291 -0
- package/src/cli/run.ts +272 -80
- package/src/cli/upgrade.ts +7 -6
- package/src/cli/watch.ts +259 -61
- package/src/cli/why.ts +193 -0
- package/src/config.ts +40 -23
- package/src/exec/env.ts +7 -1
- package/src/exec/executor.ts +239 -0
- package/src/exec/index.ts +19 -1
- package/src/exec/runner.ts +116 -29
- package/src/exec/sandbox-runtime.ts +194 -24
- package/src/graph/index.ts +3 -1
- package/src/graph/scheduler.ts +92 -18
- package/src/graph/task-graph.ts +220 -6
- package/src/index.ts +82 -84
- package/src/orchestrator/deferred-outputs.ts +144 -0
- package/src/orchestrator/download-policy.ts +171 -0
- package/src/orchestrator/events.ts +30 -0
- package/src/orchestrator/execute-task.ts +328 -94
- package/src/orchestrator/failure-mode.ts +61 -0
- package/src/orchestrator/framed-output.ts +87 -35
- package/src/orchestrator/history.ts +78 -31
- package/src/orchestrator/index.ts +43 -90
- package/src/orchestrator/local-shortcircuit.ts +22 -0
- package/src/orchestrator/logger.ts +196 -14
- package/src/orchestrator/metrics.ts +57 -1376
- package/src/orchestrator/missing-plugin.ts +9 -0
- package/src/orchestrator/options.ts +22 -4
- package/src/orchestrator/plan.ts +144 -1
- package/src/orchestrator/plugin-host.ts +199 -119
- package/src/orchestrator/plugin.ts +144 -57
- package/src/orchestrator/prepare.ts +220 -69
- package/src/orchestrator/remote-prefetch.ts +12 -8
- package/src/orchestrator/resources.ts +20 -34
- package/src/orchestrator/run-artifacts.ts +41 -16
- package/src/orchestrator/run-context.ts +167 -29
- package/src/orchestrator/run-report.ts +73 -46
- package/src/orchestrator/run.ts +590 -122
- package/src/orchestrator/stable-keys.ts +74 -33
- package/src/orchestrator/status-line.ts +47 -8
- package/src/orchestrator/summary.ts +22 -1
- package/src/orchestrator/tally.ts +78 -35
- package/src/orchestrator/task-hash.ts +105 -11
- package/src/orchestrator/task-log-buffer.ts +291 -0
- package/src/orchestrator/telemetry-host.ts +24 -6
- package/src/orchestrator/telemetry.ts +181 -21
- package/src/orchestrator/upstream.ts +72 -31
- package/src/orchestrator/verify.ts +12 -2
- package/src/plugins/local-cache/index.ts +11 -0
- package/src/plugins/local-executor/index.ts +45 -0
- package/src/plugins/schedule-history/index.ts +109 -0
- package/src/util/edit-distance.ts +21 -0
- package/src/util/errors.ts +15 -1
- package/src/util/index.ts +7 -2
- package/src/util/num.ts +37 -0
- package/src/util/paths.ts +43 -0
- package/src/util/settle.ts +48 -0
- package/src/util/size.ts +5 -1
- package/src/util/tail.ts +66 -0
- package/src/util/timing.ts +63 -0
- package/src/workspace/affected.ts +142 -15
- package/src/workspace/config-cache.ts +304 -0
- package/src/workspace/config-eval.ts +210 -0
- package/src/workspace/config-imports.ts +161 -0
- package/src/workspace/filter.ts +22 -4
- package/src/workspace/fingerprint.ts +16 -1
- package/src/workspace/index.ts +15 -3
- package/src/workspace/lockfile.ts +13 -2
- package/src/workspace/nested-dirs.ts +21 -7
- package/src/workspace/package-graph.ts +23 -20
- package/src/workspace/project-loader.ts +503 -59
- package/src/workspace/workspace.ts +213 -72
- package/src/cache/tar.ts +0 -343
- package/src/cli/backend.ts +0 -23
- package/src/cli/mcp-rpc.ts +0 -292
- package/src/cli/mcp.ts +0 -92
- package/src/orchestrator/devframe-surface.ts +0 -74
- package/src/orchestrator/predict.ts +0 -106
- package/src/orchestrator/protocol.ts +0 -156
- package/src/orchestrator/run-state.ts +0 -145
- package/src/orchestrator/wire-render.ts +0 -92
- package/src/orchestrator/wire.ts +0 -212
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// Re-evaluating a project config, import closure included, inside a
|
|
2
|
+
// long-lived process.
|
|
3
|
+
//
|
|
4
|
+
// The loader's query-string bust only changes the ENTRY's specifier.
|
|
5
|
+
// Bun caches an evaluated module by its RESOLVED specifier, so an
|
|
6
|
+
// `import './preset.js'` inside a config resolves to the same key no
|
|
7
|
+
// matter what query the entry carries — a busted entry re-evaluates
|
|
8
|
+
// against a STALE preset. Shared presets are the documented composition
|
|
9
|
+
// mechanism (`vx migrate` generates one), so through a whole `vx watch`
|
|
10
|
+
// session a preset edit was invisible; and because the resolved config
|
|
11
|
+
// feeds the cache key, vx answered `up-to-date` for a command that had
|
|
12
|
+
// changed on disk.
|
|
13
|
+
//
|
|
14
|
+
// A Worker gets its own module registry, so everything it imports is
|
|
15
|
+
// read from disk NOW. It is the only mechanism for this that the
|
|
16
|
+
// runtime exposes as public API: `globalThis.Loader.registry` — the
|
|
17
|
+
// obvious place to evict from — exists on Bun 1.3.11 and is GONE on
|
|
18
|
+
// 1.3.14, where an eviction-based fix degrades to no fix at all while
|
|
19
|
+
// still reporting success.
|
|
20
|
+
//
|
|
21
|
+
// The worker source is an inline data: URL, not a sibling file, because
|
|
22
|
+
// `bun build --compile` does NOT embed a Worker entry point — it
|
|
23
|
+
// resolves the URL from disk at runtime, so a sibling file would make
|
|
24
|
+
// the shipped standalone binary fail with ModuleNotFound.
|
|
25
|
+
//
|
|
26
|
+
// The config crosses back as JSON. That is already this project's
|
|
27
|
+
// contract for a config object: `hashTaskConfig` derives the cache key
|
|
28
|
+
// from `JSON.stringify(config)` and `vx lock` stores the same
|
|
29
|
+
// round-trip, so a JSON round-trip cannot change how a config hashes,
|
|
30
|
+
// locks or runs. `JSON.stringify(JSON.parse(s)) === s`, so a config
|
|
31
|
+
// re-read through a worker derives the SAME cache key as the
|
|
32
|
+
// in-process first load — which is why this needs no CACHE_VERSION bump.
|
|
33
|
+
|
|
34
|
+
import path from 'node:path'
|
|
35
|
+
import { MAX_TIMEOUT_MS } from '../util/index.js'
|
|
36
|
+
|
|
37
|
+
const WORKER_SRC = `
|
|
38
|
+
self.onmessage = async (e) => {
|
|
39
|
+
const { id, path } = e.data
|
|
40
|
+
try {
|
|
41
|
+
const ns = await import(path)
|
|
42
|
+
const mod = ns?.default
|
|
43
|
+
postMessage({
|
|
44
|
+
id,
|
|
45
|
+
ok: true,
|
|
46
|
+
json: mod !== null && typeof mod === 'object' ? JSON.stringify(mod) : null,
|
|
47
|
+
})
|
|
48
|
+
} catch (err) {
|
|
49
|
+
postMessage({
|
|
50
|
+
id,
|
|
51
|
+
ok: false,
|
|
52
|
+
name: err?.name ?? 'Error',
|
|
53
|
+
message: err?.message ?? String(err),
|
|
54
|
+
stack: err?.stack ?? null,
|
|
55
|
+
})
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
`
|
|
59
|
+
|
|
60
|
+
const WORKER_URL = `data:text/javascript,${encodeURIComponent(WORKER_SRC)}`
|
|
61
|
+
|
|
62
|
+
interface WorkerReply {
|
|
63
|
+
id: number
|
|
64
|
+
ok: boolean
|
|
65
|
+
json: string | null
|
|
66
|
+
name: string
|
|
67
|
+
message: string
|
|
68
|
+
stack: string | null
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface Pending {
|
|
72
|
+
resolve: (json: string | null) => void
|
|
73
|
+
reject: (err: Error) => void
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* How long a single config evaluation may take before we treat the worker as
|
|
78
|
+
* wedged. Real evaluations are ~10 ms; this exists only so a killed or hung
|
|
79
|
+
* worker cannot stall a long-lived process indefinitely. Read per call so a
|
|
80
|
+
* test can drive the deadline instead of waiting it out.
|
|
81
|
+
*/
|
|
82
|
+
function workerTimeoutMs(): number {
|
|
83
|
+
const raw = process.env['VX_CONFIG_WORKER_TIMEOUT_MS']
|
|
84
|
+
// Same kind of value as the teardown deadline, so the same treatment: a
|
|
85
|
+
// BOUND on a worker that may be wedged, with no "no limit" reading. Falls
|
|
86
|
+
// back rather than clamping, because honouring ~24.8 days would hang
|
|
87
|
+
// `vx watch` forever on a worker the OS killed — and unbounded makes it 1 ms,
|
|
88
|
+
// so EVERY config load times out instead. Both ends break the same feature.
|
|
89
|
+
if (raw !== undefined && /^[0-9]+$/.test(raw)) {
|
|
90
|
+
const n = Number(raw)
|
|
91
|
+
if (n <= MAX_TIMEOUT_MS) return n
|
|
92
|
+
}
|
|
93
|
+
return 30_000
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const pending = new Map<number, Pending>()
|
|
97
|
+
let worker: Worker | null = null
|
|
98
|
+
let inFlight = 0
|
|
99
|
+
let nextId = 0
|
|
100
|
+
let workersCreated = 0
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Workers created so far. Exported solely so a test can pin that ONE
|
|
104
|
+
* worker serves a whole concurrent round — the property that keeps a
|
|
105
|
+
* 1000-project watch cycle at one worker instead of a thousand.
|
|
106
|
+
*/
|
|
107
|
+
export function configEvalWorkerCount(): number {
|
|
108
|
+
return workersCreated
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function rejectAll(err: Error): void {
|
|
112
|
+
for (const p of pending.values()) p.reject(err)
|
|
113
|
+
pending.clear()
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The worker serving the current round. Every load that is in flight at
|
|
118
|
+
* the same moment shares it, and it is retired once the last of them
|
|
119
|
+
* settles — so a `Promise.all` round costs ONE worker, and the next
|
|
120
|
+
* round (the next watch cycle) still starts from an empty registry.
|
|
121
|
+
*
|
|
122
|
+
* Sharing within a round is also the more faithful semantics: two
|
|
123
|
+
* configs importing the same preset evaluate it once, exactly as they
|
|
124
|
+
* would in a fresh `vx run` process.
|
|
125
|
+
*/
|
|
126
|
+
function acquireWorker(): Worker {
|
|
127
|
+
if (worker !== null) return worker
|
|
128
|
+
const w = new Worker(WORKER_URL)
|
|
129
|
+
workersCreated++
|
|
130
|
+
w.onmessage = (event: MessageEvent): void => {
|
|
131
|
+
const msg = event.data as WorkerReply
|
|
132
|
+
const p = pending.get(msg.id)
|
|
133
|
+
if (p === undefined) return
|
|
134
|
+
pending.delete(msg.id)
|
|
135
|
+
if (msg.ok) {
|
|
136
|
+
p.resolve(msg.json)
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
// Rebuild the error the config actually threw. Name, message and
|
|
140
|
+
// stack all survive the hop, so a broken config reports the same
|
|
141
|
+
// text it would from an in-process import.
|
|
142
|
+
const err = new Error(msg.message)
|
|
143
|
+
err.name = msg.name
|
|
144
|
+
if (msg.stack !== null) err.stack = msg.stack
|
|
145
|
+
p.reject(err)
|
|
146
|
+
}
|
|
147
|
+
w.onerror = (event: ErrorEvent): void => {
|
|
148
|
+
rejectAll(new Error(`config worker failed: ${String(event.message)}`))
|
|
149
|
+
}
|
|
150
|
+
// A reply that cannot be deserialized would otherwise leave its caller
|
|
151
|
+
// awaiting forever — every path off this worker must settle its pending
|
|
152
|
+
// promises, including the ones that carry no usable payload.
|
|
153
|
+
w.addEventListener('messageerror', () => {
|
|
154
|
+
rejectAll(new Error('config worker sent an undeserializable reply'))
|
|
155
|
+
})
|
|
156
|
+
worker = w
|
|
157
|
+
return w
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Evaluate `configPath` against a fresh module registry and return its
|
|
162
|
+
* default export, JSON round-tripped. `null` means the module had no
|
|
163
|
+
* object default export — the caller owns that error message so it
|
|
164
|
+
* reads identically whichever path produced it.
|
|
165
|
+
*/
|
|
166
|
+
export async function evaluateConfigFresh(configPath: string): Promise<unknown> {
|
|
167
|
+
const abs = path.resolve(configPath)
|
|
168
|
+
const id = nextId++
|
|
169
|
+
inFlight++
|
|
170
|
+
// Declared out here so the `finally` can clear it on EVERY exit, including a
|
|
171
|
+
// rejection. See the note at the clear itself.
|
|
172
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
173
|
+
try {
|
|
174
|
+
const w = acquireWorker()
|
|
175
|
+
// A worker thread the OS kills — memory pressure on a loaded CI box, a
|
|
176
|
+
// hard crash — fires no `error` event, so without a deadline this await
|
|
177
|
+
// never settles and `vx watch` hangs forever on a cycle that would
|
|
178
|
+
// otherwise take milliseconds. Nothing else bounds it: there is no
|
|
179
|
+
// run-level timeout. The budget is enormous next to the ~10 ms a real
|
|
180
|
+
// evaluation costs, so it can only fire on a genuine wedge.
|
|
181
|
+
const budget = workerTimeoutMs()
|
|
182
|
+
const json = await new Promise<string | null>((resolve, reject) => {
|
|
183
|
+
pending.set(id, { resolve, reject })
|
|
184
|
+
timer = setTimeout(() => {
|
|
185
|
+
rejectAll(new Error(`config worker did not answer within ${budget}ms`))
|
|
186
|
+
if (worker !== null) {
|
|
187
|
+
worker.terminate()
|
|
188
|
+
worker = null
|
|
189
|
+
}
|
|
190
|
+
}, budget)
|
|
191
|
+
timer.unref?.()
|
|
192
|
+
w.postMessage({ id, path: abs })
|
|
193
|
+
})
|
|
194
|
+
return json === null ? null : (JSON.parse(json) as unknown)
|
|
195
|
+
} finally {
|
|
196
|
+
// In the `finally`, not after the await: a REJECTED evaluation — a config
|
|
197
|
+
// with a typo, the common case while editing — would otherwise skip the
|
|
198
|
+
// clear and leave its timer armed for the whole budget. When that orphan
|
|
199
|
+
// later fired it ran `rejectAll()` and terminated whatever worker was
|
|
200
|
+
// current by then, killing an unrelated healthy round with a timeout
|
|
201
|
+
// message naming a budget nobody set for it.
|
|
202
|
+
clearTimeout(timer)
|
|
203
|
+
pending.delete(id)
|
|
204
|
+
inFlight--
|
|
205
|
+
if (inFlight === 0 && worker !== null) {
|
|
206
|
+
worker.terminate()
|
|
207
|
+
worker = null
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// The third `changed file → project` channel for `--affected`.
|
|
2
|
+
//
|
|
3
|
+
// Directory containment answers "which project owns this file", and
|
|
4
|
+
// `cache.inputs.workspaceFiles` answers "which project declared it". Neither
|
|
5
|
+
// can see the one remaining way a file reaches a task: a project's
|
|
6
|
+
// `vx.config.*` IMPORTS it. Resolved-config hashing folds the imported values
|
|
7
|
+
// into the cache key (architecture principle #4), so editing such a file
|
|
8
|
+
// re-keys the task — and `affected.ts` states the rule this exists to keep:
|
|
9
|
+
// "input hashing sees it, so `--affected` must too."
|
|
10
|
+
//
|
|
11
|
+
// This is a STATIC scan. Nothing is evaluated: `Bun.Transpiler.scanImports`
|
|
12
|
+
// reads the specifiers and `Bun.resolveSync` turns them into paths. The
|
|
13
|
+
// `project-loader.ts` note that a bust "cannot reach the config's import
|
|
14
|
+
// closure" is about `import()` at runtime, not about reading the source.
|
|
15
|
+
//
|
|
16
|
+
// Two rules keep the walk small, and the second is the one that makes it
|
|
17
|
+
// affordable at all:
|
|
18
|
+
//
|
|
19
|
+
// - RELATIVE specifiers only. A bare specifier is a package; it moves when
|
|
20
|
+
// the lockfile moves, which the workspace fingerprint already covers.
|
|
21
|
+
// - Descend only through files owned by NO project. A config reaching into
|
|
22
|
+
// another project (this repo's `apps/docs/vx.config.ts` imports
|
|
23
|
+
// `../../src/index.ts`) records that edge and STOPS there — following it
|
|
24
|
+
// would drag substantially all of core `src/` into the closure, and the
|
|
25
|
+
// containment channel already selects the project that owns it.
|
|
26
|
+
|
|
27
|
+
import { realpath } from 'node:fs/promises'
|
|
28
|
+
import path from 'node:path'
|
|
29
|
+
import type { ProjectMeta } from './workspace.js'
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Absolute resolved targets of the RELATIVE specifiers in `source`.
|
|
33
|
+
*
|
|
34
|
+
* Paths come back realpath'd, because `Bun.resolveSync` realpaths them — see
|
|
35
|
+
* the caller, which realpaths everything it compares against for exactly that
|
|
36
|
+
* reason. `import type` is erased by `scanImports` and so contributes no edge,
|
|
37
|
+
* which is correct: an erased import cannot move a resolved value.
|
|
38
|
+
*/
|
|
39
|
+
function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'): string[] {
|
|
40
|
+
let specifiers: string[]
|
|
41
|
+
try {
|
|
42
|
+
specifiers = new Bun.Transpiler({ loader }).scanImports(source).map((i) => i.path)
|
|
43
|
+
} catch {
|
|
44
|
+
return [] // unparseable source contributes no edges
|
|
45
|
+
}
|
|
46
|
+
const out: string[] = []
|
|
47
|
+
for (const spec of specifiers) {
|
|
48
|
+
if (!spec.startsWith('./') && !spec.startsWith('../')) continue
|
|
49
|
+
try {
|
|
50
|
+
out.push(Bun.resolveSync(spec, fromDir))
|
|
51
|
+
} catch {
|
|
52
|
+
// Unresolvable (deleted, typo, extensionless miss) — a config that will
|
|
53
|
+
// not load cannot be shown to import anything, and failing selection
|
|
54
|
+
// over it would break a working build.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return out
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface ConfigImportOwnersArgs {
|
|
61
|
+
/**
|
|
62
|
+
* Realpath'd HERE, not by the caller. `Bun.resolveSync` returns realpath'd
|
|
63
|
+
* targets, so a raw root silently fails every containment check and the
|
|
64
|
+
* scan reports "no imports" — indistinguishable from a clean tree. Owning
|
|
65
|
+
* the normalisation in one place makes that misuse unrepresentable.
|
|
66
|
+
*/
|
|
67
|
+
workspaceRoot: string
|
|
68
|
+
projects: readonly ProjectMeta[]
|
|
69
|
+
/** Workspace-relative POSIX paths — the same list containment consumed. */
|
|
70
|
+
changed: readonly string[]
|
|
71
|
+
/** Already-selected projects; their configs need no scan. */
|
|
72
|
+
skip: ReadonlySet<string>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Project dir → name, REALPATH'D — which is why this cannot reuse the index
|
|
77
|
+
* the containment pass builds. `Bun.resolveSync` hands back realpath'd
|
|
78
|
+
* targets, and on darwin a workspace under `os.tmpdir()` lives at
|
|
79
|
+
* `/var/folders/…` while its realpath is `/private/var/folders/…`; comparing
|
|
80
|
+
* the two matches nothing and fails exactly like "found no imports".
|
|
81
|
+
*/
|
|
82
|
+
async function realDirIndex(projects: readonly ProjectMeta[]): Promise<Map<string, string>> {
|
|
83
|
+
const out = new Map<string, string>()
|
|
84
|
+
await Promise.all(
|
|
85
|
+
projects.map(async (p) => {
|
|
86
|
+
out.set(await realpath(p.dir).catch(() => p.dir), p.name)
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
return out
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const TS_EXT = new Set(['.ts', '.mts', '.cts'])
|
|
93
|
+
|
|
94
|
+
/** The deepest project containing `file`, or undefined when none does. */
|
|
95
|
+
function ownerOf(file: string, dirToName: ReadonlyMap<string, string>): string | undefined {
|
|
96
|
+
let dir = path.dirname(file)
|
|
97
|
+
for (;;) {
|
|
98
|
+
const name = dirToName.get(dir)
|
|
99
|
+
if (name !== undefined) return name
|
|
100
|
+
const parent = path.dirname(dir)
|
|
101
|
+
if (parent === dir) return undefined
|
|
102
|
+
dir = parent
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Projects whose config file transitively imports one of `changed`. */
|
|
107
|
+
export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set<string>> {
|
|
108
|
+
const selected = new Set<string>()
|
|
109
|
+
if (a.changed.length === 0 || a.skip.size === a.projects.length) return selected
|
|
110
|
+
const workspaceRoot = await realpath(a.workspaceRoot).catch(() => a.workspaceRoot)
|
|
111
|
+
|
|
112
|
+
const roots = new Map<string, string>() // abs config path → project name
|
|
113
|
+
await Promise.all(
|
|
114
|
+
a.projects.map(async (p) => {
|
|
115
|
+
const cfg = p.configPath
|
|
116
|
+
if (cfg === null || cfg === '' || a.skip.has(p.name)) return
|
|
117
|
+
roots.set(await realpath(cfg).catch(() => path.resolve(cfg)), p.name)
|
|
118
|
+
}),
|
|
119
|
+
)
|
|
120
|
+
if (roots.size === 0) return selected
|
|
121
|
+
const dirToName = await realDirIndex(a.projects)
|
|
122
|
+
|
|
123
|
+
// target → the files that import it. Reversed up front so one BFS from the
|
|
124
|
+
// changed set answers every root at once, instead of a walk per root.
|
|
125
|
+
const importedBy = new Map<string, string[]>()
|
|
126
|
+
const visited = new Set<string>()
|
|
127
|
+
const queue = [...roots.keys()]
|
|
128
|
+
while (queue.length > 0) {
|
|
129
|
+
const file = queue.pop()!
|
|
130
|
+
if (visited.has(file)) continue
|
|
131
|
+
visited.add(file)
|
|
132
|
+
let source: string
|
|
133
|
+
try {
|
|
134
|
+
source = await Bun.file(file).text()
|
|
135
|
+
} catch {
|
|
136
|
+
continue // unreadable: no edges, and not this pass's problem to report
|
|
137
|
+
}
|
|
138
|
+
const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
|
|
139
|
+
for (const target of scanLocalImports(source, path.dirname(file), loader)) {
|
|
140
|
+
if (!target.startsWith(workspaceRoot + path.sep)) continue
|
|
141
|
+
if (target.split(path.sep).includes('node_modules')) continue
|
|
142
|
+
const list = importedBy.get(target)
|
|
143
|
+
if (list) list.push(file)
|
|
144
|
+
else importedBy.set(target, [file])
|
|
145
|
+
// Descend ONLY through unowned files — see the header.
|
|
146
|
+
if (ownerOf(target, dirToName) === undefined) queue.push(target)
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const seen = new Set<string>()
|
|
151
|
+
const walk = [...a.changed.map((rel) => path.resolve(workspaceRoot, rel))]
|
|
152
|
+
while (walk.length > 0) {
|
|
153
|
+
const file = walk.pop()!
|
|
154
|
+
if (seen.has(file)) continue
|
|
155
|
+
seen.add(file)
|
|
156
|
+
const owner = roots.get(file)
|
|
157
|
+
if (owner !== undefined) selected.add(owner)
|
|
158
|
+
for (const importer of importedBy.get(file) ?? []) walk.push(importer)
|
|
159
|
+
}
|
|
160
|
+
return selected
|
|
161
|
+
}
|
package/src/workspace/filter.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
// Filter DSL — pnpm-style selectors for
|
|
1
|
+
// Filter DSL — pnpm-style selectors for `--filter`.
|
|
2
2
|
//
|
|
3
|
-
// <pattern> name glob (e.g. foo, @scope/*)
|
|
3
|
+
// <pattern> name glob, `*` = any characters (e.g. foo, @scope/*)
|
|
4
4
|
// ./<dir> packages whose dir is at or under <dir> (relative to workspace root)
|
|
5
5
|
// {<dir>} same as ./<dir>
|
|
6
6
|
// <pattern>... pattern + its transitive workspace dependencies
|
|
@@ -107,13 +107,24 @@ function matchProjects(
|
|
|
107
107
|
}
|
|
108
108
|
return out
|
|
109
109
|
}
|
|
110
|
-
const
|
|
110
|
+
const re = compileNameGlob(filter.matcher)
|
|
111
111
|
for (const p of projects) {
|
|
112
|
-
if (
|
|
112
|
+
if (re.test(p.name)) out.push(p.name)
|
|
113
113
|
}
|
|
114
114
|
return out
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
/**
|
|
118
|
+
* Compile a name pattern where `*` is the sole metacharacter and means "any
|
|
119
|
+
* characters" — pnpm's rule. A path glob would treat `/` as a separator, so
|
|
120
|
+
* `*` could never cross the `@scope/` boundary: `--filter '*'` would select
|
|
121
|
+
* only UNSCOPED packages, and `*core*` would match nothing at all.
|
|
122
|
+
*/
|
|
123
|
+
function compileNameGlob(pattern: string): RegExp {
|
|
124
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '.*')
|
|
125
|
+
return new RegExp(`^${escaped}$`)
|
|
126
|
+
}
|
|
127
|
+
|
|
117
128
|
export interface ApplyFiltersOptions {
|
|
118
129
|
filters: ParsedFilter[]
|
|
119
130
|
projects: ProjectMeta[]
|
|
@@ -125,6 +136,12 @@ export interface ApplyFiltersOptions {
|
|
|
125
136
|
* stays pure + sync.
|
|
126
137
|
*/
|
|
127
138
|
affectedByFilter?: Map<ParsedFilter, Set<string>>
|
|
139
|
+
/**
|
|
140
|
+
* Called once per filter that matched zero projects, before expansion.
|
|
141
|
+
* Only the TOTAL empty selection is an error, so without this a typo among
|
|
142
|
+
* several filters silently under-selects.
|
|
143
|
+
*/
|
|
144
|
+
onNoMatch?: (filter: ParsedFilter) => void
|
|
128
145
|
}
|
|
129
146
|
|
|
130
147
|
export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
|
|
@@ -134,6 +151,7 @@ export function applyFilters(opts: ApplyFiltersOptions): Set<string> {
|
|
|
134
151
|
|
|
135
152
|
for (const f of opts.filters) {
|
|
136
153
|
const matched = matchProjects(f, opts.projects, opts.affectedByFilter)
|
|
154
|
+
if (matched.length === 0) opts.onNoMatch?.(f)
|
|
137
155
|
const expanded = new Set<string>()
|
|
138
156
|
for (const name of matched) {
|
|
139
157
|
if (!f.onlyDeps) expanded.add(name)
|
|
@@ -8,7 +8,11 @@ import { xxh3 } from '../util/index.js'
|
|
|
8
8
|
// for projects that have that file present, which is fine: that's the
|
|
9
9
|
// whole point of bumping CACHE_VERSION when the fingerprint surface
|
|
10
10
|
// expands.
|
|
11
|
-
|
|
11
|
+
// Exported so `--affected` can consult the SAME list. A change to any of
|
|
12
|
+
// these re-keys every task in the workspace, so selection has to widen to
|
|
13
|
+
// match — two copies of this list would silently drift into a run that
|
|
14
|
+
// rebuilds everything while selecting nothing.
|
|
15
|
+
export const WORKSPACE_FINGERPRINT_FILES = [
|
|
12
16
|
// Lockfiles
|
|
13
17
|
'pnpm-lock.yaml',
|
|
14
18
|
'package-lock.json',
|
|
@@ -21,6 +25,17 @@ const WORKSPACE_FINGERPRINT_FILES = [
|
|
|
21
25
|
'pnpm-workspace.yaml',
|
|
22
26
|
]
|
|
23
27
|
|
|
28
|
+
// DELIBERATELY ABSENT: `vx.workspace.{ts,mts,js,mjs}`. Everything it can
|
|
29
|
+
// declare — `concurrency`, `cacheDir`, `timeout`, and the
|
|
30
|
+
// plugin list — is placement, storage or observability, never what a
|
|
31
|
+
// command produces; core's three seams may change WHERE a task runs, not
|
|
32
|
+
// WHAT it runs (architecture principle #3). Folding it in would also do
|
|
33
|
+
// active harm: a laptop declaring only the local plugins and a CI runner
|
|
34
|
+
// declaring `reapi()` would compute different fingerprints and share not
|
|
35
|
+
// one cache entry — the same split the rejected `NODE_OPTIONS` non-goal
|
|
36
|
+
// describes. A per-task input that genuinely varies belongs in
|
|
37
|
+
// `cache.inputs`, which is the surface built for it.
|
|
38
|
+
|
|
24
39
|
/**
|
|
25
40
|
* One hash for the workspace as a whole, derived from whichever
|
|
26
41
|
* lockfile + workspace-definition files exist at the root. Folded
|
package/src/workspace/index.ts
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
// Module contract. Cross-module imports must come through here; see
|
|
2
2
|
// docs/design/module-isolation-2026-06.md and tests/module-boundaries.test.ts.
|
|
3
3
|
|
|
4
|
-
export { affectedProjects, defaultAffectedBase } from './affected.js'
|
|
4
|
+
export { affectedProjects, defaultAffectedBase, workspaceGlobsMatch } from './affected.js'
|
|
5
5
|
export { applyFilters, parseFilter } from './filter.js'
|
|
6
|
-
export { computeWorkspaceFingerprint } from './fingerprint.js'
|
|
6
|
+
export { computeWorkspaceFingerprint, WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
|
|
7
7
|
export { computeNestedProjectDirs } from './nested-dirs.js'
|
|
8
8
|
export { buildPackageGraph, type PackageGraph } from './package-graph.js'
|
|
9
9
|
export {
|
|
@@ -16,7 +16,19 @@ export {
|
|
|
16
16
|
type Lockfile,
|
|
17
17
|
type LockfileEntry,
|
|
18
18
|
} from './lockfile.js'
|
|
19
|
-
export {
|
|
19
|
+
export {
|
|
20
|
+
loadProjectConfig,
|
|
21
|
+
loadProjectConfigs,
|
|
22
|
+
loadWorkspaceConfig,
|
|
23
|
+
validateProjectConfig,
|
|
24
|
+
} from './project-loader.js'
|
|
25
|
+
export type { LoadProjectConfigOptions } from './project-loader.js'
|
|
26
|
+
export {
|
|
27
|
+
blobOidOf,
|
|
28
|
+
configEvalKey,
|
|
29
|
+
configEvalKeyFromClosure,
|
|
30
|
+
type ConfigEvalStore,
|
|
31
|
+
} from './config-cache.js'
|
|
20
32
|
export {
|
|
21
33
|
findWorkspaceRoot,
|
|
22
34
|
listProjects,
|
|
@@ -52,7 +52,14 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
52
52
|
} catch {
|
|
53
53
|
throw new UserError(`${LOCKFILE_NAME} is not valid JSON — re-run \`vx lock\` or delete it`)
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
// `Array.isArray` is not redundant with the `typeof` check: `typeof [] ===
|
|
56
|
+
// 'object'` and `[] !== null`, so an array sails through and the NEXT check
|
|
57
|
+
// reports `unsupported version undefined` — a version diagnostic for what is
|
|
58
|
+
// actually a shape problem, sending the reader to re-run `vx lock` for a file
|
|
59
|
+
// that was never the right shape. `validateProjectConfig` one layer down
|
|
60
|
+
// already guards this explicitly, which is what makes the omission here an
|
|
61
|
+
// inconsistency rather than an oversight.
|
|
62
|
+
if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
|
|
56
63
|
throw new UserError(`${LOCKFILE_NAME} must be a JSON object — re-run \`vx lock\``)
|
|
57
64
|
}
|
|
58
65
|
const lock = parsed as { version?: unknown; projects?: unknown }
|
|
@@ -62,7 +69,11 @@ export async function readLockfile(root: string): Promise<Lockfile | null> {
|
|
|
62
69
|
`(this vx expects ${LOCKFILE_VERSION}) — re-run \`vx lock\``,
|
|
63
70
|
)
|
|
64
71
|
}
|
|
65
|
-
|
|
72
|
+
// Same trap, and this one is worse than a bad message: an ARRAY of entries is
|
|
73
|
+
// ACCEPTED, because `Object.entries` happily walks it — yielding a project
|
|
74
|
+
// literally named "0". `--frozen` then looks up real project names against a
|
|
75
|
+
// lock that declares none of them.
|
|
76
|
+
if (typeof lock.projects !== 'object' || lock.projects === null || Array.isArray(lock.projects)) {
|
|
66
77
|
throw new UserError(`${LOCKFILE_NAME}: \`projects\` must be an object — re-run \`vx lock\``)
|
|
67
78
|
}
|
|
68
79
|
for (const [name, entry] of Object.entries(lock.projects)) {
|
|
@@ -7,11 +7,18 @@ import type { ProjectEntry } from './workspace.js'
|
|
|
7
7
|
* project's task cannot see files inside another project, even if its
|
|
8
8
|
* globs would otherwise match.
|
|
9
9
|
*
|
|
10
|
-
* Sort once, then scan:
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
10
|
+
* Sort once, then scan forward: every dir sharing `p.dir` as a STRING
|
|
11
|
+
* prefix is contiguous in sorted order, and `p`'s nested descendants
|
|
12
|
+
* (`p.dir + sep + …`) are a sub-run within that block. But a SIBLING whose
|
|
13
|
+
* name extends `p.dir` by a char that sorts BELOW `sep` (`-`, `.`, `+`, a
|
|
14
|
+
* space; `/` is 0x2F) lands BETWEEN `p.dir` and `p.dir + sep` — so a plain
|
|
15
|
+
* `break` on the first non-descendant would stop before reaching the nested
|
|
16
|
+
* children (e.g. `foo`, `foo-utils`, `foo/nested` → `foo/nested` missed,
|
|
17
|
+
* silently breaking the project-boundary invariant). So: collect descendants,
|
|
18
|
+
* SKIP interlopers that merely share the `p.dir` string prefix, and break only
|
|
19
|
+
* once we leave the `p.dir` block entirely. The inner loop is still bounded by
|
|
20
|
+
* that block (near-O(P log P) on real trees; interlopers are few), not the
|
|
21
|
+
* full tail.
|
|
15
22
|
*/
|
|
16
23
|
export function computeNestedProjectDirs(
|
|
17
24
|
entries: Array<Pick<ProjectEntry, 'name' | 'dir'>>,
|
|
@@ -25,8 +32,15 @@ export function computeNestedProjectDirs(
|
|
|
25
32
|
const nested: string[] = []
|
|
26
33
|
for (let j = i + 1; j < sorted.length; j++) {
|
|
27
34
|
const other = sorted[j]!
|
|
28
|
-
if (
|
|
29
|
-
|
|
35
|
+
if (other.dir.startsWith(prefix)) {
|
|
36
|
+
nested.push(other.dir)
|
|
37
|
+
} else if (!other.dir.startsWith(p.dir)) {
|
|
38
|
+
// Past every dir sharing `p.dir`'s string prefix — no descendant can
|
|
39
|
+
// follow (they'd sort inside this block), so stop.
|
|
40
|
+
break
|
|
41
|
+
}
|
|
42
|
+
// else: an interloping sibling (`foo-utils` for `foo`) that shares the
|
|
43
|
+
// string prefix but isn't nested — skip it, descendants may follow.
|
|
30
44
|
}
|
|
31
45
|
result.set(p.name, nested)
|
|
32
46
|
}
|
|
@@ -51,7 +51,7 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
|
|
|
51
51
|
// per-call sort. Package graphs (unlike task graphs) may legally
|
|
52
52
|
// contain cycles; the bitset sweep requires a DAG, so a cycle
|
|
53
53
|
// (detected by the Kahn pass not draining) falls back wholesale to
|
|
54
|
-
//
|
|
54
|
+
// a per-query reachability search for that rare case.
|
|
55
55
|
const names = [...byName.keys()].sort()
|
|
56
56
|
const index = new Map<string, number>()
|
|
57
57
|
for (let i = 0; i < names.length; i++) index.set(names[i]!, i)
|
|
@@ -92,32 +92,35 @@ export function buildPackageGraph(projects: ProjectMeta[]): PackageGraph {
|
|
|
92
92
|
return closure
|
|
93
93
|
}
|
|
94
94
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
stack.
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
for (const
|
|
95
|
+
// Cyclic fallback: everything reachable from `name` via ≥1 edge (so a
|
|
96
|
+
// node inside a cycle includes itself). Deliberately a self-contained
|
|
97
|
+
// per-query search rather than a memoized recursion over sub-results:
|
|
98
|
+
// in a cycle the back-edge contributes nothing, so a shared recursion
|
|
99
|
+
// computes TRUNCATED closures for the nodes below it and caching those
|
|
100
|
+
// makes every later answer depend on which node was asked for first.
|
|
101
|
+
function reachableFrom(name: string, edges: Map<string, string[]>): string[] {
|
|
102
|
+
const seen = new Set<string>()
|
|
103
|
+
const stack = [...(edges.get(name) ?? [])]
|
|
104
|
+
while (stack.length > 0) {
|
|
105
|
+
const cur = stack.pop()!
|
|
106
|
+
if (seen.has(cur)) continue
|
|
107
|
+
seen.add(cur)
|
|
108
|
+
for (const d of edges.get(cur) ?? []) stack.push(d)
|
|
109
109
|
}
|
|
110
|
-
|
|
111
|
-
const result = [...out].sort()
|
|
112
|
-
cache.set(name, result)
|
|
113
|
-
return result
|
|
110
|
+
return [...seen].sort()
|
|
114
111
|
}
|
|
115
112
|
|
|
116
113
|
function makeAccessor(edges: Map<string, string[]>): (name: string) => string[] {
|
|
117
114
|
const closures = bitsetClosures(edges)
|
|
118
115
|
const memo = new Map<string, string[]>()
|
|
119
116
|
if (closures === null) {
|
|
120
|
-
return (name) =>
|
|
117
|
+
return (name) => {
|
|
118
|
+
const cached = memo.get(name)
|
|
119
|
+
if (cached) return cached
|
|
120
|
+
const result = reachableFrom(name, edges)
|
|
121
|
+
memo.set(name, result)
|
|
122
|
+
return result
|
|
123
|
+
}
|
|
121
124
|
}
|
|
122
125
|
const words = (names.length + 31) >>> 5
|
|
123
126
|
return (name) => {
|