@vzn/vx-migrate 0.0.639 → 0.0.640

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.
@@ -347,16 +347,16 @@ export function planNxUpstream(
347
347
  // `tsconfig.spec.json` (or a dependency's unbuilt `*.d.ts`) quiet; the
348
348
  // file appearing still re-keys it, and a missing literal is silent.
349
349
  const files = [...new Set(['package.json', 'project.json', ...inputs.files])]
350
- const cacheInputs: Record<string, unknown> = { files }
351
- if (inputs.wsFiles.length > 0) cacheInputs.workspaceFiles = inputs.wsFiles
352
- if (inputs.envNames.length > 0) cacheInputs.env = inputs.envNames
353
- if (inputs.runtimeCmds.length > 0) cacheInputs.workspaceRuntime = inputs.runtimeCmds
350
+ const keyed: Record<string, unknown> = { files }
351
+ if (inputs.wsFiles.length > 0) keyed.workspaceFiles = inputs.wsFiles
352
+ if (inputs.envNames.length > 0) keyed.env = inputs.envNames
353
+ if (inputs.runtimeCmds.length > 0) keyed.workspaceRuntime = inputs.runtimeCmds
354
354
  // A keyed group: dependants fold its key; nothing spawns.
355
355
  const task: Record<string, unknown> = {}
356
356
  if (isFileset(name))
357
357
  task.description = `Nx fileset ${name} of this project and what it reaches`
358
358
  if (edges.size > 0) task.dependsOn = [...edges].sort()
359
- task.cache = { inputs: cacheInputs, outputs: { files: [] } }
359
+ task.inputs = keyed
360
360
  const list = out.get(node) ?? []
361
361
  list.push({ name: inputTask(name), task, todos })
362
362
  out.set(node, list)
@@ -354,11 +354,21 @@ function prefixIndex(): {
354
354
  * task core refuses, and every verb failed.
355
355
  */
356
356
  export const isKeyedGroup = (t: { readonly task: Record<string, unknown> | null }): boolean =>
357
- t.task !== null && t.task['exec'] === undefined && t.task['cache'] !== undefined
357
+ t.task !== null && t.task['exec'] === undefined && t.task['inputs'] !== undefined
358
358
 
359
- /** A task's cached `inputs` block, when it has one with a `files` list. */
359
+ /**
360
+ * A task whose key cannot be complete: no cache and no key, so it runs
361
+ * every time and every dependant misses (core's keyless task). Uncached
362
+ * alone, its incomplete key still let a dependant hit stale.
363
+ */
364
+ export function unkey(task: Record<string, unknown>): void {
365
+ delete task['cache']
366
+ delete task['inputs']
367
+ }
368
+
369
+ /** A task's `inputs` block, when it has one with a `files` list. */
360
370
  function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown } | undefined {
361
- const inputs = (t.task?.['cache'] as { inputs?: { files?: unknown } } | undefined)?.inputs
371
+ const inputs = t.task?.['inputs'] as { files?: unknown } | undefined
362
372
  return Array.isArray(inputs?.files)
363
373
  ? (inputs as { files: string[]; workspaceFiles?: unknown })
364
374
  : undefined
@@ -366,7 +376,7 @@ function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown
366
376
 
367
377
  /**
368
378
  * Core refuses a task whose inputs can match another task's declared
369
- * outputs (`rules.upfrontKeys`, on by default, core X-54): such a key
379
+ * outputs (`vx lock`, always, core X-54): such a key
370
380
  * reads what a producer writes this run and cannot be known before it ran.
371
381
  * Turbo and Nx default a task's inputs to every file of its package
372
382
  * (`**\/*`), which matches a sibling build's `dist`. Each overlapping
@@ -396,7 +406,7 @@ export function excludeSiblingOutputs(tasks: GeneratedTask[]): GeneratedTask[] {
396
406
  const literal = read.find((g) => isLiteralPattern(g))
397
407
  if (literal !== undefined && isKeyedGroup(t)) continue
398
408
  if (literal !== undefined) {
399
- delete t.task!['cache']
409
+ unkey(t.task!)
400
410
  t.todos.push(
401
411
  `reads ${JSON.stringify(literal)}, which ${JSON.stringify(w.name)} writes — vx keys a ` +
402
412
  'task only on files no other task writes, so it runs uncached; read the source ' +
@@ -480,7 +490,7 @@ export function resolveSharedWorkspaceOutputs(
480
490
  }
481
491
 
482
492
  /**
483
- * `excludeSiblingOutputs` for root-anchored `cache.inputs.workspaceFiles`:
493
+ * `excludeSiblingOutputs` for root-anchored `inputs.workspaceFiles`:
484
494
  * every other task's `outputs.workspaceFiles`, and its `outputs.files` at
485
495
  * their workspace path, is taken back from a reader's workspace inputs
486
496
  * (Turbo's `globalDependencies` and `$TURBO_ROOT$` entries, Nx's
@@ -530,8 +540,7 @@ export function excludeWorkspaceOutputs(
530
540
  if (writers.length === 0) return
531
541
  for (const p of projects) {
532
542
  for (const t of p.tasks) {
533
- const inputs = (t.task?.['cache'] as { inputs?: { workspaceFiles?: unknown } } | undefined)
534
- ?.inputs
543
+ const inputs = t.task?.['inputs'] as { workspaceFiles?: unknown } | undefined
535
544
  if (inputs === undefined || !Array.isArray(inputs.workspaceFiles)) continue
536
545
  const files = inputs.workspaceFiles as string[]
537
546
  const reads = positive(files.flatMap((f) => (typeof f === 'string' ? [f] : expand(f))))
@@ -546,7 +555,7 @@ export function excludeWorkspaceOutputs(
546
555
  const literal = read.find((g) => isLiteralPattern(g))
547
556
  if (literal !== undefined && isKeyedGroup(t)) continue
548
557
  if (literal !== undefined) {
549
- delete t.task!['cache']
558
+ unkey(t.task!)
550
559
  t.todos.push(
551
560
  `reads ${JSON.stringify(literal)}, which ${w.id} writes — vx keys a task only on ` +
552
561
  'files no other task writes, so it runs uncached; read the source instead in a ' +
@@ -556,7 +565,7 @@ export function excludeWorkspaceOutputs(
556
565
  }
557
566
  files.push(`!${out}`)
558
567
  }
559
- if (t.task?.['cache'] === undefined) break
568
+ if (t.task?.['inputs'] === undefined) break
560
569
  }
561
570
  }
562
571
  }
@@ -581,7 +590,7 @@ export function excludeWorkspaceOutputs(
581
590
  const literal = out.startsWith(at) ? read.find((g) => isLiteralPattern(g)) : read[0]
582
591
  if (literal !== undefined && isKeyedGroup(t)) continue
583
592
  if (literal !== undefined) {
584
- delete t.task!['cache']
593
+ unkey(t.task!)
585
594
  t.todos.push(
586
595
  `reads ${JSON.stringify(literal.slice(at.length))}, which ${w.id} writes — vx keys ` +
587
596
  'a task only on files no other task writes, so it runs uncached; read the ' +
@@ -591,7 +600,7 @@ export function excludeWorkspaceOutputs(
591
600
  }
592
601
  files.push(back)
593
602
  }
594
- if (t.task?.['cache'] === undefined) break
603
+ if (t.task?.['inputs'] === undefined) break
595
604
  }
596
605
  }
597
606
  }
@@ -11,7 +11,7 @@ import { lstat, readFile } from 'node:fs/promises'
11
11
  import path from 'node:path'
12
12
  import { isLiteralPattern, outputsOverlap } from '@vzn/vx'
13
13
  import { relPosix } from './paths.js'
14
- import { isKeyedGroup } from './shared-outputs.js'
14
+ import { isKeyedGroup, unkey } from './shared-outputs.js'
15
15
 
16
16
  /** The files git tracks under `root`, root-relative; null outside a repo or without git. */
17
17
  export async function trackedFiles(root: string): Promise<string[] | null> {
@@ -243,7 +243,7 @@ export function spareTrackedOutputs(
243
243
  for (const q of projects) {
244
244
  const qrel = relPosix(root, q.dir)
245
245
  for (const r of q.tasks) {
246
- const inputs = (r.task?.['cache'] as { inputs?: Outputs } | undefined)?.inputs
246
+ const inputs = r.task?.['inputs'] as Outputs | undefined
247
247
  if (inputs === undefined) continue
248
248
  const own =
249
249
  q === owner &&
@@ -309,7 +309,7 @@ export function spareTrackedOutputs(
309
309
  for (const q of projects) {
310
310
  for (const r of q.tasks) {
311
311
  if (r === t) continue
312
- const inputs = (r.task?.['cache'] as { inputs?: Outputs } | undefined)?.inputs
312
+ const inputs = r.task?.['inputs'] as Outputs | undefined
313
313
  if (inputs === undefined) continue
314
314
  const f =
315
315
  (q === p ? hidden(inputs.files, local) : undefined) ??
@@ -324,7 +324,7 @@ export function spareTrackedOutputs(
324
324
  inputs.workspaceFiles = unhidden(inputs.workspaceFiles, wsBacks, rooted)
325
325
  continue
326
326
  }
327
- delete r.task!['cache']
327
+ unkey(r.task!)
328
328
  todos.push([
329
329
  `${q.name}#${r.name}`,
330
330
  `reads the committed ${f}, which its inputs take back with ${p.name}#${t.name}'s ` +
@@ -24,6 +24,7 @@ import {
24
24
  resolveSharedOutputs,
25
25
  resolveSharedWorkspaceOutputs,
26
26
  takingBack,
27
+ unkey,
27
28
  wildcardCleanTodo,
28
29
  wildcardOutput,
29
30
  wildcardTodo,
@@ -68,7 +69,7 @@ interface TurboJson {
68
69
 
69
70
  const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
70
71
 
71
- /** A name core takes in `cache.inputs.env` and `exec.env.passThrough`. */
72
+ /** A name core takes in `inputs.env` and `exec.env.passThrough`. */
72
73
  const keyable = (name: string): boolean =>
73
74
  name.length > 0 && !name.startsWith('!') && !/[*?[\]{}=\0]/.test(name)
74
75
 
@@ -936,7 +937,7 @@ export async function mapTurboWorkspace(
936
937
  if (envMode === 'loose') {
937
938
  notes.push(
938
939
  'envMode "loose": Turbo passes every environment variable to every task; vx passes only ' +
939
- 'the declared ones — list what each task reads in exec.env.passThrough (or cache.inputs.env)',
940
+ 'the declared ones — list what each task reads in exec.env.passThrough (or inputs.env)',
940
941
  )
941
942
  } else if (
942
943
  !envMode &&
@@ -954,7 +955,7 @@ export async function mapTurboWorkspace(
954
955
  notes.push(
955
956
  `Turbo 1 runs ${loose.join(', ')} in loose env mode (no passThroughEnv, so its "infer" ` +
956
957
  'mode passes every environment variable); vx passes only the declared ones — list what ' +
957
- 'each reads in exec.env.passThrough (or cache.inputs.env)',
958
+ 'each reads in exec.env.passThrough (or inputs.env)',
958
959
  )
959
960
  }
960
961
  }
@@ -1288,12 +1289,12 @@ export async function mapTurboWorkspace(
1288
1289
  notes.push(
1289
1290
  `${fw.slug} in ${few(users)}: Turbo passes ${few(fw.env)} implicitly; the configs list ` +
1290
1291
  'the names the package and its workspace dependencies spell, so add any an installed ' +
1291
- 'dependency reads to cache.inputs.env and exec.env.passThrough',
1292
+ 'dependency reads to inputs.env and exec.env.passThrough',
1292
1293
  )
1293
1294
  for (const [fw, users] of usersOf)
1294
1295
  notes.push(
1295
1296
  `${fw.slug} in ${few(users)}: Turbo passes ${few(fw.env)} implicitly; list the ones ` +
1296
- 'they read in cache.inputs.env and exec.env.passThrough',
1297
+ 'they read in inputs.env and exec.env.passThrough',
1297
1298
  )
1298
1299
 
1299
1300
  // A task's own `*` env name, in a written config: expanded over the
@@ -1470,7 +1471,7 @@ export async function mapTurboWorkspace(
1470
1471
  for (const t of projects.find((p) => p.name === meta.name)?.tasks ?? []) {
1471
1472
  const def = defFor(t.name)
1472
1473
  const dep = def === undefined ? undefined : dependencyOutputs.get(def)
1473
- const inputs = (t.task?.['cache'] as { inputs?: Record<string, unknown> } | undefined)?.inputs
1474
+ const inputs = t.task?.['inputs'] as Record<string, unknown> | undefined
1474
1475
  if (dep === undefined || inputs === undefined) continue
1475
1476
  const rels: string[] = []
1476
1477
  const unfold = new Set<string>()
@@ -1500,7 +1501,7 @@ export async function mapTurboWorkspace(
1500
1501
  if (rels.length === 0) continue
1501
1502
  const probe = rels.every(isLiteral) ? ignoredFilesProbe(rels) : dotenvGlobsProbe(rels)
1502
1503
  if (probe === null) {
1503
- delete t.task!['cache']
1504
+ unkey(t.task!)
1504
1505
  t.todos.push(
1505
1506
  `inputs read ${rels.map((r) => JSON.stringify(r)).join(', ')} from an uncached ` +
1506
1507
  'dependency, a glob vx cannot key by a probe — task runs uncached; key it in a vx.config to cache it',
@@ -1534,7 +1535,7 @@ export async function mapTurboWorkspace(
1534
1535
  notes.push(
1535
1536
  `env ${JSON.stringify(entry)}: the configs list the names the files of each task's ` +
1536
1537
  `package and its workspace dependencies spell (${n} task${n === 1 ? '' : 's'}) — add ` +
1537
- 'any other a task reads to cache.inputs.env and exec.env.passThrough',
1538
+ 'any other a task reads to inputs.env and exec.env.passThrough',
1538
1539
  )
1539
1540
  literalEnvGapsOnce(projects, notes)
1540
1541
  resolveSharedWorkspaceOutputs(root, projects)
@@ -1571,7 +1572,7 @@ function nestedInputs(root: string, projects: readonly TurboMappedProject[]): vo
1571
1572
  const nested = inside.filter((n) => !inside.some((m) => n.startsWith(`${m}/`)))
1572
1573
  if (nested.length === 0) return
1573
1574
  for (const t of p.tasks) {
1574
- const inputs = (t.task?.['cache'] as { inputs?: Record<string, unknown> } | undefined)?.inputs
1575
+ const inputs = t.task?.['inputs'] as Record<string, unknown> | undefined
1575
1576
  const files = inputs?.['files']
1576
1577
  if (inputs === undefined || !Array.isArray(files)) continue
1577
1578
  const extra: string[] = []
@@ -1763,13 +1764,13 @@ function keyOnlyGroup(t: TurboMappedTask): TurboMappedTask {
1763
1764
  const cache = task['cache'] as
1764
1765
  | { outputs: { files: unknown[]; workspaceFiles?: unknown[] } }
1765
1766
  | undefined
1766
- if (cache === undefined && task['dependsOn'] === undefined) return t
1767
+ if (task['inputs'] === undefined && task['dependsOn'] === undefined) return t
1767
1768
  if (
1768
1769
  cache !== undefined &&
1769
1770
  (cache.outputs.files.length > 0 || cache.outputs.workspaceFiles !== undefined)
1770
1771
  )
1771
1772
  return t
1772
- const { exec: _exec, ...group } = task
1773
+ const { exec: _exec, cache: _cache, ...group } = task
1773
1774
  return { ...t, task: group }
1774
1775
  }
1775
1776
 
@@ -1949,7 +1950,7 @@ function buildTask(
1949
1950
  'env',
1950
1951
  e,
1951
1952
  literal,
1952
- ' in vx env names — list explicit names in cache.inputs.env + exec.env.passThrough',
1953
+ ' in vx env names — list explicit names in inputs.env + exec.env.passThrough',
1953
1954
  ),
1954
1955
  ),
1955
1956
  opts.envNames,
@@ -1981,181 +1982,180 @@ function buildTask(
1981
1982
  if (typeof def['description'] === 'string') task.description = def['description']
1982
1983
  if (deps.length > 0) task.dependsOn = deps
1983
1984
 
1984
- if (cacheEnabled) {
1985
- const files: unknown[] = []
1986
- // `.env` files Turbo hashes although git ignores them; a glob over git's
1987
- // files keyed none of them (item 1032). Probed instead, per package and
1988
- // at the root.
1989
- let pkgDotenv = false
1990
- // Any of them below the package root needs the walk.
1991
- let pkgDotenvDeep = false
1992
- // The root-relative `.env` globs, probed at the workspace root.
1993
- const wsDotenv = [...rootDotenv]
1994
- // globalDependencies are workspace-root-relative by definition —
1995
- // they map to inputs.workspaceFiles, not project-relative files.
1996
- const wsFiles: unknown[] = [...global('inputs')]
1997
- // Root-relative path → the entry naming it.
1998
- const rels = new Map<string, Named>(literals.globalIgnored.map((f) => [f, [null, f]]))
1999
- const atRoot = pkgDir === '' || pkgDir === '.'
2000
- if (def.inputs === undefined || def.inputs.length === 0) {
2001
- // Turbo's default input set is every package file, and an empty
2002
- // `inputs` is that default: mapped as no files, it keyed on nothing
2003
- // in the package and an edit replayed a stale build (item 936).
2004
- files.push('**/*')
2005
- } else {
2006
- for (const i of def.inputs) {
2007
- if (i === '$TURBO_DEFAULT$') {
2008
- files.push('**/*')
2009
- continue
2010
- }
2011
- const neg = i.startsWith('!')
2012
- // Turbo's globs have character classes and extglobs; a vx bracket is
2013
- // a literal, so `src/**/*.[jt]s` keyed on nothing and an edit
2014
- // replayed the old build (item 1031). A positive glob with no safe
2015
- // form widens to every package file; a negation with none is dropped
2016
- // (it would exclude fewer files, never more).
2017
- const wax = minimatchToVx(neg ? i.slice(1) : i, neg)
2018
- if (wax === null) {
2019
- todos.push(`input ${JSON.stringify(i)}: glob syntax vx cannot take — map manually`)
2020
- if (!neg) files.push('**/*')
2021
- continue
2022
- }
2023
- const translated = (neg ? '!' : '') + wax
2024
- const body = wax
2025
- const up = climbed(translated)
2026
- if (!neg && isDotenvGlob(body)) {
2027
- if (body.startsWith('$TURBO_ROOT$/')) wsDotenv.push(body.slice('$TURBO_ROOT$/'.length))
2028
- else if (up !== null) wsDotenv.push(up)
2029
- else {
2030
- pkgDotenv = true
2031
- if (body.includes('/')) pkgDotenvDeep = true
2032
- }
2033
- continue
2034
- }
2035
- if (body.startsWith('$TURBO_ROOT$/')) {
2036
- wsFiles.push(...withZeroSegments((neg ? '!' : '') + body.slice('$TURBO_ROOT$/'.length)))
2037
- const rel = body.slice('$TURBO_ROOT$/'.length)
2038
- if (!neg) rels.set(rel, ['workspaceFiles', rel])
2039
- } else if (up !== null) {
2040
- wsFiles.push(...withZeroSegments(up))
2041
- if (!neg) rels.set(up, ['workspaceFiles', up])
2042
- } else if (body.startsWith('../')) {
2043
- todos.push(`input ${JSON.stringify(i)}: leaves the workspace — map manually`)
2044
- } else if (i.includes('$TURBO_ROOT$')) {
2045
- todos.push(
2046
- `input ${JSON.stringify(i)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
2047
- 'prefix (→ cache.inputs.workspaceFiles) — map manually',
2048
- )
2049
- } else {
2050
- // Unkeyed, a root file edit `*\/**` reaches in Turbo replayed a stale hit.
2051
- files.push(...withZeroSegments(translated))
2052
- const rel = path.posix.join(atRoot ? '.' : pkgDir, body)
2053
- if (!neg) rels.set(rel, [atRoot ? 'workspaceFiles' : 'files', translated])
2054
- }
2055
- }
2056
- // Exclusions alone narrow every package file: core refuses a list
2057
- // with nothing to narrow, and that refusal failed the whole run
2058
- // (item 936). Every file is the widest reading, so it can cost a
2059
- // hit and never serve a stale one.
2060
- if (files.length > 0 && files.every((f) => typeof f === 'string' && f.startsWith('!'))) {
2061
- files.unshift('**/*')
1985
+ const files: unknown[] = []
1986
+ // `.env` files Turbo hashes although git ignores them; a glob over git's
1987
+ // files keyed none of them (item 1032). Probed instead, per package and
1988
+ // at the root.
1989
+ let pkgDotenv = false
1990
+ // Any of them below the package root needs the walk.
1991
+ let pkgDotenvDeep = false
1992
+ // The root-relative `.env` globs, probed at the workspace root.
1993
+ const wsDotenv = [...rootDotenv]
1994
+ // globalDependencies are workspace-root-relative by definition —
1995
+ // they map to inputs.workspaceFiles, not project-relative files.
1996
+ const wsFiles: unknown[] = [...global('inputs')]
1997
+ // Root-relative path → the entry naming it.
1998
+ const rels = new Map<string, Named>(literals.globalIgnored.map((f) => [f, [null, f]]))
1999
+ const atRoot = pkgDir === '' || pkgDir === '.'
2000
+ if (def.inputs === undefined || def.inputs.length === 0) {
2001
+ // Turbo's default input set is every package file, and an empty
2002
+ // `inputs` is that default: mapped as no files, it keyed on nothing
2003
+ // in the package and an edit replayed a stale build (item 936).
2004
+ files.push('**/*')
2005
+ } else {
2006
+ for (const i of def.inputs) {
2007
+ if (i === '$TURBO_DEFAULT$') {
2008
+ files.push('**/*')
2009
+ continue
2062
2010
  }
2063
- }
2064
- // Turbo 1's task `dotEnv`: package-relative `.env` files it hashes,
2065
- // gitignored as a rule, so read as files they keyed nothing (item 1032).
2066
- const dotEnv = (def as { dotEnv?: unknown }).dotEnv
2067
- if (Array.isArray(dotEnv) && dotEnv.some((f) => typeof f === 'string')) pkgDotenv = true
2068
-
2069
- const outFiles: string[] = []
2070
- const zeroed = new Set<string>()
2071
- const wsOutFiles: string[] = []
2072
- for (const raw of def.outputs ?? []) {
2073
- // The first segment stays a literal (a route directory, item 667);
2074
- // the rest is Turbo's grammar: `dist/**/*.[cm]js` matched nothing, so
2075
- // a hit restored nothing (item 1031).
2076
- const cut = raw.indexOf('/')
2077
- const rest = cut < 0 ? null : minimatchToVx(raw.slice(cut + 1), raw.startsWith('!'))
2078
- if (cut >= 0 && rest === null) {
2079
- todos.push(
2080
- `output ${JSON.stringify(raw)}: glob syntax vx cannot take — task runs uncached; ` +
2081
- 'declare the exact outputs in a vx.config to cache it',
2082
- )
2083
- return { name, todos, task, uses }
2011
+ const neg = i.startsWith('!')
2012
+ // Turbo's globs have character classes and extglobs; a vx bracket is
2013
+ // a literal, so `src/**/*.[jt]s` keyed on nothing and an edit
2014
+ // replayed the old build (item 1031). A positive glob with no safe
2015
+ // form widens to every package file; a negation with none is dropped
2016
+ // (it would exclude fewer files, never more).
2017
+ const wax = minimatchToVx(neg ? i.slice(1) : i, neg)
2018
+ if (wax === null) {
2019
+ todos.push(`input ${JSON.stringify(i)}: glob syntax vx cannot take — map manually`)
2020
+ if (!neg) files.push('**/*')
2021
+ continue
2084
2022
  }
2085
- const mapped = cut < 0 ? raw : `${raw.slice(0, cut + 1)}${rest}`
2086
- // A `!` output takes a path back, as Turbo's does (A-44): Next's
2087
- // `.next/**` minus `!.next/cache/**` saved the cache and cleaned it
2088
- // before every run while vx outputs could not exclude.
2089
- const neg = mapped.startsWith('!') ? '!' : ''
2090
- const o = mapped.slice(neg.length)
2091
- const signed = (g: string) => {
2092
- const head = zeroSegments(g)
2093
- if (head === null) return [neg + g]
2094
- zeroed.add(neg + head)
2095
- return [neg + g, neg + head]
2023
+ const translated = (neg ? '!' : '') + wax
2024
+ const body = wax
2025
+ const up = climbed(translated)
2026
+ if (!neg && isDotenvGlob(body)) {
2027
+ if (body.startsWith('$TURBO_ROOT$/')) wsDotenv.push(body.slice('$TURBO_ROOT$/'.length))
2028
+ else if (up !== null) wsDotenv.push(up)
2029
+ else {
2030
+ pkgDotenv = true
2031
+ if (body.includes('/')) pkgDotenvDeep = true
2032
+ }
2033
+ continue
2096
2034
  }
2097
- if (o.startsWith('$TURBO_ROOT$/')) {
2098
- wsOutFiles.push(...signed(o.slice('$TURBO_ROOT$/'.length)))
2099
- } else if (climbed(o) !== null) {
2100
- wsOutFiles.push(...signed(climbed(o)!))
2101
- } else if (o.startsWith('../')) {
2102
- todos.push(`output ${JSON.stringify(o)}: leaves the workspace — map manually`)
2103
- } else if (o.includes('$TURBO_ROOT$')) {
2035
+ if (body.startsWith('$TURBO_ROOT$/')) {
2036
+ wsFiles.push(...withZeroSegments((neg ? '!' : '') + body.slice('$TURBO_ROOT$/'.length)))
2037
+ const rel = body.slice('$TURBO_ROOT$/'.length)
2038
+ if (!neg) rels.set(rel, ['workspaceFiles', rel])
2039
+ } else if (up !== null) {
2040
+ wsFiles.push(...withZeroSegments(up))
2041
+ if (!neg) rels.set(up, ['workspaceFiles', up])
2042
+ } else if (body.startsWith('../')) {
2043
+ todos.push(`input ${JSON.stringify(i)}: leaves the workspace — map manually`)
2044
+ } else if (i.includes('$TURBO_ROOT$')) {
2104
2045
  todos.push(
2105
- `output ${JSON.stringify(o)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
2106
- 'prefix (→ cache.outputs.workspaceFiles) — map manually',
2046
+ `input ${JSON.stringify(i)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
2047
+ 'prefix (→ inputs.workspaceFiles) — map manually',
2107
2048
  )
2108
- } else outFiles.push(...signed(o))
2049
+ } else {
2050
+ // Unkeyed, a root file edit `*\/**` reaches in Turbo replayed a stale hit.
2051
+ files.push(...withZeroSegments(translated))
2052
+ const rel = path.posix.join(atRoot ? '.' : pkgDir, body)
2053
+ if (!neg) rels.set(rel, [atRoot ? 'workspaceFiles' : 'files', translated])
2054
+ }
2109
2055
  }
2110
-
2111
- // With git's file list known, a wildcard-first output stays cached as
2112
- // nx() keeps it (owner, 2026-10-03; turbo() 2026-10-14): core never
2113
- // cleans a committed file. Turbo never cleans, so the untracked files
2114
- // it reaches are named. An X added for `X/**` is judged with it.
2115
- const wild = wildcardOutput(
2116
- outFiles.filter((g) => !zeroed.has(g)),
2117
- opts.tracked?.(pkgDir),
2118
- true,
2119
- )
2120
- if (wild !== undefined && opts.tracked === undefined) {
2121
- todos.push(wildcardTodo(wild))
2122
- return { name, todos, task, uses }
2056
+ // Exclusions alone narrow every package file: core refuses a list
2057
+ // with nothing to narrow, and that refusal failed the whole run
2058
+ // (item 936). Every file is the widest reading, so it can cost a
2059
+ // hit and never serve a stale one.
2060
+ if (files.length > 0 && files.every((f) => typeof f === 'string' && f.startsWith('!'))) {
2061
+ files.unshift('**/*')
2123
2062
  }
2124
- if (wild !== undefined) todos.push(wildcardCleanTodo(wild))
2125
- // An output over the project's own package.json (trpc's builds list
2126
- // it: tsdown rewrites `exports`) is taken back and keeps the cache, as
2127
- // nx() does (owner, 2026-10-03). The build rewrites an input the key
2128
- // folds, so its identical rewrite counts as unchanged ('mutable').
2129
- const takeBacks = ownFileTakeBacks(outFiles, opts.ownConfig?.(pkgDir))
2130
- outFiles.push(...takeBacks)
2131
- const rewritesManifest = takeBacks.includes('!package.json')
2132
-
2133
- const cacheEnv = uniq([...global('env'), ...envNames], hidden('env'))
2134
-
2135
- // Turbo hashes a root task over the whole repo; core stops a root
2136
- // project's own globs at every member (D-39), so as `files` a root
2137
- // `oxlint` over the repo replayed green after a member file broke.
2138
- // A root task's globs are the workspace's, outputs too.
2139
- if (pkgDir === '' || pkgDir === '.') {
2140
- wsFiles.unshift(...files.splice(0))
2141
- wsOutFiles.unshift(...outFiles.splice(0))
2063
+ }
2064
+ // Turbo 1's task `dotEnv`: package-relative `.env` files it hashes,
2065
+ // gitignored as a rule, so read as files they keyed nothing (item 1032).
2066
+ const dotEnv = (def as { dotEnv?: unknown }).dotEnv
2067
+ if (Array.isArray(dotEnv) && dotEnv.some((f) => typeof f === 'string')) pkgDotenv = true
2068
+
2069
+ const keyEnv = uniq([...global('env'), ...envNames], hidden('env'))
2070
+
2071
+ // Turbo hashes a root task over the whole repo; core stops a root
2072
+ // project's own globs at every member (D-39), so as `files` a root
2073
+ // `oxlint` over the repo replayed green after a member file broke.
2074
+ // A root task's globs are the workspace's, outputs too.
2075
+ if (atRoot) wsFiles.unshift(...files.splice(0))
2076
+ const inputs: Record<string, unknown> = { files: withoutTakenBack(files) }
2077
+ if (wsFiles.length > 0) inputs.workspaceFiles = withoutTakenBack(uniq(wsFiles, hidden('inputs')))
2078
+ if (keyEnv.length > 0) inputs.env = keyEnv
2079
+ if (pkgDotenv) inputs.runtime = [pkgDotenvDeep ? DOTENV_PROBE : DOTENV_PROBE_TOP]
2080
+ if (wsDotenv.length > 0) inputs.workspaceRuntime = [dotenvGlobsProbe(wsDotenv) ?? DOTENV_PROBE]
2081
+ for (const rel of rels.keys()) if (!isLiteral(rel)) rels.delete(rel)
2082
+ literals.tasks.push({ inputs, rels })
2083
+ // Turbo keys every task on its inputs, cached or not, and a dependant
2084
+ // folds that key: an uncached task's inputs still move its dependants.
2085
+ task.inputs = inputs
2086
+ if (!cacheEnabled) return { name, todos, task, uses }
2087
+
2088
+ const outFiles: string[] = []
2089
+ const zeroed = new Set<string>()
2090
+ const wsOutFiles: string[] = []
2091
+ for (const raw of def.outputs ?? []) {
2092
+ // The first segment stays a literal (a route directory, item 667);
2093
+ // the rest is Turbo's grammar: `dist/**/*.[cm]js` matched nothing, so
2094
+ // a hit restored nothing (item 1031).
2095
+ const cut = raw.indexOf('/')
2096
+ const rest = cut < 0 ? null : minimatchToVx(raw.slice(cut + 1), raw.startsWith('!'))
2097
+ if (cut >= 0 && rest === null) {
2098
+ todos.push(
2099
+ `output ${JSON.stringify(raw)}: glob syntax vx cannot take — task runs uncached; ` +
2100
+ 'declare the exact outputs in a vx.config to cache it',
2101
+ )
2102
+ return { name, todos, task, uses }
2142
2103
  }
2143
- const inputs: Record<string, unknown> = { files: withoutTakenBack(files) }
2144
- if (wsFiles.length > 0)
2145
- inputs.workspaceFiles = withoutTakenBack(uniq(wsFiles, hidden('inputs')))
2146
- if (cacheEnv.length > 0) inputs.env = cacheEnv
2147
- if (pkgDotenv) inputs.runtime = [pkgDotenvDeep ? DOTENV_PROBE : DOTENV_PROBE_TOP]
2148
- if (wsDotenv.length > 0) inputs.workspaceRuntime = [dotenvGlobsProbe(wsDotenv) ?? DOTENV_PROBE]
2149
- for (const rel of rels.keys()) if (!isLiteral(rel)) rels.delete(rel)
2150
- literals.tasks.push({ inputs, rels })
2151
- const outputs: Record<string, unknown> = { files: takingBack(outFiles) }
2152
- const ws = takingBack(wsOutFiles)
2153
- if (ws.length > 0) outputs.workspaceFiles = ws
2154
- task.cache = {
2155
- inputs,
2156
- outputs,
2157
- ...(rewritesManifest ? { inputStability: { strategy: 'mutable' } } : {}),
2104
+ const mapped = cut < 0 ? raw : `${raw.slice(0, cut + 1)}${rest}`
2105
+ // A `!` output takes a path back, as Turbo's does (A-44): Next's
2106
+ // `.next/**` minus `!.next/cache/**` saved the cache and cleaned it
2107
+ // before every run while vx outputs could not exclude.
2108
+ const neg = mapped.startsWith('!') ? '!' : ''
2109
+ const o = mapped.slice(neg.length)
2110
+ const signed = (g: string) => {
2111
+ const head = zeroSegments(g)
2112
+ if (head === null) return [neg + g]
2113
+ zeroed.add(neg + head)
2114
+ return [neg + g, neg + head]
2158
2115
  }
2116
+ if (o.startsWith('$TURBO_ROOT$/')) {
2117
+ wsOutFiles.push(...signed(o.slice('$TURBO_ROOT$/'.length)))
2118
+ } else if (climbed(o) !== null) {
2119
+ wsOutFiles.push(...signed(climbed(o)!))
2120
+ } else if (o.startsWith('../')) {
2121
+ todos.push(`output ${JSON.stringify(o)}: leaves the workspace — map manually`)
2122
+ } else if (o.includes('$TURBO_ROOT$')) {
2123
+ todos.push(
2124
+ `output ${JSON.stringify(o)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
2125
+ 'prefix (→ cache.outputs.workspaceFiles) — map manually',
2126
+ )
2127
+ } else outFiles.push(...signed(o))
2128
+ }
2129
+
2130
+ // With git's file list known, a wildcard-first output stays cached as
2131
+ // nx() keeps it (owner, 2026-10-03; turbo() 2026-10-14): core never
2132
+ // cleans a committed file. Turbo never cleans, so the untracked files
2133
+ // it reaches are named. An X added for `X/**` is judged with it.
2134
+ const wild = wildcardOutput(
2135
+ outFiles.filter((g) => !zeroed.has(g)),
2136
+ opts.tracked?.(pkgDir),
2137
+ true,
2138
+ )
2139
+ if (wild !== undefined && opts.tracked === undefined) {
2140
+ todos.push(wildcardTodo(wild))
2141
+ return { name, todos, task, uses }
2142
+ }
2143
+ if (wild !== undefined) todos.push(wildcardCleanTodo(wild))
2144
+ // An output over the project's own package.json (trpc's builds list
2145
+ // it: tsdown rewrites `exports`) is taken back and keeps the cache, as
2146
+ // nx() does (owner, 2026-10-03). The build rewrites an input the key
2147
+ // folds, so its identical rewrite counts as unchanged ('mutable').
2148
+ const takeBacks = ownFileTakeBacks(outFiles, opts.ownConfig?.(pkgDir))
2149
+ outFiles.push(...takeBacks)
2150
+ const rewritesManifest = takeBacks.includes('!package.json')
2151
+
2152
+ if (atRoot) wsOutFiles.unshift(...outFiles.splice(0))
2153
+ const outputs: Record<string, unknown> = { files: takingBack(outFiles) }
2154
+ const ws = takingBack(wsOutFiles)
2155
+ if (ws.length > 0) outputs.workspaceFiles = ws
2156
+ task.cache = {
2157
+ outputs,
2158
+ ...(rewritesManifest ? { inputStability: { strategy: 'mutable' } } : {}),
2159
2159
  }
2160
2160
 
2161
2161
  return { name, todos, task, uses }