@vzn/vx 0.0.651 → 0.0.653
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 +3 -4
- package/launcher.sh +2 -1
- package/package.json +5 -5
- package/schemas/error.json +1 -1
- package/schemas/show.json +15 -20
- package/skills/vx/SKILL.md +3 -3
- package/src/bin.ts +37 -3
- package/src/cache/archive.ts +197 -32
- package/src/cache/cache.ts +882 -230
- package/src/cache/checkpointer.ts +102 -0
- package/src/cache/file-hashes.ts +2 -1
- package/src/cache/fold-memo.ts +24 -4
- package/src/cache/fs-clock.ts +26 -0
- package/src/cache/git-inputs.ts +188 -22
- package/src/cache/history-stream.ts +151 -0
- package/src/cache/history-writer.ts +76 -0
- package/src/cache/index.ts +12 -1
- package/src/cache/inputs.ts +616 -135
- package/src/cache/key-fold.ts +1 -1
- package/src/cache/layer.ts +29 -16
- package/src/cache/layered-cache.ts +28 -7
- package/src/cache/lockfile-memo.ts +2 -1
- package/src/cache/output-index.ts +106 -44
- package/src/cache/plugin-cache.ts +2 -2
- package/src/cache/policy.ts +6 -2
- package/src/cache/remote-lane.ts +3 -1
- package/src/cache/run-history.ts +94 -25
- package/src/cache/schema.ts +46 -10
- package/src/cli/cache.ts +149 -36
- package/src/cli/completions.ts +2 -2
- package/src/cli/core-alias.ts +9 -2
- package/src/cli/docs.ts +4 -2
- package/src/cli/help.ts +27 -4
- package/src/cli/index.ts +62 -7
- package/src/cli/info.ts +5 -4
- package/src/cli/init.ts +55 -20
- package/src/cli/last.ts +218 -43
- package/src/cli/lock.ts +187 -24
- package/src/cli/plan-format.ts +24 -21
- package/src/cli/plugin-commands.ts +6 -1
- package/src/cli/plugin-templates.ts +36 -45
- package/src/cli/run.ts +68 -34
- package/src/cli/select.ts +160 -64
- package/src/cli/show.ts +70 -28
- package/src/cli/task-verb.ts +12 -7
- package/src/cli/upgrade.ts +210 -76
- package/src/cli/watch-filter.ts +88 -15
- package/src/cli/watch-fs.ts +34 -47
- package/src/cli/watch-judge.ts +73 -9
- package/src/cli/watch-set.ts +10 -8
- package/src/cli/watch.ts +190 -33
- package/src/cli/why.ts +107 -33
- package/src/cli/workspace-config.ts +11 -1
- package/src/config.ts +56 -30
- package/src/exec/env.ts +3 -1
- package/src/exec/executor.ts +116 -16
- package/src/exec/index.ts +1 -0
- package/src/exec/kill-tree.ts +7 -7
- package/src/exec/runner.ts +270 -108
- package/src/exec/sandbox-binds.ts +109 -18
- package/src/exec/sandbox-paths.ts +3 -1
- package/src/exec/sandbox-runtime.ts +446 -64
- package/src/exec/sandbox-violations.ts +250 -46
- package/src/exec/spawn-off.ts +10 -5
- package/src/exec/task-proxy.ts +93 -0
- package/src/exec/warm-shell.ts +29 -7
- package/src/graph/definition.ts +16 -3
- package/src/graph/folds.ts +89 -16
- package/src/graph/index.ts +8 -0
- package/src/graph/priorities.ts +8 -5
- package/src/graph/scheduler.ts +176 -39
- package/src/graph/stand-ins.ts +36 -0
- package/src/graph/task-graph.ts +347 -160
- package/src/graph/task-status.ts +5 -1
- package/src/handoff.ts +45 -10
- package/src/history/failure-mode.ts +39 -6
- package/src/history/history.ts +103 -30
- package/src/history/index.ts +2 -0
- package/src/history/metrics.ts +193 -73
- package/src/index.ts +6 -3
- package/src/orchestrator/admission.ts +14 -10
- package/src/orchestrator/affected-tasks.ts +35 -13
- package/src/orchestrator/command-windows.ts +8 -5
- package/src/orchestrator/deferred-outputs.ts +42 -13
- package/src/orchestrator/doctor.ts +21 -9
- package/src/orchestrator/download-policy.ts +8 -10
- package/src/orchestrator/excluded-keys.ts +15 -3
- package/src/orchestrator/execute-args.ts +9 -2
- package/src/orchestrator/execute-task.ts +612 -138
- package/src/orchestrator/graph-memo.ts +26 -4
- package/src/orchestrator/graph-stage.ts +27 -4
- package/src/orchestrator/held-servers.ts +36 -14
- package/src/orchestrator/hit-restore.ts +141 -93
- package/src/orchestrator/index.ts +4 -1
- package/src/orchestrator/keyed-projects.ts +53 -0
- package/src/orchestrator/local-floor.ts +21 -3
- package/src/orchestrator/local-shortcircuit.ts +2 -2
- package/src/orchestrator/lockfile-claim.ts +30 -4
- package/src/orchestrator/miss-save.ts +60 -61
- package/src/orchestrator/options.ts +13 -0
- package/src/orchestrator/output-host.ts +16 -4
- package/src/orchestrator/output-log.ts +1 -54
- package/src/orchestrator/persistent-task.ts +32 -16
- package/src/orchestrator/persistent.ts +152 -15
- package/src/orchestrator/placement.ts +82 -16
- package/src/orchestrator/plan.ts +39 -17
- package/src/orchestrator/plugin-host.ts +238 -110
- package/src/orchestrator/plugin.ts +59 -27
- package/src/orchestrator/prepare.ts +194 -38
- package/src/orchestrator/projects.ts +89 -10
- package/src/orchestrator/read-only.ts +115 -0
- package/src/orchestrator/remote-prefetch.ts +1 -1
- package/src/orchestrator/run-context.ts +6 -2
- package/src/orchestrator/run-failures.ts +101 -23
- package/src/orchestrator/run-id.ts +13 -2
- package/src/orchestrator/run-keys.ts +87 -28
- package/src/orchestrator/run-live.ts +16 -13
- package/src/orchestrator/run-lock.ts +27 -4
- package/src/orchestrator/run-records.ts +95 -74
- package/src/orchestrator/run-report.ts +3 -2
- package/src/orchestrator/run-stop.ts +17 -9
- package/src/orchestrator/run.ts +531 -122
- package/src/orchestrator/sandbox-fix.ts +40 -12
- package/src/orchestrator/sandbox-request.ts +314 -44
- package/src/orchestrator/save-lane.ts +24 -13
- package/src/orchestrator/shell-verdict.ts +29 -1
- package/src/orchestrator/signals.ts +103 -8
- package/src/orchestrator/stable-keys.ts +34 -27
- package/src/orchestrator/task-artifacts.ts +16 -3
- package/src/orchestrator/task-hash.ts +191 -34
- package/src/orchestrator/telemetry-host.ts +25 -6
- package/src/orchestrator/telemetry.ts +85 -13
- package/src/orchestrator/upstream.ts +3 -1
- package/src/report/events.ts +23 -6
- package/src/report/forecast.ts +103 -37
- package/src/report/framed-output.ts +179 -162
- package/src/report/index.ts +2 -1
- package/src/report/logger.ts +108 -31
- package/src/report/plain-output.ts +15 -9
- package/src/report/status-line.ts +28 -55
- package/src/report/summary.ts +61 -18
- package/src/settings.ts +50 -14
- package/src/ui/components.ts +36 -42
- package/src/ui/index.ts +3 -1
- package/src/ui/live-region.ts +67 -8
- package/src/ui/terminal.ts +12 -0
- package/src/ui/theme.ts +16 -1
- package/src/util/config-frame.ts +142 -6
- package/src/util/edit-distance.ts +17 -1
- package/src/util/errors.ts +23 -7
- package/src/util/foreign-cache-dir.ts +60 -0
- package/src/util/git-ignored.ts +59 -5
- package/src/util/index.ts +15 -1
- package/src/util/paths.ts +51 -7
- package/src/util/plain-text.ts +19 -0
- package/src/util/secret-mask.ts +57 -1
- package/src/util/settle.ts +18 -1
- package/src/util/size.ts +12 -2
- package/src/util/tail.ts +4 -1
- package/src/util/write-file.ts +86 -9
- package/src/workspace/affected.ts +225 -27
- package/src/workspace/bracket-literal.ts +2 -2
- package/src/workspace/config-bytecode.ts +23 -4
- package/src/workspace/config-eval.ts +90 -16
- package/src/workspace/config-imports.ts +96 -41
- package/src/workspace/config-schema.ts +192 -86
- package/src/workspace/dependencies-lock.ts +7 -14
- package/src/workspace/filter.ts +18 -2
- package/src/workspace/index.ts +3 -1
- package/src/workspace/json-data.ts +34 -8
- package/src/workspace/lockfile.ts +86 -37
- package/src/workspace/migration.ts +58 -31
- package/src/workspace/module-state.ts +62 -0
- package/src/workspace/nested-dirs.ts +8 -0
- package/src/workspace/project-loader.ts +276 -32
- package/src/workspace/repo-id.ts +29 -6
- package/src/workspace/workspace.ts +190 -30
package/src/cli/lock.ts
CHANGED
|
@@ -3,21 +3,29 @@
|
|
|
3
3
|
// `vx lock --check` — audit the lock against a fresh evaluation in the
|
|
4
4
|
// current environment. Design: docs/design/config-lock-2026-06.md.
|
|
5
5
|
|
|
6
|
+
import { readdirSync } from 'node:fs'
|
|
6
7
|
import { open } from 'node:fs/promises'
|
|
7
8
|
import path from 'node:path'
|
|
8
9
|
import type { ProjectConfig } from '../config.js'
|
|
9
10
|
import { flagHint, formatValue, refuse, refusedWord, seeHelp } from './help.js'
|
|
10
|
-
import {
|
|
11
|
+
import {
|
|
12
|
+
buildTaskGraph,
|
|
13
|
+
expandRequested,
|
|
14
|
+
isDefaultBuild,
|
|
15
|
+
refuseFiltersThroughGroups,
|
|
16
|
+
} from '../graph/index.js'
|
|
11
17
|
import {
|
|
12
18
|
applyGraphStage,
|
|
13
19
|
claimedDigests,
|
|
14
20
|
discoverProjects,
|
|
15
21
|
fingerprintClaims,
|
|
16
22
|
hasHook,
|
|
23
|
+
ignoredConfigRefusal,
|
|
17
24
|
loadProjects,
|
|
18
25
|
} from '../orchestrator/index.js'
|
|
19
26
|
import { resolveSetting, runRules } from '../settings.js'
|
|
20
27
|
import {
|
|
28
|
+
configErrorFrame,
|
|
21
29
|
DOT_VX_GITATTRIBUTES,
|
|
22
30
|
fsRefusalHint,
|
|
23
31
|
isFsRefusal,
|
|
@@ -26,10 +34,12 @@ import {
|
|
|
26
34
|
maskedLine,
|
|
27
35
|
relPosix,
|
|
28
36
|
type RefusalParts,
|
|
29
|
-
|
|
37
|
+
UserError,
|
|
30
38
|
secretMask,
|
|
39
|
+
secretNamed,
|
|
31
40
|
type SecretMask,
|
|
32
41
|
refuseSymlink,
|
|
42
|
+
writeAllOrNone,
|
|
33
43
|
writeNoFollow,
|
|
34
44
|
} from '../util/index.js'
|
|
35
45
|
import * as ui from '../ui/index.js'
|
|
@@ -39,6 +49,7 @@ import {
|
|
|
39
49
|
findProjectConfigs,
|
|
40
50
|
findWorkspaceRoot,
|
|
41
51
|
REVIEW_FILE,
|
|
52
|
+
PROJECT_CONFIG_FILENAMES,
|
|
42
53
|
loadProjectConfigs,
|
|
43
54
|
LOCKFILE_NAME,
|
|
44
55
|
LOCKFILE_VERSION,
|
|
@@ -48,11 +59,11 @@ import {
|
|
|
48
59
|
checkBracketLiterals,
|
|
49
60
|
checkDependencyRules,
|
|
50
61
|
computeNestedProjectDirs,
|
|
51
|
-
|
|
62
|
+
lockfileWrite,
|
|
52
63
|
DEPENDENCIES_LOCK_NAME,
|
|
53
64
|
dependenciesLock,
|
|
54
65
|
parseDependenciesLock,
|
|
55
|
-
|
|
66
|
+
dependenciesLockWrite,
|
|
56
67
|
type DependenciesLock,
|
|
57
68
|
type Lockfile,
|
|
58
69
|
type ProjectMeta,
|
|
@@ -86,6 +97,29 @@ export function parseLockArgs(args: readonly string[]): LockArgs {
|
|
|
86
97
|
|
|
87
98
|
type ConfiguredMeta = ProjectMeta & { configPath: string }
|
|
88
99
|
|
|
100
|
+
/**
|
|
101
|
+
* A `dependsOn` the graph refused (`a#dep: dependsOn a#nope …`), said as
|
|
102
|
+
* a schema refusal of that field, so it prints at the config's line with
|
|
103
|
+
* its frame as every other config error does. Undefined when the task's
|
|
104
|
+
* `dependsOn` is not in its file (a plugin's task, the default build).
|
|
105
|
+
*/
|
|
106
|
+
function placedDependsOn(
|
|
107
|
+
err: UserError,
|
|
108
|
+
all: readonly ProjectMeta[],
|
|
109
|
+
root: string,
|
|
110
|
+
): string | undefined {
|
|
111
|
+
if (err.code !== 'VX_E_DEPENDS_ON') return undefined
|
|
112
|
+
const m = /^(.+)#([^#\s]+): dependsOn (.+)$/s.exec(err.message)
|
|
113
|
+
if (m === null) return undefined
|
|
114
|
+
const configPath = all.find((p) => p.name === m[1])?.configPath
|
|
115
|
+
if (configPath === undefined || configPath === null) return undefined
|
|
116
|
+
// `does not accept …` reads on from the field; a named dep takes `names`.
|
|
117
|
+
// The frame turns a trailing ` — did you mean X?` into its fix line.
|
|
118
|
+
const said = /^(?:does not|patterns are)\b/.test(m[3]!) ? m[3] : `names ${m[3]}`
|
|
119
|
+
const placed = `${configPath}: tasks.${m[2]}.dependsOn ${said!.replace(/\. Did you mean (.+)\?$/s, ' — did you mean $1?')}`
|
|
120
|
+
return configErrorFrame(placed, root) === undefined ? undefined : placed
|
|
121
|
+
}
|
|
122
|
+
|
|
89
123
|
export async function lockCmd(args: readonly string[]): Promise<number> {
|
|
90
124
|
const parsed = parseLockArgs(args)
|
|
91
125
|
if (parsed.error) return refuse('lock', args, parsed.error, 'VX_E_USAGE')
|
|
@@ -156,11 +190,19 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
156
190
|
try {
|
|
157
191
|
const { workspaceConfig, plugins, cacheDir } = await loadCliWorkspace(root)
|
|
158
192
|
out.reviewFiles = resolveSetting('lock.reviewFiles', workspaceConfig).value !== false
|
|
159
|
-
const {
|
|
193
|
+
const {
|
|
194
|
+
configs: found,
|
|
195
|
+
strayReviews,
|
|
196
|
+
shadowed,
|
|
197
|
+
nestedWorkspaces,
|
|
198
|
+
} = await findProjectConfigs(root)
|
|
160
199
|
out.strayReviews = strayReviews
|
|
161
200
|
out.shadowed = shadowed
|
|
162
201
|
// The one load that evaluates: every reader reads the lock this writes.
|
|
163
|
-
const configs = await loadProjectConfigs(
|
|
202
|
+
const configs = await loadProjectConfigs(
|
|
203
|
+
found.map((f) => f.configPath),
|
|
204
|
+
root,
|
|
205
|
+
)
|
|
164
206
|
// In the lock's form: a field left `undefined` is a key the stored JSON
|
|
165
207
|
// never has. Entries, not assignment: a directory named `__proto__` is
|
|
166
208
|
// a key of its own (D-152).
|
|
@@ -173,10 +215,16 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
173
215
|
},
|
|
174
216
|
]),
|
|
175
217
|
)
|
|
218
|
+
if (nestedWorkspaces.length > 0)
|
|
219
|
+
out.fresh.nestedWorkspaces = nestedWorkspaces.map((dir) => lockKey(root, dir))
|
|
176
220
|
// The stages edit what they are handed; the lock keeps what the configs say.
|
|
177
221
|
const view = structuredClone(out.fresh)
|
|
178
222
|
const lock = async (): Promise<Lockfile> => view
|
|
179
223
|
out.all = await discoverProjects(root, lock, plugins, cacheDir, warn)
|
|
224
|
+
for (const m of out.all) {
|
|
225
|
+
const why = m.configPath === null ? ignoredConfigRefusal(root, m) : undefined
|
|
226
|
+
if (why !== undefined) throw new UserError(why, 'VX_E_LOCK')
|
|
227
|
+
}
|
|
180
228
|
const { projects } = await loadProjects({
|
|
181
229
|
workspaceRoot: root,
|
|
182
230
|
cacheDir,
|
|
@@ -216,11 +264,19 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
216
264
|
workspaceRoot: root,
|
|
217
265
|
rules,
|
|
218
266
|
nestedProjects: new Map(
|
|
219
|
-
[...computeNestedProjectDirs(out.all)].map(([name, dirs]) => [
|
|
267
|
+
[...computeNestedProjectDirs(out.all, nestedWorkspaces)].map(([name, dirs]) => [
|
|
220
268
|
name,
|
|
221
|
-
dirs.map((dir) =>
|
|
269
|
+
dirs.map((dir) => {
|
|
270
|
+
const project = nameOf.get(dir)
|
|
271
|
+
return project === undefined
|
|
272
|
+
? { name: relPosix(root, dir), dir, workspace: true }
|
|
273
|
+
: { name: project, dir }
|
|
274
|
+
}),
|
|
222
275
|
]),
|
|
223
276
|
),
|
|
277
|
+
projectConfigs: out.all.flatMap((m) =>
|
|
278
|
+
m.configPath === null ? [] : [{ name: m.name, configPath: m.configPath }],
|
|
279
|
+
),
|
|
224
280
|
})
|
|
225
281
|
if (hasHook(plugins, 'graph'))
|
|
226
282
|
await applyGraphStage(plugins, nodes, {
|
|
@@ -230,11 +286,14 @@ async function audit(root: string, warn: (message: string) => void): Promise<Gra
|
|
|
230
286
|
warn,
|
|
231
287
|
rules,
|
|
232
288
|
})
|
|
289
|
+
// A run refuses it after its `graph` stage; the lock, which holds the
|
|
290
|
+
// graph every run trusts, refuses it too.
|
|
291
|
+
refuseFiltersThroughGroups(nodes)
|
|
233
292
|
} catch (err) {
|
|
234
293
|
if (isUserError(err))
|
|
235
294
|
return {
|
|
236
295
|
...out,
|
|
237
|
-
broken: err.message,
|
|
296
|
+
broken: placedDependsOn(err, out.all, root) ?? err.message,
|
|
238
297
|
...(err.parts ? { brokenParts: err.parts } : {}),
|
|
239
298
|
...(err instanceof ConfigsRefused ? { alsoBroken: err.all.slice(1) } : {}),
|
|
240
299
|
}
|
|
@@ -424,11 +483,11 @@ async function writeLock(
|
|
|
424
483
|
}
|
|
425
484
|
// The copies first: one that cannot be written leaves the lock as it was,
|
|
426
485
|
// not a new lock beside stale copies and a stack.
|
|
427
|
-
for (const r of reviews) refuseSymlink(r.file)
|
|
486
|
+
for (const r of reviews) refuseSymlink(r.file, root)
|
|
428
487
|
const blocked = (
|
|
429
488
|
await Promise.all(
|
|
430
489
|
reviews.map((r) =>
|
|
431
|
-
writeNoFollow(r.file, r.text).then(
|
|
490
|
+
writeNoFollow(r.file, r.text, root).then(
|
|
432
491
|
() => null,
|
|
433
492
|
(err: unknown): { file: string; err: NodeJS.ErrnoException } => {
|
|
434
493
|
if (typeof (err as NodeJS.ErrnoException).code !== 'string' || isOutOfFds(err))
|
|
@@ -451,8 +510,13 @@ async function writeLock(
|
|
|
451
510
|
})
|
|
452
511
|
return 1
|
|
453
512
|
}
|
|
454
|
-
await
|
|
455
|
-
|
|
513
|
+
await writeAllOrNone(
|
|
514
|
+
[
|
|
515
|
+
lockfileWrite(root, check.fresh),
|
|
516
|
+
...(check.dependencies === null ? [] : [dependenciesLockWrite(root, check.dependencies)]),
|
|
517
|
+
],
|
|
518
|
+
root,
|
|
519
|
+
)
|
|
456
520
|
await writeIfMissing(path.join(root, '.vx', '.gitattributes'), DOT_VX_GITATTRIBUTES)
|
|
457
521
|
const n = metas.length
|
|
458
522
|
say(`${n} project config${n === 1 ? '' : 's'} → ${LOCKFILE_NAME}${bareNote(bare)}`)
|
|
@@ -464,6 +528,27 @@ async function writeLock(
|
|
|
464
528
|
return 0
|
|
465
529
|
}
|
|
466
530
|
|
|
531
|
+
/** The first places (three at most) two evaluated configs differ: `path: locked → now`. */
|
|
532
|
+
function configDiff(locked: unknown, now: unknown, at = '', out: string[] = []): string[] {
|
|
533
|
+
if (out.length >= 3 || Bun.deepEquals(locked, now, true)) return out
|
|
534
|
+
const isObj = (v: unknown): v is Record<string, unknown> => v !== null && typeof v === 'object'
|
|
535
|
+
if (isObj(locked) && isObj(now) && Array.isArray(locked) === Array.isArray(now)) {
|
|
536
|
+
for (const k of new Set([...Object.keys(locked), ...Object.keys(now)])) {
|
|
537
|
+
const next = Array.isArray(now) ? `${at}[${k}]` : at === '' ? k : `${at}.${k}`
|
|
538
|
+
configDiff(locked[k], now[k], next, out)
|
|
539
|
+
}
|
|
540
|
+
return out
|
|
541
|
+
}
|
|
542
|
+
out.push(`${at}: ${diffValue(locked)} → ${diffValue(now)}`)
|
|
543
|
+
return out
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function diffValue(v: unknown): string {
|
|
547
|
+
if (v === undefined) return '(none)'
|
|
548
|
+
const s = JSON.stringify(v)
|
|
549
|
+
return s.length > 60 ? `${s.slice(0, 59)}…` : s
|
|
550
|
+
}
|
|
551
|
+
|
|
467
552
|
/**
|
|
468
553
|
* Review copies with no config beside them, sorted. One left beside a
|
|
469
554
|
* config since deleted shows reviewers tasks nothing runs, and keys the
|
|
@@ -483,21 +568,45 @@ async function strayCopies(check: GraphCheck): Promise<string[]> {
|
|
|
483
568
|
return [...new Set([...kept.filter((f) => f !== null), ...check.strayReviews])].sort()
|
|
484
569
|
}
|
|
485
570
|
|
|
486
|
-
|
|
487
|
-
|
|
571
|
+
/**
|
|
572
|
+
* A copy's config renamed to a name vx does not read (`.json`, `.tsx`)
|
|
573
|
+
* is still there: "delete it" was the wrong advice. Looked for only here,
|
|
574
|
+
* once a copy is already stray.
|
|
575
|
+
*/
|
|
576
|
+
function strayLine(root: string, file: string): string {
|
|
577
|
+
const copy = `${relPosix(root, file)} has no vx.config beside it`
|
|
578
|
+
let names: string[] = []
|
|
579
|
+
try {
|
|
580
|
+
names = readdirSync(path.dirname(file))
|
|
581
|
+
} catch {
|
|
582
|
+
// gone or unreadable: nothing beside it to name
|
|
583
|
+
}
|
|
584
|
+
const unread = names
|
|
585
|
+
.filter((n) => n.startsWith('vx.config.') && !PROJECT_CONFIG_FILENAMES.includes(n))
|
|
586
|
+
.sort()[0]
|
|
587
|
+
if (unread === undefined) return `${copy} — delete it`
|
|
588
|
+
const exts = PROJECT_CONFIG_FILENAMES.map((n) => n.slice('vx.config.'.length)).join(', ')
|
|
589
|
+
return `${copy}: ${unread} is not a config vx reads (${exts})`
|
|
590
|
+
}
|
|
488
591
|
|
|
489
592
|
/**
|
|
490
593
|
* Each place an evaluated config holds the value of a secret in this
|
|
491
594
|
* environment, by the rule output masking uses (a secret-named variable,
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
* that every run
|
|
595
|
+
* one a task lists in `exec.env.secret`, or a define of such a name whose
|
|
596
|
+
* value this environment holds). The lock is committed, so such a value
|
|
597
|
+
* would be published, and masking it would freeze a `***` that every run
|
|
598
|
+
* runs (L-42).
|
|
495
599
|
*/
|
|
496
600
|
function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string; name: string }> {
|
|
497
601
|
const named = new Set<string>()
|
|
602
|
+
const defines: Array<[name: string, value: string]> = []
|
|
498
603
|
for (const config of Object.values(projects)) {
|
|
499
|
-
for (const task of Object.values(config.tasks ?? {}))
|
|
500
|
-
|
|
604
|
+
for (const task of Object.values(config.tasks ?? {})) {
|
|
605
|
+
const secret = task?.exec?.env?.secret ?? []
|
|
606
|
+
for (const n of secret) named.add(n)
|
|
607
|
+
for (const [n, v] of Object.entries(task?.exec?.env?.define ?? {}))
|
|
608
|
+
if (secretNamed(n) || secret.includes(n)) defines.push([n, v])
|
|
609
|
+
}
|
|
501
610
|
}
|
|
502
611
|
// Each variable's own mask: what the output masks of it, a URL's
|
|
503
612
|
// password and a key's lines too, not only the whole value.
|
|
@@ -507,12 +616,26 @@ function secretsIn(projects: Record<string, ProjectConfig>): Array<{ at: string;
|
|
|
507
616
|
value === undefined ? null : secretMask([{ [name]: value }], named.has(name) ? [name] : [])
|
|
508
617
|
if (mask !== null) secrets.push([mask, name])
|
|
509
618
|
}
|
|
619
|
+
// The output masks a secret define's value by the define's name, so
|
|
620
|
+
// `API_TOKEN: process.env.MY_CREDS` printed masked and locked in clear.
|
|
621
|
+
// Only a value this environment holds counts: a literal define is the
|
|
622
|
+
// config's own committed text, and npm's `'${NPM_TOKEN}'` is no secret.
|
|
623
|
+
for (const [n, v] of defines) {
|
|
624
|
+
const mask = secretMask([{ [n]: v }], [n])
|
|
625
|
+
if (mask === null) continue
|
|
626
|
+
for (const [name, value] of Object.entries(process.env))
|
|
627
|
+
if (value !== undefined && mask.spans(value).length > 0) secrets.push([mask, name])
|
|
628
|
+
}
|
|
510
629
|
if (secrets.length === 0) return []
|
|
511
630
|
const found: Array<{ at: string; name: string }> = []
|
|
512
631
|
const walk = (project: string, at: string, v: unknown): void => {
|
|
513
632
|
if (typeof v === 'string') {
|
|
514
|
-
for (const [mask, name] of secrets)
|
|
515
|
-
|
|
633
|
+
for (const [mask, name] of secrets) {
|
|
634
|
+
const place = `${project}: ${at} holds $${name}`
|
|
635
|
+
// A define's mask and its variable's own both find one value.
|
|
636
|
+
if (mask.spans(v).length > 0 && !found.some((f) => f.at === place))
|
|
637
|
+
found.push({ at: place, name })
|
|
638
|
+
}
|
|
516
639
|
} else if (Array.isArray(v)) {
|
|
517
640
|
v.forEach((x, i) => walk(project, `${at}[${i}]`, x))
|
|
518
641
|
} else if (v !== null && typeof v === 'object') {
|
|
@@ -533,6 +656,29 @@ function warnShadowed(root: string, check: GraphCheck, verb: string): void {
|
|
|
533
656
|
)
|
|
534
657
|
}
|
|
535
658
|
|
|
659
|
+
/**
|
|
660
|
+
* `read` with each file's record taken from `fresh` where only the files
|
|
661
|
+
* its digests read moved (same bytes, same extra files, an extra's hash
|
|
662
|
+
* changed): a run digests anew on such an edit (`lockfileClaim`'s
|
|
663
|
+
* `lockedOnce`), and cli.md says it needs no `vx lock`, yet `--check`
|
|
664
|
+
* failed every edit in a `file:` directory or a patch. With every hash
|
|
665
|
+
* unchanged a run keys from the record, so its digests are compared.
|
|
666
|
+
*/
|
|
667
|
+
function liveExtras(read: DependenciesLock, fresh: DependenciesLock): DependenciesLock {
|
|
668
|
+
const files = { ...read.files }
|
|
669
|
+
for (const [name, now] of Object.entries(fresh.files)) {
|
|
670
|
+
const was = files[name]
|
|
671
|
+
if (
|
|
672
|
+
was !== undefined &&
|
|
673
|
+
was.hash === now.hash &&
|
|
674
|
+
Bun.deepEquals(Object.keys(was.extras ?? {}), Object.keys(now.extras ?? {})) &&
|
|
675
|
+
!Bun.deepEquals(was.extras ?? {}, now.extras ?? {})
|
|
676
|
+
)
|
|
677
|
+
files[name] = now
|
|
678
|
+
}
|
|
679
|
+
return { ...read, files }
|
|
680
|
+
}
|
|
681
|
+
|
|
536
682
|
/**
|
|
537
683
|
* Audit the lock: find and re-evaluate every config in the CURRENT
|
|
538
684
|
* environment and deep-compare it with the locked object. That sees an
|
|
@@ -586,12 +732,26 @@ async function checkLock(root: string, json: boolean): Promise<number> {
|
|
|
586
732
|
? `${rel} is not the ${locked.file} the lock holds for ${dir === '.' ? 'the workspace root project' : dir} — run 'vx lock'`
|
|
587
733
|
: !Bun.deepEquals(fresh.config, locked.config, true)
|
|
588
734
|
? `${rel} evaluates differently from the lock here — its config, a file ` +
|
|
589
|
-
`it imports, or the environment it reads changed; run 'vx lock'`
|
|
735
|
+
`it imports, or the environment it reads changed; run 'vx lock'` +
|
|
736
|
+
// What changed: a config that reads the environment shows no file diff.
|
|
737
|
+
configDiff(locked.config, fresh.config)
|
|
738
|
+
.map((d) => `\n ${maskedLine(d)}`)
|
|
739
|
+
.join('')
|
|
590
740
|
: null
|
|
591
741
|
if (problem === null) continue
|
|
592
742
|
failures.push(problem)
|
|
593
743
|
drifted.add(dir)
|
|
594
744
|
}
|
|
745
|
+
const lockedWalls = lock.nestedWorkspaces ?? []
|
|
746
|
+
const walls = check.fresh.nestedWorkspaces ?? []
|
|
747
|
+
for (const dir of walls)
|
|
748
|
+
if (!lockedWalls.includes(dir))
|
|
749
|
+
failures.push(`${dir} is a nested workspace the lock does not hold — run 'vx lock'`)
|
|
750
|
+
for (const dir of lockedWalls)
|
|
751
|
+
if (!walls.includes(dir))
|
|
752
|
+
failures.push(
|
|
753
|
+
`the lock holds ${dir} as a nested workspace, which it is not now — run 'vx lock'`,
|
|
754
|
+
)
|
|
595
755
|
if (broken !== null && check.loaded) failures.push(broken)
|
|
596
756
|
// Each config that does not load, as `vx lock` names them: a CI fixing
|
|
597
757
|
// one at a time re-ran the gate once per broken config.
|
|
@@ -645,7 +805,10 @@ async function checkLock(root: string, json: boolean): Promise<number> {
|
|
|
645
805
|
.catch(() => null)
|
|
646
806
|
// Compared as JSON: a formatter may lay the file out its own way.
|
|
647
807
|
const read = parseDependenciesLock(text)
|
|
648
|
-
if (
|
|
808
|
+
if (
|
|
809
|
+
read === null ||
|
|
810
|
+
!Bun.deepEquals(liveExtras(read, check.dependencies), check.dependencies, true)
|
|
811
|
+
)
|
|
649
812
|
failures.push(
|
|
650
813
|
`${DEPENDENCIES_LOCK_NAME} ${text === null ? 'is missing' : 'is not what the dependencies resolve to here'} — run 'vx lock'`,
|
|
651
814
|
)
|
package/src/cli/plan-format.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
import type { AffectedReason, CacheStatus, RunPlan } from '../orchestrator/index.js'
|
|
4
4
|
import { computeReverseDepCount } from '../graph/index.js'
|
|
5
5
|
import { planList, type PlannedRow, skippedReason } from '../report/index.js'
|
|
6
|
-
import { formatBytes, formatElapsed, maskedCommand } from '../util/index.js'
|
|
6
|
+
import { formatBytes, formatElapsed, maskedCommand, printable } from '../util/index.js'
|
|
7
7
|
import * as ui from '../ui/index.js'
|
|
8
8
|
import type { ColorSupport } from '../ui/index.js'
|
|
9
9
|
|
|
@@ -12,11 +12,10 @@ import type { ColorSupport } from '../ui/index.js'
|
|
|
12
12
|
* list's grid (what the run would do with it, its usual time, where it
|
|
13
13
|
* would run), then the counts and the predicted time.
|
|
14
14
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* ⏺︎ ~1.2s run miss local @vzn/vx#build
|
|
15
|
+
* Plan 3 status cache usual time cpu mem outputs blocks worker task
|
|
16
|
+
* cached local — — — — — — local @vzn/vx#lint
|
|
17
|
+
* cached remote — — — — 12MB — local @vzn/vx#test
|
|
18
|
+
* run miss ~1.2s — — — — — local @vzn/vx#build
|
|
20
19
|
*
|
|
21
20
|
* tasks 3 planned · 2 cache hits (1 local, 1 remote, 12MB) · 1 would run
|
|
22
21
|
* predicted ~1.20s wall · ~1.20s total execution
|
|
@@ -46,12 +45,12 @@ export function planReport(plan: RunPlan): ui.Block {
|
|
|
46
45
|
if (t.cacheStatus === 'skipped') notes.push(skippedReason(t))
|
|
47
46
|
const desc = shownDescription(t)
|
|
48
47
|
if (desc !== undefined && desc !== '') notes.push(desc)
|
|
49
|
-
if (t.affected !== undefined) notes.push(`affected: ${describeReason(t.affected)}`)
|
|
48
|
+
if (t.affected !== undefined) notes.push(`affected: ${printable(describeReason(t.affected))}`)
|
|
50
49
|
const executes = !skipped && (t.cacheStatus === 'miss' || t.cacheStatus === 'no-cache')
|
|
51
50
|
rows.push({
|
|
52
51
|
node: t.node,
|
|
53
52
|
...(skipped ? NOOP : LOOK[t.cacheStatus]),
|
|
54
|
-
...(executes
|
|
53
|
+
...(executes ? { usualMs: t.p50Ms ?? null } : {}),
|
|
55
54
|
...(t.cacheStatus === 'hit-remote' && t.remoteBytes !== undefined
|
|
56
55
|
? { outputBytes: t.remoteBytes }
|
|
57
56
|
: {}),
|
|
@@ -114,7 +113,7 @@ export function planReport(plan: RunPlan): ui.Block {
|
|
|
114
113
|
const parts = [`~${formatElapsed(p.wallMs)} wall`]
|
|
115
114
|
parts.push(`~${formatElapsed(p.workMs)} total execution`)
|
|
116
115
|
if (p.unknownCount > 0) {
|
|
117
|
-
parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'}
|
|
116
|
+
parts.push(`${p.unknownCount} task${p.unknownCount === 1 ? '' : 's'} never timed (+?)`)
|
|
118
117
|
}
|
|
119
118
|
facts.push(['predicted', parts.join(' · ')])
|
|
120
119
|
}
|
|
@@ -137,37 +136,34 @@ export function formatPlanText(plan: RunPlan, c: ColorSupport = { enabled: false
|
|
|
137
136
|
return ui.render([planReport(plan)], c)
|
|
138
137
|
}
|
|
139
138
|
|
|
140
|
-
type Look = Pick<PlannedRow, '
|
|
139
|
+
type Look = Pick<PlannedRow, 'status' | 'tone' | 'cache' | 'cacheTone'>
|
|
141
140
|
|
|
142
|
-
/** Each outcome on the run's grid: its
|
|
141
|
+
/** Each outcome on the run's grid: its words, as the run would print them. */
|
|
143
142
|
const LOOK: Record<CacheStatus, Look> = {
|
|
144
143
|
'hit-local': {
|
|
145
|
-
glyph: '\u21e2',
|
|
146
144
|
status: 'cached',
|
|
147
145
|
tone: 'ok',
|
|
148
146
|
cache: 'local',
|
|
149
147
|
cacheTone: 'local',
|
|
150
148
|
},
|
|
151
149
|
'hit-remote': {
|
|
152
|
-
glyph: '\u21e3',
|
|
153
150
|
status: 'cached',
|
|
154
151
|
tone: 'ok',
|
|
155
152
|
cache: 'remote',
|
|
156
153
|
cacheTone: 'remote',
|
|
157
154
|
},
|
|
158
|
-
miss: {
|
|
155
|
+
miss: { status: 'run', tone: 'info', cache: 'miss', cacheTone: 'muted' },
|
|
159
156
|
'no-cache': {
|
|
160
|
-
glyph: '\u23fa\ufe0e',
|
|
161
157
|
status: 'run',
|
|
162
158
|
tone: 'info',
|
|
163
159
|
cache: 'no-cache',
|
|
164
160
|
cacheTone: 'muted',
|
|
165
161
|
},
|
|
166
|
-
group: {
|
|
167
|
-
'not-started': {
|
|
168
|
-
skipped: {
|
|
162
|
+
group: { status: 'group', tone: 'muted', cache: '', cacheTone: '' },
|
|
163
|
+
'not-started': { status: 'not run', tone: 'muted', cache: '', cacheTone: '' },
|
|
164
|
+
skipped: { status: 'skipped', tone: 'warn', cache: '', cacheTone: '' },
|
|
169
165
|
}
|
|
170
|
-
const NOOP: Look = {
|
|
166
|
+
const NOOP: Look = { status: 'noop', tone: 'muted', cache: '', cacheTone: '' }
|
|
171
167
|
|
|
172
168
|
/** One `--affected --dry` reason line: the cause, then the edge chain that carried it. */
|
|
173
169
|
function describeReason(r: AffectedReason): string {
|
|
@@ -181,13 +177,20 @@ function describeReason(r: AffectedReason): string {
|
|
|
181
177
|
: r.kind === 'input'
|
|
182
178
|
? `${r.file} changed (an input)`
|
|
183
179
|
: r.kind === 'workspace'
|
|
184
|
-
? `${r.file} changed (workspace-wide)`
|
|
180
|
+
? `${r.file} changed (${r.wide === true ? 'workspace-wide' : 'every task in its project'})`
|
|
185
181
|
: r.file !== undefined
|
|
186
182
|
? `${r.file} changed (in its project)`
|
|
187
183
|
: 'its project changed as a whole'
|
|
188
184
|
return r.via === undefined ? cause : `${cause}, via ${r.via.join(' ← ')}`
|
|
189
185
|
}
|
|
190
186
|
|
|
187
|
+
function wireReason(r: AffectedReason): AffectedReason {
|
|
188
|
+
if (r.wide === undefined) return r
|
|
189
|
+
const wire = { ...r }
|
|
190
|
+
delete wire.wide
|
|
191
|
+
return wire
|
|
192
|
+
}
|
|
193
|
+
|
|
191
194
|
/** A TS config may build the description from `process.env` (L-11). */
|
|
192
195
|
function shownDescription(t: RunPlan['tasks'][number]): string | undefined {
|
|
193
196
|
const { description, exec } = t.node.config
|
|
@@ -232,7 +235,7 @@ export function formatPlanJson(
|
|
|
232
235
|
...(t.executor !== undefined ? { executor: t.executor } : {}),
|
|
233
236
|
...(t.download !== undefined ? { download: t.download } : {}),
|
|
234
237
|
...(description !== undefined ? { description } : {}),
|
|
235
|
-
...(t.affected !== undefined ? { affected: t.affected } : {}),
|
|
238
|
+
...(t.affected !== undefined ? { affected: wireReason(t.affected) } : {}),
|
|
236
239
|
...(t.otherPlatform !== undefined ? { otherPlatform: t.otherPlatform } : {}),
|
|
237
240
|
...(t.blockedBy !== undefined ? { blockedBy: t.blockedBy } : {}),
|
|
238
241
|
}
|
|
@@ -337,7 +337,12 @@ export async function pluginCommandHelp(cwd = process.cwd()): Promise<string[]>
|
|
|
337
337
|
if (seen.has(verb)) continue
|
|
338
338
|
seen.add(verb)
|
|
339
339
|
lines.push(
|
|
340
|
-
...helpLines(
|
|
340
|
+
...helpLines(
|
|
341
|
+
// Two spaces at least: one ran a long verb into its description,
|
|
342
|
+
// and the help painter reads a description after two.
|
|
343
|
+
` vx ${verb.length < 17 ? `${verb.padEnd(17)} ` : `${verb} `}`,
|
|
344
|
+
`${command.description} (${plugin.name})`,
|
|
345
|
+
),
|
|
341
346
|
)
|
|
342
347
|
}
|
|
343
348
|
}
|