@vzn/vx 0.0.641 → 0.0.642

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 (67) hide show
  1. package/package.json +5 -12
  2. package/skills/vx/SKILL.md +4 -4
  3. package/src/cache/archive.ts +525 -104
  4. package/src/cache/cache.ts +92 -12
  5. package/src/cache/fold-memo.ts +58 -0
  6. package/src/cache/git-inputs.ts +213 -31
  7. package/src/cache/index.ts +6 -2
  8. package/src/cache/inputs.ts +169 -79
  9. package/src/cache/key-fold.ts +90 -69
  10. package/src/cache/layer.ts +2 -6
  11. package/src/cache/layered-cache.ts +68 -4
  12. package/src/cache/output-index.ts +25 -4
  13. package/src/cache/remote-lane.ts +27 -10
  14. package/src/cache/schema.ts +13 -3
  15. package/src/cache/scoped-cache.ts +3 -1
  16. package/src/cache/umask.ts +29 -0
  17. package/src/cli/docs.ts +71 -7
  18. package/src/cli/help.ts +4 -4
  19. package/src/cli/plan-format.ts +4 -1
  20. package/src/cli/run.ts +10 -3
  21. package/src/cli/select.ts +2 -0
  22. package/src/exec/runner.ts +5 -0
  23. package/src/exec/sandbox-runtime.ts +81 -1
  24. package/src/graph/priorities.ts +14 -1
  25. package/src/history/history.ts +43 -33
  26. package/src/index.ts +10 -0
  27. package/src/orchestrator/deferred-outputs.ts +3 -2
  28. package/src/orchestrator/execute-task.ts +20 -14
  29. package/src/orchestrator/hit-restore.ts +10 -2
  30. package/src/orchestrator/index.ts +1 -0
  31. package/src/orchestrator/lockfile-claim.ts +54 -8
  32. package/src/orchestrator/miss-save.ts +27 -17
  33. package/src/orchestrator/output-host.ts +9 -1
  34. package/src/orchestrator/plan.ts +4 -0
  35. package/src/orchestrator/plugin-host.ts +31 -0
  36. package/src/orchestrator/plugin.ts +29 -3
  37. package/src/orchestrator/prepare.ts +74 -5
  38. package/src/orchestrator/projects.ts +6 -1
  39. package/src/orchestrator/run.ts +37 -15
  40. package/src/orchestrator/sandbox-request.ts +1 -48
  41. package/src/orchestrator/save-lane.ts +17 -10
  42. package/src/orchestrator/signals.ts +7 -10
  43. package/src/orchestrator/stable-keys.ts +83 -25
  44. package/src/orchestrator/task-hash.ts +46 -12
  45. package/src/report/events.ts +12 -0
  46. package/src/report/framed-output.ts +2 -1
  47. package/src/report/logger.ts +53 -13
  48. package/src/report/status-line.ts +45 -4
  49. package/src/report/summary.ts +5 -2
  50. package/src/util/errors.ts +1 -1
  51. package/src/util/hidden-prompt.ts +74 -0
  52. package/src/util/index.ts +12 -2
  53. package/src/util/paths.ts +33 -14
  54. package/src/util/secrets.ts +150 -0
  55. package/src/workspace/affected.ts +63 -13
  56. package/src/workspace/config-schema.ts +37 -3
  57. package/src/workspace/project-loader.ts +1 -0
  58. package/src/workspace/workspace.ts +17 -11
  59. package/docs/caching.md +0 -2376
  60. package/docs/cli.md +0 -3285
  61. package/docs/execution.md +0 -676
  62. package/docs/features.md +0 -178
  63. package/docs/patterns.md +0 -219
  64. package/docs/schema.md +0 -2468
  65. package/docs/security.md +0 -253
  66. package/src/cli/docs-corpus.ts +0 -21
  67. package/src/cli/docs-text.d.ts +0 -5
