@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.
Files changed (186) hide show
  1. package/README.md +39 -50
  2. package/launcher.cjs +9 -2
  3. package/launcher.sh +2 -2
  4. package/package.json +5 -5
  5. package/schemas/cache.json +5 -5
  6. package/schemas/error.json +1 -1
  7. package/schemas/info.json +16 -2
  8. package/schemas/last.json +127 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +19 -3
  12. package/src/bin.ts +54 -13
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +330 -65
  15. package/src/cache/git-inputs.ts +274 -97
  16. package/src/cache/index.ts +7 -2
  17. package/src/cache/inputs.ts +515 -83
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +74 -11
  20. package/src/cache/layered-cache.ts +154 -59
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +44 -14
  23. package/src/cache/plugin-cache.ts +49 -23
  24. package/src/cache/remote-hits.ts +72 -0
  25. package/src/cache/remote-lane.ts +100 -15
  26. package/src/cache/run-history.ts +77 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +42 -6
  29. package/src/cache/tar-stream.ts +5 -0
  30. package/src/cache/umask.ts +1 -2
  31. package/src/cli/cache.ts +58 -27
  32. package/src/cli/completions.ts +12 -5
  33. package/src/cli/core-alias.ts +5 -2
  34. package/src/cli/docs.ts +42 -16
  35. package/src/cli/help.ts +62 -25
  36. package/src/cli/index.ts +79 -45
  37. package/src/cli/info.ts +12 -6
  38. package/src/cli/init.ts +222 -34
  39. package/src/cli/last.ts +443 -47
  40. package/src/cli/lock.ts +197 -52
  41. package/src/cli/plan-format.ts +91 -49
  42. package/src/cli/plugin-commands.ts +160 -11
  43. package/src/cli/plugin-templates.ts +13 -13
  44. package/src/cli/registry.ts +209 -0
  45. package/src/cli/run.ts +111 -41
  46. package/src/cli/select.ts +148 -49
  47. package/src/cli/show.ts +59 -16
  48. package/src/cli/task-verb.ts +16 -6
  49. package/src/cli/upgrade.ts +155 -54
  50. package/src/cli/watch-cycle.ts +10 -5
  51. package/src/cli/watch-filter.ts +71 -3
  52. package/src/cli/watch-fs.ts +22 -6
  53. package/src/cli/watch-judge.ts +48 -6
  54. package/src/cli/watch.ts +163 -42
  55. package/src/cli/why.ts +211 -73
  56. package/src/cli/workspace-config.ts +54 -15
  57. package/src/config.ts +13 -1
  58. package/src/exec/env.ts +7 -2
  59. package/src/exec/executor.ts +262 -15
  60. package/src/exec/index.ts +8 -2
  61. package/src/exec/kill-tree.ts +15 -0
  62. package/src/exec/local-executor.ts +1 -1
  63. package/src/exec/proc-sample.ts +70 -6
  64. package/src/exec/runner.ts +256 -53
  65. package/src/exec/sandbox-binds.ts +7 -3
  66. package/src/exec/sandbox-paths.ts +1 -1
  67. package/src/exec/sandbox-runtime.ts +222 -28
  68. package/src/exec/sandbox-violations.ts +52 -20
  69. package/src/graph/critical-path.ts +79 -0
  70. package/src/graph/default-build.ts +19 -0
  71. package/src/graph/definition.ts +38 -0
  72. package/src/graph/folds.ts +38 -5
  73. package/src/graph/index.ts +4 -1
  74. package/src/graph/priorities.ts +74 -8
  75. package/src/graph/scheduler.ts +326 -65
  76. package/src/graph/task-graph.ts +228 -35
  77. package/src/handoff.ts +97 -9
  78. package/src/history/failure-mode.ts +6 -3
  79. package/src/history/history.ts +268 -50
  80. package/src/history/index.ts +2 -0
  81. package/src/history/metrics.ts +142 -45
  82. package/src/index.ts +42 -10
  83. package/src/orchestrator/admission.ts +10 -6
  84. package/src/orchestrator/affected-tasks.ts +11 -6
  85. package/src/orchestrator/deferred-outputs.ts +2 -0
  86. package/src/orchestrator/doctor.ts +134 -42
  87. package/src/orchestrator/execute-args.ts +6 -0
  88. package/src/orchestrator/execute-task.ts +155 -42
  89. package/src/orchestrator/fingerprint-watch.ts +1 -1
  90. package/src/orchestrator/graph-memo.ts +140 -0
  91. package/src/orchestrator/graph-stage.ts +18 -1
  92. package/src/orchestrator/held-servers.ts +86 -0
  93. package/src/orchestrator/hit-restore.ts +227 -122
  94. package/src/orchestrator/index.ts +20 -3
  95. package/src/orchestrator/local-floor.ts +198 -0
  96. package/src/orchestrator/local-shortcircuit.ts +51 -8
  97. package/src/orchestrator/lockfile-claim.ts +96 -16
  98. package/src/orchestrator/miss-save.ts +52 -12
  99. package/src/orchestrator/options.ts +8 -6
  100. package/src/orchestrator/output-host.ts +43 -14
  101. package/src/orchestrator/output-log.ts +90 -10
  102. package/src/orchestrator/persistent-task.ts +97 -28
  103. package/src/orchestrator/persistent.ts +16 -4
  104. package/src/orchestrator/placement.ts +257 -14
  105. package/src/orchestrator/plan.ts +64 -7
  106. package/src/orchestrator/plugin-host.ts +230 -40
  107. package/src/orchestrator/plugin.ts +150 -37
  108. package/src/orchestrator/prepare.ts +178 -32
  109. package/src/orchestrator/projects.ts +65 -40
  110. package/src/orchestrator/remote-prefetch.ts +119 -25
  111. package/src/orchestrator/run-artifacts.ts +95 -32
  112. package/src/orchestrator/run-context.ts +39 -41
  113. package/src/orchestrator/run-failures.ts +65 -23
  114. package/src/orchestrator/run-id.ts +6 -3
  115. package/src/orchestrator/run-keys.ts +300 -0
  116. package/src/orchestrator/run-live.ts +294 -0
  117. package/src/orchestrator/run-lock.ts +13 -7
  118. package/src/orchestrator/run-records.ts +35 -23
  119. package/src/orchestrator/run-report.ts +3 -3
  120. package/src/orchestrator/run-steps.ts +58 -22
  121. package/src/orchestrator/run-stop.ts +22 -5
  122. package/src/orchestrator/run.ts +409 -79
  123. package/src/orchestrator/sandbox-fix.ts +7 -5
  124. package/src/orchestrator/sandbox-request.ts +78 -22
  125. package/src/orchestrator/shell-verdict.ts +5 -5
  126. package/src/orchestrator/signals.ts +26 -1
  127. package/src/orchestrator/stable-keys.ts +92 -8
  128. package/src/orchestrator/steps.ts +2 -2
  129. package/src/orchestrator/task-artifacts.ts +206 -0
  130. package/src/orchestrator/task-hash.ts +89 -14
  131. package/src/orchestrator/task-log-buffer.ts +72 -36
  132. package/src/orchestrator/task-process.ts +22 -0
  133. package/src/orchestrator/telemetry-host.ts +24 -3
  134. package/src/orchestrator/telemetry.ts +113 -38
  135. package/src/orchestrator/upstream.ts +15 -0
  136. package/src/report/events.ts +32 -13
  137. package/src/report/forecast.ts +36 -35
  138. package/src/report/framed-output.ts +173 -46
  139. package/src/report/index.ts +1 -0
  140. package/src/report/logger.ts +217 -186
  141. package/src/report/path-links.ts +12 -4
  142. package/src/report/plain-output.ts +13 -5
  143. package/src/report/status-line.ts +94 -46
  144. package/src/report/summary.ts +174 -38
  145. package/src/report/terminal-signals.ts +13 -2
  146. package/src/settings.ts +14 -6
  147. package/src/util/bun-version.ts +4 -1
  148. package/src/util/colors.ts +20 -3
  149. package/src/util/config-frame.ts +41 -4
  150. package/src/{graph → util}/dependency-spec.ts +22 -13
  151. package/src/util/edit-distance.ts +13 -1
  152. package/src/util/env-flag.ts +9 -0
  153. package/src/util/errors.ts +59 -3
  154. package/src/util/essential-env.ts +57 -0
  155. package/src/util/git-ignored.ts +1 -1
  156. package/src/util/hash.ts +35 -0
  157. package/src/util/hide-env.ts +45 -5
  158. package/src/util/index.ts +22 -5
  159. package/src/util/num.ts +8 -0
  160. package/src/util/paths.ts +121 -21
  161. package/src/util/report.ts +6 -1
  162. package/src/util/secret-mask.ts +62 -1
  163. package/src/util/secrets.ts +7 -2
  164. package/src/util/size.ts +11 -3
  165. package/src/util/tail.ts +6 -1
  166. package/src/util/timing.ts +13 -1
  167. package/src/util/ui.ts +65 -4
  168. package/src/util/which.ts +1 -1
  169. package/src/util/write-file.ts +67 -0
  170. package/src/workspace/affected.ts +710 -107
  171. package/src/workspace/bracket-literal.ts +84 -0
  172. package/src/workspace/canonical-dir.ts +37 -0
  173. package/src/workspace/config-bytecode.ts +282 -0
  174. package/src/workspace/config-eval.ts +29 -8
  175. package/src/workspace/config-imports.ts +199 -58
  176. package/src/workspace/config-schema.ts +242 -39
  177. package/src/workspace/filter.ts +37 -8
  178. package/src/workspace/index.ts +11 -2
  179. package/src/workspace/json-data.ts +3 -4
  180. package/src/workspace/lockfile.ts +57 -8
  181. package/src/workspace/migration.ts +72 -49
  182. package/src/workspace/package-graph.ts +5 -4
  183. package/src/workspace/project-loader.ts +314 -47
  184. package/src/workspace/repo-id.ts +1 -1
  185. package/src/workspace/workspace.ts +176 -33
  186. package/src/cli/foreign-flags.ts +0 -727
