@vzn/vx 0.0.50 → 0.0.52

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 (57) hide show
  1. package/README.md +38 -31
  2. package/package.json +5 -5
  3. package/src/bin.ts +1 -1
  4. package/src/cache/archive.ts +18 -6
  5. package/src/cache/cache.ts +150 -67
  6. package/src/cache/chained-cache.ts +9 -2
  7. package/src/cache/file-hashes.ts +32 -3
  8. package/src/cache/git-inputs.ts +96 -11
  9. package/src/cache/index.ts +1 -0
  10. package/src/cache/layer.ts +28 -3
  11. package/src/cache/layered-cache.ts +7 -2
  12. package/src/cache/output-index.ts +16 -7
  13. package/src/cache/run-history.ts +5 -1
  14. package/src/cache/zstd.ts +40 -8
  15. package/src/cli/cache.ts +35 -9
  16. package/src/cli/completions.ts +10 -7
  17. package/src/cli/help.ts +48 -4
  18. package/src/cli/index.ts +5 -3
  19. package/src/cli/info.ts +1 -1
  20. package/src/cli/plugin-commands.ts +10 -2
  21. package/src/cli/run.ts +4 -0
  22. package/src/cli/select.ts +32 -2
  23. package/src/cli/upgrade.ts +110 -22
  24. package/src/cli/watch.ts +38 -20
  25. package/src/config.ts +18 -5
  26. package/src/exec/executor.ts +3 -2
  27. package/src/exec/index.ts +1 -1
  28. package/src/exec/runner.ts +102 -24
  29. package/src/exec/sandbox-binds.ts +1 -1
  30. package/src/exec/sandbox-runtime.ts +149 -40
  31. package/src/exec/sandbox-violations.ts +42 -10
  32. package/src/graph/scheduler.ts +11 -5
  33. package/src/graph/task-graph.ts +96 -12
  34. package/src/index.ts +1 -8
  35. package/src/orchestrator/admission.ts +72 -17
  36. package/src/orchestrator/execute-task.ts +39 -25
  37. package/src/orchestrator/hit-restore.ts +7 -1
  38. package/src/orchestrator/miss-save.ts +29 -1
  39. package/src/orchestrator/options.ts +8 -1
  40. package/src/orchestrator/placement.ts +23 -3
  41. package/src/orchestrator/plugin-host.ts +1 -1
  42. package/src/orchestrator/plugin.ts +9 -1
  43. package/src/orchestrator/prepare.ts +28 -10
  44. package/src/orchestrator/run.ts +75 -12
  45. package/src/orchestrator/sandbox-request.ts +19 -10
  46. package/src/orchestrator/summary.ts +26 -15
  47. package/src/orchestrator/task-hash.ts +7 -2
  48. package/src/orchestrator/telemetry.ts +14 -4
  49. package/src/util/paths.ts +16 -9
  50. package/src/workspace/affected.ts +201 -38
  51. package/src/workspace/config-cache.ts +0 -2
  52. package/src/workspace/config-eval.ts +4 -1
  53. package/src/workspace/config-schema.ts +70 -10
  54. package/src/workspace/fingerprint.ts +6 -0
  55. package/src/workspace/migration.ts +1 -1
  56. package/src/workspace/project-loader.ts +53 -1
  57. package/src/workspace/workspace.ts +54 -14
@@ -96,7 +96,7 @@ export function sandboxRunUnion(nodes: Iterable<TaskNode>): SandboxRunUnion | nu
96
96
  * The domain union is computed here from every sandboxed node, because
97
97
  * SRT runs ONE filtering proxy per run and checks every request against
98
98
  * the allowlist given to `initialize()` — never the per-call one
99
- * (`sandbox-manager.js` 0.0.75). A task that declares no domains still
99
+ * (`sandbox-manager.js`, 0.0.75 and 0.0.76). A task that declares no domains still
100
100
  * reaches nothing: its profile is not given the proxy's port at all.
101
101
  * The unix-socket allowance is per run the same way: SRT's Linux seccomp
102
102
  * filter on `socket(AF_UNIX)` is all-or-nothing and read at
