@vzn/vx 0.0.15 → 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.
Files changed (111) hide show
  1. package/README.md +91 -130
  2. package/index.ts +10 -0
  3. package/launcher.mjs +10 -11
  4. package/package.json +19 -5
  5. package/plugins/local-cache/index.ts +2 -0
  6. package/plugins/local-executor/index.ts +2 -0
  7. package/plugins/schedule-history/index.ts +2 -0
  8. package/src/bin.ts +2 -2
  9. package/src/cache/archive.ts +557 -0
  10. package/src/cache/cache.ts +929 -250
  11. package/src/cache/cas-backend.ts +20 -2
  12. package/src/cache/chained-cache.ts +202 -0
  13. package/src/cache/digest.ts +0 -15
  14. package/src/cache/index.ts +13 -3
  15. package/src/cache/inputs.ts +445 -41
  16. package/src/cache/layered-cache.ts +129 -30
  17. package/src/cache/tar-stream.ts +358 -0
  18. package/src/cli/cache.ts +36 -10
  19. package/src/cli/help.ts +167 -128
  20. package/src/cli/index.ts +74 -43
  21. package/src/cli/info.ts +30 -0
  22. package/src/cli/last.ts +140 -0
  23. package/src/cli/migrate-emit.ts +21 -0
  24. package/src/cli/migrate-nx.ts +23 -0
  25. package/src/cli/migrate-scripts.ts +127 -0
  26. package/src/cli/migrate-turbo.ts +43 -6
  27. package/src/cli/migrate.ts +119 -33
  28. package/src/cli/plan-format.ts +58 -3
  29. package/src/cli/plugin-commands.ts +96 -0
  30. package/src/cli/prune.ts +291 -0
  31. package/src/cli/run.ts +272 -80
  32. package/src/cli/upgrade.ts +7 -6
  33. package/src/cli/watch.ts +259 -61
  34. package/src/cli/why.ts +193 -0
  35. package/src/config.ts +40 -23
  36. package/src/exec/env.ts +7 -1
  37. package/src/exec/executor.ts +239 -0
  38. package/src/exec/index.ts +19 -1
  39. package/src/exec/runner.ts +116 -29
  40. package/src/exec/sandbox-runtime.ts +194 -24
  41. package/src/graph/index.ts +3 -1
  42. package/src/graph/scheduler.ts +92 -18
  43. package/src/graph/task-graph.ts +220 -6
  44. package/src/index.ts +82 -84
  45. package/src/orchestrator/deferred-outputs.ts +144 -0
  46. package/src/orchestrator/download-policy.ts +171 -0
  47. package/src/orchestrator/events.ts +30 -0
  48. package/src/orchestrator/execute-task.ts +328 -94
  49. package/src/orchestrator/failure-mode.ts +61 -0
  50. package/src/orchestrator/framed-output.ts +87 -35
  51. package/src/orchestrator/history.ts +78 -31
  52. package/src/orchestrator/index.ts +43 -90
  53. package/src/orchestrator/local-shortcircuit.ts +22 -0
  54. package/src/orchestrator/logger.ts +196 -14
  55. package/src/orchestrator/metrics.ts +57 -1376
  56. package/src/orchestrator/missing-plugin.ts +9 -0
  57. package/src/orchestrator/options.ts +22 -4
  58. package/src/orchestrator/plan.ts +144 -1
  59. package/src/orchestrator/plugin-host.ts +199 -119
  60. package/src/orchestrator/plugin.ts +144 -57
  61. package/src/orchestrator/prepare.ts +220 -69
  62. package/src/orchestrator/remote-prefetch.ts +27 -8
  63. package/src/orchestrator/resources.ts +20 -34
  64. package/src/orchestrator/run-artifacts.ts +41 -16
  65. package/src/orchestrator/run-context.ts +167 -29
  66. package/src/orchestrator/run-report.ts +73 -46
  67. package/src/orchestrator/run.ts +590 -122
  68. package/src/orchestrator/stable-keys.ts +74 -33
  69. package/src/orchestrator/status-line.ts +47 -8
  70. package/src/orchestrator/summary.ts +22 -1
  71. package/src/orchestrator/tally.ts +78 -35
  72. package/src/orchestrator/task-hash.ts +105 -11
  73. package/src/orchestrator/task-log-buffer.ts +291 -0
  74. package/src/orchestrator/telemetry-host.ts +24 -6
  75. package/src/orchestrator/telemetry.ts +181 -21
  76. package/src/orchestrator/upstream.ts +72 -31
  77. package/src/orchestrator/verify.ts +12 -2
  78. package/src/plugins/local-cache/index.ts +11 -0
  79. package/src/plugins/local-executor/index.ts +45 -0
  80. package/src/plugins/schedule-history/index.ts +109 -0
  81. package/src/util/edit-distance.ts +21 -0
  82. package/src/util/errors.ts +15 -1
  83. package/src/util/index.ts +7 -2
  84. package/src/util/num.ts +37 -0
  85. package/src/util/paths.ts +43 -0
  86. package/src/util/settle.ts +48 -0
  87. package/src/util/size.ts +5 -1
  88. package/src/util/tail.ts +66 -0
  89. package/src/util/timing.ts +63 -0
  90. package/src/workspace/affected.ts +142 -15
  91. package/src/workspace/config-cache.ts +304 -0
  92. package/src/workspace/config-eval.ts +210 -0
  93. package/src/workspace/config-imports.ts +161 -0
  94. package/src/workspace/filter.ts +22 -4
  95. package/src/workspace/fingerprint.ts +16 -1
  96. package/src/workspace/index.ts +15 -3
  97. package/src/workspace/lockfile.ts +13 -2
  98. package/src/workspace/nested-dirs.ts +21 -7
  99. package/src/workspace/package-graph.ts +23 -20
  100. package/src/workspace/project-loader.ts +503 -59
  101. package/src/workspace/workspace.ts +213 -72
  102. package/src/cache/tar.ts +0 -343
  103. package/src/cli/backend.ts +0 -23
  104. package/src/cli/mcp-rpc.ts +0 -292
  105. package/src/cli/mcp.ts +0 -92
  106. package/src/orchestrator/devframe-surface.ts +0 -74
  107. package/src/orchestrator/predict.ts +0 -106
  108. package/src/orchestrator/protocol.ts +0 -156
  109. package/src/orchestrator/run-state.ts +0 -145
  110. package/src/orchestrator/wire-render.ts +0 -92
  111. 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
