@vzn/vx 0.0.645 → 0.0.648
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 +39 -50
- package/launcher.cjs +9 -2
- package/launcher.sh +2 -2
- package/package.json +5 -5
- package/schemas/cache.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/info.json +16 -2
- package/schemas/last.json +127 -5
- package/schemas/plan.json +28 -2
- package/schemas/show.json +10 -1
- package/skills/vx/SKILL.md +19 -3
- package/src/bin.ts +54 -13
- package/src/cache/archive.ts +324 -23
- package/src/cache/cache.ts +330 -65
- package/src/cache/git-inputs.ts +274 -97
- package/src/cache/index.ts +7 -2
- package/src/cache/inputs.ts +515 -83
- package/src/cache/key-fold.ts +15 -6
- package/src/cache/layer.ts +74 -11
- package/src/cache/layered-cache.ts +154 -59
- package/src/cache/linked-inputs.ts +1 -1
- package/src/cache/output-index.ts +44 -14
- package/src/cache/plugin-cache.ts +49 -23
- package/src/cache/remote-hits.ts +72 -0
- package/src/cache/remote-lane.ts +100 -15
- package/src/cache/run-history.ts +77 -23
- package/src/cache/run-store.ts +11 -10
- package/src/cache/schema.ts +42 -6
- package/src/cache/tar-stream.ts +5 -0
- package/src/cache/umask.ts +1 -2
- package/src/cli/cache.ts +58 -27
- package/src/cli/completions.ts +12 -5
- package/src/cli/core-alias.ts +5 -2
- package/src/cli/docs.ts +42 -16
- package/src/cli/help.ts +62 -25
- package/src/cli/index.ts +79 -45
- package/src/cli/info.ts +12 -6
- package/src/cli/init.ts +222 -34
- package/src/cli/last.ts +443 -47
- package/src/cli/lock.ts +197 -52
- package/src/cli/plan-format.ts +91 -49
- package/src/cli/plugin-commands.ts +160 -11
- package/src/cli/plugin-templates.ts +13 -13
- package/src/cli/registry.ts +209 -0
- package/src/cli/run.ts +111 -41
- package/src/cli/select.ts +148 -49
- package/src/cli/show.ts +59 -16
- package/src/cli/task-verb.ts +16 -6
- package/src/cli/upgrade.ts +155 -54
- package/src/cli/watch-cycle.ts +10 -5
- package/src/cli/watch-filter.ts +71 -3
- package/src/cli/watch-fs.ts +22 -6
- package/src/cli/watch-judge.ts +48 -6
- package/src/cli/watch.ts +163 -42
- package/src/cli/why.ts +211 -73
- package/src/cli/workspace-config.ts +54 -15
- package/src/config.ts +13 -1
- package/src/exec/env.ts +7 -2
- package/src/exec/executor.ts +262 -15
- package/src/exec/index.ts +8 -2
- package/src/exec/kill-tree.ts +15 -0
- package/src/exec/local-executor.ts +1 -1
- package/src/exec/proc-sample.ts +70 -6
- package/src/exec/runner.ts +256 -53
- package/src/exec/sandbox-binds.ts +7 -3
- package/src/exec/sandbox-paths.ts +1 -1
- package/src/exec/sandbox-runtime.ts +222 -28
- package/src/exec/sandbox-violations.ts +52 -20
- package/src/graph/critical-path.ts +79 -0
- package/src/graph/default-build.ts +19 -0
- package/src/graph/definition.ts +38 -0
- package/src/graph/folds.ts +38 -5
- package/src/graph/index.ts +4 -1
- package/src/graph/priorities.ts +74 -8
- package/src/graph/scheduler.ts +326 -65
- package/src/graph/task-graph.ts +228 -35
- package/src/handoff.ts +97 -9
- package/src/history/failure-mode.ts +6 -3
- package/src/history/history.ts +268 -50
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +142 -45
- package/src/index.ts +42 -10
- package/src/orchestrator/admission.ts +10 -6
- package/src/orchestrator/affected-tasks.ts +11 -6
- package/src/orchestrator/deferred-outputs.ts +2 -0
- package/src/orchestrator/doctor.ts +134 -42
- package/src/orchestrator/execute-args.ts +6 -0
- package/src/orchestrator/execute-task.ts +155 -42
- package/src/orchestrator/fingerprint-watch.ts +1 -1
- package/src/orchestrator/graph-memo.ts +140 -0
- package/src/orchestrator/graph-stage.ts +18 -1
- package/src/orchestrator/held-servers.ts +86 -0
- package/src/orchestrator/hit-restore.ts +227 -122
- package/src/orchestrator/index.ts +20 -3
- package/src/orchestrator/local-floor.ts +198 -0
- package/src/orchestrator/local-shortcircuit.ts +51 -8
- package/src/orchestrator/lockfile-claim.ts +96 -16
- package/src/orchestrator/miss-save.ts +52 -12
- package/src/orchestrator/options.ts +8 -6
- package/src/orchestrator/output-host.ts +43 -14
- package/src/orchestrator/output-log.ts +90 -10
- package/src/orchestrator/persistent-task.ts +97 -28
- package/src/orchestrator/persistent.ts +16 -4
- package/src/orchestrator/placement.ts +257 -14
- package/src/orchestrator/plan.ts +64 -7
- package/src/orchestrator/plugin-host.ts +230 -40
- package/src/orchestrator/plugin.ts +150 -37
- package/src/orchestrator/prepare.ts +178 -32
- package/src/orchestrator/projects.ts +65 -40
- package/src/orchestrator/remote-prefetch.ts +119 -25
- package/src/orchestrator/run-artifacts.ts +95 -32
- package/src/orchestrator/run-context.ts +39 -41
- package/src/orchestrator/run-failures.ts +65 -23
- package/src/orchestrator/run-id.ts +6 -3
- package/src/orchestrator/run-keys.ts +300 -0
- package/src/orchestrator/run-live.ts +294 -0
- package/src/orchestrator/run-lock.ts +13 -7
- package/src/orchestrator/run-records.ts +35 -23
- package/src/orchestrator/run-report.ts +3 -3
- package/src/orchestrator/run-steps.ts +58 -22
- package/src/orchestrator/run-stop.ts +22 -5
- package/src/orchestrator/run.ts +409 -79
- package/src/orchestrator/sandbox-fix.ts +7 -5
- package/src/orchestrator/sandbox-request.ts +78 -22
- package/src/orchestrator/shell-verdict.ts +5 -5
- package/src/orchestrator/signals.ts +26 -1
- package/src/orchestrator/stable-keys.ts +92 -8
- package/src/orchestrator/steps.ts +2 -2
- package/src/orchestrator/task-artifacts.ts +206 -0
- package/src/orchestrator/task-hash.ts +89 -14
- package/src/orchestrator/task-log-buffer.ts +72 -36
- package/src/orchestrator/task-process.ts +22 -0
- package/src/orchestrator/telemetry-host.ts +24 -3
- package/src/orchestrator/telemetry.ts +113 -38
- package/src/orchestrator/upstream.ts +15 -0
- package/src/report/events.ts +32 -13
- package/src/report/forecast.ts +36 -35
- package/src/report/framed-output.ts +173 -46
- package/src/report/index.ts +1 -0
- package/src/report/logger.ts +217 -186
- package/src/report/path-links.ts +12 -4
- package/src/report/plain-output.ts +13 -5
- package/src/report/status-line.ts +94 -46
- package/src/report/summary.ts +174 -38
- package/src/report/terminal-signals.ts +13 -2
- package/src/settings.ts +14 -6
- package/src/util/bun-version.ts +4 -1
- package/src/util/colors.ts +20 -3
- package/src/util/config-frame.ts +41 -4
- package/src/{graph → util}/dependency-spec.ts +22 -13
- package/src/util/edit-distance.ts +13 -1
- package/src/util/env-flag.ts +9 -0
- package/src/util/errors.ts +59 -3
- package/src/util/essential-env.ts +57 -0
- package/src/util/git-ignored.ts +1 -1
- package/src/util/hash.ts +35 -0
- package/src/util/hide-env.ts +45 -5
- package/src/util/index.ts +22 -5
- package/src/util/num.ts +8 -0
- package/src/util/paths.ts +121 -21
- package/src/util/report.ts +6 -1
- package/src/util/secret-mask.ts +62 -1
- package/src/util/secrets.ts +7 -2
- package/src/util/size.ts +11 -3
- package/src/util/tail.ts +6 -1
- package/src/util/timing.ts +13 -1
- package/src/util/ui.ts +65 -4
- package/src/util/which.ts +1 -1
- package/src/util/write-file.ts +67 -0
- package/src/workspace/affected.ts +710 -107
- package/src/workspace/bracket-literal.ts +84 -0
- package/src/workspace/canonical-dir.ts +37 -0
- package/src/workspace/config-bytecode.ts +282 -0
- package/src/workspace/config-eval.ts +29 -8
- package/src/workspace/config-imports.ts +199 -58
- package/src/workspace/config-schema.ts +242 -39
- package/src/workspace/filter.ts +37 -8
- package/src/workspace/index.ts +11 -2
- package/src/workspace/json-data.ts +3 -4
- package/src/workspace/lockfile.ts +57 -8
- package/src/workspace/migration.ts +72 -49
- package/src/workspace/package-graph.ts +5 -4
- package/src/workspace/project-loader.ts +314 -47
- package/src/workspace/repo-id.ts +1 -1
- package/src/workspace/workspace.ts +176 -33
- package/src/cli/foreign-flags.ts +0 -727
package/src/report/summary.ts
CHANGED
|
@@ -13,8 +13,10 @@ import {
|
|
|
13
13
|
formatBytes,
|
|
14
14
|
formatElapsed,
|
|
15
15
|
gradientRule,
|
|
16
|
+
identityColor,
|
|
16
17
|
paint,
|
|
17
18
|
SUCCESS,
|
|
19
|
+
ui,
|
|
18
20
|
WARN,
|
|
19
21
|
type ColorSupport,
|
|
20
22
|
} from '../util/index.js'
|
|
@@ -27,6 +29,7 @@ const LOCAL = '#38bdf8' // sky-400
|
|
|
27
29
|
const REMOTE = '#2563eb' // blue-600
|
|
28
30
|
const ABORTED = '#f97316' // orange-500: red's neighbour, not a failure
|
|
29
31
|
const NOT_RUN = '#a855f7' // purple-500: stopped before it began, apart from every other state
|
|
32
|
+
const RUNNING = '#06b6d4' // cyan-500: ui's info
|
|
30
33
|
const ACCENT = '#06b6d4' // cyan-500: the live region's running word
|
|
31
34
|
|
|
32
35
|
/**
|
|
@@ -85,6 +88,11 @@ export interface SummaryStats {
|
|
|
85
88
|
aborted?: number
|
|
86
89
|
/** Reached by the stop before they ran. Not in `total`. */
|
|
87
90
|
notRun?: number
|
|
91
|
+
/** Live: running now, and handed to an executor whose machine has not begun them. */
|
|
92
|
+
running?: number
|
|
93
|
+
scheduled?: number
|
|
94
|
+
/** Live: not started because a dependency has not finished. */
|
|
95
|
+
blocked?: number
|
|
88
96
|
total: number
|
|
89
97
|
upToDate: number
|
|
90
98
|
restoredLocal: number
|
|
@@ -106,10 +114,12 @@ export interface SummaryStats {
|
|
|
106
114
|
held?: { count: number; sumMs: number }
|
|
107
115
|
/** Live section only: the forecast of what is left, from history. */
|
|
108
116
|
etaMs?: number
|
|
117
|
+
/** Live section only: how long the run has gone past that forecast. */
|
|
118
|
+
overMs?: number
|
|
109
119
|
/** A run of groups only: their names, so `0 tasks` says why. */
|
|
110
120
|
emptyGroups?: readonly string[]
|
|
111
|
-
/** Each finished task's cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
|
|
112
|
-
buckets?: ReadonlyMap<string,
|
|
121
|
+
/** Each finished task's id and cache bucket by its key and its id (its run bytes), so its remote traffic can move it. */
|
|
122
|
+
buckets?: ReadonlyMap<string, TaskBucket>
|
|
113
123
|
}
|
|
114
124
|
|
|
115
125
|
/** The cache bar segment a finished task fills before its remote traffic is weighed. */
|
|
@@ -120,13 +130,26 @@ export type CacheBucket =
|
|
|
120
130
|
| 'restoredLocal'
|
|
121
131
|
| 'restoredRemote'
|
|
122
132
|
| 'saved'
|
|
133
|
+
| 'notSaved'
|
|
134
|
+
|
|
135
|
+
/** A finished task's id and cache bucket, as `SummaryStats.buckets` holds them. */
|
|
136
|
+
export interface TaskBucket {
|
|
137
|
+
task: string
|
|
138
|
+
bucket: CacheBucket
|
|
139
|
+
}
|
|
123
140
|
|
|
124
141
|
/** A finished task's own cache bucket; none for a group, a skip or an abort. */
|
|
125
142
|
export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
|
|
126
143
|
if (isGroupTask(o.node)) return undefined
|
|
127
144
|
switch (o.status) {
|
|
128
145
|
case 'success':
|
|
129
|
-
return ranNoCache(o)
|
|
146
|
+
return ranNoCache(o)
|
|
147
|
+
? 'noCache'
|
|
148
|
+
: o.saved === true
|
|
149
|
+
? 'saved'
|
|
150
|
+
: ranUnsaved(o)
|
|
151
|
+
? 'notSaved'
|
|
152
|
+
: 'miss'
|
|
130
153
|
case 'failed':
|
|
131
154
|
return ranNoCache(o) ? 'noCache' : 'miss'
|
|
132
155
|
case 'cache-hit':
|
|
@@ -138,10 +161,12 @@ export function cacheBucketOf(o: TaskOutcome): CacheBucket | undefined {
|
|
|
138
161
|
}
|
|
139
162
|
}
|
|
140
163
|
|
|
141
|
-
type
|
|
164
|
+
type RemoteState = 'uploaded' | 'uploading' | 'remoteFailed'
|
|
165
|
+
const REMOTE_RANK: Record<RemoteState, number> = { uploaded: 0, uploading: 1, remoteFailed: 2 }
|
|
166
|
+
type Buckets = Record<CacheBucket | RemoteState, number>
|
|
142
167
|
|
|
143
168
|
/**
|
|
144
|
-
* The cache bar's segments, each task in one (
|
|
169
|
+
* The cache bar's segments, each task in one (2026-10-15). With layers
|
|
145
170
|
* named, a saved task leaves the misses, and a task with remote traffic
|
|
146
171
|
* leaves its own bucket for its strongest remote state: a failed read or
|
|
147
172
|
* write, then an upload still running, then one that landed. Without, the
|
|
@@ -149,8 +174,12 @@ type Buckets = Record<CacheBucket | 'uploaded' | 'uploading' | 'remoteFailed', n
|
|
|
149
174
|
*/
|
|
150
175
|
function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets {
|
|
151
176
|
const saved = layers === undefined ? 0 : (stats.saved ?? 0)
|
|
177
|
+
// Its own yellow segment, as its legend count is: in the misses' red it
|
|
178
|
+
// was a cell with no count under it (2026-10-21).
|
|
179
|
+
const notSaved = layers === undefined ? 0 : (stats.notSaved ?? 0)
|
|
152
180
|
const b: Buckets = {
|
|
153
|
-
miss: stats.miss - saved,
|
|
181
|
+
miss: stats.miss - saved - notSaved,
|
|
182
|
+
notSaved,
|
|
154
183
|
noCache: stats.noCache ?? 0,
|
|
155
184
|
upToDate: stats.upToDate,
|
|
156
185
|
restoredLocal: stats.restoredLocal,
|
|
@@ -161,13 +190,20 @@ function cacheBuckets(stats: SummaryStats, layers: RunContext['cache']): Buckets
|
|
|
161
190
|
remoteFailed: 0,
|
|
162
191
|
}
|
|
163
192
|
if (layers?.remote !== true || stats.buckets === undefined) return b
|
|
164
|
-
|
|
165
|
-
|
|
193
|
+
// A task's traffic may sit under its key (its entry) and its id (its run
|
|
194
|
+
// bytes): it moves once, to the strongest state of the two.
|
|
195
|
+
const moved = new Map<string, RemoteState>()
|
|
196
|
+
for (const [key, k] of layers.traffic.keys) {
|
|
197
|
+
const from = stats.buckets.get(key)
|
|
166
198
|
// Still running: it counts once it ends.
|
|
167
199
|
if (from === undefined) continue
|
|
168
|
-
|
|
169
|
-
|
|
200
|
+
const state: RemoteState =
|
|
201
|
+
k.failures.length > 0 ? 'remoteFailed' : k.uploading > 0 ? 'uploading' : 'uploaded'
|
|
202
|
+
const prior = moved.get(from.task)
|
|
203
|
+
if (prior === undefined) b[from.bucket]--
|
|
204
|
+
if (prior === undefined || REMOTE_RANK[state] > REMOTE_RANK[prior]) moved.set(from.task, state)
|
|
170
205
|
}
|
|
206
|
+
for (const state of moved.values()) b[state]++
|
|
171
207
|
return b
|
|
172
208
|
}
|
|
173
209
|
|
|
@@ -183,6 +219,10 @@ export interface RunContext {
|
|
|
183
219
|
packageCount: number
|
|
184
220
|
/** The run's whole id, as `vx last` takes it; shown on the `info` row. */
|
|
185
221
|
runId?: string | undefined
|
|
222
|
+
/** The executors' own footer rows (`TaskExecutor.footer`), above the `info` row. */
|
|
223
|
+
executorRows?: () => readonly (readonly [string, ui.Inline])[]
|
|
224
|
+
/** Where a running task runs, `null` while it waits for a machine (`TaskExecutor.running`): its live row. */
|
|
225
|
+
whereRunning?: (taskId: string) => string | null | undefined
|
|
186
226
|
/** Worker-pool size for this run; shown on the `info` row. */
|
|
187
227
|
concurrency?: number
|
|
188
228
|
remoteCacheEnabled: boolean
|
|
@@ -212,6 +252,8 @@ export interface RunContext {
|
|
|
212
252
|
}
|
|
213
253
|
/** Total projects discovery found — the bar's denominator. */
|
|
214
254
|
workspaceProjectCount?: number
|
|
255
|
+
/** The run's warnings no task owns, as they were said: the footer's `warn` row. */
|
|
256
|
+
warnings?: () => readonly string[]
|
|
215
257
|
}
|
|
216
258
|
|
|
217
259
|
export function formatSummarySection(
|
|
@@ -226,7 +268,15 @@ export function formatSummarySection(
|
|
|
226
268
|
const left = stats.left ?? 0
|
|
227
269
|
const aborted = stats.aborted ?? 0
|
|
228
270
|
const notRun = stats.notRun ?? 0
|
|
229
|
-
const
|
|
271
|
+
const running = stats.running ?? 0
|
|
272
|
+
const scheduled = stats.scheduled ?? 0
|
|
273
|
+
const blocked = stats.blocked ?? 0
|
|
274
|
+
const remainder = {
|
|
275
|
+
n: Math.max(0, left - running - scheduled - blocked),
|
|
276
|
+
color: '',
|
|
277
|
+
glyph: '\u25b1',
|
|
278
|
+
dim: true,
|
|
279
|
+
}
|
|
230
280
|
|
|
231
281
|
const dim = (txt: string) => paint('', txt, colors, { dim: true })
|
|
232
282
|
const row = (label: string, value: string): string => ` ${dim(label.padEnd(8))} ${value}`
|
|
@@ -234,7 +284,7 @@ export function formatSummarySection(
|
|
|
234
284
|
|
|
235
285
|
// Tasks meter + numbers, fixed order: failed · success · skipped ·
|
|
236
286
|
// aborted · not run, then a dim total, last on its line. Every task in
|
|
237
|
-
// the run counts, each state its own segment (
|
|
287
|
+
// the run counts, each state its own segment (2026-10-14).
|
|
238
288
|
const runTotal = stats.total + aborted + notRun
|
|
239
289
|
const taskParts: string[] = []
|
|
240
290
|
if (stats.failed > 0)
|
|
@@ -243,6 +293,11 @@ export function formatSummarySection(
|
|
|
243
293
|
if (stats.skipped > 0) taskParts.push(paint(WARN, `${stats.skipped} skipped`, colors))
|
|
244
294
|
if (aborted > 0) taskParts.push(paint(ABORTED, `${aborted} aborted`, colors))
|
|
245
295
|
if (notRun > 0) taskParts.push(paint(NOT_RUN, `${notRun} not run`, colors))
|
|
296
|
+
if (running > 0) taskParts.push(paint(RUNNING, `${running} running`, colors))
|
|
297
|
+
if (scheduled > 0) taskParts.push(dim(`${scheduled} scheduled`))
|
|
298
|
+
if (blocked > 0) taskParts.push(dim(`${blocked} blocked`))
|
|
299
|
+
// What is not done yet, running included: the count the bar's unfilled part shows.
|
|
300
|
+
if (left > 0) taskParts.push(dim(`${left} left`))
|
|
246
301
|
if (runTotal > 0) taskParts.push(dim(`${runTotal} total`))
|
|
247
302
|
const taskBar = segmentBar(
|
|
248
303
|
[
|
|
@@ -251,6 +306,9 @@ export function formatSummarySection(
|
|
|
251
306
|
{ n: stats.skipped, color: WARN },
|
|
252
307
|
{ n: aborted, color: ABORTED },
|
|
253
308
|
{ n: notRun, color: NOT_RUN },
|
|
309
|
+
{ n: running, color: RUNNING },
|
|
310
|
+
{ n: scheduled, color: '', dim: true },
|
|
311
|
+
{ n: blocked, color: '', dim: true },
|
|
254
312
|
remainder,
|
|
255
313
|
],
|
|
256
314
|
colors,
|
|
@@ -271,10 +329,10 @@ export function formatSummarySection(
|
|
|
271
329
|
const wsTotal = context.workspaceProjectCount
|
|
272
330
|
if (wsTotal !== undefined && wsTotal > 0) {
|
|
273
331
|
const width = barWidth(colors)
|
|
274
|
-
const cells =
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
332
|
+
const cells =
|
|
333
|
+
context.packageCount === 0
|
|
334
|
+
? 0
|
|
335
|
+
: Math.min(width, Math.max(1, Math.round((context.packageCount / wsTotal) * width)))
|
|
278
336
|
const bar =
|
|
279
337
|
paint(WARN, '▰'.repeat(cells), colors) +
|
|
280
338
|
paint('', '▱'.repeat(width - cells), colors, { dim: true })
|
|
@@ -310,6 +368,7 @@ export function formatSummarySection(
|
|
|
310
368
|
const cacheBar = segmentBar(
|
|
311
369
|
[
|
|
312
370
|
{ n: b.miss, color: ERROR },
|
|
371
|
+
{ n: b.notSaved, color: WARN },
|
|
313
372
|
{ n: b.noCache, color: '', dim: true },
|
|
314
373
|
{ n: b.upToDate, color: SUCCESS },
|
|
315
374
|
{ n: b.restoredLocal, color: LOCAL },
|
|
@@ -325,7 +384,7 @@ export function formatSummarySection(
|
|
|
325
384
|
)
|
|
326
385
|
const cacheParts: string[] = []
|
|
327
386
|
if (layers !== undefined) {
|
|
328
|
-
// Per layer (
|
|
387
|
+
// Per layer (2026-10-15): `✓` fresh (outputs already on disk, from
|
|
329
388
|
// either layer) and `∅` no cache, both local; `↓` restored from a
|
|
330
389
|
// layer, `↑` saved to it; `✗` a remote read or write failed, `⇡` still
|
|
331
390
|
// uploading. Each count is its bar segment's, glyph and number in its colour.
|
|
@@ -337,7 +396,7 @@ export function formatSummarySection(
|
|
|
337
396
|
...(layers.local ? n(b.restoredLocal, '\u2193', LOCAL) : []),
|
|
338
397
|
...(layers.localWrite ? n(b.saved, '\u2191', LOCAL) : []),
|
|
339
398
|
]
|
|
340
|
-
// The layer words are dim; only the counts carry colour (
|
|
399
|
+
// The layer words are dim; only the counts carry colour (2026-10-15).
|
|
341
400
|
if (local.length > 0) cacheParts.push(`${dim('local')} ${local.join(' ')}`)
|
|
342
401
|
// Each transfer count is followed by its bytes, dim: what the
|
|
343
402
|
// remote moved is the cost a hit or an upload paid.
|
|
@@ -355,8 +414,8 @@ export function formatSummarySection(
|
|
|
355
414
|
]
|
|
356
415
|
if (layers.remote && remote.length > 0)
|
|
357
416
|
cacheParts.push(`${dim('remote')} ${remote.join(' ')}`)
|
|
358
|
-
if (
|
|
359
|
-
|
|
417
|
+
if (b.miss > 0) cacheParts.push(paint(ERROR, `${b.miss} miss`, colors))
|
|
418
|
+
if (b.notSaved > 0) cacheParts.push(paint(WARN, `${b.notSaved} not saved`, colors))
|
|
360
419
|
const notDownloaded = layers.notDownloaded()
|
|
361
420
|
if (notDownloaded > 0) cacheParts.push(paint(WARN, `${notDownloaded} not downloaded`, colors))
|
|
362
421
|
} else {
|
|
@@ -371,7 +430,7 @@ export function formatSummarySection(
|
|
|
371
430
|
}
|
|
372
431
|
// Skipped tasks ride the bar, yellow like the tasks meter, so the two
|
|
373
432
|
// bars fill alike; their count is the tasks legend's alone (the footer
|
|
374
|
-
// never says a fact twice,
|
|
433
|
+
// never says a fact twice, 2026-10-14).
|
|
375
434
|
lines.push(row('cache', cacheBar))
|
|
376
435
|
if (cacheParts.length > 0) lines.push(legend(cacheParts))
|
|
377
436
|
}
|
|
@@ -379,7 +438,7 @@ export function formatSummarySection(
|
|
|
379
438
|
// Cache-miss duration spread (owner: hits' restore times polluted
|
|
380
439
|
// it — only tasks that actually executed count). Dim: context.
|
|
381
440
|
// A value already on the row is not said again: one executed task read
|
|
382
|
-
// as its own max, avg and min (
|
|
441
|
+
// as its own max, avg and min (2026-10-14). Tasks that all took the
|
|
383
442
|
// same time have no spread to show.
|
|
384
443
|
const total = formatElapsed(totalMs)
|
|
385
444
|
const said = new Set([total])
|
|
@@ -405,15 +464,34 @@ export function formatSummarySection(
|
|
|
405
464
|
// that is about the future.
|
|
406
465
|
if (stats.etaMs !== undefined)
|
|
407
466
|
timeParts.push(paint(ACCENT, `${formatEta(stats.etaMs)} left`, colors))
|
|
467
|
+
else if (stats.overMs !== undefined)
|
|
468
|
+
timeParts.push(paint(ACCENT, `${formatOver(stats.overMs)} over`, colors))
|
|
408
469
|
for (const p of spreadParts) timeParts.push(dim(p))
|
|
409
470
|
const timeRow = row('time', join(timeParts))
|
|
410
471
|
// Run-shape footer (final summary only): worker pool + cache mode,
|
|
411
472
|
// grouped with time under a blank line below the meters.
|
|
412
473
|
if (context !== undefined) {
|
|
474
|
+
const own = context.executorRows?.() ?? []
|
|
475
|
+
if (own.length > 0)
|
|
476
|
+
lines.push('', ...own.map(([label, text]) => row(label, ui.inline(text, colors))))
|
|
413
477
|
const info: string[] = []
|
|
414
|
-
|
|
478
|
+
// An executor that says where tasks ran says it better than the pool.
|
|
479
|
+
if (context.concurrency !== undefined && own.length === 0)
|
|
415
480
|
info.push(`${context.concurrency} worker${context.concurrency === 1 ? '' : 's'}`)
|
|
416
|
-
|
|
481
|
+
const layers = context.cache
|
|
482
|
+
info.push(
|
|
483
|
+
layers === undefined
|
|
484
|
+
? context.remoteCacheEnabled
|
|
485
|
+
? 'local + remote cache'
|
|
486
|
+
: 'local cache'
|
|
487
|
+
: layers.local && layers.remote
|
|
488
|
+
? 'local + remote cache'
|
|
489
|
+
: layers.local
|
|
490
|
+
? 'local cache'
|
|
491
|
+
: layers.remote
|
|
492
|
+
? 'remote cache'
|
|
493
|
+
: 'no cache',
|
|
494
|
+
)
|
|
417
495
|
if (context.cacheScope !== undefined) info.push(`scope ${context.cacheScope}`)
|
|
418
496
|
// The plugin's hand on the run: a task a policy held with a worker
|
|
419
497
|
// free would otherwise have started; without the line nothing says
|
|
@@ -426,13 +504,33 @@ export function formatSummarySection(
|
|
|
426
504
|
)
|
|
427
505
|
}
|
|
428
506
|
if (context.runId !== undefined) info.push(`run ${context.runId}`)
|
|
429
|
-
lines.push('',
|
|
507
|
+
lines.push('', ...warnRows(context.warnings?.() ?? [], row, colors))
|
|
508
|
+
lines.push(row('info', join(info)), timeRow)
|
|
430
509
|
} else {
|
|
431
510
|
lines.push('', timeRow)
|
|
432
511
|
}
|
|
433
512
|
return lines
|
|
434
513
|
}
|
|
435
514
|
|
|
515
|
+
/**
|
|
516
|
+
* A long run scrolls its warnings (a remote cache's 403, a plugin's
|
|
517
|
+
* failed flush) out of sight, and the footer read as a clean run
|
|
518
|
+
* (2026-10-27). The footer counts the distinct ones; it does not repeat
|
|
519
|
+
* them, so a warning stays one line in a log (a refused token costs
|
|
520
|
+
* ONE line, tests/remote-cache-degrade.test.ts in @vzn/vx-migrate).
|
|
521
|
+
*/
|
|
522
|
+
function warnRows(
|
|
523
|
+
warnings: readonly string[],
|
|
524
|
+
row: (label: string, value: string) => string,
|
|
525
|
+
colors: ColorSupport,
|
|
526
|
+
): string[] {
|
|
527
|
+
const n = new Set(
|
|
528
|
+
warnings.map((w) => Bun.stripANSI(w).split('\n')[0]!.trim()).filter((w) => w !== ''),
|
|
529
|
+
).size
|
|
530
|
+
if (n === 0) return []
|
|
531
|
+
return [row('warn', paint(WARN, `${n} ${n === 1 ? 'warning' : 'warnings'} above`, colors))]
|
|
532
|
+
}
|
|
533
|
+
|
|
436
534
|
/**
|
|
437
535
|
* A forecast, said as coarsely as it is known: `~4s`, `~2m 05s`, `<1s`
|
|
438
536
|
* once the history says the end is due.
|
|
@@ -444,16 +542,27 @@ function formatEta(ms: number): string {
|
|
|
444
542
|
return `~${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
|
445
543
|
}
|
|
446
544
|
|
|
545
|
+
function formatOver(ms: number): string {
|
|
546
|
+
const s = Math.max(1, Math.ceil(ms / 1000))
|
|
547
|
+
return s < 60 ? `+${s}s` : `+${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
|
548
|
+
}
|
|
549
|
+
|
|
447
550
|
/**
|
|
448
551
|
* The `--verbosity 1` per-task table (`RunOptions.summaryTable`). Groups are
|
|
449
|
-
* left out as the footer
|
|
552
|
+
* left out as the footer leaves them: a group ran no command. Painted in the
|
|
553
|
+
* list's vocabulary: header bold violet, ids in their hues, status in its
|
|
554
|
+
* state colour, rule and durations dim.
|
|
450
555
|
*/
|
|
451
|
-
export function formatOutcomeTable(
|
|
556
|
+
export function formatOutcomeTable(
|
|
557
|
+
outcomes: readonly TaskOutcome[],
|
|
558
|
+
colors: ColorSupport = NO_COLOR,
|
|
559
|
+
): string[] {
|
|
452
560
|
const rows = outcomes
|
|
453
561
|
.filter((o) => !isGroupTask(o.node))
|
|
454
562
|
.map((o) => {
|
|
455
563
|
const v = projectOutcome(o)
|
|
456
|
-
|
|
564
|
+
const status = neverStarted(o) ? 'not run' : outcomeLabel(v)
|
|
565
|
+
return { o, task: v.taskId, status, duration: `${v.durationMs}ms` }
|
|
457
566
|
})
|
|
458
567
|
if (rows.length === 0) return []
|
|
459
568
|
const widths = {
|
|
@@ -461,17 +570,43 @@ export function formatOutcomeTable(outcomes: readonly TaskOutcome[]): string[] {
|
|
|
461
570
|
status: Math.max(6, ...rows.map((r) => r.status.length)),
|
|
462
571
|
duration: Math.max(8, ...rows.map((r) => r.duration.length)),
|
|
463
572
|
}
|
|
464
|
-
const
|
|
465
|
-
|
|
466
|
-
const
|
|
573
|
+
const pad = (text: string, width: number) => ' '.repeat(Math.max(0, width - text.length))
|
|
574
|
+
const header = `${'TASK'.padEnd(widths.task)} ${'STATUS'.padEnd(widths.status)} ${'DURATION'.padStart(widths.duration)}`
|
|
575
|
+
const dim = (t: string) => paint('', t, colors, { dim: true })
|
|
467
576
|
return [
|
|
468
577
|
'',
|
|
469
|
-
header,
|
|
470
|
-
'-'.repeat(header.length),
|
|
471
|
-
...rows.map((r) =>
|
|
578
|
+
paint(TABLE_HEADER, header, colors, { bold: true }),
|
|
579
|
+
dim('-'.repeat(header.length)),
|
|
580
|
+
...rows.map((r) => {
|
|
581
|
+
const { node } = r.o
|
|
582
|
+
const id =
|
|
583
|
+
node.projectName !== undefined && node.taskName !== undefined
|
|
584
|
+
? paint(identityColor(node.projectName), node.projectName, colors) +
|
|
585
|
+
dim('#') +
|
|
586
|
+
paint(identityColor(node.taskName), node.taskName, colors)
|
|
587
|
+
: r.task
|
|
588
|
+
const status = paint(tableStatusColor(r.o), r.status, colors)
|
|
589
|
+
return `${id}${pad(r.task, widths.task)} ${status}${pad(r.status, widths.status)} ${pad(r.duration, widths.duration)}${dim(r.duration)}`
|
|
590
|
+
}),
|
|
472
591
|
]
|
|
473
592
|
}
|
|
474
593
|
|
|
594
|
+
/** violet-400, as the task list's header. */
|
|
595
|
+
const TABLE_HEADER = '#a78bfa'
|
|
596
|
+
|
|
597
|
+
function tableStatusColor(o: TaskOutcome): string {
|
|
598
|
+
switch (o.status) {
|
|
599
|
+
case 'failed':
|
|
600
|
+
return ERROR
|
|
601
|
+
case 'skipped':
|
|
602
|
+
return WARN
|
|
603
|
+
case 'aborted':
|
|
604
|
+
return neverStarted(o) ? NOT_RUN : ABORTED
|
|
605
|
+
default:
|
|
606
|
+
return SUCCESS
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
475
610
|
// Local byte formatter — the orchestrator can't import cli/format.ts
|
|
476
611
|
// (module boundary), and the footer only needs one compact form.
|
|
477
612
|
/** Ran here with a `cache` block, succeeded, and its result was not saved. */
|
|
@@ -544,13 +679,14 @@ export function formatRunSummary(
|
|
|
544
679
|
}
|
|
545
680
|
|
|
546
681
|
/** Each finished task's cache bucket by its key and by its id. */
|
|
547
|
-
function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string,
|
|
548
|
-
const out = new Map<string,
|
|
682
|
+
function bucketsOf(outcomes: readonly TaskOutcome[]): Map<string, TaskBucket> {
|
|
683
|
+
const out = new Map<string, TaskBucket>()
|
|
549
684
|
for (const o of outcomes) {
|
|
550
685
|
const bucket = cacheBucketOf(o)
|
|
551
686
|
if (bucket === undefined) continue
|
|
552
|
-
|
|
553
|
-
out.set(o.
|
|
687
|
+
const entry = { task: o.node.id, bucket }
|
|
688
|
+
if (o.hash !== undefined) out.set(o.hash, entry)
|
|
689
|
+
out.set(o.node.id, entry)
|
|
554
690
|
}
|
|
555
691
|
return out
|
|
556
692
|
}
|
|
@@ -12,6 +12,7 @@ export interface TerminalSignals {
|
|
|
12
12
|
export function terminalSignals(
|
|
13
13
|
env: Readonly<Record<string, string | undefined>>,
|
|
14
14
|
): TerminalSignals {
|
|
15
|
+
if (inMultiplexer(env)) return { progress: false, notify: false }
|
|
15
16
|
const program = env['TERM_PROGRAM']
|
|
16
17
|
if (program === 'ghostty') return { progress: true, notify: true }
|
|
17
18
|
// iTerm2 posts OSC 9 since 3.0 and draws OSC 9;4 since 3.6.
|
|
@@ -24,6 +25,17 @@ export function terminalSignals(
|
|
|
24
25
|
return { progress: false, notify: false }
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
/** Inside tmux or screen, which keep the outer terminal's variables (`WT_SESSION`, its `TERM_PROGRAM`). */
|
|
29
|
+
function inMultiplexer(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
30
|
+
const program = env['TERM_PROGRAM']
|
|
31
|
+
return (
|
|
32
|
+
env['TMUX'] !== undefined ||
|
|
33
|
+
env['STY'] !== undefined ||
|
|
34
|
+
program === 'tmux' ||
|
|
35
|
+
program === 'screen'
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
27
39
|
function atLeast(version: string | undefined, major: number, minor: number): boolean {
|
|
28
40
|
const m = /^(\d+)\.(\d+)/.exec(version ?? '')
|
|
29
41
|
if (m === null) return false
|
|
@@ -50,9 +62,8 @@ export function notifyEscape(text: string): string {
|
|
|
50
62
|
* tmux and screen pass it through only when configured to.
|
|
51
63
|
*/
|
|
52
64
|
export function terminalLinks(env: Readonly<Record<string, string | undefined>>): boolean {
|
|
53
|
-
if (env
|
|
65
|
+
if (inMultiplexer(env)) return false
|
|
54
66
|
const program = env['TERM_PROGRAM']
|
|
55
|
-
if (program === 'tmux' || program === 'screen') return false
|
|
56
67
|
if (program === 'ghostty' || program === 'WezTerm' || program === 'vscode') return true
|
|
57
68
|
if (program === 'iTerm.app') return atLeast(env['TERM_PROGRAM_VERSION'], 3, 1)
|
|
58
69
|
if (env['WT_SESSION'] !== undefined || env['KITTY_WINDOW_ID'] !== undefined) return true
|
package/src/settings.ts
CHANGED
|
@@ -15,8 +15,7 @@ import {
|
|
|
15
15
|
export type SettingKind =
|
|
16
16
|
| { readonly type: 'int'; readonly min: number }
|
|
17
17
|
| { readonly type: 'concurrency' }
|
|
18
|
-
|
|
19
|
-
| { readonly type: 'duration'; readonly timer?: true; readonly zero?: true }
|
|
18
|
+
| { readonly type: 'duration'; readonly timer?: true }
|
|
20
19
|
// `off`: `false` is a value, 0 bytes (the thing the size bounds is off).
|
|
21
20
|
| { readonly type: 'size'; readonly off?: true }
|
|
22
21
|
| { readonly type: 'bool' }
|
|
@@ -48,7 +47,14 @@ export const SETTINGS: readonly Setting[] = [
|
|
|
48
47
|
path: 'baseBranch',
|
|
49
48
|
kind: { type: 'string' },
|
|
50
49
|
default: "origin/HEAD's branch, then main, then master",
|
|
51
|
-
doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`.',
|
|
50
|
+
doc: 'The default branch: a run on it has no cache scope and a bare `--affected` no base; elsewhere both use the merge base with `origin/<baseBranch>`. Named alone: `main`, not `origin/main`.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
path: 'affectedBase',
|
|
54
|
+
kind: { type: 'string' },
|
|
55
|
+
default: "a plugin's base commit, else the merge base with `baseBranch`",
|
|
56
|
+
doc: "The commit a bare `--affected` compares with, on the base branch too. `--affected=<base>` beats it; it beats a plugin's base commit. Env only: a committed base would be stale on the next push.",
|
|
57
|
+
config: false,
|
|
52
58
|
},
|
|
53
59
|
{
|
|
54
60
|
path: 'run.concurrency',
|
|
@@ -316,7 +322,7 @@ export function parseSettingValue(kind: SettingKind, raw: unknown): unknown {
|
|
|
316
322
|
return parseConcurrency(typeof raw === 'number' ? String(raw) : raw)
|
|
317
323
|
case 'duration': {
|
|
318
324
|
const n = typeof raw === 'string' ? parseDuration(raw) : null
|
|
319
|
-
if (n === null ||
|
|
325
|
+
if (n === null || n === 0) return null
|
|
320
326
|
return kind.timer === true && n > MAX_TIMEOUT_MS ? null : n
|
|
321
327
|
}
|
|
322
328
|
case 'size':
|
|
@@ -352,10 +358,12 @@ export function settingRefusal(
|
|
|
352
358
|
: typeof raw === 'string' && /^\d+$/.test(raw)
|
|
353
359
|
? Number(raw)
|
|
354
360
|
: null
|
|
355
|
-
|
|
361
|
+
const bare = n !== null && Number.isSafeInteger(n) && n > 0
|
|
362
|
+
// A bare number past the ceiling is past it in either unit it could mean.
|
|
363
|
+
const ms = bare ? n : typeof raw === 'string' ? parseDuration(raw) : null
|
|
364
|
+
if (bare && !(kind.timer === true && n > MAX_TIMEOUT_MS)) {
|
|
356
365
|
return `${where} takes a duration with its unit, like '${n}ms' or '${Math.max(1, Math.round(n / 1000))}s' (got ${got})`
|
|
357
366
|
}
|
|
358
|
-
const ms = typeof raw === 'string' ? parseDuration(raw) : null
|
|
359
367
|
if (kind.timer === true && ms !== null && ms > MAX_TIMEOUT_MS) {
|
|
360
368
|
return (
|
|
361
369
|
`${where} of ${got} exceeds the maximum timer delay (${MAX_TIMEOUT_MS} ms, ~24.8 days): ` +
|
package/src/util/bun-version.ts
CHANGED
|
@@ -20,8 +20,11 @@
|
|
|
20
20
|
* refusing would be heavier than the measurement supports. What the
|
|
21
21
|
* measurement supports is that a green exit can hide a truncated answer, and
|
|
22
22
|
* that is what the warning says.
|
|
23
|
+
*
|
|
24
|
+
* 1.4.4: vx expands a brace range (`{1..3}`) as `Bun.Glob` does from 1.4.4
|
|
25
|
+
* on; an older `Bun.Glob` reads it as text, so a scan and a match disagree.
|
|
23
26
|
*/
|
|
24
|
-
export const MIN_BUN = [1, 4,
|
|
27
|
+
export const MIN_BUN = [1, 4, 4] as const
|
|
25
28
|
|
|
26
29
|
/** True when `version` is older than {@link MIN_BUN}. */
|
|
27
30
|
export function isUnsupportedBun(version: string): boolean {
|
package/src/util/colors.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface PaintOptions {
|
|
|
34
34
|
* NO_COLOR non-empty → off (overrides FORCE_COLOR)
|
|
35
35
|
* FORCE_COLOR=0 or =false → off, even on a TTY
|
|
36
36
|
* FORCE_COLOR any other value → on (the empty string too)
|
|
37
|
+
* a CI whose log renders ANSI (`ciPaints`) → on
|
|
37
38
|
* else: on iff stdout is a TTY that is not `TERM=dumb`.
|
|
38
39
|
* `0` and `false` are the convention's "off" (supports-color, chalk,
|
|
39
40
|
* Node); reading every non-empty value as on painted a piped CI log that
|
|
@@ -45,7 +46,7 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
|
|
|
45
46
|
? false
|
|
46
47
|
: force !== undefined
|
|
47
48
|
? force !== '0' && force !== 'false'
|
|
48
|
-
: stream.isTTY === true && !dumbTerminal()
|
|
49
|
+
: ciPaints() || (stream.isTTY === true && !dumbTerminal())
|
|
49
50
|
return {
|
|
50
51
|
enabled,
|
|
51
52
|
get columns() {
|
|
@@ -54,6 +55,20 @@ export function detectColors(stream: NodeJS.WriteStream = process.stdout): Color
|
|
|
54
55
|
}
|
|
55
56
|
}
|
|
56
57
|
|
|
58
|
+
/**
|
|
59
|
+
* A CI whose log viewer renders ANSI colour though the job's output is a
|
|
60
|
+
* pipe (supports-color reads the same variables): its log was plain.
|
|
61
|
+
*/
|
|
62
|
+
function ciPaints(): boolean {
|
|
63
|
+
const env = process.env
|
|
64
|
+
return (
|
|
65
|
+
env['GITHUB_ACTIONS'] === 'true' ||
|
|
66
|
+
env['GITLAB_CI'] === 'true' ||
|
|
67
|
+
env['BUILDKITE'] === 'true' ||
|
|
68
|
+
env['CIRCLECI'] === 'true'
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
|
|
57
72
|
/**
|
|
58
73
|
* `TERM=dumb` (Emacs `M-x shell`, some CI log viewers) can neither paint
|
|
59
74
|
* nor move the cursor: vx prints to it as off a terminal.
|
|
@@ -81,7 +96,9 @@ export function paint(
|
|
|
81
96
|
colors: ColorSupport,
|
|
82
97
|
opts: PaintOptions = {},
|
|
83
98
|
): string {
|
|
84
|
-
|
|
99
|
+
// Empty text paints nothing: an escape pair around no text is bytes and no
|
|
100
|
+
// mark (a full bar's empty remainder).
|
|
101
|
+
if (!colors.enabled || text === '') return text
|
|
85
102
|
let prefix = ''
|
|
86
103
|
if (opts.bold) prefix += BOLD
|
|
87
104
|
if (opts.dim) prefix += DIM
|
|
@@ -92,7 +109,7 @@ export function paint(
|
|
|
92
109
|
|
|
93
110
|
/**
|
|
94
111
|
* Identity hues for the two halves of a `project#task` id, never a status
|
|
95
|
-
* colour: one name, one hue, in every run, list and row (
|
|
112
|
+
* colour: one name, one hue, in every run, list and row (2026-10-15:
|
|
96
113
|
* every `#build` matches every other `#build`).
|
|
97
114
|
*/
|
|
98
115
|
const IDENTITY_PALETTE = [
|
package/src/util/config-frame.ts
CHANGED
|
@@ -18,7 +18,7 @@ const HEAD = /^(\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s)(?:: (task
|
|
|
18
18
|
*/
|
|
19
19
|
export function configErrorFrame(message: string, cwd: string): string | undefined {
|
|
20
20
|
const head = HEAD.exec(message)
|
|
21
|
-
if (head === null) return
|
|
21
|
+
if (head === null) return positionFrame(message, cwd)
|
|
22
22
|
const [, file, fieldPath, colon] = head as unknown as [string, string, string?, string?]
|
|
23
23
|
const rest = message.slice(head[0].length)
|
|
24
24
|
// The field the refusal is about, past the path that leads to it:
|
|
@@ -35,6 +35,37 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
|
|
|
35
35
|
}
|
|
36
36
|
const at = locate(source, segments)
|
|
37
37
|
if (at === undefined) return undefined
|
|
38
|
+
return framed(file, source, at, message.slice(file.length), cwd)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// The loader's `Project config <file>:<line>:<col>: …`: a syntax error,
|
|
42
|
+
// placed by Bun, in the config itself.
|
|
43
|
+
const POSITION =
|
|
44
|
+
/^(?:Project|Workspace) config (\/[^\n]*?\/(?:vx\.config|\.vx\/workspace)\.[cm]?[jt]s):(\d+):(\d+)(: [^]*)$/
|
|
45
|
+
|
|
46
|
+
function positionFrame(message: string, cwd: string): string | undefined {
|
|
47
|
+
const pos = POSITION.exec(message)
|
|
48
|
+
if (pos === null) return undefined
|
|
49
|
+
const [, file, line, col, rest] = pos as unknown as [string, string, string, string, string]
|
|
50
|
+
let source: string
|
|
51
|
+
try {
|
|
52
|
+
source = readFileSync(file, 'utf8')
|
|
53
|
+
} catch {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
const at = { line: Number(line), col: Math.max(1, Number(col)) }
|
|
57
|
+
if (at.line < 1 || at.line > source.split('\n').length) return undefined
|
|
58
|
+
return framed(file, source, at, rest, cwd)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `<file>:<line>:<col><rest>` relative to `cwd`, over the source lines up to `at`. */
|
|
62
|
+
function framed(
|
|
63
|
+
file: string,
|
|
64
|
+
source: string,
|
|
65
|
+
at: { line: number; col: number },
|
|
66
|
+
rest: string,
|
|
67
|
+
cwd: string,
|
|
68
|
+
): string {
|
|
38
69
|
const lines = source.split('\n')
|
|
39
70
|
// Relative to where the user stands, unless that means climbing out.
|
|
40
71
|
const rel = path.relative(cwd, file)
|
|
@@ -50,7 +81,7 @@ export function configErrorFrame(message: string, cwd: string): string | undefin
|
|
|
50
81
|
.replace(/\t/g, ' ')
|
|
51
82
|
.replace(/\S/g, ' ')
|
|
52
83
|
frame.push(` ${' '.repeat(width)} | ${lead}^`)
|
|
53
|
-
return `${shown}:${at.line}:${at.col}${
|
|
84
|
+
return `${shown}:${at.line}:${at.col}${rest}\n\n${frame.join('\n')}`
|
|
54
85
|
}
|
|
55
86
|
|
|
56
87
|
/**
|
|
@@ -106,14 +137,20 @@ export function configRefusal(message: string, cwd: string): ConfigRefusal | und
|
|
|
106
137
|
const cut = framed.indexOf('\n\n')
|
|
107
138
|
const line = framed.slice(0, cut)
|
|
108
139
|
const frame = framed.slice(cut + 2)
|
|
109
|
-
|
|
140
|
+
// A path may hold spaces (`packages/my app/`); the first `:line:col` ends it.
|
|
141
|
+
const at = /^([^\n]+?:\d+:\d+):? (.*)$/s.exec(line)
|
|
110
142
|
if (at === null) return { where: line, problem: '', frame }
|
|
111
|
-
|
|
143
|
+
// After ` — `: the field meant, or how vx spells another runner's field.
|
|
144
|
+
const said =
|
|
145
|
+
/^(.*?)(?: \(allowed: ([^)]*)\))?(?: — (?:did you mean ([^?]+)\?|vx spells it (.+)))?$/s.exec(
|
|
146
|
+
at[2]!,
|
|
147
|
+
)!
|
|
112
148
|
return {
|
|
113
149
|
where: at[1]!,
|
|
114
150
|
problem: said[1]!.replace(/^has /, ''),
|
|
115
151
|
...(said[2] !== undefined ? { allowed: said[2] } : {}),
|
|
116
152
|
...(said[3] !== undefined ? { fix: `use ${said[3]}` } : {}),
|
|
153
|
+
...(said[4] !== undefined ? { fix: `use ${said[4]}` } : {}),
|
|
117
154
|
frame,
|
|
118
155
|
}
|
|
119
156
|
}
|