@vzn/vx 0.0.652 → 0.0.653

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 (167) hide show
  1. package/README.md +2 -2
  2. package/launcher.sh +2 -1
  3. package/package.json +5 -5
  4. package/schemas/error.json +1 -1
  5. package/schemas/show.json +1 -0
  6. package/skills/vx/SKILL.md +3 -3
  7. package/src/bin.ts +25 -3
  8. package/src/cache/archive.ts +196 -31
  9. package/src/cache/cache.ts +863 -225
  10. package/src/cache/checkpointer.ts +102 -0
  11. package/src/cache/file-hashes.ts +2 -1
  12. package/src/cache/fold-memo.ts +24 -4
  13. package/src/cache/fs-clock.ts +26 -0
  14. package/src/cache/git-inputs.ts +188 -22
  15. package/src/cache/history-stream.ts +22 -5
  16. package/src/cache/index.ts +10 -1
  17. package/src/cache/inputs.ts +573 -152
  18. package/src/cache/key-fold.ts +1 -1
  19. package/src/cache/layer.ts +23 -10
  20. package/src/cache/layered-cache.ts +28 -7
  21. package/src/cache/output-index.ts +106 -44
  22. package/src/cache/plugin-cache.ts +2 -2
  23. package/src/cache/policy.ts +6 -2
  24. package/src/cache/remote-lane.ts +3 -1
  25. package/src/cache/run-history.ts +50 -10
  26. package/src/cache/schema.ts +42 -9
  27. package/src/cli/cache.ts +147 -34
  28. package/src/cli/completions.ts +2 -2
  29. package/src/cli/core-alias.ts +9 -2
  30. package/src/cli/docs.ts +4 -2
  31. package/src/cli/help.ts +25 -2
  32. package/src/cli/index.ts +38 -7
  33. package/src/cli/info.ts +4 -3
  34. package/src/cli/init.ts +55 -20
  35. package/src/cli/last.ts +184 -41
  36. package/src/cli/lock.ts +187 -24
  37. package/src/cli/plan-format.ts +24 -21
  38. package/src/cli/plugin-commands.ts +6 -1
  39. package/src/cli/plugin-templates.ts +1 -1
  40. package/src/cli/run.ts +68 -34
  41. package/src/cli/select.ts +160 -64
  42. package/src/cli/show.ts +63 -18
  43. package/src/cli/task-verb.ts +12 -7
  44. package/src/cli/upgrade.ts +210 -76
  45. package/src/cli/watch-filter.ts +88 -15
  46. package/src/cli/watch-fs.ts +34 -47
  47. package/src/cli/watch-judge.ts +72 -8
  48. package/src/cli/watch-set.ts +6 -4
  49. package/src/cli/watch.ts +179 -25
  50. package/src/cli/why.ts +107 -33
  51. package/src/cli/workspace-config.ts +11 -1
  52. package/src/config.ts +9 -0
  53. package/src/exec/env.ts +3 -1
  54. package/src/exec/executor.ts +76 -15
  55. package/src/exec/kill-tree.ts +7 -7
  56. package/src/exec/runner.ts +270 -108
  57. package/src/exec/sandbox-binds.ts +109 -18
  58. package/src/exec/sandbox-runtime.ts +134 -37
  59. package/src/exec/sandbox-violations.ts +250 -46
  60. package/src/exec/spawn-off.ts +10 -5
  61. package/src/exec/warm-shell.ts +29 -7
  62. package/src/graph/folds.ts +89 -16
  63. package/src/graph/index.ts +8 -0
  64. package/src/graph/priorities.ts +8 -5
  65. package/src/graph/scheduler.ts +173 -38
  66. package/src/graph/stand-ins.ts +36 -0
  67. package/src/graph/task-graph.ts +327 -141
  68. package/src/graph/task-status.ts +5 -1
  69. package/src/handoff.ts +45 -10
  70. package/src/history/failure-mode.ts +15 -5
  71. package/src/history/history.ts +103 -30
  72. package/src/history/index.ts +1 -0
  73. package/src/history/metrics.ts +189 -69
  74. package/src/index.ts +2 -1
  75. package/src/orchestrator/admission.ts +13 -9
  76. package/src/orchestrator/affected-tasks.ts +35 -13
  77. package/src/orchestrator/command-windows.ts +8 -5
  78. package/src/orchestrator/deferred-outputs.ts +41 -12
  79. package/src/orchestrator/doctor.ts +21 -9
  80. package/src/orchestrator/download-policy.ts +3 -3
  81. package/src/orchestrator/excluded-keys.ts +14 -2
  82. package/src/orchestrator/execute-args.ts +9 -2
  83. package/src/orchestrator/execute-task.ts +564 -123
  84. package/src/orchestrator/graph-memo.ts +26 -4
  85. package/src/orchestrator/graph-stage.ts +27 -4
  86. package/src/orchestrator/held-servers.ts +36 -14
  87. package/src/orchestrator/hit-restore.ts +136 -87
  88. package/src/orchestrator/index.ts +4 -1
  89. package/src/orchestrator/keyed-projects.ts +25 -0
  90. package/src/orchestrator/local-floor.ts +21 -3
  91. package/src/orchestrator/lockfile-claim.ts +30 -4
  92. package/src/orchestrator/miss-save.ts +33 -53
  93. package/src/orchestrator/options.ts +13 -0
  94. package/src/orchestrator/output-host.ts +16 -4
  95. package/src/orchestrator/output-log.ts +1 -54
  96. package/src/orchestrator/persistent-task.ts +31 -17
  97. package/src/orchestrator/persistent.ts +152 -15
  98. package/src/orchestrator/placement.ts +81 -15
  99. package/src/orchestrator/plan.ts +37 -15
  100. package/src/orchestrator/plugin-host.ts +232 -108
  101. package/src/orchestrator/plugin.ts +53 -27
  102. package/src/orchestrator/prepare.ts +194 -38
  103. package/src/orchestrator/projects.ts +89 -10
  104. package/src/orchestrator/read-only.ts +115 -0
  105. package/src/orchestrator/run-context.ts +6 -2
  106. package/src/orchestrator/run-failures.ts +43 -14
  107. package/src/orchestrator/run-id.ts +13 -2
  108. package/src/orchestrator/run-keys.ts +87 -28
  109. package/src/orchestrator/run-live.ts +16 -13
  110. package/src/orchestrator/run-lock.ts +27 -4
  111. package/src/orchestrator/run-records.ts +12 -10
  112. package/src/orchestrator/run-report.ts +2 -1
  113. package/src/orchestrator/run-stop.ts +17 -9
  114. package/src/orchestrator/run.ts +443 -107
  115. package/src/orchestrator/sandbox-fix.ts +40 -12
  116. package/src/orchestrator/sandbox-request.ts +286 -59
  117. package/src/orchestrator/save-lane.ts +24 -13
  118. package/src/orchestrator/shell-verdict.ts +20 -1
  119. package/src/orchestrator/signals.ts +103 -8
  120. package/src/orchestrator/stable-keys.ts +17 -9
  121. package/src/orchestrator/task-artifacts.ts +15 -2
  122. package/src/orchestrator/task-hash.ts +187 -30
  123. package/src/orchestrator/telemetry-host.ts +25 -6
  124. package/src/orchestrator/telemetry.ts +80 -12
  125. package/src/orchestrator/upstream.ts +3 -1
  126. package/src/report/events.ts +20 -3
  127. package/src/report/forecast.ts +103 -37
  128. package/src/report/framed-output.ts +165 -157
  129. package/src/report/index.ts +2 -1
  130. package/src/report/logger.ts +107 -30
  131. package/src/report/plain-output.ts +13 -9
  132. package/src/report/status-line.ts +28 -55
  133. package/src/report/summary.ts +57 -14
  134. package/src/settings.ts +42 -6
  135. package/src/ui/components.ts +36 -42
  136. package/src/ui/index.ts +3 -1
  137. package/src/ui/live-region.ts +67 -8
  138. package/src/ui/terminal.ts +12 -0
  139. package/src/ui/theme.ts +16 -1
  140. package/src/util/config-frame.ts +17 -3
  141. package/src/util/edit-distance.ts +17 -1
  142. package/src/util/errors.ts +23 -7
  143. package/src/util/foreign-cache-dir.ts +60 -0
  144. package/src/util/git-ignored.ts +59 -5
  145. package/src/util/index.ts +15 -1
  146. package/src/util/paths.ts +50 -6
  147. package/src/util/plain-text.ts +19 -0
  148. package/src/util/secret-mask.ts +57 -1
  149. package/src/util/settle.ts +16 -0
  150. package/src/util/size.ts +12 -2
  151. package/src/util/write-file.ts +86 -9
  152. package/src/workspace/affected.ts +225 -27
  153. package/src/workspace/config-bytecode.ts +23 -4
  154. package/src/workspace/config-eval.ts +90 -16
  155. package/src/workspace/config-imports.ts +96 -41
  156. package/src/workspace/config-schema.ts +126 -25
  157. package/src/workspace/dependencies-lock.ts +7 -14
  158. package/src/workspace/filter.ts +10 -1
  159. package/src/workspace/index.ts +3 -1
  160. package/src/workspace/json-data.ts +34 -8
  161. package/src/workspace/lockfile.ts +86 -37
  162. package/src/workspace/migration.ts +46 -25
  163. package/src/workspace/module-state.ts +62 -0
  164. package/src/workspace/nested-dirs.ts +8 -0
  165. package/src/workspace/project-loader.ts +276 -32
  166. package/src/workspace/repo-id.ts +29 -6
  167. package/src/workspace/workspace.ts +190 -30