package/src/cli/select.ts CHANGED
@@ -4,27 +4,30 @@
4
4
  // here goes through the staged load, so the answer is the run's.
5
5
 
6
6
  import path from 'node:path'
7
+ import { settingEnv } from '../settings.js'
7
8
  import fs from 'node:fs'
8
9
  import {
9
10
  affectedChanges,
10
11
  type AffectedChanges,
11
12
  affectedProjects,
12
- refIsHead,
13
+ headBase,
13
14
  applyFilters,
14
15
  buildPackageGraph,
15
16
  configuredBaseBranch,
16
17
  defaultAffectedBase,
18
+ findProjectConfigs,
17
19
  findWorkspaceRoot,
18
20
  type FingerprintClaims,
19
21
  lockKey,
20
22
  NO_LOCK,
21
23
  parseFilter,
24
+ PROJECT_CONFIG_FILENAMES,
22
25
  type ProjectMeta,
23
26
  readLockfile,
24
27
  } from '../workspace/index.js'
25
28
  import type { ProjectConfig } from '../config.js'
26
29
  import type { ProjectEntry } from '../workspace/index.js'
27
- import { parseDependencySpec } from '../graph/index.js'
30
+ import { isDefaultBuild, parseDependencySpec } from '../graph/index.js'
28
31
  import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
