@vzn/vx-migrate 0.0.651 → 0.0.653

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,14 +1,57 @@
1
1
  /** How long the body's first chunk is waited for: a refusal is not held on a body that never comes. */
2
2
  const BODY_WAIT_MS = 1_000
3
3
 
4
+ /**
5
+ * The most of the first chunk read: a token that starts within the line's
6
+ * 200 characters must end within it to be hidden whole (a JWT is ~1 KB).
7
+ */
8
+ const READ_BYTES = 4096
9
+
10
+ /** Shorter values are not hidden, as core masks none under 6 characters: each would blank common words. */
11
+ const MIN_TOKEN_CHARS = 6
12
+
13
+ /**
14
+ * `text` with each of `tokens` written `***`: a server that quoted a
15
+ * request's Authorization header printed the token to the CI log and
16
+ * cache.db, as core masks only secret-named variables.
17
+ */
18
+ export function hideTokens(text: string, tokens: Iterable<string | undefined>): string {
19
+ let out = text
20
+ for (const token of tokens)
21
+ if (token !== undefined && token.length >= MIN_TOKEN_CHARS) out = out.replaceAll(token, '***')
22
+ return out
23
+ }
24
+
25
+ /** `work`, each error it throws with `hide` applied: a thrown fetch error can quote a request's header. */
26
+ export async function hidingErrors<T>(
27
+ work: Promise<T>,
28
+ hide: (text: string) => string,
29
+ ): Promise<T> {
30
+ try {
31
+ return await work
32
+ } catch (err) {
33
+ const message = err instanceof Error ? err.message : String(err)
34
+ const hidden = hide(message)
35
+ if (hidden === message) throw err
36
+ // The code is how core groups a failure (a reset connection), and a retry reads it.
37
+ const code = (err as { code?: unknown } | null)?.code
38
+ throw Object.assign(new Error(hidden), code === undefined ? {} : { code })
39
+ }
40
+ }
41
+
4
42
  /**
5
43
  * A remote's refusal, named by its status and its own words: "HTTP 500"
6
44
  * alone said nothing, and a batch query Vercel refused with 400 "Too many
7
45
  * hashes. Limit is 100." read as no answer and was never seen (main
8
- * f71b377, 2026-10-23). One line of the body, bounded: it is remote text.
9
- * Shared by `turboCache()` and `nxCache()`.
46
+ * f71b377, 2026-10-23). One line of the body, bounded: it is remote text,
47
+ * each token `hide` knows written `***` before the cut, so none is left in
48
+ * part. Shared by `turboCache()` and `nxCache()`.
10
49
  */
