@vzn/vx 0.0.594 → 0.0.595

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 (54) hide show
  1. package/package.json +5 -5
  2. package/src/bin.ts +26 -11
  3. package/src/cache/archive.ts +2 -2
  4. package/src/cache/cache.ts +96 -90
  5. package/src/cache/config-evals.ts +19 -4
  6. package/src/cache/git-inputs.ts +19 -8
  7. package/src/cache/inputs.ts +110 -37
  8. package/src/cache/layered-cache.ts +25 -2
  9. package/src/cache/tar-stream.ts +1 -1
  10. package/src/cli/cache.ts +1 -11
  11. package/src/cli/completions.ts +42 -6
  12. package/src/cli/foreign-flags.ts +25 -4
  13. package/src/cli/help.ts +5 -3
  14. package/src/cli/init.ts +1 -1
  15. package/src/cli/plan-format.ts +9 -0
  16. package/src/cli/plugin-templates.ts +2 -2
  17. package/src/cli/run.ts +29 -27
  18. package/src/cli/select.ts +30 -3
  19. package/src/cli/show.ts +42 -3
  20. package/src/cli/watch-filter.ts +14 -1
  21. package/src/cli/watch-fs.ts +15 -7
  22. package/src/cli/watch-judge.ts +1 -1
  23. package/src/cli/watch.ts +18 -11
  24. package/src/config.ts +5 -5
  25. package/src/exec/env.ts +5 -0
  26. package/src/exec/index.ts +1 -0
  27. package/src/exec/proc-sample.ts +5 -1
  28. package/src/exec/runner.ts +98 -40
  29. package/src/exec/sandbox-paths.ts +11 -5
  30. package/src/exec/sandbox-runtime.ts +20 -4
  31. package/src/graph/index.ts +1 -0
  32. package/src/graph/scheduler.ts +1 -1
  33. package/src/index.ts +6 -0
  34. package/src/orchestrator/execute-task.ts +29 -7
  35. package/src/orchestrator/hit-restore.ts +6 -3
  36. package/src/orchestrator/local-shortcircuit.ts +4 -1
  37. package/src/orchestrator/lockfile-claim.ts +43 -46
  38. package/src/orchestrator/plan.ts +25 -1
  39. package/src/orchestrator/plugin-host.ts +25 -3
  40. package/src/orchestrator/plugin.ts +2 -1
  41. package/src/orchestrator/run.ts +52 -8
  42. package/src/orchestrator/signals.ts +5 -2
  43. package/src/orchestrator/summary.ts +14 -1
  44. package/src/util/index.ts +7 -1
  45. package/src/util/secret-mask.ts +36 -0
  46. package/src/util/settle.ts +15 -0
  47. package/src/workspace/affected.ts +30 -8
  48. package/src/workspace/config-cache.ts +25 -4
  49. package/src/workspace/config-imports.ts +1 -1
  50. package/src/workspace/config-schema.ts +48 -6
  51. package/src/workspace/migrate-scripts.ts +1 -1
  52. package/src/workspace/package-graph.ts +15 -3
  53. package/src/workspace/project-loader.ts +72 -0
  54. package/src/workspace/workspace.ts +82 -11
@@ -182,11 +182,14 @@ export type ProjectFilesCache = Map<
182
182
  >
183
183
 
