@vzn/vx 0.0.643 → 0.0.645

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 (56) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +15 -23
  3. package/package.json +5 -5
  4. package/schemas/info.json +0 -11
  5. package/schemas/init.json +5 -0
  6. package/skills/vx/SKILL.md +1 -1
  7. package/src/cache/archive.ts +89 -2
  8. package/src/cache/cache.ts +396 -176
  9. package/src/cache/file-hashes.ts +2 -14
  10. package/src/cache/fold-memo.ts +7 -3
  11. package/src/cache/index.ts +4 -2
  12. package/src/cache/key-fold.ts +4 -1
  13. package/src/cache/layer.ts +14 -10
  14. package/src/cache/layered-cache.ts +157 -41
  15. package/src/cache/plugin-cache.ts +40 -44
  16. package/src/cache/remote-lane.ts +59 -25
  17. package/src/cache/scoped-cache.ts +3 -80
  18. package/src/cache/tar-stream.ts +12 -7
  19. package/src/cli/format.ts +1 -2
  20. package/src/cli/help.ts +10 -4
  21. package/src/cli/info.ts +2 -19
  22. package/src/cli/init.ts +121 -20
  23. package/src/cli/plan-format.ts +6 -2
  24. package/src/cli/plugin-commands.ts +4 -5
  25. package/src/cli/plugin-templates.ts +1 -1
  26. package/src/cli/run.ts +3 -1
  27. package/src/cli/upgrade.ts +39 -9
  28. package/src/config.ts +13 -10
  29. package/src/exec/sandbox-paths.ts +2 -2
  30. package/src/graph/scheduler.ts +3 -1
  31. package/src/graph/task-graph.ts +15 -11
  32. package/src/index.ts +2 -2
  33. package/src/orchestrator/affected-tasks.ts +0 -18
  34. package/src/orchestrator/doctor.ts +0 -17
  35. package/src/orchestrator/execute-task.ts +1 -1
  36. package/src/orchestrator/index.ts +1 -0
  37. package/src/orchestrator/options.ts +6 -8
  38. package/src/orchestrator/plan.ts +7 -1
  39. package/src/orchestrator/plugin-host.ts +6 -5
  40. package/src/orchestrator/plugin.ts +12 -5
  41. package/src/orchestrator/prepare.ts +16 -46
  42. package/src/orchestrator/projects.ts +8 -1
  43. package/src/orchestrator/remote-prefetch.ts +11 -4
  44. package/src/orchestrator/run-context.ts +48 -5
  45. package/src/orchestrator/run-settings.ts +3 -0
  46. package/src/orchestrator/run.ts +10 -26
  47. package/src/orchestrator/task-hash.ts +1 -3
  48. package/src/report/logger.ts +0 -4
  49. package/src/report/summary.ts +14 -13
  50. package/src/settings.ts +38 -15
  51. package/src/util/index.ts +1 -0
  52. package/src/util/paths.ts +42 -33
  53. package/src/util/size.ts +4 -5
  54. package/src/workspace/affected.ts +0 -39
  55. package/src/workspace/config-schema.ts +0 -36
  56. package/src/workspace/index.ts +0 -1