11
- export async function refusal(res: Response, what = ''): Promise<Error> {
50
+ export async function refusal(
51
+ res: Response,
52
+ hide: (text: string) => string,
53
+ what = '',
54
+ ): Promise<Error> {
12
55
  let said = ''
13
56
  try {
14
57
  const reader = res.body?.getReader()
@@ -22,7 +65,9 @@ export async function refusal(res: Response, what = ''): Promise<Error> {
22
65
  ]).finally(() => clearTimeout(timer))
23
66
  // quiet: read no further; a cancel that fails has nothing left to free
24
67
  void reader.cancel().catch(() => undefined)
25
- said = new TextDecoder().decode(first?.value?.subarray(0, 512)).replace(/\s+/g, ' ').trim()
68
+ said = hide(new TextDecoder().decode(first?.value?.subarray(0, READ_BYTES)))
69
+ .replace(/\s+/g, ' ')
70
+ .trim()
26
71
  }
27
72
  } catch {
28
73
  // quiet: the status alone still names the refusal
@@ -179,8 +179,8 @@ interface Cached {
179
179
  function cachedOutputs(t: GeneratedTask, index: number): Cached | null {
180
180
  const task = t.task
181
181
  if (task === null) return null
182
- const cache = task['cache'] as { outputs?: { files?: unknown } } | undefined
183
- const files = cache?.outputs?.files
182
+ const outputs = task['outputs'] as { files?: unknown } | undefined
183
+ const files = outputs?.files
184
184
  if (!Array.isArray(files)) return null
185
185
  // Where a task's outputs might land is its positive globs, as core reads
186
186
  // them: a `!` compiled as a glob is true of every other path.
@@ -267,9 +267,7 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
267
267
  for (const { k, gb } of pairs) {
268
268
  if (k.back.has(gb)) continue
269
269
  k.back.add(gb)
270
- ;(tasks[k.index]!.task!['cache'] as { outputs: { files: string[] } }).outputs.files.push(
271
- `!${gb}`,
272
- )
270
+ ;(tasks[k.index]!.task!['outputs'] as { files: string[] }).files.push(`!${gb}`)
273
271
  }
274
272
  kept.push(c)
275
273
  continue
@@ -289,17 +287,18 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
289
287
  sharedFiles.every((g) => isLiteralPattern(g) && TSBUILDINFO.test(g))
290
288
  ) {
291
289
  c.files = c.files.filter((g) => !sharedFiles.includes(g))
292
- const outputs = (t.task!['cache'] as { outputs: { files: string[] } }).outputs
290
+ const outputs = t.task!['outputs'] as { files: string[] }
293
291
  outputs.files = outputs.files.filter((g) => !sharedFiles.includes(g))
294
292
  kept.push(c)
295
293
  continue
296
294
  }
297
295
  dropped.add(c.index)
298
296
  const shared = c.files.find((gb) => clash.files.some((ga) => hits(clash, ga, gb)))!
299
- delete t.task!['cache']
297
+ const theirs = clash.files.find((ga) => hits(clash, ga, shared))!
298
+ uncache(t.task!)
300
299
  t.todos.push(
301
- `declares the output ${JSON.stringify(shared)} that ${JSON.stringify(clash.name)} also ` +
302
- "declares — vx cleans a task's outputs before it runs and before a restore, so two " +
300
+ `declares the output ${sharing(shared, theirs, JSON.stringify(clash.name))} — ` +
301
+ "vx cleans a task's outputs before it runs and before a restore, so two " +
303
302
  "cached tasks on one path would delete each other's work; this one runs uncached. " +
304
303
  'Give it its own output path to cache it.',
305
304
  )
@@ -308,6 +307,13 @@ export function resolveSharedOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
308
307
  return tasks
309
308
  }
310
309
 
310
+ /** `"<mine>" that <owner> also declares`, or both globs when they only overlap. */
311
+ function sharing(mine: string, theirs: string, owner: string): string {
312
+ return mine === theirs
313
+ ? `${JSON.stringify(mine)} that ${owner} also declares`
314
+ : `${JSON.stringify(mine)}, which overlaps the output ${JSON.stringify(theirs)} that ${owner} declares`
315
+ }
316
+
311
317
  /**
312
318
  * Two globs can match one path only when one's literal prefix is a
313
319
  * directory of the other's (`packages/*\/dist/**` and `packages/a/dist/**`),
@@ -369,10 +375,18 @@ export const isKeyedGroup = (t: { readonly task: Record<string, unknown> | null
369
375
  * alone, its incomplete key still let a dependant hit stale.
370
376
  */
371
377
  export function unkey(task: Record<string, unknown>): void {
372
- delete task['cache']
378
+ uncache(task)
373
379
  delete task['inputs']
374
380
  }
375
381
 
382
+ /** No cache: `outputs` and `inputs.stability` go with it, as they mean nothing without. */
383
+ export function uncache(task: Record<string, unknown>): void {
384
+ delete task['cache']
385
+ delete task['outputs']
386
+ const inputs = task['inputs'] as Record<string, unknown> | undefined
387
+ if (inputs !== undefined) delete inputs['stability']
388
+ }
389
+
376
390
  /** A task's `inputs` block, when it has one with a `files` list. */
377
391
  function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown } | undefined {
378
392
  const inputs = t.task?.['inputs'] as { files?: unknown } | undefined
@@ -391,10 +405,17 @@ function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown
391
405
  * files git tracks, and a build's outputs are not, so the adopted tool
392
406
  * never keyed them either. A literal input another task writes cannot be
393
407
  * taken back (core refuses a `!` over a literal input), so that task runs
394
- * uncached with a todo. A reader whose `!` hides a committed file is the
395
- * tracked pass's (`spareTrackedOutputs`).
408
+ * uncached with a todo. Under `walksGlobs` (Turbo) so does a glob that
409
+ * names the output's place (`gen/**` beside a `gen` writing `gen/**`)
410
+ * when no `dependsOn` path reaches the writer: Turbo walks an explicit
411
+ * glob on disk, and with no edge the writer's key does not reach the
412
+ * reader's either, so taken back it replayed the old output. A glob led
413
+ * by `**` walks the whole package, as the default does, and keeps the
414
+ * `!`. Nx hashes no gitignored file, and its mapper drops an input under
415
+ * an untracked output before this pass. A reader whose `!` hides a
416
+ * committed file is the tracked pass's (`spareTrackedOutputs`).
396
417
  */
397
- export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
418
+ export function excludeSiblingOutputs(tasks: GeneratedTask[], walksGlobs = false): GeneratedTask[] {
398
419
  const writers = tasks.flatMap((t, index) => {
399
420
  const c = cachedOutputs(t, index)
400
421
  return c === null ? [] : [c]
@@ -421,6 +442,18 @@ export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
421
442
  )
422
443
  return
423
444
  }
445
+ const named = walksGlobs
446
+ ? read.find((g) => !g.replace(/^(\.\/)+/, '').startsWith('**'))
447
+ : undefined
448
+ if (named !== undefined && !isKeyedGroup(t) && !reaches(tasks, index, w.index)) {
449
+ unkey(t.task!)
450
+ t.todos.push(
451
+ `reads ${JSON.stringify(named)}, which ${JSON.stringify(w.name)} writes and no ` +
452
+ 'dependsOn reaches — vx keys a task only on files no other task writes, so it runs ' +
453
+ 'uncached; add the edge or read the source instead in a vx.config to cache it',
454
+ )
455
+ return
456
+ }
424
457
  inputs.files.push(`!${out}`)
425
458
  }
426
459
  }
