@vzn/vx 0.0.640 → 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 (76) hide show
  1. package/launcher.sh +9 -2
  2. package/package.json +5 -12
  3. package/skills/vx/SKILL.md +4 -4
  4. package/src/bin.ts +8 -6
  5. package/src/cache/archive.ts +735 -107
  6. package/src/cache/cache.ts +133 -29
  7. package/src/cache/fold-memo.ts +58 -0
  8. package/src/cache/git-inputs.ts +213 -31
  9. package/src/cache/index.ts +9 -2
  10. package/src/cache/inputs.ts +244 -108
  11. package/src/cache/key-fold.ts +90 -69
  12. package/src/cache/layer.ts +9 -7
  13. package/src/cache/layered-cache.ts +74 -9
  14. package/src/cache/output-index.ts +25 -4
  15. package/src/cache/remote-lane.ts +27 -10
  16. package/src/cache/schema.ts +13 -3
  17. package/src/cache/scoped-cache.ts +11 -3
  18. package/src/cache/umask.ts +29 -0
  19. package/src/cli/docs.ts +71 -7
  20. package/src/cli/help.ts +11 -5
  21. package/src/cli/plan-format.ts +4 -1
  22. package/src/cli/run.ts +10 -3
  23. package/src/cli/select.ts +2 -0
  24. package/src/cli/upgrade.ts +37 -2
  25. package/src/cli/watch-filter.ts +1 -6
  26. package/src/cli/watch-fs.ts +2 -1
  27. package/src/exec/runner.ts +5 -0
  28. package/src/exec/sandbox-runtime.ts +81 -1
  29. package/src/graph/priorities.ts +14 -1
  30. package/src/handoff.ts +5 -0
  31. package/src/history/history.ts +43 -33
  32. package/src/index.ts +11 -1
  33. package/src/orchestrator/deferred-outputs.ts +3 -2
  34. package/src/orchestrator/execute-task.ts +20 -14
  35. package/src/orchestrator/hit-restore.ts +111 -29
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/local-shortcircuit.ts +8 -0
  38. package/src/orchestrator/lockfile-claim.ts +54 -8
  39. package/src/orchestrator/miss-save.ts +27 -17
  40. package/src/orchestrator/output-host.ts +9 -1
  41. package/src/orchestrator/plan.ts +4 -0
  42. package/src/orchestrator/plugin-host.ts +35 -5
  43. package/src/orchestrator/plugin.ts +29 -3
  44. package/src/orchestrator/prepare.ts +74 -5
  45. package/src/orchestrator/projects.ts +6 -1
  46. package/src/orchestrator/run-report.ts +30 -9
  47. package/src/orchestrator/run.ts +62 -38
  48. package/src/orchestrator/sandbox-request.ts +1 -48
  49. package/src/orchestrator/save-lane.ts +17 -10
  50. package/src/orchestrator/signals.ts +7 -10
  51. package/src/orchestrator/stable-keys.ts +83 -25
  52. package/src/orchestrator/task-hash.ts +46 -12
  53. package/src/report/events.ts +12 -0
  54. package/src/report/framed-output.ts +2 -1
  55. package/src/report/logger.ts +58 -15
  56. package/src/report/status-line.ts +45 -4
  57. package/src/report/summary.ts +11 -6
  58. package/src/util/errors.ts +1 -1
  59. package/src/util/hidden-prompt.ts +74 -0
  60. package/src/util/index.ts +13 -0
  61. package/src/util/paths.ts +58 -5
  62. package/src/util/secrets.ts +150 -0
  63. package/src/workspace/affected.ts +63 -13
  64. package/src/workspace/config-eval.ts +2 -2
  65. package/src/workspace/config-schema.ts +37 -3
  66. package/src/workspace/project-loader.ts +6 -4
  67. package/src/workspace/workspace.ts +17 -11
  68. package/docs/caching.md +0 -2352
  69. package/docs/cli.md +0 -3280
  70. package/docs/execution.md +0 -675
  71. package/docs/features.md +0 -178
  72. package/docs/patterns.md +0 -219
  73. package/docs/schema.md +0 -2461
  74. package/docs/security.md +0 -252
  75. package/src/cli/docs-corpus.ts +0 -21
  76. package/src/cli/docs-text.d.ts +0 -5