@@ -183,11 +183,14 @@ export async function sandboxRequestFor(
183
183
  // Pre-create what the task said it will write — after the grants are
184
184
  // resolved, which refuses one that leaves the project through a link
185
185
  // before anything is created along it (item 1003).
186
- const config = wallOff(resolveSandboxConfig(sandbox, node.projectDir), workspaceRoot, [
187
- ...nested,
188
- path.join(workspaceRoot, '.git'),
189
- path.join(workspaceRoot, '.vx'),
190
- ])
186
+ const walls = [...nested, path.join(workspaceRoot, '.git'), path.join(workspaceRoot, '.vx')].map(
187
+ toRealPath,
188
+ )
189
+ const config = wallOff(
190
+ resolveSandboxConfig(sandbox, node.projectDir, walls),
191
+ workspaceRoot,
192
+ walls,
193
+ )
191
194
  const placeholders = await prepareOutputsForBind(node.projectDir, sandbox.allow?.write ?? [])
192
195
  const request: NonNullable<ExecuteRequest['sandbox']> = {
193
196
  // Only what the task declared, plus node_modules. Write paths are
@@ -197,7 +200,13 @@ export async function sandboxRequestFor(
197
200
  // Enforcement anchors at the WORKSPACE ROOT: a task may not leave
198
201
  // its project, so every sibling and every root file is denied.
199
202
  // Reporting is a different question — see `reportWithin` below.
200
- baseDenyRead: [workspaceRoot],
203
+ // On macOS the walls are denied too: seatbelt does not mount, so the
204
+ // punch cannot apply, and SRT emits a deny strictly inside a literal
205
+ // read grant AFTER the grant, where it wins (`lateReadDenyFilters`,
206
+ // 0.0.76). A grant naming a wall is not strictly outside it and wins.
207
+ // Without it a root task's `read: ['.']` read its nested projects under
208
+ // seatbelt (B-4).
209
+ baseDenyRead: process.platform === 'darwin' ? [workspaceRoot, ...walls] : [workspaceRoot],
201
210
  // …but only denials INSIDE the project are worth reporting. A task
202
211
  // bumping into the wall is the sandbox working, not a finding: the
203
212
  // walk `bun build --compile` makes from `/` down to its cwd lists
@@ -422,14 +431,14 @@ async function prepareOutputsForBind(
422
431
  * punched (`punchWalls`); a write grant whose bind would hold one — a file
423
432
  * grant binds its directory on Linux, so `write: ['out.txt']` in a root
424
433
  * project bound the whole workspace writable, `.git` included — is
425
- * refused, as there is no writable bind that leaves a wall out.
434
+ * refused, as there is no writable bind that leaves a wall out. A glob's
435
+ * hits already stop at the walls (`resolveSandboxConfig` takes them).
426
436
  */
427
437
  function wallOff(
428
438
  config: ResolvedSandboxConfig,
429
439
  workspaceRoot: string,
430
- raw: readonly string[],
440
+ walls: readonly string[],
431
441
  ): ResolvedSandboxConfig {
432
- const walls = raw.map(toRealPath)
433
442
  const home = toRealPath(workspaceRoot)
434
443
  for (const bind of bindableWrites(config.allowWrite)) {
435
444
  // A bind outside the workspace is the user's own path (`/tmp/x`,
@@ -326,23 +326,34 @@ export function formatRunSummary(
326
326
  }
327
327
 
328
328
  /**
329
- * Post-summary section naming the tasks a shutdown signal killed. An aborted
330
- * task did no work, so it is in no tally bucket and no history row — but the
331
- * run still exits non-zero, and without this the user reads a red exit over a
332
- * fully green summary that names nothing. The interactive Ctrl-C path never
333
- * gets here (the signal handler exits before any outcome lands), so this only
334
- * ever prints for a run that reached its summary WITH a task killed by some
335
- * other signal — an external `kill`, a supervisor, a self-terminating child.
336
- * Empty when nothing aborted.
329
+ * Post-summary section naming the tasks a shutdown signal killed, and those
330
+ * the stop reached before they ran. An aborted task did no work, so it is in
331
+ * no tally bucket and no history row — but the run still exits non-zero, and
332
+ * without this the user reads a red exit over a fully green summary that
333
+ * names nothing. Empty when nothing aborted.
337
334
  */
338
335
  export function formatAbortedSection(outcomes: readonly TaskOutcome[]): string[] {
339
- const aborted = outcomes.filter((o) => o.status === 'aborted')
340
- if (aborted.length === 0) return []
341
- const lines = [
342
- '',
343
- ` Aborted: ${aborted.length} task${aborted.length === 1 ? '' : 's'} killed by a shutdown signal — not counted above`,
344
- ]
345
- for (const o of aborted) lines.push(` ✗ ${o.node.id} — exit ${o.exitCode}, nothing cached`)
336
+ // The scheduler marks what the stop reached before dispatch `aborted` too,
337
+ // with an invented exit 1: listed as killed, a task that never ran read as
338
+ // one the signal cut short (item 1062). Only a started one carries a
339
+ // wall-clock start. A group is no task here, as in the skipped section.
340
+ const aborted = outcomes.filter((o) => o.status === 'aborted' && !isGroupTask(o.node))
341
+ const killed = aborted.filter((o) => o.wallclockStartNs !== undefined)
342
+ const unstarted = aborted.filter((o) => o.wallclockStartNs === undefined)
343
+ const tasks = (n: number): string => `${n} task${n === 1 ? '' : 's'}`
344
+ const lines: string[] = []
345
+ if (killed.length > 0) {
346
+ lines.push(
347
+ '',
348
+ ` Aborted: ${tasks(killed.length)} killed by a shutdown signal — not counted above`,
349
+ )
350
+ for (const o of killed) lines.push(` ✗ ${o.node.id} — exit ${o.exitCode}, nothing cached`)
351
+ }
352
+ if (unstarted.length > 0) {
353
+ const they = unstarted.length === 1 ? 'it' : 'they'
354
+ lines.push('', ` Not started: ${tasks(unstarted.length)} the run stopped before ${they} ran`)
355
+ for (const o of unstarted) lines.push(` · ${o.node.id}`)
356
+ }
346
357
  return lines
347
358
  }
348
359
 
@@ -6,6 +6,7 @@ import {
6
6
  type CacheKeyInput,
7
7
  type CacheLayer,
8
8
  FILE_HASH_RACY_MS,
9
+ racyWindowMs,
9
10
  resolveInputs,
10
11
  type GitFilesCache,
11
12
  WORKSPACE_OUTPUT_PREFIX,
@@ -222,8 +223,12 @@ export async function movedInput(
222
223
  } catch {
223
224
  return f.path
224
225
  }
225
- if (commandFrom !== undefined && ctimeMs >= commandFrom) return f.path
226
- if (ctimeMs >= f.since - FILE_HASH_RACY_MS) suspects.push(f)
226
+ // A whole-second stamp may stand for any write in its second, one
227
+ // during the command included (`racyWindowMs`).
228
+ if (commandFrom !== undefined && ctimeMs >= commandFrom - racyWindowMs(ctimeMs, 0)) {
229
+ return f.path
230
+ }
231
+ if (ctimeMs >= f.since - racyWindowMs(ctimeMs, FILE_HASH_RACY_MS)) suspects.push(f)
227
232
  }
228
233
  for (const f of suspects) {
229
234
  const now = await cache.hashFile(f.path).catch(() => undefined)
@@ -107,7 +107,7 @@ export interface RunContextRecord {
107
107
  /** ULID, shared by every record in one `vx run`. */
108
108
  runId: string
109
109
  vxVersion: string
110
- /** The invocation command line (process.argv-derived). */
110
+ /** The invocation command line (process.argv-derived), what follows `--` counted, not quoted. */
111
111
  command: string
112
112
  requestedTasks: readonly string[]
113
113
  /** Compact cache-policy flags, e.g. `'lR,lW,rR,rW'`. */
@@ -318,8 +318,14 @@ export interface TelemetrySink {
318
318
  * Losing a slow sink's telemetry is strictly better than the alternative:
319
319
  * `run()` never returns, so the cache never closes and `vx` exits 0 on a
320
320
  * failed run.
321
+ *
322
+ * `signal` aborts at that deadline. Abandoning a flush does not end its
323
+ * I/O: a request still in flight keeps the event loop, and so the
324
+ * process, alive after the run has returned — a hanging GitHub API held
325
+ * `vx run` open until the CI job's own timeout (item 1055). A sink passes
326
+ * the signal to its `fetch` (or closes its socket on it).
321
327
  */
322
- flush?(): Promise<void>
328
+ flush?(signal: AbortSignal): Promise<void>
323
329
  }
324
330
 
325
331
  /** Read-only context a sink is created with. No mutable run handle — the
@@ -496,12 +502,13 @@ export function createTelemetrySource(args: {
496
502
  // pending — Bun exits 0 and a FAILED run reports green, with the cache's
497
503
  // accessed_at bumps and every later plugin's teardown lost with it.
498
504
  // Sinks race concurrently, so each still gets the whole budget.
505
+ const deadline = new AbortController()
499
506
  const settled = await settleWithin(
500
507
  Promise.all(
501
508
  sinks.map(async (sink) => {
502
509
  if (disabled.has(sink) || sink.flush === undefined) return
503
510
  try {
504
- await sink.flush()
511
+ await sink.flush(deadline.signal)
505
512
  } catch (err) {
506
513
  // A flush failure can never break the run — but it still costs
507
514
  // this sink its export, so say so rather than dropping it.
@@ -513,7 +520,10 @@ export function createTelemetrySource(args: {
513
520
  ),
514
521
  ms,
515
522
  )
516
- if (!settled) warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
523
+ if (!settled) {
524
+ deadline.abort(new Error(`telemetry flush deadline (${ms}ms)`))
525
+ warn?.(`[vx] telemetry flush timed out after ${ms}ms; buffered records lost`)
526
+ }
517
527
  },
518
528
  }
519
529
  }
package/src/util/paths.ts CHANGED
@@ -69,21 +69,28 @@ function prefixOf(glob: string, wildcards: RegExp): string {
69
69
 
70
70
  /**
71
71
  * The directories a task's declared outputs cover WHOLE — every glob is
72
- * `<dir>/**` with a plain, non-root, non-escaping `<dir>` — or `null` when
73
- * any glob is shaped otherwise. The directory-mtime short-circuit on a warm
74
- * hit (`Cache.outputDirsCurrent`) is sound only for whole subtrees: with
75
- * every directory under `<dir>` recorded, a file added or removed anywhere
76
- * the glob could see bumps a recorded directory's mtime (its parent, or a
77
- * new directory whose creation bumped a recorded ancestor). A root-anchored
78
- * `**\/*.js` has no such closed set, so it keeps the walk.
72
+ * `<dir>/**` or a bare literal (which `asTrees` reads as the path or the
73
+ * tree under it), with a plain, non-root, non-escaping `<dir>` — or `null`
74
+ * when any glob is shaped otherwise. The directory-mtime short-circuit on a
75
+ * warm hit (`Cache.outputDirsCurrent`) is sound only for whole subtrees:
76
+ * with every directory under `<dir>` recorded, a file added or removed
77
+ * anywhere the glob could see bumps a recorded directory's mtime (its
78
+ * parent, or a new directory whose creation bumped a recorded ancestor). A
79
+ * root-anchored `**\/*.js` has no such closed set, so it keeps the walk.
80
+ * A literal that names a FILE is no directory: the snapshot refuses it
81
+ * whole (`recordOutputDirs`) and that task keeps the walk too, so the
82
+ * literal costs nothing it did not cost before; one that names a directory
83
+ * gets the same stats a `<dir>/**` does (Next 27: an nx() output file
84
+ * had to be `<dir>/**` to keep the short-circuit, and saved nothing).
79
85
  */
80
86
  export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null {
81
87
  if (globs.length === 0) return null
82
88
  const out: string[] = []
83
89
  for (const g of globs.map(normalizeGlob)) {
84
90
  const m = /^([^*?{}!]+?)\/\*\*$/.exec(g)
85
- if (m === null) return null
86
- const dir = m[1]!.replace(/\/+$/, '')
91
+ const whole = m?.[1] ?? (isLiteralPattern(g) && !g.startsWith('!') ? g : null)
92
+ if (whole === null) return null
93
+ const dir = whole.replace(/\/+$/, '')
87
94
  if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
88
95
  return null
89
96
  out.push(dir)
@@ -21,6 +21,7 @@ import { configImportOwners } from './config-imports.js'
21
21
  import { configImports } from './config-cache.js'
22
22
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
23
23
  import { WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
24
+ import { buildPackageGraph } from './package-graph.js'
24
25
  import type { PackageJson, ProjectMeta } from './workspace.js'
25
26
 
26
27
  /**
@@ -42,9 +43,19 @@ function spawnGitSync(
42
43
  }
43
44
  }
44
45
 
45
- function spawnGit(args: string[], cwd: string): Bun.Subprocess<'ignore', 'pipe', 'pipe'> {
46
+ function spawnGit(
47
+ args: string[],
48
+ cwd: string,
49
+ stdin?: Uint8Array,
50
+ ): Bun.Subprocess<Uint8Array | 'ignore', 'pipe', 'pipe'> {
46
51
  try {
47
- return Bun.spawn({ cmd: [executablePath('git'), ...args], cwd, stdout: 'pipe', stderr: 'pipe' })
52
+ return Bun.spawn({
53
+ cmd: [executablePath('git'), ...args],
54
+ cwd,
55
+ stdin: stdin ?? 'ignore',
56
+ stdout: 'pipe',
57
+ stderr: 'pipe',
58
+ })
48
59
  } catch (err) {
49
60
  if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
50
61
  throw err
@@ -79,6 +90,13 @@ export interface AffectedArgs {
79
90
  * — the common one — never needs it.
80
91
  */
81
92
  fingerprintClaims?: () => Promise<FingerprintClaims>
93
+ /**
94
+ * The cross-project `dependsOn` edges, project → the projects its tasks
95
+ * name. Asked only when a package's name moved or it was removed: a
96
+ * config naming `dependsOn: ['lib#build']` breaks on it, which the
97
+ * package graph cannot see (item 1085).
98
+ */
99
+ taskEdges?: () => Promise<ReadonlyMap<string, readonly string[]>>
82
100
  }
83
101
 
84
102
  export interface FingerprintClaims {
@@ -225,13 +243,28 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
225
243
  const owned = projectsContaining(args.workspaceRoot, changed, args.projects)
226
244
  for (const name of claimedOwned) owned.add(name)
227
245
 
228
- // A package the change REMOVED is no project now, so containment maps its
229
- // paths to nothing, and the dependents walk sees only today's graph: a
230
- // `git rm -r pkgs/lib`, or `lib` dropped from `workspaces`, re-keyed and
231
- // broke `app#build` while `--affected` said nothing affected (item 959).
232
- const dependents = await dependentsOfRemoved(args.workspaceRoot, base, changed, args.projects)
233
- if (dependents === undefined) return new Set(args.projects.map((p) => p.name))
234
- for (const name of dependents) owned.add(name)
246
+ // A manifest edit can drop an edge today's graph no longer shows: a
247
+ // package the change REMOVED is no project now (a `git rm -r pkgs/lib`,
248
+ // or `lib` dropped from `workspaces`, item 959), and one whose `version`
249
+ // or `name` moved no longer satisfies what a dependent declares. Either
250
+ // re-keys the dependent through its upstream, while containment maps the
251
+ // change to the package alone and the dependents walk sees only today's
252
+ // graph, so `--affected` left `app#build` out (D-3).
253
+ const manifests = changed.filter((rel) => path.posix.basename(rel) === 'package.json')
254
+ if (manifests.length > 0) {
255
+ const atBase = await gitBlobsAt(args.workspaceRoot, base, manifests)
256
+ const dependents = await dependentsAtBase(
257
+ args.workspaceRoot,
258
+ atBase,
259
+ args.projects,
260
+ args.taskEdges,
261
+ )
262
+ if (dependents === undefined) return new Set(args.projects.map((p) => p.name))
263
+ for (const name of dependents) owned.add(name)
264
+ for (const name of parentsOfNewNested(args.workspaceRoot, atBase, args.projects)) {
265
+ owned.add(name)
266
+ }
267
+ }
235
268
 
236
269
  // THIRD CHANNEL: a project whose `vx.config.*` IMPORTS a changed file.
237
270
  // Resolved-config hashing folds those values into the key, so the same
@@ -254,22 +287,27 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
254
287
  }
255
288
 
256
289
  /**
257
- * The projects that depend on a package the change removed: a
258
- * `<dir>/package.json` the base had and the tree does not, read at the base
259
- * for its name. `undefined` (every project) when the root manifest's
260
- * `workspaces` moved: which packages left is a discovery at the base, and
261
- * selection cannot tell cheaply. Only DELETED manifests are read at the
262
- * base, one `git show` each; an edited one stays a project and is owned.
290
+ * The projects whose workspace dependencies at the base differ from
291
+ * today's: the package graph is built again over the changed manifests as
292
+ * the base had them, and every project whose `directDeps` (the edges its
293
+ * key folds) moved is selected. `undefined` (every project) when the root
294
+ * manifest's `workspaces` moved: which packages left is a discovery at the
295
+ * base, and selection cannot tell cheaply. `atBase` holds each changed
296
+ * manifest as the base had it, read in one `git cat-file --batch`.
263
297
  */
264
- async function dependentsOfRemoved(
298
+ async function dependentsAtBase(
265
299
  workspaceRoot: string,
266
- base: string,
267
- changed: readonly string[],
300
+ atBase: ReadonlyMap<string, Uint8Array | null>,
268
301
  projects: readonly ProjectMeta[],
302
+ taskEdges: AffectedArgs['taskEdges'],
269
303
  ): Promise<Set<string> | undefined> {
270
- const out = new Set<string>()
271
- const removed = new Set<string>()
272
- for (const rel of changed) {
304
+ const byDir = new Map<string, ProjectMeta>()
305
+ for (const p of projects) byDir.set(p.dir, p)
306
+ const nameNow = new Map(projects.map((p) => [p.dir, p.name]))
307
+ // Base names no project holds today: a removed or renamed package.
308
+ const gone = new Set<string>()
309
+ const changedDirs = new Set<string>()
310
+ for (const rel of atBase.keys()) {
273
311
  if (rel === 'package.json') {
274
312
  const workspacesOf = (bytes: Uint8Array | null): string => {
275
313
  if (bytes === null) return ''
@@ -281,37 +319,141 @@ async function dependentsOfRemoved(
281
319
  return 'unreadable'
282
320
  }
283
321
  }
284
- const before = workspacesOf(await gitBytesAt(workspaceRoot, base, rel))
322
+ const before = workspacesOf(atBase.get(rel) ?? null)
285
323
  const after = workspacesOf(await bytesOrNull(path.join(workspaceRoot, rel)))
286
324
  if (before !== after) return undefined
287
- continue
288
325
  }
289
- if (path.posix.basename(rel) !== 'package.json') continue
290
- if ((await bytesOrNull(path.join(workspaceRoot, rel))) !== null) continue
291
- const bytes = await gitBytesAt(workspaceRoot, base, rel)
292
- if (bytes === null) continue
326
+ const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
327
+ changedDirs.add(dir)
328
+ byDir.delete(dir)
329
+ const bytes = atBase.get(rel)
330
+ if (bytes === undefined || bytes === null) continue
331
+ let pkg: PackageJson
293
332
  try {
294
- const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
295
- if (typeof name === 'string' && name !== '') removed.add(name)
333
+ pkg = JSON.parse(new TextDecoder().decode(bytes)) as PackageJson
296
334
  } catch {
297
335
  // A manifest that did not parse at the base named no package.
336
+ continue
298
337
  }
338
+ if (
339
+ pkg === null ||
340
+ typeof pkg !== 'object' ||
341
+ typeof pkg.name !== 'string' ||
342
+ pkg.name === ''
343
+ ) {
344
+ continue
345
+ }
346
+ byDir.set(dir, { name: pkg.name, dir, packageJson: pkg, configPath: null })
347
+ if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
299
348
  }
300
- if (removed.size === 0) return out
349
+ const now = buildPackageGraph([...projects])
350
+ const then = buildPackageGraph([...byDir.values()])
351
+ const out = new Set<string>()
301
352
  for (const p of projects) {
302
- const pkg = p.packageJson
303
- for (const deps of [
304
- pkg.dependencies,
305
- pkg.devDependencies,
306
- pkg.peerDependencies,
307
- pkg.optionalDependencies,
308
- ]) {
309
- if (deps !== undefined && Object.keys(deps).some((d) => removed.has(d))) out.add(p.name)
353
+ // A project whose own manifest changed is owned by containment.
354
+ if (changedDirs.has(p.dir)) continue
355
+ const a = now.directDeps(p.name)
356
+ const b = then.directDeps(p.name)
357
+ if (a.length !== b.length || a.some((d, i) => d !== b[i])) out.add(p.name)
358
+ }
359
+ if (gone.size > 0 && taskEdges !== undefined) {
360
+ for (const [project, targets] of await taskEdges()) {
361
+ if (targets.some((t) => gone.has(t))) out.add(project)
310
362
  }
311
363
  }
312
364
  return out
313
365
  }
314
366
 
367
+ /**
368
+ * The projects a NEW nested project took files from. A project's inputs
369
+ * stop at every project below it, so a `package.json` that makes an
370
+ * existing directory a project re-keys the project above it — its
371
+ * `**` lost the directory's files — while containment maps the change to
372
+ * the new project alone, and `--affected` left the re-keyed parent out.
373
+ * "New" is judged at the base: no manifest there, or one with no name,
374
+ * which discovery skips. `atBase` holds each changed manifest there.
375
+ */
376
+ function parentsOfNewNested(
377
+ workspaceRoot: string,
378
+ atBase: ReadonlyMap<string, Uint8Array | null>,
379
+ projects: readonly ProjectMeta[],
380
+ ): Set<string> {
381
+ const out = new Set<string>()
382
+ const dirToName = new Map<string, string>()
383
+ for (const p of projects) dirToName.set(p.dir, p.name)
384
+ for (const [rel, bytes] of atBase) {
385
+ const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
386
+ if (!dirToName.has(dir)) continue
387
+ let parent: string | undefined
388
+ for (let d = path.dirname(dir); parent === undefined; d = path.dirname(d)) {
389
+ parent = dirToName.get(d)
390
+ if (path.dirname(d) === d) break
391
+ }
392
+ if (parent === undefined || out.has(parent)) continue
393
+ let named = false
394
+ if (bytes !== null) {
395
+ try {
396
+ const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
397
+ named = typeof name === 'string' && name !== ''
398
+ } catch {
399
+ // A manifest that did not parse at the base named no project.
400
+ }
401
+ }
402
+ if (!named) out.add(parent)
403
+ }
404
+ return out
405
+ }
406
+
407
+ /**
408
+ * Each of `files` (workspace-root-relative) as `ref` has it, in one `git
409
+ * cat-file --batch`: null for a file the ref does not have. `./` anchors
410
+ * each path to the cwd, the workspace root, as `gitBytesAt` does.
411
+ */
412
+ async function gitBlobsAt(
413
+ workspaceRoot: string,
414
+ ref: string,
415
+ files: readonly string[],
416
+ ): Promise<Map<string, Uint8Array | null>> {
417
+ const blobs = new Map<string, Uint8Array | null>()
418
+ // The batch reads one path per line; a name holding a newline asks alone.
419
+ const batched: string[] = []
420
+ for (const file of files) {
421
+ if (file.includes('\n')) blobs.set(file, await gitBytesAt(workspaceRoot, ref, file))
422
+ else batched.push(file)
423
+ }
424
+ if (batched.length === 0) return blobs
425
+ const proc = spawnGit(
426
+ ['cat-file', '--batch'],
427
+ workspaceRoot,
428
+ new TextEncoder().encode(batched.map((f) => `${ref}:./${f}\n`).join('')),
429
+ )
430
+ const [out, stderr, exit] = await Promise.all([
431
+ new Response(proc.stdout).bytes(),
432
+ new Response(proc.stderr).text(),
433
+ proc.exited,
434
+ ])
435
+ if (exit !== 0) {
436
+ throw new UserError(`git cat-file --batch failed (exit ${exit}): ${stderr.trim()}`)
437
+ }
438
+ let at = 0
439
+ for (const file of batched) {
440
+ const eol = out.indexOf(0x0a, at)
441
+ if (eol === -1) throw new UserError(`git cat-file --batch ended before ${ref}:./${file}`)
442
+ const header = new TextDecoder().decode(out.subarray(at, eol))
443
+ at = eol + 1
444
+ // `<oid> <type> <size>`, or `<spec> missing` / `<spec> ambiguous`.
445
+ const m = /^[0-9a-f]{40,64} (\w+) (\d+)$/.exec(header)
446
+ if (m === null) {
447
+ blobs.set(file, null)
448
+ continue
449
+ }
450
+ const size = Number(m[2])
451
+ blobs.set(file, m[1] === 'blob' ? out.slice(at, at + size) : null)
452
+ at += size + 1
453
+ }
454
+ return blobs
455
+ }
456
+
315
457
  /**
316
458
  * Whether `changed` holds the workspace config or a file it imports by
317
459
  * relative specifier. Its plugins' `config` and `project` stages shape
@@ -505,6 +647,14 @@ function isDirectory(abs: string): boolean {
505
647
  }
506
648
  }
507
649
 
650
+ function realpathOr(p: string): string {
651
+ try {
652
+ return realpathSync(p)
653
+ } catch {
654
+ return p
655
+ }
656
+ }
657
+
508
658
  function projectsContaining(
509
659
  workspaceRoot: string,
510
660
  changedRelPaths: readonly string[],
@@ -519,6 +669,19 @@ function projectsContaining(
519
669
  // pays for.
520
670
  const dirToName = new Map<string, string>()
521
671
  for (const p of projects) dirToName.set(p.dir, p.name)
672
+ // A member linked in from elsewhere in the tree (`pkgs/b -> ../ext/b`) is
673
+ // indexed by its link, and git reports its files at their real place
674
+ // (`ext/b/src/a.txt`), which resolved to no project: an edit there
675
+ // selected nothing (item 1079). Its real place under the root is indexed
676
+ // too. One realpath per member, and only on an --affected run.
677
+ const realRoot = realpathOr(workspaceRoot)
678
+ for (const p of projects) {
679
+ const real = realpathOr(p.dir)
680
+ const rel = path.relative(realRoot, real)
681
+ if (rel.startsWith('..') || path.isAbsolute(rel)) continue
682
+ const spelled = path.resolve(workspaceRoot, rel)
683
+ if (spelled !== p.dir && !dirToName.has(spelled)) dirToName.set(spelled, p.name)
684
+ }
522
685
  const owned = new Set<string>()
523
686
  for (const rel of changedRelPaths) {
524
687
  let dir = path.resolve(workspaceRoot, rel)
@@ -201,8 +201,6 @@ export const PURE_CORE_EXPORTS: ReadonlySet<string> = new Set([
201
201
  'normalizeGlob',
202
202
  'isLiteralPattern',
203
203
  'PLUGIN_HOOKS',
204
- 'TASK_STATUSES',
205
- 'PERSISTENT_TASK_NAMES',
206
204
  ])
207
205
 
208
206
  /** Whether an `import`/`export … from '@vzn/vx'` statement takes only pure values. */
@@ -45,7 +45,10 @@ self.onmessage = async (e) => {
45
45
  const { id, path } = e.data
46
46
  try {
47
47
  const ns = await import(path)
48
- const mod = ns?.default
48
+ // Awaited as the in-process load's async return flattens it: a Promise
49
+ // default loaded on a run and was refused as "an instance of Promise"
50
+ // on every later evaluation in the process (D-5).
51
+ const mod = await ns?.default
49
52
  const isObject = mod !== null && typeof mod === 'object'
50
53
  const nonJson = isObject ? nonJsonPaths(mod) : []
51
54
  postMessage({