@@ -11,6 +11,7 @@ import path from 'node:path'
11
11
  import type { ProjectConfig, WorkspaceConfig } from '../config.js'
12
12
  import {
13
13
  executablePath,
14
+ gitCaseArgs,
14
15
  gitEnv,
15
16
  gitSpawnRefusal,
16
17
  hasControl,
@@ -21,7 +22,13 @@ import {
21
22
  relPosix,
22
23
  UserError,
23
24
  } from '../util/index.js'
24
- import { gitTop, WORKSPACE_CONFIG_FILENAMES, workspaceFile, workspaceRootOf } from '../handoff.js'
25
+ import {
26
+ gitTop,
27
+ WORKSPACE_CONFIG_FILENAMES,
28
+ workspaceFile,
29
+ workspaceFileBroken,
30
+ workspaceRootOf,
31
+ } from '../handoff.js'
25
32
  import { canonicalDir } from './canonical-dir.js'
26
33
  import { parseFilter } from './filter.js'
27
34
  import { repoIdOf } from './repo-id.js'
@@ -86,6 +93,7 @@ export async function findWorkspaceRoot(
86
93
  { code: 'VX_E_NO_WORKSPACE', message: `${headline} — run \`vx init\` to create one` },
87
94
  )
88
95
  }
96
+ if (workspaceFileBroken(file)) await refuseNotAFile(file)
89
97
  const root = workspaceRootOf(file)