@@ -81,9 +81,9 @@ export interface SummaryStats {
81
81
  /** Ended OK — executed successes AND cache hits. */
82
82
  successful: number
83
83
  skipped: number
84
- /** Killed by a shutdown signal (final summary only). Not in `total`. */
84
+ /** Killed by a shutdown signal, shown as `aborted`. Not in `total`. */
85
85
  aborted?: number
86
- /** Reached by the stop before they ran (final summary only). Not in `total`. */
86
+ /** Reached by the stop before they ran. Not in `total`. */
87
87
  notRun?: number
88
88
  total: number
89
89
  upToDate: number
@@ -110,7 +110,7 @@ export interface SummaryStats {
110
110
  savedMs?: number
111
111
  /** A run of groups only: their names, so `0 tasks` says why. */
112
112
  emptyGroups?: readonly string[]
113
- /** Each finished task's cache bucket by its key, so its remote traffic can move it. */
113
+ /** Each finished task's cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
114
114
  buckets?: ReadonlyMap<string, CacheBucket>
115
115
  }
116
116
 
@@ -183,6 +183,8 @@ export interface RunContext {
183
183
  version: string
184
184
  /** Projects with a real task in the graph — the "affected" half of the bar. */
185
185
  packageCount: number
186
+ /** The run's whole id, as `vx last` takes it; shown on the `info` row. */
187
+ runId?: string | undefined
186
188
  /** Worker-pool size for this run; shown on the `info` row. */
187
189
  concurrency?: number
188
190
  remoteCacheEnabled: boolean
@@ -415,6 +417,7 @@ export function formatSummarySection(
415
417
  `admit held ${stats.held.count} task${stats.held.count === 1 ? '' : 's'}, ${formatElapsed(stats.held.sumMs)} in all`,
416
418
  )
417
419
  }
420
+ if (context.runId !== undefined) info.push(`run ${context.runId}`)
418
421
  lines.push('', row('info', join(info)), timeRow)
419
422
  } else {
420
423
  lines.push('', timeRow)
@@ -536,12 +539,14 @@ export function formatRunSummary(
536
539
  )
537
540
  }
538
541
 
539
- /** Each finished task's cache bucket by its key. */
542
+ /** Each finished task's cache bucket by its key and by its id. */
540
543
  function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, CacheBucket> {
541
544
  const out = new Map<string, CacheBucket>()
542
545
  for (const o of outcomes) {
543
- const bucket = o.hash === undefined ? undefined : cacheBucketOf(o)
544
- if (bucket !== undefined) out.set(o.hash!, bucket)
546
+ const bucket = cacheBucketOf(o)
547
+ if (bucket === undefined) continue
548
+ if (o.hash !== undefined) out.set(o.hash, bucket)
549
+ out.set(o.node.id, bucket)
545
550
  }
546
551
  return out
547
552
  }
