@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.
- package/LICENSE +21 -0
- package/README.md +306 -0
- package/index.ts +4 -0
- package/package.json +44 -6
- package/src/adoption-plugin.ts +125 -0
- package/src/bin.ts +18 -0
- package/src/dotenv-probe.ts +86 -0
- package/src/glob-grammar.ts +63 -0
- package/src/index.ts +15 -0
- package/src/mapping-cache.ts +101 -0
- package/src/migrate-nx.ts +154 -0
- package/src/migrate-turbo.ts +400 -0
- package/src/migrate.ts +165 -0
- package/src/nx/export-graph.ts +29 -0
- package/src/nx/index.ts +638 -0
- package/src/nx/nx-deps.ts +272 -0
- package/src/nx/nx-dotenv.ts +106 -0
- package/src/nx/nx-inputs.ts +210 -0
- package/src/nx/nx-map.ts +1430 -0
- package/src/nx/nx-outputs.ts +231 -0
- package/src/nx/nx-upstream.ts +356 -0
- package/src/nx-cache/index.ts +236 -0
- package/src/nx-command.ts +458 -0
- package/src/nx-dotenv.cjs +41 -0
- package/src/nx-env.cjs +152 -0
- package/src/nx-exec.cjs +294 -0
- package/src/paths.ts +36 -0
- package/src/plugin-gaps.ts +48 -0
- package/src/remote-deadline.ts +11 -0
- package/src/remote-retry.ts +52 -0
- package/src/remote-token.ts +18 -0
- package/src/script-command.ts +69 -0
- package/src/shared-outputs.ts +373 -0
- package/src/tracked-outputs.ts +248 -0
- package/src/turbo/index.ts +291 -0
- package/src/turbo/turbo-map.ts +1976 -0
- package/src/turbo-cache/index.ts +661 -0
- package/src/workspace-notes.ts +54 -0
|
@@ -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
|
+
}
|