@vzn/vx 0.0.635 → 0.0.637

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 (141) hide show
  1. package/README.md +47 -19
  2. package/docs/caching.md +222 -210
  3. package/docs/cli.md +699 -664
  4. package/docs/execution.md +67 -75
  5. package/docs/features.md +44 -40
  6. package/docs/patterns.md +31 -32
  7. package/docs/schema.md +418 -202
  8. package/docs/security.md +76 -39
  9. package/launcher.sh +43 -0
  10. package/package.json +5 -5
  11. package/schemas/cache.json +41 -19
  12. package/schemas/info.json +23 -11
  13. package/schemas/last.json +48 -2
  14. package/schemas/lock.json +1 -1
  15. package/schemas/plan.json +2 -2
  16. package/schemas/show.json +13 -4
  17. package/schemas/why.json +15 -1
  18. package/skills/vx/SKILL.md +16 -12
  19. package/src/bin.ts +17 -2
  20. package/src/cache/archive.ts +11 -0
  21. package/src/cache/cache.ts +105 -53
  22. package/src/cache/git-inputs.ts +277 -63
  23. package/src/cache/index.ts +7 -1
  24. package/src/cache/inputs.ts +125 -35
  25. package/src/cache/key-fold.ts +8 -7
  26. package/src/cache/layer.ts +8 -17
  27. package/src/cache/layered-cache.ts +16 -102
  28. package/src/cache/policy.ts +25 -29
  29. package/src/cache/run-history.ts +36 -14
  30. package/src/cache/schema.ts +21 -23
  31. package/src/cache/scoped-cache.ts +224 -0
  32. package/src/cli/cache.ts +150 -28
  33. package/src/cli/completions.ts +2 -2
  34. package/src/cli/core-alias.ts +1 -1
  35. package/src/cli/foreign-flags.ts +13 -12
  36. package/src/cli/help.ts +94 -61
  37. package/src/cli/index.ts +42 -13
  38. package/src/cli/info.ts +38 -23
  39. package/src/cli/init.ts +115 -406
  40. package/src/cli/last.ts +145 -4
  41. package/src/cli/lock.ts +333 -111
  42. package/src/cli/plan-format.ts +2 -2
  43. package/src/cli/plugin-commands.ts +5 -9
  44. package/src/cli/plugin-templates.ts +10 -10
  45. package/src/cli/run.ts +123 -144
  46. package/src/cli/select.ts +33 -39
  47. package/src/cli/show.ts +17 -19
  48. package/src/cli/task-verb.ts +2 -9
  49. package/src/cli/upgrade.ts +402 -145
  50. package/src/cli/watch-cycle.ts +9 -2
  51. package/src/cli/watch-filter.ts +33 -60
  52. package/src/cli/watch-fs.ts +29 -16
  53. package/src/cli/watch-judge.ts +12 -3
  54. package/src/cli/watch-set.ts +16 -42
  55. package/src/cli/watch.ts +188 -215
  56. package/src/cli/why.ts +28 -17
  57. package/src/cli/workspace-config.ts +31 -68
  58. package/src/config.ts +191 -71
  59. package/src/exec/env.ts +1 -31
  60. package/src/exec/executor.ts +1 -5
  61. package/src/exec/kill-tree.ts +2 -1
  62. package/src/exec/sandbox-runtime.ts +12 -1
  63. package/src/exec/sandbox-violations.ts +9 -1
  64. package/src/graph/dependency-spec.ts +1 -1
  65. package/src/graph/index.ts +1 -0
  66. package/src/graph/priorities.ts +1 -1
  67. package/src/graph/scheduler.ts +19 -7
  68. package/src/graph/task-graph.ts +44 -20
  69. package/src/handoff.ts +169 -0
  70. package/src/index.ts +25 -57
  71. package/src/orchestrator/affected-tasks.ts +7 -5
  72. package/src/orchestrator/command-windows.ts +44 -0
  73. package/src/orchestrator/deferred-outputs.ts +12 -10
  74. package/src/orchestrator/doctor.ts +98 -110
  75. package/src/orchestrator/download-policy.ts +3 -3
  76. package/src/orchestrator/events.ts +19 -3
  77. package/src/orchestrator/excluded-keys.ts +0 -2
  78. package/src/orchestrator/execute-task.ts +192 -70
  79. package/src/orchestrator/fingerprint-watch.ts +18 -15
  80. package/src/orchestrator/forecast.ts +10 -2
  81. package/src/orchestrator/framed-output.ts +8 -3
  82. package/src/orchestrator/graph-stage.ts +5 -5
  83. package/src/orchestrator/history.ts +70 -30
  84. package/src/orchestrator/hit-restore.ts +15 -3
  85. package/src/orchestrator/index.ts +18 -4
  86. package/src/orchestrator/local-shortcircuit.ts +2 -1
  87. package/src/orchestrator/lockfile-claim.ts +79 -20
  88. package/src/orchestrator/logger.ts +71 -39
  89. package/src/orchestrator/metrics.ts +71 -6
  90. package/src/orchestrator/miss-save.ts +3 -0
  91. package/src/orchestrator/options.ts +43 -28
  92. package/src/orchestrator/output-host.ts +72 -0
  93. package/src/orchestrator/plan.ts +6 -8
  94. package/src/orchestrator/plugin-host.ts +87 -63
  95. package/src/orchestrator/plugin.ts +94 -60
  96. package/src/orchestrator/prepare.ts +114 -73
  97. package/src/orchestrator/projects.ts +102 -99
  98. package/src/orchestrator/remote-prefetch.ts +2 -1
  99. package/src/orchestrator/run-context.ts +25 -6
  100. package/src/orchestrator/run-failures.ts +17 -8
  101. package/src/orchestrator/run-lock.ts +1 -1
  102. package/src/orchestrator/run-report.ts +9 -9
  103. package/src/orchestrator/run-settings.ts +48 -0
  104. package/src/orchestrator/run.ts +127 -88
  105. package/src/orchestrator/sandbox-fix.ts +39 -20
  106. package/src/orchestrator/sandbox-request.ts +84 -13
  107. package/src/orchestrator/signals.ts +1 -1
  108. package/src/orchestrator/stable-keys.ts +13 -11
  109. package/src/orchestrator/status-line.ts +48 -6
  110. package/src/orchestrator/summary.ts +46 -62
  111. package/src/orchestrator/task-hash.ts +12 -83
  112. package/src/orchestrator/telemetry.ts +3 -3
  113. package/src/settings.ts +417 -0
  114. package/src/util/errors.ts +1 -1
  115. package/src/util/essential-env.ts +31 -0
  116. package/src/util/hide-env.ts +107 -0
  117. package/src/util/index.ts +8 -2
  118. package/src/util/paths.ts +10 -0
  119. package/src/util/settle.ts +49 -30
  120. package/src/util/size.ts +23 -4
  121. package/src/util/timing.ts +3 -2
  122. package/src/util/ulid.ts +1 -1
  123. package/src/util/verbs.ts +0 -15
  124. package/src/workspace/affected.ts +77 -222
  125. package/src/workspace/config-eval.ts +15 -20
  126. package/src/workspace/config-imports.ts +78 -3
  127. package/src/workspace/config-schema.ts +251 -241
  128. package/src/workspace/dependency-rules.ts +57 -0
  129. package/src/workspace/filter.ts +2 -2
  130. package/src/workspace/fingerprint.ts +11 -121
  131. package/src/workspace/index.ts +22 -27
  132. package/src/workspace/load-reads.ts +4 -4
  133. package/src/workspace/lockfile.ts +96 -140
  134. package/src/workspace/migration.ts +242 -146
  135. package/src/workspace/package-graph.ts +30 -216
  136. package/src/workspace/project-loader.ts +80 -285
  137. package/src/workspace/repo-id.ts +2 -1
  138. package/src/workspace/workspace.ts +362 -831
  139. package/src/cache/config-evals.ts +0 -130
  140. package/src/workspace/config-cache.ts +0 -661
  141. package/src/workspace/migrate-scripts.ts +0 -1121
