@vzn/vx 0.0.646 → 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 (184) hide show
  1. package/README.md +29 -22
  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 +112 -5
  9. package/schemas/plan.json +28 -2
  10. package/schemas/show.json +10 -1
  11. package/skills/vx/SKILL.md +18 -2
  12. package/src/bin.ts +53 -12
  13. package/src/cache/archive.ts +324 -23
  14. package/src/cache/cache.ts +268 -53
  15. package/src/cache/git-inputs.ts +219 -87
  16. package/src/cache/index.ts +3 -2
  17. package/src/cache/inputs.ts +460 -78
  18. package/src/cache/key-fold.ts +15 -6
  19. package/src/cache/layer.ts +49 -9
  20. package/src/cache/layered-cache.ts +123 -58
  21. package/src/cache/linked-inputs.ts +1 -1
  22. package/src/cache/output-index.ts +19 -3
  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 +90 -14
  26. package/src/cache/run-history.ts +70 -23
  27. package/src/cache/run-store.ts +11 -10
  28. package/src/cache/schema.ts +25 -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/docs.ts +42 -16
  34. package/src/cli/help.ts +58 -19
  35. package/src/cli/index.ts +79 -33
  36. package/src/cli/info.ts +12 -6
  37. package/src/cli/init.ts +186 -31
  38. package/src/cli/last.ts +439 -45
  39. package/src/cli/lock.ts +197 -52
  40. package/src/cli/plan-format.ts +91 -49
  41. package/src/cli/plugin-commands.ts +70 -14
  42. package/src/cli/plugin-templates.ts +13 -13
  43. package/src/cli/registry.ts +209 -0
  44. package/src/cli/run.ts +109 -34
  45. package/src/cli/select.ts +143 -46
  46. package/src/cli/show.ts +59 -16
  47. package/src/cli/task-verb.ts +16 -6
  48. package/src/cli/upgrade.ts +151 -49
  49. package/src/cli/watch-cycle.ts +10 -5
  50. package/src/cli/watch-filter.ts +71 -3
  51. package/src/cli/watch-fs.ts +22 -6
  52. package/src/cli/watch-judge.ts +48 -6
  53. package/src/cli/watch.ts +163 -42
  54. package/src/cli/why.ts +211 -73
  55. package/src/cli/workspace-config.ts +54 -15
  56. package/src/config.ts +13 -1
  57. package/src/exec/env.ts +7 -2
  58. package/src/exec/executor.ts +154 -10
  59. package/src/exec/index.ts +6 -2
  60. package/src/exec/kill-tree.ts +15 -0
  61. package/src/exec/local-executor.ts +1 -1
  62. package/src/exec/proc-sample.ts +3 -1
  63. package/src/exec/runner.ts +253 -54
  64. package/src/exec/sandbox-binds.ts +7 -3
  65. package/src/exec/sandbox-paths.ts +1 -1
  66. package/src/exec/sandbox-runtime.ts +211 -24
  67. package/src/exec/sandbox-violations.ts +52 -20
  68. package/src/graph/critical-path.ts +79 -0
  69. package/src/graph/default-build.ts +19 -0
  70. package/src/graph/definition.ts +38 -0
  71. package/src/graph/folds.ts +38 -5
  72. package/src/graph/index.ts +4 -1
  73. package/src/graph/priorities.ts +74 -8
  74. package/src/graph/scheduler.ts +270 -60
  75. package/src/graph/task-graph.ts +228 -35
  76. package/src/handoff.ts +97 -9
  77. package/src/history/failure-mode.ts +6 -3
  78. package/src/history/history.ts +167 -61
  79. package/src/history/index.ts +2 -0
  80. package/src/history/metrics.ts +90 -30
  81. package/src/index.ts +30 -13
  82. package/src/orchestrator/admission.ts +10 -6
  83. package/src/orchestrator/affected-tasks.ts +11 -6
  84. package/src/orchestrator/deferred-outputs.ts +2 -0
  85. package/src/orchestrator/doctor.ts +134 -42
  86. package/src/orchestrator/execute-args.ts +4 -1
  87. package/src/orchestrator/execute-task.ts +130 -38
  88. package/src/orchestrator/fingerprint-watch.ts +1 -1
  89. package/src/orchestrator/graph-memo.ts +140 -0
  90. package/src/orchestrator/graph-stage.ts +18 -1
  91. package/src/orchestrator/held-servers.ts +1 -1
  92. package/src/orchestrator/hit-restore.ts +217 -119
  93. package/src/orchestrator/index.ts +4 -3
  94. package/src/orchestrator/local-floor.ts +119 -25
  95. package/src/orchestrator/local-shortcircuit.ts +28 -4
  96. package/src/orchestrator/lockfile-claim.ts +96 -16
  97. package/src/orchestrator/miss-save.ts +42 -8
  98. package/src/orchestrator/options.ts +8 -6
  99. package/src/orchestrator/output-host.ts +43 -14
  100. package/src/orchestrator/output-log.ts +90 -10
  101. package/src/orchestrator/persistent-task.ts +84 -29
  102. package/src/orchestrator/persistent.ts +16 -4
  103. package/src/orchestrator/placement.ts +185 -17
  104. package/src/orchestrator/plan.ts +64 -7
  105. package/src/orchestrator/plugin-host.ts +214 -42
  106. package/src/orchestrator/plugin.ts +113 -37
  107. package/src/orchestrator/prepare.ts +168 -30
  108. package/src/orchestrator/projects.ts +65 -40
  109. package/src/orchestrator/remote-prefetch.ts +119 -25
  110. package/src/orchestrator/run-artifacts.ts +95 -32
  111. package/src/orchestrator/run-context.ts +39 -41
  112. package/src/orchestrator/run-failures.ts +65 -23
  113. package/src/orchestrator/run-id.ts +6 -3
  114. package/src/orchestrator/run-keys.ts +149 -13
  115. package/src/orchestrator/run-live.ts +294 -0
  116. package/src/orchestrator/run-lock.ts +13 -7
  117. package/src/orchestrator/run-records.ts +35 -29
  118. package/src/orchestrator/run-report.ts +3 -3
  119. package/src/orchestrator/run-steps.ts +58 -22
  120. package/src/orchestrator/run-stop.ts +22 -5
  121. package/src/orchestrator/run.ts +331 -88
  122. package/src/orchestrator/sandbox-fix.ts +7 -5
  123. package/src/orchestrator/sandbox-request.ts +70 -20
  124. package/src/orchestrator/shell-verdict.ts +5 -5
  125. package/src/orchestrator/signals.ts +26 -1
  126. package/src/orchestrator/stable-keys.ts +92 -8
  127. package/src/orchestrator/steps.ts +2 -2
  128. package/src/orchestrator/task-artifacts.ts +90 -26
  129. package/src/orchestrator/task-hash.ts +78 -13
  130. package/src/orchestrator/task-log-buffer.ts +72 -36
  131. package/src/orchestrator/task-process.ts +7 -1
  132. package/src/orchestrator/telemetry-host.ts +22 -2
  133. package/src/orchestrator/telemetry.ts +53 -21
  134. package/src/orchestrator/upstream.ts +15 -0
  135. package/src/report/events.ts +32 -13
  136. package/src/report/forecast.ts +31 -9
  137. package/src/report/framed-output.ts +141 -50
  138. package/src/report/index.ts +1 -0
  139. package/src/report/logger.ts +170 -179
  140. package/src/report/path-links.ts +12 -4
  141. package/src/report/plain-output.ts +13 -5
  142. package/src/report/status-line.ts +81 -53
  143. package/src/report/summary.ts +114 -32
  144. package/src/report/terminal-signals.ts +13 -2
  145. package/src/settings.ts +7 -6
  146. package/src/util/bun-version.ts +4 -1
  147. package/src/util/colors.ts +4 -2
  148. package/src/util/config-frame.ts +41 -4
  149. package/src/{graph → util}/dependency-spec.ts +22 -13
  150. package/src/util/edit-distance.ts +13 -1
  151. package/src/util/env-flag.ts +9 -0
  152. package/src/util/errors.ts +59 -3
  153. package/src/util/essential-env.ts +57 -0
  154. package/src/util/git-ignored.ts +1 -1
  155. package/src/util/hash.ts +35 -0
  156. package/src/util/hide-env.ts +45 -5
  157. package/src/util/index.ts +21 -5
  158. package/src/util/num.ts +8 -0
  159. package/src/util/paths.ts +91 -21
  160. package/src/util/report.ts +6 -1
  161. package/src/util/secret-mask.ts +62 -1
  162. package/src/util/secrets.ts +7 -2
  163. package/src/util/size.ts +11 -3
  164. package/src/util/tail.ts +6 -1
  165. package/src/util/timing.ts +13 -1
  166. package/src/util/ui.ts +61 -3
  167. package/src/util/which.ts +1 -1
  168. package/src/util/write-file.ts +67 -0
  169. package/src/workspace/affected.ts +710 -107
  170. package/src/workspace/bracket-literal.ts +84 -0
  171. package/src/workspace/canonical-dir.ts +37 -0
  172. package/src/workspace/config-bytecode.ts +17 -11
  173. package/src/workspace/config-eval.ts +29 -8
  174. package/src/workspace/config-imports.ts +199 -58
  175. package/src/workspace/config-schema.ts +242 -39
  176. package/src/workspace/filter.ts +37 -8
  177. package/src/workspace/index.ts +11 -1
  178. package/src/workspace/json-data.ts +3 -4
  179. package/src/workspace/lockfile.ts +57 -8
  180. package/src/workspace/migration.ts +69 -18
  181. package/src/workspace/package-graph.ts +5 -4
  182. package/src/workspace/project-loader.ts +226 -37
  183. package/src/workspace/repo-id.ts +1 -1
  184. package/src/workspace/workspace.ts +176 -33