@@ -97,7 +97,7 @@ export function wantsJson(argv: readonly string[]): boolean {
97
97
  /**
98
98
  * The JSON a verb that answers JSON prints for a refusal: one line. A
99
99
  * core code (`VX_E_…`) links its section of cli.md, which says the fix;
100
- * `vx docs <code>` prints the same section offline. A plugin's code has none.
100
+ * `vx docs <code>` prints the same section. A plugin's code has none.
101
101
  */
102
102
  export function errorDocument(code: string, message: string): string {
103
103
  const docs = code.startsWith('VX_E_')
@@ -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
@@ -59,6 +59,8 @@ export {
59
59
  normalizeBunGlob,
60
60
  normalizeGlob,
61
61
  outputMatcher,
62
+ outputNever,
63
+ restorableOutput,
62
64
  printable,
63
65
  relPosix,
64
66
  DOT_VX_GITATTRIBUTES,
@@ -71,6 +73,7 @@ export {
71
73
  taskGlob,
72
74
  wholeSubtreePrefixes,
73
75
  slashBraceExpansions,
76
+ braceAlternatives,
74
77
  } from './paths.js'
75
78
  export {
76
79
  claimExitForSignal,
@@ -116,3 +119,13 @@ export {
116
119
  secretNamed,
117
120
  type SecretMask,
118
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
@@ -12,11 +12,12 @@ export function toPosix(p: string): string {
12
12
  }
13
13
 
14
14
  /**
15
- * `.vx/.gitignore`: git ignores the machine-local `.vx/cache` and nothing
16
- * else in `.vx`, so the workspace config and the lock beside it are
17
- * committed. `vx init` and the cache write it when missing.
15
+ * `.vx/.gitignore`: git ignores the machine-local `.vx/cache`, `.vx/tmp`
16
+ * (where restores stage) and nothing else in `.vx`, so the workspace
17
+ * config and the lock beside it are committed. `vx init` and the cache
18
+ * write it when missing.
18
19
  */
19
- export const DOT_VX_GITIGNORE = '/cache\n'
20
+ export const DOT_VX_GITIGNORE = '/cache\n/tmp\n'
20
21
 
21
22
  /** `.vx/.gitattributes`' text: GitHub collapses the machine lock in a diff. */
22
23
  export const DOT_VX_GITATTRIBUTES = 'projects.lock.json linguist-generated=true\n'
@@ -385,6 +386,58 @@ export function outputMatcher(globs: readonly string[]): (rel: string) => boolea
385
386
  return (rel) => pos(rel) && !neg(rel)
386
387
  }
387
388
 
389
+ /**
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).
399
+ */
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
+ }
417
+
418
+ /** Whether a positive glob in `globs` names a `node_modules` directory. */
419
+ function namesNodeModules(globs: readonly string[]): boolean {
420
+ return globs.some(
421
+ (g) => !g.startsWith('!') && normalizeGlob(g).split('/').includes('node_modules'),
422
+ )
423
+ }
424
+
425
+ /**
426
+ * Whether a restore may write `rel`: one of the outputs `globs` declare,
427
+ * never under `OUTPUT_NEVER`, and under `node_modules` only where a glob
428
+ * names it — what the save side could have packed (`resolveOutputs`). An
429
+ * artifact naming anything else was not written by this task's save, and
430
+ * none of it is restored (2026-10-16).
431
+ */
432
+ export function restorableOutput(
433
+ globs: readonly string[],
434
+ scope: 'project' | 'workspace' = 'project',
435
+ ): (rel: string) => boolean {
436
+ const isOutput = outputMatcher(globs)
437
+ const never = anyTaskGlob(outputNever(globs, scope))
438
+ return (rel) => isOutput(rel) && !never(rel)
439
+ }
440
+
388
441
  /**
389
442
  * `pattern` with its first brace group expanded, recursively, while that
390
443
  * group holds a `/`; any other pattern as itself, since `Bun.Glob`
@@ -396,7 +449,7 @@ export function slashBraceExpansions(pattern: string): string[] {
396
449
  }
397
450
 
398
451
  /** `pattern` with every brace group of two or more alternatives expanded. */
399
- function braceAlternatives(pattern: string): string[] {
452
+ export function braceAlternatives(pattern: string): string[] {
400
453
  return expandBraces(pattern, false)
401
454
  }
402
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(
@@ -103,7 +103,7 @@ const nonJsonPaths = ${nonJsonPaths.toString()}
103
103
  throw new Error('process.exit(' + (code ?? '') + ') in a config: ' + ${JSON.stringify(CONFIG_EXIT)})
104
104
  }
105
105
  }
106
- // Bun's resolver reads \\ as a separator, so a config under a\\b is served
106
+ // Bun's resolver reads \\ as a separator and ? as a query, so a config under a\\b or a?b is served
107
107
  // from the path as written (project-loader.ts's LITERAL_QUERY), read now.
108
108
  let literal = false
109
109
  const serveLiteral = () => {
@@ -172,7 +172,7 @@ self.onmessage = async (e) => {
172
172
  if (umaskIn !== null && globalThis.process.umask() !== umaskIn) globalThis.process.umask(umaskIn)
173
173
  }
174
174
  try {
175
- const literalPath = path.includes('\\\\')
175
+ const literalPath = /[\\\\?]/.test(path)
176
176
  if (literalPath) serveLiteral()
177
177
  const ns = await import(literalPath ? path + '?vx-literal' : path)
178
178
  // Awaited as the in-process load's async return flattens it: a Promise
@@ -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`,
@@ -75,8 +75,9 @@ let serving = false
75
75
  const HELD_QUERY = /\?vx-(?:held|literal)=([0-9a-f]+)$/
76
76
 
77
77
  // Bun's resolver reads `\` as a separator, even in a file: URL, so a
78
- // config under `a\b` was looked for under `a/b`. Resolving it here keeps
79
- // the path as written; only the served load can take it.
78
+ // config under `a\b` was looked for under `a/b`, and `?` as a query, so
79
+ // one under `a?b` was looked for under `a`. Resolving it here keeps the
80
+ // path as written; only the served load can take it.
80
81
  const LITERAL_QUERY = /\?vx-literal=[0-9a-f]+$/
81
82
 
82
83
  function serveHeldSources(): void {
@@ -148,14 +149,14 @@ async function loadDefaultExport(
148
149
  // URL yet, and a repeat load (the one that could replay an evaluation
149
150
  // made under earlier env values) re-evaluates in a worker instead
150
151
  // (`loadedConfigs`, item 678).
151
- const literal = configPath.includes('\\')
152
+ const literal = /[\\?]/.test(configPath)
152
153
  const source =
153
154
  kind === 'Project' || literal
154
155
  ? servableSource(bytes, /\.[cm]?ts$/.test(configPath) ? 'ts' : 'js')
155
156
  : null
156
157
  if (literal && source === null) {
157
158
  throw new UserError(
158
- `${kind} config ${configPath} sits under a path that holds a backslash, which Bun loads only as an ES module in UTF-8`,
159
+ `${kind} config ${configPath} sits under a path that holds ${configPath.includes('\\') ? 'a backslash' : 'a question mark'}, which Bun loads only as an ES module in UTF-8`,
159
160
  )
160
161
  }
161
162
  const hash = xxh3hex(bytes)
@@ -639,6 +640,7 @@ const BUN_MEMBERS_VX_READS: readonly PropertyKey[] = [
639
640
  'plugin',
640
641
  'randomUUIDv7',
641
642
  'resolveSync',
643
+ 'secrets',
642
644
  'serve',
643
645
  'sleep',
644
646
  'sleepSync',