package/src/util/paths.ts CHANGED
@@ -48,7 +48,15 @@ export function relPosix(from: string, to: string): string {
48
48
  * Every glob reaching here is user-written, so every one takes the rule.
49
49
  */
50
50
  export function staticPrefix(rawGlob: string): string {
51
- return prefixOf(normalizeGlob(rawGlob), GLOB_WILDCARDS)
51
+ // An escaped character is part of the prefix and names itself:
52
+ // `app/\[id\]/**` is rooted at the directory `app/[id]`.
53
+ const g = normalizeGlob(rawGlob)
54
+ if (!g.includes('\\')) return prefixOf(g, GLOB_WILDCARDS)
55
+ const masked = g.replace(/\\./g, '\0\0')
56
+ const prefix = prefixOf(masked, GLOB_WILDCARDS)
57
+ return prefix === '.' || prefix === '/'
58
+ ? prefix
59
+ : g.slice(0, prefix.length).replace(/\\(.)/g, '$1')
52
60
  }
53
61
 
54
62
  /**
@@ -98,10 +106,10 @@ export function wholeSubtreePrefixes(globs: readonly string[]): string[] | null
98
106
  if (globs.length === 0) return null
99
107
  const out: string[] = []
100
108
  for (const g of globs.map(normalizeGlob)) {
101
- const m = /^([^*?{}!]+?)\/\*\*$/.exec(g)
102
- const whole = m?.[1] ?? (isLiteralPattern(g) && !g.startsWith('!') ? g : null)
103
- if (whole === null) return null
104
- const dir = whole.replace(/\/+$/, '')
109
+ const named = /^(.+?)\/\*\*$/.exec(g)?.[1] ?? g
110
+ if (g.startsWith('!') || !namesOnePath(named)) return null
111
+ // An escaped bracket names itself: `app/\[id\]/**` is the tree `app/[id]`.
112
+ const dir = named.replace(/\\(.)/g, '$1').replace(/\/+$/, '')
105
113
  if (dir === '' || dir === '.' || dir.startsWith('/') || dir.split('/').includes('..'))
106
114
  return null
107
115
  out.push(dir)
@@ -136,10 +144,6 @@ function normalizeWith(glob: string, wildcards: RegExp): string {
136
144
  let g = neg ? glob.slice(1) : glob
137
145
  g = g.replace(/\/{2,}/g, '/').replace(/(^|\/)(\.\/)+/g, '$1')
138
146
  if (g === '.') g = ''
139
- // Where a bracket is literal, Turbo's escaped spelling `\[id\]` names the
140
- // same path as `[id]`; one spelling is what lets every literal fast path
141
- // (string compare, `settleLiterals`, `asTrees`) see it as a literal.
142
- if (wildcards === GLOB_WILDCARDS && g.includes('\\')) g = g.replace(/\\([[\]])/g, '$1')
143
147
  if (wildcards.test(g) && g.endsWith('/')) g = `${g.replace(/\/+$/, '')}/**`
144
148
  return neg ? `!${g}` : g
145
149
  }
@@ -172,21 +176,19 @@ export function isLiteralPattern(glob: string): boolean {
172
176
  * class written out nine times — a spelling that drifts is how two guards
173
177
  * come to answer differently for one pattern.
174
178
  *
175
- * `[` and `]` are NOT in it: a bracket is a literal character in a task
176
- * glob, and there are no character classes (item 667). Route directories
177
- * are named `[id]` across Next.js, SvelteKit and Astro, and read as a class
178
- * `app/[id]/**` matched `app/i/…` and `app/d/…` and never the route: the
179
- * file never entered the key (a stale hit) and the output clean deleted an
180
- * unrelated `app/i/page.js`. {@link taskGlob} is how such a glob reaches
181
- * `Bun.Glob`, whose own alphabet still reads the class.
179
+ * A task glob is a standard glob (M, 2026-10-18): `[...]` is a character
180
+ * class and `\[id\]` names the route directory `[id]`. An escape counts as
181
+ * a wildcard too: such a pattern is matched by `Bun.Glob`, which reads the
182
+ * escape, never compared as a string; `asTrees` still reads an escaped-only
183
+ * pattern as one path.
182
184
  */
183
- export const GLOB_WILDCARDS = /[*?{}]/
185
+ export const GLOB_WILDCARDS = /[*?{}[\]\\]/
184
186
 
185
187
  /**
186
- * `Bun.Glob`'s own alphabet, the class included, for the globs vx does not
187
- * own: package-manager workspace members and `--filter` path globs follow
188
- * npm/pnpm semantics, and a sandbox grant's prefix must stop where the scan
189
- * that expands the grant (`Bun.Glob`, `MOUNT_WILDCARDS`) sees a wildcard.
188
+ * `Bun.Glob`'s own alphabet, for the globs vx does not own: package-manager
189
+ * workspace members and `--filter` path globs follow npm/pnpm semantics,
190
+ * and a sandbox grant's prefix must stop where the scan that expands the
191
+ * grant (`Bun.Glob`, `MOUNT_WILDCARDS`) sees a wildcard.
190
192
  */
191
193
  export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
192
194
 
@@ -200,11 +202,7 @@ export const BUN_GLOB_WILDCARDS = /[*?[\]{}]/
200
202
  */
201
203
  export const EXTGLOB = /[!@+*?]\(/
202
204
 
203
- /**
204
- * Compile a task glob for `Bun.Glob`, with every bare bracket escaped so it
205
- * matches itself (item 667). Every task glob is compiled here and nowhere
206
- * else: one site that forgot is a route directory that keys nothing.
207
- */
205
+ /** Compile a task glob for `Bun.Glob`. Every task glob is compiled here and nowhere else. */
208
206
  export function taskGlob(pattern: string): Bun.Glob {
209
207
  const memo = memoFor(taskGlobs)
210
208
  let glob = memo.get(pattern)
@@ -230,9 +228,7 @@ function memoFor<T>(memos: WeakMap<object, Map<string, T>>): Map<string, T> {
230
228
  }
231
229
 
232
230
  function compileTaskGlob(pattern: string): Bun.Glob {
233
- // A `]` with no `[` before it is already literal to `Bun.Glob`.
234
- const source = pattern.includes('[') ? pattern.replace(/(?<!\\)[[\]]/g, '\\$&') : pattern
235
- const glob = new Bun.Glob(source)
231
+ const glob = new Bun.Glob(pattern)
236
232
  const alternatives = braceAlternatives(pattern)
237
233
  if (alternatives.length > 1) {
238
234
  const any = anyTaskGlob(alternatives)
@@ -288,12 +284,12 @@ const REGEXP_SYNTAX = /[\\^$.*+?()[\]{}|]/g
288
284
  * The RegExp source of a task glob, matching what `Bun.Glob` matches
289
285
  * (`tests/util-paths.test.ts` holds the two to the same verdict on every
290
286
  * shape): `*` any run within a name, `?` one character, a `**` segment any
291
- * number of directories, a bracket itself. Null for the syntax left to
292
- * `Bun.Glob`: a brace, an escape, a leading `!`, an empty, `.` or `..`
287
+ * number of directories. Null for the syntax left to `Bun.Glob`: a brace,
288
+ * a character class, an escape, a leading `!`, an empty, `.` or `..`
293
289
  * segment, a `**` inside a name, or two `**` segments in a row.
294
290
  */
295
291
  function regExpSource(pattern: string): string | null {
296
- if (/[\\{}]/.test(pattern) || pattern.startsWith('!')) return null
292
+ if (/[\\{}[\]]/.test(pattern) || pattern.startsWith('!')) return null
297
293
  const segments = pattern.split('/')
298
294
  let out = ''
299
295
  for (let i = 0; i < segments.length; i++) {
@@ -342,7 +338,7 @@ export function asTrees(patterns: readonly string[]): string[] {
342
338
  const out: string[] = []
343
339
  for (const raw of patterns) {
344
340
  const p = normalizeGlob(raw)
345
- if (!isLiteralPattern(p)) {
341
+ if (!namesOnePath(p)) {
346
342
  out.push(p)
347
343
  continue
348
344
  }
@@ -353,6 +349,19 @@ export function asTrees(patterns: readonly string[]): string[] {
353
349
  return out
354
350
  }
355
351
 
352
+ /**
353
+ * The one path a task glob names, an escaped one included (`app/\[id\]`
354
+ * names `app/[id]`), or null for a pattern.
355
+ */
356
+ export function literalPath(glob: string): string | null {
357
+ if (isLiteralPattern(glob)) return glob
358
+ return isLiteralPattern(glob.replace(/\\./g, '_')) ? glob.replace(/\\(.)/g, '$1') : null
359
+ }
360
+
361
+ function namesOnePath(glob: string): boolean {
362
+ return literalPath(glob) !== null
363
+ }
364
+
356
365
  /**
357
366
  * An output list's positive globs, and what its `!` entries take back
358
367
  * (A-44). A reader that only needs where outputs MIGHT land (an overlap
package/src/util/size.ts CHANGED
@@ -4,13 +4,13 @@ import { parseDecimalInt } from './num.js'
4
4
 
5
5
  /**
6
6
  * Parse a byte-size string: a bare integer (`"1048576"`) or an integer
7
- * with a K/M/G/T suffix, powers of 1024, optional trailing `B`
8
- * (`"512MB"`, `"8G"`). Returns `null` for anything else — including
7
+ * with a K/M/G/T suffix, powers of 1024, optional trailing `B` or `iB`
8
+ * (`"512MB"`, `"8G"`, `"32KiB"`). Returns `null` for anything else — including
9
9
  * fractional sizes (`"1.5GB"`); callers wanting fractions must express
10
10
  * them in a smaller unit.
11
11
  */
12
12
  export function parseSize(input: string): number | null {
13
- const m = input.match(/^(\d+)([KMGT])?B?$/i)
13
+ const m = input.match(/^(\d+)(?:([KMGT])(?:i?B)?|B)?$/i)
14
14
  if (!m) return null
15
15
  // Digits past 2^53 parse to a different number than the user typed.
16
16
  const n = parseDecimalInt(m[1]!)
@@ -49,8 +49,7 @@ export function parseDuration(input: string): number | null {
49
49
 
50
50
  /**
51
51
  * Human-readable byte size with KB/MB/GB/TB/PB suffixes and one
52
- * decimal of precision below 10 of any unit. Used by `vx info`, `vx cache clean` and the
53
- * run's `cacheRetention` line.
52
+ * decimal of precision below 10 of any unit. Used by `vx info` and `vx cache clean`.
54
53
  */
55
54
  export function formatBytes(n: number): string {
56
55
  if (n < 1024) return `${n} B`
@@ -14,7 +14,6 @@ import {
14
14
  gitSpawnRefusal,
15
15
  isExecutableMissing,
16
16
  isInstalledPath,
17
- isUserError,
18
17
  notAWorkTree,
19
18
  relPosix,
20
19
  UserError,
@@ -741,44 +740,6 @@ function noHistoryRefusal(workspaceRoot: string): UserError | undefined {
741
740
  return undefined
742
741
  }
743
742
 
744
- /**
745
- * What a branch changed, for which of its tasks read its cache scope
746
- * (owner, 2026-10-17): from `base` (a plugin's: a pull request's base
747
- * commit), else from the merge base with the base branch. No base, or
748
- * one git cannot resolve (a CI checkout at `fetch-depth: 1`), is no
749
- * change: every task reads the default key, which costs hits, never
750
- * correctness, since a key must still match.
751
- */
752
- export async function branchChanges(
753
- workspaceRoot: string,
754
- base: string | undefined,
755
- baseBranch: string | undefined,
756
- projects: Promise<readonly ProjectMeta[]>,
757
- /** The run's own enumeration, so the diff walks nothing a second time. */
758
- walked: Pick<AffectedArgs, 'untracked' | 'links'> = {},
759
- ): Promise<AffectedChanges> {
760
- const none: AffectedChanges = {
761
- projects: new Set(),
762
- changed: [],
763
- paths: new Map(),
764
- whole: new Set(),
765
- }
766
- const since = base ?? (await baseBranchRef(workspaceRoot, baseBranch))?.ref
767
- if (since === undefined) return none
768
- try {
769
- return await affectedChanges({
770
- workspaceRoot,
771
- since,
772
- projects,
773
- configImports: false,
774
- ...walked,
775
- })
776
- } catch (err) {
777
- if (!isUserError(err)) throw err
778
- return none
779
- }
780
- }
781
-
782
743
  /**
783
744
  * The base branch as a ref: `origin/<name>`, else the local branch (a
784
745
  * clone with no remote), and whether HEAD is on it. The name is the
@@ -19,8 +19,6 @@ import {
19
19
  CORE_VERBS,
20
20
  nearest,
21
21
  normalizeGlob,
22
- parseDuration,
23
- parseSize,
24
22
  UserError,
25
23
  BUN_GLOB_WILDCARDS,
26
24
  EXTGLOB,
@@ -203,15 +201,6 @@ function validateDependencyRules(value: unknown, where: string): void {
203
201
  /** `value` as the setting at `path` takes it, refused in `where`'s words. */
204
202
  function validateSettingValue(value: unknown, path: string, where: string): void {
205
203
  const { kind } = setting(path)
206
- // The bound `vx cache clean` refuses too: each run would empty the cache,
207
- // the entries it just saved included, so nothing ever hits (item 969).
208
- if (
209
- path === 'cache.retention.olderThan' &&
210
- typeof value === 'string' &&
211
- parseDuration(value) === 0
212
- ) {
213
- throw new UserError(`${where} of 0 evicts every entry after every run`)
214
- }
215
204
  // A file's values are typed: a count or a switch is no string (only a
216
205
  // share of the cores is), as a variable's or a flag's must be.
217
206
  const untyped =
@@ -237,7 +226,6 @@ function validateSettingsExtra(config: WorkspaceConfig, configPath: string): voi
237
226
  }
238
227
  // `policy`'s grammar is the cache module's (`parseCachePolicy`), which
239
228
  // this module may not import: the run parses it before any task (prepare.ts).
240
- if (config.cache?.retention !== undefined) validateRetention(config.cache.retention, configPath)
241
229
  }
242
230
 
243
231
  /**
@@ -250,30 +238,6 @@ function shown(v: unknown): string {
250
238
  return typeof v === 'bigint' ? `${v}n` : (JSON.stringify(v) ?? String(v))
251
239
  }
252
240
 
253
- function validateRetention(
254
- retention: NonNullable<NonNullable<WorkspaceConfig['cache']>['retention']>,
255
- configPath: string,
256
- ): void {
257
- const { olderThan, maxSize } = retention
258
- // An empty object would load and evict nothing, which reads as a policy.
259
- if (olderThan === undefined && maxSize === undefined) {
260
- throw new UserError(
261
- `${configPath}: \`cache.retention\` names neither \`olderThan\` nor \`maxSize\``,
262
- )
263
- }
264
- // As for olderThan (item 969).
265
- if (maxSize !== undefined && parseSize(maxSize) === 0) {
266
- throw new UserError(
267
- `${configPath}: \`cache.retention.maxSize\` of 0 evicts every entry after every run`,
268
- )
269
- }
270
- if (maxSize !== undefined && /^\d+$/.test(maxSize)) {
271
- throw new UserError(
272
- `${configPath}: \`cache.retention.maxSize\` '${maxSize}' reads as ${maxSize} bytes — give a unit (e.g. '${maxSize}MB', '${maxSize}GB')`,
273
- )
274
- }
275
- }
276
-
277
241
  /** A `plugins` entry that stands for no plugin: `undefined`, `null` or `false`. */
278
242
  export function isSkippedPlugin(p: unknown): p is undefined | null | false {
279
243
  return p === undefined || p === null || p === false
@@ -6,7 +6,6 @@ export {
6
6
  type AffectedChanges,
7
7
  affectedProjects,
8
8
  defaultAffectedBase,
9
- branchChanges,
10
9
  type FingerprintClaims,
11
10
  refIsHead,
12
11
  } from './affected.js'