@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.
- package/package.json +5 -12
- package/skills/vx/SKILL.md +4 -4
- package/src/cache/archive.ts +525 -104
- package/src/cache/cache.ts +92 -12
- package/src/cache/fold-memo.ts +58 -0
- package/src/cache/git-inputs.ts +213 -31
- package/src/cache/index.ts +6 -2
- package/src/cache/inputs.ts +169 -79
- package/src/cache/key-fold.ts +90 -69
- package/src/cache/layer.ts +2 -6
- package/src/cache/layered-cache.ts +68 -4
- package/src/cache/output-index.ts +25 -4
- package/src/cache/remote-lane.ts +27 -10
- package/src/cache/schema.ts +13 -3
- package/src/cache/scoped-cache.ts +3 -1
- package/src/cache/umask.ts +29 -0
- package/src/cli/docs.ts +71 -7
- package/src/cli/help.ts +4 -4
- package/src/cli/plan-format.ts +4 -1
- package/src/cli/run.ts +10 -3
- package/src/cli/select.ts +2 -0
- package/src/exec/runner.ts +5 -0
- package/src/exec/sandbox-runtime.ts +81 -1
- package/src/graph/priorities.ts +14 -1
- package/src/history/history.ts +43 -33
- package/src/index.ts +10 -0
- package/src/orchestrator/deferred-outputs.ts +3 -2
- package/src/orchestrator/execute-task.ts +20 -14
- package/src/orchestrator/hit-restore.ts +10 -2
- package/src/orchestrator/index.ts +1 -0
- package/src/orchestrator/lockfile-claim.ts +54 -8
- package/src/orchestrator/miss-save.ts +27 -17
- package/src/orchestrator/output-host.ts +9 -1
- package/src/orchestrator/plan.ts +4 -0
- package/src/orchestrator/plugin-host.ts +31 -0
- package/src/orchestrator/plugin.ts +29 -3
- package/src/orchestrator/prepare.ts +74 -5
- package/src/orchestrator/projects.ts +6 -1
- package/src/orchestrator/run.ts +37 -15
- package/src/orchestrator/sandbox-request.ts +1 -48
- package/src/orchestrator/save-lane.ts +17 -10
- package/src/orchestrator/signals.ts +7 -10
- package/src/orchestrator/stable-keys.ts +83 -25
- package/src/orchestrator/task-hash.ts +46 -12
- package/src/report/events.ts +12 -0
- package/src/report/framed-output.ts +2 -1
- package/src/report/logger.ts +53 -13
- package/src/report/status-line.ts +45 -4
- package/src/report/summary.ts +5 -2
- package/src/util/errors.ts +1 -1
- package/src/util/hidden-prompt.ts +74 -0
- package/src/util/index.ts +12 -2
- package/src/util/paths.ts +33 -14
- package/src/util/secrets.ts +150 -0
- package/src/workspace/affected.ts +63 -13
- package/src/workspace/config-schema.ts +37 -3
- package/src/workspace/project-loader.ts +1 -0
- package/src/workspace/workspace.ts +17 -11
- package/docs/caching.md +0 -2376
- package/docs/cli.md +0 -3285
- package/docs/execution.md +0 -676
- package/docs/features.md +0 -178
- package/docs/patterns.md +0 -219
- package/docs/schema.md +0 -2468
- package/docs/security.md +0 -253
- package/src/cli/docs-corpus.ts +0 -21
- 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
|
|
391
|
-
*
|
|
392
|
-
*
|
|
393
|
-
*
|
|
394
|
-
* and
|
|
395
|
-
* uncommitted workspace config and a hit restored a lock or
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
307
|
-
//
|
|
308
|
-
//
|
|
309
|
-
//
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
331
|
-
|
|
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 {
|
|
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`,
|
|
@@ -188,14 +188,19 @@ export function linkDiscoveredEdges(
|
|
|
188
188
|
[edge.reach, reach],
|
|
189
189
|
] as const) {
|
|
190
190
|
for (const target of kind) {
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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. `
|
|
421
|
-
*
|
|
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:
|
|
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.
|
|
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 }
|