184
184
  export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedInputs> {
185
+ const projectRel = path.relative(args.workspaceRoot, args.projectDir).split(path.sep).join('/')
185
186
  const { files: projectFiles, listing } = await resolveFiles({
186
187
  projectDir: args.projectDir,
187
188
  workspaceRoot: args.workspaceRoot,
188
189
  files: args.inputs?.files,
189
190
  ownOutputs: args.ownOutputs,
191
+ ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
192
+ projectRel,
190
193
  nestedProjectDirs: args.nestedProjectDirs,
191
194
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
192
195
  ...(args.projectFilesCache !== undefined ? { projectFilesCache: args.projectFilesCache } : {}),
@@ -199,6 +202,8 @@ export async function resolveInputs(args: ResolveInputsArgs): Promise<ResolvedIn
199
202
  workspaceRoot: args.workspaceRoot,
200
203
  workspaceFiles: wsDecl,
201
204
  ownWorkspaceOutputs: args.ownWorkspaceOutputs ?? [],
205
+ ownOutputs: args.ownOutputs,
206
+ projectRel,
202
207
  ...(args.gitFilesCache !== undefined ? { gitFilesCache: args.gitFilesCache } : {}),
203
208
  ...(args.workspaceFilesCache !== undefined ? { memo: args.workspaceFilesCache } : {}),
204
209
  })
@@ -258,6 +263,9 @@ function resolveWorkspaceFiles(args: {
258
263
  workspaceRoot: string
259
264
  workspaceFiles: readonly string[]
260
265
  ownWorkspaceOutputs: readonly string[]
266
+ /** The task's project-relative `outputs.files`, and its project's root-relative directory. */
267
+ ownOutputs: readonly string[]
268
+ projectRel: string
261
269
  gitFilesCache?: GitFilesCache
262
270
  memo?: WorkspaceFilesCache
263
271
  }): { files: Promise<string[]>; listing: InputListing } | undefined {
@@ -274,6 +282,11 @@ function resolveWorkspaceFiles(args: {
274
282
  // A path the task's own outputs take back with `!` is no output, so it
275
283
  // stays an input (A-44).
276
284
  const ownOutput = outputMatcher(args.ownWorkspaceOutputs)
285
+ // Its project outputs too, which a root-anchored glob can reach as well:
286
+ // left in, the task's own build moved its key, and no run was ever saved.
287
+ let ownProjectOutput: ((rel: string) => boolean) | undefined
288
+ const ownProject = (rel: string): boolean =>
289
+ (ownProjectOutput ??= underProject(args.projectRel, outputMatcher(args.ownOutputs)))(rel)
277
290
  const positiveGlobs = asTrees(positive).map(globFor)
278
291
  // Workspace-wide partition, keyed by the workspace root. Populated
279
292
  // up-front by `populateGitFilesCache(..., workspaceWide: true)` when
@@ -293,9 +306,15 @@ function resolveWorkspaceFiles(args: {
293
306
  const memoKey =
294
307
  args.memo === undefined
295
308
  ? undefined
296
- : JSON.stringify([positive, negative, args.ownWorkspaceOutputs])
309
+ : JSON.stringify([
310
+ positive,
311
+ negative,
312
+ args.ownWorkspaceOutputs,
313
+ args.ownOutputs,
314
+ args.projectRel,
315
+ ])
297
316
  let isPositive: ((rel: string) => boolean) | undefined
298
- const excluded = (rel: string): boolean => isExcluded(rel) || ownOutput(rel)
317
+ const excluded = (rel: string): boolean => isExcluded(rel) || ownOutput(rel) || ownProject(rel)
299
318
  const listing: InputListing = {
300
319
  root: args.workspaceRoot,
301
320
  listed: gitFiles,
@@ -352,6 +371,24 @@ async function resolveWorkspaceFilesOver(
352
371
  return candidates.filter((abs) => oids?.has(abs) === true || isInputOnDisk(abs)).sort()
353
372
  }
354
373
 
374
+ /** A project-relative matcher asked of root-relative paths: false outside the project. */
375
+ function underProject(
376
+ projectRel: string,
377
+ matches: (rel: string) => boolean,
378
+ ): (rel: string) => boolean {
379
+ if (projectRel === '') return matches
380
+ const prefix = `${projectRel}/`
381
+ return (rel) => rel.startsWith(prefix) && matches(rel.slice(prefix.length))
382
+ }
383
+
384
+ /** A root-relative matcher asked of project-relative paths. */
385
+ function inProject(
386
+ projectRel: string,
387
+ matches: (rel: string) => boolean,
388
+ ): (rel: string) => boolean {
389
+ return projectRel === '' ? matches : (rel) => matches(`${projectRel}/${rel}`)
390
+ }
391
+
355
392
  /** Anything at the path — file, directory, symlink to anything or to nothing. */
356
393
  function existsOnDisk(abs: string): boolean {
357
394
  try {
@@ -712,25 +749,13 @@ export async function cleanOutputs(args: {
712
749
  projectDir: string
713
750
  outputs: string[]
714
751
  nestedProjectDirs: string[]
715
- /**
716
- * Before a miss: keep the directory each wildcard glob is rooted at
717
- * (`dist` for `dist/**`). The task writes its matches under it, so a
718
- * remove there bought only an rmdir and the task's mkdir: 0.4 ms of a
719
- * 4.9 ms one-file miss (B-49). A restore prunes it, since the entry's
720
- * shape decides there.
721
- */
722
- keepGlobRoots?: boolean
723
752
  }): Promise<string[]> {
724
753
  const files = await resolveOutputs(args)
725
754
  // `force: true` makes rm tolerate ENOENT (e.g. when two output
726
755
  // globs overlap and a sibling already deleted a path mid-iteration).
727
756
  // A symlink is unlinked, never followed.
728
757
  const removed = await removeAll(files, args.projectDir)
729
- await pruneEmptiedDirs(
730
- args.projectDir,
731
- removed,
732
- args.keepGlobRoots === true ? globRoots(args.projectDir, args.outputs) : undefined,
733
- )
758
+ await pruneEmptiedDirs(args.projectDir, removed, pruneScope(args.projectDir, args.outputs))
734
759
  // Project-relative posix paths of what was removed — the caller
735
760
  // feeds these to GitFilesCache.markOutputsChanged after a restore.
736
761
  return files.map((f) => relPosix(args.projectDir, f))
@@ -740,15 +765,20 @@ export async function cleanOutputs(args: {
740
765
  * Remove exactly these project-relative paths — the recorded rows of an
741
766
  * ADDITIVE task's own artifact (item 588), never a glob: the glob would
742
767
  * take the upstream's files the task adds beside. Emptied directories are
743
- * pruned as `cleanOutputs` prunes them, and stop at one the upstream still
744
- * fills.
768
+ * pruned as `cleanOutputs` prunes them, only inside the declared `outputs`
769
+ * (`pruneScope`), and stop at one the upstream still fills.
745
770
  */
746
771
  export async function cleanOutputPaths(args: {
747
772
  projectDir: string
748
773
  rels: readonly string[]
774
+ outputs: readonly string[]
749
775
  }): Promise<void> {
750
776
  const files = args.rels.map((r) => path.resolve(args.projectDir, r))
751
- await pruneEmptiedDirs(args.projectDir, await removeAll(files, args.projectDir))
777
+ await pruneEmptiedDirs(
778
+ args.projectDir,
779
+ await removeAll(files, args.projectDir),
780
+ pruneScope(args.projectDir, args.outputs),
781
+ )
752
782
  }
753
783
 
754
784
  /**
@@ -914,7 +944,10 @@ function notThroughLink(files: readonly string[], root: string): string[] {
914
944
  return null
915
945
  }
916
946
  }
917
- const realRoot = real(root) ?? root
947
+ // Resolved only once a directory exists to compare: after a clean pruned
948
+ // the outputs (the common restore) nothing does, and the call was most
949
+ // of an empty clean.
950
+ let realRoot: string | undefined
918
951
  const own = new Map<string, boolean>()
919
952
  return files.filter((f) => {
920
953
  const dir = path.dirname(f)
@@ -923,7 +956,8 @@ function notThroughLink(files: readonly string[], root: string): string[] {
923
956
  // A directory already gone has nothing to delete through; its path
924
957
  // stays so the prune still reaches the parents it emptied.
925
958
  const r = real(dir)
926
- ok = r === null || r === path.join(realRoot, path.relative(root, dir))
959
+ ok =
960
+ r === null || r === path.join((realRoot ??= real(root) ?? root), path.relative(root, dir))
927
961
  own.set(dir, ok)
928
962
  }
929
963
  return ok
@@ -937,12 +971,13 @@ function notThroughLink(files: readonly string[], root: string): string[] {
937
971
  * wrote `dist/out/…` and now writes `dist/out`) blocks the rename, and an
938
972
  * empty directory is not an output anyone declared. A directory that still
939
973
  * holds something — a stray the globs do not cover — stays, and the restore
940
- * says so if it is in the way.
974
+ * says so if it is in the way. With a `scope`, only a directory inside it
975
+ * goes (`pruneScope`).
941
976
  */
942
977
  async function pruneEmptiedDirs(
943
978
  root: string,
944
979
  removed: readonly string[],
945
- keep?: ReadonlySet<string>,
980
+ scope?: PruneScope,
946
981
  ): Promise<void> {
947
982
  const rootResolved = path.resolve(root)
948
983
  // LEVEL ORDER, not a walk-up per directory. A parent is attempted only
@@ -960,7 +995,9 @@ async function pruneEmptiedDirs(
960
995
  const parents = new Set<string>()
961
996
  const dirs = [...level].filter(
962
997
  (dir) =>
963
- dir !== rootResolved && dir.startsWith(rootResolved + path.sep) && keep?.has(dir) !== true,
998
+ dir !== rootResolved &&
999
+ dir.startsWith(rootResolved + path.sep) &&
1000
+ (scope === undefined || inScope(dir, scope)),
964
1001
  )
965
1002
  const gone = (err: NodeJS.ErrnoException): boolean => err.code === 'ENOENT'
966
1003
  if (dirs.length <= SYNC_CLEAN_MAX) {
@@ -983,19 +1020,42 @@ async function pruneEmptiedDirs(
983
1020
  }
984
1021
  }
985
1022
 
1023
+ interface PruneScope {
1024
+ /** Wildcard globs' roots (`dist` for `dist/**`): prune strictly below. */
1025
+ below: string[]
1026
+ /** Literal outputs (`out` as a tree): prune the path itself and below. */
1027
+ atOrBelow: string[]
1028
+ }
1029
+
986
1030
  /**
987
- * The directories the wildcard output globs are rooted at, absolute. A
988
- * literal names a file or a tree whose shape the task decides, so it has
989
- * none; nor has a glob rooted at the project itself.
1031
+ * Where a clean, before a miss or a restore, may prune: only the trees the
1032
+ * task declared. A directory above a glob's root or holding a literal
1033
+ * output (`out` for `out/a.txt`) stays: a sibling task running beside this
1034
+ * one may have just made it and not yet written into it, and pruning it
1035
+ * failed that task "Directory nonexistent". A glob's root stays: the task
1036
+ * writes under it, and a remove there bought only an rmdir and the task's
1037
+ * mkdir (B-49). A literal's own path goes: the entry or the task may need
1038
+ * a file there. A glob rooted at the project prunes nothing.
990
1039
  */
991
- function globRoots(projectDir: string, outputs: readonly string[]): Set<string> {
992
- const roots = new Set<string>()
1040
+ function pruneScope(base: string, outputs: readonly string[]): PruneScope {
1041
+ const scope: PruneScope = { below: [], atOrBelow: [] }
993
1042
  for (const g of outputs) {
994
- if (g.startsWith('!') || isLiteralPattern(g)) continue
1043
+ if (g.startsWith('!')) continue
1044
+ if (isLiteralPattern(g)) {
1045
+ scope.atOrBelow.push(path.resolve(base, stripTrailingSlash(normalizeGlob(g))))
1046
+ continue
1047
+ }
995
1048
  const prefix = staticPrefix(g)
996
- if (prefix !== '.') roots.add(path.resolve(projectDir, prefix))
1049
+ if (prefix !== '.') scope.below.push(path.resolve(base, prefix))
997
1050
  }
998
- return roots
1051
+ return scope
1052
+ }
1053
+
1054
+ function inScope(dir: string, scope: PruneScope): boolean {
1055
+ return (
1056
+ scope.below.some((r) => dir.startsWith(r + path.sep)) ||
1057
+ scope.atOrBelow.some((r) => dir === r || dir.startsWith(r + path.sep))
1058
+ )
999
1059
  }
1000
1060
 
1001
1061
  /**
@@ -1033,7 +1093,11 @@ export async function cleanWorkspaceOutputs(args: {
1033
1093
  outputs: string[]
1034
1094
  }): Promise<string[]> {
1035
1095
  const files = await resolveWorkspaceOutputs(args)
1036
- await pruneEmptiedDirs(args.workspaceRoot, await removeAll(files, args.workspaceRoot))
1096
+ await pruneEmptiedDirs(
1097
+ args.workspaceRoot,
1098
+ await removeAll(files, args.workspaceRoot),
1099
+ pruneScope(args.workspaceRoot, args.outputs),
1100
+ )
1037
1101
  return files.map((f) => relPosix(args.workspaceRoot, f))
1038
1102
  }
1039
1103
 
@@ -1133,6 +1197,9 @@ interface ResolveFilesArgs {
1133
1197
  workspaceRoot: string
1134
1198
  files: string[] | undefined
1135
1199
  ownOutputs: string[]
1200
+ /** Root-relative `outputs.workspaceFiles`, and the project's root-relative directory. */
1201
+ ownWorkspaceOutputs: readonly string[]
1202
+ projectRel: string
1136
1203
  nestedProjectDirs: string[]
1137
1204
  gitFilesCache?: GitFilesCache
1138
1205
  projectFilesCache?: ProjectFilesCache
@@ -1324,6 +1391,8 @@ async function resolveFiles(
1324
1391
  const { positive, negative, isExcluded, ownOutput, positiveGlobs, isPositive } = plan
1325
1392
 
1326
1393
  const nested = inNestedProject(args.projectDir, args.nestedProjectDirs)
1394
+ // Its root-anchored outputs that land in the project, as `files` sees them.
1395
+ const ownWsOutput = inProject(args.projectRel, outputMatcher(args.ownWorkspaceOutputs))
1327
1396
 
1328
1397
  // Defer to git for the file set (Turbo / Nx parity). Nested .gitignore
1329
1398
  // files, .git/info/exclude, and global excludes all participate
@@ -1335,7 +1404,7 @@ async function resolveFiles(
1335
1404
  // duration of one orchestrator run.
1336
1405
  // Everything below the snapshot that decides the result: the project, what
1337
1406
  // it declares, what it excludes as its own outputs, and the boundaries.
1338
- const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${args.nestedProjectDirs.join('\u0001')}`
1407
+ const memoKey = `${args.projectDir}\0${positive.join('\u0001')}\0${negative.join('\u0001')}\0${args.ownOutputs.join('\u0001')}\0${args.ownWorkspaceOutputs.join('\u0001')}\0${args.nestedProjectDirs.join('\u0001')}`
1339
1408
  let gitFiles = args.gitFilesCache?.snapshotFor(args.projectDir, positiveGlobs)
1340
1409
  let undecodable = args.gitFilesCache?.undecodableNames
1341
1410
  if (gitFiles !== undefined) {
@@ -1346,7 +1415,7 @@ async function resolveFiles(
1346
1415
  if (memo !== undefined && memo.snapshot === gitFiles) {
1347
1416
  return {
1348
1417
  files: [...memo.result],
1349
- listing: listingFor(args.projectDir, gitFiles, plan, nested),
1418
+ listing: listingFor(args.projectDir, gitFiles, plan, nested, ownWsOutput),
1350
1419
  }
1351
1420
  }
1352
1421
  }
@@ -1397,7 +1466,7 @@ async function resolveFiles(
1397
1466
  input = isPositive(rel) && !isExcluded(rel) && !ownOutput(rel)
1398
1467
  verdicts.set(rel, input)
1399
1468
  }
1400
- if (!input || nested(rel)) continue
1469
+ if (!input || nested(rel) || ownWsOutput(rel)) continue
1401
1470
  candidates.push(base === undefined ? path.resolve(args.projectDir, rel) : base + rel)
1402
1471
  }
1403
1472
  const unmatchedLiterals = unanswered(plan.literals, gitFiles)
@@ -1424,7 +1493,10 @@ async function resolveFiles(
1424
1493
  // Stored only on the way out: a declaration whose literal named an
1425
1494
  // invisible file threw above, and every task sharing it must throw too.
1426
1495
  args.projectFilesCache?.set(memoKey, { snapshot: gitFiles, result: resolved })
1427
- return { files: [...resolved], listing: listingFor(args.projectDir, gitFiles, plan, nested) }
1496
+ return {
1497
+ files: [...resolved],
1498
+ listing: listingFor(args.projectDir, gitFiles, plan, nested, ownWsOutput),
1499
+ }
1428
1500
  }
1429
1501
 
1430
1502
  function listingFor(
@@ -1432,11 +1504,12 @@ function listingFor(
1432
1504
  listed: readonly string[],
1433
1505
  plan: FilesPlan,
1434
1506
  nested: (rel: string) => boolean,
1507
+ ownWsOutput: (rel: string) => boolean,
1435
1508
  ): InputListing {
1436
1509
  return {
1437
1510
  root,
1438
1511
  listed,
1439
- isInput: (rel) => inPlan(plan, rel),
1512
+ isInput: (rel) => inPlan(plan, rel) && !ownWsOutput(rel),
1440
1513
  nested,
1441
1514
  prefixes: plan.prefixes,
1442
1515
  literals: plan.literals,
@@ -185,7 +185,27 @@ class ScopedRemote implements RemoteCacheLayer {
185
185
  }
186
186
 
187
187
  async has(hash: string): Promise<boolean> {
188
- return (await this.remote.has(this.scoped(hash))) || this.remote.has(hash)
188
+ return this.fallThrough(
189
+ () => this.remote.has(this.scoped(hash)),
190
+ () => this.remote.has(hash),
191
+ )
192
+ }
193
+
194
+ /**
195
+ * The scope's answer, else the trusted one. A scope lookup that fails
196
+ * still asks the trusted key, so a hit there is not lost; its error is
197
+ * rethrown (and warned) when the trusted key fails or has nothing too.
198
+ */
199
+ private async fallThrough<T>(own: () => Promise<T>, trusted: () => Promise<T>): Promise<T> {
200
+ let found: T
201
+ try {
202
+ found = await own()
203
+ } catch (err) {
204
+ const fallback = await trusted().catch(() => undefined)
205
+ if (!fallback) throw err
206
+ return fallback
207
+ }
208
+ return found || trusted()
189
209
  }
190
210
 
191
211
  private async scopedHasMany(hashes: readonly string[]): Promise<Set<string> | null> {
@@ -207,7 +227,10 @@ class ScopedRemote implements RemoteCacheLayer {
207
227
  hash: string,
208
228
  ): Promise<{ body: Blob | Response; durationMs: number | undefined } | null> {
209
229
  if (this.trustedOnly.has(hash)) return this.remote.get(hash)
210
- return (await this.remote.get(this.scoped(hash))) ?? this.remote.get(hash)
230
+ return this.fallThrough(
231
+ () => this.remote.get(this.scoped(hash)),
232
+ () => this.remote.get(hash),
233
+ )
211
234
  }
212
235
 
213
236
  put(hash: string, body: Blob, meta: { durationMs: number }): Promise<void> {
@@ -11,7 +11,7 @@
11
11
  // short entry. Non-regular entries are reported with their type and their
12
12
  // bodies skipped; the caller decides what to materialise (nothing but `0`).
13
13
 
14
- export interface TarEntry {
14
+ interface TarEntry {
15
15
  name: string
16
16
  size: number
17
17
  /** POSIX typeflag: '0' regular, '5' directory, '2' symlink, … */
package/src/cli/cache.ts CHANGED
@@ -185,19 +185,9 @@ async function pruneCmd(args: readonly string[]): Promise<number> {
185
185
  printPruned({ evicted: 0, bytesFreed: 0, orphans: 0, orphanBytes: 0 }, dry, json)
186
186
  return 0
187
187
  }
188
- if (parsed.dryRun === true) {
189
- const earlier = await Cache.orphansBeforeReset(dir)
190
- if (earlier !== null) {
191
- warnToStderr(
192
- `[vx] the cache index is schema ${earlier.found} from another vx version: the prune resets it first, and every artifact past the hour's grace is then an orphan`,
193
- )
194
- printPruned({ evicted: 0, bytesFreed: 0, ...earlier }, true, json)
195
- return 0
196
- }
197
- }
198
188
  const cache =
199
189
  parsed.dryRun === true
200
- ? new Cache(dir, undefined, undefined, undefined, 'inspect', storeRoot)
190
+ ? new Cache(dir, undefined, undefined, undefined, 'preview', storeRoot)
201
191
  : new Cache(dir, undefined, root, undefined, 'open', storeRoot)
202
192
  // A prune deletes rows and artifacts; a cache this user cannot write is
203
193
  // refused up front with the directory named, as a run refuses it, rather
@@ -8,6 +8,13 @@
8
8
  import { acceptedFlags, CORE_VERBS, flagHint, seeHelp } from './help.js'
9
9
  import { nearest } from '../util/index.js'
10
10
 
11
+ /** A flag whose value is one of a fixed set; the tests hold each to its parser. */
12
+ export const FLAG_VALUES: Readonly<Record<string, readonly string[]>> = {
13
+ '--format': ['pretty', 'json'],
14
+ '--output-logs': ['full', 'errors-only', 'hash-only', 'none'],
15
+ '--download': ['all', 'toplevel', 'none'],
16
+ }
17
+
11
18
  export type CompletionShell = 'bash' | 'zsh' | 'fish'
12
19
  const SHELLS: readonly CompletionShell[] = ['bash', 'zsh', 'fish']
13
20
 
@@ -36,6 +43,14 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
36
43
  ...((CORE_VERBS as readonly string[]).includes(v) ? verbFlags(v) : ['--help']),
37
44
  ],
38
45
  }))
46
+ // `vx run --output-logs <Tab>` offered the flags again; a flag with a
47
+ // fixed value set offers that set.
48
+ const values = table.flatMap((t) =>
49
+ t.words.flatMap((w) => {
50
+ const vs = FLAG_VALUES[w]
51
+ return vs === undefined ? [] : [{ verb: t.verb, flag: w, values: vs.join(' ') }]
52
+ }),
53
+ )
39
54
  switch (shell) {
40
55
  case 'bash':
41
56
  return [
@@ -43,10 +58,23 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
43
58
  '_vx() {',
44
59
  ' local cur="${COMP_WORDS[COMP_CWORD]}"',
45
60
  ' local verb="${COMP_WORDS[1]}"',
61
+ ' local prev="${COMP_WORDS[COMP_CWORD-1]}"',
46
62
  ' if [ "$COMP_CWORD" -eq 1 ]; then',
47
63
  ` COMPREPLY=($(compgen -W "${verbs.join(' ')}" -- "$cur"))`,
48
64
  ' return',
49
65
  ' fi',
66
+ // bash splits `--format=js` at the `=` (COMP_WORDBREAKS).
67
+ ' if [ "$cur" = "=" ]; then',
68
+ ' cur=""',
69
+ ' elif [ "$prev" = "=" ]; then',
70
+ ' prev="${COMP_WORDS[COMP_CWORD-2]}"',
71
+ ' fi',
72
+ ' case "$verb $prev" in',
73
+ ...values.map(
74
+ (v) =>
75
+ ` "${v.verb} ${v.flag}") COMPREPLY=($(compgen -W "${v.values}" -- "$cur")); return ;;`,
76
+ ),
77
+ ' esac',
50
78
  ' case "$verb" in',
51
79
  ...table.map(
52
80
  (t) => ` ${t.verb}) COMPREPLY=($(compgen -W "${t.words.join(' ')}" -- "$cur")) ;;`,
@@ -66,6 +94,9 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
66
94
  ` compadd -- ${verbs.join(' ')}`,
67
95
  ' return',
68
96
  ' fi',
97
+ ' case "${words[2]} ${words[CURRENT-1]}" in',
98
+ ...values.map((v) => ` "${v.verb} ${v.flag}") compadd -- ${v.values}; return ;;`),
99
+ ' esac',
69
100
  ' case "${words[2]}" in',
70
101
  ...table.map((t) => ` ${t.verb}) compadd -- ${t.words.join(' ')} ;;`),
71
102
  ' esac',
@@ -86,11 +117,14 @@ export function completionScript(shell: CompletionShell, verbs: readonly string[
86
117
  'complete -c vx -f',
87
118
  ...verbs.map((v) => `complete -c vx -n __fish_use_subcommand -a ${v}`),
88
119
  ...table.flatMap((t) =>
89
- t.words.map((w) =>
90
- w.startsWith('--')
91
- ? `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}" -l ${w.slice(2)}`
92
- : `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}" -a ${w}`,
93
- ),
120
+ t.words.map((w) => {
121
+ const on = `complete -c vx -n "__fish_seen_subcommand_from ${t.verb}"`
122
+ if (!w.startsWith('--')) return `${on} -a ${w}`
123
+ const vs = FLAG_VALUES[w]
124
+ return vs === undefined
125
+ ? `${on} -l ${w.slice(2)}`
126
+ : `${on} -l ${w.slice(2)} -x -a "${vs.join(' ')}"`
127
+ }),
94
128
  ),
95
129
  '',
96
130
  ].join('\n')
@@ -111,7 +145,9 @@ export async function completionsCmd(
111
145
  return 1
112
146
  }
113
147
  const one = shell !== undefined && args.length === 1
114
- const best = one ? nearest(shell, SHELLS) : undefined
148
+ // One edit: shell names are three or four letters, so two edits took
149
+ // `tcsh` to `bash`, a different shell.
150
+ const best = one ? nearest(shell, SHELLS, 1) : undefined
115
151
  process.stderr.write(
116
152
  `vx completions: expected one shell — bash, zsh or fish${one ? ` (got ${shell})` : ''}${best === undefined ? '' : `. Did you mean ${best}?`}${seeHelp('completions')}\n`,
117
153
  )
@@ -36,6 +36,17 @@ const list = (v: string | undefined): string[] =>
36
36
  .map((s) => s.trim())
37
37
  .filter((s) => s !== '')
38
38
 
39
+ // Nx's `--projects` labels (find-matching-projects, 23.2.1): `name:` and
40
+ // `directory:` say which match to make; vx's filter says it by form. `tag:`
41
+ // is a filter form already.
42
+ const nxPattern = (p: string): string => {
43
+ const neg = p.startsWith('!') ? '!' : ''
44
+ const s = neg === '' ? p : p.slice(1)
45
+ if (s.startsWith('name:')) return `${neg}${s.slice(5)}`
46
+ if (s.startsWith('directory:')) return `${neg}./${s.slice(10)}`
47
+ return p
48
+ }
49
+
39
50
  const GRAPH_IMAGE = /\.(svg|png|jpe?g|pdf|json|html|mermaid)$/i
40
51
 
41
52
  export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
@@ -142,7 +153,14 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
142
153
  outcome: 'same',
143
154
  vx: '`--output-logs full|errors-only|hash-only|none`',
144
155
  },
145
- { runner: 'turbo', names: ['--no-cache'], value: false, outcome: 'same', vx: '`--no-cache`' },
156
+ {
157
+ runner: 'turbo',
158
+ names: ['--no-cache'],
159
+ value: false,
160
+ outcome: 'same',
161
+ // Turbo 2.11's own help: "Equivalent to `--cache=local:r,remote:r`".
162
+ vx: "`--no-cache` reads nothing either; Turbo's (reads, no writes) is `--cache local:r,remote:r`",
163
+ },
146
164
  {
147
165
  runner: 'turbo',
148
166
  names: ['--cache'],
@@ -407,12 +425,15 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
407
425
  names: ['-p', '--projects'],
408
426
  value: true,
409
427
  outcome: 'alias',
410
- vx: "`--filter <pattern>`, one per project; a list opening with `!` starts from all (`--filter '*'`)",
428
+ vx: "`--filter <pattern>`, one per project (`directory:<d>` is `./<d>`, `name:<n>` is `<n>`); a list opening with `!` starts from all (`--filter '*'`)",
411
429
  // Nx reads a leading exclusion as "all projects but": `-p '!b,a'` is
412
430
  // every project except b, where vx's filters alone would select a.
413
431
  to: (v) => {
414
432
  const ps = list(v)
415
- return [...(ps[0]?.startsWith('!') ? ['*'] : []), ...ps].flatMap((p) => ['--filter', p])
433
+ return [...(ps[0]?.startsWith('!') ? ['*'] : []), ...ps].flatMap((p) => [
434
+ '--filter',
435
+ nxPattern(p),
436
+ ])
416
437
  },
417
438
  },
418
439
  {
@@ -421,7 +442,7 @@ export const FOREIGN_FLAGS: readonly ForeignFlag[] = [
421
442
  value: true,
422
443
  outcome: 'alias',
423
444
  vx: "`--filter '!<pattern>'`, one per project",
424
- to: (v) => list(v).flatMap((p) => ['--filter', `!${p}`]),
445
+ to: (v) => list(v).flatMap((p) => ['--filter', `!${nxPattern(p)}`]),
425
446
  },
426
447
  {
427
448
  runner: 'nx',
package/src/cli/help.ts CHANGED
@@ -111,7 +111,7 @@ export function helpText(pluginCommands: readonly string[] = []): string {
111
111
  ' vx lock [--check]',
112
112
  ' vx init [--dry] [--force] [--mjs] [--plugin <seam>]',
113
113
  ' vx upgrade [TAG]',
114
- ' vx show [PROJECT[#TASK] | TASK] [--format pretty|json]',
114
+ ' vx show [PROJECT[#TASK] | TASK] [--filter <pattern>] [--affected[=<ref>]] [--format pretty|json]',
115
115
  ' vx info [--format pretty|json] [--cache-dir <path>]',
116
116
  ' vx why (TASK | PKG#TASK) [--run RUNID] [--format pretty|json] [--cache-dir <path>]',
117
117
  ' vx last [RUNID] [--list[=N]] [--failed] [--format pretty|json] [--cache-dir <path>]',
@@ -140,8 +140,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
140
140
  ' to its flags; unnamed layers keep their value. Examples:',
141
141
  ' local:rw,remote:r (remote read-only), remote: (remote off),',
142
142
  ' local:r (local read-only).',
143
- ' --cache-dir <path> Cache directory override (cwd-relative). Beats the workspace',
144
- ' cacheDir field + the .vx/cache default.',
143
+ ' --cache-dir <path> Cache directory override (cwd-relative). Beats VX_CACHE_DIR,',
144
+ ' the workspace cacheDir field and the ~/.vx/<id>/cache default.',
145
145
  ' --retry <n> Re-run a failed task up to <n> more times (default for tasks',
146
146
  " without their own exec.retries; explicit config wins). Doesn't",
147
147
  ' affect cache keys.',
@@ -209,6 +209,8 @@ export function helpText(pluginCommands: readonly string[] = []): string {
209
209
  ' evaluation, not the lock), one block per task.',
210
210
  " vx show <pkg>#<task> Print a single task's resolved config.",
211
211
  ' vx show <task> That task in every project declaring it.',
212
+ " --filter <pattern> The list (or a task's projects) as `vx run --filter` selects.",
213
+ ' --affected[=<ref>] Only what `vx run --affected` selects: changed and dependents.',
212
214
  ' --format <fmt> pretty (default) | json.',
213
215
  ' vx info Workspace doctor: vx/bun/git versions, project +',
214
216
  ' task counts, cache dir/entries/size, recent runs,',
package/src/cli/init.ts CHANGED
@@ -369,7 +369,7 @@ function namelessNotes(root: string, dirs: readonly string[]): string[] {
369
369
  const withScripts = dirs
370
370
  .filter((dir) => {
371
371
  const scripts = (
372
- JSON.parse(readText(path.join(dir, 'package.json')) || '{}') as {
372
+ JSON.parse(readText(path.join(dir, 'package.json')).replace(/^\uFEFF/, '') || '{}') as {
373
373
  scripts?: unknown
374
374
  }
375
375
  ).scripts
@@ -29,6 +29,7 @@ export function formatPlanText(plan: RunPlan): string {
29
29
  let miss = 0
30
30
  let nocache = 0
31
31
  let noop = 0
32
+ let idle = 0
32
33
  for (const t of real) {
33
34
  // `@noop`: the run succeeds it without running, whatever its key says.
34
35
  const skipped = t.executor === 'noop'
@@ -56,6 +57,7 @@ export function formatPlanText(plan: RunPlan): string {
56
57
  else if (t.cacheStatus === 'hit-remote') remote++
57
58
  else if (t.cacheStatus === 'miss') miss++
58
59
  else if (t.cacheStatus === 'no-cache') nocache++
60
+ else if (t.cacheStatus === 'not-started') idle++
59
61
  }
60
62
 
61
63
  const hitParts: string[] = []
@@ -67,6 +69,7 @@ export function formatPlanText(plan: RunPlan): string {
67
69
  if (miss > 0) summary.push(`${miss} would run`)
68
70
  if (nocache > 0) summary.push(`${nocache} no-cache`)
69
71
  if (noop > 0) summary.push(`${noop} noop`)
72
+ if (idle > 0) summary.push(`${idle} not started`)
70
73
  lines.push('')
71
74
  lines.push(summary.join(', ') + '.')
72
75
 
@@ -199,6 +202,8 @@ function symbolFor(s: CacheStatus): string {
199
202
  return '·'
200
203
  case 'group':
201
204
  return '○'
205
+ case 'not-started':
206
+ return '−'
202
207
  }
203
208
  }
204
209
 
@@ -214,6 +219,8 @@ function describe(s: CacheStatus): string {
214
219
  return 'no-cache (would exec)'
215
220
  case 'group':
216
221
  return 'group task'
222
+ case 'not-started':
223
+ return 'not started (unneeded)'
217
224
  }
218
225
  }
219
226
 
@@ -229,5 +236,7 @@ function dotColor(s: CacheStatus): string {
229
236
  return '#e5e7eb' // gray-200
230
237
  case 'group':
231
238
  return '#f5d0fe' // fuchsia-200
239
+ case 'not-started':
240
+ return '#ffffff'
232
241
  }
233
242
  }