@@ -0,0 +1,74 @@
1
+ // A secret asked for on the terminal: hidden, one line, only where someone can answer.
2
+
3
+ /** Where a missing secret is asked for. */
4
+ export interface SecretTerminal {
5
+ /** stdin and stderr are both terminals: someone can answer. */
6
+ interactive(): boolean
7
+ /** Ask `question` on stderr with the answer hidden; resolves the line typed. */
8
+ ask(question: string): Promise<string>
9
+ }
10
+
11
+ interface HiddenInput {
12
+ isRaw?: boolean
13
+ setRawMode(mode: boolean): unknown
14
+ on(event: 'data', fn: (chunk: Buffer | string) => void): unknown
15
+ off(event: 'data', fn: (chunk: Buffer | string) => void): unknown
16
+ resume(): unknown
17
+ pause(): unknown
18
+ }
19
+
20
+ /**
21
+ * Read one line from a raw-mode terminal, echoing nothing; backspace edits.
22
+ * Ctrl-D answers nothing. Raw mode delivers no SIGINT, so Ctrl-C answers
23
+ * nothing and raises it: the run stops as it would at any other moment.
24
+ */
25
+ function askHidden(
26
+ question: string,
27
+ input: HiddenInput,
28
+ output: { write(s: string): unknown },
29
+ interrupt: () => void,
30
+ ): Promise<string> {
31
+ output.write(question)
32
+ const wasRaw = input.isRaw === true
33
+ input.setRawMode(true)
34
+ input.resume()
35
+ return new Promise((resolve) => {
36
+ let line = ''
37
+ const done = () => {
38
+ input.off('data', onData)
39
+ input.setRawMode(wasRaw)
40
+ input.pause()
41
+ output.write('\n')
42
+ }
43
+ const onData = (chunk: Buffer | string) => {
44
+ for (const ch of String(chunk)) {
45
+ if (ch === '\r' || ch === '\n') {
46
+ done()
47
+ resolve(line)
48
+ return
49
+ }
50
+ if (ch === '\u0003' || ch === '\u0004') {
51
+ done()
52
+ resolve('')
53
+ if (ch === '\u0003') interrupt()
54
+ return
55
+ }
56
+ if (ch === '\u007f' || ch === '\b') line = line.replace(/.$/u, '')
57
+ else if (ch >= ' ') line += ch
58
+ }
59
+ }
60
+ input.on('data', onData)
61
+ })
62
+ }
63
+
64
+ /** A process's terminal: asked only when its stdin and stderr are both TTYs. */
65
+ export function terminalOf(
66
+ stdin: HiddenInput & { isTTY?: boolean },
67
+ stderr: { isTTY?: boolean; write(s: string): unknown },
68
+ interrupt: () => void = () => process.kill(process.pid, 'SIGINT'),
69
+ ): SecretTerminal {
70
+ return {
71
+ interactive: () => stdin.isTTY === true && stderr.isTTY === true,
72
+ ask: (question) => askHidden(question, stdin, stderr, interrupt),
73
+ }
74
+ }
package/src/util/index.ts CHANGED
@@ -58,9 +58,8 @@ export {
58
58
  GLOB_WILDCARDS,
59
59
  normalizeBunGlob,
60
60
  normalizeGlob,
61
- namesNodeModules,
62
- OUTPUT_NEVER,
63
61
  outputMatcher,
62
+ outputNever,
64
63
  restorableOutput,
65
64
  printable,
66
65
  relPosix,
@@ -74,6 +73,7 @@ export {
74
73
  taskGlob,
75
74
  wholeSubtreePrefixes,
76
75
  slashBraceExpansions,
76
+ braceAlternatives,
77
77
  } from './paths.js'
78
78
  export {
79
79
  claimExitForSignal,
@@ -119,3 +119,13 @@ export {
119
119
  secretNamed,
120
120
  type SecretMask,
121
121
  } from './secret-mask.js'
122
+ export {
123
+ isSecretUnavailable,
124
+ SecretBroker,
125
+ type PluginSecrets,
126
+ type SecretRequest,
127
+ type SecretStore,
128
+ type SecretUnavailable,
129
+ type SecretUnavailableReason,
130
+ } from './secrets.js'
131
+ export { terminalOf, type SecretTerminal } from './hidden-prompt.js'
package/src/util/paths.ts CHANGED
@@ -387,18 +387,36 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
387
387
  }
388
388
 
389
389
  /**
390
- * What no output glob reaches. An output is otherwise taken as written —
391
- * `node_modules/**` IS an install task's output — but no task produces
392
- * these, and a root project declaring `**` would otherwise wipe its
393
- * repository and the cache it is restoring from (2026-09-16). `.vx` whole
394
- * and each `vx.lock.json` are vx's own: as outputs, the clean deleted an
395
- * uncommitted workspace config and a hit restored a lock or ignore file the
396
- * key does not see over the current one.
390
+ * What no output glob reaches, relative to the project: its root's `.git`
391
+ * and `.vx`, and the `vx.lock.json` beside its config. An output is
392
+ * otherwise taken as written — `node_modules/**` IS an install task's
393
+ * output — but a root project declaring `**` would otherwise wipe its
394
+ * repository and the cache it is restoring from (2026-09-16), the clean
395
+ * deleted an uncommitted workspace config, and a hit restored a lock or
396
+ * ignore file the key does not see over the current one. Only where vx
397
+ * writes them: reserved at any depth, a hit dropped a build's own
398
+ * `dist/.git/**` and `dist/sub/vx.lock.json` (2026-10-17).
397
399
  */
398
- export const OUTPUT_NEVER = ['**/.git/**', '**/.vx/**', '**/vx.lock.json']
400
+ const OUTPUT_NEVER = ['.git/**', '.vx/**', 'vx.lock.json']
401
+
402
+ /**
403
+ * `OUTPUT_NEVER` relative to the workspace root, for `outputs.workspaceFiles`:
404
+ * those globs cross into other projects, and every project's `vx.lock.json`
405
+ * is vx's.
406
+ */
407
+ const WORKSPACE_OUTPUT_NEVER = [...OUTPUT_NEVER, '**/vx.lock.json']
408
+
409
+ /**
410
+ * What `globs` may not reach in `scope`: its never-list, and every
411
+ * `node_modules` unless a glob names one.
412
+ */
413
+ export function outputNever(globs: readonly string[], scope: 'project' | 'workspace'): string[] {
414
+ const never = scope === 'project' ? OUTPUT_NEVER : WORKSPACE_OUTPUT_NEVER
415
+ return namesNodeModules(globs) ? never : [...never, '**/node_modules/**']
416
+ }
399
417
 
400
418
  /** Whether a positive glob in `globs` names a `node_modules` directory. */
401
- export function namesNodeModules(globs: readonly string[]): boolean {
419
+ function namesNodeModules(globs: readonly string[]): boolean {
402
420
  return globs.some(
403
421
  (g) => !g.startsWith('!') && normalizeGlob(g).split('/').includes('node_modules'),
404
422
  )
@@ -411,11 +429,12 @@ export function namesNodeModules(globs: readonly string[]): boolean {
411
429
  * artifact naming anything else was not written by this task's save, and
412
430
  * none of it is restored (2026-10-16).
413
431
  */
414
- export function restorableOutput(globs: readonly string[]): (rel: string) => boolean {
432
+ export function restorableOutput(
433
+ globs: readonly string[],
434
+ scope: 'project' | 'workspace' = 'project',
435
+ ): (rel: string) => boolean {
415
436
  const isOutput = outputMatcher(globs)
416
- const never = anyTaskGlob(
417
- namesNodeModules(globs) ? OUTPUT_NEVER : [...OUTPUT_NEVER, '**/node_modules/**'],
418
- )
437
+ const never = anyTaskGlob(outputNever(globs, scope))
419
438
  return (rel) => isOutput(rel) && !never(rel)
420
439
  }
421
440
 
@@ -430,7 +449,7 @@ export function slashBraceExpansions(pattern: string): string[] {
430
449
  }
431
450
 
432
451
  /** `pattern` with every brace group of two or more alternatives expanded. */
433
- function braceAlternatives(pattern: string): string[] {
452
+ export function braceAlternatives(pattern: string): string[] {
434
453
  return expandBraces(pattern, false)
435
454
  }
436
455
 
@@ -0,0 +1,150 @@
1
+ // A plugin's secrets, kept in the OS keychain (`Bun.secrets`) under the
2
+ // plugin's own name, and asked for on the terminal when a plugin offers a
3
+ // prompt and someone can answer (owner, 2026-10-17). Core reads no
4
+ // environment here: a variable, an option or a config file is the
5
+ // plugin's to check first, so the keychain is always the last source.
6
+
7
+ import { type SecretTerminal, terminalOf } from './hidden-prompt.js'
8
+
9
+ /** What `ctx.secret` is asked: the entry's name, and the question to ask on a terminal. */
10
+ export interface SecretRequest {
11
+ /** The entry's name within the plugin's keychain service: one per remote, say. */
12
+ readonly key: string
13
+ /** Asked when the keychain lacks the entry. Omitted: never asked, a miss is `not-found`. */
14
+ readonly prompt?: string
15
+ }
16
+
17
+ /** A plugin's keychain, on the contexts of the hooks that run before the first task. */
18
+ export interface PluginSecrets {
19
+ /**
20
+ * The entry, else the terminal's answer (then saved), else a rejection a
21
+ * `isSecretUnavailable` guard names. Once per run per key: concurrent asks
22
+ * share the answer.
23
+ */
24
+ secret(req: SecretRequest): Promise<string>
25
+ /** Delete the entry (a server refused it), so the next run asks again. Never throws. */
26
+ forgetSecret(req: { readonly key: string }): Promise<void>
27
+ }
28
+
29
+ /**
30
+ * Why `secret` had nothing: the keychain lacks it and no prompt was given
31
+ * (`not-found`), a prompt was given and nobody can answer — CI, no TTY, or
32
+ * the status line already up (`no-terminal`), the answer was empty
33
+ * (`declined`), or there is no keychain and no answer (`store-unavailable`).
34
+ */
35
+ export type SecretUnavailableReason = 'not-found' | 'no-terminal' | 'declined' | 'store-unavailable'
36
+
37
+ export interface SecretUnavailable extends Error {
38
+ readonly reason: SecretUnavailableReason
39
+ }
40
+
41
+ const NAME = 'SecretUnavailable'
42
+
43
+ /** A `secret` rejection that is a missing secret, not a fault. By name: a plugin may load its own copy of core. */
44
+ export function isSecretUnavailable(err: unknown): err is SecretUnavailable {
45
+ return err instanceof Error && err.name === NAME && 'reason' in err
46
+ }
47
+
48
+ function unavailable(key: string, reason: SecretUnavailableReason): SecretUnavailable {
49
+ return Object.assign(new Error(`secret '${key}' unavailable: ${reason}`), { name: NAME, reason })
50
+ }
51
+
52
+ /** The keychain as the broker reads and writes it: `Bun.secrets`, or a test's stand-in. */
53
+ export interface SecretStore {
54
+ get(o: { service: string; name: string }): Promise<string | null>
55
+ set(o: { service: string; name: string; value: string }): Promise<void>
56
+ delete(o: { service: string; name: string }): Promise<boolean>
57
+ }
58
+
59
+ // Reached only when asked: touching `process.stdin` opens the stream.
60
+ const processTerminal: SecretTerminal = {
61
+ interactive: () => terminalOf(process.stdin, process.stderr).interactive(),
62
+ ask: (question) => terminalOf(process.stdin, process.stderr).ask(question),
63
+ }
64
+
65
+ /**
66
+ * One run's secrets for every plugin. A prompt waits for the one before
67
+ * it, so two plugins never ask at once, and none is asked once
68
+ * `closePrompts` says the status line is up.
69
+ */
70
+ export class SecretBroker {
71
+ private readonly memo = new Map<string, Promise<string>>()
72
+ private prompting: Promise<unknown> = Promise.resolve()
73
+ private closed = false
74
+
75
+ constructor(
76
+ private readonly ci: boolean,
77
+ private readonly warn: (message: string) => void,
78
+ private readonly store: SecretStore = Bun.secrets,
79
+ private readonly terminal: SecretTerminal = processTerminal,
80
+ /** Quiets the terminal's live region until the release it returns. */
81
+ private readonly quiet: () => () => void = () => () => undefined,
82
+ ) {}
83
+
84
+ /** `plugin`'s keychain: its entries live under its package name. */
85
+ for(plugin: string): PluginSecrets {
86
+ return {
87
+ secret: (req) => {
88
+ const memoKey = `${plugin}\0${req.key}`
89
+ let pending = this.memo.get(memoKey)
90
+ if (pending === undefined) {
91
+ pending = this.resolve(plugin, req)
92
+ this.memo.set(memoKey, pending)
93
+ }
94
+ return pending
95
+ },
96
+ forgetSecret: async ({ key }) => {
97
+ this.memo.delete(`${plugin}\0${key}`)
98
+ try {
99
+ await this.store.delete({ service: plugin, name: key })
100
+ } catch {
101
+ // no keychain to delete from
102
+ }
103
+ },
104
+ }
105
+ }
106
+
107
+ /** The status line is about to start: from here a prompt would be drawn over. */
108
+ closePrompts(): void {
109
+ this.closed = true
110
+ }
111
+
112
+ private async resolve(service: string, req: SecretRequest): Promise<string> {
113
+ const entry = { service, name: req.key }
114
+ let storeDown = false
115
+ try {
116
+ const stored = await this.store.get(entry)
117
+ if (stored) return stored
118
+ } catch {
119
+ // No keychain (headless Linux without libsecret).
120
+ storeDown = true
121
+ }
122
+ if (req.prompt === undefined)
123
+ throw unavailable(req.key, storeDown ? 'store-unavailable' : 'not-found')
124
+ const asked = this.prompting.then(() => this.ask(req.key, req.prompt!))
125
+ this.prompting = asked.catch(() => undefined)
126
+ const answer = await asked
127
+ try {
128
+ await this.store.set({ ...entry, value: answer })
129
+ } catch (err) {
130
+ this.warn(
131
+ `[vx] could not save '${req.key}' to the OS keychain (${err instanceof Error ? err.message : String(err)}); used for this run only`,
132
+ )
133
+ }
134
+ return answer
135
+ }
136
+
137
+ private async ask(key: string, prompt: string): Promise<string> {
138
+ if (this.ci || this.closed || !this.terminal.interactive())
139
+ throw unavailable(key, 'no-terminal')
140
+ const release = this.quiet()
141
+ let answer: string
142
+ try {
143
+ answer = (await this.terminal.ask(prompt)).trim()
144
+ } finally {
145
+ release()
146
+ }
147
+ if (answer === '') throw unavailable(key, 'declined')
148
+ return answer
149
+ }
150
+ }
@@ -122,7 +122,10 @@ export interface FingerprintClaims {
122
122
  file: string
123
123
  before: Uint8Array | null
124
124
  after: Uint8Array | null
125
+ extras?: ReadonlyMap<string, { before: Uint8Array | null; after: Uint8Array | null }>
125
126
  }): Promise<ReadonlySet<string> | undefined>
127
+ /** The files a claimed file names that its claimant keys too; absent = it reads none. */
128
+ extraFiles?(file: string, bytes: Uint8Array): readonly string[] | undefined
126
129
  }
127
130
 
128
131
  /**
@@ -303,32 +306,79 @@ export async function affectedChanges(
303
306
  let claims: FingerprintClaims | undefined
304
307
  const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
305
308
  (claims ??= await args.fingerprintClaims?.())
306
- // A patch bun.lock names folds into the key of the plugin that claims
307
- // bun.lock (`bun()`; the lockfile records no hash of it), so its edit
308
- // widens as a lockfile edit does. A patch the lockfile no longer names
309
- // moved bun.lock itself.
310
- if (changed.length > 0) {
311
- const lock = await bytesOrNull(path.join(args.workspaceRoot, 'bun.lock'))
312
- if (lock !== null) {
313
- const patches = new Set(bunPatchFiles(lock))
314
- const patch = changed.find((p) => patches.has(p))
315
- if (patch !== undefined && (await loadClaims())?.files.has('bun.lock')) {
316
- return everything(patch)
309
+ // A file a claimed lockfile names that its claimant keys but the
310
+ // lockfile does not pin (`FingerprintClaim.extraFiles`: bun.lock's
311
+ // patches, a `file:`, `link:` or `portal:` directory
312
+ // a manager copies or links) is part of
313
+ // that lockfile: a change to one, or under one, is a lockfile change the
314
+ // claimant answers with it. Only a path no project below the root owns
315
+ // can be one, so a diff inside the projects never loads the claims.
316
+ const extrasBy = new Map<
317
+ string,
318
+ Map<string, { before: Uint8Array | null; after: Uint8Array | null }>
319
+ >()
320
+ const rootDir = path.resolve(args.workspaceRoot)
321
+ const loose = changed.filter((rel) => {
322
+ const abs = path.join(rootDir, rel)
323
+ return !args.projects.some((p) => p.dir !== rootDir && abs.startsWith(p.dir + path.sep))
324
+ })
325
+ const extraClaims = loose.length > 0 ? await loadClaims() : undefined
326
+ for (const file of extraClaims?.files ?? []) {
327
+ const after = await bytesOrNull(path.join(args.workspaceRoot, file))
328
+ const before = await gitBytesAt(args.workspaceRoot, base, file)
329
+ const namedAfter = after === null ? [] : extraClaims!.extraFiles?.(file, after)
330
+ const named = new Set([
331
+ ...(before === null ? [] : (extraClaims!.extraFiles?.(file, before) ?? [])),
332
+ ...(namedAfter ?? []),
333
+ ])
334
+ const extras = new Map<string, { before: Uint8Array | null; after: Uint8Array | null }>()
335
+ for (const rel of named) {
336
+ const at = path.posix.normalize(rel).replace(/\/+$/, '')
337
+ if (loose.includes(at)) {
338
+ extras.set(rel, {
339
+ before: await gitBytesAt(args.workspaceRoot, base, at),
340
+ after: await bytesOrNull(path.join(args.workspaceRoot, at)),
341
+ })
342
+ continue
343
+ }
344
+ // A directory's sides are not its bytes: a file under it changed,
345
+ // so they differ, and only the projects reaching it move.
346
+ const under = loose.filter((p) => p.startsWith(`${at}/`))
347
+ if (under.length > 0) {
348
+ extras.set(rel, { before: null, after: new TextEncoder().encode(under.join('\0')) })
317
349
  }
318
350
  }
351
+ if (extras.size > 0) extrasBy.set(file, extras)
352
+ else if (file === 'bun.lock' && after !== null && namedAfter === undefined) {
353
+ // A bun.lock claimant that names no file still folds the patches
354
+ // bun.lock names (the lockfile records no hash of them): it cannot
355
+ // say which projects one reaches, so its edit widens.
356
+ const patches = new Set(bunPatchFiles(after))
357
+ const patch = loose.find((p) => patches.has(p))
358
+ if (patch !== undefined) return everything(patch)
359
+ }
319
360
  }
320
361
  const claimedOwned = new Map<string, string>()
321
362
  const rootNamesChanged = changed.filter((p) => !p.includes('/'))
363
+ for (const file of extrasBy.keys()) {
364
+ if (!rootNamesChanged.includes(file)) rootNamesChanged.push(file)
365
+ }
322
366
  const claimed = rootNamesChanged.length > 0 ? await loadClaims() : undefined
323
367
  for (const file of rootNamesChanged) {
324
368
  if (claimed === undefined || !claimed.files.has(file)) continue
369
+ const extras = extrasBy.get(file)
325
370
  const answer = await claimed.affected({
326
371
  file,
327
372
  before: await gitBytesAt(args.workspaceRoot, base, file),
328
373
  after: await bytesOrNull(path.join(args.workspaceRoot, file)),
374
+ ...(extras !== undefined ? { extras } : {}),
329
375
  })
330
- if (answer === undefined) return everything(file)
331
- for (const name of answer) if (!claimedOwned.has(name)) claimedOwned.set(name, file)
376
+ const why =
377
+ extras === undefined || changed.includes(file)
378
+ ? file
379
+ : path.posix.normalize([...extras.keys()][0]!).replace(/\/+$/, '')
380
+ if (answer === undefined) return everything(why)
381
+ for (const name of answer) if (!claimedOwned.has(name)) claimedOwned.set(name, why)
332
382
  }
333
383
 
334
384
  const realDirs = new Map(
@@ -378,16 +378,21 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
378
378
  }
379
379
  }
380
380
  if (plug.fingerprint !== undefined) {
381
- const claim = plug.fingerprint as { files?: unknown; affected?: unknown } | null
381
+ const claim = plug.fingerprint as {
382
+ files?: unknown
383
+ affected?: unknown
384
+ extraFiles?: unknown
385
+ } | null
382
386
  if (
383
387
  claim === null ||
384
388
  typeof claim !== 'object' ||
385
389
  !Array.isArray(claim.files) ||
386
390
  claim.files.length === 0 ||
387
- typeof claim.affected !== 'function'
391
+ typeof claim.affected !== 'function' ||
392
+ (claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function')
388
393
  ) {
389
394
  throw new UserError(
390
- `${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function }`,
395
+ `${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function }`,
391
396
  )
392
397
  }
393
398
  for (const file of claim.files as unknown[]) {
@@ -770,6 +775,7 @@ export function validateProjectConfig(
770
775
  }
771
776
  assertPosixGlob(g, `${where}.inputs.files`, 'project')
772
777
  assertNoForeignToken(g, `${where}.inputs.files`)
778
+ assertInputGlob(g, `${where}.inputs.files`)
773
779
  if (namesDirItself(g)) {
774
780
  throw new UserError(
775
781
  `${where}.inputs.files: "${g}" names the project directory itself and selects nothing — use "**" for everything under it`,
@@ -1333,6 +1339,33 @@ function assertPosixGlob(glob: string, where: string, base: string): void {
1333
1339
  )
1334
1340
  }
1335
1341
 
1342
+ const ONE_ALTERNATIVE_BRACE = /(?<!\\)\{[^{},]*\}/
1343
+
1344
+ /**
1345
+ * What every run would refuse, or silently key nothing for, refused at
1346
+ * `vx lock` instead. `Bun.Glob` reads `{b}` as a brace of one alternative,
1347
+ * so `src/{b}.ts` matches `src/b.ts` and never a file named `{b}.ts`:
1348
+ * neither reading is sure to be the one meant, so both spellings are
1349
+ * named. A path under `.git` is never listed by git, so it keyed nothing.
1350
+ */
1351
+ function assertInputGlob(glob: string, where: string): void {
1352
+ const m = ONE_ALTERNATIVE_BRACE.exec(glob)
1353
+ if (m !== null) {
1354
+ const inner = m[0].slice(1, -1)
1355
+ throw new UserError(
1356
+ `${where}: "${glob}" holds a brace with one alternative, which matches ` +
1357
+ `"${inner}" and never a name holding "${m[0]}". Write "${glob.replace(m[0], inner)}", ` +
1358
+ `or "${glob.replace(m[0], `\\{${inner}\\}`)}" for the braces themselves.`,
1359
+ )
1360
+ }
1361
+ if (glob.replace(/^!/, '').split('/').includes('.git')) {
1362
+ throw new UserError(
1363
+ `${where}: "${glob}" names a path under .git, which git never lists, so it keys nothing — ` +
1364
+ `inputs are the repository's files`,
1365
+ )
1366
+ }
1367
+ }
1368
+
1336
1369
  /**
1337
1370
  * `!!x` INVERTS the input set instead of double-negating it.
1338
1371
  *
@@ -1565,6 +1598,7 @@ function validateWorkspaceGlobs(v: unknown, where: string, negation: boolean): v
1565
1598
  }
1566
1599
  assertPosixGlob(g, where, 'workspace-root')
1567
1600
  assertNoForeignToken(g, where)
1601
+ if (negation) assertInputGlob(g, where)
1568
1602
  if (namesDirItself(g)) {
1569
1603
  throw new UserError(
1570
1604
  `${where}: "${g}" names the workspace root itself and selects nothing — use "**" for everything under it`,
@@ -640,6 +640,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
640
640
  'plugin',
641
641
  'randomUUIDv7',
642
642
  'resolveSync',
643
+ 'secrets',
643
644
  'serve',
644
645
  'sleep',
645
646
  'sleepSync',
@@ -188,14 +188,19 @@ export function linkDiscoveredEdges(
188
188
  [edge.reach, reach],
189
189
  ] as const) {
190
190
  for (const target of kind) {
191
- const dir = path.resolve(root, target)
192
- const rel = relPosix(root, dir)
193
- const where = `plugin '${edge.by}' gave project "${edge.meta.name}" an edge to ${target}`
194
- if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
195
- throw new UserError(`${where}: outside the workspace root`)
191
+ // Every project lies inside the root (`discoveredProject` refuses
192
+ // one that does not), so a target naming one needs no other check:
193
+ // a plugin hands project dirs as they are, absolute, and resolving
194
+ // and judging each of 5,000 edges was ~15 ms at 1,000 projects.
195
+ const name = byDir.get(target) ?? byDir.get(path.resolve(root, target))
196
+ if (name === undefined) {
197
+ const rel = relPosix(root, path.resolve(root, target))
198
+ const where = `plugin '${edge.by}' gave project "${edge.meta.name}" an edge to ${target}`
199
+ if (rel === '..' || rel.startsWith('../') || path.isAbsolute(rel)) {
200
+ throw new UserError(`${where}: outside the workspace root`)
201
+ }
202
+ throw new UserError(`${where}: not a project`)
196
203
  }
197
- const name = byDir.get(dir)
198
- if (name === undefined) throw new UserError(`${where}: not a project`)
199
204
  let set = into.get(edge.meta)
200
205
  if (set === undefined) into.set(edge.meta, (set = new Set()))
201
206
  set.add(name)
@@ -417,12 +422,13 @@ export function lockedProjects(
417
422
  * The project a plugin's `discover` entry names: a new meta (`added`) for a
418
423
  * directory that is not a project yet, else the project already there,
419
424
  * renamed when its name was its path's. A config's `name` wins over the
420
- * plugin's; a name another plugin gave is not changed. `by` names the
421
- * plugin in a refusal.
425
+ * plugin's; a name another plugin gave is not changed. `known` is the
426
+ * projects by directory: a scan per entry was quadratic, 106 ms of a 5,000
427
+ * project warm run. `by` names the plugin in a refusal.
422
428
  */
423
429
  export async function discoveredProject(
424
430
  root: string,
425
- known: readonly ProjectMeta[],
431
+ known: ReadonlyMap<string, ProjectMeta>,
426
432
  entry: {
427
433
  readonly dir: string
428
434
  readonly name?: string
@@ -456,7 +462,7 @@ export async function discoveredProject(
456
462
  throw new UserError(`${where}${named} at ${dir}: outside the workspace root`)
457
463
  }
458
464
  const shown = rel === '' ? '.' : rel
459
- const meta = known.find((p) => p.dir === dir)
465
+ const meta = known.get(dir)
460
466
  if (meta !== undefined) {
461
467
  const from = namedBy.get(meta)
462
468
  if (name === undefined || name === meta.name || from === 'config') return { meta, added: false }