@@ -6,25 +6,23 @@
6
6
  // index + unstaged — so it captures everything you touched and nothing
7
7
  // the base branch moved on with. Matches Turbo's `[<since>]` semantics.
8
8
 
9
- import { realpathSync, statSync } from 'node:fs'
10
9
  import { realpath } from 'node:fs/promises'
11
10
  import path from 'node:path'
12
11
  import {
13
12
  executablePath,
14
- UserError,
13
+ gitEnv,
15
14
  gitSpawnRefusal,
16
15
  isExecutableMissing,
17
16
  isInstalledPath,
18
17
  notAWorkTree,
19
18
  relPosix,
19
+ UserError,
20
20
  } from '../util/index.js'
21
21
  import { LOCKFILE_NAME } from './lockfile.js'
22
22
  import { configImportOwners, realpathOr } from './config-imports.js'
23
- import { configImports } from './config-cache.js'
24
23
  import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
25
- import { bunPatchFiles, WORKSPACE_FINGERPRINT_FILES } from './fingerprint.js'
26
- import { buildPackageGraph } from './package-graph.js'
27
- import type { PackageJson, ProjectMeta } from './workspace.js'
24
+ import { bunPatchFiles } from './fingerprint.js'
25
+ import { PROJECT_CONFIG_FILENAMES, type ProjectMeta } from './workspace.js'
28
26
 