package/src/cli/select.ts CHANGED
@@ -10,22 +10,24 @@ import {
10
10
  affectedChanges,
11
11
  type AffectedChanges,
12
12
  affectedProjects,
13
- refIsHead,
13
+ headBase,
14
14
  applyFilters,
15
15
  buildPackageGraph,
16
16
  configuredBaseBranch,
17
17
  defaultAffectedBase,
18
+ findProjectConfigs,
18
19
  findWorkspaceRoot,
19
20
  type FingerprintClaims,
20
21
  lockKey,
21
22
  NO_LOCK,
22
23
  parseFilter,
24
+ PROJECT_CONFIG_FILENAMES,
23
25
  type ProjectMeta,
24
26
  readLockfile,
25
27
  } from '../workspace/index.js'
26
28
  import type { ProjectConfig } from '../config.js'
27
29
  import type { ProjectEntry } from '../workspace/index.js'
28
- import { parseDependencySpec } from '../graph/index.js'
30
+ import { isDefaultBuild, parseDependencySpec } from '../graph/index.js'
29
31
  import { declaresInput, workspaceFilesReachInto } from '../cache/index.js'
30
32
  import {
31
33
  isUserError,
@@ -33,8 +35,10 @@ import {
33
35
  maskedCommand,
34
36
  maskedLine,
35
37
  nearest,
38
+ relPosix,
36
39
  UserError,
37
40
  sayNotice,
41
+ ui,
38
42
  } from '../util/index.js'
39
43
  import {
40
44
  claimedAffected,
@@ -42,7 +46,6 @@ import {
42
46
  fingerprintClaims,
43
47
  gitOfDiscovery,
44
48
  hasHook,
45
- isDefaultBuild,
46
49
  keepDiscoveryGraph,
47
50
  } from '../orchestrator/index.js'
48
51
  import {
@@ -50,6 +53,7 @@ import {
50
53
  discoverCliProjects,
51
54
  loadCliProjects,
52
55
  loadCliWorkspace,
56
+ stageCacheDir,
53
57
  } from './workspace-config.js'
54
58
 
55
59
  /**
@@ -70,10 +74,12 @@ export async function workspaceGlobOwners(
70
74
  loadCliProjects(root, projects, 'all', load),
71
75
  /** The changed nested repositories (`AffectedChanges.nested`). */
72
76
  nested: readonly string[] = [],
77
+ /** `AffectedChanges.dirLinks`. */
78
+ dirLinks: ReadonlySet<string> = new Set(),
73
79
  ): Promise<string[]> {
74
80
  const declaresMatch = (config: ProjectConfig): boolean => {
75
81
  for (const task of Object.values(config.tasks ?? {})) {
76
- if (changed.some((rel) => declaresInput(task, null, rel))) return true
82
+ if (changed.some((rel) => declaresInput(task, null, rel, dirLinks.has(rel)))) return true
77
83
  if (nested.some((dir) => workspaceFilesReachInto(task, dir))) return true
78
84
  }
79
85
  return false
@@ -88,7 +94,7 @@ export async function workspaceGlobOwners(
88
94
  // "nothing affected" and exit 0 without ever reaching the run's own
89
95
  // refusal. The sweep reads each entry from the lock: it never evaluates.
90
96
  const lock = await readLockfile(root)
91
- if (lock === null) throw new UserError(NO_LOCK)
97
+ if (lock === null) throw new UserError(NO_LOCK, 'VX_E_LOCK')
92
98
  const owners: string[] = []
93
99
  for (const meta of projects) {
94
100
  if (meta.configPath === null || meta.configPath === '') continue
@@ -118,19 +124,19 @@ async function workspaceFingerprintClaims(
118
124
  const claims = fingerprintClaims(ws.plugins)
119
125
  const ctx = {
120
126
  workspaceRoot: root,
121
- cacheDir: load.cacheDir ?? ws.cacheDir,
122
- warn: (m: string) => process.stderr.write(`${maskedLine(m)}\n`),
127
+ cacheDir: stageCacheDir(ws, load),
128
+ warn: (m: string) => ui.say('warn', maskedLine(m)),
123
129
  projects: projects.map((p) => ({ name: p.name, dir: p.dir })),
124
130
  }
125
131
  return {
126
132
  files: new Set(claims.keys()),
127
133
  affected: (change) => claimedAffected(claims.get(change.file)!, change, ctx),
128
- extraFiles: (file, bytes) => claimedExtraFiles(claims.get(file)!, file, bytes),
134
+ extraFiles: (file, bytes) => claimedExtraFiles(claims.get(file)!, file, bytes, ctx),
129
135
  }
130
136
  }
131
137
 
132
- async function loadWorkspaceProjects(cwd: string): Promise<ProjectMeta[]> {
133
- return await discoverCliProjects(await findWorkspaceRoot(cwd))
138
+ async function loadWorkspaceProjects(cwd: string, load: CliLoadOptions): Promise<ProjectMeta[]> {
139
+ return await discoverCliProjects(await findWorkspaceRoot(cwd), load)
134
140
  }
135
141
 
136
142
  export async function findCwdProject(cwd: string): Promise<string | null> {
@@ -144,19 +150,17 @@ export async function findCwdProject(cwd: string): Promise<string | null> {
144
150
  */
145
151
  export async function findCwdSelection(
146
152
  cwd: string,
153
+ load: CliLoadOptions = {},
147
154
  ): Promise<{ name: string; discovered: { root: string; projects: ProjectMeta[] } } | null> {
148
155
  const root = await findWorkspaceRoot(cwd)
149
- const projects = await discoverCliProjects(root)
156
+ const projects = await discoverCliProjects(root, load)
150
157
  const abs = path.resolve(cwd)
151
- const within = (dirOf: (p: ProjectMeta) => string): string | null => {
152
- let best: string | null = null
153
- let bestLength = -1
158
+ const within = (dirOf: (p: ProjectMeta) => string): { name: string; dir: string } | null => {
159
+ let best: { name: string; dir: string } | null = null
154
160
  for (const p of projects) {
155
161
  const dir = dirOf(p)
156
- if ((abs === dir || abs.startsWith(dir + path.sep)) && dir.length > bestLength) {
157
- best = p.name
158
- bestLength = dir.length
159
- }
162
+ if ((abs === dir || abs.startsWith(dir + path.sep)) && dir.length > (best?.dir.length ?? -1))
163
+ best = { name: p.name, dir }
160
164
  }
161
165
  return best
162
166
  }
@@ -165,7 +169,7 @@ export async function findCwdSelection(
165
169
  // so inside such a member nothing matched and a run from there was "not
166
170
  // inside a project" (item 1025). Only then are the members realpathed:
167
171
  // one syscall each, paid only by a run no plain match could place.
168
- const name =
172
+ const found =
169
173
  within((p) => p.dir) ??
170
174
  within((p) => {
171
175
  try {
@@ -174,7 +178,45 @@ export async function findCwdSelection(
174
178
  return p.dir
175
179
  }
176
180
  })
177
- 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
+ }
178
220
  }
179
221
 
180
222
  export type FilterResolution =
@@ -219,7 +261,7 @@ export function taskEdgesFrom(staged: ReadonlyMap<string, ProjectEntry>): Map<st
219
261
  for (const raw of task.dependsOn ?? []) {
220
262
  let spec
221
263
  try {
222
- spec = parseDependencySpec(raw)
264
+ spec = parseDependencySpec(raw, 'dependsOn')
223
265
  } catch {
224
266
  continue
225
267
  }
@@ -249,7 +291,12 @@ function refusalOf(err: unknown): Refusal {
249
291
  * else a plugin's (`setBranch`: a pull request's base commit), else the
250
292
  * merge base with `baseBranch`.
251
293
  */
252
- 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> {
253
300
  const root = await findWorkspaceRoot(cwd)
254
301
  let base = affected || ((settingEnv('affectedBase') as string | undefined) ?? '')
255
302
  if (base === '') {
@@ -257,7 +304,7 @@ export async function affectedFilterFor(cwd: string, affected: string): Promise<
257
304
  try {
258
305
  base =
259
306
  ws.branch.base ??
260
- (await defaultAffectedBase(root, configuredBaseBranch(ws.workspaceConfig)))
307
+ (await defaultAffectedBase(root, configuredBaseBranch(ws.workspaceConfig), instead))
261
308
  } catch (err) {
262
309
  if (!isUserError(err)) throw err
263
310
  return refusalOf(err)
@@ -272,9 +319,11 @@ export async function resolveFilters(
272
319
  load: CliLoadOptions = {},
273
320
  /** The raw filter `--affected` became: its diff is kept per path for the run. */
274
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',
275
324
  ): Promise<FilterResolution> {
276
325
  const root = await findWorkspaceRoot(cwd)
277
- const projects = await loadWorkspaceProjects(cwd)
326
+ const projects = await loadWorkspaceProjects(cwd, load)
278
327
  let parsed: ReturnType<typeof parseFilter>[]
279
328
  try {
280
329
  parsed = raw.map((r) => parseFilter(r, root))
@@ -332,8 +381,11 @@ export async function resolveFilters(
332
381
  workspaceRoot: root,
333
382
  since: f.gitSince,
334
383
  projects,
335
- workspaceGlobOwners: (changed: readonly string[], nested: readonly string[]) =>
336
- 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),
337
389
  fingerprintClaims: () => workspaceFingerprintClaims(root, projects, load),
338
390
  ...(git !== undefined
339
391
  ? {
@@ -346,6 +398,10 @@ export async function resolveFilters(
346
398
  for (const [rel, oid] of g.trusted) if (oid.startsWith('120000:')) out.push(rel)
347
399
  return out
348
400
  },
401
+ flagged: async () => {
402
+ const g = await git.start()
403
+ return g.scopes.includes('.') ? g.flagged : null
404
+ },
349
405
  }
350
406
  : {}),
351
407
  }
@@ -385,20 +441,21 @@ export async function resolveFilters(
385
441
  ...(tags !== undefined ? { tags } : {}),
386
442
  // A `[<since>]` selector matching nothing is the ordinary "nothing
387
443
  // changed" outcome, reported below; only a name/path pattern that
388
- // matched nothing is worth flagging as a probable typo.
389
- onNoMatch: (f) => {
390
- if (f.gitSince === undefined) unmatched.push(f.raw)
391
- 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)
392
448
  },
393
449
  // A pattern that matched, with nothing on the walk it asked for: the
394
450
  // fact to say, where "no projects matched" read as a typo (item 1030).
395
451
  onEmptyWalk: (f, matched) => {
396
452
  if (f.negate) return
397
453
  const which = matched.join(', ')
454
+ const several = matched.length > 1
398
455
  emptyWalks.push(
399
456
  f.onlyDeps
400
- ? `filter "${f.raw}" matched ${which}, which depends on no project`
401
- : `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'}`,
402
459
  )
403
460
  },
404
461
  })
@@ -410,18 +467,23 @@ export async function resolveFilters(
410
467
  // Every pattern matched and an exclusion took them all back: "no
411
468
  // projects matched filter(s): one, !one" read as a typo of `one`.
412
469
  const negations = parsed.filter((f) => f.negate).map((f) => f.raw)
413
- const excluded = `${negations.join(', ')} excluded every project the other filters matched`
414
- 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
+ ) {
415
477
  if (includes.every((f) => emptyDiffs.has(f))) {
416
- const refs = includes.map((f) => f.gitSince).join(', ')
478
+ const refs = [...new Set(includes.map((f) => f.gitSince))].join(', ')
417
479
  // A base that IS HEAD (a single-branch clone whose `origin/HEAD` is
418
480
  // the branch under test) can never mark anything affected, and this
419
481
  // exit-0 note was the only sign (CI persona, 2026-09-16). Say so.
420
- const self = includes.find((f) => refIsHead(root, f.gitSince!))
482
+ const self = includes.map((f) => headBase(root, f.gitSince!)).find((b) => b !== undefined)
421
483
  const hint =
422
484
  self === undefined
423
485
  ? ''
424
- : ` — ${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)`
425
487
  return { empty: `nothing affected since ${refs}${hint}` }
426
488
  }
427
489
  // The diff matched and a walk or an exclusion left nothing: still the
@@ -439,18 +501,19 @@ export async function resolveFilters(
439
501
  }
440
502
  // One line, not a warning per pattern and then an error saying the same:
441
503
  // the patterns are in the error, and the nearest project name is the
442
- // hint a typo needs.
443
- return {
444
- error: `no projects matched filter(s): ${raw.join(', ')}${didYouMean(unmatched, projects, tags)}`,
445
- }
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}` }
446
509
  }
447
510
  // Something matched, so the run proceeds; a pattern that matched nothing
448
511
  // alongside it is still worth a line — it is probably a typo.
449
512
  for (const f of unmatched)
450
513
  sayNotice(
451
514
  'warn',
452
- 'vx run',
453
- `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)}`,
454
517
  )
455
518
  // Each include is a union: what `--filter other` selected runs its tasks
456
519
  // whether or not the `--affected` diff reaches them, and an exclude
@@ -504,7 +567,7 @@ export async function pickTask(
504
567
  /** The projects `--filter` / `--affected` selected; every project when absent. */
505
568
  only?: ReadonlySet<string>,
506
569
  ): Promise<PickedTask | null | 'interrupted'> {
507
- const projects = await loadWorkspaceProjects(cwd)
570
+ const projects = await loadWorkspaceProjects(cwd, load)
508
571
  // The staged load: a task a `project` plugin gave a config-less package
509
572
  // is on the menu, as it is in a run.
510
573
  const staged = await loadCliProjects(await findWorkspaceRoot(cwd), projects, 'all', load)
@@ -599,13 +662,42 @@ export async function pickTask(
599
662
  }
600
663
  }
601
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
+
602
694
  /** The hint for the first unmatched pattern: a near tag for a `tag:` one, else a near project name. */
603
695
  function didYouMean(
604
696
  unmatched: readonly string[],
605
697
  projects: Iterable<{ name: string }>,
606
698
  tags: ReadonlyMap<string, readonly string[]> | undefined,
607
699
  ): string {
608
- const first = unmatched[0]?.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
700
+ const first = unmatched[0] === undefined ? undefined : typedName(unmatched[0])
609
701
  if (first?.startsWith('tag:') !== true) return didYouMeanProject(unmatched, projects)
610
702
  const known = [...new Set([...(tags?.values() ?? [])].flat())].sort()
611
703
  if (known.length === 0) return '. No project declares tags'
@@ -627,11 +719,16 @@ function didYouMeanProject(
627
719
  if (bare !== n) byBare.set(bare, [...(byBare.get(bare) ?? []), n])
628
720
  }
629
721
  for (const pattern of unmatched) {
630
- const typed = pattern.replace(/^!|\.\.\.$|^\.\.\.|\^/g, '')
722
+ const typed = typedName(pattern)
631
723
  // Two edits from any two-letter name: `--filter //` hinted `ui` (X-13).
632
724
  if (typed === '//') {
633
725
  return '. `//` is the workspace root, which is no project here: add a vx.config there and run `vx lock`'
634
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
+ }
635
732
  const best = nearest(typed, names)
636
733
  if (best !== undefined) return `. Did you mean ${best}?`
637
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}?)`