@vzn/vx 0.0.650 → 0.0.651
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/README.md +5 -20
- package/package.json +5 -5
- package/schemas/last.json +15 -0
- package/src/bin.ts +9 -31
- package/src/cache/cache.ts +202 -25
- package/src/cache/file-hashes.ts +3 -0
- package/src/cache/git-inputs.ts +188 -39
- package/src/cache/index.ts +1 -0
- package/src/cache/inputs.ts +3 -0
- package/src/cache/layer.ts +11 -0
- package/src/cache/layered-cache.ts +4 -0
- package/src/cache/linked-inputs.ts +3 -0
- package/src/cache/lockfile-memo.ts +3 -2
- package/src/cache/output-index.ts +5 -2
- package/src/cache/remote-lane.ts +2 -1
- package/src/cache/run-history.ts +11 -1
- package/src/cache/schema.ts +11 -1
- package/src/cli/cache.ts +26 -16
- package/src/cli/completions.ts +4 -2
- package/src/cli/docs.ts +10 -11
- package/src/cli/help.ts +12 -23
- package/src/cli/index.ts +4 -9
- package/src/cli/info.ts +15 -7
- package/src/cli/init.ts +23 -15
- package/src/cli/last.ts +213 -203
- package/src/cli/lock.ts +55 -5
- package/src/cli/plan-format.ts +100 -118
- package/src/cli/plugin-commands.ts +8 -2
- package/src/cli/plugin-templates.ts +2 -2
- package/src/cli/run.ts +46 -27
- package/src/cli/select.ts +43 -5
- package/src/cli/show.ts +38 -42
- package/src/cli/task-verb.ts +22 -0
- package/src/cli/upgrade.ts +3 -1
- package/src/cli/watch-fs.ts +6 -1
- package/src/cli/watch-judge.ts +2 -1
- package/src/cli/watch.ts +83 -44
- package/src/cli/why.ts +123 -89
- package/src/cli/workspace-config.ts +2 -1
- package/src/exec/executor.ts +2 -1
- package/src/exec/kill-tree.ts +58 -5
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/pipes.ts +152 -0
- package/src/exec/runner.ts +199 -182
- package/src/exec/sandbox-binds.ts +1 -1
- package/src/exec/sandbox-runtime.ts +11 -2
- package/src/exec/spawn-off.ts +154 -0
- package/src/exec/warm-shell.ts +146 -0
- package/src/graph/critical-path.ts +40 -28
- package/src/graph/priorities.ts +98 -44
- package/src/graph/scheduler.ts +14 -10
- package/src/graph/task-graph.ts +27 -1
- package/src/handoff.ts +10 -7
- package/src/history/history.ts +5 -0
- package/src/history/metrics.ts +9 -1
- package/src/index.ts +3 -1
- package/src/orchestrator/doctor.ts +1 -1
- package/src/orchestrator/execute-task.ts +16 -2
- package/src/orchestrator/graph-memo.ts +50 -1
- package/src/orchestrator/graph-stage.ts +6 -0
- package/src/orchestrator/index.ts +3 -0
- package/src/orchestrator/live-usage.ts +16 -11
- package/src/orchestrator/locked-digests.ts +63 -0
- package/src/orchestrator/lockfile-claim.ts +108 -29
- package/src/orchestrator/miss-save.ts +31 -3
- package/src/orchestrator/persistent-task.ts +3 -1
- package/src/orchestrator/plan.ts +5 -1
- package/src/orchestrator/plugin-host.ts +61 -0
- package/src/orchestrator/plugin.ts +47 -2
- package/src/orchestrator/prepare.ts +16 -1
- package/src/orchestrator/projects.ts +4 -1
- package/src/orchestrator/run-context.ts +29 -10
- package/src/orchestrator/run-failures.ts +1 -1
- package/src/orchestrator/run-records.ts +10 -0
- package/src/orchestrator/run.ts +53 -22
- package/src/orchestrator/sandbox-request.ts +2 -2
- package/src/orchestrator/signals.ts +8 -5
- package/src/orchestrator/task-hash.ts +17 -3
- package/src/report/events.ts +3 -11
- package/src/report/forecast.ts +10 -32
- package/src/report/framed-output.ts +272 -173
- package/src/report/index.ts +8 -3
- package/src/report/logger.ts +73 -38
- package/src/report/plain-output.ts +1 -1
- package/src/report/status-line.ts +49 -357
- package/src/report/summary.ts +100 -131
- package/src/ui/ansi.ts +48 -0
- package/src/ui/components.ts +506 -0
- package/src/ui/index.ts +79 -0
- package/src/ui/kit.ts +37 -0
- package/src/ui/live-region.ts +363 -0
- package/src/{util/refusal-notice.ts → ui/refusal.ts} +7 -5
- package/src/ui/terminal.ts +51 -0
- package/src/ui/theme.ts +224 -0
- package/src/ui/version-line.ts +28 -0
- package/src/util/config-frame.ts +65 -10
- package/src/util/edit-distance.ts +6 -1
- package/src/util/index.ts +2 -15
- package/src/util/paths.ts +20 -2
- package/src/util/secret-mask.ts +39 -3
- package/src/util/settle.ts +31 -0
- package/src/util/timing.ts +4 -16
- package/src/workspace/affected.ts +89 -30
- package/src/workspace/config-schema.ts +4 -2
- package/src/workspace/dependencies-lock.ts +191 -0
- package/src/workspace/filter.ts +28 -0
- package/src/workspace/index.ts +9 -0
- package/src/workspace/lockfile.ts +159 -6
- package/src/workspace/migration.ts +4 -3
- package/src/workspace/workspace.ts +3 -0
- package/src/util/colors.ts +0 -133
- package/src/util/report.ts +0 -44
- package/src/util/ui.ts +0 -258
- /package/src/{report/path-links.ts → ui/links.ts} +0 -0
- /package/src/{report → ui}/terminal-signals.ts +0 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// `vx --version`'s one line, imported alone: the answer that loads nothing
|
|
2
|
+
// else (the ui barrel costs ~20 ms, `node:tty` 17 ms). Painted as the
|
|
3
|
+
// `status` component's verb and the info hue; piped, the same words a
|
|
4
|
+
// script reads.
|
|
5
|
+
|
|
6
|
+
import { fstatSync } from 'node:fs'
|
|
7
|
+
|
|
8
|
+
function versionLine(version: string, where: string, on: boolean): string {
|
|
9
|
+
const sgr = (code: string, text: string): string => (on ? `\x1b[${code}m${text}\x1b[0m` : text)
|
|
10
|
+
return `${sgr('1', 'vx')} ${sgr('38;2;6;182;212', version)}${where === '' ? '' : sgr('2', where)}\n`
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Write the line to stdout. Colour is decided here, not by `detectColors`:
|
|
15
|
+
* fd 1 as a character device is the TTY answer, and a CI's log is not
|
|
16
|
+
* painted for this one line. `Bun.stdout`, not `process.stdout`: the Node
|
|
17
|
+
* stream's first touch is ~7 ms, half of this verb (2026-10-02). A reader
|
|
18
|
+
* gone (EPIPE) is no failure of the line.
|
|
19
|
+
*/
|
|
20
|
+
export async function printVersion(version: string, where: string): Promise<void> {
|
|
21
|
+
const env = process.env
|
|
22
|
+
const on = env['NO_COLOR']
|
|
23
|
+
? false
|
|
24
|
+
: env['FORCE_COLOR'] !== undefined
|
|
25
|
+
? env['FORCE_COLOR'] !== '0' && env['FORCE_COLOR'] !== 'false'
|
|
26
|
+
: env['TERM'] !== 'dumb' && fstatSync(1).isCharacterDevice()
|
|
27
|
+
await Bun.write(Bun.stdout, versionLine(version, where, on)).catch(() => {})
|
|
28
|
+
}
|
package/src/util/config-frame.ts
CHANGED
|
@@ -7,9 +7,11 @@
|
|
|
7
7
|
import { readFileSync } from 'node:fs'
|
|
8
8
|
import path from 'node:path'
|
|
9
9
|
|
|
10
|
-
// `<file>: tasks.<path>[:] …`,
|
|
11
|
-
//
|
|
12
|
-
|
|
10
|
+
// `<file>: tasks.<path>[:] …`, `<file>: \`<setting>\` …`, `<file>: task name "<name>" …`,
|
|
11
|
+
// or a top-level `<file> has unknown field …`, from a project's vx.config
|
|
12
|
+
// or the workspace's .vx/workspace.ts.
|
|
13
|
+
const HEAD =
|
|
14
|
+
/^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (?:(tasks\.[^\s:]+)(:?)|`([^`\n]+)`|task name ("(?:[^"\\\n]|\\.)*")))? /
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
17
|
* The message with its file named at `line:col`, relative to `cwd`, and a
|
|
@@ -19,13 +21,22 @@ const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (task
|
|
|
19
21
|
export function configErrorFrame(message: string, cwd: string): string | undefined {
|
|
20
22
|
const head = HEAD.exec(message)
|
|
21
23
|
if (head === null) return positionFrame(message, cwd)
|
|
22
|
-
const [, file,
|
|
24
|
+
const [, file, tasksPath, colon, setting, taskName] = head as unknown as [
|
|
25
|
+
string,
|
|
26
|
+
string,
|
|
27
|
+
string?,
|
|
28
|
+
string?,
|
|
29
|
+
string?,
|
|
30
|
+
string?,
|
|
31
|
+
]
|
|
23
32
|
const rest = message.slice(head[0].length)
|
|
24
33
|
// The field the refusal is about, past the path that leads to it:
|
|
25
34
|
// `has unknown field "comand"`, or `tasks.build: \`cache\` is not allowed`.
|
|
26
35
|
const named =
|
|
27
36
|
colon === ':' ? /^`([^`]+)`/.exec(rest) : /^has (?:unknown )?field "([^"]+)"/.exec(rest)
|
|
28
|
-
const
|
|
37
|
+
const fieldPath =
|
|
38
|
+
taskName === undefined ? (tasksPath ?? setting)?.split('.') : ['tasks', JSON.parse(taskName)]
|
|
39
|
+
const segments = [...(fieldPath ?? []), ...(named === null ? [] : [named[1]!])]
|
|
29
40
|
if (segments.length === 0) return undefined
|
|
30
41
|
let source: string
|
|
31
42
|
try {
|
|
@@ -86,29 +97,43 @@ function framed(
|
|
|
86
97
|
|
|
87
98
|
/**
|
|
88
99
|
* Where the last of `segments` is declared as a key, each one searched
|
|
89
|
-
*
|
|
90
|
-
* longest run of segments that names a key wins.
|
|
100
|
+
* inside the object literal the one before holds. A task name holds dots
|
|
101
|
+
* (`lint.oxfmt`), so the longest run of segments that names a key wins.
|
|
102
|
+
* A last segment the object leaves out (`outputs is required`) is placed
|
|
103
|
+
* at its parent: searched past it, the next task's `outputs` was framed.
|
|
91
104
|
*/
|
|
92
105
|
function locate(
|
|
93
106
|
source: string,
|
|
94
107
|
segments: readonly string[],
|
|
95
108
|
): { line: number; col: number } | undefined {
|
|
96
109
|
let pos = 0
|
|
110
|
+
let end = source.length
|
|
97
111
|
let found = -1
|
|
98
112
|
let i = 0
|
|
99
113
|
while (i < segments.length) {
|
|
100
114
|
let next = -1
|
|
101
115
|
for (let j = segments.length; j > i && next < 0; j--) {
|
|
102
116
|
const name = segments.slice(i, j).join('.')
|
|
103
|
-
|
|
117
|
+
// An empty key is quoted, or every `:` matched; a control character
|
|
118
|
+
// is in the source as its escape (`'\u001b'`).
|
|
119
|
+
const spelled = JSON.stringify(name).slice(1, -1)
|
|
120
|
+
const names = spelled === name ? escapeRe(name) : `${escapeRe(name)}|${escapeRe(spelled)}`
|
|
121
|
+
const re = new RegExp(
|
|
122
|
+
`(?<![\\w$.])(['"\`]${name === '' ? '' : '?'})(?:${names})\\1\\s*:`,
|
|
123
|
+
'g',
|
|
124
|
+
)
|
|
104
125
|
re.lastIndex = pos
|
|
105
126
|
const m = re.exec(source)
|
|
106
|
-
if (m === null) continue
|
|
127
|
+
if (m === null || m.index >= end) continue
|
|
107
128
|
found = m.index + m[1]!.length
|
|
108
129
|
pos = m.index + m[0].length
|
|
130
|
+
end = literalEnd(source, pos)
|
|
109
131
|
next = j
|
|
110
132
|
}
|
|
111
|
-
if (next < 0)
|
|
133
|
+
if (next < 0) {
|
|
134
|
+
if (found < 0 || i !== segments.length - 1) return undefined
|
|
135
|
+
break
|
|
136
|
+
}
|
|
112
137
|
i = next
|
|
113
138
|
}
|
|
114
139
|
if (found < 0) return undefined
|
|
@@ -117,6 +142,36 @@ function locate(
|
|
|
117
142
|
return { line, col: found - before.lastIndexOf('\n') }
|
|
118
143
|
}
|
|
119
144
|
|
|
145
|
+
/**
|
|
146
|
+
* Where the object or array literal starting at `from` (past blanks) ends;
|
|
147
|
+
* `from` itself when the value is no literal (`exec: 'x'`, `cache: shared`),
|
|
148
|
+
* so no key is searched inside it.
|
|
149
|
+
*/
|
|
150
|
+
function literalEnd(source: string, from: number): number {
|
|
151
|
+
let i = from
|
|
152
|
+
while (i < source.length && /\s/.test(source[i]!)) i++
|
|
153
|
+
if (source[i] !== '{' && source[i] !== '[') return from
|
|
154
|
+
let depth = 0
|
|
155
|
+
for (; i < source.length; i++) {
|
|
156
|
+
const c = source[i]!
|
|
157
|
+
if (c === '"' || c === "'" || c === '`') {
|
|
158
|
+
for (i++; i < source.length && source[i] !== c; i++) if (source[i] === '\\') i++
|
|
159
|
+
} else if (c === '/' && source[i + 1] === '/') {
|
|
160
|
+
i = source.indexOf('\n', i)
|
|
161
|
+
if (i < 0) return source.length
|
|
162
|
+
} else if (c === '/' && source[i + 1] === '*') {
|
|
163
|
+
i = source.indexOf('*/', i + 2)
|
|
164
|
+
if (i < 0) return source.length
|
|
165
|
+
i++
|
|
166
|
+
} else if (c === '{' || c === '[') depth++
|
|
167
|
+
else if (c === '}' || c === ']') {
|
|
168
|
+
depth--
|
|
169
|
+
if (depth === 0) return i
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
return source.length
|
|
173
|
+
}
|
|
174
|
+
|
|
120
175
|
function escapeRe(s: string): string {
|
|
121
176
|
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
122
177
|
}
|
|
@@ -37,9 +37,14 @@ export function nearest(
|
|
|
37
37
|
// Two edits rewrite half of a word of four letters or fewer (`--poll`
|
|
38
38
|
// read as `--all`, `vx test` as `last`), unless they swap two letters.
|
|
39
39
|
const short = maxEdits === 2 && name.replace(/^-+/, '').length <= 4
|
|
40
|
+
// The same word in another case is the nearest whatever its length:
|
|
41
|
+
// `--ALL` is three edits from `--all`, `BUILD` five from `build`.
|
|
42
|
+
const lower = name.toLowerCase()
|
|
43
|
+
const list = [...candidates]
|
|
44
|
+
for (const c of list) if (c !== name && c.toLowerCase() === lower) return c
|
|
40
45
|
let best: string | undefined
|
|
41
46
|
let bestD = maxEdits + 1
|
|
42
|
-
for (const c of
|
|
47
|
+
for (const c of list) {
|
|
43
48
|
// Capped one past the budget, so "past it" never reads as within it:
|
|
44
49
|
// `--continue-on-error` was nine edits from `--concurrency`, capped to
|
|
45
50
|
// the three `vx run`'s same-prefix hint allows, and offered.
|
package/src/util/index.ts
CHANGED
|
@@ -27,25 +27,11 @@ export {
|
|
|
27
27
|
} from './errors.js'
|
|
28
28
|
export { configErrorFrame, configRefusal } from './config-frame.js'
|
|
29
29
|
export { envFlagOn } from './env-flag.js'
|
|
30
|
-
export {
|
|
31
|
-
detectColors,
|
|
32
|
-
dumbTerminal,
|
|
33
|
-
ERROR,
|
|
34
|
-
identityColor,
|
|
35
|
-
paint,
|
|
36
|
-
SUCCESS,
|
|
37
|
-
WARN,
|
|
38
|
-
type ColorSupport,
|
|
39
|
-
} from './colors.js'
|
|
40
|
-
export { barWidth, gradientRule, ruleWidth } from './report.js'
|
|
41
|
-
import * as ui from './ui.js'
|
|
42
|
-
export { refusalNotice, sayLine, sayNotice } from './refusal-notice.js'
|
|
43
|
-
export { ui }
|
|
44
30
|
export { keysSorted, xxh3, xxh3hex } from './hash.js'
|
|
45
31
|
export {
|
|
46
32
|
beginRun,
|
|
47
33
|
mark,
|
|
48
|
-
|
|
34
|
+
timingLines,
|
|
49
35
|
restartTimings,
|
|
50
36
|
span,
|
|
51
37
|
stageTimes,
|
|
@@ -86,6 +72,7 @@ export {
|
|
|
86
72
|
} from './paths.js'
|
|
87
73
|
export {
|
|
88
74
|
claimExitForSignal,
|
|
75
|
+
dieOf,
|
|
89
76
|
exitClaimedBySignal,
|
|
90
77
|
killGraceMs,
|
|
91
78
|
settleWithin,
|
package/src/util/paths.ts
CHANGED
|
@@ -523,15 +523,33 @@ export function braceAlternatives(pattern: string): string[] {
|
|
|
523
523
|
return expandBraces(pattern, false)
|
|
524
524
|
}
|
|
525
525
|
|
|
526
|
+
/**
|
|
527
|
+
* Where the `[`…`]` class opening at `i` ends, as `bracket-literal.ts`
|
|
528
|
+
* reads one (a `]` right after the `[` is a member); `i` for a `[` with no
|
|
529
|
+
* `]`, which `Bun.Glob` takes literally. A `,` `{` or `}` inside a class
|
|
530
|
+
* is a member: split there, `src/{a[,]b,x}.ts` became `src/a[.ts` and
|
|
531
|
+
* `src/]b.ts`, so an edit to `src/a,b.ts` kept the key (turborepo#13763).
|
|
532
|
+
*/
|
|
533
|
+
function classEnd(pattern: string, i: number): number {
|
|
534
|
+
const end = pattern.indexOf(']', i + 2)
|
|
535
|
+
return end === -1 ? i : end
|
|
536
|
+
}
|
|
537
|
+
|
|
526
538
|
function expandBraces(pattern: string, slashOnly: boolean): string[] {
|
|
527
539
|
if (pattern.includes('\\')) return [pattern]
|
|
528
|
-
for (let open =
|
|
540
|
+
for (let open = 0; open < pattern.length; open++) {
|
|
541
|
+
if (pattern[open] === '[') {
|
|
542
|
+
open = classEnd(pattern, open)
|
|
543
|
+
continue
|
|
544
|
+
}
|
|
545
|
+
if (pattern[open] !== '{') continue
|
|
529
546
|
let depth = 0
|
|
530
547
|
const cuts: number[] = []
|
|
531
548
|
let close = -1
|
|
532
549
|
for (let i = open; i < pattern.length && close === -1; i++) {
|
|
533
550
|
const c = pattern[i]
|
|
534
|
-
if (c === '
|
|
551
|
+
if (c === '[') i = classEnd(pattern, i)
|
|
552
|
+
else if (c === '{') depth++
|
|
535
553
|
else if (c === '}' && --depth === 0) close = i
|
|
536
554
|
else if (c === ',' && depth === 1) cuts.push(i)
|
|
537
555
|
}
|
package/src/util/secret-mask.ts
CHANGED
|
@@ -115,15 +115,51 @@ export function secretMask(
|
|
|
115
115
|
for (const name in source) {
|
|
116
116
|
const value = source[name]
|
|
117
117
|
if (secretNamed(name)) add(value)
|
|
118
|
-
if (value?.includes('://') === true && value.includes('@'))
|
|
119
|
-
for (const
|
|
120
|
-
}
|
|
118
|
+
if (value?.includes('://') === true && value.includes('@'))
|
|
119
|
+
for (const pw of urlPasswords(value)) add(pw)
|
|
121
120
|
}
|
|
122
121
|
for (const name of named) add(source[name])
|
|
123
122
|
}
|
|
124
123
|
// This process's secrets: the environment's and the held ones.
|
|
125
124
|
if (sources.includes(process.env)) for (const value of held) add(value)
|
|
126
125
|
if (values.size === 0) return null
|
|
126
|
+
const found = [...values]
|
|
127
|
+
for (const m of masks) if (sameValues(m.values, found)) return m.mask
|
|
128
|
+
if (masks.length >= MASKS_KEPT) masks.shift()
|
|
129
|
+
const mask = maskOf(values)
|
|
130
|
+
masks.push({ values: found, mask })
|
|
131
|
+
return mask
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Masks built, with the values each masks: every task of a run asks for
|
|
136
|
+
* the same one, and building it (a sort, a RegExp the engine compiles
|
|
137
|
+
* again) took 30 ms of a cold run's main thread at 500 tasks. The values
|
|
138
|
+
* are read fresh on every call; only the same values reuse a mask.
|
|
139
|
+
*/
|
|
140
|
+
const masks: Array<{ values: readonly string[]; mask: SecretMask }> = []
|
|
141
|
+
const MASKS_KEPT = 8
|
|
142
|
+
|
|
143
|
+
function sameValues(a: readonly string[], b: readonly string[]): boolean {
|
|
144
|
+
if (a.length !== b.length) return false
|
|
145
|
+
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Each URL value's passwords, found once: the value decides them. */
|
|
150
|
+
const urlPasswordsOf = new Map<string, string[]>()
|
|
151
|
+
|
|
152
|
+
function urlPasswords(value: string): string[] {
|
|
153
|
+
let found = urlPasswordsOf.get(value)
|
|
154
|
+
if (found === undefined) {
|
|
155
|
+
found = []
|
|
156
|
+
for (const m of value.matchAll(URL_PASSWORD)) if (m[1] !== undefined) found.push(m[1])
|
|
157
|
+
urlPasswordsOf.set(value, found)
|
|
158
|
+
}
|
|
159
|
+
return found
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function maskOf(values: ReadonlySet<string>): SecretMask {
|
|
127
163
|
// Longest first, so a value holding another is replaced whole.
|
|
128
164
|
const sorted = [...values].sort((a, b) => b.length - a.length)
|
|
129
165
|
const pattern = new RegExp(
|
package/src/util/settle.ts
CHANGED
|
@@ -69,6 +69,37 @@ export async function settleWithin(p: Promise<unknown>, ms: number): Promise<boo
|
|
|
69
69
|
// with the run's verdict (1), so bin.ts asks before it exits.
|
|
70
70
|
let signalOwnsExit = false
|
|
71
71
|
|
|
72
|
+
/** How long `dieOf` waits for its own signal to land before the caller exits anyway. */
|
|
73
|
+
const SELF_SIGNAL_WAIT_MS = 1000
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Die of `signal`: its listeners off, so Bun restores the default action,
|
|
77
|
+
* the signal sent to this process, and the caller held until it lands. A
|
|
78
|
+
* kill is not always delivered before it returns: a signal another thread
|
|
79
|
+
* takes, or one a tracer holds (the sandbox's `strace -f` sees every
|
|
80
|
+
* signal before the tracee does), lands later, and the `process.exit`
|
|
81
|
+
* after it won: vx exited with the signal's code (130), not of the
|
|
82
|
+
* signal, and a script running it
|
|
83
|
+
* went on (CI, PR #4867). Bounded: the caller's exit code is the fallback
|
|
84
|
+
* should the signal never land. `testTimerMs('SELF_SIGNAL_DELAY')` sends
|
|
85
|
+
* it from a helper that late instead, the window a test widens.
|
|
86
|
+
*/
|
|
87
|
+
export function dieOf(signal: 'SIGINT' | 'SIGTERM' | 'SIGHUP'): void {
|
|
88
|
+
process.removeAllListeners(signal)
|
|
89
|
+
const lateMs = testTimerMs('SELF_SIGNAL_DELAY', 0)
|
|
90
|
+
if (lateMs > 0) {
|
|
91
|
+
Bun.spawn(['sh', '-c', `sleep ${lateMs / 1000}; kill -${signal.slice(3)} ${process.pid}`], {
|
|
92
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
93
|
+
})
|
|
94
|
+
} else {
|
|
95
|
+
process.kill(process.pid, signal)
|
|
96
|
+
}
|
|
97
|
+
// Synchronous: an `exit` listener's loop never turns again. A signal
|
|
98
|
+
// that lands ends the process here; the wait costs nothing then.
|
|
99
|
+
const until = performance.now() + lateMs + SELF_SIGNAL_WAIT_MS
|
|
100
|
+
while (performance.now() < until) Bun.sleepSync(5)
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
/** A signal's handler will end the process: bin.ts leaves the exit to it. */
|
|
73
104
|
export function claimExitForSignal(): void {
|
|
74
105
|
signalOwnsExit = true
|
package/src/util/timing.ts
CHANGED
|
@@ -9,7 +9,6 @@
|
|
|
9
9
|
// prints (a push per stage, ~15 a run): a telemetry sink draws them as
|
|
10
10
|
// spans (`stageTimes`). The per-call `span`s stay off unless enabled.
|
|
11
11
|
|
|
12
|
-
import { detectColors, paint } from './colors.js'
|
|
13
12
|
const enabled = process.env.VX_DEBUG_TIMING === 'true' || process.env.VX_DEBUG_TIMING === '1'
|
|
14
13
|
let t0 = Bun.nanoseconds()
|
|
15
14
|
// The wall clock at t0, read then: `performance.timeOrigin` plus the
|
|
@@ -88,9 +87,9 @@ export function span(label: string): () => void {
|
|
|
88
87
|
}
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
/**
|
|
92
|
-
export function
|
|
93
|
-
if (!enabled || marks.length === 0) return
|
|
90
|
+
/** The stage table's lines (once, at the end of a run); none unless enabled. */
|
|
91
|
+
export function timingLines(): string[] | undefined {
|
|
92
|
+
if (!enabled || marks.length === 0) return undefined
|
|
94
93
|
const width = Math.max(...marks.map(([l]) => l.length))
|
|
95
94
|
let prev = 0
|
|
96
95
|
const lines = ['[vx timing] stage'.padEnd(width + 14) + ' own cumulative']
|
|
@@ -118,16 +117,5 @@ export function printTimings(): void {
|
|
|
118
117
|
' (wall per call, summed; concurrent calls overlap — compare spans, not totals)',
|
|
119
118
|
)
|
|
120
119
|
}
|
|
121
|
-
|
|
122
|
-
process.stderr.write(
|
|
123
|
-
`${lines
|
|
124
|
-
.map((l) =>
|
|
125
|
-
l.startsWith('[vx timing]')
|
|
126
|
-
? `${paint('#06b6d4', '[vx timing]', c, { bold: true })}${paint('', l.slice(11), c, { dim: true })}`
|
|
127
|
-
: l.startsWith(' (')
|
|
128
|
-
? paint('', l, c, { dim: true })
|
|
129
|
-
: l,
|
|
130
|
-
)
|
|
131
|
-
.join('\n')}\n`,
|
|
132
|
-
)
|
|
120
|
+
return lines
|
|
133
121
|
}
|
|
@@ -18,8 +18,16 @@ import {
|
|
|
18
18
|
notAWorkTree,
|
|
19
19
|
relPosix,
|
|
20
20
|
UserError,
|
|
21
|
+
xxh3hex,
|
|
21
22
|
} from '../util/index.js'
|
|
22
|
-
import { LOCKFILE_NAME, lockKey } from './lockfile.js'
|
|
23
|
+
import { LOCKFILE_NAME, lockEntries, lockKey } from './lockfile.js'
|
|
24
|
+
import {
|
|
25
|
+
DEPENDENCIES_LOCK_NAME,
|
|
26
|
+
type DependenciesLock,
|
|
27
|
+
dependantsOf,
|
|
28
|
+
recordedDigests,
|
|
29
|
+
parseDependenciesLock,
|
|
30
|
+
} from './dependencies-lock.js'
|
|
23
31
|
import { configImportOwners, realpathOr } from './config-imports.js'
|
|
24
32
|
import { WORKSPACE_CONFIG_FILENAMES } from './project-loader.js'
|
|
25
33
|
import { bunPatchFiles } from './fingerprint.js'
|
|
@@ -143,6 +151,10 @@ export interface FingerprintClaims {
|
|
|
143
151
|
}): Promise<ReadonlySet<string> | undefined>
|
|
144
152
|
/** The files a claimed file names that its claimant keys too; absent = it reads none. */
|
|
145
153
|
extraFiles?(file: string, bytes: Uint8Array): Promise<readonly string[] | undefined>
|
|
154
|
+
/** Each project's digest of a claimed file now (`FingerprintClaim.digests`); absent or undefined = none. */
|
|
155
|
+
digests?(
|
|
156
|
+
file: string,
|
|
157
|
+
): Promise<{ hash: string; projects: Readonly<Record<string, string>> } | undefined>
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
/**
|
|
@@ -388,6 +400,12 @@ export async function affectedChanges(
|
|
|
388
400
|
if (wsConfig !== undefined) return everything(wsConfig)
|
|
389
401
|
// The workspace file is loaded once, for the extra files below and the
|
|
390
402
|
// changed root names after them.
|
|
403
|
+
// The base commit's `vx lock` record, read once and only when asked.
|
|
404
|
+
let baseDeps: Promise<DependenciesLock | null> | undefined
|
|
405
|
+
const baseDependencies = (): Promise<DependenciesLock | null> =>
|
|
406
|
+
(baseDeps ??= gitBytesAt(args.workspaceRoot, base, DEPENDENCIES_LOCK_NAME).then(
|
|
407
|
+
parseDependenciesLock,
|
|
408
|
+
))
|
|
391
409
|
let claims: FingerprintClaims | undefined
|
|
392
410
|
const loadClaims = async (): Promise<FingerprintClaims | undefined> =>
|
|
393
411
|
(claims ??= await args.fingerprintClaims?.())
|
|
@@ -446,6 +464,32 @@ export async function affectedChanges(
|
|
|
446
464
|
if (patch !== undefined) return everything(patch)
|
|
447
465
|
}
|
|
448
466
|
}
|
|
467
|
+
// The projects whose digest of `file` moved, from the base's recorded
|
|
468
|
+
// digests and the claimant's now: the base side is never parsed. Null
|
|
469
|
+
// when the base recorded none for these bytes (no lock, or another
|
|
470
|
+
// hash) or the claimant gives none now: `affected` answers then.
|
|
471
|
+
const recordedMoves = async (
|
|
472
|
+
asked: FingerprintClaims,
|
|
473
|
+
file: string,
|
|
474
|
+
before: Uint8Array | null,
|
|
475
|
+
after: Uint8Array | null,
|
|
476
|
+
): Promise<ReadonlySet<string> | null> => {
|
|
477
|
+
if (before === null || after === null || asked.digests === undefined) return null
|
|
478
|
+
const base = await baseDependencies()
|
|
479
|
+
const was = base === null ? undefined : recordedDigests(base, file)
|
|
480
|
+
// Base digests that read another file are that file's at the base,
|
|
481
|
+
// which the record does not prove.
|
|
482
|
+
if (was === undefined || was.hash !== xxh3hex(before) || Object.keys(was.extras).length > 0)
|
|
483
|
+
return null
|
|
484
|
+
const now = await asked.digests(file)
|
|
485
|
+
if (now === undefined) return null
|
|
486
|
+
const moved = new Set<string>()
|
|
487
|
+
for (const p of projects) {
|
|
488
|
+
const d = was.of(lockKey(rootDir, p.dir))
|
|
489
|
+
if (d === undefined || d !== now.projects[p.name]) moved.add(p.name)
|
|
490
|
+
}
|
|
491
|
+
return moved
|
|
492
|
+
}
|
|
449
493
|
const claimedOwned = new Map<string, string>()
|
|
450
494
|
const rootNamesChanged = changed.filter((p) => !p.includes('/'))
|
|
451
495
|
for (const file of extrasBy.keys()) {
|
|
@@ -455,12 +499,11 @@ export async function affectedChanges(
|
|
|
455
499
|
for (const file of rootNamesChanged) {
|
|
456
500
|
if (claimed === undefined || !claimed.files.has(file)) continue
|
|
457
501
|
const extras = extrasBy.get(file)
|
|
458
|
-
const
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
...(extras !== undefined ? { extras } : {})
|
|
463
|
-
})
|
|
502
|
+
const before = await gitBytesAt(args.workspaceRoot, base, file)
|
|
503
|
+
const after = await bytesOrNull(path.join(args.workspaceRoot, file))
|
|
504
|
+
const answer =
|
|
505
|
+
(extras === undefined ? await recordedMoves(claimed, file, before, after) : undefined) ??
|
|
506
|
+
(await claimed.affected({ file, before, after, ...(extras !== undefined ? { extras } : {}) }))
|
|
464
507
|
const why =
|
|
465
508
|
extras === undefined || changed.includes(file)
|
|
466
509
|
? file
|
|
@@ -505,20 +548,8 @@ export async function affectedChanges(
|
|
|
505
548
|
if (diffed.paths.includes(LOCKFILE_NAME) || untracked.includes(LOCKFILE_NAME)) {
|
|
506
549
|
// A side with no lock (or one that will not parse) ran nothing from
|
|
507
550
|
// it: the paths of the configs say what changed, as before the lock.
|
|
508
|
-
const
|
|
509
|
-
|
|
510
|
-
try {
|
|
511
|
-
const locked = (JSON.parse(new TextDecoder().decode(bytes)) as { projects?: unknown })
|
|
512
|
-
.projects
|
|
513
|
-
return typeof locked === 'object' && locked !== null
|
|
514
|
-
? (locked as Record<string, unknown>)
|
|
515
|
-
: null
|
|
516
|
-
} catch {
|
|
517
|
-
return null
|
|
518
|
-
}
|
|
519
|
-
}
|
|
520
|
-
const before = entries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
|
|
521
|
-
const after = entries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
|
|
551
|
+
const before = lockEntries(await gitBytesAt(args.workspaceRoot, base, LOCKFILE_NAME))
|
|
552
|
+
const after = lockEntries(await bytesOrNull(path.join(args.workspaceRoot, LOCKFILE_NAME)))
|
|
522
553
|
const same = (dir: string): boolean =>
|
|
523
554
|
before === null ||
|
|
524
555
|
after === null ||
|
|
@@ -538,9 +569,23 @@ export async function affectedChanges(
|
|
|
538
569
|
const atBase = await gitBlobsAt(args.workspaceRoot, base, markers)
|
|
539
570
|
// A config the base had in a directory that is no project now: the
|
|
540
571
|
// project is gone, and with it the edge naming who depended on it,
|
|
541
|
-
// while their keys lost an upstream. Today's graph cannot say who
|
|
542
|
-
|
|
543
|
-
|
|
572
|
+
// while their keys lost an upstream. Today's graph cannot say who; the
|
|
573
|
+
// base's dependencies lock can. Without one, every project.
|
|
574
|
+
// A config that moved, the same bytes under a new project, is no
|
|
575
|
+
// loss: its files select it where it lives.
|
|
576
|
+
const nameAt = new Map(projects.map((p) => [lockKey(rootDir, p.dir), p.name]))
|
|
577
|
+
for (const gone of await removedProjectConfigs(args.workspaceRoot, atBase, projects)) {
|
|
578
|
+
const recorded = await baseDependencies()
|
|
579
|
+
if (recorded === null) return everything(gone)
|
|
580
|
+
const held = dependantsOf(recorded, path.posix.dirname(gone)).flatMap(
|
|
581
|
+
(d) => nameAt.get(d) ?? [],
|
|
582
|
+
)
|
|
583
|
+
for (const name of held) {
|
|
584
|
+
owned.add(name)
|
|
585
|
+
whole.add(name)
|
|
586
|
+
if (!claimedOwned.has(name)) claimedOwned.set(name, gone)
|
|
587
|
+
}
|
|
588
|
+
}
|
|
544
589
|
for (const name of parentsOfNewNested(args.workspaceRoot, atBase, projects)) {
|
|
545
590
|
owned.add(name)
|
|
546
591
|
whole.add(name)
|
|
@@ -587,7 +632,12 @@ export async function affectedChanges(
|
|
|
587
632
|
}
|
|
588
633
|
|
|
589
634
|
/** What vx writes under `.vx`, which no key reads (cache/inputs.ts `ALWAYS_IGNORE`). */
|
|
590
|
-
const VX_OWN: ReadonlySet<string> = new Set([
|
|
635
|
+
const VX_OWN: ReadonlySet<string> = new Set([
|
|
636
|
+
LOCKFILE_NAME,
|
|
637
|
+
DEPENDENCIES_LOCK_NAME,
|
|
638
|
+
'.vx/.gitattributes',
|
|
639
|
+
'.vx/.gitignore',
|
|
640
|
+
])
|
|
591
641
|
|
|
592
642
|
const CONFIG_NAMES: ReadonlySet<string> = new Set(PROJECT_CONFIG_FILENAMES)
|
|
593
643
|
/**
|
|
@@ -632,22 +682,31 @@ function parentsOfNewNested(
|
|
|
632
682
|
return out
|
|
633
683
|
}
|
|
634
684
|
|
|
635
|
-
/**
|
|
636
|
-
async function
|
|
685
|
+
/** Each config in `atBase` the base had whose file and project are both gone now. */
|
|
686
|
+
async function removedProjectConfigs(
|
|
637
687
|
workspaceRoot: string,
|
|
638
688
|
atBase: ReadonlyMap<string, Uint8Array | null>,
|
|
639
689
|
projects: readonly ProjectMeta[],
|
|
640
|
-
): Promise<string
|
|
690
|
+
): Promise<string[]> {
|
|
641
691
|
const dirs = new Set(projects.map((p) => p.dir.normalize('NFC')))
|
|
692
|
+
const gone: string[] = []
|
|
693
|
+
let added: Uint8Array[] | undefined
|
|
642
694
|
for (const [rel, bytes] of atBase) {
|
|
643
695
|
// A manifest's directory may never have been a project; a config's was.
|
|
644
696
|
if (bytes === null || !CONFIG_NAMES.has(path.posix.basename(rel))) continue
|
|
645
697
|
const dir = path.resolve(workspaceRoot, path.posix.dirname(rel)).normalize('NFC')
|
|
646
698
|
if (dirs.has(dir)) continue
|
|
647
699
|
if (await Bun.file(path.join(workspaceRoot, rel)).exists()) continue
|
|
648
|
-
|
|
700
|
+
// Each config the base lacked, read once, only when one went.
|
|
701
|
+
added ??= await Promise.all(
|
|
702
|
+
[...atBase]
|
|
703
|
+
.filter(([r, b]) => b === null && CONFIG_NAMES.has(path.posix.basename(r)))
|
|
704
|
+
.map(([r]) => bytesOrNull(path.join(workspaceRoot, r))),
|
|
705
|
+
).then((all) => all.filter((b): b is Uint8Array => b !== null))
|
|
706
|
+
if (added!.some((b) => Buffer.from(b).equals(bytes))) continue
|
|
707
|
+
gone.push(rel)
|
|
649
708
|
}
|
|
650
|
-
return
|
|
709
|
+
return gone
|
|
651
710
|
}
|
|
652
711
|
|
|
653
712
|
/**
|
|
@@ -440,6 +440,7 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
440
440
|
files?: unknown
|
|
441
441
|
affected?: unknown
|
|
442
442
|
extraFiles?: unknown
|
|
443
|
+
digests?: unknown
|
|
443
444
|
} | null
|
|
444
445
|
if (
|
|
445
446
|
claim === null ||
|
|
@@ -447,10 +448,11 @@ export function validateWorkspace(config: WorkspaceConfig, configPath: string):
|
|
|
447
448
|
!Array.isArray(claim.files) ||
|
|
448
449
|
claim.files.length === 0 ||
|
|
449
450
|
typeof claim.affected !== 'function' ||
|
|
450
|
-
(claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function')
|
|
451
|
+
(claim.extraFiles !== undefined && typeof claim.extraFiles !== 'function') ||
|
|
452
|
+
(claim.digests !== undefined && typeof claim.digests !== 'function')
|
|
451
453
|
) {
|
|
452
454
|
throw new UserError(
|
|
453
|
-
`${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function }`,
|
|
455
|
+
`${configPath}: \`plugins[${i}].fingerprint\` of plugin '${pkg}' must be { files: [name, …], affected: function, extraFiles?: function, digests?: function }`,
|
|
454
456
|
)
|
|
455
457
|
}
|
|
456
458
|
for (const file of claim.files as unknown[]) {
|