29
32
  import {
30
33
  isUserError,
@@ -32,8 +35,10 @@ import {
32
35
  maskedCommand,
33
36
  maskedLine,
34
37
  nearest,
38
+ relPosix,
35
39
  UserError,
36
40
  sayNotice,
41
+ ui,
37
42
  } from '../util/index.js'
38
43
  import {
39
44
  claimedAffected,
@@ -41,7 +46,6 @@ import {
41
46
  fingerprintClaims,
42
47
  gitOfDiscovery,
43
48
  hasHook,
44
- isDefaultBuild,
45
49
  keepDiscoveryGraph,
46
50
  } from '../orchestrator/index.js'
47
51
  import {
@@ -49,6 +53,7 @@ import {
49
53
  discoverCliProjects,
50
54
  loadCliProjects,
51
55
  loadCliWorkspace,
56
+ stageCacheDir,
52
57
  } from './workspace-config.js'
53
58
 
54
59
  /**
@@ -69,10 +74,12 @@ export async function workspaceGlobOwners(
69
74
  loadCliProjects(root, projects, 'all', load),
70
75
  /** The changed nested repositories (`AffectedChanges.nested`). */
71
76
  nested: readonly string[] = [],
77
+ /** `AffectedChanges.dirLinks`. */
78
+ dirLinks: ReadonlySet<string> = new Set(),
72
79
  ): Promise<string[]> {
73
80
  const declaresMatch = (config: ProjectConfig): boolean => {
74
81
  for (const task of Object.values(config.tasks ?? {})) {
75
- if (changed.some((rel) => declaresInput(task, null, rel))) return true
82
+ if (changed.some((rel) => declaresInput(task, null, rel, dirLinks.has(rel)))) return true
76
83
  if (nested.some((dir) => workspaceFilesReachInto(task, dir))) return true
77
84
  }
78
85
  return false
@@ -87,7 +94,7 @@ export async function workspaceGlobOwners(
87
94
  // "nothing affected" and exit 0 without ever reaching the run's own
88
95
  // refusal. The sweep reads each entry from the lock: it never evaluates.
89
96
  const lock = await readLockfile(root)
90
- if (lock === null) throw new UserError(NO_LOCK)
97
+ if (lock === null) throw new UserError(NO_LOCK, 'VX_E_LOCK')
91
98
  const owners: string[] = []
92
99
  for (const meta of projects) {
93
100
  if (meta.configPath === null || meta.configPath === '') continue
@@ -117,19 +124,19 @@ async function workspaceFingerprintClaims(
117
124
  const claims = fingerprintClaims(ws.plugins)
118
125
  const ctx = {
119
126
  workspaceRoot: root,
120
- cacheDir: load.cacheDir ?? ws.cacheDir,
121
- warn: (m: string) => process.stderr.write(`${maskedLine(m)}\n`),
127
+ cacheDir: stageCacheDir(ws, load),
128
+ warn: (m: string) => ui.say('warn', maskedLine(m)),
122
129
  projects: projects.map((p) => ({ name: p.name, dir: p.dir })),
123
130
  }
124
131
  return {
125
132
  files: new Set(claims.keys()),
126
133
  affected: (change) => claimedAffected(claims.get(change.file)!, change, ctx),
127
- extraFiles: (file, bytes) => claimedExtraFiles(claims.get(file)!, file, bytes),
134
+ extraFiles: (file, bytes) => claimedExtraFiles(claims.get(file)!, file, bytes, ctx),
128
135
  }
129
136
  }
130
137
 
131
- async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
132
- return await discoverCliProjects(await findWorkspaceRoot(cwd))
138
+ async function loadWorkspaceProjects(cwd: string, load: CliLoadOptions): Promise<ProjectMeta[]> {
139
+ return await discoverCliProjects(await findWorkspaceRoot(cwd), load)
133
140
  }
134
141
 
135
142
  export async function findCwdProject(cwd: string): Promise<string | null> {
@@ -143,19 +150,17 @@ export async function findCwdProject(cwd: string): Promise<string | null> {
143
150
  */
144
151
  export async function findCwdSelection(
145
152
  cwd: string,
153
+ load: CliLoadOptions = {},
146
154
  ): Promise<{ name: string; discovered: { root: string; projects: ProjectMeta[] } } | null> {
147
155
  const root = await findWorkspaceRoot(cwd)
148
- const projects = await discoverCliProjects(root)
156
+ const projects = await discoverCliProjects(root, load)
149
157
  const abs = path.resolve(cwd)
150
- const within = (dirOf: (p: ProjectMeta) => string): string | null => {
151
- let best: string | null = null
152
- let bestLength = -1
158
+ const within = (dirOf: (p: ProjectMeta) => string): { name: string; dir: string } | null => {
159
+ let best: { name: string; dir: string } | null = null
153
160
  for (const p of projects) {
154
161
  const dir = dirOf(p)
155
- if ((abs === dir || abs.startsWith(dir + path.sep)) && dir.length > bestLength) {
156
- best = p.name
157
- bestLength = dir.length
158
- }
162
+ if ((abs === dir || abs.startsWith(dir + path.sep)) && dir.length > (best?.dir.length ?? -1))
163
+ best = { name: p.name, dir }
159
164
  }
160
165
  return best
161
166
  }
@@ -164,7 +169,7 @@ export async function findCwdSelection(
164
169
  // so inside such a member nothing matched and a run from there was "not
165
170
  // inside a project" (item 1025). Only then are the members realpathed:
166
171
  // one syscall each, paid only by a run no plain match could place.
167
- const name =
172
+ const found =
168
173
  within((p) => p.dir) ??
169
174
  within((p) => {
170
175
  try {
@@ -173,7 +178,45 @@ export async function findCwdSelection(
173
178
  return p.dir
174
179
  }
175
180
  })
176
- return name === null ? null : { name, discovered: { root, projects } }
181
+ await refuseUnlockedConfig(root, abs, found?.dir ?? null)
182
+ return found === null ? null : { name: found.name, discovered: { root, projects } }
183
+ }
184
+
185
+ /**
186
+ * A config `vx lock` would find between the cwd and the project placing it
187
+ * (the root, when none does): a config written after the lock is no project
188
+ * yet, so a run from its directory ran the locked project above it (the
189
+ * root's `build` for a new member) or said "not inside a project". One
190
+ * readdir per directory between them, none from a project's own; the
191
+ * listing that judges a config found is paid only when one is.
192
+ */
193
+ async function refuseUnlockedConfig(
194
+ root: string,
195
+ abs: string,
196
+ placed: string | null,
197
+ ): Promise<void> {
198
+ const top = placed ?? root
199
+ if (abs !== top && !abs.startsWith(top + path.sep)) return
200
+ for (let dir = abs; ; dir = path.dirname(dir)) {
201
+ if (dir === placed) return
202
+ let names: string[]
203
+ try {
204
+ names = fs.readdirSync(dir)
205
+ } catch {
206
+ names = []
207
+ }
208
+ const file = PROJECT_CONFIG_FILENAMES.find((n) => names.includes(n))
209
+ if (file !== undefined) {
210
+ const { configs } = await findProjectConfigs(root)
211
+ const config = configs.find((c) => c.dir === dir)
212
+ if (config !== undefined) {
213
+ throw new UserError(
214
+ `${relPosix(root, config.configPath)} is not in the lock yet — run \`vx lock\``,
215
+ )
216
+ }
217
+ }
218
+ if (dir === root || dir === path.dirname(dir)) return
219
+ }
177
220
  }
178
221
 
179
222
  export type FilterResolution =
@@ -218,7 +261,7 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
218
261
  for (const raw of task.dependsOn ?? []) {
219
262
  let spec
220
263
  try {
221
- spec = parseDependencySpec(raw)
264
+ spec = parseDependencySpec(raw, 'dependsOn')
222
265
  } catch {
223
266
  continue
224
267
  }
@@ -244,18 +287,24 @@ function refusalOf(err: unknown): Refusal {
244
287
 
245
288
  /**
246
289
  * The filter `--affected[=<base>]` stands for: `...[<base>]`, the changed
247
- * projects and their dependents. An empty base is a plugin's (`setBranch`:
248
- * a pull request's base commit), else the merge base with `baseBranch`.
290
+ * projects and their dependents. An empty base is `VX_AFFECTED_BASE`'s,
291
+ * else a plugin's (`setBranch`: a pull request's base commit), else the
292
+ * merge base with `baseBranch`.
249
293
  */
250
- export async function affectedFilterFor(cwd: string, affected: string): Promise<string | Refusal> {
294
+ export async function affectedFilterFor(
295
+ cwd: string,
296
+ affected: string,
297
+ /** What the verb does instead on the base branch (`defaultAffectedBase`). */
298
+ instead?: string,
299
+ ): Promise<string | Refusal> {
251
300
  const root = await findWorkspaceRoot(cwd)
252
- let base = affected
301
+ let base = affected || ((settingEnv('affectedBase') as string | undefined) ?? '')
253
302
  if (base === '') {
254
303
  const ws = await loadCliWorkspace(root)
255
304
  try {
256
305
  base =
257
306
  ws.branch.base ??
258
- (await defaultAffectedBase(root, configuredBaseBranch(ws.workspaceConfig)))
307
+ (await defaultAffectedBase(root, configuredBaseBranch(ws.workspaceConfig), instead))
259
308
  } catch (err) {
260
309
  if (!isUserError(err)) throw err
261
310
  return refusalOf(err)
@@ -270,9 +319,11 @@ export async function resolveFilters(
270
319
  load: CliLoadOptions = {},
271
320
  /** The raw filter `--affected` became: its diff is kept per path for the run. */
272
321
  affected?: string,
322
+ /** The verb a warning names: `vx show` and `vx watch` select as a run does. */
323
+ verb: 'run' | 'show' | 'watch' = 'run',
273
324
  ): Promise<FilterResolution> {
274
325
  const root = await findWorkspaceRoot(cwd)
275
- const projects = await loadWorkspaceProjects(cwd)
326
+ const projects = await loadWorkspaceProjects(cwd, load)
276
327
  let parsed: ReturnType<typeof parseFilter>[]
277
328
  try {
278
329
  parsed = raw.map((r) => parseFilter(r, root))
@@ -330,8 +381,11 @@ export async function resolveFilters(
330
381
  workspaceRoot: root,
331
382
  since: f.gitSince,
332
383
  projects,
333
- workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
334
- workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested),
384
+ workspaceGlobOwners: (
385
+ changed: readonly string[],
386
+ nested: readonly string[],
387
+ dirLinks: ReadonlySet<string>,
388
+ ) => workspaceGlobOwners(root, projects, changed, load, stagedOnce, nested, dirLinks),
335
389
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
336
390
  ...(git !== undefined
337
391
  ? {
@@ -344,6 +398,10 @@ export async function resolveFilters(
344
398
  for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
345
399
  return out
346
400
  },
401
+ flagged: async () => {
402
+ const g = await git.start()
403
+ return g.scopes.includes('.') ? g.flagged : null
404
+ },
347
405
  }
348
406
  : {}),
349
407
  }
@@ -383,20 +441,21 @@ export async function resolveFilters(
383
441
  ...(tags !== undefined ? { tags } : {}),
384
442
  // A `[<since>]` selector matching nothing is the ordinary "nothing
385
443
  // changed" outcome, reported below; only a name/path pattern that
386
- // matched nothing is worth flagging as a probable typo.
387
- onNoMatch: (f) => {
388
- if (f.gitSince === undefined) unmatched.push(f.raw)
389
- else emptyDiffs.add(f)
444
+ // matched no project in the workspace, ref or not, is a probable typo.
445
+ onNoMatch: (f, emptyDiff) => {
446
+ if (emptyDiff) emptyDiffs.add(f)
447
+ else unmatched.push(f.raw)
390
448
  },
391
449
  // A pattern that matched, with nothing on the walk it asked for: the
392
450
  // fact to say, where "no projects matched" read as a typo (item 1030).
393
451
  onEmptyWalk: (f, matched) => {
394
452
  if (f.negate) return
395
453
  const which = matched.join(', ')
454
+ const several = matched.length > 1
396
455
  emptyWalks.push(
397
456
  f.onlyDeps
398
- ? `filter "${f.raw}" matched ${which}, which depends on no project`
399
- : `filter "${f.raw}" matched ${which}, and no project depends on it`,
457
+ ? `filter "${f.raw}" matched ${which}, which ${several ? 'depend' : 'depends'} on no project`
458
+ : `filter "${f.raw}" matched ${which}, and no project depends on ${several ? 'them' : 'it'}`,
400
459
  )
401
460
  },
402
461
  })
@@ -408,18 +467,23 @@ export async function resolveFilters(
408
467
  // Every pattern matched and an exclusion took them all back: "no
409
468
  // projects matched filter(s): one, !one" read as a typo of `one`.
410
469
  const negations = parsed.filter((f) => f.negate).map((f) => f.raw)
411
- const excluded = `${negations.join(', ')} excluded every project the other filters matched`
412
- if (includes.length > 0 && includes.every((f) => f.gitSince !== undefined)) {
470
+ // With no include the base is every project: there is no "other filter".
471
+ const excluded = `${negations.join(', ')} excluded every project${includes.length > 0 ? ' the other filters matched' : ''}`
472
+ if (
473
+ unmatched.length === 0 &&
474
+ includes.length > 0 &&
475
+ includes.every((f) => f.gitSince !== undefined)
476
+ ) {
413
477
  if (includes.every((f) => emptyDiffs.has(f))) {
414
- const refs = includes.map((f) => f.gitSince).join(', ')
478
+ const refs = [...new Set(includes.map((f) => f.gitSince))].join(', ')
415
479
  // A base that IS HEAD (a single-branch clone whose `origin/HEAD` is
416
480
  // the branch under test) can never mark anything affected, and this
417
481
  // exit-0 note was the only sign (CI persona, 2026-09-16). Say so.
418
- const self = includes.find((f) => refIsHead(root, f.gitSince!))
482
+ const self = includes.map((f) => headBase(root, f.gitSince!)).find((b) => b !== undefined)
419
483
  const hint =
420
484
  self === undefined
421
485
  ? ''
422
- : ` — ${self.gitSince} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
486
+ : ` — ${self} is HEAD itself: compare with the branch you merge into (--affected=origin/main) or the previous commit (--affected=HEAD~1)`
423
487
  return { empty: `nothing affected since ${refs}${hint}` }
424
488
  }
425
489
  // The diff matched and a walk or an exclusion left nothing: still the
@@ -437,18 +501,19 @@ export async function resolveFilters(
437
501
  }
438
502
  // One line, not a warning per pattern and then an error saying the same:
439
503
  // the patterns are in the error, and the nearest project name is the
440
- // hint a typo needs.
441
- return {
442
- error: `no projects matched filter(s): ${raw.join(', ')}${didYouMean(unmatched, projects, tags)}`,
443
- }
504
+ // hint a typo needs. Only the ones that matched nothing: `app !app apq` named
505
+ // app, and `--affected` a `...[<base>]` nobody typed.
506
+ const hint =
507
+ (await unlockedHint(root, parsed, unmatched)) ?? didYouMean(unmatched, projects, tags)
508
+ return { error: `no projects matched filter(s): ${unmatched.join(', ')}${hint}` }
444
509
  }
445
510
  // Something matched, so the run proceeds; a pattern that matched nothing
446
511
  // alongside it is still worth a line — it is probably a typo.
447
512
  for (const f of unmatched)
448
513
  sayNotice(
449
514
  'warn',
450
- 'vx run',
451
- `filter "${f}" matched no projects${didYouMean([f], projects, tags)}`,
515
+ `vx ${verb}`,
516
+ `filter "${f}" matched no projects${(await unlockedHint(root, parsed, [f])) ?? didYouMean([f], projects, tags)}`,
452
517
  )
453
518
  // Each include is a union: what `--filter other` selected runs its tasks
454
519
  // whether or not the `--affected` diff reaches them, and an exclude
@@ -502,7 +567,7 @@ export async function pickTask(
502
567
  /** The projects `--filter` / `--affected` selected; every project when absent. */
503
568
  only?: ReadonlySet<string>,
504
569
  ): Promise<PickedTask | null | 'interrupted'> {
505
- const projects = await loadWorkspaceProjects(cwd)
570
+ const projects = await loadWorkspaceProjects(cwd, load)
506
571
  // The staged load: a task a `project` plugin gave a config-less package
507
572
  // is on the menu, as it is in a run.
508
573
  const staged = await loadCliProjects(await findWorkspaceRoot(cwd), projects, 'all', load)
@@ -597,13 +662,42 @@ export async function pickTask(
597
662
  }
598
663
  }
599
664
 
665
+ /**
666
+ * `. packages/c/vx.config.ts is not in the lock yet — run \`vx lock\``
667
+ * for an unmatched `./dir` filter naming a directory `vx lock` would make
668
+ * a project: a config written after the lock is none yet, and "Projects:
669
+ * …" without it read as a typo of the path. Asked on the refusal's path only.
670
+ */
671
+ async function unlockedHint(
672
+ root: string,
673
+ parsed: readonly ReturnType<typeof parseFilter>[],
674
+ unmatched: readonly string[],
675
+ ): Promise<string | undefined> {
676
+ const dirs = parsed
677
+ .filter((f) => f.isPath && f.pathGlob === undefined && unmatched.includes(f.raw))
678
+ .map((f) => f.matcher)
679
+ if (dirs.length === 0) return undefined
680
+ const { configs } = await findProjectConfigs(root)
681
+ const config = configs.find((c) => dirs.includes(c.dir))
682
+ if (config === undefined) return undefined
683
+ return `. ${relPosix(root, config.configPath)} is not in the lock yet — run \`vx lock\``
684
+ }
685
+
686
+ /** A pattern as a name: its `[<since>]` and graph operators aside. */
687
+ // `^\.\.\.` anchored in one alternation with `!` kept `!...apq`'s dots.
688
+ const typedName = (pattern: string): string =>
689
+ pattern
690
+ .replace(/\[[^\]]*\]$/, '')
691
+ .replace(/^!/, '')
692
+ .replace(/^\.\.\.|\.\.\.$|\^/g, '')
693
+
600
694
  /** The hint for the first unmatched pattern: a near tag for a `tag:` one, else a near project name. */
601
695
  function didYouMean(
602
696
  unmatched: readonly string[],
603
697
  projects: Iterable<{ name: string }>,
604
698
  tags: ReadonlyMap<string, readonly string[]> | undefined,
605
699
  ): string {
606
- const first = unmatched[0]?.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
700
+ const first = unmatched[0] === undefined ? undefined : typedName(unmatched[0])
607
701
  if (first?.startsWith('tag:') !== true) return didYouMeanProject(unmatched, projects)
608
702
  const known = [...new Set([...(tags?.values() ?? [])].flat())].sort()
609
703
  if (known.length === 0) return '. No project declares tags'
@@ -625,11 +719,16 @@ function didYouMeanProject(
625
719
  if (bare !== n) byBare.set(bare, [...(byBare.get(bare) ?? []), n])
626
720
  }
627
721
  for (const pattern of unmatched) {
628
- const typed = pattern.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
722
+ const typed = typedName(pattern)
629
723
  // Two edits from any two-letter name: `--filter //` hinted `ui` (X-13).
630
724
  if (typed === '//') {
631
725
  return '. `//` is the workspace root, which is no project here: add a vx.config there and run `vx lock`'
632
726
  }
727
+ // Nx's `--projects=a,b`: the edit distance hinted the first name alone.
728
+ const parts = pattern.split(',').filter((p) => p !== '')
729
+ if (parts.length > 1) {
730
+ return `. A filter is one pattern: ${parts.map((p) => `--filter ${p}`).join(' ')}`
731
+ }
633
732
  const best = nearest(typed, names)
634
733
  if (best !== undefined) return `. Did you mean ${best}?`
635
734
  const bare = byBare.has(typed) ? typed : nearest(typed, byBare.keys())
package/src/cli/show.ts CHANGED
@@ -9,7 +9,8 @@
9
9
  import path from 'node:path'
10
10
  import type { ProjectConfig, TaskConfig } from '../config.js'
11
11
  import { declaredTask } from '../graph/index.js'
12
- import { affectedTaskProjects, isDefaultBuild } from '../orchestrator/index.js'
12
+ import { isDefaultBuild } from '../graph/index.js'
13
+ import { affectedTaskProjects } from '../orchestrator/index.js'
13
14
  import { flagHint, formatValue, refuse, seeHelp } from './help.js'
14
15
  import {
15
16
  type ColorSupport,
@@ -18,6 +19,7 @@ import {
18
19
  nearMatches,
19
20
  relPosix,
20
21
  secretMask,
22
+ type SecretMask,
21
23
  UserError,
22
24
  sayNotice,
23
25
  ui,
@@ -32,6 +34,7 @@ import {
32
34
  import {
33
35
  type AffectedChanges,
34
36
  buildPackageGraph,
37
+ EMPTY_LOCK,
35
38
  findWorkspaceRoot,
36
39
  type ProjectEntry,
37
40
  type ProjectMeta,
@@ -55,17 +58,16 @@ export function parseShowArgs(args: readonly string[]): ShowArgs {
55
58
  let format: string | undefined
56
59
  if (a === '--format') format = args[++i] ?? ''
57
60
  else if (a.startsWith('--format=')) format = a.slice('--format='.length)
58
- else if (a === '--filter') {
59
- const v = args[++i]
61
+ else if (a === '--filter' || a.startsWith('--filter=')) {
62
+ const v = a === '--filter' ? args[++i] : a.slice('--filter='.length)
60
63
  // `--filter --format json` took `--format` as the pattern; no filter opens with `--`.
61
- if (v === undefined || v === '' || v.startsWith('--'))
64
+ if (v === undefined || v === '' || (a === '--filter' && v.startsWith('--')))
62
65
  return {
63
66
  ...out,
64
67
  error: `--filter requires a value: a project name, glob or path${seeHelp('show')}`,
65
68
  }
66
69
  out.filters.push(v)
67
- } else if (a.startsWith('--filter=')) out.filters.push(a.slice('--filter='.length))
68
- else if (a === '--affected') out.affected = ''
70
+ } else if (a === '--affected') out.affected = ''
69
71
  else if (a.startsWith('--affected=')) out.affected = a.slice('--affected='.length)
70
72
  else if (a.startsWith('-'))
71
73
  return { ...out, error: `unknown flag: ${a}${flagHint('show', a)}${seeHelp('show')}` }
@@ -116,6 +118,12 @@ export async function showCmd(args: readonly string[]): Promise<number> {
116
118
  ? parsed.target
117
119
  : parsed.target.slice(0, hashAt)
118
120
  const taskName = hashAt === -1 ? undefined : parsed.target!.slice(hashAt + 1)
121
+ if (projectName === '' && taskName !== undefined && taskName !== '') {
122
+ throw new UserError(
123
+ `vx show: missing project name before '#' in "${parsed.target}" (vx show ${taskName} shows it in every project)`,
124
+ 'VX_E_USAGE',
125
+ )
126
+ }
119
127
  if (taskName === '') {
120
128
  const list = projectName === '' ? 'vx show' : `vx show ${projectName}`
121
129
  throw new UserError(
@@ -152,12 +160,16 @@ export async function showCmd(args: readonly string[]): Promise<number> {
152
160
  const raw = [...parsed.filters]
153
161
  let affected: string | undefined
154
162
  if (parsed.affected !== undefined) {
155
- const f = await affectedFilterFor(process.cwd(), parsed.affected)
163
+ const f = await affectedFilterFor(
164
+ process.cwd(),
165
+ parsed.affected,
166
+ 'drop --affected to list them all',
167
+ )
156
168
  if (typeof f === 'object') throw new UserError(`vx show: ${f.error}`, f.code ?? 'VX_E_USAGE')
157
169
  affected = f
158
170
  raw.unshift(f)
159
171
  }
160
- const r = await resolveFilters(process.cwd(), raw, {}, affected)
172
+ const r = await resolveFilters(process.cwd(), raw, {}, affected, 'show')
161
173
  if ('error' in r) throw new UserError(`vx show: ${r.error}`, r.code ?? 'VX_E_USAGE')
162
174
  if ('empty' in r) sayNotice('info', 'vx show', r.empty)
163
175
  selected = new Set('empty' in r ? [] : r.names)
@@ -173,7 +185,7 @@ export async function showCmd(args: readonly string[]): Promise<number> {
173
185
  sayNotice(
174
186
  'info',
175
187
  'vx show',
176
- 'no projects: no directory holds a vx.config — run `vx init` to write them, then `vx lock`',
188
+ `no projects: ${EMPTY_LOCK.charAt(0).toLowerCase()}${EMPTY_LOCK.slice(1)}`,
177
189
  )
178
190
  const shown = metas.filter((m) => inSelection(m.name))
179
191
  process.stdout.write(renderList(root, shown, projects, parsed.format, colorsOut()))
@@ -220,6 +232,17 @@ export async function showCmd(args: readonly string[]): Promise<number> {
220
232
  )
221
233
  shown = shown.filter((p) => kept.has(p.name))
222
234
  }
235
+ // An empty list printed nothing and exited 0, where the run says why.
236
+ if (shown.length === 0 && parsed.format === 'pretty') {
237
+ const n = declaring.length
238
+ sayNotice(
239
+ 'info',
240
+ 'vx show',
241
+ byDiff !== undefined
242
+ ? `no affected project's ${projectName} runs (${n} declare${n === 1 ? 's' : ''} it)`
243
+ : `no selected project declares ${projectName} (${n} outside the selection ${n === 1 ? 'does' : 'do'})`,
244
+ )
245
+ }
223
246
  process.stdout.write(renderTaskAcross(root, shown, projectName!, parsed.format, colorsOut()))
224
247
  return 0
225
248
  }
@@ -280,10 +303,26 @@ function projectDir(root: string, meta: ProjectMeta): string {
280
303
  * description) (L-11).
281
304
  */
282
305
  function shownTask(task: TaskConfig): TaskConfig {
283
- const secrets = secretMask([process.env, task.exec?.env?.define], task.exec?.env?.secret)
306
+ const env = task.exec?.env
307
+ const secrets =
308
+ env?.define === undefined && (env?.secret ?? []).length === 0
309
+ ? envMask()
310
+ : secretMask([process.env, env?.define], env?.secret)
284
311
  return secrets === null ? task : (maskStrings(task, secrets.mask) as TaskConfig)
285
312
  }
286
313
 
314
+ let envOnly: SecretMask | null | undefined
315
+
316
+ /**
317
+ * The mask of a task that defines and names no variable: vx's own env's,
318
+ * built once. Built per task, its pattern was 10 of `vx show build`'s
319
+ * 58 ms over 100 projects.
320
+ */
321
+ function envMask(): SecretMask | null {
322
+ if (envOnly === undefined) envOnly = secretMask([process.env])
323
+ return envOnly
324
+ }
325
+
287
326
  function maskStrings(value: unknown, mask: (text: string) => string): unknown {
288
327
  if (typeof value === 'string') return mask(value)
289
328
  if (Array.isArray(value)) return value.map((v) => maskStrings(v, mask))
@@ -327,11 +366,7 @@ function renderList(
327
366
  }
328
367
  })
329
368
  if (format === 'json') {
330
- return `${JSON.stringify(
331
- rows.map(({ name, dir, tags, tasks }) => ({ name, dir, tags, tasks })),
332
- null,
333
- 2,
334
- )}\n`
369
+ return `${JSON.stringify(rows.map(({ name, dir, tags, tasks }) => ({ name, dir, tags, tasks })))}\n`
335
370
  }
336
371
  if (rows.length === 0) return ''
337
372
  const anyTags = rows.some((r) => r.tags.length > 0)
@@ -435,8 +470,9 @@ function taskReport(name: string, dir: string, taskName: string, task: TaskConfi
435
470
  function taskRows(raw: TaskConfig): ui.FieldRow[] {
436
471
  const task = shownTask(raw)
437
472
  const rows: ui.FieldRow[] = []
473
+ // An empty list is a value: a bare label read as a cut-off line.
438
474
  const list = (xs: readonly string[] | undefined): string | undefined =>
439
- xs === undefined ? undefined : xs.join(', ')
475
+ xs === undefined ? undefined : xs.length === 0 ? 'none' : xs.join(', ')
440
476
  const add = (label: string, value: string | number | boolean | undefined): void => {
441
477
  if (value !== undefined) rows.push([label, String(value)])
442
478
  }
@@ -457,6 +493,7 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
457
493
  )
458
494
  add('env.secret', list(exec?.env?.secret))
459
495
  add('remote', exec?.remote === undefined ? undefined : String(exec.remote))
496
+ add('platforms', list(exec?.platforms))
460
497
  add('sandbox', exec?.sandbox === undefined ? undefined : JSON.stringify(exec.sandbox))
461
498
  const persistent = exec?.persistent
462
499
  if (persistent !== undefined) {
@@ -479,6 +516,12 @@ function taskRows(raw: TaskConfig): ui.FieldRow[] {
479
516
  if (cache !== undefined) {
480
517
  add('outputs.files', list(cache.outputs.files))
481
518
  add('outputs.workspaceFiles', list(cache.outputs.workspaceFiles))
519
+ add(
520
+ 'outputs.clean',
521
+ cache.outputs.clean === undefined ? undefined : cache.outputs.clean ? 'yes' : 'no',
522
+ )
523
+ add('inputStability.strategy', cache.inputStability?.strategy)
524
+ add('inputStability.severity', cache.inputStability?.severity)
482
525
  }
483
526
  return rows
484
527
  }
@@ -4,7 +4,9 @@
4
4
  // does it. It stays a refusal: a verb that runs whatever task shares its
5
5
  // name would change meaning the day a plugin declares that verb.
6
6
 
7
- import { isDefaultBuild, nxProjectTarget, projectNamed } from '../orchestrator/index.js'
7
+ import type { TaskConfig } from '../config.js'
8
+ import { isDefaultBuild } from '../graph/index.js'
9
+ import { nxProjectTarget, projectNamed } from '../orchestrator/index.js'
8
10
  import { findWorkspaceRoot } from '../workspace/index.js'
9
11
  import { nearest } from '../util/index.js'
10
12
  import { findCwdProject } from './select.js'
@@ -26,19 +28,27 @@ export async function taskVerbHint(
26
28
  if (projects === null) return null
27
29
  const tasks = new Set<string>()
28
30
  // The default build is no task `vx run` takes by name (X-145).
31
+ const declared = (task: TaskConfig | undefined): boolean =>
32
+ task !== undefined && !isDefaultBuild(task)
29
33
  for (const p of projects.values())
30
- for (const [t, task] of Object.entries(p.config.tasks ?? {}))
31
- if (!isDefaultBuild(task)) tasks.add(t)
34
+ for (const [t, task] of Object.entries(p.config.tasks ?? {})) if (declared(task)) tasks.add(t)
32
35
  const task = tasks.has(command) ? command : typo ? nearest(command, tasks) : undefined
33
36
  if (task === undefined) return null
34
37
  // Nx's `nx build app`: the word after the target is its project.
35
38
  const project = rest[0] === undefined ? undefined : projectNamed(rest[0], projects)
39
+ // A bare `vx run <task>` runs the cwd project's alone, and refuses where
40
+ // that project does not declare it.
41
+ const declaresHere = async (): Promise<boolean> => {
42
+ const here = await findCwdProject(cwd)
43
+ const own = here === null ? undefined : projects.get(here)?.config.tasks
44
+ return own !== undefined && Object.hasOwn(own, task) && declared(own[task])
45
+ }
36
46
  const run =
37
47
  project !== undefined
38
48
  ? `vx run ${task} --filter ${project}`
39
- : (await findCwdProject(cwd)) === null
40
- ? `vx run ${task} --all`
41
- : `vx run ${task}`
49
+ : (await declaresHere())
50
+ ? `vx run ${task}`
51
+ : `vx run ${task} --all`
42
52
  return task === command
43
53
  ? `\`${command}\` is a task here, not a command (did you mean ${run}?)`
44
54
  : `unknown command: ${command}, a typo of the task \`${task}\` (did you mean ${run}?)`