@vzn/vx-migrate 0.0.650 → 0.0.652

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.
@@ -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,14 +287,14 @@ 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
+ uncache(t.task!)
300
298
  t.todos.push(
301
299
  `declares the output ${JSON.stringify(shared)} that ${JSON.stringify(clash.name)} also ` +
302
300
  "declares — vx cleans a task's outputs before it runs and before a restore, so two " +
@@ -369,10 +367,18 @@ export const isKeyedGroup = (t: { readonly task: Record<string, unknown> | null
369
367
  * alone, its incomplete key still let a dependant hit stale.
370
368
  */
371
369
  export function unkey(task: Record<string, unknown>): void {
372
- delete task['cache']
370
+ uncache(task)
373
371
  delete task['inputs']
374
372
  }
375
373
 
374
+ /** No cache: `outputs` and `inputs.stability` go with it, as they mean nothing without. */
375
+ export function uncache(task: Record<string, unknown>): void {
376
+ delete task['cache']
377
+ delete task['outputs']
378
+ const inputs = task['inputs'] as Record<string, unknown> | undefined
379
+ if (inputs !== undefined) delete inputs['stability']
380
+ }
381
+
376
382
  /** A task's `inputs` block, when it has one with a `files` list. */
377
383
  function inputsOf(t: GeneratedTask): { files: string[]; workspaceFiles?: unknown } | undefined {
378
384
  const inputs = t.task?.['inputs'] as { files?: unknown } | undefined
@@ -457,11 +463,11 @@ export function resolveSharedWorkspaceOutputs(
457
463
  for (const p of projects) {
458
464
  const rel = relPosix(root, p.dir)
459
465
  for (const t of p.tasks) {
460
- const cache = t.task?.['cache'] as
461
- | { outputs?: { files?: unknown; workspaceFiles?: unknown } }
466
+ const outputs = t.task?.['outputs'] as
467
+ | { files?: unknown; workspaceFiles?: unknown }
462
468
  | undefined
463
- const ws = positive(cache?.outputs?.workspaceFiles)
464
- const own = positive(cache?.outputs?.files).map((g) =>
469
+ const ws = positive(outputs?.workspaceFiles)
470
+ const own = positive(outputs?.files).map((g) =>
465
471
  rel === '' ? g.replace(/^(\.\/)+/, '') : `${rel}/${g.replace(/^(\.\/)+/, '')}`,
466
472
  )
467
473
  if (ws.length === 0 && own.length === 0) continue
@@ -485,7 +491,7 @@ export function resolveSharedWorkspaceOutputs(
485
491
  kept.push({ id: `${p.name}#${t.name}`, project: p.name, ws, own })
486
492
  continue
487
493
  }
488
- delete t.task!['cache']
494
+ uncache(t.task!)
489
495
  t.todos.push(
490
496
  `declares the workspace output ${JSON.stringify(shared)} that ${clash.id} also declares — ` +
491
497
  "vx cleans a task's outputs before it runs and before a restore, so two cached tasks " +
@@ -525,9 +531,9 @@ export function excludeWorkspaceOutputs(
525
531
  for (const p of projects) {
526
532
  const rel = relPosix(root, p.dir)
527
533
  for (const t of p.tasks) {
528
- const outputs = (
529
- t.task?.['cache'] as { outputs?: { files?: unknown; workspaceFiles?: unknown } } | undefined
530
- )?.outputs
534
+ const outputs = t.task?.['outputs'] as
535
+ | { files?: unknown; workspaceFiles?: unknown }
536
+ | undefined
531
537
  const globs = [
532
538
  ...positive(outputs?.workspaceFiles),
533
539
  ...positive(outputs?.files).map((g) =>
@@ -266,7 +266,7 @@ export function spareTrackedOutputs(
266
266
  const rel = relPosix(root, p.dir)
267
267
  const own = rel === '' ? tracked : filesUnder(tracked, rel).map((f) => f.slice(rel.length + 1))
268
268
  for (const t of p.tasks) {
269
- const outputs = (t.task?.['cache'] as { outputs?: Outputs } | undefined)?.outputs
269
+ const outputs = t.task?.['outputs'] as Outputs | undefined
270
270
  if (outputs === undefined) continue
271
271
  const hits: [key: 'files' | 'workspaceFiles', hit: string[]][] = []
272
272
  for (const [key, base] of [
@@ -24,6 +24,7 @@ import { onceReader, type ReadText, tscBuildInfo } from '../tsc-buildinfo.js'
24
24
  import type * as TurboMap from './turbo-map.js'
25
25
  import type { TurboMappedProject } from './turbo-map.js'
26
26
  import {
27
+ ENV_CONDITION_KEYS,
27
28
  envNamesThatMap,
28
29
  microfrontendsConfigs,
29
30
  rootTaskProject,
@@ -71,12 +72,24 @@ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
71
72
  const keys = await workspaceKeys(rootOf(ctx.workspaceRoot))
72
73
  if (workspace.run?.concurrency === undefined && keys.concurrency !== undefined)
73
74
  workspace.run = { ...workspace.run, concurrency: keys.concurrency }
75
+ // `TURBO_FORCE` (`true` or `1`, as Turbo reads it) is `turbo run --force`.
76
+ const force = Bun.env['TURBO_FORCE']?.trim()
77
+ if (force === 'true' || force === '1')
78
+ workspace.cache = { ...workspace.cache, policy: forcedPolicy(workspace.cache?.policy) }
74
79
  // `turbo run --affected` compares with TURBO_SCM_BASE when set, and
75
80
  // on GitHub Actions with what the event names.
76
81
  const base =
77
82
  Bun.env['TURBO_SCM_BASE']?.trim() ||
78
83
  githubActionsBase(rootOf(ctx.workspaceRoot), keys.remoteBaseFallback === true)
79
84
  if (base) ctx.setBranch({ base })
85
+ // ...up to TURBO_SCM_HEAD. vx's head is the checkout (HEAD and its
86
+ // working tree): a head at HEAD's commit is that, any other is said.
87
+ const head = Bun.env['TURBO_SCM_HEAD']?.trim()
88
+ if (head && !atHead(rootOf(ctx.workspaceRoot), head))
89
+ ctx.warn(
90
+ `TURBO_SCM_HEAD (${head}) is not the checked-out commit: --affected compares ` +
91
+ `the base with the checkout, so check out ${head} to compare with it.`,
92
+ )
80
93
  },
81
94
  // Turbo's packages are the package manager's members, each a project
82
95
  // with no vx.config written. `//#task` keys run on the root, as Turbo
@@ -99,6 +112,18 @@ export function turbo(options: TurboPluginOptions = {}): VxPlugin {
99
112
 
100
113
  const UNKNOWN_SHA = '0'.repeat(40)
101
114
 
115
+ /** Whether `ref` names HEAD's commit. */
116
+ function atHead(root: string, ref: string): boolean {
117
+ // An option is no ref; rev-parse prints `--end-of-options` back (git 2.43).
118
+ if (ref.startsWith('-')) return false
119
+ const proc = Bun.spawnSync(['git', 'rev-parse', 'HEAD', `${ref}^{commit}`], {
120
+ cwd: root,
121
+ stderr: 'ignore',
122
+ })
123
+ const [head, at] = proc.stdout.toString().split('\n')
124
+ return proc.exitCode === 0 && head === at
125
+ }
126
+
102
127
  /**
103
128
  * Turbo's `--affected` base on GitHub Actions when `TURBO_SCM_BASE` is
104
129
  * unset (`get_github_base_ref`): a pull request's `GITHUB_BASE_REF`, else
@@ -185,6 +210,25 @@ async function workspaceKeys(root: string): Promise<{
185
210
  return out
186
211
  }
187
212
 
213
+ /**
214
+ * A policy with every read off, as `--force` is: no hit is replayed. The
215
+ * remote is written only where the workspace's own policy names it, so a
216
+ * laptop's forced run never writes the cache CI reads.
217
+ */
218
+ function forcedPolicy(spec = ''): string {
219
+ const flags = new Map([
220
+ ['local', 'w'],
221
+ ['remote', ''],
222
+ ])
223
+ for (const seg of spec.split(',')) {
224
+ if (seg.trim() === '') continue
225
+ const m = /^(local|remote):([rw]*)$/.exec(seg.trim())
226
+ if (m === null) return spec // core refuses it, naming `cache.policy`
227
+ flags.set(m[1]!, m[2]!.replace('r', ''))
228
+ }
229
+ return [...flags].map(([layer, f]) => `${layer}:${f}`).join(',')
230
+ }
231
+
188
232
  const envNamesNow = (): string[] => Object.keys(process.env).sort()
189
233
 
190
234
  const textOf = (file: string): Promise<string> =>
@@ -223,6 +267,8 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
223
267
  // An env wildcard expands over these; a new name it matches maps afresh.
224
268
  JSON.stringify(envNames),
225
269
  process.env['TURBO_CI_VENDOR_ENV_KEY'] ?? '',
270
+ // A framework conditional keys its names on these values (skew protection).
271
+ JSON.stringify(ENV_CONDITION_KEYS.map((k) => process.env[k] ?? null)),
226
272
  process.env['TURBO_ENV_MODE'] ?? '',
227
273
  ...configs,
228
274
  yarnrc,
@@ -265,6 +311,7 @@ async function mapAll(
265
311
  splice: (_kind, values) => values,
266
312
  persistentTodo: PERSISTENT_NOTE,
267
313
  envNames,
314
+ envValue: (name) => process.env[name],
268
315
  vendorEnvPrefix: process.env['TURBO_CI_VENDOR_ENV_KEY'] ?? '',
269
316
  envMode: process.env['TURBO_ENV_MODE'] ?? '',
270
317
  ...(tracked === null ? {} : { tracked: trackedKinds(tracked) }),
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The root manifest's `engines`, compacted, printed by one `awk` at the
3
+ * workspace root: Turbo folds that value into every task's global hash
4
+ * (crates/turborepo-task-hash/src/global_hash.rs), so raising
5
+ * `engines.node` re-ran every task under `turbo run` and replayed a hit
6
+ * under vx. A probe reads it live, so `turbo()` and the config the
7
+ * migration writes key it alike; it prints only the `engines` value, so
8
+ * no other root edit re-keys a task. A top-level JSON scanner, strings
9
+ * and escapes skipped, whitespace outside strings dropped: a reformat
10
+ * keys alike, a reorder misses (never a stale hit). One process, no Node.
11
+ */
12
+ export const ROOT_ENGINES_PROBE =
13
+ 'LC_ALL=C awk \'BEGIN{RS="\\001"}{n=length($0);d=0;q=0;c=0;o="";for(i=1;i<=n;i++){x=substr($0,i,1);if(c&&(q||x!~/[ \\t\\r\\n]/))o=o x;if(q){if(x=="\\\\"){i++;if(c)o=o substr($0,i,1)}else if(x=="\\""){q=0;if(d==1&&!c)k=substr($0,s,i-s)}continue}if(x=="\\""){q=1;s=i+1}else if(x=="{"||x=="[")d++;else if(x=="}"||x=="]"){d--;if(c&&d==1){print o;exit}}else if(x==":"&&d==1&&!c&&k=="engines")c=1}}\' package.json'
14
+
15
+ /** Whether Turbo hashes the root's `engines`: a non-empty object, as it reads one. */
16
+ export function keysRootEngines(rootPackageJson: unknown): boolean {
17
+ const engines = (rootPackageJson as { engines?: unknown } | null)?.engines
18
+ return typeof engines === 'object' && engines !== null && Object.keys(engines).length > 0
19
+ }
@@ -24,6 +24,7 @@ import {
24
24
  resolveSharedOutputs,
25
25
  resolveSharedWorkspaceOutputs,
26
26
  takingBack,
27
+ uncache,
27
28
  unkey,
28
29
  wildcardCleanTodo,
29
30
  wildcardOutput,
@@ -36,9 +37,12 @@ import {
36
37
  keyable,
37
38
  microfrontendsConfigs,
38
39
  ROOT,
40
+ TURBO_BUILTIN_PASS_THROUGH,
41
+ TURBO_DEFAULT_ENV,
39
42
  turboConfigFile,
40
43
  } from './turbo-reads.js'
41
44
  import type { TrackedKinds } from '../tracked-outputs.js'
45
+ import { keysRootEngines, ROOT_ENGINES_PROBE } from './root-engines.js'
42
46
  import {
43
47
  DOTENV_PROBE,
44
48
  DOTENV_PROBE_TOP,
@@ -72,6 +76,8 @@ interface TurboJson {
72
76
  /** Turbo 2.11 `futureFlags.globalConfiguration`: the global lists live here. */
73
77
  global?: { inputs?: string[]; env?: string[]; passThroughEnv?: string[]; envMode?: unknown }
74
78
  envMode?: unknown
79
+ /** Turbo 2.11.6: the package's labels, which `--filter tag:` selects by. */
80
+ tags?: string[]
75
81
  }
76
82
 
77
83
  const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
@@ -133,6 +139,18 @@ function explicitEnv(
133
139
  return out.filter((n) => !excluded.some((re) => re.test(n)))
134
140
  }
135
141
 
142
+ /**
143
+ * The literal names of Turbo's builtin pass-through list, the same under
144
+ * `turbo()` as in written configs: `exec.env.passThrough` is keyed by name,
145
+ * so a list matched against the live environment keyed each machine apart
146
+ * and missed the cache `turbo()` filled once the configs were written. A
147
+ * wildcard family is a note. A name that is no shell variable name
148
+ * (`ProgramFiles(x86)`) is left out: core refuses it there.
149
+ */
150
+ const BUILTIN_PASS_THROUGH = TURBO_BUILTIN_PASS_THROUGH.filter((e) =>
151
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(e),
152
+ )
153
+
136
154
  /** A gap's message: a wildcard, or a literal name core cannot key. */
137
155
  const envGap = (field: string, entry: string, literal: string | null, list: string): string =>
138
156
  literal === null
@@ -161,8 +179,11 @@ const KNOWN_TASK_KEYS = new Set([
161
179
  // watch` keeps it up and restarts it only when its config changes or it
162
180
  // dies, so either value maps to nothing.
163
181
  'interruptible',
164
- // Labels: Turbo keeps them out of the hash and the behaviour.
182
+ // Turbo 2.11.6 selects a task by them (`--filter tag:`); a vx tag belongs
183
+ // to a project, so only the package-level `tags` map (`packageTags`).
165
184
  'tags',
185
+ // Turbo parses it for external CI tooling and acts on nothing.
186
+ 'experimentalCI',
166
187
  ])
167
188
 
168
189
  // Turbo's per-task `outputLogs` against vx's per-run `--output-logs`.
@@ -191,6 +212,8 @@ export interface TurboMappedTask {
191
212
  export interface TurboMappedProject {
192
213
  name: string
193
214
  dir: string
215
+ /** The package's turbo.json `tags`, its vx project tags. */
216
+ tags?: string[]
194
217
  tasks: TurboMappedTask[]
195
218
  }
196
219
 
@@ -222,6 +245,12 @@ export interface MapTurboOptions {
222
245
  * Absent (`bunx @vzn/vx-migrate` writes files), a wildcard is a todo.
223
246
  */
224
247
  envNames?: readonly string[]
248
+ /**
249
+ * A variable's value where the tasks will run, read only for a framework
250
+ * conditional's key (Turbo's `envConditionals`). Absent (written configs),
251
+ * every conditional name is keyed: more misses, never a stale hit.
252
+ */
253
+ envValue?: (name: string) => string | undefined
225
254
  /**
226
255
  * Without `envNames` (written configs): the upper-case names the files
227
256
  * under `dirs` spell, which a framework's `*` prefix is matched against.
@@ -354,10 +383,12 @@ const TOP_LISTS = [
354
383
  'globalPassThroughEnv',
355
384
  'globalDotEnv',
356
385
  'extends',
386
+ 'tags',
357
387
  ]
358
388
  const GLOBAL_LISTS = ['inputs', 'env', 'passThroughEnv']
359
389
  const TASK_LISTS = ['dependsOn', 'outputs', 'env', 'passThroughEnv', 'with', 'dotEnv']
360
390
  const TASK_FLAGS = ['cache', 'persistent', 'interactive', 'interruptible']
391
+ const ENV_LISTS = new Set(['globalEnv', 'globalPassThroughEnv', 'env', 'passThroughEnv'])
361
392
 
362
393
  /**
363
394
  * The shape of every field the mapper reads, refused by name. A number
@@ -373,12 +404,47 @@ function checkTurboShape(cfg: unknown, label: string): void {
373
404
  if (v !== undefined && (!Array.isArray(v) || v.some((x) => typeof x !== 'string')))
374
405
  refuse(at, 'an array of strings')
375
406
  }
407
+ // Turbo refuses `$NAME` in an env list (`InvalidEnvPrefix`), its own
408
+ // `$TURBO_EXTENDS$` aside; mapped, core keyed a variable literally
409
+ // named `$NAME`, which is never set.
410
+ const envList = (v: unknown, at: string): void => {
411
+ list(v, at)
412
+ const bad = (v as string[] | undefined)?.find(
413
+ (x) => x.startsWith('$') && x !== '$TURBO_EXTENDS$',
414
+ )
415
+ if (bad !== undefined)
416
+ throw new UserError(
417
+ `${label}: ${at} holds ${JSON.stringify(bad)}: Turbo refuses a "$" before an env name — write ${JSON.stringify(bad.slice(1))}`,
418
+ )
419
+ }
420
+ // Turbo refuses an absolute glob (`AbsolutePathInConfig`), read past its
421
+ // `!` and `$TURBO_ROOT$/`; mapped, core refused it naming the project,
422
+ // as if its own config held it.
423
+ const paths = (v: unknown, at: string): void => {
424
+ for (const x of Array.isArray(v) ? v : []) {
425
+ const globs: unknown[] = isRecord(x) && Array.isArray(x['globs']) ? x['globs'] : [x]
426
+ for (const g of globs) {
427
+ if (typeof g !== 'string') continue
428
+ const bare = g.replace(/^!/, '').replace(/^\$TURBO_ROOT\$\//, '')
429
+ if (path.isAbsolute(bare))
430
+ throw new UserError(
431
+ `${label}: ${at} holds ${JSON.stringify(g)}: Turbo refuses an absolute path — name it relative to the package, or to the root with $TURBO_ROOT$/`,
432
+ )
433
+ }
434
+ }
435
+ }
376
436
  if (!isRecord(cfg)) refuse('the file', 'a JSON object')
377
437
  const c = cfg as Record<string, unknown>
378
- for (const k of TOP_LISTS) list(c[k], k)
438
+ for (const k of TOP_LISTS) (ENV_LISTS.has(k) ? envList : list)(c[k], k)
439
+ paths(c['globalDependencies'], 'globalDependencies')
379
440
  if (c['global'] !== undefined) {
380
441
  if (!isRecord(c['global'])) refuse('global', 'an object')
381
- for (const k of GLOBAL_LISTS) list((c['global'] as Record<string, unknown>)[k], `global.${k}`)
442
+ for (const k of GLOBAL_LISTS)
443
+ (ENV_LISTS.has(k) ? envList : list)(
444
+ (c['global'] as Record<string, unknown>)[k],
445
+ `global.${k}`,
446
+ )
447
+ paths((c['global'] as Record<string, unknown>)['inputs'], 'global.inputs')
382
448
  }
383
449
  for (const field of ['tasks', 'pipeline']) {
384
450
  const tasks = c[field]
@@ -388,7 +454,7 @@ function checkTurboShape(cfg: unknown, label: string): void {
388
454
  const at = `${field}.${JSON.stringify(name)}`
389
455
  if (!isRecord(def)) refuse(at, 'an object')
390
456
  const d = def as Record<string, unknown>
391
- for (const k of TASK_LISTS) list(d[k], `${at}.${k}`)
457
+ for (const k of TASK_LISTS) (ENV_LISTS.has(k) ? envList : list)(d[k], `${at}.${k}`)
392
458
  // Turbo 2.11 adds `{ mode, globs, withDefaults }` entries (`flatInputs`).
393
459
  const inputs = d['inputs']
394
460
  if (
@@ -396,6 +462,8 @@ function checkTurboShape(cfg: unknown, label: string): void {
396
462
  (!Array.isArray(inputs) || inputs.some((x) => typeof x !== 'string' && !isRecord(x)))
397
463
  )
398
464
  refuse(`${at}.inputs`, 'an array of globs')
465
+ paths(inputs, `${at}.inputs`)
466
+ paths(d['outputs'], `${at}.outputs`)
399
467
  for (const k of TASK_FLAGS)
400
468
  if (d[k] !== undefined && typeof d[k] !== 'boolean') refuse(`${at}.${k}`, 'true or false')
401
469
  for (const k of ['outputLogs', 'outputMode'])
@@ -404,6 +472,16 @@ function checkTurboShape(cfg: unknown, label: string): void {
404
472
  }
405
473
  }
406
474
 
475
+ /**
476
+ * A package's top-level turbo.json `tags`, which Turbo 2.11.6's `--filter
477
+ * tag:<label>` selects every task of the package by, as vx's `tag:` does a
478
+ * project's. Its own file only: Turbo inherits no package tag, and the
479
+ * root's label the root package. Blank ones dropped, as vx refuses them.
480
+ */
481
+ function packageTags(cfg: TurboJson | undefined): string[] {
482
+ return [...new Set((cfg?.tags ?? []).filter((t) => t.trim() !== ''))]
483
+ }
484
+
407
485
  function tasksOf(cfg: TurboJson): Record<string, TurboTask> {
408
486
  return cfg.tasks ?? cfg.pipeline ?? {}
409
487
  }
@@ -762,6 +840,22 @@ async function rootPackageJson(root: string): Promise<PackageMeta['packageJson']
762
840
  return (pkg !== null && typeof pkg === 'object' ? pkg : {}) as PackageMeta['packageJson']
763
841
  }
764
842
 
843
+ /**
844
+ * `turboConfigFile`, refusing a directory that holds both files as Turbo
845
+ * does (vercel/turborepo#10083): `turbo.json` shadowed the `.jsonc` beside
846
+ * it in silence, so an edit there keyed and ran nothing.
847
+ */
848
+ async function onlyConfigFile(dir: string, root: string): Promise<string | null> {
849
+ const file = await turboConfigFile(dir)
850
+ if (file?.endsWith('.json') === true && (await Bun.file(`${file}c`).exists())) {
851
+ const where = dir === root ? 'the workspace root' : relPosix(root, dir)
852
+ throw new UserError(
853
+ `${where} holds both turbo.json and turbo.jsonc: Turbo refuses this — remove one`,
854
+ )
855
+ }
856
+ return file
857
+ }
858
+
765
859
  export async function mapTurboWorkspace(
766
860
  root: string,
767
861
  metas: readonly PackageMeta[],
@@ -769,7 +863,16 @@ export async function mapTurboWorkspace(
769
863
  ): Promise<TurboMapping> {
770
864
  // A workspace with no Turbo config answered with a bare ENOENT and a stack
771
865
  // (item 1043): turbo() declared in the wrong repo, or its file removed.
772
- const rootFile = await turboConfigFile(root)
866
+ const rootFile = await onlyConfigFile(root, root)
867
+ // Turbo reads its root config from the file TURBO_ROOT_TURBO_JSON names,
868
+ // against the cwd (vercel/turborepo#9087); reading the root's in its
869
+ // place ran another task graph than `turbo run`.
870
+ const named = Bun.env['TURBO_ROOT_TURBO_JSON']
871
+ if (named && (rootFile === null || path.resolve(named) !== rootFile)) {
872
+ throw new UserError(
873
+ `TURBO_ROOT_TURBO_JSON names ${path.resolve(named)}: turbo() reads only the workspace root's turbo.json or turbo.jsonc — unset it, or move that config to the root`,
874
+ )
875
+ }
773
876
  if (rootFile === null) {
774
877
  throw new UserError(
775
878
  `no turbo.json or turbo.jsonc at the workspace root (${root}): turbo() maps a Turbo repo's config — add one, or remove turbo() from .vx/workspace.ts`,
@@ -829,6 +932,7 @@ export async function mapTurboWorkspace(
829
932
  // over git's files keyed nothing: the workspace probe keys them (item
830
933
  // 1032).
831
934
  const rootMeta = metas.find((m) => path.resolve(m.dir) === path.resolve(root))
935
+ const rootPkg = rootMeta?.packageJson ?? (await rootPackageJson(root))
832
936
  const globalFiles = [
833
937
  ...globalDeps.filter((d) => envDependency(d) === null).map(turboGlobstar),
834
938
  ...(rootCfg.globalDotEnv ?? []),
@@ -837,7 +941,7 @@ export async function mapTurboWorkspace(
837
941
  // packages that depend on the database package).
838
942
  ...(rootCfg.tasks === undefined && rootCfg.pipeline !== undefined
839
943
  ? []
840
- : rootDependencyGlobs(root, rootMeta?.packageJson ?? (await rootPackageJson(root)), metas)),
944
+ : rootDependencyGlobs(root, rootPkg, metas)),
841
945
  ...microfrontendsConfigs(root, [root, ...metas.map((m) => m.dir)]).map((c) => c.rel),
842
946
  ]
843
947
  const rootDotenv = globalFiles.filter((f) => isDotenvGlob(f))
@@ -851,18 +955,31 @@ export async function mapTurboWorkspace(
851
955
  const globals = {
852
956
  inputs: globalFiles.filter((f) => !isDotenvGlob(f) && !globalIgnored.includes(f)),
853
957
  env: [
854
- ...envNames('globalEnv', rootCfg.globalEnv ?? []),
958
+ // Turbo's defaults sit before the user's list, so its `!` takes them back.
959
+ ...envNames('globalEnv', [...TURBO_DEFAULT_ENV, ...(rootCfg.globalEnv ?? [])]),
855
960
  ...globalDeps.flatMap((d) => envDependency(d) ?? []),
856
961
  ],
857
- pass: envNames('globalPassThroughEnv', rootCfg.globalPassThroughEnv ?? []),
962
+ pass: [
963
+ ...envNames('globalPassThroughEnv', rootCfg.globalPassThroughEnv ?? []),
964
+ ...BUILTIN_PASS_THROUGH,
965
+ ],
858
966
  }
859
967
 
968
+ // A written config names no variable by pattern: Turbo's builtin
969
+ // families are the user's to list, once, for the tasks that read them.
970
+ if (opts.envNames === undefined && opts.sourceNames !== undefined)
971
+ notes.push(
972
+ `Turbo passes every ${TURBO_BUILTIN_PASS_THROUGH.filter((e) => e.endsWith('*')).join(', ')} ` +
973
+ 'variable to each task; the configs pass its literal names only — add the ones a task ' +
974
+ 'reads to exec.env.passThrough',
975
+ )
976
+
860
977
  // The workspace root as a project (a `vx.config` there, D-39) holds
861
978
  // Turbo's `//#task`s (`rootMeta`, above); Turbo runs no plain task in the root package.
862
979
  const files = new Map<string, TurboJson>([[ROOT, rootCfg]])
863
980
  for (const meta of metas) {
864
981
  if (meta === rootMeta) continue
865
- const file = await turboConfigFile(meta.dir)
982
+ const file = await onlyConfigFile(meta.dir, root)
866
983
  if (file !== null) files.set(meta.name, await readTurboJson(file, root))
867
984
  }
868
985
 
@@ -1113,6 +1230,8 @@ export async function mapTurboWorkspace(
1113
1230
  const head = e.slice(0, -1)
1114
1231
  return live.filter((n) => n.startsWith(head)).sort()
1115
1232
  })
1233
+ for (const c of fw.conditionals ?? [])
1234
+ if (opts.envValue?.(c.key) === c.value) names.push(...c.include)
1116
1235
  inferredOf.set(m.name, vendor ? names.filter((n) => !n.startsWith(vendor)) : names)
1117
1236
  } else if (opts.sourceNames !== undefined) {
1118
1237
  // A written config cannot ask the run's environment, and with the
@@ -1125,11 +1244,15 @@ export async function mapTurboWorkspace(
1125
1244
  m.name,
1126
1245
  // The bare prefix is no name: formbricks' web spells `NEXT_PUBLIC_`
1127
1246
  // in a `startsWith` test, and the configs keyed a variable of it.
1128
- fw.env.flatMap((e) => {
1129
- if (!e.endsWith('*')) return [e]
1130
- const head = e.slice(0, -1)
1131
- return spelled.filter((n) => n.startsWith(head) && n.length > head.length)
1132
- }),
1247
+ [
1248
+ ...fw.env.flatMap((e) => {
1249
+ if (!e.endsWith('*')) return [e]
1250
+ const head = e.slice(0, -1)
1251
+ return spelled.filter((n) => n.startsWith(head) && n.length > head.length)
1252
+ }),
1253
+ // Written for another environment: keyed whatever the condition.
1254
+ ...(fw.conditionals ?? []).flatMap((c) => c.include),
1255
+ ],
1133
1256
  )
1134
1257
  sourcedOf.set(fw, [...(sourcedOf.get(fw) ?? []), m.name])
1135
1258
  } else usersOf.set(fw, [...(usersOf.get(fw) ?? []), m.name])
@@ -1323,7 +1446,8 @@ export async function mapTurboWorkspace(
1323
1446
  tasks.push(mapped)
1324
1447
  }
1325
1448
  excludeSiblingOutputs(resolveSharedOutputs(tasks))
1326
- projects.push({ name: meta.name, dir: meta.dir, tasks })
1449
+ const tags = packageTags(meta === rootMeta ? rootCfg : files.get(meta.name))
1450
+ projects.push({ name: meta.name, dir: meta.dir, ...(tags.length > 0 ? { tags } : {}), tasks })
1327
1451
  }
1328
1452
 
1329
1453
  // Turbo 2.11's `dependencyOutputs` input hashes the named files after
@@ -1426,6 +1550,14 @@ export async function mapTurboWorkspace(
1426
1550
  }
1427
1551
  }
1428
1552
 
1553
+ if (keysRootEngines(rootPkg))
1554
+ for (const p of projects)
1555
+ for (const t of p.tasks) {
1556
+ const inputs = t.task?.['inputs'] as Record<string, unknown> | undefined
1557
+ if (inputs === undefined) continue
1558
+ const probes = (inputs['workspaceRuntime'] as unknown[] | undefined) ?? []
1559
+ inputs['workspaceRuntime'] = [...probes, ROOT_ENGINES_PROBE]
1560
+ }
1429
1561
  nestedInputs(root, projects)
1430
1562
  for (const [entry, n] of wildcardUses)
1431
1563
  notes.push(
@@ -1446,9 +1578,42 @@ export async function mapTurboWorkspace(
1446
1578
  pruneUnreachedPersistentNotes(projects, metas, opts.persistentTodo)
1447
1579
  pruneOrphanPersistentNotes(projects, opts.persistentTodo)
1448
1580
  refuseTaskNames(projects, 'task')
1581
+ sortOrderFree(projects)
1449
1582
  return { projects, notes, globals }
1450
1583
  }
1451
1584
 
1585
+ function byText(a: unknown, b: unknown): number {
1586
+ const x = typeof a === 'string' ? a : JSON.stringify(a)
1587
+ const y = typeof b === 'string' ? b : JSON.stringify(b)
1588
+ return x < y ? -1 : x > y ? 1 : 0
1589
+ }
1590
+
1591
+ function negationsLast(a: unknown, b: unknown): number {
1592
+ const neg = (g: unknown) => Number(typeof g === 'string' && g.startsWith('!'))
1593
+ return neg(a) - neg(b) || byText(a, b)
1594
+ }
1595
+
1596
+ /**
1597
+ * Turbo sorts a task's `outputs` and `dependsOn` before hashing, so
1598
+ * reordering them in turbo.json keeps the hit (vercel/turborepo#2064).
1599
+ * Core keys a config's lists as written, and gives neither list's order a
1600
+ * meaning: an edge set, and a `!` output takes back from the positives
1601
+ * wherever it stands (`outputMatcher`). Sorted here, negations last, so
1602
+ * the reorder keys the same and a written config reads the same.
1603
+ */
1604
+ function sortOrderFree(projects: readonly TurboMappedProject[]): void {
1605
+ for (const p of projects)
1606
+ for (const t of p.tasks) {
1607
+ const task = t.task
1608
+ if (task === null) continue
1609
+ if (Array.isArray(task['dependsOn'])) task['dependsOn'] = [...task['dependsOn']].sort(byText)
1610
+ const outputs = task['outputs'] as Record<string, unknown> | undefined
1611
+ if (outputs === undefined) continue
1612
+ for (const k of ['files', 'workspaceFiles'])
1613
+ if (Array.isArray(outputs[k])) outputs[k] = [...outputs[k]].sort(negationsLast)
1614
+ }
1615
+ }
1616
+
1452
1617
  /**
1453
1618
  * Turbo hashes a package's files as git lists them, nested workspace
1454
1619
  * packages included (probed on 2.11.6: `a#build` keys `n/x.ts` of a
@@ -1658,16 +1823,12 @@ function keyOnlyGroup(t: TurboMappedTask): TurboMappedTask {
1658
1823
  if (task === null) return t
1659
1824
  const exec = task['exec'] as Record<string, unknown>
1660
1825
  if (exec['persistent'] !== undefined || exec['interactive'] !== undefined) return t
1661
- const cache = task['cache'] as
1662
- | { outputs: { files: unknown[]; workspaceFiles?: unknown[] } }
1663
- | undefined
1826
+ const outputs = task['outputs'] as { files: unknown[]; workspaceFiles?: unknown[] } | undefined
1664
1827
  if (task['inputs'] === undefined && task['dependsOn'] === undefined) return t
1665
- if (
1666
- cache !== undefined &&
1667
- (cache.outputs.files.length > 0 || cache.outputs.workspaceFiles !== undefined)
1668
- )
1828
+ if (outputs !== undefined && (outputs.files.length > 0 || outputs.workspaceFiles !== undefined))
1669
1829
  return t
1670
- const { exec: _exec, cache: _cache, ...group } = task
1830
+ const { exec: _exec, ...group } = task
1831
+ uncache(group)
1671
1832
  return { ...t, task: group }
1672
1833
  }
1673
1834
 
@@ -1800,6 +1961,12 @@ function buildTask(
1800
1961
  todos.push(
1801
1962
  `dependsOn ${JSON.stringify(d)}: no workspace package is named ${pkg} — edge dropped`,
1802
1963
  )
1964
+ // Turbo runs a root task only when turbo.json names it `//#x`; the
1965
+ // script alone is not enough (vercel/turborepo#3592).
1966
+ else if (d.startsWith(`${ROOT}#`))
1967
+ todos.push(
1968
+ `dependsOn ${JSON.stringify(d)}: turbo.json defines no "${d}" task — edge dropped`,
1969
+ )
1803
1970
  else
1804
1971
  todos.push(
1805
1972
  `dependsOn ${JSON.stringify(d)}: ${pkg} declares no ${task} script — edge dropped`,
@@ -1961,6 +2128,14 @@ function buildTask(
1961
2128
  if (files.length > 0 && files.every((f) => typeof f === 'string' && f.startsWith('!'))) {
1962
2129
  files.unshift('**/*')
1963
2130
  }
2131
+ // Turbo keys the package's own package.json beside any explicit
2132
+ // `inputs` (PACKAGE_CONFIG_INPUTS, turborepo#1832): without it, an edit
2133
+ // to its `exports` or `type` replayed the old build. A glob naming it
2134
+ // already, or an exclusion of it, is left as written.
2135
+ const named = files.some(
2136
+ (g) => typeof g === 'string' && new Bun.Glob(g.replace(/^!/, '')).match('package.json'),
2137
+ )
2138
+ if (!named) files.push('package.json')
1964
2139
  }
1965
2140
  // Turbo 1's task `dotEnv`: package-relative `.env` files it hashes,
1966
2141
  // gitignored as a rule, so read as files they keyed nothing (item 1032).
@@ -2038,7 +2213,7 @@ function buildTask(
2038
2213
  } else if (o.includes('$TURBO_ROOT$')) {
2039
2214
  todos.push(
2040
2215
  `output ${JSON.stringify(o)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
2041
- 'prefix (→ cache.outputs.workspaceFiles) — map manually',
2216
+ 'prefix (→ outputs.workspaceFiles) — map manually',
2042
2217
  )
2043
2218
  } else outFiles.push(...signed(o))
2044
2219
  }
@@ -2072,12 +2247,18 @@ function buildTask(
2072
2247
  const rewritesManifest = takeBacks.includes('!package.json')
2073
2248
 
2074
2249
  if (atRoot) wsOutFiles.unshift(...outFiles.splice(0))
2075
- const outputs: Record<string, unknown> = { files: takingBack(outFiles) }
2076
- const ws = takingBack(wsOutFiles)
2250
+ // Sorted here, before the passes that take them back from other tasks'
2251
+ // inputs, so those `!` entries come in this order too (`sortOrderFree`).
2252
+ const outputs: Record<string, unknown> = { files: takingBack(outFiles).sort(negationsLast) }
2253
+ const ws = takingBack(wsOutFiles).sort(negationsLast)
2077
2254
  if (ws.length > 0) outputs.workspaceFiles = ws
2078
- task.cache = {
2079
- outputs,
2080
- ...(rewritesManifest ? { inputStability: { strategy: 'mutable' } } : {}),
2255
+ task.outputs = outputs
2256
+ task.cache = true
2257
+ if (rewritesManifest) {
2258
+ task.inputs = {
2259
+ ...(task.inputs as Record<string, unknown>),
2260
+ stability: { strategy: 'mutable' },
2261
+ }
2081
2262
  }
2082
2263
 
2083
2264
  return { name, todos, task, uses }