29
27
  /**
30
28
  * Every git call here goes through these two: a git that is not on PATH
@@ -38,7 +36,13 @@ function spawnGitSync(
38
36
  stderr: 'pipe' | 'ignore' = 'pipe',
39
37
  ): ReturnType<typeof Bun.spawnSync> {
40
38
  try {
41
- return Bun.spawnSync({ cmd: [executablePath('git'), ...args], cwd, stdout: 'pipe', stderr })
39
+ return Bun.spawnSync({
40
+ cmd: [executablePath('git'), ...args],
41
+ env: gitEnv(),
42
+ cwd,
43
+ stdout: 'pipe',
44
+ stderr,
45
+ })
42
46
  } catch (err) {
43
47
  if (isExecutableMissing(err)) throw gitSpawnRefusal(cwd)
44
48
  throw err
@@ -53,6 +57,7 @@ function spawnGit(
53
57
  try {
54
58
  return Bun.spawn({
55
59
  cmd: [executablePath('git'), ...args],
60
+ env: gitEnv(),
56
61
  cwd,
57
62
  stdin: stdin ?? 'ignore',
58
63
  stdout: 'pipe',
@@ -91,17 +96,10 @@ export interface AffectedArgs {
91
96
  /**
92
97
  * The fingerprint files a plugin claims (`VxPlugin.fingerprint`) and its
93
98
  * answer for a change to one. Resolved lazily: loading the workspace
94
- * file costs an evaluation, and a diff that touches no fingerprint file
95
- * — the common one — never needs it.
99
+ * file costs an evaluation, and a diff that touches no root file — the
100
+ * common one — never needs it.
96
101
  */
97
102
  fingerprintClaims?: () => Promise<FingerprintClaims>
98
- /**
99
- * The cross-project `dependsOn` edges, project → the projects its tasks
100
- * name. Asked only when a package's name moved or it was removed: a
101
- * config naming `dependsOn: ['lib#build']` breaks on it, which the
102
- * package graph cannot see (item 1085).
103
- */
104
- taskEdges?: () => Promise<ReadonlyMap<string, readonly string[]>>
105
103
  /**
106
104
  * The untracked files, workspace-relative, from a walk the caller shares
107
105
  * with the run (`GitEnumeration.untracked`); null or absent spawns `git
@@ -135,7 +133,7 @@ export async function affectedProjects(args: AffectedArgs): Promise<Set<string>>
135
133
  * projects, each one's changed paths relative to it, and the projects a
136
134
  * change reaches as a whole — every task in one is affected whatever its
137
135
  * inputs say, because the reason was no path of its own (a lockfile claim,
138
- * a manifest edge at the base, a config import, a nested repository, a
136
+ * a config import, a nested repository, a
139
137
  * workspace-wide file).
140
138
  */
141
139
  export interface AffectedChanges {
@@ -246,7 +244,7 @@ export async function affectedChanges(
246
244
  gitPaths(args.workspaceRoot, ['ls-files', '--others', '--exclude-standard', '-z']))(),
247
245
  ])
248
246
 
249
- // vx-lock.json (workspace-root metadata) is excluded like a gitignored
247
+ // .vx/projects.lock.json (workspace-root metadata) is excluded like a gitignored
250
248
  // file: re-running `vx lock` must not mark every project affected.
251
249
  // An untracked file under `node_modules` is an install, which the input
252
250
  // enumeration drops too (`isInstalledPath`); a tracked one is a source.