90
98
  if (uid !== 0 && root !== start && (await gitTop(start)) === null) {
91
99
  // The link itself and the `.vx` holding it, not what a link names: a
@@ -110,6 +118,22 @@ export async function findWorkspaceRoot(
110
118
  return root
111
119
  }
112
120
 
121
+ /**
122
+ * Refuses a config path that is no file: it marks a root or a project all
123
+ * the same, so skipping it ran nothing there and said nothing. Learned by
124
+ * `lstat` here, off the normal path.
125
+ */
126
+ async function refuseNotAFile(file: string): Promise<never> {
127
+ const link = await stat(file).then(
128
+ () => false,
129
+ () => true,
130
+ )
131
+ throw new UserError(
132
+ `${file}: is ${link ? 'a broken link' : 'not a file'} — fix or remove it`,
133
+ 'VX_E_CONFIG',
134
+ )
135
+ }
136
+
113
137
  /**
114
138
  * The workspace's own cache directory (index, history, memos):
115
139
  * `VX_CACHE_DIR`, else the workspace's `cache.dir`, else `.vx/cache`,
@@ -249,6 +273,8 @@ export interface ProjectConfigs {
249
273
  readonly strayReviews: string[]
250
274
  /** Absolute: config files a higher-precedence one beside them hides, the root's workspace config among them, by the one read. */
251
275
  readonly shadowed: { file: string; read: string }[]
276
+ /** Absolute, sorted: the outermost directories holding their own `.vx/workspace.*`. */
277
+ readonly nestedWorkspaces: string[]
252
278
  }
