@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/select.ts
CHANGED
|
@@ -25,10 +25,10 @@ import {
|
|
|
25
25
|
type ProjectMeta,
|
|
26
26
|
readLockfile,
|
|
27
27
|
} from '../workspace/index.js'
|
|
28
|
-
import type { ProjectConfig } from '../config.js'
|
|
28
|
+
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
29
29
|
import type { ProjectEntry } from '../workspace/index.js'
|
|
30
30
|
import { isDefaultBuild, parseDependencySpec } from '../graph/index.js'
|
|
31
|
-
import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
|
|
31
|
+
import { declaresInput, lazyGitEnumeration, workspaceFilesReachInto } from '../cache/index.js'
|
|
32
32
|
import {
|
|
33
33
|
isUserError,
|
|
34
34
|
listed,
|
|
@@ -45,7 +45,6 @@ import {
|
|
|
45
45
|
claimedExtraFiles,
|
|
46
46
|
claimedDigests,
|
|
47
47
|
fingerprintClaims,
|
|
48
|
-
gitOfDiscovery,
|
|
49
48
|
hasHook,
|
|
50
49
|
keepDiscoveryGraph,
|
|
51
50
|
} from '../orchestrator/index.js'
|
|
@@ -77,17 +76,53 @@ export async function workspaceGlobOwners(
|
|
|
77
76
|
nested: readonly string[] = [],
|
|
78
77
|
/** `AffectedChanges.dirLinks`. */
|
|
79
78
|
dirLinks: ReadonlySet<string> = new Set(),
|
|
79
|
+
/** `AffectedChanges.untracked`. */
|
|
80
|
+
untracked: ReadonlySet<string> = new Set(),
|
|
80
81
|
): Promise<string[]> {
|
|
81
|
-
|
|
82
|
+
// The changed paths a `workspaceFiles` list matches, asked once per
|
|
83
|
+
// distinct list rather than once per task: tasks × changed paths was
|
|
84
|
+
// 14.5 s on 8000 projects with 64k changed paths. A tracked match selects
|
|
85
|
+
// any task declaring the list; an untracked one only a task whose own
|
|
86
|
+
// outputs it is not, so those are kept for the per-task check.
|
|
87
|
+
const byList = new Map<string, { tracked: boolean; loose: string[] }>()
|
|
88
|
+
const matchesOf = (ws: readonly string[]) => {
|
|
89
|
+
const key = ws.join('\0')
|
|
90
|
+
let found = byList.get(key)
|
|
91
|
+
if (found !== undefined) return found
|
|
92
|
+
found = { tracked: false, loose: [] }
|
|
93
|
+
const probe = { inputs: { files: [], workspaceFiles: ws } } as unknown as TaskConfig
|
|
94
|
+
for (const rel of changed) {
|
|
95
|
+
const loose = untracked.has(rel)
|
|
96
|
+
if (!declaresInput(probe, null, rel, dirLinks.has(rel))) continue
|
|
97
|
+
if (!loose) {
|
|
98
|
+
found.tracked = true
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
found.loose.push(rel)
|
|
102
|
+
}
|
|
103
|
+
byList.set(key, found)
|
|
104
|
+
return found
|
|
105
|
+
}
|
|
106
|
+
const declaresMatch = (config: ProjectConfig, projectAbs: string): boolean => {
|
|
107
|
+
let projectDir: string | undefined
|
|
82
108
|
for (const task of Object.values(config.tasks ?? {})) {
|
|
83
|
-
|
|
109
|
+
const ws = task.inputs?.workspaceFiles
|
|
110
|
+
if (ws === undefined || ws.length === 0) continue
|
|
111
|
+
const input = (rel: string): boolean => {
|
|
112
|
+
const loose = untracked.has(rel)
|
|
113
|
+
if (loose) projectDir ??= relPosix(root, projectAbs)
|
|
114
|
+
return declaresInput(task, null, rel, dirLinks.has(rel), loose, projectDir)
|
|
115
|
+
}
|
|
116
|
+
// A hand-edited lock entry that is no list is asked path by path.
|
|
117
|
+
const found = Array.isArray(ws) ? matchesOf(ws) : { tracked: changed.some(input), loose: [] }
|
|
118
|
+
if (found.tracked || found.loose.some(input)) return true
|
|
84
119
|
if (nested.some((dir) => workspaceFilesReachInto(task, dir))) return true
|
|
85
120
|
}
|
|
86
121
|
return false
|
|
87
122
|
}
|
|
88
123
|
try {
|
|
89
124
|
const staged = await stagedLoad()
|
|
90
|
-
return [...staged.values()].filter((p) => declaresMatch(p.config)).map((p) => p.name)
|
|
125
|
+
return [...staged.values()].filter((p) => declaresMatch(p.config, p.dir)).map((p) => p.name)
|
|
91
126
|
} catch {
|
|
92
127
|
// Fall through to the per-file sweep.
|
|
93
128
|
}
|
|
@@ -102,7 +137,7 @@ export async function workspaceGlobOwners(
|
|
|
102
137
|
const dir = lockKey(root, meta.dir)
|
|
103
138
|
if (!Object.hasOwn(lock.projects, dir)) continue
|
|
104
139
|
try {
|
|
105
|
-
if (declaresMatch(lock.projects[dir]!.config)) owners.push(meta.name)
|
|
140
|
+
if (declaresMatch(lock.projects[dir]!.config, meta.dir)) owners.push(meta.name)
|
|
106
141
|
} catch {
|
|
107
142
|
// A hand-edited entry that will not read cannot be shown to declare a
|
|
108
143
|
// matching glob; surfacing it here would fail a run the load tolerates.
|
|
@@ -287,6 +322,12 @@ function refusalOf(err: unknown): Refusal {
|
|
|
287
322
|
: { error: err.message }
|
|
288
323
|
}
|
|
289
324
|
|
|
325
|
+
/** The filter `--affected` became; `env` is the `VX_AFFECTED_BASE` value that gave its base. */
|
|
326
|
+
export interface AffectedFilter {
|
|
327
|
+
readonly filter: string
|
|
328
|
+
readonly env?: string
|
|
329
|
+
}
|
|
330
|
+
|
|
290
331
|
/**
|
|
291
332
|
* The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
|
|
292
333
|
* projects and their dependents. An empty base is `VX_AFFECTED_BASE`'s,
|
|
@@ -298,9 +339,10 @@ export async function affectedFilterFor(
|
|
|
298
339
|
affected: string,
|
|
299
340
|
/** What the verb does instead on the base branch (`defaultAffectedBase`). */
|
|
300
341
|
instead?: string,
|
|
301
|
-
): Promise<
|
|
342
|
+
): Promise<AffectedFilter | Refusal> {
|
|
302
343
|
const root = await findWorkspaceRoot(cwd)
|
|
303
|
-
|
|
344
|
+
const env = affected === '' ? (settingEnv('affectedBase') as string | undefined) : undefined
|
|
345
|
+
let base = affected || (env ?? '')
|
|
304
346
|
if (base === '') {
|
|
305
347
|
const ws = await loadCliWorkspace(root)
|
|
306
348
|
try {
|
|
@@ -312,42 +354,98 @@ export async function affectedFilterFor(
|
|
|
312
354
|
return refusalOf(err)
|
|
313
355
|
}
|
|
314
356
|
}
|
|
315
|
-
return `...[${base}]`
|
|
357
|
+
return env === undefined ? { filter: `...[${base}]` } : { filter: `...[${base}]`, env }
|
|
316
358
|
}
|
|
317
359
|
|
|
318
360
|
export async function resolveFilters(
|
|
319
361
|
cwd: string,
|
|
320
362
|
raw: string[],
|
|
321
363
|
load: CliLoadOptions = {},
|
|
322
|
-
/** The
|
|
323
|
-
affected?:
|
|
364
|
+
/** The filter `--affected` became: its diff is kept per path for the run. */
|
|
365
|
+
affected?: AffectedFilter,
|
|
324
366
|
/** The verb a warning names: `vx show` and `vx watch` select as a run does. */
|
|
325
367
|
verb: 'run' | 'show' | 'watch' = 'run',
|
|
326
368
|
): Promise<FilterResolution> {
|
|
327
369
|
const root = await findWorkspaceRoot(cwd)
|
|
328
|
-
const projects = await loadWorkspaceProjects(cwd, load)
|
|
329
370
|
let parsed: ReturnType<typeof parseFilter>[]
|
|
330
371
|
try {
|
|
331
372
|
parsed = raw.map((r) => parseFilter(r, root))
|
|
332
373
|
} catch (err) {
|
|
374
|
+
await loadWorkspaceProjects(cwd, load)
|
|
333
375
|
return refusalOf(err)
|
|
334
376
|
}
|
|
377
|
+
// A filter that diffs against git reads its untracked files from the
|
|
378
|
+
// run's whole-tree walk (one walk where it spawned its own), and the run
|
|
379
|
+
// reuses it with the discovery (I-26). The walk needs only the root, so
|
|
380
|
+
// it starts before the workspace config and discovery, not after them.
|
|
381
|
+
const git = parsed.some((f) => f.gitSince !== undefined) ? lazyGitEnumeration(root) : undefined
|
|
382
|
+
void git?.start()
|
|
383
|
+
const discovering = discoverCliProjects(root, load, git)
|
|
384
|
+
discovering.catch(() => {})
|
|
335
385
|
// `sinceViaDeps` (`e2e...[main]`) walks the changed projects' dependents.
|
|
336
386
|
const walksGraph = parsed.some(
|
|
337
387
|
(f) => f.withDeps || f.withDependents || f.onlyDeps || f.sinceViaDeps === true,
|
|
338
388
|
)
|
|
339
|
-
// A filter that diffs against git starts the run's whole-tree walk now:
|
|
340
|
-
// the diff reads its untracked files from it (one walk where it spawned
|
|
341
|
-
// its own), and the run reuses it with the discovery (I-26).
|
|
342
|
-
const git = parsed.some((f) => f.gitSince !== undefined) ? gitOfDiscovery(projects) : undefined
|
|
343
|
-
void git?.start()
|
|
344
389
|
// Every reader of the staged configs in this pass — the `pkg#task`
|
|
345
390
|
// edge walk, the `workspaceFiles` owners of a changed path — shares
|
|
346
391
|
// ONE load, and the run reuses it (`RunOptions.staged`): the `project`
|
|
347
392
|
// stage runs once per project per run.
|
|
348
393
|
let stagedPromise: Promise<Map<string, ProjectEntry>> | undefined
|
|
349
394
|
const stagedOnce = (): Promise<Map<string, ProjectEntry>> =>
|
|
350
|
-
(stagedPromise ??= loadCliProjects(root, projects, 'all', load))
|
|
395
|
+
(stagedPromise ??= discovering.then((projects) => loadCliProjects(root, projects, 'all', load)))
|
|
396
|
+
// A scoped enumeration lists only its projects: null asks git.
|
|
397
|
+
const enumeration = async () => (git === undefined ? undefined : await git.start())
|
|
398
|
+
// Each `[<since>]` diff starts now, before discovery ends: merge-base and
|
|
399
|
+
// diff need only the root and the ref, so they overlap the workspace
|
|
400
|
+
// config, discovery and the config load, where they waited on all three.
|
|
401
|
+
// One after another, as a shallow clone's fetches must be; each is
|
|
402
|
+
// awaited in filter order below, so a refusal comes where it came.
|
|
403
|
+
const diffs = new Map<(typeof parsed)[number], Promise<AffectedChanges | Set<string>>>()
|
|
404
|
+
let previous: Promise<unknown> | undefined
|
|
405
|
+
for (const f of parsed) {
|
|
406
|
+
if (f.gitSince === undefined) continue
|
|
407
|
+
const args: Parameters<typeof affectedChanges>[0] = {
|
|
408
|
+
workspaceRoot: root,
|
|
409
|
+
since: f.gitSince,
|
|
410
|
+
projects: discovering,
|
|
411
|
+
workspaceGlobOwners: async (
|
|
412
|
+
changed: readonly string[],
|
|
413
|
+
nested: readonly string[],
|
|
414
|
+
dirLinks: ReadonlySet<string>,
|
|
415
|
+
untracked: ReadonlySet<string>,
|
|
416
|
+
) =>
|
|
417
|
+
workspaceGlobOwners(
|
|
418
|
+
root,
|
|
419
|
+
await discovering,
|
|
420
|
+
changed,
|
|
421
|
+
load,
|
|
422
|
+
stagedOnce,
|
|
423
|
+
nested,
|
|
424
|
+
dirLinks,
|
|
425
|
+
untracked,
|
|
426
|
+
),
|
|
427
|
+
fingerprintClaims: async () => workspaceFingerprintClaims(root, await discovering, load),
|
|
428
|
+
untracked: async () => (await enumeration())?.untracked ?? null,
|
|
429
|
+
links: async () => {
|
|
430
|
+
const g = await enumeration()
|
|
431
|
+
if (g === undefined || !g.scopes.includes('.')) return null
|
|
432
|
+
const out: string[] = []
|
|
433
|
+
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
434
|
+
return out
|
|
435
|
+
},
|
|
436
|
+
flagged: async () => {
|
|
437
|
+
const g = await enumeration()
|
|
438
|
+
return g !== undefined && g.scopes.includes('.') ? g.flagged : null
|
|
439
|
+
},
|
|
440
|
+
}
|
|
441
|
+
const diff = (): Promise<AffectedChanges | Set<string>> =>
|
|
442
|
+
f.raw === affected?.filter ? affectedChanges(args) : affectedProjects(args)
|
|
443
|
+
const started = previous === undefined ? diff() : previous.then(diff)
|
|
444
|
+
started.catch(() => {})
|
|
445
|
+
diffs.set(f, started)
|
|
446
|
+
previous = started
|
|
447
|
+
}
|
|
448
|
+
const projects = await discovering
|
|
351
449
|
let edges: Map<string, string[]> | undefined
|
|
352
450
|
if (walksGraph) {
|
|
353
451
|
try {
|
|
@@ -367,50 +465,34 @@ export async function resolveFilters(
|
|
|
367
465
|
}
|
|
368
466
|
}
|
|
369
467
|
const graph = buildPackageGraph(projects, edges)
|
|
370
|
-
// The graph a run reusing this discovery builds is this one when
|
|
371
|
-
// edge
|
|
372
|
-
|
|
468
|
+
// The graph a run reusing this discovery builds is this one when the task
|
|
469
|
+
// edges added none: each is a discovered edge already, or names no project.
|
|
470
|
+
const known = new Set(projects.map((p) => p.name))
|
|
471
|
+
if (
|
|
472
|
+
edges === undefined ||
|
|
473
|
+
projects.every((p) =>
|
|
474
|
+
(edges.get(p.name) ?? []).every((t) => !known.has(t) || p.deps?.includes(t) === true),
|
|
475
|
+
)
|
|
476
|
+
)
|
|
477
|
+
keepDiscoveryGraph(projects, graph)
|
|
373
478
|
|
|
374
|
-
//
|
|
375
|
-
// applyFilters pass runs.
|
|
376
|
-
// there's only one anyway.
|
|
479
|
+
// Every `[<since>]` filter's diff, started above, is in before the pure
|
|
480
|
+
// applyFilters pass runs.
|
|
377
481
|
const affectedByFilter = new Map<(typeof parsed)[number], Set<string>>()
|
|
482
|
+
// The diffs that named a change, though maybe in no project: a root file
|
|
483
|
+
// no task declares. Then the tree is not clean against HEAD.
|
|
484
|
+
const changedAny = new Set<(typeof parsed)[number]>()
|
|
378
485
|
let changes: AffectedChanges | undefined
|
|
379
|
-
for (const f of
|
|
380
|
-
if (f.gitSince === undefined) continue
|
|
486
|
+
for (const [f, diff] of diffs) {
|
|
381
487
|
try {
|
|
382
|
-
const
|
|
383
|
-
workspaceRoot: root,
|
|
384
|
-
since: f.gitSince,
|
|
385
|
-
projects,
|
|
386
|
-
workspaceGlobOwners: (
|
|
387
|
-
changed: readonly string[],
|
|
388
|
-
nested: readonly string[],
|
|
389
|
-
dirLinks: ReadonlySet<string>,
|
|
390
|
-
) => workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested, dirLinks),
|
|
391
|
-
fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
|
|
392
|
-
...(git !== undefined
|
|
393
|
-
? {
|
|
394
|
-
untracked: async () => (await git.start()).untracked,
|
|
395
|
-
// A scoped enumeration lists only its projects: null asks git.
|
|
396
|
-
links: async () => {
|
|
397
|
-
const g = await git.start()
|
|
398
|
-
if (!g.scopes.includes('.')) return null
|
|
399
|
-
const out: string[] = []
|
|
400
|
-
for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
|
|
401
|
-
return out
|
|
402
|
-
},
|
|
403
|
-
flagged: async () => {
|
|
404
|
-
const g = await git.start()
|
|
405
|
-
return g.scopes.includes('.') ? g.flagged : null
|
|
406
|
-
},
|
|
407
|
-
}
|
|
408
|
-
: {}),
|
|
409
|
-
}
|
|
488
|
+
const answer = await diff
|
|
410
489
|
let names: Set<string>
|
|
411
|
-
if (
|
|
412
|
-
|
|
490
|
+
if (answer instanceof Set) {
|
|
491
|
+
names = answer
|
|
492
|
+
} else {
|
|
493
|
+
changes = answer
|
|
413
494
|
names = changes.projects
|
|
495
|
+
if (changes.changed.length > 0 || changes.nested !== undefined) changedAny.add(f)
|
|
414
496
|
// A `graph` plugin may tie any task to a changed path (an edge, an
|
|
415
497
|
// input), and the run alone builds that graph: every project is a
|
|
416
498
|
// candidate, and the run keeps the tasks its final graph reaches.
|
|
@@ -420,12 +502,18 @@ export async function resolveFilters(
|
|
|
420
502
|
) {
|
|
421
503
|
names = new Set(projects.map((p) => p.name))
|
|
422
504
|
}
|
|
423
|
-
} else {
|
|
424
|
-
names = await affectedProjects(args)
|
|
425
505
|
}
|
|
426
506
|
affectedByFilter.set(f, names)
|
|
427
507
|
} catch (err) {
|
|
428
|
-
|
|
508
|
+
const refusal = refusalOf(err)
|
|
509
|
+
// A base the variable gave was reported as if typed.
|
|
510
|
+
if (
|
|
511
|
+
affected?.env !== undefined &&
|
|
512
|
+
f.raw === affected.filter &&
|
|
513
|
+
refusal.code === 'VX_E_AFFECTED_BASE'
|
|
514
|
+
)
|
|
515
|
+
return { ...refusal, error: `VX_AFFECTED_BASE=${affected.env}: ${refusal.error}` }
|
|
516
|
+
return refusal
|
|
429
517
|
}
|
|
430
518
|
}
|
|
431
519
|
|
|
@@ -482,10 +570,14 @@ export async function resolveFilters(
|
|
|
482
570
|
// the branch under test) can never mark anything affected, and this
|
|
483
571
|
// exit-0 note was the only sign (CI persona, 2026-09-16). Say so.
|
|
484
572
|
const self = includes.map((f) => headBase(root, f.gitSince!)).find((b) => b !== undefined)
|
|
573
|
+
// `HEAD` asked by name checks uncommitted work: "HEAD is HEAD itself"
|
|
574
|
+
// read as nonsense for a valid question.
|
|
485
575
|
const hint =
|
|
486
|
-
self === undefined
|
|
576
|
+
self === undefined || (self === 'HEAD' && includes.some((f) => changedAny.has(f)))
|
|
487
577
|
? ''
|
|
488
|
-
:
|
|
578
|
+
: self === 'HEAD'
|
|
579
|
+
? ' — the working tree has no change against HEAD; to compare commits, use the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)'
|
|
580
|
+
: ` — ${self} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
|
|
489
581
|
return { empty: `nothing affected since ${refs}${hint}` }
|
|
490
582
|
}
|
|
491
583
|
// The diff matched and a walk or an exclusion left nothing: still the
|
|
@@ -507,7 +599,11 @@ export async function resolveFilters(
|
|
|
507
599
|
// app, and `--affected` a `...[<base>]` nobody typed.
|
|
508
600
|
const hint =
|
|
509
601
|
(await unlockedHint(root, parsed, unmatched)) ?? didYouMean(unmatched, projects, tags)
|
|
510
|
-
|
|
602
|
+
// Quoted, as the warning below quotes it: bare, `apq...` and the hint's
|
|
603
|
+
// full stop read `apq....` in the flat message `--format json` carries.
|
|
604
|
+
return {
|
|
605
|
+
error: `no projects matched filter(s): ${unmatched.map((f) => `"${f}"`).join(', ')}${hint}`,
|
|
606
|
+
}
|
|
511
607
|
}
|
|
512
608
|
// Something matched, so the run proceeds; a pattern that matched nothing
|
|
513
609
|
// alongside it is still worth a line — it is probably a typo.
|
|
@@ -520,7 +616,7 @@ export async function resolveFilters(
|
|
|
520
616
|
// Each include is a union: what `--filter other` selected runs its tasks
|
|
521
617
|
// whether or not the `--affected` diff reaches them, and an exclude
|
|
522
618
|
// still removes it (X-10).
|
|
523
|
-
const others = parsed.filter((f) => f.negate || f.raw !== affected)
|
|
619
|
+
const others = parsed.filter((f) => f.negate || f.raw !== affected?.filter)
|
|
524
620
|
const outright =
|
|
525
621
|
changes !== undefined && others.some((f) => !f.negate)
|
|
526
622
|
? [
|
package/src/cli/show.ts
CHANGED
|
@@ -6,11 +6,12 @@
|
|
|
6
6
|
// resolved config. `<pkg>#<task>`: one task. `<task>`: that task in every
|
|
7
7
|
// project declaring it.
|
|
8
8
|
|
|
9
|
+
import { existsSync } from 'node:fs'
|
|
9
10
|
import path from 'node:path'
|
|
10
11
|
import type { ProjectConfig, TaskConfig } from '../config.js'
|
|
11
12
|
import { declaredTask } from '../graph/index.js'
|
|
12
13
|
import { isDefaultBuild } from '../graph/index.js'
|
|
13
|
-
import { affectedTaskProjects } from '../orchestrator/index.js'
|
|
14
|
+
import { affectedTaskProjects, projectAtDir, unlockedDir } from '../orchestrator/index.js'
|
|
14
15
|
import { flagHint, formatValue, refuse, seeHelp } from './help.js'
|
|
15
16
|
import {
|
|
16
17
|
listed,
|
|
@@ -22,7 +23,7 @@ import {
|
|
|
22
23
|
} from '../util/index.js'
|
|
23
24
|
import * as ui from '../ui/index.js'
|
|
24
25
|
import { sayNotice } from '../ui/index.js'
|
|
25
|
-
import { affectedFilterFor, resolveFilters } from './select.js'
|
|
26
|
+
import { type AffectedFilter, affectedFilterFor, resolveFilters } from './select.js'
|
|
26
27
|
import {
|
|
27
28
|
discoverCliProjects,
|
|
28
29
|
loadCliProjects,
|
|
@@ -34,6 +35,7 @@ import {
|
|
|
34
35
|
buildPackageGraph,
|
|
35
36
|
EMPTY_LOCK,
|
|
36
37
|
findWorkspaceRoot,
|
|
38
|
+
PROJECT_CONFIG_FILENAMES,
|
|
37
39
|
type ProjectEntry,
|
|
38
40
|
type ProjectMeta,
|
|
39
41
|
} from '../workspace/index.js'
|
|
@@ -113,6 +115,22 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
113
115
|
}
|
|
114
116
|
parsed.target = `${rootMeta.name}${parsed.target.slice(2)}`
|
|
115
117
|
}
|
|
118
|
+
// A project's directory typed for its name: by edit distance, `p/a`
|
|
119
|
+
// hinted `p/b`, another project.
|
|
120
|
+
const atDir = (typed: string, suffix: string): string | undefined => {
|
|
121
|
+
const name = projectAtDir(typed, root, metas)
|
|
122
|
+
return name === undefined ? undefined : ` — did you mean ${name}${suffix}?`
|
|
123
|
+
}
|
|
124
|
+
// A directory whose config came after the lock: the line `vx run` gives,
|
|
125
|
+
// not "unknown project" and a guess.
|
|
126
|
+
const refuseUnlocked = async (typed: string): Promise<void> => {
|
|
127
|
+
const config = await unlockedDir(typed, metas, root)
|
|
128
|
+
if (config === undefined) return
|
|
129
|
+
throw new UserError(
|
|
130
|
+
`${parsed.target}: ${relPosix(root, config)} is not in the lock yet — run \`vx lock\``,
|
|
131
|
+
'VX_E_LOCK',
|
|
132
|
+
)
|
|
133
|
+
}
|
|
116
134
|
const hashAt = parsed.target?.indexOf('#') ?? -1
|
|
117
135
|
const projectName =
|
|
118
136
|
parsed.target === undefined
|
|
@@ -136,8 +154,9 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
136
154
|
}
|
|
137
155
|
if (projectName !== undefined && (taskName !== undefined || byName.has(projectName))) {
|
|
138
156
|
if (!byName.has(projectName)) {
|
|
157
|
+
await refuseUnlocked(projectName)
|
|
139
158
|
throw new UserError(
|
|
140
|
-
`vx show: unknown project: "${projectName}"${suggest(projectName, [...byName.keys()], '', 'projects')}`,
|
|
159
|
+
`vx show: unknown project: "${projectName}"${atDir(projectName, `#${taskName}`) ?? suggest(projectName, [...byName.keys()], '', 'projects')}`,
|
|
141
160
|
'VX_E_UNKNOWN_PROJECT',
|
|
142
161
|
)
|
|
143
162
|
}
|
|
@@ -161,16 +180,16 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
161
180
|
)
|
|
162
181
|
}
|
|
163
182
|
const raw = [...parsed.filters]
|
|
164
|
-
let affected:
|
|
183
|
+
let affected: AffectedFilter | undefined
|
|
165
184
|
if (parsed.affected !== undefined) {
|
|
166
185
|
const f = await affectedFilterFor(
|
|
167
186
|
process.cwd(),
|
|
168
187
|
parsed.affected,
|
|
169
188
|
'drop --affected to list them all',
|
|
170
189
|
)
|
|
171
|
-
if (
|
|
190
|
+
if ('error' in f) throw new UserError(`vx show: ${f.error}`, f.code ?? 'VX_E_USAGE')
|
|
172
191
|
affected = f
|
|
173
|
-
raw.unshift(f)
|
|
192
|
+
raw.unshift(f.filter)
|
|
174
193
|
}
|
|
175
194
|
const r = await resolveFilters(process.cwd(), raw, {}, affected, 'show')
|
|
176
195
|
if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
|
|
@@ -203,13 +222,14 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
203
222
|
declares(declaredTask(p.config, projectName!)),
|
|
204
223
|
)
|
|
205
224
|
if (declaring.length === 0) {
|
|
225
|
+
await refuseUnlocked(projectName!)
|
|
206
226
|
const names = new Set<string>()
|
|
207
227
|
for (const p of projects.values())
|
|
208
228
|
for (const [t, task] of Object.entries(p.config.tasks ?? {}))
|
|
209
229
|
if (declares(task)) names.add(t)
|
|
210
230
|
// `nx show projects` lists them; here a bare `vx show` does.
|
|
211
231
|
const nx = projectName === 'projects' ? ' (`nx show projects` is `vx show` here)' : ''
|
|
212
|
-
const unknown = `vx show: unknown project or task: "${projectName}"${nx}${suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`
|
|
232
|
+
const unknown = `vx show: unknown project or task: "${projectName}"${nx}${atDir(projectName!, '') ?? suggest(projectName!, [...byName.keys(), ...names], '', 'projects and tasks')}`
|
|
213
233
|
const base =
|
|
214
234
|
parsed.spacedBase === projectName
|
|
215
235
|
? `${unknown.endsWith('.') ? '' : '.'} \`--affected\` takes its base with '=': --affected=${projectName}`
|
|
@@ -258,7 +278,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
|
|
|
258
278
|
const dir = projectDir(root, meta)
|
|
259
279
|
|
|
260
280
|
if (taskName === undefined) {
|
|
261
|
-
emit(renderProject(meta.name, dir, config, meta
|
|
281
|
+
emit(renderProject(meta.name, dir, config, configNote(meta), parsed.format))
|
|
262
282
|
return 0
|
|
263
283
|
}
|
|
264
284
|
|
|
@@ -369,7 +389,9 @@ function renderList(
|
|
|
369
389
|
tags: projects.get(meta.name)?.config.tags ?? [],
|
|
370
390
|
tasks: Object.keys(tasks),
|
|
371
391
|
onlyDefault: Object.values(tasks).every((t) => isDefaultBuild(t)),
|
|
392
|
+
hasDefault: Object.values(tasks).some((t) => isDefaultBuild(t)),
|
|
372
393
|
configured: meta.configPath !== null,
|
|
394
|
+
note: configNote(meta),
|
|
373
395
|
}
|
|
374
396
|
})
|
|
375
397
|
if (format === 'json') {
|
|
@@ -383,23 +405,38 @@ function renderList(
|
|
|
383
405
|
// A package with no config file has only tasks a plugin gave it, or the
|
|
384
406
|
// default `build` a configured dependant's closure gave it.
|
|
385
407
|
const tasks = r.configured
|
|
386
|
-
?
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
408
|
+
? r.tasks.length > 0 && r.hasDefault
|
|
409
|
+
? `${count}, default build`
|
|
410
|
+
: count
|
|
411
|
+
: r.note !== null && r.note !== 'none'
|
|
412
|
+
? r.note
|
|
413
|
+
: n === 0
|
|
414
|
+
? 'no vx config'
|
|
415
|
+
: r.onlyDefault
|
|
416
|
+
? `${count}, default build (no vx config)`
|
|
417
|
+
: `${count} from plugins (no vx config)`
|
|
392
418
|
return [ui.id(r.name), r.dir, tasks, ...(anyTags ? [r.tags.join(', ')] : [])]
|
|
393
419
|
})
|
|
394
420
|
const columns = ['project', 'dir', 'tasks', ...(anyTags ? ['tags'] : [])]
|
|
395
421
|
return [ui.section('Projects', String(rows.length), ui.table(columns, cells))]
|
|
396
422
|
}
|
|
397
423
|
|
|
424
|
+
/**
|
|
425
|
+
* Null for a project the lock holds a config for; else `none`, or a config
|
|
426
|
+
* written after the lock: "no vx config" sent a user who had just written
|
|
427
|
+
* one looking for a typo.
|
|
428
|
+
*/
|
|
429
|
+
function configNote(meta: ProjectMeta): string | null {
|
|
430
|
+
if (meta.configPath !== null) return null
|
|
431
|
+
const file = PROJECT_CONFIG_FILENAMES.find((n) => existsSync(path.join(meta.dir, n)))
|
|
432
|
+
return file === undefined ? 'none' : `${file} not locked — run vx lock`
|
|
433
|
+
}
|
|
434
|
+
|
|
398
435
|
function renderProject(
|
|
399
436
|
name: string,
|
|
400
437
|
dir: string,
|
|
401
438
|
config: ProjectConfig | null,
|
|
402
|
-
|
|
439
|
+
note: string | null,
|
|
403
440
|
format: 'pretty' | 'json',
|
|
404
441
|
): Shown {
|
|
405
442
|
if (format === 'json') {
|
|
@@ -414,11 +451,16 @@ function renderProject(
|
|
|
414
451
|
['dir', dir],
|
|
415
452
|
...(tags.length > 0 ? [['tags', tags.join(', ')] as const] : []),
|
|
416
453
|
// A config-less package a dependant reaches has the default `build` (X-9).
|
|
417
|
-
...(
|
|
418
|
-
...(tasks.length === 0 &&
|
|
454
|
+
...(note === null ? [] : [['config', note] as const]),
|
|
455
|
+
...(tasks.length === 0 && note === null ? [['tasks', 'none declared'] as const] : []),
|
|
419
456
|
]
|
|
420
457
|
const blocks = tasks.map(([taskName, task]) =>
|
|
421
|
-
joined([
|
|
458
|
+
joined([
|
|
459
|
+
isDefaultBuild(task)
|
|
460
|
+
? ui.line(' ', ui.id(`${name}#${taskName}`), ui.dim(' default'))
|
|
461
|
+
: ui.text(ui.id(`${name}#${taskName}`)),
|
|
462
|
+
ui.fields(taskRows(task)),
|
|
463
|
+
]),
|
|
422
464
|
)
|
|
423
465
|
return [ui.section(name, ui.fields(head), ...blocks)]
|
|
424
466
|
}
|
|
@@ -507,17 +549,17 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
|
|
|
507
549
|
add('inputs.tasks', list(inputs.tasks))
|
|
508
550
|
add('inputs.runtime', list(inputs.runtime))
|
|
509
551
|
add('inputs.workspaceRuntime', list(inputs.workspaceRuntime))
|
|
552
|
+
add('inputs.stability.strategy', inputs.stability?.strategy)
|
|
553
|
+
add('inputs.stability.severity', inputs.stability?.severity)
|
|
510
554
|
}
|
|
511
|
-
const
|
|
512
|
-
if (
|
|
513
|
-
add('outputs.files', list(
|
|
514
|
-
add('outputs.workspaceFiles', list(
|
|
515
|
-
add(
|
|
516
|
-
'outputs.clean',
|
|
517
|
-
cache.outputs.clean === undefined ? undefined : cache.outputs.clean ? 'yes' : 'no',
|
|
518
|
-
)
|
|
519
|
-
add('inputStability.strategy', cache.inputStability?.strategy)
|
|
520
|
-
add('inputStability.severity', cache.inputStability?.severity)
|
|
555
|
+
const outputs = task.outputs
|
|
556
|
+
if (outputs !== undefined) {
|
|
557
|
+
add('outputs.files', list(outputs.files))
|
|
558
|
+
add('outputs.workspaceFiles', list(outputs.workspaceFiles))
|
|
559
|
+
add('outputs.clean', outputs.clean === undefined ? undefined : outputs.clean ? 'yes' : 'no')
|
|
521
560
|
}
|
|
561
|
+
// The first question `vx why` raises ("declares no `cache: true`"), which
|
|
562
|
+
// only --format json answered.
|
|
563
|
+
if (task.cache === true) add('cache', 'yes')
|
|
522
564
|
return rows
|
|
523
565
|
}
|
package/src/cli/task-verb.ts
CHANGED
|
@@ -68,20 +68,25 @@ export async function nxTargetHint(tasks: readonly string[], cwd: string): Promi
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
|
-
* The task names `vx run <task>` can take
|
|
72
|
-
*
|
|
73
|
-
*
|
|
71
|
+
* The task names `vx run <task>` can take in the asked scope (`asked`: the
|
|
72
|
+
* projects `--filter` / `--affected` selected, or `all`), else the cwd
|
|
73
|
+
* project's, else every project's, sorted; null when no workspace loads.
|
|
74
|
+
* What a run with no task and no terminal for the picker names instead of
|
|
75
|
+
* a guess.
|
|
74
76
|
*/
|
|
75
|
-
export async function taskNamesHere(
|
|
77
|
+
export async function taskNamesHere(
|
|
78
|
+
cwd: string,
|
|
79
|
+
asked?: ReadonlySet<string> | 'all',
|
|
80
|
+
): Promise<{ names: string[]; inProject: boolean } | null> {
|
|
76
81
|
const projects = await workspaceProjects(cwd)
|
|
77
82
|
if (projects === null) return null
|
|
78
|
-
const own = await findCwdProject(cwd)
|
|
83
|
+
const own = asked === undefined ? await findCwdProject(cwd) : null
|
|
79
84
|
const names = new Set<string>()
|
|
80
85
|
for (const p of projects.values())
|
|
81
|
-
if (own === null || p.name === own)
|
|
86
|
+
if (typeof asked === 'object' ? asked.has(p.name) : own === null || p.name === own)
|
|
82
87
|
for (const [t, task] of Object.entries(p.config.tasks ?? {}))
|
|
83
88
|
if (!isDefaultBuild(task)) names.add(t)
|
|
84
|
-
return [...names].sort()
|
|
89
|
+
return { names: [...names].sort(), inProject: own !== null }
|
|
85
90
|
}
|
|
86
91
|
|
|
87
92
|
/**
|