@vzn/vx-migrate 0.0.0 → 0.0.485

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.
@@ -0,0 +1,1976 @@
1
+ // Turbo → vx mapping, the pure half. Reads turbo.json (`tasks` in turbo 2,
2
+ // `pipeline` in turbo 1), per-package turbo.json overlays and each package's
3
+ // scripts, and emits one TaskConfig-shaped object per (package, task) — a
4
+ // task exists for a package only when the package declares the script,
5
+ // turbo's own rule. Two consumers, one mapper, so they cannot drift:
6
+ // - `@vzn/vx-migrate` renders these to vx.config.ts files, splicing
7
+ // turbo's global fields in as imports of a generated preset;
8
+ // - `@vzn/vx-migrate` hands them to the `project` stage live, with the
9
+ // global values inlined, so a Turbo repo runs under vx with no file
10
+ // written.
11
+ // The consumer decides what a global becomes through `splice`.
12
+
13
+ import { existsSync, readFileSync } from 'node:fs'
14
+ import path from 'node:path'
15
+ import { pruneOrphanPersistentNotes, type ProjectMeta, UserError } from '@vzn/vx'
16
+ import { minimatchToVx } from '../glob-grammar.js'
17
+ import { shellQuote } from '../nx-command.js'
18
+ import { scriptCommand, yarnPnp } from '../script-command.js'
19
+ import {
20
+ ownFileOutput,
21
+ ownFileTodo,
22
+ resolveSharedOutputs,
23
+ resolveSharedWorkspaceOutputs,
24
+ takingBack,
25
+ literalTailMatches,
26
+ wildcardOutput,
27
+ wildcardTodo,
28
+ } from '../shared-outputs.js'
29
+ import { packageScripts, relPosix } from '../paths.js'
30
+ import { MAX_SPARED, type TrackedKinds } from '../tracked-outputs.js'
31
+ import {
32
+ DOTENV_PROBE,
33
+ DOTENV_PROBE_TOP,
34
+ dotenvGlobsProbe,
35
+ ignoredFilesProbe,
36
+ } from '../dotenv-probe.js'
37
+
38
+ /** `path.relative` with forward slashes — the shape an ESM specifier or a report line needs. */
39
+ interface TurboTask {
40
+ dependsOn?: string[]
41
+ inputs?: string[]
42
+ outputs?: string[]
43
+ env?: string[]
44
+ passThroughEnv?: string[]
45
+ cache?: boolean
46
+ persistent?: boolean
47
+ with?: string[]
48
+ [key: string]: unknown
49
+ }
50
+
51
+ interface TurboJson {
52
+ tasks?: Record<string, TurboTask>
53
+ pipeline?: Record<string, TurboTask>
54
+ globalDependencies?: string[]
55
+ globalEnv?: string[]
56
+ globalPassThroughEnv?: string[]
57
+ /** Turbo 1.10–1.13: root-relative `.env` files every task hashes. */
58
+ globalDotEnv?: string[]
59
+ /** A package config's parents: `//` (the root) first, then packages by name. */
60
+ extends?: string[]
61
+ /** Turbo 2.11 `futureFlags.globalConfiguration`: the global lists live here. */
62
+ global?: { inputs?: string[]; env?: string[]; passThroughEnv?: string[]; envMode?: unknown }
63
+ envMode?: unknown
64
+ }
65
+
66
+ /** A name core takes in `cache.inputs.env` and `exec.env.passThrough`. */
67
+ const keyable = (name: string): boolean =>
68
+ name.length > 0 && !name.startsWith('!') && !/[*?[\]{}=\0]/.test(name)
69
+
70
+ /**
71
+ * The names of `names` a live mapping can read differently: each a `*` in
72
+ * one of `configs` (turbo.json texts, any string, a superset) or in Turbo's
73
+ * framework table matches, and each core cannot key (a literal one is
74
+ * dropped only when no variable has it). The kept mapping keyed on every
75
+ * name, so any variable that came or went (a CI step's, `VX_TIMING`)
76
+ * remapped the workspace: ~330 ms a run on vercel/ai.
77
+ */
78
+ export function envNamesThatMap(configs: readonly string[], names: readonly string[]): string[] {
79
+ const patterns = new Set(FRAMEWORK_ENV.flatMap((f) => f.env).filter((e) => e.includes('*')))
80
+ for (const text of configs)
81
+ for (const m of text.matchAll(/"((?:[^"\\]|\\.)*)"/g))
82
+ if (m[1]!.includes('*')) patterns.add(m[1]!)
83
+ // Backslashes and a leading `!` dropped, every `*` a wildcard: wider
84
+ // than Turbo's reading, never narrower.
85
+ const res = [...patterns].map(
86
+ (p) =>
87
+ new RegExp(
88
+ `^${p.replaceAll('\\', '').replace(/^!/, '').split('*').map(RegExp.escape).join('.*')}$`,
89
+ ),
90
+ )
91
+ return names.filter((n) => !keyable(n) || res.some((re) => re.test(n)))
92
+ }
93
+
94
+ /**
95
+ * A Turbo env list's explicit names, read as Turbo reads them
96
+ * (`wildcard_to_regex_pattern`): `*` is the one wildcard, `\*` a
97
+ * literal `*`, a leading `\!` a literal `!`, and every other character
98
+ * (`?`, `[`) itself. A `!` entry takes names back out of what the list
99
+ * matched (openstatus drops `!NEXT_PUBLIC_VERCEL_URL` from a
100
+ * `NEXT_PUBLIC_*` it would otherwise hash); vx matches no name it is not
101
+ * given, so an exclusion only removes the list's own names and needs no
102
+ * todo. With `live`, a `*` name expands over those names, and a literal
103
+ * core cannot key (unkey's `NEXT_PUBLIC_\*`) is dropped when no variable
104
+ * has it, as Turbo hashes nothing for it then. Any other wildcard, or such
105
+ * a literal, is `gap(entry, literal)`'s to report.
106
+ */
107
+ function explicitEnv(
108
+ entries: readonly string[],
109
+ gap: (entry: string, literal: string | null) => void,
110
+ live?: readonly string[],
111
+ spelled?: { readonly names: readonly string[]; readonly used: (entry: string) => void },
112
+ ): string[] {
113
+ const out: string[] = []
114
+ const excluded: RegExp[] = []
115
+ // Literal runs split at each unescaped `*`; one run is a literal name.
116
+ const runs = (p: string): string[] => p.split(/(?<!\\)\*/).map((r) => r.replaceAll('\\*', '*'))
117
+ const regex = (parts: readonly string[]): RegExp =>
118
+ new RegExp(`^${parts.map(RegExp.escape).join('.*')}$`)
119
+ for (const e of entries) {
120
+ if (e.startsWith('!') && e.length > 1) {
121
+ excluded.push(regex(runs(e.slice(1))))
122
+ continue
123
+ }
124
+ const parts = runs(e.startsWith('\\!') ? e.slice(1) : e)
125
+ if (parts.length === 1) {
126
+ const name = parts[0]!
127
+ if (keyable(name)) out.push(name)
128
+ else if (live === undefined || live.includes(name)) gap(e, name)
129
+ } else if (live !== undefined) {
130
+ // Turbo matches a `*` name against the environment it runs in; so
131
+ // does a live mapping, and the names it finds are keyed and passed.
132
+ const re = regex(parts)
133
+ out.push(...live.filter((n) => re.test(n) && keyable(n)).sort())
134
+ } else if (spelled !== undefined) {
135
+ // A written config cannot ask the run's environment: the names the
136
+ // package's files spell stand in for it (clerk's root `E2E_*`), the
137
+ // bare prefix no name.
138
+ const re = regex(parts)
139
+ const bare = parts.join('')
140
+ const hits = spelled.names.filter((n) => n !== bare && re.test(n) && keyable(n))
141
+ if (hits.length === 0) gap(e, null)
142
+ else {
143
+ out.push(...hits)
144
+ spelled.used(e)
145
+ }
146
+ } else gap(e, null)
147
+ }
148
+ return out.filter((n) => !excluded.some((re) => re.test(n)))
149
+ }
150
+
151
+ /** A gap's message: a wildcard, or a literal name core cannot key. */
152
+ const envGap = (field: string, entry: string, literal: string | null, list: string): string =>
153
+ literal === null
154
+ ? `${field} ${JSON.stringify(entry)}: wildcards are not supported${list}`
155
+ : `${field} ${JSON.stringify(entry)}: Turbo reads this as the one variable ` +
156
+ `${JSON.stringify(literal)} (only \`*\` is a wildcard), a name vx cannot key — dropped`
157
+
158
+ const KNOWN_TASK_KEYS = new Set([
159
+ 'dependsOn',
160
+ 'with',
161
+ 'inputs',
162
+ 'outputs',
163
+ 'env',
164
+ 'passThroughEnv',
165
+ 'cache',
166
+ 'persistent',
167
+ 'extends',
168
+ 'outputLogs',
169
+ // `outputLogs` as Turbo 1 spells it (renamed in 2.0; 1.13 still reads it).
170
+ 'outputMode',
171
+ 'dotEnv',
172
+ 'command',
173
+ 'description',
174
+ // `turbo watch` restarts a persistent task only when it says so; `vx
175
+ // watch` stops and re-spawns every persistent task each cycle, so either
176
+ // value maps to nothing.
177
+ 'interruptible',
178
+ // Labels: Turbo keeps them out of the hash and the behaviour.
179
+ 'tags',
180
+ ])
181
+
182
+ // Turbo's per-task `outputLogs` against vx's per-run `--output-logs`.
183
+ // `new-only` — frames for the tasks that ran, a one-liner per cache hit —
184
+ // is what vx's default flow already does, so the most common value in
185
+ // the wild (every Vercel template) maps to nothing and warns about
186
+ // nothing. The other values have no per-task knob in vx; the todo names
187
+ // the run flag that carries them.
188
+ const OUTPUT_LOGS_DEFAULT = 'new-only'
189
+ const OUTPUT_LOGS_RUN_FLAG = new Set(['full', 'hash-only', 'errors-only', 'none'])
190
+
191
+ // Turbo 2's framework inference: a package that depends on a framework
192
+ // has its variables hashed into its tasks and passed to them, with
193
+ // nothing in turbo.json saying so. vx env names are explicit, so the
194
+ // variables were stripped in silence and a build that inlines them
195
+ // (Next's `NEXT_PUBLIC_*`) read empty values (item 940). A live mapping
196
+ // (`envNames`) infers them as Turbo does; the migrate CLI lists the names
197
+ // the package's own files spell (`sourceNames`) and says so in a note.
198
+ // Turbo's own table (`packages/turbo-types/src/json/frameworks.json`),
199
+ // in its order: a package takes the FIRST framework it matches, `all`
200
+ // needing every dependency and `some` any one.
201
+ const NITRO_ENV = [
202
+ 'NITRO_*',
203
+ 'SERVER_*',
204
+ 'AWS_APP_ID',
205
+ 'INPUT_AZURE_STATIC_WEB_APPS_API_TOKEN',
206
+ 'CLEAVR',
207
+ 'CF_PAGES',
208
+ 'FIREBASE_APP_HOSTING',
209
+ 'NETLIFY',
210
+ 'STORMKIT',
211
+ 'NOW_BUILDER',
212
+ 'ZEABUR',
213
+ 'RENDER',
214
+ ]
215
+ const FRAMEWORK_ENV: ReadonlyArray<{
216
+ slug: string
217
+ env: readonly string[]
218
+ all: boolean
219
+ dependencies: readonly string[]
220
+ }> = [
221
+ { slug: 'astro', env: ['PUBLIC_*'], all: true, dependencies: ['astro'] },
222
+ { slug: 'blitzjs', env: ['NEXT_PUBLIC_*'], all: true, dependencies: ['blitz'] },
223
+ {
224
+ slug: 'create-react-app',
225
+ env: ['REACT_APP_*'],
226
+ all: false,
227
+ dependencies: ['react-scripts', 'react-dev-utils'],
228
+ },
229
+ { slug: 'expo', env: ['EXPO_PUBLIC_*'], all: true, dependencies: ['expo'] },
230
+ { slug: 'gatsby', env: ['GATSBY_*'], all: true, dependencies: ['gatsby'] },
231
+ {
232
+ slug: 'nextjs',
233
+ env: ['NEXT_PUBLIC_*', 'NEXT_DEPLOYMENT_ID'],
234
+ all: true,
235
+ dependencies: ['next'],
236
+ },
237
+ {
238
+ slug: 'nitro',
239
+ env: NITRO_ENV,
240
+ all: false,
241
+ dependencies: ['nitropack', 'nitropack-nightly', 'nitro', 'nitro-nightly'],
242
+ },
243
+ {
244
+ slug: 'nuxtjs',
245
+ env: ['NUXT_*', 'NUXT_ENV_*', ...NITRO_ENV, 'LAUNCH_EDITOR'],
246
+ all: false,
247
+ dependencies: ['nuxt', 'nuxt-edge', 'nuxt3', 'nuxt3-edge'],
248
+ },
249
+ { slug: 'redwoodjs', env: ['REDWOOD_ENV_*'], all: true, dependencies: ['@redwoodjs/core'] },
250
+ {
251
+ slug: 'remix',
252
+ env: ['REMIX_*'],
253
+ all: false,
254
+ dependencies: ['@remix-run/dev', '@remix-run/react', '@remix-run/serve', '@react-router/dev'],
255
+ },
256
+ { slug: 'sanity', env: ['SANITY_STUDIO_*'], all: true, dependencies: ['@sanity/cli'] },
257
+ { slug: 'solidstart', env: ['VITE_*'], all: true, dependencies: ['solid-js', 'solid-start'] },
258
+ { slug: 'sveltekit', env: ['VITE_*', 'PUBLIC_*'], all: true, dependencies: ['@sveltejs/kit'] },
259
+ { slug: 'vite', env: ['VITE_*'], all: true, dependencies: ['vite'] },
260
+ {
261
+ slug: 'vue',
262
+ env: ['VUE_APP_*', 'LAUNCH_EDITOR'],
263
+ all: true,
264
+ dependencies: ['@vue/cli-service'],
265
+ },
266
+ ]
267
+
268
+ /** The three global fields of turbo.json a task may draw on. */
269
+ export type TurboGlobal = 'inputs' | 'env' | 'pass'
270
+
271
+ export interface TurboMappedTask {
272
+ name: string
273
+ /** What did not map, in the words the migration report prints. */
274
+ todos: string[]
275
+ /** TaskConfig-shaped, with whatever `splice` put in its arrays; null when the
276
+ * target has no vx representation (the todos say why). */
277
+ task: Record<string, unknown> | null
278
+ /** Which globals `task` draws on — the renderer imports exactly these. */
279
+ uses: ReadonlySet<TurboGlobal>
280
+ }
281
+
282
+ export interface TurboMappedProject {
283
+ name: string
284
+ dir: string
285
+ tasks: TurboMappedTask[]
286
+ }
287
+
288
+ export interface TurboMapping {
289
+ projects: TurboMappedProject[]
290
+ /** Report lines about what the workspace holds that vx has no place for. */
291
+ notes: string[]
292
+ globals: { inputs: string[]; env: string[]; pass: string[] }
293
+ }
294
+
295
+ export interface MapTurboOptions {
296
+ /**
297
+ * What a task's array holds for one global field: `vx migrate` splices a
298
+ * preset import (`{ raw: '...globalInputs' }`), the plugin the values
299
+ * themselves. Called only for a global that is non-empty.
300
+ */
301
+ splice(kind: TurboGlobal, values: readonly string[]): readonly unknown[]
302
+ /** The TODO attached to a persistent task, in the consumer's words. */
303
+ persistentTodo: string
304
+ /**
305
+ * A task's `npm_package_name` / `npm_package_version`: `vx migrate` reads
306
+ * them from the manifest it imports (`{ raw: 'pkg.version' }`), so a bump
307
+ * reaches them; absent, the values themselves.
308
+ */
309
+ manifestField?(key: 'name' | 'version'): unknown
310
+ /**
311
+ * The environment's variable names, when the mapping runs where the tasks
312
+ * will (`turbo()`): an env wildcard (`NEXT_PUBLIC_*`) expands over them.
313
+ * Absent (`vx migrate` writes files), a wildcard is a todo.
314
+ */
315
+ envNames?: readonly string[]
316
+ /**
317
+ * Without `envNames` (written configs): the upper-case names the files
318
+ * under `dirs` spell, which a framework's `*` prefix is matched against.
319
+ */
320
+ sourceNames?: (dirs: readonly string[]) => Promise<readonly string[]>
321
+ /**
322
+ * `TURBO_CI_VENDOR_ENV_KEY`, which a platform sets (Vercel:
323
+ * `NEXT_PUBLIC_VERCEL_`): names with it are left out of framework
324
+ * inference, as Turbo leaves them, since they change on every deploy.
325
+ */
326
+ vendorEnvPrefix?: string
327
+ /** `TURBO_ENV_MODE`, when the mapping runs where the tasks will: above turbo.json's, as in Turbo. */
328
+ envMode?: string
329
+ /**
330
+ * What git tracks under a root-relative package directory: a
331
+ * wildcard-first output of a kind it has none of (`*.xml`, `dist` at any depth)
332
+ * stays cached. Absent, every wildcard-first output runs uncached.
333
+ */
334
+ tracked?: (rel: string) => TrackedKinds
335
+ /**
336
+ * The config file name a root-relative package's mapped tasks live beside
337
+ * (null: none), which core holds an output to. Absent, every spelling.
338
+ */
339
+ ownConfig?: (rel: string) => string | null
340
+ /**
341
+ * Which root-relative paths git ignores (`gitIgnored`): an input named by
342
+ * path among them is keyed by a probe. Absent, none is.
343
+ */
344
+ ignored?: (rels: readonly string[]) => Promise<ReadonlySet<string>>
345
+ }
346
+
347
+ /**
348
+ * A cacheable task's inputs and what its turbo.json names literally: each
349
+ * path root-relative, and the list and entry that name it (none: a global
350
+ * already left out).
351
+ */
352
+ interface Literals {
353
+ readonly inputs: Record<string, unknown>
354
+ readonly rels: ReadonlyMap<string, Named>
355
+ }
356
+ type Named = readonly ['files' | 'workspaceFiles' | null, string]
357
+
358
+ /** No glob syntax: one path, which git may ignore. */
359
+ const isLiteral = (glob: string): boolean => !/[*?[\]{}()!]/.test(glob)
360
+
361
+ /**
362
+ * Keys gitignored paths (root-relative, with what names them) by a probe and takes
363
+ * their entries out of the file lists. A global one was never put there.
364
+ */
365
+ function keyIgnored(
366
+ inputs: Record<string, unknown>,
367
+ hits: readonly (readonly [string, Named])[],
368
+ ): void {
369
+ for (const kind of ['files', 'workspaceFiles'] as const) {
370
+ const list = inputs[kind] as unknown[] | undefined
371
+ const entries = new Set(hits.flatMap(([, [k, e]]) => (k === kind ? [e] : [])))
372
+ if (list === undefined || entries.size === 0) continue
373
+ const kept = list.filter((e) => typeof e !== 'string' || !entries.has(e))
374
+ // Exclusions left alone narrow nothing core will take.
375
+ const left = kept.some((e) => typeof e !== 'string' || !e.startsWith('!')) ? kept : []
376
+ if (kind === 'files') inputs.files = left
377
+ else if (left.length > 0) inputs.workspaceFiles = left
378
+ else delete inputs.workspaceFiles
379
+ }
380
+ const probes = (inputs.workspaceRuntime as unknown[] | undefined) ?? []
381
+ inputs.workspaceRuntime = [...probes, ignoredFilesProbe(hits.map(([rel]) => rel))]
382
+ }
383
+
384
+ /**
385
+ * A directory's Turbo config: `turbo.json`, else `turbo.jsonc` (Turbo 2.5+
386
+ * reads either). Only `turbo.json` was looked for, so a `turbo.jsonc` root
387
+ * failed the run with ENOENT and a package's `turbo.jsonc` overlay was
388
+ * skipped in silence — its `inputs` then keyed nothing (item 938).
389
+ */
390
+ export async function turboConfigFile(dir: string): Promise<string | null> {
391
+ for (const name of ['turbo.json', 'turbo.jsonc']) {
392
+ const file = path.join(dir, name)
393
+ if (await Bun.file(file).exists()) return file
394
+ }
395
+ return null
396
+ }
397
+
398
+ /**
399
+ * The workspace root as a project to name when turbo.json declares `//#`
400
+ * tasks: its package name and directory. Null with no such task, a
401
+ * nameless root, or a file the mapping refuses (it names the file).
402
+ */
403
+ export async function rootTaskProject(root: string): Promise<{ name: string; dir: string } | null> {
404
+ const file = await turboConfigFile(root)
405
+ if (file === null) return null
406
+ let turbo: { tasks?: unknown; pipeline?: unknown } | null
407
+ let pkg: { name?: unknown } | null
408
+ try {
409
+ turbo = Bun.JSONC.parse(await Bun.file(file).text()) as typeof turbo
410
+ pkg = (await Bun.file(path.join(root, 'package.json')).json()) as typeof pkg
411
+ } catch {
412
+ return null
413
+ }
414
+ const tasks = turbo?.tasks ?? turbo?.pipeline
415
+ const hasRootTask =
416
+ typeof tasks === 'object' &&
417
+ tasks !== null &&
418
+ Object.keys(tasks).some((k) => k.startsWith(`${ROOT}#`))
419
+ if (!hasRootTask || typeof pkg?.name !== 'string' || pkg.name === '') return null
420
+ return { name: pkg.name, dir: root }
421
+ }
422
+
423
+ async function readTurboJson(file: string, root: string): Promise<TurboJson> {
424
+ const text = await Bun.file(file).text()
425
+ let parsed: unknown
426
+ try {
427
+ // turbo.json allows comments + trailing commas.
428
+ parsed = Bun.JSONC.parse(text) ?? {}
429
+ } catch (err) {
430
+ const msg = err instanceof Error ? err.message : String(err)
431
+ throw new UserError(`failed to parse ${relPosix(root, file)}: ${msg}`)
432
+ }
433
+ checkTurboShape(parsed, relPosix(root, file))
434
+ return parsed as TurboJson
435
+ }
436
+
437
+ const isRecord = (v: unknown): v is Record<string, unknown> =>
438
+ typeof v === 'object' && v !== null && !Array.isArray(v)
439
+
440
+ const TOP_LISTS = [
441
+ 'globalDependencies',
442
+ 'globalEnv',
443
+ 'globalPassThroughEnv',
444
+ 'globalDotEnv',
445
+ 'extends',
446
+ ]
447
+ const GLOBAL_LISTS = ['inputs', 'env', 'passThroughEnv']
448
+ const TASK_LISTS = ['dependsOn', 'outputs', 'env', 'passThroughEnv', 'with', 'dotEnv']
449
+ const TASK_FLAGS = ['cache', 'persistent', 'interactive', 'interruptible']
450
+
451
+ /**
452
+ * The shape of every field the mapper reads, refused by name. A number
453
+ * where turbo.json holds a list reached the mapper's loops: `"dependsOn":
454
+ * true` printed `TypeError: true is not iterable` with its stack from
455
+ * `bunx @vzn/vx-migrate` (fuzzed, L-15). Turbo refuses the same file.
456
+ */
457
+ function checkTurboShape(cfg: unknown, label: string): void {
458
+ const refuse = (at: string, what: string): never => {
459
+ throw new UserError(`${label}: ${at} must be ${what}`)
460
+ }
461
+ const list = (v: unknown, at: string): void => {
462
+ if (v !== undefined && (!Array.isArray(v) || v.some((x) => typeof x !== 'string')))
463
+ refuse(at, 'an array of strings')
464
+ }
465
+ if (!isRecord(cfg)) refuse('the file', 'a JSON object')
466
+ const c = cfg as Record<string, unknown>
467
+ for (const k of TOP_LISTS) list(c[k], k)
468
+ if (c['global'] !== undefined) {
469
+ if (!isRecord(c['global'])) refuse('global', 'an object')
470
+ for (const k of GLOBAL_LISTS) list((c['global'] as Record<string, unknown>)[k], `global.${k}`)
471
+ }
472
+ for (const field of ['tasks', 'pipeline']) {
473
+ const tasks = c[field]
474
+ if (tasks === undefined) continue
475
+ if (!isRecord(tasks)) refuse(field, 'an object of tasks')
476
+ for (const [name, def] of Object.entries(tasks as Record<string, unknown>)) {
477
+ const at = `${field}.${JSON.stringify(name)}`
478
+ if (!isRecord(def)) refuse(at, 'an object')
479
+ const d = def as Record<string, unknown>
480
+ for (const k of TASK_LISTS) list(d[k], `${at}.${k}`)
481
+ // Turbo 2.11 adds `{ mode, globs, withDefaults }` entries (`flatInputs`).
482
+ const inputs = d['inputs']
483
+ if (
484
+ inputs !== undefined &&
485
+ (!Array.isArray(inputs) || inputs.some((x) => typeof x !== 'string' && !isRecord(x)))
486
+ )
487
+ refuse(`${at}.inputs`, 'an array of globs')
488
+ for (const k of TASK_FLAGS)
489
+ if (d[k] !== undefined && typeof d[k] !== 'boolean') refuse(`${at}.${k}`, 'true or false')
490
+ for (const k of ['outputLogs', 'outputMode'])
491
+ if (d[k] !== undefined && typeof d[k] !== 'string') refuse(`${at}.${k}`, 'a string')
492
+ }
493
+ }
494
+ }
495
+
496
+ function tasksOf(cfg: TurboJson): Record<string, TurboTask> {
497
+ return cfg.tasks ?? cfg.pipeline ?? {}
498
+ }
499
+
500
+ function declares(meta: ProjectMeta, dependency: string): boolean {
501
+ const pj = meta.packageJson as unknown as Record<string, unknown>
502
+ // Turbo counts every kind but a peer in a monorepo.
503
+ return ['dependencies', 'devDependencies', 'optionalDependencies'].some((field) => {
504
+ const deps = pj[field]
505
+ return typeof deps === 'object' && deps !== null && Object.hasOwn(deps, dependency)
506
+ })
507
+ }
508
+
509
+ /** A script value that can become `exec.command` verbatim. */
510
+ function usableScript(value: unknown): value is string {
511
+ return typeof value === 'string' && value.length > 0
512
+ }
513
+
514
+ function describeScript(value: unknown): string {
515
+ if (value === null) return 'null'
516
+ if (typeof value === 'string') return 'an empty string'
517
+ if (Array.isArray(value)) return 'an array'
518
+ return `a ${typeof value}`
519
+ }
520
+
521
+ /**
522
+ * Turbo 2.11's task `command` (`futureFlags.experimentalTaskCommand`),
523
+ * which Turbo holds authoritative over the package's script: an argv runs
524
+ * where the package has no script, and `null` or `[]` never runs, even
525
+ * where it has one. A per-toolchain map (`{ "javascript": [...] }`)
526
+ * applies its `javascript` entry (`typescript` is Turbo's alias for it)
527
+ * to a JS package, and without one the script runs as before. Reported
528
+ * as having no vx form, the key was dropped: on turborepo itself four
529
+ * tasks with no script were missing, and the edges to them with them
530
+ * (`docs#build` ran before the schema it copies).
531
+ *
532
+ * `undefined`: the script decides. `null`: no command. A string: the
533
+ * argv as one sh line, each word quoted, run from the package dir as
534
+ * Turbo runs it, with no `pre`/`post` hooks (it is not a script).
535
+ */
536
+ function commandOverride(def: TurboTask | undefined): string | null | undefined {
537
+ if (def === undefined || !Object.hasOwn(def, 'command')) return undefined
538
+ const raw = def['command']
539
+ if (raw === null) return null
540
+ const map = raw !== null && typeof raw === 'object' && !Array.isArray(raw)
541
+ const argv = map
542
+ ? ((raw as Record<string, unknown>)['javascript'] ??
543
+ (raw as Record<string, unknown>)['typescript'])
544
+ : raw
545
+ if (!isArgv(argv)) return undefined
546
+ return argv.length === 0 ? null : argv.map(shellQuote).join(' ')
547
+ }
548
+
549
+ function isArgv(v: unknown): v is string[] {
550
+ return Array.isArray(v) && v.every((w) => typeof w === 'string')
551
+ }
552
+
553
+ /** A `command` Turbo would refuse: neither an argv, `null`, nor a toolchain map of them. */
554
+ function badCommand(def: TurboTask): boolean {
555
+ if (!Object.hasOwn(def, 'command')) return false
556
+ const raw = def['command']
557
+ if (raw === null || isArgv(raw)) return false
558
+ if (typeof raw !== 'object' || Array.isArray(raw)) return true
559
+ return Object.values(raw as Record<string, unknown>).some((v) => !isArgv(v))
560
+ }
561
+
562
+ /** Declared task names for a package: plain root keys, `pkg#name` keys
563
+ * for this package, and per-package turbo.json keys — in that order. */
564
+ const ROOT = '//'
565
+
566
+ /**
567
+ * A package's turbo.json files, root first and its own last, as Turbo's
568
+ * `turbo_json_chain` orders them: a package config may extend another
569
+ * package's (`"extends": ["//", "shared"]`, read by Turbo 2.11, refused by 2.5), and read as
570
+ * root-plus-own the shared file's tasks and fields were gone (a task it
571
+ * adds not emitted, an `inputs` it widens not keyed: a stale hit).
572
+ */
573
+ function turboChain(pkgName: string, files: ReadonlyMap<string, TurboJson>): TurboJson[] {
574
+ const out: TurboJson[] = []
575
+ const seen = new Set<string>()
576
+ const stack: { name: string; path: string[]; required: boolean }[] = [
577
+ { name: pkgName, path: [], required: false },
578
+ ]
579
+ while (stack.length > 0) {
580
+ const { name, path, required } = stack.pop()!
581
+ if (path.includes(name)) {
582
+ throw new UserError(`turbo.json extends form a cycle: ${[...path, name].join(' → ')}`)
583
+ }
584
+ if (seen.has(name)) continue
585
+ const cfg = files.get(name)
586
+ if (cfg === undefined) {
587
+ if (required) {
588
+ throw new UserError(`turbo.json of ${path.at(-1)} extends ${name}, which has no turbo.json`)
589
+ }
590
+ // Only the package's own file can be missing unrequired: read as the root's.
591
+ stack.push({ name: ROOT, path, required: false })
592
+ continue
593
+ }
594
+ out.push(cfg)
595
+ seen.add(name)
596
+ if (name === ROOT) continue
597
+ for (const parent of parentsOf(cfg)) {
598
+ stack.push({ name: parent, path: [...path, name], required: true })
599
+ }
600
+ }
601
+ return out.reverse()
602
+ }
603
+
604
+ /** A package config's `extends`; Turbo refuses one that names none, read here as the root's. */
605
+ function parentsOf(cfg: TurboJson): string[] {
606
+ return Array.isArray(cfg.extends) && cfg.extends.length > 0 ? cfg.extends : [ROOT]
607
+ }
608
+
609
+ /**
610
+ * Whether `name` is a task of `pkgName`, as Turbo's
611
+ * `has_task_definition_in_run` decides: the package's own entry, else the
612
+ * first parent in `extends` order that has one; an `extends: false` with
613
+ * nothing else is the opt-out, and it holds for every package below it.
614
+ */
615
+ function taskDefined(
616
+ pkgName: string,
617
+ name: string,
618
+ files: ReadonlyMap<string, TurboJson>,
619
+ at: string = pkgName,
620
+ seen: Set<string> = new Set(),
621
+ ): 'found' | 'excluded' | 'none' {
622
+ if (seen.has(at)) return 'none'
623
+ seen.add(at)
624
+ const cfg = files.get(at)
625
+ if (cfg === undefined) return at === ROOT ? 'none' : taskDefined(pkgName, name, files, ROOT, seen)
626
+ const tasks = tasksOf(cfg)
627
+ const def = tasks[`${pkgName}#${name}`] ?? tasks[name]
628
+ if (def !== undefined) return optedOut(def) ? 'excluded' : 'found'
629
+ if (at === ROOT) return 'none'
630
+ for (const parent of parentsOf(cfg)) {
631
+ const r = taskDefined(pkgName, name, files, parent, seen)
632
+ if (r !== 'none') return r
633
+ }
634
+ return 'none'
635
+ }
636
+
637
+ /** Every task `pkgName` has, in the order its files name them. */
638
+ function taskNamesFor(
639
+ pkgName: string,
640
+ chain: readonly TurboJson[],
641
+ files: ReadonlyMap<string, TurboJson>,
642
+ ): string[] {
643
+ const names = new Set<string>()
644
+ for (const cfg of chain) {
645
+ for (const key of Object.keys(tasksOf(cfg))) {
646
+ if (!key.includes('#')) names.add(key)
647
+ else if (key.startsWith(`${pkgName}#`)) names.add(key.slice(pkgName.length + 1))
648
+ }
649
+ }
650
+ return [...names].filter((n) => taskDefined(pkgName, n, files) === 'found')
651
+ }
652
+
653
+ /**
654
+ * The task's definition along the chain, as Turbo's
655
+ * `resolve_task_definitions_from_chain` folds it: the root's (a root
656
+ * `pkg#task` REPLACES `task` for that package, as Turbo's `TurboJson::task`
657
+ * looks it up: merged field by field, the generic task's `inputs` narrowed
658
+ * a `pkg#task` that names none and an edit outside them was a stale hit,
659
+ * item 935), then each later file's overlaid on it. The file nearest the
660
+ * package that says `extends: false` starts afresh: its own fields, if
661
+ * any, then the files after it.
662
+ */
663
+ function definitionOf(pkgName: string, name: string, chain: readonly TurboJson[]): TurboTask {
664
+ const defs = chain.map((cfg, i) => {
665
+ const tasks = tasksOf(cfg)
666
+ return i === 0 ? (tasks[`${pkgName}#${name}`] ?? tasks[name]) : tasks[name]
667
+ })
668
+ let from = 0
669
+ for (let i = defs.length - 1; i > 0; i--) {
670
+ if (defs[i]?.extends === false) {
671
+ from = i
672
+ break
673
+ }
674
+ }
675
+ let def: TurboTask = {}
676
+ for (const d of defs.slice(from)) {
677
+ if (d === undefined) continue
678
+ const own: TurboTask = { ...d }
679
+ delete own.extends
680
+ if (Array.isArray(own.inputs)) own.inputs = flatInputs(own.inputs)
681
+ def = withOverlay(def, own)
682
+ }
683
+ return def
684
+ }
685
+
686
+ /**
687
+ * Turbo 2.11's structured inputs as the strings they stand for: a
688
+ * `startup` or `jit` entry is its globs (and every file with
689
+ * `withDefaults`), keyed as the other inputs are (vx has no separate
690
+ * just-in-time pass). `dependencyOutputs` is nothing: vx folds each
691
+ * dependency's key instead (its outputs follow from it). Read as strings,
692
+ * an object crashed the plugin and failed the whole run.
693
+ */
694
+ function flatInputs(inputs: readonly unknown[]): string[] {
695
+ return inputs.flatMap((i): string[] => {
696
+ if (typeof i === 'string') return [i]
697
+ if (i === null || typeof i !== 'object') return []
698
+ const { mode, globs, withDefaults } = i as {
699
+ mode?: unknown
700
+ globs?: unknown
701
+ withDefaults?: unknown
702
+ }
703
+ if (mode !== 'startup' && mode !== 'jit') return []
704
+ return [
705
+ ...(withDefaults === true ? ['$TURBO_DEFAULT$'] : []),
706
+ ...(Array.isArray(globs) ? globs.filter((g): g is string => typeof g === 'string') : []),
707
+ ]
708
+ })
709
+ }
710
+
711
+ /**
712
+ * A glob naming `.env` files: its last segment starts with `.env` or ends
713
+ * with it, the shapes Turbo's docs and create-turbo write (`.env*`,
714
+ * `**\/.env.*local`, `.env.local`).
715
+ */
716
+ function isDotenvGlob(glob: string): boolean {
717
+ const last = glob.slice(glob.lastIndexOf('/') + 1)
718
+ return last.startsWith('.env') || last.endsWith('.env')
719
+ }
720
+
721
+ /** Turbo 1's env dependency, `$NAME`, as the name; null for anything else, `$TURBO_…$` tokens included. */
722
+ function envDependency(entry: string): string | null {
723
+ const m = /^\$([A-Za-z_][A-Za-z0-9_]*)$/.exec(entry)
724
+ return m === null ? null : m[1]!
725
+ }
726
+
727
+ /**
728
+ * A package overlay on the task it inherits. A field the overlay sets
729
+ * replaces the inherited one, except an array holding `$TURBO_EXTENDS$`
730
+ * (Turbo 2.5+): that is the inherited list plus the overlay's other
731
+ * entries. Spread whole, the token stayed as a literal input glob and
732
+ * env name and the root's `inputs`, `env` and `dependsOn` were gone, so a
733
+ * source edit was a hit (item 906).
734
+ */
735
+ function withOverlay(inherited: TurboTask, overlay: TurboTask | undefined): TurboTask {
736
+ if (overlay === undefined) return inherited
737
+ const out: Record<string, unknown> = { ...inherited, ...overlay }
738
+ for (const [field, value] of Object.entries(overlay)) {
739
+ if (!Array.isArray(value) || !value.includes(TURBO_EXTENDS)) continue
740
+ const base = (inherited as Record<string, unknown>)[field]
741
+ out[field] = [...(Array.isArray(base) ? base : []), ...value.filter((v) => v !== TURBO_EXTENDS)]
742
+ }
743
+ return out as TurboTask
744
+ }
745
+
746
+ const TURBO_EXTENDS = '$TURBO_EXTENDS$'
747
+
748
+ /**
749
+ * A per-package `{ "extends": false }` with nothing else is Turbo's
750
+ * opt-out: the package's script exists, the root defines the task, and
751
+ * Turbo 2.9 runs nothing for it (n8n's `@n8n/storybook` on `build` and
752
+ * `test`; probed with `--dry=json`, 2026-09-11). With any other key the
753
+ * task runs on those keys alone, the root definition not inherited.
754
+ */
755
+ function optedOut(def: TurboTask | undefined): boolean {
756
+ return def?.extends === false && Object.keys(def).length === 1
757
+ }
758
+
759
+ /**
760
+ * Turbo 2.11's `global` block (`futureFlags.globalConfiguration`) in the
761
+ * top-level fields it replaces, as Turbo's `resolve_global_config` moves
762
+ * them. Unread, its `inputs` and `env` keyed nothing: an edit to a global
763
+ * file was a hit everywhere.
764
+ */
765
+ function withGlobal(cfg: TurboJson): TurboJson {
766
+ const g = cfg.global
767
+ if (g === undefined || g === null || typeof g !== 'object') return cfg
768
+ const { globalDependencies: _d, globalEnv: _e, globalPassThroughEnv: _p, ...rest } = cfg
769
+ return {
770
+ ...rest,
771
+ ...(g.inputs === undefined ? {} : { globalDependencies: g.inputs }),
772
+ ...(g.env === undefined ? {} : { globalEnv: g.env }),
773
+ ...(g.passThroughEnv === undefined ? {} : { globalPassThroughEnv: g.passThroughEnv }),
774
+ }
775
+ }
776
+
777
+ /**
778
+ * The packages the workspace root depends on, transitively, as root-relative
779
+ * `<dir>/**` globs: Turbo hashes their files into its global hash
780
+ * (`root_internal_package_dependencies_paths`), so an edit to one re-keys
781
+ * every task. with-nestjs's root dev-depends on `@repo/eslint-config`, and
782
+ * `api#lint` (`lint: {}`, no edge) lints with it; unread, an edit to the
783
+ * shared rules replayed every lint from the cache.
784
+ */
785
+ function rootDependencyGlobs(
786
+ root: string,
787
+ rootPkg: ProjectMeta['packageJson'],
788
+ metas: readonly ProjectMeta[],
789
+ ): string[] {
790
+ const byName = new Map(metas.map((m) => [m.name, m]))
791
+ const depsOf = (pkg: ProjectMeta['packageJson']): string[] =>
792
+ (['dependencies', 'devDependencies', 'optionalDependencies'] as const).flatMap((f) => {
793
+ const d: unknown = pkg[f]
794
+ return d !== null && typeof d === 'object' ? Object.keys(d) : []
795
+ })
796
+ const seen = new Set<string>()
797
+ const queue = depsOf(rootPkg)
798
+ while (queue.length > 0) {
799
+ const name = queue.pop()!
800
+ const meta = byName.get(name)
801
+ if (meta === undefined || seen.has(name)) continue
802
+ seen.add(name)
803
+ queue.push(...depsOf(meta.packageJson))
804
+ }
805
+ const out: string[] = []
806
+ for (const name of seen) {
807
+ const rel = relPosix(root, byName.get(name)!.dir)
808
+ if (rel !== '' && rel !== '.') out.push(`${rel}/**`)
809
+ }
810
+ return out.sort()
811
+ }
812
+
813
+ /** The root's manifest; `{}` when it is missing or no object. */
814
+ async function rootPackageJson(root: string): Promise<ProjectMeta['packageJson']> {
815
+ const pkg = (await Bun.file(path.join(root, 'package.json'))
816
+ .json()
817
+ .catch(() => null)) as unknown
818
+ return (pkg !== null && typeof pkg === 'object' ? pkg : {}) as ProjectMeta['packageJson']
819
+ }
820
+
821
+ /**
822
+ * The microfrontends configs Turbo hashes into every task: each package's
823
+ * (the root's too) `microfrontends.json`, else `microfrontends.jsonc`, or
824
+ * the one name `VC_MICROFRONTENDS_CONFIG_FILE_NAME` gives, unless it is a
825
+ * child's `{ "partOf": … }` (Turbo's `update_turbo_json` appends them to
826
+ * the root's global deps). Unread, an edit to `web`'s routes replayed every
827
+ * sibling app's build (with-microfrontends, Turbo 2.11's dry run moves all
828
+ * 18 tasks). Paths are root-relative; `text` is what the mapping read.
829
+ */
830
+ export function microfrontendsConfigs(
831
+ root: string,
832
+ dirs: readonly string[],
833
+ custom = process.env['VC_MICROFRONTENDS_CONFIG_FILE_NAME'] ?? '',
834
+ ): { rel: string; text: string }[] {
835
+ const names = custom === '' ? ['microfrontends.json', 'microfrontends.jsonc'] : [custom]
836
+ const out: { rel: string; text: string }[] = []
837
+ for (const dir of new Set(dirs.map((d) => path.resolve(d)))) {
838
+ const file = names.map((n) => path.join(dir, n)).find((f) => existsSync(f))
839
+ if (file === undefined) continue
840
+ const text = readFileSync(file, 'utf8')
841
+ let child = false
842
+ try {
843
+ const parsed = Bun.JSONC.parse(text) as { partOf?: unknown } | null
844
+ child = typeof parsed?.partOf === 'string'
845
+ } catch {}
846
+ if (!child) out.push({ rel: relPosix(root, file), text })
847
+ }
848
+ return out.sort((a, b) => (a.rel < b.rel ? -1 : 1))
849
+ }
850
+
851
+ export async function mapTurboWorkspace(
852
+ root: string,
853
+ metas: readonly ProjectMeta[],
854
+ opts: MapTurboOptions,
855
+ ): Promise<TurboMapping> {
856
+ // A workspace with no Turbo config answered with a bare ENOENT and a stack
857
+ // (item 1043): turbo() declared in the wrong repo, or its file removed.
858
+ const rootFile = await turboConfigFile(root)
859
+ if (rootFile === null) {
860
+ throw new UserError(
861
+ `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`,
862
+ )
863
+ }
864
+ const rootCfg = withGlobal(await readTurboJson(rootFile, root))
865
+ const pnp = yarnPnp(root)
866
+ const rootTasks = tasksOf(rootCfg)
867
+
868
+ // Turbo 1 lists an env var as `$NAME` among `globalDependencies` (and a
869
+ // task's `dependsOn`); read as a file it was a glob that matched nothing,
870
+ // and the var re-keyed nothing (item 909).
871
+ const globalDeps = rootCfg.globalDependencies ?? []
872
+ const notes: string[] = []
873
+ // Loose mode hands every task the whole environment; vx's is isolated,
874
+ // so a task that reads an undeclared variable ran without it, and said
875
+ // nothing (a build baking a URL from the env built without one).
876
+ const envMode = opts.envMode || (rootCfg.global?.envMode ?? rootCfg.envMode)
877
+ if (envMode === 'loose') {
878
+ notes.push(
879
+ 'envMode "loose": Turbo passes every environment variable to every task; vx passes only ' +
880
+ 'the declared ones — list what each task reads in exec.env.passThrough (or cache.inputs.env)',
881
+ )
882
+ } else if (
883
+ !envMode &&
884
+ rootCfg.tasks === undefined &&
885
+ rootCfg.pipeline !== undefined &&
886
+ !('globalPassThroughEnv' in rootCfg)
887
+ ) {
888
+ // Turbo 1's default mode, "infer", runs a task loose unless a
889
+ // pass-through list applies to it (probed on 1.13.4: dub's and
890
+ // trigger.dev's every task), so it read variables nobody declared.
891
+ const loose = Object.entries(rootTasks)
892
+ .filter(([, def]) => def?.passThroughEnv === undefined)
893
+ .map(([name]) => name)
894
+ if (loose.length > 0) {
895
+ notes.push(
896
+ `Turbo 1 runs ${loose.join(', ')} in loose env mode (no passThroughEnv, so its "infer" ` +
897
+ 'mode passes every environment variable); vx passes only the declared ones — list what ' +
898
+ 'each reads in exec.env.passThrough (or cache.inputs.env)',
899
+ )
900
+ }
901
+ }
902
+ // A wildcard names no one variable: core refuses it, and in a global
903
+ // list that refusal failed every task of the run (item 937). Reported
904
+ // once, as a task's own `env` wildcard is per task.
905
+ const envNames = (field: string, names: readonly string[]): string[] =>
906
+ explicitEnv(
907
+ names,
908
+ (e, literal) =>
909
+ notes.push(envGap(field, e, literal, ' in vx env names — list explicit names')),
910
+ opts.envNames,
911
+ )
912
+ // Turbo 1's `globalDotEnv` files are hashed as `globalDependencies` are
913
+ // (item 937). A `.env`-shaped one is gitignored as a rule, and a glob
914
+ // over git's files keyed nothing: the workspace probe keys them (item
915
+ // 1032).
916
+ const rootMeta = metas.find((m) => path.resolve(m.dir) === path.resolve(root))
917
+ const globalFiles = [
918
+ ...globalDeps.filter((d) => envDependency(d) === null),
919
+ ...(rootCfg.globalDotEnv ?? []),
920
+ // Turbo 2 added the root's dependencies to its global hash; 1.13.4's
921
+ // leaves them out (trigger.dev: a Prisma migration re-keyed only the
922
+ // packages that depend on the database package).
923
+ ...(rootCfg.tasks === undefined && rootCfg.pipeline !== undefined
924
+ ? []
925
+ : rootDependencyGlobs(root, rootMeta?.packageJson ?? (await rootPackageJson(root)), metas)),
926
+ ...microfrontendsConfigs(root, [root, ...metas.map((m) => m.dir)]).map((c) => c.rel),
927
+ ]
928
+ const rootDotenv = globalFiles.filter((f) => isDotenvGlob(f))
929
+ // Turbo globs an explicit input on the disk, gitignored or not. Core
930
+ // refuses a literal input git ignores (it would key nothing), so a
931
+ // `config.local.json` in globalDependencies ran every task uncached: a
932
+ // probe keys each such path instead. One `git check-ignore` per kind.
933
+ const globalIgnored = [
934
+ ...(await (opts.ignored?.(globalFiles.filter(isLiteral)) ?? new Set<string>())),
935
+ ].filter((f) => !isDotenvGlob(f))
936
+ const globals = {
937
+ inputs: globalFiles.filter((f) => !isDotenvGlob(f) && !globalIgnored.includes(f)),
938
+ env: [
939
+ ...envNames('globalEnv', rootCfg.globalEnv ?? []),
940
+ ...globalDeps.flatMap((d) => envDependency(d) ?? []),
941
+ ],
942
+ pass: envNames('globalPassThroughEnv', rootCfg.globalPassThroughEnv ?? []),
943
+ }
944
+
945
+ // The workspace root as a project (a `vx.config` there, D-39) holds
946
+ // Turbo's `//#task`s (`rootMeta`, above); Turbo runs no plain task in the root package.
947
+ const files = new Map<string, TurboJson>([[ROOT, rootCfg]])
948
+ for (const meta of metas) {
949
+ if (meta === rootMeta) continue
950
+ const file = await turboConfigFile(meta.dir)
951
+ if (file !== null) files.set(meta.name, await readTurboJson(file, root))
952
+ }
953
+
954
+ const rootTaskNames = Object.keys(rootTasks)
955
+ .filter((k) => k.startsWith(`${ROOT}#`) && !optedOut(rootTasks[k]!))
956
+ .map((k) => k.slice(ROOT.length + 1))
957
+ if (rootMeta === undefined) {
958
+ for (const name of rootTaskNames) {
959
+ notes.push(
960
+ `note: root task ${ROOT}#${name} not migrated — the workspace root is no project; ` +
961
+ 'a root package.json name no package holds makes it one',
962
+ )
963
+ }
964
+ }
965
+
966
+ // Turbo's single-package mode: a repo with no workspaces runs turbo.json's
967
+ // plain tasks on its root package (its `non-monorepo` example). As a
968
+ // `//#task` holder only, the root planned nothing: "no projects declare".
969
+ const singlePackage = rootMeta !== undefined && metas.length === 1
970
+ const definitions = (meta: ProjectMeta) => {
971
+ if (meta === rootMeta && !singlePackage) {
972
+ const defined = new Set(rootTaskNames)
973
+ const defFor = (name: string): TurboTask | undefined =>
974
+ defined.has(name) ? definitionOf(ROOT, name, [rootCfg]) : undefined
975
+ return { defined, defFor }
976
+ }
977
+ const chain = turboChain(meta.name, files)
978
+ const defined = new Set(taskNamesFor(meta.name, chain, files))
979
+ const defFor = (name: string): TurboTask | undefined =>
980
+ defined.has(name) ? definitionOf(meta.name, name, chain) : undefined
981
+ return { defined, defFor }
982
+ }
983
+
984
+ // First pass: which tasks does each package emit? Needed so dependsOn
985
+ // edges can be validated/dropped against the real emitted set.
986
+ const emitted = new Map<string, Set<string>>()
987
+ // Which of those are persistent: a `with` sidecar maps only onto one.
988
+ const persistentAt = new Map<string, Set<string>>()
989
+ // The tasks with a command: a same-package edge walks through the rest
990
+ // (item 939), groups included, so a group never waits on itself.
991
+ const runnable = new Map<string, Set<string>>()
992
+ // Each task's `with` targets as `pkg#task`, to keep one edge of a pair.
993
+ const withOf = new Map<string, readonly string[]>()
994
+ for (const meta of metas) {
995
+ const scripts = packageScripts(meta)
996
+ const { defined, defFor } = definitions(meta)
997
+ const set = new Set<string>()
998
+ const lasting = new Set<string>()
999
+ for (const name of defined) {
1000
+ const override = commandOverride(defFor(name))
1001
+ if (override === undefined ? usableScript(scripts[name]) : override !== null) {
1002
+ set.add(name)
1003
+ if (defFor(name)?.persistent === true) lasting.add(name)
1004
+ const w = defFor(name)?.with
1005
+ if (Array.isArray(w))
1006
+ withOf.set(
1007
+ `${meta.name}#${name}`,
1008
+ w.map((e) => sidecarId(e, meta.name, rootMeta?.name)),
1009
+ )
1010
+ }
1011
+ }
1012
+ emitted.set(meta.name, set)
1013
+ runnable.set(meta.name, new Set(set))
1014
+ persistentAt.set(meta.name, lasting)
1015
+ }
1016
+ // Which script-less nodes another package's edge can reach: `^name`
1017
+ // anywhere, or `pkg#name` for that package's.
1018
+ const caretNames = new Set<string>()
1019
+ const crossIds = new Set<string>()
1020
+ for (const meta of metas) {
1021
+ const { defined, defFor } = definitions(meta)
1022
+ for (const name of defined) {
1023
+ for (const d of defFor(name)?.dependsOn ?? []) {
1024
+ if (d.startsWith('^')) caretNames.add(d.slice(1))
1025
+ else if (d.includes('#'))
1026
+ crossIds.add(
1027
+ d.startsWith(`${ROOT}#`) ? `${rootMeta?.name ?? ROOT}${d.slice(ROOT.length)}` : d,
1028
+ )
1029
+ }
1030
+ }
1031
+ }
1032
+ const scripted = new Set<string>()
1033
+ for (const set of runnable.values()) for (const name of set) scripted.add(name)
1034
+ const entry = new Set<string>()
1035
+ // A script-less task is Turbo's no-op node, and it keeps its edges. Its
1036
+ // `^` edge to its own name needs nothing (core's `^task` walks past a
1037
+ // project without the task to the nearest one with it); a `^` edge to
1038
+ // another name (rallly's script-less `build` → `^db:generate`), or an
1039
+ // edge to a task of its own package or another (with-tailwind's
1040
+ // `ui#build` → `build:styles`), is lost when
1041
+ // another package reaches the node, and a persistent `with` sidecar
1042
+ // whenever the node is run: such a node is a group task (below). One no
1043
+ // other package reaches stays none (a `test: [build]` in a package with
1044
+ // no tests adds nothing Turbo's `^` would not).
1045
+ const sidecarGroups = new Set<string>()
1046
+ for (const meta of metas) {
1047
+ const scripts = packageScripts(meta)
1048
+ const own = runnable.get(meta.name)!
1049
+ const { defined, defFor } = definitions(meta)
1050
+ for (const name of defined) {
1051
+ const def = defFor(name)
1052
+ if (scripts[name] !== undefined || commandOverride(def) !== undefined) continue
1053
+ const runs = (id: string, of: ReadonlyMap<string, ReadonlySet<string>>): boolean => {
1054
+ const at = id.indexOf('#')
1055
+ return of.get(id.slice(0, at))?.has(id.slice(at + 1)) === true
1056
+ }
1057
+ const sidecar = (def?.with ?? []).some((e) =>
1058
+ runs(sidecarId(e, meta.name, rootMeta?.name), persistentAt),
1059
+ )
1060
+ const local = (def?.dependsOn ?? []).some((d) => {
1061
+ if (envDependency(d) !== null || d.includes('$TURBO_ROOT$')) return false
1062
+ if (d.startsWith('^'))
1063
+ return d !== `^${name}` && [...runnable.values()].some((r) => r.has(d.slice(1)))
1064
+ if (!d.includes('#')) return d !== name && (own.has(d) || defined.has(d))
1065
+ return runs(
1066
+ d.startsWith(`${ROOT}#`) ? `${rootMeta?.name ?? ROOT}${d.slice(ROOT.length)}` : d,
1067
+ emitted,
1068
+ )
1069
+ })
1070
+ const reached = caretNames.has(name) || crossIds.has(`${meta.name}#${name}`)
1071
+ if (sidecar) sidecarGroups.add(`${meta.name}#${name}`)
1072
+ if (sidecar || (local && reached)) emitted.get(meta.name)!.add(name)
1073
+ // A name no package has a script for is an entry point of its own:
1074
+ // `turbo run ci` over `ci: { dependsOn: ["lint", "build"] }`, or
1075
+ // cal.com's `deploy` → `@calcom/web#build`, runs its edges, and vx
1076
+ // said no project declares it. Its `pkg#task` edges stay in that
1077
+ // package alone: core's `--filter`/`--affected` reach follows a task
1078
+ // edge across packages, and cal.com's `deploy` in all 116 would have
1079
+ // made every package a dependent of web.
1080
+ else if (!scripted.has(name)) {
1081
+ const kept = entryEdges(def?.dependsOn ?? [], meta.name, rootMeta?.name)
1082
+ const keeps = kept.some((d) => {
1083
+ if (envDependency(d) !== null || d.includes('$TURBO_ROOT$')) return false
1084
+ if (d.startsWith('^'))
1085
+ return d !== `^${name}` && [...runnable.values()].some((r) => r.has(d.slice(1)))
1086
+ return d !== name && (own.has(d) || defined.has(d))
1087
+ })
1088
+ if (keeps) {
1089
+ emitted.get(meta.name)!.add(name)
1090
+ entry.add(`${meta.name}#${name}`)
1091
+ }
1092
+ }
1093
+ }
1094
+ }
1095
+ // Turbo's transit node (its with-vitest example; the docs' pattern for a
1096
+ // task that runs in parallel yet re-runs on a dependency's edit):
1097
+ // `transit: { dependsOn: ["^transit"] }`, no script anywhere, and
1098
+ // `test: { dependsOn: ["transit"] }` (or `["^transit"]`). Turbo hashes the no-op per package,
1099
+ // over its files, so `test` keys on its dependencies' sources. Dropped,
1100
+ // vx keyed `test` on its own files alone: a dependency's edit was a hit.
1101
+ // Each package runs it as a key-only task, `true` and cached, as nx()'s
1102
+ // `nx-input:*` twins do.
1103
+ const withScript = new Set<string>()
1104
+ for (const set of runnable.values()) for (const name of set) withScript.add(name)
1105
+ const sameRefs = new Set<string>()
1106
+ const caretSelf = new Set<string>()
1107
+ for (const meta of metas) {
1108
+ const { defined, defFor } = definitions(meta)
1109
+ for (const name of defined) {
1110
+ for (const d of defFor(name)?.dependsOn ?? []) {
1111
+ if (d === `^${name}`) caretSelf.add(name)
1112
+ // create-t3-turbo reaches its `topo` node only as `^topo`.
1113
+ else if (d.startsWith('^')) sameRefs.add(d.slice(1))
1114
+ else if (!d.includes('#') && envDependency(d) === null) sameRefs.add(d)
1115
+ }
1116
+ }
1117
+ }
1118
+ const transit = new Set([...caretSelf].filter((n) => sameRefs.has(n) && !withScript.has(n)))
1119
+ if (transit.size > 0) {
1120
+ for (const meta of metas) {
1121
+ const { defined, defFor } = definitions(meta)
1122
+ for (const name of transit)
1123
+ if (defined.has(name) && commandOverride(defFor(name)) === undefined) {
1124
+ emitted.get(meta.name)!.add(name)
1125
+ runnable.get(meta.name)!.add(name)
1126
+ }
1127
+ }
1128
+ }
1129
+ // The same no-op node in a package that lacks a script others run:
1130
+ // with-vite's `ui` has no `build`, its apps bundle it, and Turbo's
1131
+ // `ui#build` hashes ui's files into theirs. Walked past, vx's `^build`
1132
+ // folded nothing of ui, and an edit to it replayed both apps (a stale
1133
+ // hit). Key-only too, with no outputs: Turbo's no-op cleans nothing.
1134
+ // One with edges of its own (with-shell-commands' `tooling-config#build`
1135
+ // → `prebuild`) was a group, which keys nothing: Turbo's node still
1136
+ // hashes the package's files, so it is key-only with its edges. A node
1137
+ // that starts persistent sidecars stays a group.
1138
+ const keyOnly = new Map<string, Set<string>>()
1139
+ for (const meta of metas) {
1140
+ const scripts = packageScripts(meta)
1141
+ const { defined, defFor } = definitions(meta)
1142
+ for (const name of defined) {
1143
+ const def = defFor(name)
1144
+ if (!caretSelf.has(name) || !withScript.has(name) || transit.has(name)) continue
1145
+ if (scripts[name] !== undefined || commandOverride(def) !== undefined) continue
1146
+ if (sidecarGroups.has(`${meta.name}#${name}`)) continue
1147
+ if (def?.cache === false || def?.persistent === true) continue
1148
+ emitted.get(meta.name)!.add(name)
1149
+ runnable.get(meta.name)!.add(name)
1150
+ let set = keyOnly.get(meta.name)
1151
+ if (set === undefined) keyOnly.set(meta.name, (set = new Set()))
1152
+ set.add(name)
1153
+ }
1154
+ }
1155
+ const emittedAnywhere = new Set<string>()
1156
+ for (const set of emitted.values()) for (const name of set) emittedAnywhere.add(name)
1157
+
1158
+ // A live mapping infers as Turbo does: the prefix joins each task's env
1159
+ // list, where the task's own `!` entries can take names back.
1160
+ const inferredOf = new Map<string, readonly string[]>()
1161
+ // The names a package's files and its workspace dependencies' spell, once.
1162
+ const spelledMemo = new Map<string, Promise<readonly string[]>>()
1163
+ const spelledBy = (m: ProjectMeta): Promise<readonly string[]> => {
1164
+ let names = spelledMemo.get(m.name)
1165
+ if (names === undefined) {
1166
+ const closure = new Set<ProjectMeta>([m])
1167
+ for (const p of closure)
1168
+ for (const d of metas) if (!closure.has(d) && declares(p, d.name)) closure.add(d)
1169
+ names = opts.sourceNames!([...closure].map((p) => p.dir))
1170
+ spelledMemo.set(m.name, names)
1171
+ }
1172
+ return names
1173
+ }
1174
+ const usersOf = new Map<(typeof FRAMEWORK_ENV)[number], string[]>()
1175
+ const sourcedOf = new Map<(typeof FRAMEWORK_ENV)[number], string[]>()
1176
+ for (const m of metas) {
1177
+ if (emitted.get(m.name)!.size === 0) continue
1178
+ const fw = FRAMEWORK_ENV.find((f) =>
1179
+ f.all
1180
+ ? f.dependencies.every((d) => declares(m, d))
1181
+ : f.dependencies.some((d) => declares(m, d)),
1182
+ )
1183
+ if (fw === undefined) continue
1184
+ if (opts.envNames !== undefined) {
1185
+ const live = opts.envNames
1186
+ const vendor = opts.vendorEnvPrefix
1187
+ const names = fw.env.flatMap((e) => {
1188
+ if (!e.endsWith('*')) return [e]
1189
+ const head = e.slice(0, -1)
1190
+ return live.filter((n) => n.startsWith(head)).sort()
1191
+ })
1192
+ inferredOf.set(m.name, vendor ? names.filter((n) => !n.startsWith(vendor)) : names)
1193
+ } else if (opts.sourceNames !== undefined) {
1194
+ // A written config cannot ask the run's environment, and with the
1195
+ // prefix only in a note, a migrated Next build inlined every
1196
+ // NEXT_PUBLIC_ value empty. The names the package's own files spell
1197
+ // are what its build reads, and so are its workspace dependencies':
1198
+ // Next bundles their source (cal.com's web spells 23, with them 58).
1199
+ const spelled = await spelledBy(m)
1200
+ inferredOf.set(
1201
+ m.name,
1202
+ // The bare prefix is no name: formbricks' web spells `NEXT_PUBLIC_`
1203
+ // in a `startsWith` test, and the configs keyed a variable of it.
1204
+ fw.env.flatMap((e) => {
1205
+ if (!e.endsWith('*')) return [e]
1206
+ const head = e.slice(0, -1)
1207
+ return spelled.filter((n) => n.startsWith(head) && n.length > head.length)
1208
+ }),
1209
+ )
1210
+ sourcedOf.set(fw, [...(sourcedOf.get(fw) ?? []), m.name])
1211
+ } else usersOf.set(fw, [...(usersOf.get(fw) ?? []), m.name])
1212
+ }
1213
+ for (const [fw, users] of sourcedOf)
1214
+ notes.push(
1215
+ `Turbo infers ${fw.slug} in ${users.join(', ')} and hashes and passes ${fw.env.join(', ')} ` +
1216
+ 'to its tasks; the configs list the names their files and their workspace ' +
1217
+ 'dependencies’ spell — add any only an installed dependency reads to cache.inputs.env ' +
1218
+ 'and exec.env.passThrough',
1219
+ )
1220
+ for (const [fw, users] of usersOf)
1221
+ notes.push(
1222
+ `Turbo infers ${fw.slug} in ${users.join(', ')} and hashes and passes ${fw.env.join(', ')} to ` +
1223
+ 'its tasks; vx env names are explicit — list the ones they read in cache.inputs.env ' +
1224
+ 'and exec.env.passThrough',
1225
+ )
1226
+
1227
+ // A task's own `*` env name, in a written config: expanded over the
1228
+ // names its package spells (`explicitEnv`), each entry noted once.
1229
+ const wildcardUses = new Map<string, number>()
1230
+ const spelledOf = new Map<
1231
+ string,
1232
+ { readonly names: readonly string[]; readonly used: (entry: string) => void }
1233
+ >()
1234
+ if (opts.envNames === undefined && opts.sourceNames !== undefined)
1235
+ for (const meta of metas) {
1236
+ const { defined, defFor } = definitions(meta)
1237
+ const wild = [...defined].some((name) =>
1238
+ [...(defFor(name)?.env ?? []), ...(defFor(name)?.passThroughEnv ?? [])].some(
1239
+ (e) => !e.startsWith('!') && /(?<!\\)\*/.test(e),
1240
+ ),
1241
+ )
1242
+ if (!wild) continue
1243
+ spelledOf.set(meta.name, {
1244
+ names: await spelledBy(meta),
1245
+ used: (e) => wildcardUses.set(e, (wildcardUses.get(e) ?? 0) + 1),
1246
+ })
1247
+ }
1248
+
1249
+ const projects: TurboMappedProject[] = []
1250
+ const literals = { tasks: [] as Literals[], globalIgnored }
1251
+ for (const meta of metas) {
1252
+ const scripts = packageScripts(meta)
1253
+ const own = runnable.get(meta.name)!
1254
+ const { defined, defFor } = definitions(meta)
1255
+ const tasks: TurboMappedTask[] = []
1256
+ for (const name of defined) {
1257
+ const override = commandOverride(defFor(name))
1258
+ if (override === null) continue
1259
+ const script = scripts[name]
1260
+ const noop = keyOnly.get(meta.name)?.has(name) === true
1261
+ if (
1262
+ override === undefined &&
1263
+ script === undefined &&
1264
+ ((transit.has(name) && own.has(name)) || noop)
1265
+ ) {
1266
+ tasks.push(
1267
+ buildTask(
1268
+ literals,
1269
+ name,
1270
+ noop ? { ...defFor(name)!, outputs: [] } : defFor(name)!,
1271
+ 'true',
1272
+ own,
1273
+ defFor,
1274
+ emitted,
1275
+ emittedAnywhere,
1276
+ globals,
1277
+ opts,
1278
+ relPosix(root, meta.dir),
1279
+ rootDotenv,
1280
+ rootMeta?.name,
1281
+ { name: meta.name, persistentAt, withOf },
1282
+ inferredOf.get(meta.name) ?? [],
1283
+ spelledOf.get(meta.name),
1284
+ ),
1285
+ )
1286
+ continue
1287
+ }
1288
+ if (override === undefined && script === undefined && emitted.get(meta.name)!.has(name)) {
1289
+ // No script, but edges Turbo's no-op node keeps (above): a group
1290
+ // task depending on them does the same (with-tailwind's `ui#dev`
1291
+ // starts `dev:styles` and `dev:components`; its `ui#build` builds
1292
+ // `build:styles` and `build:components`).
1293
+ const groupDef = entry.has(`${meta.name}#${name}`)
1294
+ ? {
1295
+ ...defFor(name)!,
1296
+ dependsOn: entryEdges(defFor(name)!.dependsOn ?? [], meta.name, rootMeta?.name),
1297
+ }
1298
+ : defFor(name)!
1299
+ const t = buildTask(
1300
+ literals,
1301
+ name,
1302
+ groupDef,
1303
+ '',
1304
+ own,
1305
+ defFor,
1306
+ emitted,
1307
+ emittedAnywhere,
1308
+ globals,
1309
+ opts,
1310
+ relPosix(root, meta.dir),
1311
+ rootDotenv,
1312
+ rootMeta?.name,
1313
+ { name: meta.name, persistentAt, withOf },
1314
+ inferredOf.get(meta.name) ?? [],
1315
+ spelledOf.get(meta.name),
1316
+ )
1317
+ // Emitted even when its edges all drop: other packages' edges were
1318
+ // validated against it, and `dependsOn: []` is a group that waits
1319
+ // on nothing.
1320
+ const deps = t.task?.['dependsOn']
1321
+ tasks.push({
1322
+ name,
1323
+ todos: t.todos.filter((x) => x !== opts.persistentTodo),
1324
+ task: { dependsOn: Array.isArray(deps) ? deps : [] },
1325
+ uses: new Set(),
1326
+ })
1327
+ continue
1328
+ }
1329
+ if (override === undefined && !usableScript(script)) {
1330
+ // The turbo task exists and so does the script KEY, but its value
1331
+ // can't become a command. Report it instead of emitting
1332
+ // `command: 42` / `command: null` — the first writes a config that
1333
+ // fails to load, the second used to abort the whole migration in
1334
+ // the emitter, and both landed AFTER "migrated clean, 0 TODOs".
1335
+ // An ABSENT script stays silent: turbo skips the task there too.
1336
+ if (script !== undefined) {
1337
+ tasks.push({
1338
+ name,
1339
+ todos: [
1340
+ `package.json script ${JSON.stringify(name)} is ${describeScript(script)}, not a ` +
1341
+ 'non-empty command string — task skipped; write the command by hand',
1342
+ ],
1343
+ task: null,
1344
+ uses: new Set(),
1345
+ })
1346
+ }
1347
+ continue
1348
+ }
1349
+ const command = override ?? scriptCommand(name, script as string, scripts, pnp)
1350
+ const mapped = buildTask(
1351
+ literals,
1352
+ name,
1353
+ defFor(name)!,
1354
+ command,
1355
+ own,
1356
+ defFor,
1357
+ emitted,
1358
+ emittedAnywhere,
1359
+ globals,
1360
+ opts,
1361
+ relPosix(root, meta.dir),
1362
+ rootDotenv,
1363
+ rootMeta?.name,
1364
+ { name: meta.name, persistentAt, withOf },
1365
+ inferredOf.get(meta.name) ?? [],
1366
+ spelledOf.get(meta.name),
1367
+ )
1368
+ if (override === undefined && mapped.task !== null)
1369
+ npmScriptEnv(mapped.task, name, command === script, meta, opts)
1370
+ tasks.push(mapped)
1371
+ }
1372
+ resolveSharedOutputs(tasks)
1373
+ projects.push({ name: meta.name, dir: meta.dir, tasks })
1374
+ }
1375
+
1376
+ if (opts.ignored !== undefined) {
1377
+ const ignored = await opts.ignored([
1378
+ ...new Set(literals.tasks.flatMap((l) => [...l.rels.keys()])),
1379
+ ])
1380
+ for (const { inputs, rels } of literals.tasks) {
1381
+ const hits = [...rels].filter(([rel]) => ignored.has(rel))
1382
+ if (hits.length > 0) keyIgnored(inputs, hits)
1383
+ }
1384
+ }
1385
+
1386
+ nestedInputs(root, projects)
1387
+ for (const [entry, n] of wildcardUses)
1388
+ notes.push(
1389
+ `env ${JSON.stringify(entry)}: the configs list the names the files of each task's ` +
1390
+ `package and its workspace dependencies spell (${n} task${n === 1 ? '' : 's'}) — add ` +
1391
+ 'any other a task reads to cache.inputs.env and exec.env.passThrough',
1392
+ )
1393
+ literalEnvGapsOnce(projects, notes)
1394
+ resolveSharedWorkspaceOutputs(root, projects)
1395
+ pruneUnreachedPersistentNotes(projects, metas, opts.persistentTodo)
1396
+ pruneOrphanPersistentNotes(projects, opts.persistentTodo)
1397
+ return { projects, notes, globals }
1398
+ }
1399
+
1400
+ /**
1401
+ * Turbo hashes a package's files as git lists them, nested workspace
1402
+ * packages included (probed on 2.11.6: `a#build` keys `n/x.ts` of a
1403
+ * package `n` inside `a`). cal.com's `@calcom/app-store` holds ~100 app
1404
+ * packages its source imports by relative path; core's file globs stop at
1405
+ * a nested project, so an edit to an app replayed `@calcom/web#build`
1406
+ * from the cache. Each file glob that reaches a nested package is listed
1407
+ * again in `workspaceFiles`, anchored there, or at the package when it
1408
+ * opens on `**` (its own files are keyed twice, the same way).
1409
+ */
1410
+ function nestedInputs(root: string, projects: readonly TurboMappedProject[]): void {
1411
+ const rels = projects.map((p) => relPosix(root, p.dir))
1412
+ projects.forEach((p, i) => {
1413
+ const own = rels[i]!
1414
+ if (own === '' || own === '.') return
1415
+ const inside = rels.filter((r) => r.startsWith(`${own}/`)).map((r) => r.slice(own.length + 1))
1416
+ // A package inside a nested one is reached through it.
1417
+ const nested = inside.filter((n) => !inside.some((m) => n.startsWith(`${m}/`)))
1418
+ if (nested.length === 0) return
1419
+ for (const t of p.tasks) {
1420
+ const inputs = (t.task?.['cache'] as { inputs?: Record<string, unknown> } | undefined)?.inputs
1421
+ const files = inputs?.['files']
1422
+ if (inputs === undefined || !Array.isArray(files)) continue
1423
+ const extra: string[] = []
1424
+ for (const g of files) {
1425
+ if (typeof g !== 'string') continue
1426
+ const neg = g.startsWith('!')
1427
+ const body = neg ? g.slice(1) : g
1428
+ // One that opens on `**` reaches them all: listed once, at the package.
1429
+ if (body.startsWith('**')) extra.push(`${neg ? '!' : ''}${own}/${body}`)
1430
+ else
1431
+ for (const n of nested) {
1432
+ const under = reanchor(body, n)
1433
+ if (under !== null) extra.push(`${neg ? '!' : ''}${own}/${n}/${under}`)
1434
+ }
1435
+ }
1436
+ if (!extra.some((g) => !g.startsWith('!'))) continue
1437
+ const ws = inputs['workspaceFiles']
1438
+ inputs['workspaceFiles'] = uniq([...(Array.isArray(ws) ? ws : []), ...extra])
1439
+ }
1440
+ })
1441
+ }
1442
+
1443
+ /**
1444
+ * A literal env name core cannot key (`\\*`, which Turbo reads as the one
1445
+ * variable `*`) keys nothing in Turbo either unless the run sets it, so it
1446
+ * is no task's to fix: openstatus's root `build` env wrote the same
1447
+ * TODO into 45 configs. Reported once, with how many tasks name it.
1448
+ */
1449
+ function literalEnvGapsOnce(projects: readonly TurboMappedProject[], notes: string[]): void {
1450
+ const counts = new Map<string, number>()
1451
+ for (const p of projects)
1452
+ for (const t of p.tasks)
1453
+ t.todos = t.todos.filter((todo) => {
1454
+ if (!/^(env|passThroughEnv) ".*": Turbo reads this as the one variable /s.test(todo))
1455
+ return true
1456
+ counts.set(todo, (counts.get(todo) ?? 0) + 1)
1457
+ return false
1458
+ })
1459
+ for (const [todo, n] of counts) notes.push(`${todo} (${n} task${n === 1 ? '' : 's'})`)
1460
+ }
1461
+
1462
+ /**
1463
+ * The part of a package-relative glob below `dir`, its nested package's
1464
+ * directory: `**\/*.ts` under `n` is `**\/*.ts`, `src/**` under `src/n`
1465
+ * is `**`; null when the glob cannot reach a file there.
1466
+ */
1467
+ function reanchor(glob: string, dir: string): string | null {
1468
+ const gs = glob.split('/')
1469
+ const ds = dir.split('/')
1470
+ for (let i = 0; i < ds.length; i++) {
1471
+ const seg = gs[i]
1472
+ if (seg === undefined) return null
1473
+ if (seg === '**') return gs.slice(i).join('/')
1474
+ if (!new Bun.Glob(seg).match(ds[i]!)) return null
1475
+ }
1476
+ return gs.length > ds.length ? gs.slice(ds.length).join('/') : null
1477
+ }
1478
+
1479
+ /**
1480
+ * The readiness note on a persistent task is for its dependents. Core's
1481
+ * prune matches by name, so any `^dev` kept every persistent `dev`'s note:
1482
+ * create-t3-turbo's root `dev` depends on `^dev`, and its three apps, which
1483
+ * nothing depends on, each carried a note about dependents. A `^name` edge
1484
+ * reaches only the packages its package depends on (transitively, as core
1485
+ * walks past one without the task); `name` its own package; `pkg#name` one.
1486
+ */
1487
+ function pruneUnreachedPersistentNotes(
1488
+ projects: readonly TurboMappedProject[],
1489
+ metas: readonly ProjectMeta[],
1490
+ note: string,
1491
+ ): void {
1492
+ const byName = new Map(metas.map((m) => [m.name, m]))
1493
+ const below = (name: string): Set<string> => {
1494
+ const seen = new Set<string>()
1495
+ const queue = [name]
1496
+ while (queue.length > 0) {
1497
+ const m = byName.get(queue.pop()!)
1498
+ if (m === undefined) continue
1499
+ for (const d of metas)
1500
+ if (!seen.has(d.name) && d.name !== name && declares(m, d.name)) {
1501
+ seen.add(d.name)
1502
+ queue.push(d.name)
1503
+ }
1504
+ }
1505
+ return seen
1506
+ }
1507
+ const reached = new Set<string>()
1508
+ for (const p of projects)
1509
+ for (const t of p.tasks) {
1510
+ const deps = t.task?.['dependsOn']
1511
+ if (!Array.isArray(deps)) continue
1512
+ for (const d of deps) {
1513
+ if (typeof d !== 'string') continue
1514
+ if (d.startsWith('^')) for (const q of below(p.name)) reached.add(`${q}#${d.slice(1)}`)
1515
+ else reached.add(d.includes('#') ? d : `${p.name}#${d}`)
1516
+ }
1517
+ }
1518
+ for (const p of projects)
1519
+ for (const t of p.tasks) {
1520
+ if (reached.has(`${p.name}#${t.name}`)) continue
1521
+ const at = t.todos.indexOf(note)
1522
+ if (at !== -1) t.todos.splice(at, 1)
1523
+ }
1524
+ }
1525
+
1526
+ /**
1527
+ * A name both a global list and the task's own list carry — `globalEnv`
1528
+ * and a task `env`, `globalDependencies` and a `$TURBO_ROOT$/` input —
1529
+ * is listed once, in its first position. `spliced` names the global values
1530
+ * an opaque preset spread already holds (`vx-migrate`'s configs): written
1531
+ * twice there, a migrated config listed `tsconfig.base.json` twice and
1532
+ * keyed apart from the live `turbo()` run, which lists it once.
1533
+ */
1534
+ function uniq(values: readonly unknown[], spliced: readonly string[] = []): unknown[] {
1535
+ const seen = new Set<string>(spliced)
1536
+ const out: unknown[] = []
1537
+ for (const v of values) {
1538
+ if (typeof v === 'string') {
1539
+ if (seen.has(v)) continue
1540
+ seen.add(v)
1541
+ }
1542
+ out.push(v)
1543
+ }
1544
+ return out
1545
+ }
1546
+
1547
+ /** A `with` entry as `pkg#task`: bare is the task's own package, `//#` the root. */
1548
+ function sidecarId(entry: string, own: string, rootName: string | undefined): string {
1549
+ const hashAt = entry.indexOf('#')
1550
+ if (hashAt === -1) return `${own}#${entry}`
1551
+ return entry.startsWith(`${ROOT}#`) ? `${rootName ?? ROOT}${entry.slice(ROOT.length)}` : entry
1552
+ }
1553
+
1554
+ /**
1555
+ * The `npm_*` variables Turbo's tasks see: it runs a script through the
1556
+ * package manager (`pnpm run build`), which sets them, and vx runs the
1557
+ * body itself, so `echo $npm_package_version` printed nothing. The event
1558
+ * is the script's own name, which folded `pre`/`post` hooks do not share
1559
+ * (core's `vx init` rule, D-34).
1560
+ */
1561
+ function npmScriptEnv(
1562
+ task: Record<string, unknown>,
1563
+ script: string,
1564
+ unfolded: boolean,
1565
+ meta: ProjectMeta,
1566
+ opts: MapTurboOptions,
1567
+ ): void {
1568
+ const exec = task['exec'] as { command: string; env?: Record<string, unknown> }
1569
+ // Only a command that names one gets them, as core's `vx init` does: a
1570
+ // written config reads the manifest through a JSON import, which a
1571
+ // user's `tsc` over the package may refuse (G-123), and a live mapping
1572
+ // keys as the written one does, so the two share a cache.
1573
+ const named = new Set([...exec.command.matchAll(/\$\{?(npm_[A-Za-z0-9_]+)/g)].map((m) => m[1]))
1574
+ const wanted = (v: string) => named.has(v)
1575
+ const field = (key: 'name' | 'version', value: string): unknown =>
1576
+ opts.manifestField?.(key) ?? value
1577
+ const define: Record<string, unknown> = {}
1578
+ if (wanted('npm_package_name')) define['npm_package_name'] = field('name', meta.name)
1579
+ const version = meta.packageJson.version
1580
+ if (typeof version === 'string' && version !== '' && wanted('npm_package_version'))
1581
+ define['npm_package_version'] = field('version', version)
1582
+ if (unfolded && wanted('npm_lifecycle_event')) define['npm_lifecycle_event'] = script
1583
+ if (Object.keys(define).length > 0) exec.env = { ...exec.env, define }
1584
+ }
1585
+
1586
+ /**
1587
+ * An entry group's edges in one package: `pkg#task` is that package's own
1588
+ * task there and nobody's elsewhere (`//#task` the root's).
1589
+ */
1590
+ function entryEdges(deps: readonly string[], pkg: string, rootName: string | undefined): string[] {
1591
+ return deps.flatMap((d) => {
1592
+ if (d.startsWith('^') || !d.includes('#') || envDependency(d) !== null) return [d]
1593
+ const at = d.indexOf('#')
1594
+ const owner = d.slice(0, at) === ROOT ? (rootName ?? ROOT) : d.slice(0, at)
1595
+ return owner === pkg ? [d.slice(at + 1)] : []
1596
+ })
1597
+ }
1598
+
1599
+ function buildTask(
1600
+ literals: { readonly tasks: Literals[]; readonly globalIgnored: readonly string[] },
1601
+ name: string,
1602
+ def: TurboTask,
1603
+ command: string,
1604
+ own: ReadonlySet<string>,
1605
+ defFor: (name: string) => TurboTask | undefined,
1606
+ emitted: ReadonlyMap<string, ReadonlySet<string>>,
1607
+ emittedAnywhere: ReadonlySet<string>,
1608
+ globals: TurboMapping['globals'],
1609
+ opts: MapTurboOptions,
1610
+ pkgDir: string,
1611
+ rootDotenv: readonly string[],
1612
+ rootName: string | undefined,
1613
+ sidecars: {
1614
+ readonly name: string
1615
+ readonly persistentAt: ReadonlyMap<string, ReadonlySet<string>>
1616
+ readonly withOf: ReadonlyMap<string, readonly string[]>
1617
+ },
1618
+ inferred: readonly string[],
1619
+ spelled?: { readonly names: readonly string[]; readonly used: (entry: string) => void },
1620
+ ): TurboMappedTask {
1621
+ const todos: string[] = []
1622
+ // A glob that climbs out of the package (`../../packages/app-store/
1623
+ // *.generated.ts`, cal.com's app-store-cli) is a workspace-root glob
1624
+ // in vx's terms: re-anchor it on the root. One that climbs out of the
1625
+ // workspace has no home and is reported.
1626
+ const climbed = (glob: string): string | null => {
1627
+ const body = glob.startsWith('!') ? glob.slice(1) : glob
1628
+ if (!body.startsWith('../')) return null
1629
+ const anchored = path.posix.normalize(path.posix.join(pkgDir, body))
1630
+ if (anchored.startsWith('../')) return null
1631
+ return (glob.startsWith('!') ? '!' : '') + anchored
1632
+ }
1633
+ const uses = new Set<TurboGlobal>()
1634
+ const global = (kind: TurboGlobal): readonly unknown[] => {
1635
+ const values = globals[kind]
1636
+ if (values.length === 0) return []
1637
+ uses.add(kind)
1638
+ return opts.splice(kind, values)
1639
+ }
1640
+ // The global names a splice holds out of `uniq`'s sight: an opaque
1641
+ // preset spread (`vx-migrate`), never the names themselves (`turbo()`).
1642
+ const hidden = (...kinds: TurboGlobal[]): string[] =>
1643
+ kinds.flatMap((k) =>
1644
+ opts.splice(k, globals[k]).some((v) => typeof v !== 'string') ? globals[k] : [],
1645
+ )
1646
+ const persistent = def.persistent === true
1647
+ const cacheEnabled = def.cache !== false && !persistent
1648
+
1649
+ for (const [key, value] of Object.entries(def)) {
1650
+ if (KNOWN_TASK_KEYS.has(key)) continue
1651
+ // Turbo hands an interactive task the terminal's stdin in its TUI; vx
1652
+ // hands no task the terminal (runner.ts), so a prompt there reads end
1653
+ // of input (create-t3-turbo's \`drizzle-kit push\`). Nothing to map.
1654
+ if (key === 'interactive') {
1655
+ if (value === true) {
1656
+ todos.push(
1657
+ 'turbo key "interactive": vx gives no task the terminal, so a prompt reads end of ' +
1658
+ 'input — run a task that asks for input outside vx',
1659
+ )
1660
+ }
1661
+ continue
1662
+ }
1663
+ todos.push(
1664
+ `turbo key ${JSON.stringify(key)} (${JSON.stringify(value)}) has no vx equivalent — ` +
1665
+ 'map it manually',
1666
+ )
1667
+ }
1668
+ if (badCommand(def)) {
1669
+ todos.push(
1670
+ `turbo key "command" (${JSON.stringify(def['command'])}) is not an argv, null or a toolchain map of them — the script runs; write the command by hand`,
1671
+ )
1672
+ }
1673
+ const logsKey =
1674
+ (def as { outputLogs?: unknown }).outputLogs !== undefined ? 'outputLogs' : 'outputMode'
1675
+ const outputLogs = (def as Record<string, unknown>)[logsKey]
1676
+ if (outputLogs !== undefined && outputLogs !== OUTPUT_LOGS_DEFAULT) {
1677
+ todos.push(
1678
+ typeof outputLogs === 'string' && OUTPUT_LOGS_RUN_FLAG.has(outputLogs)
1679
+ ? `turbo key "${logsKey}" (${JSON.stringify(outputLogs)}) is a per-run setting in vx — ` +
1680
+ `run with --output-logs ${outputLogs}`
1681
+ : `turbo key "${logsKey}" (${JSON.stringify(outputLogs)}) is not a value vx knows — ` +
1682
+ 'run with --output-logs full|hash-only|errors-only|none',
1683
+ )
1684
+ }
1685
+
1686
+ const deps: string[] = []
1687
+ const envDeps: string[] = []
1688
+ // A same-package task Turbo defines but this package has no script for
1689
+ // is still a node in Turbo's graph — a no-op that keeps its own edges.
1690
+ // Dropping it dropped them too: `test → codegen → ^build` with no
1691
+ // `codegen` script lost `^build`, so `test` ran before its dependency's
1692
+ // build and its key never folded it (item 939). Its edges are followed
1693
+ // in its place, once each.
1694
+ const through = new Set<string>([name])
1695
+ const walk = (dependsOn: readonly string[]): void => {
1696
+ for (const d of dependsOn) {
1697
+ const envName = envDependency(d)
1698
+ if (envName !== null) {
1699
+ envDeps.push(envName)
1700
+ continue
1701
+ }
1702
+ if (d.includes('$TURBO_ROOT$')) {
1703
+ todos.push(
1704
+ `dependsOn ${JSON.stringify(d)} uses $TURBO_ROOT$, which names no task — ` +
1705
+ 'restructure manually',
1706
+ )
1707
+ continue
1708
+ }
1709
+ if (d.startsWith('^')) {
1710
+ // A task no package runs gives `^name` no edges under turbo. Passed
1711
+ // through, core refuses it as a typo: no project declares the name.
1712
+ if (emittedAnywhere.has(d.slice(1)) && !deps.includes(d)) deps.push(d)
1713
+ continue
1714
+ }
1715
+ const hashAt = d.indexOf('#')
1716
+ if (hashAt !== -1) {
1717
+ // `//#x` is the root project's `x`, named as vx names it.
1718
+ const pkg = d.startsWith(`${ROOT}#`) ? (rootName ?? ROOT) : d.slice(0, hashAt)
1719
+ const task = d.slice(hashAt + 1)
1720
+ if (emitted.get(pkg)?.has(task)) {
1721
+ const edge = `${pkg}#${task}`
1722
+ if (!deps.includes(edge)) deps.push(edge)
1723
+ } else if (pkg === ROOT)
1724
+ todos.push(
1725
+ `dependsOn ${JSON.stringify(d)}: the workspace root is no project — edge dropped; ` +
1726
+ 'a root package.json name no package holds makes it one',
1727
+ )
1728
+ // A package outside the workspace (highlight's `rrweb`, a submodule
1729
+ // not checked out) is no script gap.
1730
+ else if (!emitted.has(pkg))
1731
+ todos.push(
1732
+ `dependsOn ${JSON.stringify(d)}: no workspace package is named ${pkg} — edge dropped`,
1733
+ )
1734
+ else
1735
+ todos.push(
1736
+ `dependsOn ${JSON.stringify(d)}: ${pkg} declares no ${task} script — edge dropped`,
1737
+ )
1738
+ continue
1739
+ }
1740
+ if (own.has(d)) {
1741
+ if (!deps.includes(d)) deps.push(d)
1742
+ continue
1743
+ }
1744
+ const hop = through.has(d) ? undefined : defFor(d)
1745
+ if (hop !== undefined) {
1746
+ through.add(d)
1747
+ walk(hop.dependsOn ?? [])
1748
+ }
1749
+ }
1750
+ }
1751
+ walk(def.dependsOn ?? [])
1752
+
1753
+ // `with`: tasks Turbo runs alongside this one (`web#dev` with
1754
+ // `api#dev`). An edge to a persistent task is that: vx starts the
1755
+ // dependant once the sidecar has spawned, and runs it only when this
1756
+ // one runs. An edge to a task that ends would wait for it instead.
1757
+ const self = `${sidecars.name}#${name}`
1758
+ for (const w of def.with ?? []) {
1759
+ const id = sidecarId(w, sidecars.name, rootName)
1760
+ const hashAt = id.indexOf('#')
1761
+ const pkg = id.slice(0, hashAt)
1762
+ const task = id.slice(hashAt + 1)
1763
+ if (!sidecars.persistentAt.get(pkg)?.has(task)) {
1764
+ todos.push(
1765
+ `with ${JSON.stringify(w)}: ${emitted.get(pkg)?.has(task) ? 'not a persistent task' : `${pkg} declares no ${task} script`} — ` +
1766
+ 'run it beside this one by hand',
1767
+ )
1768
+ continue
1769
+ }
1770
+ // Turbo runs a pair that names each other side by side; as two edges
1771
+ // they are a cycle core refuses. One edge, the same way round each time.
1772
+ if (id === self || (sidecars.withOf.get(id)?.includes(self) && id > self)) continue
1773
+ const edge = pkg === sidecars.name ? task : id
1774
+ if (!deps.includes(edge)) deps.push(edge)
1775
+ }
1776
+
1777
+ const envNames: string[] = explicitEnv(
1778
+ [...inferred, ...envDeps, ...(def.env ?? [])],
1779
+ (e, literal) =>
1780
+ todos.push(
1781
+ envGap(
1782
+ 'env',
1783
+ e,
1784
+ literal,
1785
+ ' in vx env names — list explicit names in cache.inputs.env + exec.env.passThrough',
1786
+ ),
1787
+ ),
1788
+ opts.envNames,
1789
+ spelled,
1790
+ )
1791
+ const passNames: string[] = explicitEnv(
1792
+ def.passThroughEnv ?? [],
1793
+ (e, literal) => todos.push(envGap('passThroughEnv', e, literal, ' — list explicit names')),
1794
+ opts.envNames,
1795
+ spelled,
1796
+ )
1797
+
1798
+ const passThrough = uniq(
1799
+ [...global('env'), ...global('pass'), ...envNames, ...passNames],
1800
+ hidden('env', 'pass'),
1801
+ )
1802
+
1803
+ const exec: Record<string, unknown> = { command }
1804
+ if (passThrough.length > 0) exec.env = { passThrough }
1805
+ if (persistent) {
1806
+ exec.persistent = {}
1807
+ todos.push(opts.persistentTodo)
1808
+ }
1809
+
1810
+ const task: Record<string, unknown> = { exec }
1811
+ if (typeof def['description'] === 'string') task.description = def['description']
1812
+ if (deps.length > 0) task.dependsOn = deps
1813
+
1814
+ if (cacheEnabled) {
1815
+ const files: unknown[] = []
1816
+ // `.env` files Turbo hashes although git ignores them; a glob over git's
1817
+ // files keyed none of them (item 1032). Probed instead, per package and
1818
+ // at the root.
1819
+ let pkgDotenv = false
1820
+ // Any of them below the package root needs the walk.
1821
+ let pkgDotenvDeep = false
1822
+ // The root-relative `.env` globs, probed at the workspace root.
1823
+ const wsDotenv = [...rootDotenv]
1824
+ // globalDependencies are workspace-root-relative by definition —
1825
+ // they map to inputs.workspaceFiles, not project-relative files.
1826
+ const wsFiles: unknown[] = [...global('inputs')]
1827
+ // Root-relative path → the entry naming it.
1828
+ const rels = new Map<string, Named>(literals.globalIgnored.map((f) => [f, [null, f]]))
1829
+ const atRoot = pkgDir === '' || pkgDir === '.'
1830
+ if (def.inputs === undefined || def.inputs.length === 0) {
1831
+ // Turbo's default input set is every package file, and an empty
1832
+ // `inputs` is that default: mapped as no files, it keyed on nothing
1833
+ // in the package and an edit replayed a stale build (item 936).
1834
+ files.push('**/*')
1835
+ } else {
1836
+ for (const i of def.inputs) {
1837
+ if (i === '$TURBO_DEFAULT$') {
1838
+ files.push('**/*')
1839
+ continue
1840
+ }
1841
+ const neg = i.startsWith('!')
1842
+ // Turbo's globs have character classes and extglobs; a vx bracket is
1843
+ // a literal, so `src/**/*.[jt]s` keyed on nothing and an edit
1844
+ // replayed the old build (item 1031). A positive glob with no safe
1845
+ // form widens to every package file; a negation with none is dropped
1846
+ // (it would exclude fewer files, never more).
1847
+ const wax = minimatchToVx(neg ? i.slice(1) : i, neg)
1848
+ if (wax === null) {
1849
+ todos.push(`input ${JSON.stringify(i)}: glob syntax vx cannot take — map manually`)
1850
+ if (!neg) files.push('**/*')
1851
+ continue
1852
+ }
1853
+ const translated = (neg ? '!' : '') + wax
1854
+ const body = wax
1855
+ const up = climbed(translated)
1856
+ if (!neg && isDotenvGlob(body)) {
1857
+ if (body.startsWith('$TURBO_ROOT$/')) wsDotenv.push(body.slice('$TURBO_ROOT$/'.length))
1858
+ else if (up !== null) wsDotenv.push(up)
1859
+ else {
1860
+ pkgDotenv = true
1861
+ if (body.includes('/')) pkgDotenvDeep = true
1862
+ }
1863
+ continue
1864
+ }
1865
+ if (body.startsWith('$TURBO_ROOT$/')) {
1866
+ wsFiles.push((neg ? '!' : '') + body.slice('$TURBO_ROOT$/'.length))
1867
+ const rel = body.slice('$TURBO_ROOT$/'.length)
1868
+ if (!neg) rels.set(rel, ['workspaceFiles', rel])
1869
+ } else if (up !== null) {
1870
+ wsFiles.push(up)
1871
+ if (!neg) rels.set(up, ['workspaceFiles', up])
1872
+ } else if (body.startsWith('../')) {
1873
+ todos.push(`input ${JSON.stringify(i)}: leaves the workspace — map manually`)
1874
+ } else if (i.includes('$TURBO_ROOT$')) {
1875
+ todos.push(
1876
+ `input ${JSON.stringify(i)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
1877
+ 'prefix (→ cache.inputs.workspaceFiles) — map manually',
1878
+ )
1879
+ } else {
1880
+ files.push(translated)
1881
+ const rel = path.posix.join(atRoot ? '.' : pkgDir, body)
1882
+ if (!neg) rels.set(rel, [atRoot ? 'workspaceFiles' : 'files', translated])
1883
+ }
1884
+ }
1885
+ // Exclusions alone narrow every package file: core refuses a list
1886
+ // with nothing to narrow, and that refusal failed the whole run
1887
+ // (item 936). Every file is the widest reading, so it can cost a
1888
+ // hit and never serve a stale one.
1889
+ if (files.length > 0 && files.every((f) => typeof f === 'string' && f.startsWith('!'))) {
1890
+ files.unshift('**/*')
1891
+ }
1892
+ }
1893
+ // Turbo 1's task `dotEnv`: package-relative `.env` files it hashes,
1894
+ // gitignored as a rule, so read as files they keyed nothing (item 1032).
1895
+ const dotEnv = (def as { dotEnv?: unknown }).dotEnv
1896
+ if (Array.isArray(dotEnv) && dotEnv.some((f) => typeof f === 'string')) pkgDotenv = true
1897
+
1898
+ const outFiles: string[] = []
1899
+ const wsOutFiles: string[] = []
1900
+ for (const raw of def.outputs ?? []) {
1901
+ // The first segment stays a literal (a route directory, item 667);
1902
+ // the rest is Turbo's grammar: `dist/**/*.[cm]js` matched nothing, so
1903
+ // a hit restored nothing (item 1031).
1904
+ const cut = raw.indexOf('/')
1905
+ const rest = cut < 0 ? null : minimatchToVx(raw.slice(cut + 1), raw.startsWith('!'))
1906
+ if (cut >= 0 && rest === null) {
1907
+ todos.push(
1908
+ `output ${JSON.stringify(raw)}: glob syntax vx cannot take — task runs uncached; ` +
1909
+ 'declare the exact outputs in a vx.config to cache it',
1910
+ )
1911
+ return { name, todos, task, uses }
1912
+ }
1913
+ const mapped = cut < 0 ? raw : `${raw.slice(0, cut + 1)}${rest}`
1914
+ // A `!` output takes a path back, as Turbo's does (A-44): Next's
1915
+ // `.next/**` minus `!.next/cache/**` saved the cache and cleaned it
1916
+ // before every run while vx outputs could not exclude.
1917
+ const neg = mapped.startsWith('!') ? '!' : ''
1918
+ const o = mapped.slice(neg.length)
1919
+ if (o.startsWith('$TURBO_ROOT$/')) {
1920
+ wsOutFiles.push(neg + o.slice('$TURBO_ROOT$/'.length))
1921
+ } else if (climbed(o) !== null) {
1922
+ wsOutFiles.push(neg + climbed(o)!)
1923
+ } else if (o.startsWith('../')) {
1924
+ todos.push(`output ${JSON.stringify(o)}: leaves the workspace — map manually`)
1925
+ } else if (o.includes('$TURBO_ROOT$')) {
1926
+ todos.push(
1927
+ `output ${JSON.stringify(o)}: $TURBO_ROOT$ only maps as a '$TURBO_ROOT$/<path>' ` +
1928
+ 'prefix (→ cache.outputs.workspaceFiles) — map manually',
1929
+ )
1930
+ } else outFiles.push(neg + o)
1931
+ }
1932
+
1933
+ // A committed file a literal-tail wildcard reaches is taken back, as
1934
+ // the runtime spares a tracked output; past the runtime's limit the
1935
+ // task stays uncached.
1936
+ for (const o of [...outFiles]) {
1937
+ const hits = literalTailMatches(o, opts.tracked?.(pkgDir))
1938
+ if (hits !== null && hits.length <= MAX_SPARED)
1939
+ for (const h of hits) if (!outFiles.includes(`!${h}`)) outFiles.push(`!${h}`)
1940
+ }
1941
+ const wild = wildcardOutput(outFiles, opts.tracked?.(pkgDir), true)
1942
+ if (wild !== undefined) {
1943
+ todos.push(wildcardTodo(wild))
1944
+ return { name, todos, task, uses }
1945
+ }
1946
+ const own = ownFileOutput(outFiles, opts.ownConfig?.(pkgDir))
1947
+ if (own !== undefined) {
1948
+ todos.push(ownFileTodo(own))
1949
+ return { name, todos, task, uses }
1950
+ }
1951
+
1952
+ const cacheEnv = uniq([...global('env'), ...envNames], hidden('env'))
1953
+
1954
+ // Turbo hashes a root task over the whole repo; core stops a root
1955
+ // project's own globs at every member (D-39), so as `files` a root
1956
+ // `oxlint` over the repo replayed green after a member file broke.
1957
+ // A root task's globs are the workspace's, outputs too.
1958
+ if (pkgDir === '' || pkgDir === '.') {
1959
+ wsFiles.unshift(...files.splice(0))
1960
+ wsOutFiles.unshift(...outFiles.splice(0))
1961
+ }
1962
+ const inputs: Record<string, unknown> = { files }
1963
+ if (wsFiles.length > 0) inputs.workspaceFiles = uniq(wsFiles, hidden('inputs'))
1964
+ if (cacheEnv.length > 0) inputs.env = cacheEnv
1965
+ if (pkgDotenv) inputs.runtime = [pkgDotenvDeep ? DOTENV_PROBE : DOTENV_PROBE_TOP]
1966
+ if (wsDotenv.length > 0) inputs.workspaceRuntime = [dotenvGlobsProbe(wsDotenv) ?? DOTENV_PROBE]
1967
+ for (const rel of rels.keys()) if (!isLiteral(rel)) rels.delete(rel)
1968
+ literals.tasks.push({ inputs, rels })
1969
+ const outputs: Record<string, unknown> = { files: takingBack(outFiles) }
1970
+ const ws = takingBack(wsOutFiles)
1971
+ if (ws.length > 0) outputs.workspaceFiles = ws
1972
+ task.cache = { inputs, outputs }
1973
+ }
1974
+
1975
+ return { name, todos, task, uses }
1976
+ }