253
279
 
254
280
  /**
@@ -265,11 +291,16 @@ export interface ProjectConfigs {
265
291
  export async function findProjectConfigs(root: string): Promise<ProjectConfigs> {
266
292
  const inGit = (await gitTop(root)) !== null
267
293
  const rels = inGit ? await gitConfigs(root) : await scanConfigs(root)
268
- const nested: string[] = []
269
- for (const rel of rels) {
294
+ // Each on disk, a broken link included, as the root walk takes one: git
295
+ // lists a tracked one deleted from the worktree, which marks no root.
296
+ const marks = rels.filter((rel) => {
270
297
  const at = rel.lastIndexOf('/.vx/')
271
- if (at > 0 && WORKSPACE_RANK.has(rel.slice(at + 1))) nested.push(rel.slice(0, at))
272
- }
298
+ return at > 0 && WORKSPACE_RANK.has(rel.slice(at + 1))
299
+ })
300
+ const onDisk = await Promise.all(marks.map((rel) => entryAt(path.join(root, rel))))
301
+ const nested = marks
302
+ .filter((_, i) => onDisk[i])
303
+ .map((rel) => rel.slice(0, rel.lastIndexOf('/.vx/')))
273
304
  if (inGit) nested.push(...(await unlistedNested(root, rels, nested)))
274
305
  const byDir = new Map<string, string[]>()
275
306
  const reviewed: string[] = []
@@ -286,8 +317,10 @@ export async function findProjectConfigs(root: string): Promise<ProjectConfigs>
286
317
  }
287
318
  const shadowed: { file: string; read: string }[] = []
288
319
  // The files among `names` (ranked), the first of them the one read: git
289
- // lists a tracked config deleted from the worktree, and a directory may
290
- // carry a config's name. Every other file is shadowed, said by `vx lock`.
320
+ // lists a tracked config deleted from the worktree, which is skipped.
321
+ // Every other file is shadowed, said by `vx lock`. With no file, an entry
322
+ // by a config's name (a broken link, a directory) is refused, as the root
323
+ // walk refuses one: skipped, its project was in no lock and nothing said so.
291
324
  const firstFile = async (
292
325
  dir: string,
293
326
  names: string[],
@@ -297,6 +330,9 @@ export async function findProjectConfigs(root: string): Promise<ProjectConfigs>
297
330
  const files = (
298
331
  await Promise.all(names.map(async (n) => ((await isFile(path.join(dir, n))) ? n : null)))
299
332
  ).filter((n) => n !== null)
333
+ if (files.length === 0)
334
+ for (const n of names)
335
+ if (await entryAt(path.join(dir, n))) await refuseNotAFile(path.join(dir, n))
300
336
  for (const n of files.slice(1))
301
337
  shadowed.push({ file: path.join(dir, n), read: path.join(dir, files[0]!) })
302
338
  return files[0] === undefined ? null : path.join(dir, files[0])
@@ -329,7 +365,12 @@ export async function findProjectConfigs(root: string): Promise<ProjectConfigs>
329
365
  )
330
366
  .filter((f) => f !== null)
331
367
  .sort()
332
- return { configs, strayReviews, shadowed }
368
+ // A project's outputs stop at each, as at a nested project (computeNestedProjectDirs).
369
+ const nestedWorkspaces = nested
370
+ .filter((n) => !nested.some((m) => n.startsWith(`${m}/`)))
371
+ .map((rel) => path.join(root, rel))
372
+ .sort()
373
+ return { configs, strayReviews, shadowed, nestedWorkspaces: [...new Set(nestedWorkspaces)] }
333
374
  }
334
375
 
335
376
  /**
@@ -368,6 +409,17 @@ async function unlistedNested(
368
409
  return found.filter((d) => d !== null)
369
410
  }
370
411
 
412
+ /** An entry by that name, whatever it is (`lstat`): a broken link counts. */
413
+ async function entryAt(p: string): Promise<boolean> {
414
+ try {
415
+ await lstat(p)
416
+ return true
417
+ } catch (err) {
418
+ if (isOutOfFds(err)) throw err
419
+ return false
420
+ }
421
+ }
422
+
371
423
  async function isFile(p: string): Promise<boolean> {
372
424
  try {
373
425
  return (await stat(p)).isFile()
@@ -386,21 +438,26 @@ async function isFile(p: string): Promise<boolean> {
386
438
  */
387
439
  async function gitConfigs(root: string): Promise<string[]> {
388
440
  const [configs, index, untracked] = await Promise.all([
389
- gitZ(root, [
390
- 'ls-files',
391
- '-z',
392
- '--cached',
393
- '--others',
394
- '--exclude-standard',
395
- '--',
396
- ':(glob)**/vx.config.*',
397
- ':(glob)**/.vx/workspace.*',
398
- `:(glob)**/${REVIEW_FILE}`,
399
- NOT_INSTALLS,
400
- ]),
401
- gitZ(root, ['ls-files', '-z', '--stage', '--', NOT_INSTALLS]),
441
+ gitZ(
442
+ root,
443
+ [
444
+ 'ls-files',
445
+ '-z',
446
+ '--cached',
447
+ '--others',
448
+ '--exclude-standard',
449
+ '--',
450
+ ':(glob)**/vx.config.*',
451
+ ':(glob)**/.vx/workspace.*',
452
+ `:(glob)**/${REVIEW_FILE}`,
453
+ NOT_INSTALLS,
454
+ ...NOT_VX_STATE,
455
+ ],
456
+ true,
457
+ ),
458
+ gitZ(root, ['ls-files', '-z', '--stage', '--', NOT_INSTALLS, NOT_IN_VX]),
402
459
  // An embedded repository is the one untracked entry git lists as `dir/`.
403
- gitZ(root, ['ls-files', '-z', '--others', '--exclude-standard', '--', NOT_INSTALLS]),
460
+ gitZ(root, ['ls-files', '-z', '--others', '--exclude-standard', '--', NOT_INSTALLS, NOT_IN_VX]),
404
461
  ])
405
462
  // A submodule's `.git` is a file, an embedded repository's a directory.
406
463
  const isRepo = (rel: string): Promise<boolean> =>
@@ -431,12 +488,32 @@ async function gitConfigs(root: string): Promise<string[]> {
431
488
  */
432
489
  const NOT_INSTALLS = ':(exclude,glob)**/node_modules/**'
433
490
 
434
- /** `git <args>`'s NUL-separated output at `cwd`. */
435
- async function gitZ(cwd: string, args: string[]): Promise<string[]> {
491
+ /**
492
+ * Nothing under a `.vx` directory is a project, as the walk outside git
493
+ * skips it: a config git listed in `.vx/cache/x` made one only in git.
494
+ */
495
+ const NOT_IN_VX = ':(exclude,glob)**/.vx/**'
496
+ /** `NOT_IN_VX` that keeps `.vx/workspace.*`, which marks a nested workspace. */
497
+ const NOT_VX_STATE = [
498
+ ':(exclude,glob)**/.vx/*/**',
499
+ ':(exclude,glob)**/.vx/vx.config.*',
500
+ `:(exclude,glob)**/.vx/${REVIEW_FILE}`,
501
+ ]
502
+
503
+ /** Whether root-relative POSIX `rel` lies under a `.vx` directory, where no project lives. */
504
+ export function inVxDir(rel: string): boolean {
505
+ return rel.startsWith('.vx/') || rel.includes('/.vx/')
506
+ }
507
+
508
+ /**
509
+ * `git <args>`'s NUL-separated output at `cwd`. `strict`: a path not valid
510
+ * UTF-8 is refused, as decoding turns it into one no file has.
511
+ */
512
+ async function gitZ(cwd: string, args: string[], strict = false): Promise<string[]> {
436
513
  let proc: Bun.Subprocess<'ignore', 'pipe', 'pipe'>
437
514
  try {
438
515
  proc = Bun.spawn({
439
- cmd: [executablePath('git'), ...args],
516
+ cmd: [executablePath('git'), ...gitCaseArgs(cwd), ...args],
440
517
  cwd,
441
518
  env: gitEnv(),
442
519
  stdin: 'ignore',
@@ -454,10 +531,53 @@ async function gitZ(cwd: string, args: string[]): Promise<string[]> {
454
531
  ])
455
532
  if (code !== 0)
456
533
  throw new UserError(`git ls-files failed (exit ${code}): ${err.trim()}`, 'VX_E_GIT')
457
- return new TextDecoder('utf-8', { ignoreBOM: true })
458
- .decode(out)
459
- .split('\0')
460
- .filter((p) => p !== '')
534
+ const text = new TextDecoder('utf-8', { ignoreBOM: true }).decode(out)
535
+ // U+FFFD alone is cheap to look for; a valid name may hold it, so the bytes confirm.
536
+ if (strict && text.includes('\uFFFD')) {
537
+ let start = 0
538
+ for (let i = 0; i <= out.length; i++) {
539
+ if (i < out.length && out[i] !== 0) continue
540
+ const entry = out.subarray(start, i)
541
+ start = i + 1
542
+ if (!isUtf8(entry)) {
543
+ const slash = entry.lastIndexOf(0x2f)
544
+ throw notUtf8(slash === -1 ? entry : entry.subarray(0, slash))
545
+ }
546
+ }
547
+ }
548
+ return text.split('\0').filter((p) => p !== '')
549
+ }
550
+
551
+ const strictUtf8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
552
+
553
+ function isUtf8(bytes: Uint8Array): boolean {
554
+ try {
555
+ strictUtf8.decode(bytes)
556
+ return true
557
+ } catch {
558
+ return false
559
+ }
560
+ }
561
+
562
+ /** `rel`, a root-relative directory, with each byte that is not UTF-8 shown as `\xNN`. */
563
+ function notUtf8(rel: Uint8Array): UserError {
564
+ let shown = ''
565
+ for (let i = 0; i < rel.length;) {
566
+ const lead = rel[i]!
567
+ const n =
568
+ lead < 0x80 ? 1 : lead < 0xc2 ? 0 : lead < 0xe0 ? 2 : lead < 0xf0 ? 3 : lead < 0xf5 ? 4 : 0
569
+ const seq = rel.subarray(i, i + n)
570
+ if (n > 0 && isUtf8(seq)) {
571
+ shown += strictUtf8.decode(seq)
572
+ i += n
573
+ } else {
574
+ shown += `\\x${lead.toString(16).padStart(2, '0')}`
575
+ i++
576
+ }
577
+ }
578
+ return new UserError(
579
+ `${printable(shown)}: the directory's name is not valid UTF-8, which no project path may hold — rename it`,
580
+ )
461
581
  }
462
582
 
463
583
  /** The config paths under `root` outside git, root-relative: none under `node_modules`, `.git` or `.vx`. */
@@ -475,17 +595,56 @@ async function scanConfigs(root: string): Promise<string[]> {
475
595
  if (rel !== '' && entries.some((e) => e.name === '.vx') && (await workspaceFileIn(root, rel)))
476
596
  return
477
597
  const below: string[] = []
598
+ let lossy = false
478
599
  for (const e of entries) {
479
600
  const child = rel === '' ? e.name : `${rel}/${e.name}`
480
601
  if (CONFIG_RANK.has(e.name) || e.name === REVIEW_FILE) out.push(child)
481
- else if (e.isDirectory() && !SCAN_SKIPS.has(e.name)) below.push(child)
602
+ else if (e.isDirectory() && !SCAN_SKIPS.has(e.name)) {
603
+ below.push(child)
604
+ // A name not valid UTF-8 decodes to one no directory has (a valid one may hold U+FFFD).
605
+ if (e.name.includes('\uFFFD')) lossy = true
606
+ }
482
607
  }
608
+ if (lossy) await refuseNotUtf8(root, rel)
483
609
  await Promise.all(below.map(walk))
484
610
  }
485
611
  await walk('')
486
612
  return out
487
613
  }
488
614
 
615
+ /**
616
+ * Refuses a directory in `rel` whose name is not valid UTF-8 and that holds
617
+ * a config: walked by bytes, as no string path reaches it.
618
+ */
619
+ async function refuseNotUtf8(root: string, rel: string): Promise<void> {
620
+ const dir = Buffer.from(rel === '' ? root : path.join(root, rel))
621
+ for (const name of await readdir(dir, { encoding: 'buffer' })) {
622
+ if (isUtf8(name)) continue
623
+ if (await holdsConfig(Buffer.concat([dir, Buffer.from('/'), name]))) {
624
+ throw notUtf8(rel === '' ? name : Buffer.concat([Buffer.from(`${rel}/`), name]))
625
+ }
626
+ }
627
+ }
628
+
629
+ // Bun 1.4.4 drops `withFileTypes` under `encoding: 'buffer'`, so each entry is an lstat.
630
+ async function holdsConfig(dir: Buffer): Promise<boolean> {
631
+ let names: Buffer[]
632
+ try {
633
+ names = await readdir(dir, { encoding: 'buffer' })
634
+ } catch (err) {
635
+ if (isOutOfFds(err)) throw err
636
+ return false
637
+ }
638
+ for (const name of names) {
639
+ const shown = name.toString()
640
+ if (CONFIG_RANK.has(shown)) return true
641
+ if (SCAN_SKIPS.has(shown)) continue
642
+ const at = Buffer.concat([dir, Buffer.from('/'), name])
643
+ if ((await lstat(at)).isDirectory() && (await holdsConfig(at))) return true
644
+ }
645
+ return false
646
+ }
647
+
489
648
  // `.vx` holds vx's state (the cache), which a checkout ignores.
490
649
  const SCAN_SKIPS = new Set(['node_modules', '.git', '.vx'])
491
650
 
@@ -635,6 +794,7 @@ export function projectNameProblem(name: string): string | null {
635
794
  return walk
636
795
  }
637
796
  if (f.withDeps || f.withDependents || f.onlyDeps || f.onlyDependents) return walk
797
+ if (f.within !== undefined) return "ends in '{…}', which a filter reads as a directory"
638
798
  if (f.gitSince !== undefined)
639
799
  return `ends in "[${f.gitSince}]", which a filter reads as a git range`
640
800
  if (f.tag === true) return "starts with 'tag:', which a filter reads as a tag selector"