@@ -258,62 +256,54 @@ export async function affectedChanges(
258
256
  const nested = new Set(diffed.gitlinks)
259
257
  for (const rel of untracked) if (rel.endsWith('/')) nested.add(rel)
260
258
 
261
- // A lockfile or workspace-definition change re-keys EVERY task, because the
262
- // workspace fingerprint folds those files into every cache key. Mapping
263
- // changed paths to project directories cannot see that — they sit at the
264
- // root and belong to no project — so `--affected` selected ZERO projects for
265
- // a change that invalidates the entire cache. `vx run test --affected` after
266
- // a `pnpm update` exited 0 having run nothing, which is precisely the
267
- // failure `docs/cli.md` warns against in the sentence it states as a
268
- // principle: "input hashing sees it, so `--affected` must too."
259
+ // A root file a plugin CLAIMS (`VxPlugin.fingerprint`: a lockfile, an
260
+ // install setting) re-keys tasks through what its claimant folds, and it
261
+ // sits at the root, owned by no project or by the root alone: mapping
262
+ // paths to directories selected ZERO projects for a lockfile bump that
263
+ // re-keyed them all, and `vx run test --affected` after a `pnpm update`
264
+ // exited 0 having run nothing — the failure `docs/cli.md` warns against:
265
+ // "input hashing sees it, so `--affected` must too." So selection asks the
266
+ // claimant the question its key answers, with the bytes at the base ref
267
+ // and in the working tree; the answer is unioned with the path-owned
268
+ // projects below, and only "cannot tell" widens. Core keys no root file
269
+ // itself, so an unclaimed one is a path like any other.
269
270
  //
270
271
  // Selection is not hashed, so widening it here changes no cache key.
271
- //
272
- // A file a plugin CLAIMS is the exception: the key folds what the plugin
273
- // says per project, so selection asks the plugin the same question, with
274
- // the bytes at the base ref and in the working tree. Its answer is
275
- // unioned with the path-owned projects below; only "cannot tell" widens.
276
272
  const everything = (): AffectedChanges => {
277
273
  const all = new Set(args.projects.map((p) => p.name))
278
274
  return { projects: all, changed, paths: new Map(), whole: all }
279
275
  }
280
- if (await workspaceConfigChanged(args.workspaceRoot, changed)) return everything()
281
- const fingerprintChanged = changed.filter((p) => FINGERPRINT_SET.has(p))
282
- // A patch bun.lock names folds into every key, core's fingerprint and
283
- // `bun()`'s claim alike, so its edit widens as a lockfile edit does. A
284
- // patch the lockfile no longer names moved bun.lock itself.
276
+ if (workspaceConfigChanged(changed)) return everything()
277
+ // Loading the workspace file costs an evaluation; only a changed ROOT
278
+ // name can be claimed, so a diff inside the projects never loads it.
279
+ let claims: FingerprintClaims | undefined
280
+ const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
281
+ (claims ??= await args.fingerprintClaims?.())
282
+ // A patch bun.lock names folds into the key of the plugin that claims
283
+ // bun.lock (`bun()`; the lockfile records no hash of it), so its edit
284
+ // widens as a lockfile edit does. A patch the lockfile no longer names
285
+ // moved bun.lock itself.
285
286
  if (changed.length > 0) {
286
287
  const lock = await bytesOrNull(path.join(args.workspaceRoot, 'bun.lock'))
287
288
  if (lock !== null) {
288
289
  const patches = new Set(bunPatchFiles(lock))
289
- if (changed.some((p) => patches.has(p))) return everything()
290
+ if (changed.some((p) => patches.has(p)) && (await loadClaims())?.files.has('bun.lock')) {
291
+ return everything()
292
+ }
290
293
  }
291
294
  }
292
295
  const claimedOwned = new Set<string>()
293
- // A claim may also name a root file core does not fold (`turbo.json`,
294
- // read by `turbo()`'s stages): it re-keys tasks through their resolved
295
- // configs and belongs to no project (item 961). Only a changed ROOT name
296
- // can be one, so a diff inside the projects never loads the claims.
297
- const rootNamesChanged = changed.filter((p) => !p.includes('/') && !FINGERPRINT_SET.has(p))
298
- let claims: FingerprintClaims | undefined
299
- if (
300
- (fingerprintChanged.length > 0 || rootNamesChanged.length > 0) &&
301
- args.fingerprintClaims !== undefined
302
- ) {
303
- claims = await args.fingerprintClaims()
304
- for (const file of rootNamesChanged) if (claims.files.has(file)) fingerprintChanged.push(file)
305
- }
306
- if (fingerprintChanged.length > 0) {
307
- for (const file of fingerprintChanged) {
308
- if (claims === undefined || !claims.files.has(file)) return everything()
309
- const answer = await claims.affected({
310
- file,
311
- before: await gitBytesAt(args.workspaceRoot, base, file),
312
- after: await bytesOrNull(path.join(args.workspaceRoot, file)),
313
- })
314
- if (answer === undefined) return everything()
315
- for (const name of answer) claimedOwned.add(name)
316
- }
296
+ const rootNamesChanged = changed.filter((p) => !p.includes('/'))
297
+ const claimed = rootNamesChanged.length > 0 ? await loadClaims() : undefined
298
+ for (const file of rootNamesChanged) {
299
+ if (claimed === undefined || !claimed.files.has(file)) continue
300
+ const answer = await claimed.affected({
301
+ file,
302
+ before: await gitBytesAt(args.workspaceRoot, base, file),
303
+ after: await bytesOrNull(path.join(args.workspaceRoot, file)),
304
+ })
305
+ if (answer === undefined) return everything()
306
+ for (const name of answer) claimedOwned.add(name)
317
307
  }
318
308
 
319
309
  const realDirs = new Map(
@@ -342,27 +332,11 @@ export async function affectedChanges(
342
332
  )
343
333
  for (const name of claimedOwned) owned.add(name)
344
334
 
345
- // A manifest edit can drop an edge today's graph no longer shows: a
346
- // package the change REMOVED is no project now (a `git rm -r pkgs/lib`,
347
- // or `lib` dropped from `workspaces`, item 959), and one whose `version`
348
- // or `name` moved no longer satisfies what a dependent declares. Either
349
- // re-keys the dependent through its upstream, while containment maps the
350
- // change to the package alone and the dependents walk sees only today's
351
- // graph, so `--affected` left `app#build` out (D-3).
352
- const manifests = changed.filter((rel) => path.posix.basename(rel) === 'package.json')
353
- if (manifests.length > 0) {
354
- const atBase = await gitBlobsAt(args.workspaceRoot, base, manifests)
355
- const dependents = await dependentsAtBase(
356
- args.workspaceRoot,
357
- atBase,
358
- args.projects,
359
- args.taskEdges,
360
- )
361
- if (dependents === undefined) return everything()
362
- for (const name of dependents) {
363
- owned.add(name)
364
- whole.add(name)
365
- }
335
+ // A config that makes an existing directory a project took files from
336
+ // the project above it.
337
+ const configs = changed.filter((rel) => CONFIG_NAMES.has(path.posix.basename(rel)))
338
+ if (configs.length > 0) {
339
+ const atBase = await gitBlobsAt(args.workspaceRoot, base, configs)
366
340
  for (const name of parentsOfNewNested(args.workspaceRoot, atBase, args.projects)) {
367
341
  owned.add(name)
368
342
  whole.add(name)
@@ -400,102 +374,16 @@ export async function affectedChanges(
400
374
  return done()
401
375
  }
402
376
 
403
- /**
404
- * The projects whose workspace dependencies at the base differ from
405
- * today's: the package graph is built again over the changed manifests as
406
- * the base had them, and every project whose `directDeps` (the edges its
407
- * key folds) moved is selected. `undefined` (every project) when the root
408
- * manifest's `workspaces` moved: which packages left is a discovery at the
409
- * base, and selection cannot tell cheaply. `atBase` holds each changed
410
- * manifest as the base had it, read in one `git cat-file --batch`.
411
- */
412
- async function dependentsAtBase(
413
- workspaceRoot: string,
414
- atBase: ReadonlyMap<string, Uint8Array | null>,
415
- projects: readonly ProjectMeta[],
416
- taskEdges: AffectedArgs['taskEdges'],
417
- ): Promise<Set<string> | undefined> {
418
- const byDir = new Map<string, ProjectMeta>()
419
- for (const p of projects) byDir.set(p.dir, p)
420
- const nameNow = new Map(projects.map((p) => [p.dir, p.name]))
421
- // Base names no project holds today: a removed or renamed package.
422
- const gone = new Set<string>()
423
- const changedDirs = new Set<string>()
424
- for (const rel of atBase.keys()) {
425
- if (rel === 'package.json') {
426
- const workspacesOf = (bytes: Uint8Array | null): string => {
427
- if (bytes === null) return ''
428
- try {
429
- return JSON.stringify(
430
- (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).workspaces ?? null,
431
- )
432
- } catch {
433
- return 'unreadable'
434
- }
435
- }
436
- const before = workspacesOf(atBase.get(rel) ?? null)
437
- const after = workspacesOf(await bytesOrNull(path.join(workspaceRoot, rel)))
438
- if (before !== after) return undefined
439
- }
440
- const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
441
- changedDirs.add(dir)
442
- byDir.delete(dir)
443
- const bytes = atBase.get(rel)
444
- if (bytes === undefined || bytes === null) continue
445
- let pkg: PackageJson
446
- try {
447
- pkg = JSON.parse(new TextDecoder().decode(bytes)) as PackageJson
448
- } catch {
449
- // A manifest that did not parse at the base named no package.
450
- continue
451
- }
452
- if (
453
- pkg === null ||
454
- typeof pkg !== 'object' ||
455
- typeof pkg.name !== 'string' ||
456
- pkg.name === ''
457
- ) {
458
- continue
459
- }
460
- // Today's catalogs, or both graphs would differ on every `catalog:`
461
- // entry. A catalog edit is a `pnpm-workspace.yaml` or root manifest
462
- // edit, which the fingerprint widening and the root's own change answer.
463
- const catalogs = projects[0]?.catalogs
464
- byDir.set(dir, {
465
- name: pkg.name,
466
- dir,
467
- packageJson: pkg,
468
- configPath: null,
469
- ...(catalogs === undefined ? {} : { catalogs }),
470
- })
471
- if (nameNow.get(dir) !== pkg.name) gone.add(pkg.name)
472
- }
473
- const now = buildPackageGraph([...projects])
474
- const then = buildPackageGraph([...byDir.values()])
475
- const out = new Set<string>()
476
- for (const p of projects) {
477
- // A project whose own manifest changed is owned by containment.
478
- if (changedDirs.has(p.dir)) continue
479
- const a = now.directDeps(p.name)
480
- const b = then.directDeps(p.name)
481
- if (a.length !== b.length || a.some((d, i) => d !== b[i])) out.add(p.name)
482
- }
483
- if (gone.size > 0 && taskEdges !== undefined) {
484
- for (const [project, targets] of await taskEdges()) {
485
- if (targets.some((t) => gone.has(t))) out.add(project)
486
- }
487
- }
488
- return out
489
- }
377
+ const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
490
378
 
491
379
  /**
492
380
  * The projects a NEW nested project took files from. A project's inputs
493
- * stop at every project below it, so a `package.json` that makes an
494
- * existing directory a project re-keys the project above it — its
495
- * `**` lost the directory's files — while containment maps the change to
496
- * the new project alone, and `--affected` left the re-keyed parent out.
497
- * "New" is judged at the base: no manifest there, or one with no name,
498
- * which discovery skips. `atBase` holds each changed manifest there.
381
+ * stop at every project below it, so a config that makes an existing
382
+ * directory a project re-keys the project above it — its `**` lost the
383
+ * directory's files — while containment maps the change to the new
384
+ * project alone, and `--affected` left the re-keyed parent out. "New" is
385
+ * judged at the base: none of the changed configs in its directory was
386
+ * there. `atBase` holds each changed config there.
499
387
  */
500
388
  function parentsOfNewNested(
501
389
  workspaceRoot: string,
@@ -507,7 +395,10 @@ function parentsOfNewNested(
507
395
  // dir discovered by readdir keeps the spelling it was created with.
508
396
  const dirToName = new Map<string, string>()
509
397
  for (const p of projects) dirToName.set(p.dir.normalize('NFC'), p.name)
510
- for (const [rel, bytes] of atBase) {
398
+ const existed = new Set<string>()
399
+ for (const [rel, bytes] of atBase) if (bytes !== null) existed.add(path.posix.dirname(rel))
400
+ for (const rel of atBase.keys()) {
401
+ if (existed.has(path.posix.dirname(rel))) continue
511
402
  const dir = path.resolve(workspaceRoot, path.posix.dirname(rel))
512
403
  if (!dirToName.has(dir)) continue
513
404
  let parent: string | undefined
@@ -515,17 +406,7 @@ function parentsOfNewNested(
515
406
  parent = dirToName.get(d)
516
407
  if (path.dirname(d) === d) break
517
408
  }
518
- if (parent === undefined || out.has(parent)) continue
519
- let named = false
520
- if (bytes !== null) {
521
- try {
522
- const name = (JSON.parse(new TextDecoder().decode(bytes)) as PackageJson).name
523
- named = typeof name === 'string' && name !== ''
524
- } catch {
525
- // A manifest that did not parse at the base named no project.
526
- }
527
- }
528
- if (!named) out.add(parent)
409
+ if (parent !== undefined) out.add(parent)
529
410
  }
530
411
  return out
531
412
  }
@@ -582,44 +463,18 @@ async function gitBlobsAt(
582
463
  }
583
464
 
584
465
  /**
585
- * Whether `changed` holds the workspace config or a file it imports by
586
- * relative specifier. Its plugins' `config` and `project` stages shape
587
- * every project's resolved config, so such an edit can re-key any task,
588
- * and selection cannot tell which: an edit that changed every key
589
- * selected nothing (item 953). The fingerprint leaves the file out (its
590
- * placement plugins must not split a cache between machines); a stage it
591
- * installs re-keys through the resolved configs instead. Selection is not
592
- * hashed, so widening here changes no key.
466
+ * Whether `changed` holds the workspace config. Its plugins' `config` and
467
+ * `project` stages shape every project's resolved config, so such an edit
468
+ * can re-key any task, and selection cannot tell which: an edit that
469
+ * changed every key selected nothing (item 953). No key folds the file
470
+ * (its placement plugins must not split a cache between machines); a
471
+ * stage it installs re-keys through the resolved configs instead.
472
+ * Selection is not hashed, so widening here changes no key.
593
473
  */
594
- async function workspaceConfigChanged(
595
- workspaceRoot: string,
596
- changed: readonly string[],
597
- ): Promise<boolean> {
598
- if (changed.length === 0) return false
599
- const set = new Set(changed)
600
- if (WORKSPACE_CONFIG_FILENAMES.some((n) => set.has(n))) return true
601
- const config = WORKSPACE_CONFIG_FILENAMES.map((n) => path.join(workspaceRoot, n)).find((f) => {
602
- try {
603
- return statSync(f).isFile()
604
- } catch {
605
- return false
606
- }
607
- })
608
- if (config === undefined) return false
609
- const root = realpathSync(workspaceRoot)
610
- for (const file of await configImports(config)) {
611
- if (set.has(relPosix(root, file))) return true
612
- }
613
- return false
474
+ function workspaceConfigChanged(changed: readonly string[]): boolean {
475
+ return WORKSPACE_CONFIG_FILENAMES.some((n) => changed.includes(n))
614
476
  }
615
477
 
616
- /**
617
- * The fingerprint files as workspace-root-relative paths. Read from the same
618
- * constant the fingerprint itself walks, so a new lockfile format cannot be
619
- * taught to one surface and not the other.
620
- */
621
- const FINGERPRINT_SET: ReadonlySet<string> = new Set(WORKSPACE_FINGERPRINT_FILES)
622
-
623
478
  /**
624
479
  * A root file's bytes at `ref`, or null when the ref has no such file.
625
480
  * `./` anchors the path to the cwd (the workspace root) rather than the
@@ -6,7 +6,7 @@
6
6
  // `import './preset.js'` inside a config resolves to the same key no
7
7
  // matter what query the entry carries — a busted entry re-evaluates
8
8
  // against a STALE preset. Shared presets are the documented composition
9
- // mechanism (`vx migrate` generates one), so through a whole `vx watch`
9
+ // mechanism (`bunx @vzn/vx-migrate` generates one), so through a whole `vx watch`
10
10
  // session a preset edit was invisible; and because the resolved config
11
11
  // feeds the cache key, vx answered `up-to-date` for a command that had
12
12
  // changed on disk.
@@ -36,7 +36,7 @@
36
36
  // with the message the first load gives (item 701).
37
37
 
38
38
  import path from 'node:path'
39
- import { MAX_TIMEOUT_MS, UserError } from '../util/index.js'
39
+ import { MAX_TIMEOUT_MS, parseDuration, UserError } from '../util/index.js'
40
40
  import { nonJsonMessage, nonJsonPaths, type NonJsonValue } from './json-data.js'
41
41
 
42
42
  /** Why a config's `process.exit` throws, on both load paths (D-65). */
@@ -76,10 +76,10 @@ export const WATCHED_BUILTIN_NAMES = [
76
76
 
77
77
  const WORKER_SRC = `
78
78
  const nonJsonPaths = ${nonJsonPaths.toString()}
79
- // The parent's stdout is a verb's JSON or vx mcp's JSON-RPC stream, and a
80
- // config printing on a repeat load wrote into it: the server's own redirect
81
- // covers the parent thread only (D-64). This worker only evaluates configs,
82
- // so every route to fd 1 is stderr's, Bun's own included (item 1069).
79
+ // The parent's stdout is a verb's JSON, and a config printing on a repeat
80
+ // load wrote into it: the parent's own redirect covers the parent thread
81
+ // only (D-64). This worker only evaluates configs, so every route to fd 1
82
+ // is stderr's, Bun's own included (item 1069).
83
83
  // Synchronous, before any message: an awaited import of the Console left
84
84
  // the first evaluation racing its own redirect (D-73).
85
85
  {
@@ -262,22 +262,17 @@ export function thrownValueMessage(kind: string, configPath: string, shown: stri
262
262
  /**
263
263
  * How long a single config evaluation may take, in the worker or in process
264
264
  * (D-66); in process a synchronous loop holds the thread the deadline needs. Real evaluations are ~10 ms; this exists only so a hung config or a
265
- * killed worker cannot stall a run or a long-lived process indefinitely. Read
266
- * per call so a test can drive the deadline instead of waiting it out.
265
+ * killed worker cannot stall a run or a long-lived process indefinitely.
266
+ * `config.workerTimeout` (`VX_CONFIG_WORKER_TIMEOUT`, read before any
267
+ * workspace loads), per call so a test can drive the deadline.
267
268
  */
268
269
  export function evalBudgetMs(): number {
269
- const raw = process.env['VX_CONFIG_WORKER_TIMEOUT_MS']
270
- // Same kind of value as the teardown deadline, so the same treatment: a
271
- // BOUND on a worker that may be wedged, with no "no limit" reading. Falls
272
- // back rather than clamping, because honouring ~24.8 days would hang
273
- // `vx watch` forever on a worker the OS killed — and unbounded makes it 1 ms,
274
- // so EVERY config load times out instead. Both ends break the same feature,
275
- // and `0` is the second end: it fired on the next tick (D-157).
276
- if (raw !== undefined && /^[0-9]+$/.test(raw)) {
277
- const n = Number(raw)
278
- if (n > 0 && n <= MAX_TIMEOUT_MS) return n
279
- }
280
- return 30_000
270
+ // A bound on a worker that may be wedged, with no "no limit" reading: 0
271
+ // fired on the next tick (D-157) and a timer past the platform's range at
272
+ // 1 ms, so both fall back. The CLI refuses either before a run
273
+ // (checkSettingsEnv); an embedder's falls back.
274
+ const n = parseDuration(process.env['VX_CONFIG_WORKER_TIMEOUT'] ?? '')
275
+ return n !== null && n > 0 && n <= MAX_TIMEOUT_MS ? n : 30_000
281
276
  }
282
277
 
283
278
  const pending = new Map<number, Pending>()
@@ -17,7 +17,7 @@
17
17
  // affordable at all:
18
18
  //
19
19
  // - RELATIVE specifiers only. A bare specifier is a package; it moves when
20
- // the lockfile moves, which the workspace fingerprint already covers.
20
+ // the lockfile moves, which a lockfile plugin's key covers.
21
21
  // A tsconfig `paths` / `baseUrl` alias is the exception: Bun loads it
22
22
  // from disk (D-27).
23
23
  // - One reversed BFS from every config at once, each file read once
@@ -27,12 +27,15 @@
27
27
  // too, so an edit there moves the site's key. The walk stopped at the
28
28
  // first file of another project, and containment selected core alone
29
29
  // while the site's key moved (X-128).
30
+ //
31
+ // `configImports` walks one config's relative closure forward, for the
32
+ // files `vx watch` watches.
30
33
 
31
- import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs'
34
+ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from 'node:fs'
32
35
  import { realpath } from 'node:fs/promises'
33
36
  import { builtinModules } from 'node:module'
34
37
  import path from 'node:path'
35
- import { isOutOfFds } from '../util/index.js'
38
+ import { isOutOfFds, realPath } from '../util/index.js'
36
39
  import type { ProjectMeta } from './workspace.js'
37
40
 
38
41
  const BUILTINS = new Set(builtinModules)
@@ -467,3 +470,75 @@ export async function configImportOwners(a: ConfigImportOwnersArgs): Promise<Set
467
470
  }
468
471
  return selected
469
472
  }
473
+
474
+ /**
475
+ * The canonical directory Bun resolves `file`'s imports from: its REAL
476
+ * path's, so a config linked in from elsewhere imports its neighbours
477
+ * there, not beside the link (item 950).
478
+ */
479
+ function realDirOf(file: string): string {
480
+ if (lstatSync(file).isSymbolicLink()) return path.dirname(realPath(file))
481
+ return realPath(path.dirname(file))
482
+ }
483
+
484
+ /**
485
+ * Every file a config imports by relative specifier, transitively, outside
486
+ * `node_modules`, the config itself excluded. `vx watch` watches these: a
487
+ * shared preset outside the project (`../../shared/preset.mjs`, the way
488
+ * configs compose) changed what a run evaluates and no watcher saw it
489
+ * (item 949). A file that cannot be read ends its branch; an import that
490
+ * cannot be resolved is still listed, by the names it would have.
491
+ */
492
+ export async function configImports(configPath: string): Promise<string[]> {
493
+ const seen = new Set<string>([configPath])
494
+ const out: string[] = []
495
+ const queue = [configPath]
496
+ while (queue.length > 0) {
497
+ const file = queue.shift()!
498
+ let source: string
499
+ try {
500
+ source = await Bun.file(file).text()
501
+ } catch {
502
+ continue
503
+ }
504
+ let specs: string[]
505
+ try {
506
+ specs = scanner(TS_EXT.has(path.extname(file)) ? 'ts' : 'js')
507
+ .scanImports(source)
508
+ .map((i) => i.path)
509
+ } catch {
510
+ // A source the parser refuses (mid-edit) keeps the imports a plain
511
+ // match sees: watching too few files is what this list exists to
512
+ // prevent.
513
+ specs = [...source.matchAll(/\bfrom\s*['"]([^'"]+)['"]|\bimport\s*['"]([^'"]+)['"]/g)].map(
514
+ (m) => (m[1] ?? m[2])!,
515
+ )
516
+ }
517
+ let dir: string | undefined
518
+ for (const spec of specs) {
519
+ if (!spec.startsWith('./') && !spec.startsWith('../')) continue
520
+ let resolved: string
521
+ try {
522
+ dir ??= realDirOf(file)
523
+ resolved = realPath(Bun.resolveSync(spec, dir))
524
+ } catch {
525
+ // Missing for now, still imported: the config that names it fails
526
+ // to load until it is back, and a list without it left `vx watch`
527
+ // deaf to its return. Named as written, and not walked.
528
+ if (dir === undefined) continue
529
+ for (const ext of path.extname(spec) === '' ? RESOLVED_EXTENSIONS : ['']) {
530
+ const missing = path.resolve(dir, spec + ext)
531
+ if (seen.has(missing)) continue
532
+ seen.add(missing)
533
+ out.push(missing)
534
+ }
535
+ continue
536
+ }
537
+ if (seen.has(resolved) || resolved.split(path.sep).includes('node_modules')) continue
538
+ seen.add(resolved)
539
+ out.push(resolved)
540
+ queue.push(resolved)
541
+ }
542
+ }
543
+ return out
544
+ }