+ }
@@ -1,6 +1,6 @@
1
- // Filter DSL — pnpm-style selectors for `-F / --filter`.
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 glob = new Bun.Glob(filter.matcher)
110
+ const re = compileNameGlob(filter.matcher)
111
111
  for (const p of projects) {
112
- if (glob.match(p.name)) out.push(p.name)
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
- const WORKSPACE_FINGERPRINT_FILES = [
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
@@ -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 { loadProjectConfig, loadWorkspaceConfig } from './project-loader.js'
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
- if (typeof parsed !== 'object' || parsed === null) {
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
- if (typeof lock.projects !== 'object' || lock.projects === null) {
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: nested projects of `p` form a contiguous run
11
- * immediately after `p` in the sorted order (any non-descendant breaks
12
- * the prefix-match). O(P log P) instead of the naive O(P²) cross
13
- * product. Stable for any project set with no dir collisions, which
14
- * the workspace loader already enforces.
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 (!other.dir.startsWith(prefix)) break
29
- nested.push(other.dir)
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
- // the legacy DFS — byte-identical behavior for that rare case.
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
- function legacyTransitive(
96
- name: string,
97
- edges: Map<string, string[]>,
98
- cache: Map<string, string[]>,
99
- stack: Set<string> = new Set(),
100
- ): string[] {
101
- const cached = cache.get(name)
102
- if (cached) return cached
103
- if (stack.has(name)) return []
104
- stack.add(name)
105
- const out = new Set<string>()
106
- for (const d of edges.get(name) ?? []) {
107
- out.add(d)
108
- for (const t of legacyTransitive(d, edges, cache, stack)) out.add(t)
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
- stack.delete(name)
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) => legacyTransitive(name, edges, memo)
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) => {