@@ -428,6 +461,26 @@ export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
428
461
  return tasks
429
462
  }
430
463
 
464
+ /** Does a same-project `dependsOn` path lead from task `from` to task `to`? */
465
+ function reaches(tasks: readonly GeneratedTask[], from: number, to: number): boolean {
466
+ const byName = new Map(tasks.map((t, i) => [t.name, i]))
467
+ const seen = new Set([from])
468
+ const queue = [from]
469
+ for (let i = queue.shift(); i !== undefined; i = queue.shift()) {
470
+ const deps = tasks[i]!.task?.['dependsOn']
471
+ if (!Array.isArray(deps)) continue
472
+ for (const d of deps) {
473
+ const next = typeof d === 'string' ? byName.get(d) : undefined
474
+ if (next === to) return true
475
+ if (next !== undefined && !seen.has(next)) {
476
+ seen.add(next)
477
+ queue.push(next)
478
+ }
479
+ }
480
+ }
481
+ return false
482
+ }
483
+
431
484
  /**
432
485
  * The same across projects, for workspace outputs: cal.com's shared
433
486
  * `post-install` writes `../../node_modules/@prisma/client/**` from every
@@ -457,11 +510,11 @@ export function resolveSharedWorkspaceOutputs(
457
510
  for (const p of projects) {
458
511
  const rel = relPosix(root, p.dir)
459
512
  for (const t of p.tasks) {
460
- const cache = t.task?.['cache'] as
461
- | { outputs?: { files?: unknown; workspaceFiles?: unknown } }
513
+ const outputs = t.task?.['outputs'] as
514
+ | { files?: unknown; workspaceFiles?: unknown }
462
515
  | undefined
463
- const ws = positive(cache?.outputs?.workspaceFiles)
464
- const own = positive(cache?.outputs?.files).map((g) =>
516
+ const ws = positive(outputs?.workspaceFiles)
517
+ const own = positive(outputs?.files).map((g) =>
465
518
  rel === '' ? g.replace(/^(\.\/)+/, '') : `${rel}/${g.replace(/^(\.\/)+/, '')}`,
466
519
  )
467
520
  if (ws.length === 0 && own.length === 0) continue
@@ -470,11 +523,19 @@ export function resolveSharedWorkspaceOutputs(
470
523
  a.find((g) => b.some((h) => outputsOverlap(h, g)))
471
524
  let clash: (typeof kept)[number] | undefined
472
525
  let shared: string | undefined
526
+ let theirs: string | undefined
473
527
  for (const i of candidates([...ws, ...own])) {
474
528
  const k = kept[i]!
475
- shared =
476
- overlap(ws, [...k.ws, ...k.own]) ??
477
- overlap(own, k.project === p.name ? k.ws : [...k.ws, ...k.own])
529
+ const pools: [string[], string[]][] = [
530
+ [ws, [...k.ws, ...k.own]],
531
+ [own, k.project === p.name ? k.ws : [...k.ws, ...k.own]],
532
+ ]
533
+ for (const [mine, other] of pools) {
534
+ shared = overlap(mine, other)
535
+ if (shared === undefined) continue
536
+ theirs = other.find((h) => outputsOverlap(h, shared!))
537
+ break
538
+ }
478
539
  if (shared !== undefined) {
479
540
  clash = k
480
541
  break
@@ -485,9 +546,9 @@ export function resolveSharedWorkspaceOutputs(
485
546
  kept.push({ id: `${p.name}#${t.name}`, project: p.name, ws, own })
486
547
  continue
487
548
  }
488
- delete t.task!['cache']
549
+ uncache(t.task!)
489
550
  t.todos.push(
490
- `declares the workspace output ${JSON.stringify(shared)} that ${clash.id} also declares — ` +
551
+ `declares the workspace output ${sharing(shared!, theirs!, clash.id)} — ` +
491
552
  "vx cleans a task's outputs before it runs and before a restore, so two cached tasks " +
492
553
  "on one path would delete each other's work; this one runs uncached. Give it its own " +
493
554
  'output path to cache it.',
@@ -525,9 +586,9 @@ export function excludeWorkspaceOutputs(
525
586
  for (const p of projects) {
526
587
  const rel = relPosix(root, p.dir)
527
588
  for (const t of p.tasks) {
528
- const outputs = (
529
- t.task?.['cache'] as { outputs?: { files?: unknown; workspaceFiles?: unknown } } | undefined
530
- )?.outputs
589
+ const outputs = t.task?.['outputs'] as
590
+ | { files?: unknown; workspaceFiles?: unknown }
591
+ | undefined
531
592
  const globs = [
532
593
  ...positive(outputs?.workspaceFiles),
533
594
  ...positive(outputs?.files).map((g) =>
@@ -72,6 +72,47 @@ export async function gitIgnored(root: string, rels: readonly string[]): Promise
72
72
  }
73
73
  }
74
74
 
75
+ /**
76
+ * What git ignores under the fixed prefixes of the root-relative `globs`:
77
+ * each ignored file, and a directory ignored whole (or holding only
78
+ * ignored files) as one `dir/` entry (`--directory`: `dist/` is never
79
+ * walked). Entries under `node_modules` (the lockfile keys an install) or
80
+ * vx's own `.vx/` are left out. One spawn; empty outside a repo or
81
+ * without git.
82
+ */
83
+ export async function gitIgnoredUnder(root: string, globs: readonly string[]): Promise<string[]> {
84
+ if (globs.length === 0) return []
85
+ const prefixes = [...new Set(globs.map((g) => literalPrefix(g) || '.'))].sort()
86
+ // A prefix under another adds nothing; `ls-files` takes no pathspec file.
87
+ const kept = prefixes.includes('.')
88
+ ? ['.']
89
+ : prefixes.filter((p, i) => !prefixes.slice(0, i).some((q) => p.startsWith(`${q}/`)))
90
+ try {
91
+ const p = Bun.spawn(
92
+ [
93
+ 'git',
94
+ 'ls-files',
95
+ '-z',
96
+ '--others',
97
+ '--ignored',
98
+ '--exclude-standard',
99
+ '--directory',
100
+ '--no-empty-directory',
101
+ '--',
102
+ ...kept,
103
+ ],
104
+ { cwd: root, stdout: 'pipe', stderr: 'ignore' },
105
+ )
106
+ const out = await new Response(p.stdout).text()
107
+ if ((await p.exited) !== 0) return []
108
+ return out
109
+ .split('\0')
110
+ .filter((f) => f !== '' && !f.startsWith('.vx/') && !f.split('/').includes('node_modules'))
111
+ } catch {
112
+ return []
113
+ }
114
+ }
115
+
75
116
  /**
76
117
  * What a project's tracked files are: their extensions (lower case, no
77
118
  * dot), directory names at any depth, and top-level entry names.
@@ -165,18 +206,20 @@ function literalPrefix(glob: string): string {
165
206
  return out.join('/')
166
207
  }
167
208
 
168
- /** The tracked `files` (relative to the same base as `globs`) the positive globs cover and no `!` takes back. */
209
+ /**
210
+ * The tracked `files` (relative to the same base as `globs`) the positive
211
+ * globs cover, even one the globs' own `!` takes back: a reader's take-back
212
+ * is of the positives alone (`excludeSiblingOutputs`), so its `!*\/**`
213
+ * beside a `*\/**` minus `!src/**` output still hid src.
214
+ */
169
215
  function coveredTracked(globs: readonly string[], files: readonly string[]): string[] {
170
- const positive = globs.filter((g) => !g.startsWith('!'))
171
- const taken = globs.filter((g) => g.startsWith('!')).map((g) => g.slice(1))
172
216
  const hit = new Set<string>()
173
- for (const g of positive) {
217
+ for (const g of globs) {
218
+ if (g.startsWith('!')) continue
174
219
  const prefix = literalPrefix(g)
175
220
  for (const f of files) {
176
221
  if (prefix !== '' && f !== prefix && !f.startsWith(`${prefix}/`)) continue
177
- if (!outputsOverlap(g, f)) continue
178
- if (taken.some((t) => outputsOverlap(t, f))) continue
179
- hit.add(f)
222
+ if (outputsOverlap(g, f)) hit.add(f)
180
223
  }
181
224
  }
182
225
  return [...hit].sort()
@@ -266,7 +309,7 @@ export function spareTrackedOutputs(
266
309
  const rel = relPosix(root, p.dir)
267
310
  const own = rel === '' ? tracked : filesUnder(tracked, rel).map((f) => f.slice(rel.length + 1))
268
311
  for (const t of p.tasks) {
269
- const outputs = (t.task?.['cache'] as { outputs?: Outputs } | undefined)?.outputs
312
+ const outputs = t.task?.['outputs'] as Outputs | undefined
270
313
  if (outputs === undefined) continue
271
314
  const hits: [key: 'files' | 'workspaceFiles', hit: string[]][] = []
272
315
  for (const [key, base] of [
@@ -275,20 +318,16 @@ export function spareTrackedOutputs(
275
318
  ] as const) {
276
319
  const globs = outputs[key]
277
320
  if (!Array.isArray(globs) || globs.length === 0) continue
278
- let strings = globs.filter((g): g is string => typeof g === 'string')
321
+ const strings = globs.filter((g): g is string => typeof g === 'string')
279
322
  // An output naming one committed file (eslint's
280
- // `eslint-suppressions.json`) is a source: the entry goes, and so
281
- // does the `!` a reader's inputs got for it, or every reader ran
282
- // uncached (hono).
323
+ // `eslint-suppressions.json`, a codegen's `gen.ts`) is also a
324
+ // source: the `!` a reader's inputs got for it goes, or every
325
+ // reader ran uncached (hono). The entry stays: dropped, a task
326
+ // that rewrites the file never saved.
283
327
  const rootedOf = (g: string) => (key === 'files' && rel !== '' ? `${rel}/${g}` : g)
284
- const sources = new Set(
285
- strings.filter((g) => isLiteralPattern(g) && trackedSet.has(rootedOf(g))),
286
- )
287
- if (sources.size > 0) {
288
- strings = strings.filter((g) => !sources.has(g.startsWith('!') ? g.slice(1) : g))
289
- outputs[key] = strings
290
- for (const g of sources) unhide(key === 'files' ? p : null, rootedOf(g))
291
- }
328
+ for (const g of strings)
329
+ if (isLiteralPattern(g) && trackedSet.has(rootedOf(g)))
330
+ unhide(key === 'files' ? p : null, rootedOf(g))
292
331
  const hit = coveredTracked(strings, base)
293
332
  if (hit.length > 0) hits.push([key, hit])
294
333
  }
@@ -18,7 +18,7 @@ import { refuseUnknownOptions, UserError, type PluginOptionKinds } from '@vzn/vx
18
18
  import { type AdoptionRun, adoptionPlugin } from '../adoption-plugin.js'
19
19
  import type { AdoptionMapping } from '../mapping-cache.js'
20
20
  import { collectGaps } from '../plugin-gaps.js'
21
- import { gitIgnored, trackedKinds } from '../tracked-outputs.js'
21
+ import { gitIgnored, gitIgnoredUnder, trackedKinds } from '../tracked-outputs.js'
22
22
  import { packageScripts, relPosix } from '../paths.js'
23
23
  import { onceReader, type ReadText, tscBuildInfo } from '../tsc-buildinfo.js'
24
24
  import type * as TurboMap from './turbo-map.js'
@@ -317,6 +317,7 @@ async function mapAll(
317
317
  ...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
318
318
  ownConfig: (rel) => configs.get(rel === '.' ? '' : rel) ?? null,
319
319
  ignored: (rels) => gitIgnored(root, rels),
320
+ ignoredUnder: (globs) => gitIgnoredUnder(root, globs),
320
321
  buildInfo: (rel, command) => tscBuildInfo(read, path.join(root, rel), command),
321
322
  })
322
323
  const byName = new Map<string, TurboMappedProject>()