@vzn/vx 0.0.100 → 0.0.101

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 CHANGED
@@ -96,15 +96,15 @@ runs on every commit. More: [Quickstart](https://vznjs.github.io/vx/quickstart/)
96
96
  [CLI](packages/vx/docs/cli.md) · [every config field](packages/vx/docs/schema.md) ·
97
97
  [playground](https://vznjs.github.io/vx/playground/) (the planner, in your browser).
98
98
 
99
- ## Already on Turborepo or Nx?
99
+ ## Already on Turborepo, Nx or moon?
100
100
 
101
- Keep your `turbo.json` or Nx project graph. Two lines run it under vx,
102
- with nothing rewritten:
101
+ Keep your `turbo.json`, Nx project graph or `.moon/`. Two lines run it
102
+ under vx, with nothing rewritten:
103
103
 
104
104
  ```ts
105
105
  // vx.workspace.ts
106
106
  import { defineWorkspace } from '@vzn/vx'
107
- import { turbo } from '@vzn/vx-migrate' // or: nx
107
+ import { turbo } from '@vzn/vx-migrate' // or: nx, moon
108
108
 
109
109
  export default defineWorkspace({ plugins: [turbo()] })
110
110
  ```
@@ -112,7 +112,7 @@ export default defineWorkspace({ plugins: [turbo()] })
112
112
  When you want configs of your own, one command writes them:
113
113
 
114
114
  ```sh
115
- bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json or an Nx graph
115
+ bunx @vzn/vx-migrate # vx.config.ts per package, from turbo.json, an Nx graph or .moon/
116
116
  ```
117
117
 
118
118
  The written configs derive the same cache keys, so what `turbo()` cached
@@ -170,15 +170,15 @@ when another tool is the better pick:
170
170
  Declared in `vx.workspace.ts`. Without one, vx runs and caches on this
171
171
  machine.
172
172
 
173
- | Package | What it adds |
174
- | ---------------------------------------------------------- | ----------------------------------------------------------------------------- |
175
- | [`@vzn/vx-migrate`](packages/vx-migrate) | Run a Turbo or Nx repo unchanged; write configs; keep a Turbo or Nx cache |
176
- | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
177
- | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
178
- | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
179
- | [`@vzn/vx-github`](packages/vx-github) | GitHub Actions job summary and Checks API |
180
- | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
181
- | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
173
+ | Package | What it adds |
174
+ | ---------------------------------------------------------- | ------------------------------------------------------------------------------- |
175
+ | [`@vzn/vx-migrate`](packages/vx-migrate) | Run a Turbo, Nx or moon repo unchanged; write configs; keep a Turbo or Nx cache |
176
+ | [`@vzn/vx-reapi`](packages/vx-reapi) | Remote cache and remote execution over Bazel's REAPI (NativeLink, BuildBuddy) |
177
+ | [`@vzn/vx-lockfile`](packages/vx-lockfile) | Key each project on its own lockfile slice: pnpm, bun, npm, yarn |
178
+ | [`@vzn/vx-otel`](packages/vx-otel) | OpenTelemetry traces, metrics and logs, no SDK |
179
+ | [`@vzn/vx-github`](packages/vx-github) | GitHub Actions job summary and Checks API |
180
+ | [`@vzn/vx-mcp`](packages/vx-mcp) | `vx mcp`: a read-only MCP server for AI coding agents |
181
+ | [`@vzn/vx-schedule-history`](packages/vx-schedule-history) | Schedule by the critical path learned from past runs |
182
182
 
183
183
  Write your own: [plugin guide](https://vznjs.github.io/vx/guides/plugins/) ·
184
184
  [architecture](packages/vx/docs/architecture.md).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx",
3
- "version": "0.0.100",
3
+ "version": "0.0.101",
4
4
  "description": "A fast, correct task runner and build cache for JavaScript monorepos.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -17,10 +17,10 @@
17
17
  "node": ">=18"
18
18
  },
19
19
  "optionalDependencies": {
20
- "@vzn/vx-linux-x64": "0.0.100",
21
- "@vzn/vx-linux-arm64": "0.0.100",
22
- "@vzn/vx-darwin-x64": "0.0.100",
23
- "@vzn/vx-darwin-arm64": "0.0.100"
20
+ "@vzn/vx-linux-x64": "0.0.101",
21
+ "@vzn/vx-linux-arm64": "0.0.101",
22
+ "@vzn/vx-darwin-x64": "0.0.101",
23
+ "@vzn/vx-darwin-arm64": "0.0.101"
24
24
  },
25
25
  "dependencies": {
26
26
  "@anthropic-ai/sandbox-runtime": "^0.0.76"
package/src/cli/cache.ts CHANGED
@@ -74,7 +74,7 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
74
74
  return { error: `--older-than requires a value (e.g. 30d, 24h, 60m)` }
75
75
  }
76
76
  const ms = parseDuration(v)
77
- if (ms === null) return { error: `invalid duration: ${v}` }
77
+ if (ms === null) return { error: `invalid duration: ${v} (e.g. 30d, 24h, 60m)` }
78
78
  // `0d` evicts everything ever cached. That is far more often a
79
79
  // computed-to-zero retention than an intent, and no other flag
80
80
  // combination expresses "wipe the cache" — so refuse it and name
@@ -91,7 +91,7 @@ export function parsePruneArgs(args: readonly string[]): PruneArgs {
91
91
  return { error: `--max-size requires a value (e.g. 500M, 1G)` }
92
92
  }
93
93
  const bytes = parseSize(v)
94
- if (bytes === null) return { error: `invalid size: ${v}` }
94
+ if (bytes === null) return { error: `invalid size: ${v} (e.g. 500M, 1G)` }
95
95
  if (bytes === 0) {
96
96
  return {
97
97
  error: `--max-size 0 would evict every entry — delete the cache directory instead`,
package/src/cli/help.ts CHANGED
@@ -44,7 +44,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
44
44
  ' vx watch [OPTIONS] TASK [-- forwarded-args...]',
45
45
  ' vx cache prune [--older-than <duration>] [--max-size <size>] [--dry-run] [--cache-dir <path>]',
46
46
  ' vx lock [--check]',
47
- ' vx init [--dry] [--force] [--mjs]',
47
+ ' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
48
48
  ' vx upgrade [tag]',
49
49
  ' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
50
50
  ' vx info [--format pretty|json] [--cache-dir <path>]',
@@ -158,6 +158,9 @@ export function helpText(pluginCommands: readonly string[] = []): string {
158
158
  ' TODO(vx-migrate) comments.',
159
159
  ' --dry Print the generated files instead of writing them.',
160
160
  ' --force Overwrite existing vx.config.* files.',
161
+ ' --plugin <seam> Instead: write plugins/<seam>.ts, a runnable plugin, and its',
162
+ ' test (executor, cache, telemetry, schedule, admit, commands,',
163
+ ' project, graph, key).',
161
164
  ' From turbo.json or an Nx project graph: `bunx @vzn/vx-migrate` (its own package).',
162
165
  '',
163
166
  'Shell completions:',
package/src/cli/init.ts CHANGED
@@ -2,9 +2,13 @@
2
2
  // per package from its package.json scripts, and the workspace file every
3
3
  // run needs. Turbo and Nx are not read here; a runner's own config beside
4
4
  // the scripts is the richer source, and `@vzn/vx-migrate` maps it.
5
+ // `vx init --plugin <seam>` writes a runnable plugin for one seam and its
6
+ // test instead (plugin-templates.ts, the examples the gate runs).
5
7
 
8
+ import { mkdir } from 'node:fs/promises'
6
9
  import path from 'node:path'
7
10
  import { flagHint, seeHelp } from './help.js'
11
+ import { PLUGIN_TEMPLATES } from './plugin-templates.js'
8
12
  import { isUserError, UserError } from '../util/index.js'
9
13
  import {
10
14
  applyMigration,
@@ -20,18 +24,31 @@ export interface InitArgs {
20
24
  force: boolean
21
25
  /** `vx.config.mjs` instead of `.ts` — see `ApplyMigrationArgs.format`. */
22
26
  mjs: boolean
27
+ /** `--plugin <seam>`: scaffold that seam's plugin instead of the workspace. */
28
+ plugin?: string
23
29
  error?: string
24
30
  }
25
31
 
26
32
  export function parseInitArgs(args: readonly string[]): InitArgs {
27
33
  const out: InitArgs = { dry: false, force: false, mjs: false }
28
- for (const a of args) {
29
- if (a === '--dry') out.dry = true
34
+ for (let i = 0; i < args.length; i++) {
35
+ const a = args[i]!
36
+ if (a === '--plugin' || a.startsWith('--plugin=')) {
37
+ const seam = a === '--plugin' ? args[++i] : a.slice('--plugin='.length)
38
+ const seams = Object.keys(PLUGIN_TEMPLATES).join(', ')
39
+ if (seam === undefined || !Object.hasOwn(PLUGIN_TEMPLATES, seam)) {
40
+ return {
41
+ ...out,
42
+ error: `--plugin takes a seam: one of ${seams}${seam === undefined ? '' : ` (got '${seam}')`}`,
43
+ }
44
+ }
45
+ out.plugin = seam
46
+ } else if (a === '--dry') out.dry = true
30
47
  else if (a === '--force') out.force = true
31
48
  else if (a === '--mjs') out.mjs = true
32
49
  else if (a.startsWith('-'))
33
50
  return { ...out, error: `unknown flag: ${a}${flagHint('init', a)}${seeHelp('init')}` }
34
- else return { ...out, error: `unexpected argument: ${a}` }
51
+ else return { ...out, error: `unexpected argument: ${a}${seeHelp('init')}` }
35
52
  }
36
53
  return out
37
54
  }
@@ -42,6 +59,7 @@ export async function initCmd(args: readonly string[]): Promise<number> {
42
59
  process.stderr.write(`vx init: ${parsed.error}\n`)
43
60
  return 1
44
61
  }
62
+ if (parsed.plugin !== undefined) return scaffoldPlugin(parsed.plugin, parsed)
45
63
  const reads: LoadReads = new Map()
46
64
  // "a workspace from nowhere" still needs a package.json to start from,
47
65
  // and the lookup's own refusal named no next step.
@@ -96,3 +114,32 @@ export async function initCmd(args: readonly string[]): Promise<number> {
96
114
  format: parsed.mjs ? 'mjs' : 'ts',
97
115
  })
98
116
  }
117
+
118
+ /** Write `plugins/<seam>.ts` and its test under the workspace root (the cwd when there is none). */
119
+ async function scaffoldPlugin(seam: string, args: InitArgs): Promise<number> {
120
+ const root = await findWorkspaceRoot(process.cwd(), new Map()).catch(() => process.cwd())
121
+ const template = PLUGIN_TEMPLATES[seam]!
122
+ const files = [
123
+ [path.join('plugins', `${seam}.ts`), template.plugin],
124
+ [path.join('plugins', `${seam}.test.ts`), template.test],
125
+ ] as const
126
+ if (!args.force) {
127
+ for (const [rel] of files) {
128
+ if (await Bun.file(path.join(root, rel)).exists()) {
129
+ process.stderr.write(`vx init: ${rel} exists; --force overwrites it\n`)
130
+ return 1
131
+ }
132
+ }
133
+ }
134
+ if (!args.dry) {
135
+ await mkdir(path.join(root, 'plugins'), { recursive: true })
136
+ for (const [rel, text] of files) await Bun.write(path.join(root, rel), text)
137
+ }
138
+ const factory = /^export function (\w+)/m.exec(template.plugin)![1]!
139
+ process.stdout.write(
140
+ `${args.dry ? 'would write' : 'wrote'} ${files.map(([rel]) => rel).join(' and ')}\n` +
141
+ `Declare it in vx.workspace.ts: import { ${factory} } from './plugins/${seam}.ts', then plugins: [${factory}(…)]\n` +
142
+ `Test it: bun test plugins/${seam}.test.ts (needs @vzn/vx installed)\n`,
143
+ )
144
+ return 0
145
+ }
package/src/cli/last.ts CHANGED
@@ -65,7 +65,8 @@ export function parseLastArgs(args: readonly string[]): LastArgs {
65
65
  }
66
66
  if (a.startsWith('-'))
67
67
  return { ...out, error: `unknown flag: ${a}${flagHint('last', a)}${seeHelp('last')}` }
68
- if (out.runId !== undefined) return { ...out, error: `unexpected argument: ${a}` }
68
+ if (out.runId !== undefined)
69
+ return { ...out, error: `unexpected argument: ${a}${seeHelp('last')}` }
69
70
  out.runId = a
70
71
  }
71
72
  // One run's replay or a list of runs, not both: the id was dropped.
@@ -0,0 +1,51 @@
1
+ // Generated from packages/vx-plugin-examples/plugins by
2
+ // tests/plugin-templates.unsafe.test.ts; edit the examples, then
3
+ // VX_UPDATE_CONTRACT=1 that test. `vx init --plugin <seam>` writes these.
4
+
5
+ export const PLUGIN_TEMPLATES: Readonly<Record<string, { plugin: string; test: string }>> = {
6
+ admit: {
7
+ plugin:
8
+ "// `admit`: whether a ready task starts now, beside what runs here. At most\n// one task of the named `task` runs at a time (say, one that holds a port).\n// Synchronous and cheap: it is asked at every dispatch.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function oneAtATime(task: string): VxPlugin {\n const named = (id: string): boolean => id.endsWith(`#${task}`)\n return definePlugin(import.meta, {\n admit(node, ctx) {\n return !named(node.id) || !ctx.running.some((r) => named(r.id))\n },\n })\n}\n",
9
+ test: "// A test for the example `admit` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-admit-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `oneAtATime()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'admit.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { oneAtATime } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('two tasks of the named task never overlap, even with two workers', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { serve: { exec: { command: 'sleep 0.2' } } } }`)\n }\n await workspace(`oneAtATime('serve')`)\n const order: string[] = []\n await run({ cwd: root, tasks: ['serve'], log: log(order), handleSignals: false, concurrency: 2 })\n // Each start is followed by its own end: no second start in between.\n expect(order).toHaveLength(4)\n expect(order[1]!.replace('end', 'start')).toBe(order[0]!)\n expect(order[3]!.replace('end', 'start')).toBe(order[2]!)\n}, 20_000)\n",
10
+ },
11
+ cache: {
12
+ plugin:
13
+ "// `cache`: where artifacts live. A directory stands in for a remote store;\n// `LayeredCache` puts it behind the local cache, so a remote error is a\n// miss and a warning, never a failed run.\nimport { mkdir, rename } from 'node:fs/promises'\nimport path from 'node:path'\nimport { definePlugin, LayeredCache, type RemoteCacheLayer, type VxPlugin } from '@vzn/vx'\n\nclass DirRemote implements RemoteCacheLayer {\n constructor(readonly endpoint: string) {}\n async has(hash: string): Promise<boolean> {\n return Bun.file(path.join(this.endpoint, hash)).exists()\n }\n async get(hash: string) {\n const file = Bun.file(path.join(this.endpoint, hash))\n return (await file.exists()) ? { body: file, durationMs: undefined } : null\n }\n async put(hash: string, body: Blob): Promise<void> {\n await mkdir(this.endpoint, { recursive: true })\n // Written aside, then renamed: a reader never sees half an artifact.\n const tmp = path.join(this.endpoint, `.${hash}.${process.pid}`)\n await Bun.write(tmp, body)\n await rename(tmp, path.join(this.endpoint, hash))\n }\n}\n\nexport function dirCache(dir: string): VxPlugin {\n return definePlugin(import.meta, {\n cache(ctx) {\n return new LayeredCache(ctx.localCache, new DirRemote(dir), {\n policy: ctx.policy,\n onRemoteError: (err) => ctx.warn(`dir-cache: ${err.message}`),\n })\n },\n })\n}\n",
14
+ test: "// A test for the example `cache` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-cache-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `dirCache()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'cache.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { dirCache } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a run on an empty local cache restores from the directory', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'mkdir -p dist && echo built > dist/out.txt' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: ['dist/**'] } },\n } } }`,\n )\n await workspace(`dirCache(${JSON.stringify(path.join(root, 'remote'))})`)\n const first = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(first.outcomes.map((o) => o.status)).toEqual(['success'])\n await rm(path.join(root, '.vx'), { recursive: true, force: true })\n await rm(path.join(dir, 'dist'), { recursive: true, force: true })\n const second = await run({ cwd: root, tasks: ['build'], log: log(), handleSignals: false })\n expect(second.outcomes.map((o) => o.status)).toEqual(['cache-hit-remote'])\n expect(await Bun.file(path.join(dir, 'dist', 'out.txt')).text()).toBe('built\\n')\n}, 20_000)\n",
15
+ },
16
+ commands: {
17
+ plugin:
18
+ "// `commands`: a CLI verb. `vx cache-dir` prints where this workspace's cache\n// lives. The verb's return value is the exit code.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function cacheDirVerb(): VxPlugin {\n return definePlugin(import.meta, {\n commands: {\n 'cache-dir': {\n description: \"print this workspace's cache directory\",\n run(argv, ctx) {\n if (argv.length > 0) {\n console.error(`vx cache-dir: takes no arguments, got ${argv.join(' ')}`)\n return 2\n }\n console.log(ctx.cacheDir)\n return 0\n },\n },\n },\n })\n}\n",
19
+ test: "// A test for the example `commands` plugin beside it: the verb is called\n// the way vx calls it, with the context vx hands it. `bun test` runs it.\nimport { expect, it, spyOn } from 'bun:test'\nimport { cacheDirVerb } from './commands.ts'\n\nconst verb = cacheDirVerb().commands!['cache-dir']!\nconst ctx = { workspaceRoot: '/ws', cacheDir: '/ws/.vx/cache', warn() {}, concurrency: 1 }\n\nit('prints the cache directory; an argument is exit 2', async () => {\n const out = spyOn(console, 'log').mockImplementation(() => {})\n const err = spyOn(console, 'error').mockImplementation(() => {})\n try {\n expect(await verb.run([], ctx)).toBe(0)\n expect(out.mock.calls).toEqual([['/ws/.vx/cache']])\n expect(await verb.run(['x'], ctx)).toBe(2)\n expect(err.mock.calls).toEqual([['vx cache-dir: takes no arguments, got x']])\n } finally {\n out.mockRestore()\n err.mockRestore()\n }\n})\n",
20
+ },
21
+ executor: {
22
+ plugin:
23
+ "// `executor`: where a task's command runs. This one runs it here, in a\n// shell of its own, and stops the shell's whole process group when core\n// aborts the request (a stop, or `exec.timeout`): core cannot reach a\n// process an executor spawned.\nimport { definePlugin, type TaskExecutor, type VxPlugin } from '@vzn/vx'\n\nexport function shellExecutor(): VxPlugin {\n const executor: TaskExecutor = {\n name: 'shell',\n async execute(req) {\n const started = Date.now()\n const child = Bun.spawn(['sh', '-c', [req.command, ...req.forwardArgs].join(' ')], {\n cwd: req.cwd,\n env: req.env,\n stdout: 'pipe',\n stderr: 'pipe',\n detached: true,\n })\n const stop = (): void => {\n try {\n process.kill(-child.pid, 'SIGTERM')\n } catch {\n // already gone\n }\n }\n req.signal?.addEventListener('abort', stop, { once: true })\n const drain = async (stream: ReadableStream<Uint8Array>, on: (s: string) => void) => {\n const decoder = new TextDecoder()\n let all = ''\n for await (const chunk of stream) {\n const text = decoder.decode(chunk, { stream: true })\n all += text\n on(text)\n }\n return all\n }\n const [stdout, stderr, exitCode] = await Promise.all([\n drain(child.stdout, req.onStdout),\n drain(child.stderr, req.onStderr),\n child.exited,\n ])\n req.signal?.removeEventListener('abort', stop)\n return {\n exitCode,\n durationMs: Date.now() - started,\n stdout,\n stderr,\n violations: [],\n where: 'shell',\n }\n },\n }\n return definePlugin(import.meta, { executor: () => executor })\n}\n",
24
+ test: "// A test for the example `executor` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-executor-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `shellExecutor()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'executor.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { shellExecutor } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('runs the command and says where; stops it on exec.timeout', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: {\n hello: { exec: { command: 'echo hi > out.txt' } },\n slow: { exec: { command: 'exec sleep 30', timeout: 300 } },\n } }`,\n )\n await workspace('shellExecutor()')\n const ok = await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(ok.outcomes.map((o) => [o.node.id, o.status, o.where])).toEqual([\n ['a#hello', 'success', 'shell'],\n ])\n expect(await Bun.file(path.join(dir, 'out.txt')).text()).toBe('hi\\n')\n const started = Date.now()\n const slow = await run({ cwd: root, tasks: ['slow'], log: log(), handleSignals: false })\n expect(slow.outcomes.map((o) => [o.node.id, o.status, o.timedOut === true])).toEqual([\n ['a#slow', 'failed', true],\n ])\n expect(Date.now() - started).toBeLessThan(10_000)\n}, 20_000)\n",
25
+ },
26
+ graph: {
27
+ plugin:
28
+ "// `graph`: the run's edges. Every task named `task` waits for the one\n// before it in id order, so they run one after another across projects.\n// Core checks the edited graph again (a missing dep, a cycle).\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function chain(task: string): VxPlugin {\n return definePlugin(import.meta, {\n graph(nodes) {\n const ids = [...nodes.keys()].filter((id) => id.endsWith(`#${task}`)).sort()\n for (const [i, id] of ids.entries()) {\n const prev = ids[i - 1]\n const node = nodes.get(id)!\n if (prev !== undefined && !node.deps.includes(prev)) node.deps.push(prev)\n }\n },\n })\n}\n",
29
+ test: "// A test for the example `graph` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-graph-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `chain()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'graph.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { chain } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('each named task waits on the one before it in id order', async () => {\n for (const name of ['a', 'b', 'c']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n await workspace(`chain('build')`)\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n expect(plan.tasks.map((t) => `${t.node.id} <- ${t.deps.join(',')}`).sort()).toEqual([\n 'a#build <- ',\n 'b#build <- a#build',\n 'c#build <- b#build',\n ])\n}, 20_000)\n",
30
+ },
31
+ key: {
32
+ plugin:
33
+ "// `key`: extra cache-key material. The value of each named environment\n// variable is folded into every cached task's key and named in `vx why`,\n// so a change to it is a miss. An unset variable folds as ''.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function envKey(names: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n key() {\n return Object.fromEntries(names.map((n) => [n, process.env[n] ?? '']))\n },\n })\n}\n",
34
+ test: "// A test for the example `key` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { planRun, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-key-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `envKey()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'key.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { envKey } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit(\"the variable's value moves the key; the same value keeps it\", async () => {\n await pkg(\n 'a',\n `export default { tasks: { build: {\n exec: { command: 'true' },\n cache: { inputs: { files: ['package.json'] }, outputs: { files: [] } },\n } } }`,\n )\n await workspace(`envKey(['EXAMPLE_FLAG'])`)\n const hash = async (value: string): Promise<string | undefined> => {\n process.env['EXAMPLE_FLAG'] = value\n try {\n const plan = await planRun({ cwd: root, tasks: ['build'], log: log() })\n return plan.tasks[0]!.hash\n } finally {\n delete process.env['EXAMPLE_FLAG']\n }\n }\n const one = await hash('1')\n expect(one).toBeDefined()\n expect(await hash('1')).toBe(one)\n expect(await hash('2')).not.toBe(one)\n}, 20_000)\n",
35
+ },
36
+ project: {
37
+ plugin:
38
+ "// `project`: a project's tasks. Every package.json script becomes a task of\n// the same name that runs it, unless the project already declares one.\n// Core re-validates each edit as it would the user's own config.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function scriptTasks(): VxPlugin {\n return definePlugin(import.meta, {\n project(config, ctx) {\n const scripts = ctx.packageJson['scripts']\n if (scripts === null || typeof scripts !== 'object') return\n config.tasks ??= {}\n for (const [name, command] of Object.entries(scripts)) {\n if (typeof command !== 'string' || config.tasks[name] !== undefined) continue\n config.tasks[name] = { exec: { command } }\n }\n },\n })\n}\n",
39
+ test: "// A test for the example `project` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-project-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `scriptTasks()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'project.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { scriptTasks } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('a package.json script runs as a task; a declared task wins', async () => {\n const dir = await pkg(\n 'a',\n `export default { tasks: { build: { exec: { command: 'echo declared > build.txt' } } } }`,\n { hello: 'echo hi > hello.txt', build: 'echo script > build.txt' },\n )\n await workspace('scriptTasks()')\n const r = await run({ cwd: root, tasks: ['hello', 'build'], log: log(), handleSignals: false })\n expect(r.outcomes.map((o) => `${o.node.id} ${o.status}`).sort()).toEqual([\n 'a#build success',\n 'a#hello success',\n ])\n expect(await Bun.file(path.join(dir, 'hello.txt')).text()).toBe('hi\\n')\n expect(await Bun.file(path.join(dir, 'build.txt')).text()).toBe('declared\\n')\n}, 20_000)\n",
40
+ },
41
+ schedule: {
42
+ plugin:
43
+ "// `schedule`: which ready task runs first. Tasks named here outrank the rest;\n// earlier in the list, higher.\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function prioritize(ids: readonly string[]): VxPlugin {\n return definePlugin(import.meta, {\n schedule(nodes) {\n const weights = new Map<string, number>()\n for (const [i, id] of ids.entries()) if (nodes.has(id)) weights.set(id, 1e6 - i)\n return weights\n },\n })\n}\n",
44
+ test: "// A test for the example `schedule` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-schedule-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `prioritize()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'schedule.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { prioritize } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('the named task starts first on one worker, whichever it is', async () => {\n for (const name of ['a', 'b']) {\n await pkg(name, `export default { tasks: { build: { exec: { command: 'true' } } } }`)\n }\n const first = async (id: string): Promise<string | undefined> => {\n await workspace(`prioritize([${JSON.stringify(id)}])`)\n const order: string[] = []\n await run({\n cwd: root,\n tasks: ['build'],\n log: log(order),\n handleSignals: false,\n concurrency: 1,\n })\n return order[0]\n }\n expect(await first('b#build')).toBe('start b#build')\n expect(await first('a#build')).toBe('start a#build')\n}, 20_000)\n",
45
+ },
46
+ telemetry: {
47
+ plugin:
48
+ "// `telemetry`: who observes. One JSON line per run, appended to a file.\n// Observability never breaks a run: a sink that throws is switched off,\n// and `flush` is where slow I/O goes (core bounds it).\nimport { appendFile } from 'node:fs/promises'\nimport { definePlugin, type VxPlugin } from '@vzn/vx'\n\nexport function jsonlTelemetry(file: string): VxPlugin {\n return definePlugin(import.meta, {\n telemetry() {\n const lines: string[] = []\n return {\n onRunSummary(s) {\n lines.push(\n JSON.stringify({ tasks: s.taskCount, failed: s.failedCount, cached: s.hitCount }),\n )\n },\n async flush() {\n if (lines.length > 0) await appendFile(file, lines.splice(0).join('\\n') + '\\n')\n },\n }\n },\n })\n}\n",
49
+ test: "// A test for the example `telemetry` plugin beside it: a throwaway workspace\n// declares the plugin and vx's own run() drives it. `bun test` runs it.\nimport { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'\nimport os from 'node:os'\nimport path from 'node:path'\nimport { afterEach, beforeEach, expect, it } from 'bun:test'\nimport { run, type Logger } from '@vzn/vx'\n\nlet root: string\nbeforeEach(async () => {\n root = await mkdtemp(path.join(os.tmpdir(), 'vx-plugin-telemetry-'))\n await writeFile(path.join(root, 'package.json'), JSON.stringify({ name: 'ws', private: true }))\n await writeFile(path.join(root, 'pnpm-workspace.yaml'), 'packages:\\n - \"packages/*\"\\n')\n Bun.spawnSync({ cmd: ['git', 'init', '-q'], cwd: root })\n})\nafterEach(async () => {\n await rm(root, { recursive: true, force: true })\n})\n\n/** A package `name` whose `vx.config.mjs` is `config`. */\nasync function pkg(\n name: string,\n config: string,\n scripts?: Record<string, string>,\n): Promise<string> {\n const dir = path.join(root, 'packages', name)\n await mkdir(dir, { recursive: true })\n await writeFile(\n path.join(dir, 'package.json'),\n JSON.stringify({ name, version: '1.0.0', ...(scripts ? { scripts } : {}) }),\n )\n await writeFile(path.join(dir, 'vx.config.mjs'), config)\n return dir\n}\n\n/** A `vx.workspace.mjs` declaring `call`, e.g. `jsonlTelemetry()`, from the plugin file beside this test. */\nasync function workspace(call: string): Promise<void> {\n const from = JSON.stringify(path.join(import.meta.dir, 'telemetry.ts'))\n await writeFile(\n path.join(root, 'vx.workspace.mjs'),\n `import { jsonlTelemetry } from ${from}\\nexport default { plugins: [${call}] }\\n`,\n )\n}\n\nfunction log(order: string[] = []): Logger {\n return {\n status() {},\n taskStart(node) {\n order.push(`start ${node.id}`)\n },\n taskStdout() {},\n taskStderr() {},\n taskComplete(node) {\n order.push(`end ${node.id}`)\n },\n }\n}\n\nit('appends one line per run', async () => {\n await pkg('a', `export default { tasks: { hello: { exec: { command: 'true' } } } }`)\n const file = path.join(root, 'runs.jsonl')\n await workspace(`jsonlTelemetry(${JSON.stringify(file)})`)\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n await run({ cwd: root, tasks: ['hello'], log: log(), handleSignals: false })\n expect(await Bun.file(file).text()).toBe(\n '{\"tasks\":1,\"failed\":0,\"cached\":0}\\n{\"tasks\":1,\"failed\":0,\"cached\":0}\\n',\n )\n}, 20_000)\n",
50
+ },
51
+ }
package/src/cli/run.ts CHANGED
@@ -150,11 +150,19 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
150
150
  const a = before[i]
151
151
  if (a === '--filter' || a?.startsWith('--filter=')) {
152
152
  const v = a === '--filter' ? before[++i] : a.slice('--filter='.length)
153
- if (v === undefined || v === '') return { ...out, error: `--filter requires a value` }
153
+ if (v === undefined || v === '')
154
+ return {
155
+ ...out,
156
+ error: `--filter requires a value (a project name, glob or path, e.g. --filter app)`,
157
+ }
154
158
  out.filters.push(v)
155
159
  } else if (a === '--concurrency' || a?.startsWith('--concurrency=')) {
156
160
  const v = a === '--concurrency' ? before[++i] : a.slice('--concurrency='.length)
157
- if (v === undefined) return { ...out, error: `--concurrency requires a value` }
161
+ if (v === undefined)
162
+ return {
163
+ ...out,
164
+ error: `--concurrency requires a value (a positive integer, or a share of the cores such as 50%)`,
165
+ }
158
166
  const n = parseConcurrency(v)
159
167
  if (n === null)
160
168
  return {
@@ -193,7 +201,8 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
193
201
  out.frozen = true
194
202
  } else if (a === '--retry' || a?.startsWith('--retry=')) {
195
203
  const v = a === '--retry' ? before[++i] : a.slice('--retry='.length)
196
- if (v === undefined) return { ...out, error: `--retry requires a value` }
204
+ if (v === undefined)
205
+ return { ...out, error: `--retry requires a value (a non-negative integer)` }
197
206
  const n = parseDecimalInt(v)
198
207
  if (n === null) {
199
208
  return { ...out, error: `--retry must be a non-negative integer, got: ${v}` }
@@ -201,7 +210,8 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
201
210
  out.retries = n
202
211
  } else if (a === '--timeout' || a?.startsWith('--timeout=')) {
203
212
  const v = a === '--timeout' ? before[++i] : a.slice('--timeout='.length)
204
- if (v === undefined) return { ...out, error: `--timeout requires a value` }
213
+ if (v === undefined)
214
+ return { ...out, error: `--timeout requires a value (a positive integer, in ms)` }
205
215
  const n = parseDecimalInt(v)
206
216
  if (n !== null && n > MAX_TIMEOUT_MS) {
207
217
  // Not "effectively no limit" — setTimeout reduces a delay past 2^31-1
@@ -243,7 +253,11 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
243
253
  out.cacheDir = v
244
254
  } else if (a === '--cache' || a?.startsWith('--cache=')) {
245
255
  const v = a === '--cache' ? before[++i] : a.slice('--cache='.length)
246
- if (v === undefined) return { ...out, error: `--cache requires a value` }
256
+ if (v === undefined)
257
+ return {
258
+ ...out,
259
+ error: `--cache requires a value (a spec like local:r, local:rw, remote:, or local:,remote:rw)`,
260
+ }
247
261
  // A spec with no segment at all applies NOTHING, so `--cache=`
248
262
  // (an empty shell var, or someone reaching for "no cache") left
249
263
  // all four axes ON — the opposite of the intent. Reject it; an
@@ -283,7 +297,8 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
283
297
  out.continueMode = v
284
298
  } else if (a === '--verbosity' || a?.startsWith('--verbosity=')) {
285
299
  const v = a === '--verbosity' ? before[++i] : a.slice('--verbosity='.length)
286
- if (v === undefined) return { ...out, error: `--verbosity requires a value` }
300
+ if (v === undefined)
301
+ return { ...out, error: `--verbosity requires a value (a non-negative integer)` }
287
302
  const n = parseDecimalInt(v)
288
303
  if (n === null) return { ...out, error: `invalid verbosity: ${v} (a non-negative integer)` }
289
304
  out.verbosity = n
@@ -317,7 +332,7 @@ export function parseRunArgs(args: readonly string[]): RunArgs {
317
332
  out.affected = a.slice('--affected='.length)
318
333
  } else if (a === '--tag' || a?.startsWith('--tag=')) {
319
334
  const raw = a === '--tag' ? before[++i] : a.slice('--tag='.length)
320
- if (raw === undefined) return { ...out, error: `${a} requires a value` }
335
+ if (raw === undefined) return { ...out, error: `${a} requires a value (k=v)` }
321
336
  // Split on the FIRST `=` so values may contain `=` (e.g. a URL).
322
337
  const eq = raw.indexOf('=')
323
338
  if (eq <= 0) return { ...out, error: `invalid --tag (expected k=v): ${raw}` }
package/src/cli/show.ts CHANGED
@@ -35,7 +35,8 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
35
35
  else if (a.startsWith('--format=')) format = a.slice('--format='.length)
36
36
  else if (a.startsWith('-'))
37
37
  return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
38
- else if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
38
+ else if (out.target !== undefined)
39
+ return { ...out, error: `unexpected argument: ${a}${seeHelp('show')}` }
39
40
  // An empty name is a part of every name: its "did you mean" listed the
40
41
  // whole workspace.
41
42
  else if (a === '') return { ...out, error: 'empty target (omit it to list every project)' }
@@ -0,0 +1,182 @@
1
+ // What `vx watch` watches: the projects a cycle can run, what their configs
2
+ // declare (outputs to ignore, inputs, workspace-wide globs) and the member
3
+ // directories a package glob can grow. Read at start and again whenever an
4
+ // edit reshapes the set; the loop in watch.ts decides when.
5
+
6
+ import fs from 'node:fs'
7
+ import path from 'node:path'
8
+ import type { ProjectConfig } from '../config.js'
9
+ import {
10
+ buildPackageGraph,
11
+ configImports,
12
+ loadProjectConfig,
13
+ WORKSPACE_CONFIG_FILENAMES,
14
+ type ProjectEntry,
15
+ type ProjectMeta,
16
+ } from '../workspace/index.js'
17
+ import { taskEdges, taskEdgesFrom } from './select.js'
18
+ import { type CliLoadOptions, loadCliProjects } from './workspace-config.js'
19
+
20
+ /**
21
+ * The projects a cycle can run, so both watcher arms cover the same dirs:
22
+ * the scope plus its transitive dependencies through the package graph
23
+ * and the cross-project `dependsOn` edges the task graph adds
24
+ * (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
25
+ * per-project arm watched the filter's answer only, so
26
+ * `vx watch build --filter app` never re-ran on a `lib` edit that
27
+ * `vx run build --filter app` would have rebuilt. A whole-workspace
28
+ * scope is every project already and walks nothing. A config the loader
29
+ * rejects contributes no edges here; the run that just happened said so.
30
+ */
31
+ export async function watchedProjects(
32
+ workspaceRoot: string,
33
+ allProjects: readonly ProjectMeta[],
34
+ scope: readonly ProjectMeta[],
35
+ load: CliLoadOptions = {},
36
+ staged: ReadonlyMap<string, ProjectEntry> | null = null,
37
+ ): Promise<ProjectMeta[]> {
38
+ if (scope.length === allProjects.length) return [...allProjects]
39
+ let edges: Map<string, string[]> | undefined
40
+ try {
41
+ edges =
42
+ staged !== null
43
+ ? taskEdgesFrom(staged)
44
+ : (await taskEdges(workspaceRoot, allProjects, load)).edges
45
+ } catch {
46
+ edges = undefined
47
+ }
48
+ const graph = buildPackageGraph([...allProjects], edges)
49
+ const names = new Set(scope.map((p) => p.name))
50
+ for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
51
+ return allProjects.filter((p) => names.has(p.name))
52
+ }
53
+
54
+ /** What one sweep over every config tells the loop. */
55
+ export interface ConfigSweep {
56
+ workspaceWide: boolean
57
+ workspaceInputs: string[]
58
+ outputs: Map<string, string[]>
59
+ /** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
60
+ inputs: Map<string, string[]>
61
+ /** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
62
+ uncached: Set<string>
63
+ /** Files the project configs import by relative path (item 949). */
64
+ configImports: string[]
65
+ /** Files the workspace config imports by relative path: loaded once per process (item 949). */
66
+ workspaceConfigImports: string[]
67
+ /** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
68
+ staged: Map<string, ProjectEntry> | null
69
+ }
70
+
71
+ /**
72
+ * One sweep over every config for the two things the loop needs: whether
73
+ * any task declares `inputs.workspaceFiles` — globs with no project
74
+ * prefix, so the whole tree has to be watched — and every project's
75
+ * declared outputs, so their writes are not taken for edits. The run
76
+ * path's load (`loadProjects`): the plugin `project` stage applies, so an
77
+ * output a plugin gave a config-less package is ignored like a declared
78
+ * one, and a pure config is served from its cached evaluation rather than
79
+ * re-evaluated in a worker. A config that fails to load is out of this
80
+ * concern's scope — the run that just happened already said so, or will —
81
+ * and the sweep falls back to the files that do load, one by one.
82
+ */
83
+ export async function sweepConfigs(
84
+ projects: readonly ProjectMeta[],
85
+ workspaceRoot: string,
86
+ load: CliLoadOptions = {},
87
+ ): Promise<ConfigSweep> {
88
+ const outputs = new Map<string, string[]>()
89
+ const inputs = new Map<string, string[]>()
90
+ const addTo = (
91
+ into: Map<string, string[]>,
92
+ dir: string,
93
+ globs: readonly string[] | undefined,
94
+ ): void => {
95
+ if (globs === undefined || globs.length === 0) return
96
+ into.set(dir, [...(into.get(dir) ?? []), ...globs])
97
+ }
98
+ const add = (dir: string, globs: readonly string[] | undefined): void =>
99
+ addTo(outputs, dir, globs)
100
+ const workspaceInputs = new Set<string>()
101
+ const uncached = new Set<string>()
102
+ const fold = (dir: string, config: ProjectConfig): void => {
103
+ for (const task of Object.values(config.tasks ?? {})) {
104
+ if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
105
+ uncached.add(dir)
106
+ for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
107
+ addTo(inputs, dir, task.cache?.inputs?.files)
108
+ addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
109
+ add(dir, task.cache?.outputs?.files)
110
+ add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
111
+ }
112
+ }
113
+ const result = (staged: Map<string, ProjectEntry> | null): ConfigSweep => ({
114
+ workspaceWide: workspaceInputs.size > 0,
115
+ workspaceInputs: [...workspaceInputs],
116
+ outputs,
117
+ inputs,
118
+ uncached,
119
+ configImports: [...projectImports],
120
+ workspaceConfigImports: [...wsImports],
121
+ staged,
122
+ })
123
+ const projectImports = new Set<string>()
124
+ const wsImports = new Set<string>()
125
+ await Promise.all([
126
+ ...projects.map(async (p) => {
127
+ if (p.configPath === null) return
128
+ for (const f of await configImports(p.configPath)) projectImports.add(f)
129
+ }),
130
+ ...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
131
+ for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
132
+ }),
133
+ ])
134
+ let staged: Map<string, ProjectEntry> | null = null
135
+ try {
136
+ staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
137
+ } catch {
138
+ staged = null
139
+ }
140
+ if (staged !== null) {
141
+ for (const p of staged.values()) fold(p.dir, p.config)
142
+ return result(staged)
143
+ }
144
+ await Promise.all(
145
+ projects.map(async (p) => {
146
+ if (p.configPath === null) return
147
+ try {
148
+ fold(p.dir, await loadProjectConfig(p.configPath))
149
+ } catch {
150
+ // broken config — out of this concern's scope
151
+ }
152
+ }),
153
+ )
154
+ return result(null)
155
+ }
156
+
157
+ /**
158
+ * The directory entries under a package glob's directory that can be
159
+ * members: directories (or links) whose name is not dotted and not
160
+ * `node_modules`, the same rule discovery applies. A watcher on that
161
+ * directory reacts only when this set changes.
162
+ */
163
+ export function memberEntries(base: string): ReadonlySet<string> {
164
+ const out = new Set<string>()
165
+ let entries: fs.Dirent[]
166
+ try {
167
+ entries = fs.readdirSync(base, { withFileTypes: true })
168
+ } catch {
169
+ return out
170
+ }
171
+ for (const e of entries) {
172
+ if (e.name.startsWith('.') || e.name === 'node_modules') continue
173
+ if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
174
+ }
175
+ return out
176
+ }
177
+
178
+ export function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
179
+ if (a.size !== b.size) return false
180
+ for (const name of a) if (!b.has(name)) return false
181
+ return true
182
+ }
package/src/cli/watch.ts CHANGED
@@ -25,22 +25,15 @@ import {
25
25
  type RunOptions,
26
26
  } from '../orchestrator/index.js'
27
27
  import {
28
- buildPackageGraph,
29
28
  findWorkspaceRoot,
30
29
  type LoadReads,
31
30
  listProjects,
32
- loadProjectConfig,
33
31
  loadWorkspace,
34
32
  LOCKFILE_NAME,
35
33
  memberBaseDirs,
36
- WORKSPACE_CONFIG_FILENAMES,
37
- configImports,
38
- type ProjectEntry,
39
34
  type ProjectMeta,
40
35
  } from '../workspace/index.js'
41
- import type { ProjectConfig } from '../config.js'
42
- import { type CliLoadOptions, loadCliProjects, loadCliWorkspace } from './workspace-config.js'
43
- import { taskEdges, taskEdgesFrom } from './select.js'
36
+ import { type CliLoadOptions, loadCliWorkspace } from './workspace-config.js'
44
37
  import {
45
38
  gitIgnored,
46
39
  isIgnoredWatchPath,
@@ -59,6 +52,7 @@ import {
59
52
  WATCH_PROBE_TIMEOUT_MS,
60
53
  type WatchHandle,
61
54
  } from './watch-fs.js'
55
+ import { memberEntries, sameMembers, sweepConfigs, watchedProjects } from './watch-set.js'
62
56
 
63
57
  /** Wait this long after the last filesystem event before re-running. */
64
58
  const DEBOUNCE_MS = 150
@@ -264,151 +258,6 @@ export async function watchCmd(args: readonly string[]): Promise<number> {
264
258
  })
265
259
  }
266
260
 
267
- /**
268
- * The projects a cycle can run, so both watcher arms cover the same dirs:
269
- * the scope plus its transitive dependencies through the package graph
270
- * and the cross-project `dependsOn` edges the task graph adds
271
- * (`e2e` → `app` from `dependsOn: ['app#build']`). Before this, the
272
- * per-project arm watched the filter's answer only, so
273
- * `vx watch build --filter app` never re-ran on a `lib` edit that
274
- * `vx run build --filter app` would have rebuilt. A whole-workspace
275
- * scope is every project already and walks nothing. A config the loader
276
- * rejects contributes no edges here; the run that just happened said so.
277
- */
278
- export async function watchedProjects(
279
- workspaceRoot: string,
280
- allProjects: readonly ProjectMeta[],
281
- scope: readonly ProjectMeta[],
282
- load: CliLoadOptions = {},
283
- staged: ReadonlyMap<string, ProjectEntry> | null = null,
284
- ): Promise<ProjectMeta[]> {
285
- if (scope.length === allProjects.length) return [...allProjects]
286
- let edges: Map<string, string[]> | undefined
287
- try {
288
- edges =
289
- staged !== null
290
- ? taskEdgesFrom(staged)
291
- : (await taskEdges(workspaceRoot, allProjects, load)).edges
292
- } catch {
293
- edges = undefined
294
- }
295
- const graph = buildPackageGraph([...allProjects], edges)
296
- const names = new Set(scope.map((p) => p.name))
297
- for (const p of scope) for (const d of graph.transitiveDeps(p.name)) names.add(d)
298
- return allProjects.filter((p) => names.has(p.name))
299
- }
300
-
301
- /**
302
- * One sweep over every config for the two things the loop needs: whether
303
- * any task declares `inputs.workspaceFiles` — globs with no project
304
- * prefix, so the whole tree has to be watched — and every project's
305
- * declared outputs, so their writes are not taken for edits. The run
306
- * path's load (`loadProjects`): the plugin `project` stage applies, so an
307
- * output a plugin gave a config-less package is ignored like a declared
308
- * one, and a pure config is served from its cached evaluation rather than
309
- * re-evaluated in a worker. A config that fails to load is out of this
310
- * concern's scope — the run that just happened already said so, or will —
311
- * and the sweep falls back to the files that do load, one by one.
312
- */
313
- export async function sweepConfigs(
314
- projects: readonly ProjectMeta[],
315
- workspaceRoot: string,
316
- load: CliLoadOptions = {},
317
- ): Promise<{
318
- workspaceWide: boolean
319
- workspaceInputs: string[]
320
- outputs: Map<string, string[]>
321
- /** Declared input globs per directory they are relative to, the counterweight to `outputs`. */
322
- inputs: Map<string, string[]>
323
- /** Projects with a task that has a command and no cache: it reads what it likes, git-ignored files included. */
324
- uncached: Set<string>
325
- /** Files the project configs import by relative path (item 949). */
326
- configImports: string[]
327
- /** Files the workspace config imports by relative path: loaded once per process (item 949). */
328
- workspaceConfigImports: string[]
329
- /** The staged load the sweep read, when the run path's load succeeded; `watchedProjects` reads the same one. */
330
- staged: Map<string, ProjectEntry> | null
331
- }> {
332
- const outputs = new Map<string, string[]>()
333
- const inputs = new Map<string, string[]>()
334
- const addTo = (
335
- into: Map<string, string[]>,
336
- dir: string,
337
- globs: readonly string[] | undefined,
338
- ): void => {
339
- if (globs === undefined || globs.length === 0) return
340
- into.set(dir, [...(into.get(dir) ?? []), ...globs])
341
- }
342
- const add = (dir: string, globs: readonly string[] | undefined): void =>
343
- addTo(outputs, dir, globs)
344
- const workspaceInputs = new Set<string>()
345
- const uncached = new Set<string>()
346
- const fold = (dir: string, config: ProjectConfig): void => {
347
- for (const task of Object.values(config.tasks ?? {})) {
348
- if (task.cache === undefined && task.exec !== undefined && task.exec.persistent === undefined)
349
- uncached.add(dir)
350
- for (const g of task.cache?.inputs?.workspaceFiles ?? []) workspaceInputs.add(g)
351
- addTo(inputs, dir, task.cache?.inputs?.files)
352
- addTo(inputs, workspaceRoot, task.cache?.inputs?.workspaceFiles)
353
- add(dir, task.cache?.outputs?.files)
354
- add(workspaceRoot, task.cache?.outputs?.workspaceFiles)
355
- }
356
- }
357
- const result = (
358
- staged: Map<string, ProjectEntry> | null,
359
- ): {
360
- workspaceWide: boolean
361
- workspaceInputs: string[]
362
- outputs: Map<string, string[]>
363
- inputs: Map<string, string[]>
364
- uncached: Set<string>
365
- configImports: string[]
366
- workspaceConfigImports: string[]
367
- staged: Map<string, ProjectEntry> | null
368
- } => ({
369
- workspaceWide: workspaceInputs.size > 0,
370
- workspaceInputs: [...workspaceInputs],
371
- outputs,
372
- inputs,
373
- uncached,
374
- configImports: [...projectImports],
375
- workspaceConfigImports: [...wsImports],
376
- staged,
377
- })
378
- const projectImports = new Set<string>()
379
- const wsImports = new Set<string>()
380
- await Promise.all([
381
- ...projects.map(async (p) => {
382
- if (p.configPath === null) return
383
- for (const f of await configImports(p.configPath)) projectImports.add(f)
384
- }),
385
- ...WORKSPACE_CONFIG_FILENAMES.map(async (name) => {
386
- for (const f of await configImports(path.join(workspaceRoot, name))) wsImports.add(f)
387
- }),
388
- ])
389
- let staged: Map<string, ProjectEntry> | null = null
390
- try {
391
- staged = await loadCliProjects(workspaceRoot, projects, 'all', load)
392
- } catch {
393
- staged = null
394
- }
395
- if (staged !== null) {
396
- for (const p of staged.values()) fold(p.dir, p.config)
397
- return result(staged)
398
- }
399
- await Promise.all(
400
- projects.map(async (p) => {
401
- if (p.configPath === null) return
402
- try {
403
- fold(p.dir, await loadProjectConfig(p.configPath))
404
- } catch {
405
- // broken config — out of this concern's scope
406
- }
407
- }),
408
- )
409
- return result(null)
410
- }
411
-
412
261
  interface WatchLoopArgs {
413
262
  opts: RunOptions
414
263
  /** The persistent tasks the initial run left running; the next cycle stops them before it starts. */
@@ -1099,30 +948,3 @@ export function pendingAfterCycle(
1099
948
  const [abs, label] = [...pending][0]!
1100
949
  return [abs, label]
1101
950
  }
1102
-
1103
- /**
1104
- * The directory entries under a package glob's directory that can be
1105
- * members: directories (or links) whose name is not dotted and not
1106
- * `node_modules`, the same rule discovery applies. A watcher on that
1107
- * directory reacts only when this set changes.
1108
- */
1109
- export function memberEntries(base: string): ReadonlySet<string> {
1110
- const out = new Set<string>()
1111
- let entries: fs.Dirent[]
1112
- try {
1113
- entries = fs.readdirSync(base, { withFileTypes: true })
1114
- } catch {
1115
- return out
1116
- }
1117
- for (const e of entries) {
1118
- if (e.name.startsWith('.') || e.name === 'node_modules') continue
1119
- if (e.isDirectory() || e.isSymbolicLink()) out.add(e.name)
1120
- }
1121
- return out
1122
- }
1123
-
1124
- function sameMembers(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
1125
- if (a.size !== b.size) return false
1126
- for (const name of a) if (!b.has(name)) return false
1127
- return true
1128
- }
package/src/cli/why.ts CHANGED
@@ -86,7 +86,8 @@ export function parseWhyArgs(args: readonly string[]): WhyArgs {
86
86
  }
87
87
  if (a.startsWith('-'))
88
88
  return { ...out, error: `unknown flag: ${a}${flagHint('why', a)}${seeHelp('why')}` }
89
- if (out.target !== undefined) return { ...out, error: `unexpected argument: ${a}` }
89
+ if (out.target !== undefined)
90
+ return { ...out, error: `unexpected argument: ${a}${seeHelp('why')}` }
90
91
  // Empty is no task: read as a query it matched every recorded one.
91
92
  if (a !== '') out.target = a
92
93
  }
@@ -64,8 +64,9 @@ export function taintTracker(
64
64
  u !== undefined && (failed(u) || taintOf(u.node.id))
65
65
  // Post-order over the settled deps with an explicit stack: a chain of
66
66
  // restore-tier hits is judged in one ask, and a recursion would take a
67
- // frame per hop. Memoized once every dep has settled; until then the
68
- // answer may still grow.
67
+ // frame per hop. Taint is final at once; a clean answer only once every
68
+ // dep has settled and its own answer is kept, since a dep below a settled
69
+ // one may still fail (C-23).
69
70
  const taintOf = (root: string): boolean => {
70
71
  const known = memo.get(root)
71
72
  if (known !== undefined) return known
@@ -95,7 +96,7 @@ export function taintTracker(
95
96
  return u !== undefined && (failed(u) || valueOf(d) === true)
96
97
  })
97
98
  local.set(id, taint)
98
- if (taint || deps.every((d) => outcomes.has(d))) memo.set(id, taint)
99
+ if (taint || deps.every((d) => outcomes.has(d) && memo.has(d))) memo.set(id, taint)
99
100
  }
100
101
  return valueOf(root)!
101
102
  }
@@ -135,6 +135,14 @@ export function unprovidedBareImports(
135
135
  return out
136
136
  }
137
137
 
138
+ /**
139
+ * A textual pass before the scan: a relative specifier opens with a quoted
140
+ * `./` or `../`, or spells one with an escape, and most configs hold
141
+ * neither, so the scan is skipped for them (D-23). A candidate in a comment
142
+ * or string still goes to the scan, which decides.
143
+ */
144
+ const RELATIVE_CANDIDATE = /["'`](?:\.\.?\/|[^"'`\n]*\\)/
145
+
138
146
  /**
139
147
  * Absolute resolved targets of the RELATIVE specifiers in `source`; one that
140
148
  * does not resolve contributes the paths it names.
@@ -145,6 +153,7 @@ export function unprovidedBareImports(
145
153
  * which is correct: an erased import cannot move a resolved value.
146
154
  */
147
155
  function scanLocalImports(source: string, fromDir: string, loader: 'ts' | 'js'): string[] {
156
+ if (!RELATIVE_CANDIDATE.test(source)) return []
148
157
  let specifiers: string[]
149
158
  try {
150
159
  specifiers = scanner(loader)
@@ -246,26 +255,35 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
246
255
  // changed set answers every root at once, instead of a walk per root.
247
256
  const importedBy = new Map<string, string[]>()
248
257
  const visited = new Set<string>()
249
- const queue = [...roots.keys()]
250
- while (queue.length > 0) {
251
- const file = queue.pop()!
252
- if (visited.has(file)) continue
253
- visited.add(file)
254
- let source: string
255
- try {
256
- source = await Bun.file(file).text()
257
- } catch {
258
- continue // unreadable: no edges, and not this pass's problem to report
259
- }
260
- const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
261
- for (const target of scanLocalImports(source, path.dirname(file), loader)) {
262
- if (!target.startsWith(workspaceRoot + path.sep)) continue
263
- if (target.split(path.sep).includes('node_modules')) continue
264
- const list = importedBy.get(target)
265
- if (list) list.push(file)
266
- else importedBy.set(target, [file])
267
- // Descend ONLY through unowned files — see the header.
268
- if (ownerOf(target, dirToName) === undefined) queue.push(target)
258
+ // A level at a time, its files read together: one awaited read per
259
+ // config put 5,000 of them 200 ms behind (D-23).
260
+ let frontier = [...roots.keys()]
261
+ while (frontier.length > 0) {
262
+ const read = await Promise.all(
263
+ frontier.map(async (file) => {
264
+ if (visited.has(file)) return null
265
+ visited.add(file)
266
+ try {
267
+ return { file, source: await Bun.file(file).text() }
268
+ } catch {
269
+ return null // unreadable: no edges, and not this pass's problem to report
270
+ }
271
+ }),
272
+ )
273
+ frontier = []
274
+ for (const entry of read) {
275
+ if (entry === null) continue
276
+ const { file, source } = entry
277
+ const loader = TS_EXT.has(path.extname(file)) ? 'ts' : 'js'
278
+ for (const target of scanLocalImports(source, path.dirname(file), loader)) {
279
+ if (!target.startsWith(workspaceRoot + path.sep)) continue
280
+ if (target.split(path.sep).includes('node_modules')) continue
281
+ const list = importedBy.get(target)
282
+ if (list) list.push(file)
283
+ else importedBy.set(target, [file])
284
+ // Descend ONLY through unowned files — see the header.
285
+ if (ownerOf(target, dirToName) === undefined) frontier.push(target)
286
+ }
269
287
  }
270
288
  }
271
289