@vzn/vx-migrate 0.0.645 → 0.0.648

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.
@@ -14,21 +14,21 @@ import path from 'node:path'
14
14
  import type { ProjectMeta, VxPlugin } from '@vzn/vx'
15
15
  import type { PackageMeta } from '../packages.js'
16
16
  import { memberProjects, withManifests } from '../manifests.js'
17
- import { refuseUnknownOptions, type PluginOptionKinds } from '@vzn/vx'
17
+ import { refuseUnknownOptions, UserError, type PluginOptionKinds } from '@vzn/vx'
18
18
  import { type AdoptionRun, adoptionPlugin } from '../adoption-plugin.js'
19
19
  import type { AdoptionMapping } from '../mapping-cache.js'
20
20
  import { collectGaps } from '../plugin-gaps.js'
21
21
  import { gitIgnored, trackedKinds } from '../tracked-outputs.js'
22
22
  import { packageScripts, relPosix } from '../paths.js'
23
23
  import { onceReader, type ReadText, tscBuildInfo } from '../tsc-buildinfo.js'
24
+ import type * as TurboMap from './turbo-map.js'
25
+ import type { TurboMappedProject } from './turbo-map.js'
24
26
  import {
25
27
  envNamesThatMap,
26
- mapTurboWorkspace,
27
28
  microfrontendsConfigs,
28
29
  rootTaskProject,
29
30
  turboConfigFile,
30
- type TurboMappedProject,
31
- } from './turbo-map.js'
31
+ } from './turbo-reads.js'
32
32
 
33
33
  /** The note every persistent task carries; like every gap, reported once per run for all its tasks. */
34
34
  const PERSISTENT_NOTE =
@@ -38,7 +38,8 @@ const PERSISTENT_NOTE =
38
38
  export interface TurboPluginOptions {
39
39
  /**
40
40
  * Where turbo.json lives. Defaults to the workspace root; a repo whose
41
- * turbo.json sits elsewhere names the directory here.
41
+ * turbo.json sits elsewhere names the directory here, relative to the workspace
42
+ * root or absolute.
42
43
  */
43
44
  readonly root?: string
44
45
  }
@@ -54,22 +55,27 @@ const TURBO_PLUGIN_KEYS: PluginOptionKinds<TurboPluginOptions> = {
54
55
  */
55
56
  export function turbo(options: TurboPluginOptions = {}): VxPlugin {
56
57
  refuseUnknownOptions('turbo()', options, TURBO_PLUGIN_KEYS)
58
+ if (options.root === '') throw new UserError('turbo() option "root" is a directory, not empty')
59
+ // Against the workspace root, as vx reads every path: against the cwd,
60
+ // `vx run` from a package dir read another directory.
61
+ const rootOf = (workspaceRoot: string): string =>
62
+ options.root === undefined ? workspaceRoot : path.resolve(workspaceRoot, options.root)
57
63
  return adoptionPlugin(
58
64
  import.meta,
59
- (ctx) => run(options.root ?? ctx.workspaceRoot, ctx.projects),
65
+ (ctx) => run(rootOf(ctx.workspaceRoot), ctx.projects),
60
66
  // At the workspace root only: a claim is a root name (a `root` elsewhere
61
67
  // is not claimed; its edits select as any unowned file does).
62
68
  options.root === undefined ? ['turbo.json', 'turbo.jsonc'] : [],
63
69
  {
64
70
  async config(workspace, ctx) {
65
- const keys = await workspaceKeys(options.root ?? ctx.workspaceRoot)
71
+ const keys = await workspaceKeys(rootOf(ctx.workspaceRoot))
66
72
  if (workspace.run?.concurrency === undefined && keys.concurrency !== undefined)
67
73
  workspace.run = { ...workspace.run, concurrency: keys.concurrency }
68
74
  // `turbo run --affected` compares with TURBO_SCM_BASE when set, and
69
75
  // on GitHub Actions with what the event names.
70
76
  const base =
71
77
  Bun.env['TURBO_SCM_BASE']?.trim() ||
72
- githubActionsBase(options.root ?? ctx.workspaceRoot, keys.remoteBaseFallback === true)
78
+ githubActionsBase(rootOf(ctx.workspaceRoot), keys.remoteBaseFallback === true)
73
79
  if (base) ctx.setBranch({ base })
74
80
  },
75
81
  // Turbo's packages are the package manager's members, each a project
@@ -189,14 +195,20 @@ const textOf = (file: string): Promise<string> =>
189
195
  /**
190
196
  * Everything the mapping reads: the root's and each package's
191
197
  * `turbo.json` / `turbo.jsonc`, every package manifest, the root's,
192
- * `.yarnrc.yml` and the microfrontends configs.
198
+ * `.yarnrc.yml`, `.npmrc`, `pnpm-workspace.yaml` and the microfrontends configs.
193
199
  */
194
200
  async function run(root: string, projects: readonly ProjectMeta[]): Promise<AdoptionRun> {
195
- const metas = await withManifests(projects)
196
- const dirs = [root, ...metas.map((m) => m.dir)]
197
- const configs = await Promise.all(
198
- dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
199
- )
201
+ const dirs = [root, ...projects.map((m) => m.dir)]
202
+ const [metas, configs, yarnrc, rootManifest, hookConfigs] = await Promise.all([
203
+ withManifests(projects),
204
+ Promise.all(
205
+ dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
206
+ ),
207
+ textOf(path.join(root, '.yarnrc.yml')),
208
+ textOf(path.join(root, 'package.json')),
209
+ // Whether the manager runs `pre` / `post` hooks, so whether they fold.
210
+ Promise.all(['.npmrc', 'pnpm-workspace.yaml'].map((f) => textOf(path.join(root, f)))),
211
+ ])
200
212
  const envNames = envNamesThatMap(configs, envNamesNow())
201
213
  // The tsconfigs a `tsc` script reads are the mapping's inputs too.
202
214
  const tsc = onceReader()
@@ -213,9 +225,11 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
213
225
  process.env['TURBO_CI_VENDOR_ENV_KEY'] ?? '',
214
226
  process.env['TURBO_ENV_MODE'] ?? '',
215
227
  ...configs,
216
- await textOf(path.join(root, '.yarnrc.yml')),
217
- // Its dependencies are global inputs, a member or not.
218
- await textOf(path.join(root, 'package.json')),
228
+ yarnrc,
229
+ // Whether pnpm or npm runs a script's `pre` / `post` hooks (`runsScriptHooks`).
230
+ ...hookConfigs,
231
+ // Its dependencies are global inputs, a member or not; its `packageManager` names who runs hooks.
232
+ rootManifest,
219
233
  JSON.stringify(microfrontendsConfigs(root, dirs)),
220
234
  // A config file added beside mapped tasks changes what an output may cover.
221
235
  JSON.stringify(metas.map((m) => [m.name, m.dir, m.packageJson, m.configPath])),
@@ -244,6 +258,8 @@ async function mapAll(
244
258
  m.configPath === null ? [] : [[relPosix(root, m.dir), path.basename(m.configPath)] as const],
245
259
  ),
246
260
  )
261
+ // Only a mapping the kept one no longer holds loads the mapper.
262
+ const { mapTurboWorkspace } = require('./turbo-map.js') as typeof TurboMap
247
263
  const mapped = await mapTurboWorkspace(root, metas, {
248
264
  // Inline: the values themselves, where `bunx @vzn/vx-migrate` splices a preset import.
249
265
  splice: (_kind, values) => values,
@@ -10,13 +10,13 @@
10
10
  // written.
11
11
  // The consumer decides what a global becomes through `splice`.
12
12
 
13
- import { existsSync, readFileSync } from 'node:fs'
14
13
  import path from 'node:path'
15
14
  import { isLiteralPattern, pruneOrphanPersistentNotes, UserError } from '@vzn/vx'
16
15
  import type { PackageMeta } from '../packages.js'
17
- import { literalGlob, minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
16
+ import { refuseTaskNames } from '../plugin-gaps.js'
17
+ import { apartTakeBacks, literalGlob, minimatchToVx, withoutTakenBack } from '../glob-grammar.js'
18
18
  import { shellQuote } from '../nx-command.js'
19
- import { scriptCommand, yarnPnp } from '../script-command.js'
19
+ import { type Owner, runsScriptHooks, scriptCommand, yarnPnp } from '../script-command.js'
20
20
  import {
21
21
  ownFileTakeBacks,
22
22
  excludeSiblingOutputs,
@@ -31,6 +31,13 @@ import {
31
31
  } from '../shared-outputs.js'
32
32
  import { manifestGraph, type ManifestGraph } from '../manifest-graph.js'
33
33
  import { isRecord, packageScripts, relPosix } from '../paths.js'
34
+ import {
35
+ FRAMEWORK_ENV,
36
+ keyable,
37
+ microfrontendsConfigs,
38
+ ROOT,
39
+ turboConfigFile,
40
+ } from './turbo-reads.js'
34
41
  import type { TrackedKinds } from '../tracked-outputs.js'
35
42
  import {
36
43
  DOTENV_PROBE,
@@ -69,34 +76,6 @@ interface TurboJson {
69
76
 
70
77
  const SHELL_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/
71
78
 
72
- /** A name core takes in `inputs.env` and `exec.env.passThrough`. */
73
- const keyable = (name: string): boolean =>
74
- name.length > 0 && !name.startsWith('!') && !/[*?[\]{}=\0]/.test(name)
75
-
76
- /**
77
- * The names of `names` a live mapping can read differently: each a `*` in
78
- * one of `configs` (turbo.json texts, any string, a superset) or in Turbo's
79
- * framework table matches, and each core cannot key (a literal one is
80
- * dropped only when no variable has it). The kept mapping keyed on every
81
- * name, so any variable that came or went (a CI step's, `VX_DEBUG_TIMING`)
82
- * remapped the workspace: ~330 ms a run on vercel/ai.
83
- */
84
- export function envNamesThatMap(configs: readonly string[], names: readonly string[]): string[] {
85
- const patterns = new Set(FRAMEWORK_ENV.flatMap((f) => f.env).filter((e) => e.includes('*')))
86
- for (const text of configs)
87
- for (const m of text.matchAll(/"((?:[^"\\]|\\.)*)"/g))
88
- if (m[1]!.includes('*')) patterns.add(m[1]!)
89
- // Backslashes and a leading `!` dropped, every `*` a wildcard: wider
90
- // than Turbo's reading, never narrower.
91
- const res = [...patterns].map(
92
- (p) =>
93
- new RegExp(
94
- `^${p.replaceAll('\\', '').replace(/^!/, '').split('*').map(RegExp.escape).join('.*')}$`,
95
- ),
96
- )
97
- return names.filter((n) => !keyable(n) || res.some((re) => re.test(n)))
98
- }
99
-
100
79
  /**
101
80
  * A Turbo env list's explicit names, read as Turbo reads them
102
81
  * (`wildcard_to_regex_pattern`): `*` is the one wildcard, `\*` a
@@ -195,83 +174,6 @@ const KNOWN_TASK_KEYS = new Set([
195
174
  const OUTPUT_LOGS_DEFAULT = 'new-only'
196
175
  const OUTPUT_LOGS_RUN_FLAG = new Set(['full', 'hash-only', 'errors-only', 'none'])
197
176
 
198
- // Turbo 2's framework inference: a package that depends on a framework
199
- // has its variables hashed into its tasks and passed to them, with
200
- // nothing in turbo.json saying so. vx env names are explicit, so the
201
- // variables were stripped in silence and a build that inlines them
202
- // (Next's `NEXT_PUBLIC_*`) read empty values (item 940). A live mapping
203
- // (`envNames`) infers them as Turbo does; the migrate CLI lists the names
204
- // the package's own files spell (`sourceNames`) and says so in a note.
205
- // Turbo's own table (`packages/turbo-types/src/json/frameworks.json`),
206
- // in its order: a package takes the FIRST framework it matches, `all`
207
- // needing every dependency and `some` any one.
208
- const NITRO_ENV = [
209
- 'NITRO_*',
210
- 'SERVER_*',
211
- 'AWS_APP_ID',
212
- 'INPUT_AZURE_STATIC_WEB_APPS_API_TOKEN',
213
- 'CLEAVR',
214
- 'CF_PAGES',
215
- 'FIREBASE_APP_HOSTING',
216
- 'NETLIFY',
217
- 'STORMKIT',
218
- 'NOW_BUILDER',
219
- 'ZEABUR',
220
- 'RENDER',
221
- ]
222
- const FRAMEWORK_ENV: ReadonlyArray<{
223
- slug: string
224
- env: readonly string[]
225
- all: boolean
226
- dependencies: readonly string[]
227
- }> = [
228
- { slug: 'astro', env: ['PUBLIC_*'], all: true, dependencies: ['astro'] },
229
- { slug: 'blitzjs', env: ['NEXT_PUBLIC_*'], all: true, dependencies: ['blitz'] },
230
- {
231
- slug: 'create-react-app',
232
- env: ['REACT_APP_*'],
233
- all: false,
234
- dependencies: ['react-scripts', 'react-dev-utils'],
235
- },
236
- { slug: 'expo', env: ['EXPO_PUBLIC_*'], all: true, dependencies: ['expo'] },
237
- { slug: 'gatsby', env: ['GATSBY_*'], all: true, dependencies: ['gatsby'] },
238
- {
239
- slug: 'nextjs',
240
- env: ['NEXT_PUBLIC_*', 'NEXT_DEPLOYMENT_ID'],
241
- all: true,
242
- dependencies: ['next'],
243
- },
244
- {
245
- slug: 'nitro',
246
- env: NITRO_ENV,
247
- all: false,
248
- dependencies: ['nitropack', 'nitropack-nightly', 'nitro', 'nitro-nightly'],
249
- },
250
- {
251
- slug: 'nuxtjs',
252
- env: ['NUXT_*', 'NUXT_ENV_*', ...NITRO_ENV, 'LAUNCH_EDITOR'],
253
- all: false,
254
- dependencies: ['nuxt', 'nuxt-edge', 'nuxt3', 'nuxt3-edge'],
255
- },
256
- { slug: 'redwoodjs', env: ['REDWOOD_ENV_*'], all: true, dependencies: ['@redwoodjs/core'] },
257
- {
258
- slug: 'remix',
259
- env: ['REMIX_*'],
260
- all: false,
261
- dependencies: ['@remix-run/dev', '@remix-run/react', '@remix-run/serve', '@react-router/dev'],
262
- },
263
- { slug: 'sanity', env: ['SANITY_STUDIO_*'], all: true, dependencies: ['@sanity/cli'] },
264
- { slug: 'solidstart', env: ['VITE_*'], all: true, dependencies: ['solid-js', 'solid-start'] },
265
- { slug: 'sveltekit', env: ['VITE_*', 'PUBLIC_*'], all: true, dependencies: ['@sveltejs/kit'] },
266
- { slug: 'vite', env: ['VITE_*'], all: true, dependencies: ['vite'] },
267
- {
268
- slug: 'vue',
269
- env: ['VUE_APP_*', 'LAUNCH_EDITOR'],
270
- all: true,
271
- dependencies: ['@vue/cli-service'],
272
- },
273
- ]
274
-
275
177
  /** The three global fields of turbo.json a task may draw on. */
276
178
  export type TurboGlobal = 'inputs' | 'env' | 'pass'
277
179
 
@@ -398,6 +300,14 @@ function covers(globs: readonly string[], rel: string): boolean {
398
300
  )
399
301
  }
400
302
 
303
+ /**
304
+ * Turbo reads `**` glued to text as the deep glob: `**x` as `**\/*x`, `x**`
305
+ * as `x*\/**` (vercel/turborepo#5431). Core refuses the glued spelling, so
306
+ * copied verbatim it failed the load.
307
+ */
308
+ const turboGlobstar = (glob: string): string =>
309
+ glob.replace(/\*\*([^*/]+)/g, '**/*$1').replace(/(?!^!)([^*/]+)\*\*/g, '$1*/**')
310
+
401
311
  /** No glob syntax: one path, which git may ignore. */
402
312
  const isLiteral = (glob: string): boolean => !/[*?[\]{}()!]/.test(glob)
403
313
 
@@ -424,45 +334,6 @@ function keyIgnored(
424
334
  inputs.workspaceRuntime = [...probes, ignoredFilesProbe(hits.map(([rel]) => rel))]
425
335
  }
426
336
 
427
- /**
428
- * A directory's Turbo config: `turbo.json`, else `turbo.jsonc` (Turbo 2.5+
429
- * reads either). Only `turbo.json` was looked for, so a `turbo.jsonc` root
430
- * failed the run with ENOENT and a package's `turbo.jsonc` overlay was
431
- * skipped in silence — its `inputs` then keyed nothing (item 938).
432
- */
433
- export async function turboConfigFile(dir: string): Promise<string | null> {
434
- for (const name of ['turbo.json', 'turbo.jsonc']) {
435
- const file = path.join(dir, name)
436
- if (await Bun.file(file).exists()) return file
437
- }
438
- return null
439
- }
440
-
441
- /**
442
- * The workspace root as a project to name when turbo.json declares `//#`
443
- * tasks: its package name and directory. Null with no such task, a
444
- * nameless root, or a file the mapping refuses (it names the file).
445
- */
446
- export async function rootTaskProject(root: string): Promise<{ name: string; dir: string } | null> {
447
- const file = await turboConfigFile(root)
448
- if (file === null) return null
449
- let turbo: { tasks?: unknown; pipeline?: unknown } | null
450
- let pkg: { name?: unknown } | null
451
- try {
452
- turbo = Bun.JSONC.parse(await Bun.file(file).text()) as typeof turbo
453
- pkg = (await Bun.file(path.join(root, 'package.json')).json()) as typeof pkg
454
- } catch {
455
- return null
456
- }
457
- const tasks = turbo?.tasks ?? turbo?.pipeline
458
- const hasRootTask =
459
- typeof tasks === 'object' &&
460
- tasks !== null &&
461
- Object.keys(tasks).some((k) => k.startsWith(`${ROOT}#`))
462
- if (!hasRootTask || typeof pkg?.name !== 'string' || pkg.name === '') return null
463
- return { name: pkg.name, dir: root }
464
- }
465
-
466
337
  async function readTurboJson(file: string, root: string): Promise<TurboJson> {
467
338
  const text = await Bun.file(file).text()
468
339
  let parsed: unknown
@@ -606,7 +477,6 @@ function badCommand(def: TurboTask): boolean {
606
477
 
607
478
  /** Declared task names for a package: plain root keys, `pkg#name` keys
608
479
  * for this package, and per-package turbo.json keys — in that order. */
609
- const ROOT = '//'
610
480
 
611
481
  /**
612
482
  * A package's turbo.json files, root first and its own last, as Turbo's
@@ -892,36 +762,6 @@ async function rootPackageJson(root: string): Promise<PackageMeta['packageJson']
892
762
  return (pkg !== null && typeof pkg === 'object' ? pkg : {}) as PackageMeta['packageJson']
893
763
  }
894
764
 
895
- /**
896
- * The microfrontends configs Turbo hashes into every task: each package's
897
- * (the root's too) `microfrontends.json`, else `microfrontends.jsonc`, or
898
- * the one name `VC_MICROFRONTENDS_CONFIG_FILE_NAME` gives, unless it is a
899
- * child's `{ "partOf": … }` (Turbo's `update_turbo_json` appends them to
900
- * the root's global deps). Unread, an edit to `web`'s routes replayed every
901
- * sibling app's build (with-microfrontends, Turbo 2.11's dry run moves all
902
- * 18 tasks). Paths are root-relative; `text` is what the mapping read.
903
- */
904
- export function microfrontendsConfigs(
905
- root: string,
906
- dirs: readonly string[],
907
- custom = process.env['VC_MICROFRONTENDS_CONFIG_FILE_NAME'] ?? '',
908
- ): { rel: string; text: string }[] {
909
- const names = custom === '' ? ['microfrontends.json', 'microfrontends.jsonc'] : [custom]
910
- const out: { rel: string; text: string }[] = []
911
- for (const dir of new Set(dirs.map((d) => path.resolve(d)))) {
912
- const file = names.map((n) => path.join(dir, n)).find((f) => existsSync(f))
913
- if (file === undefined) continue
914
- const text = readFileSync(file, 'utf8')
915
- let child = false
916
- try {
917
- const parsed = Bun.JSONC.parse(text) as { partOf?: unknown } | null
918
- child = typeof parsed?.partOf === 'string'
919
- } catch {}
920
- if (!child) out.push({ rel: relPosix(root, file), text })
921
- }
922
- return out.sort((a, b) => (a.rel < b.rel ? -1 : 1))
923
- }
924
-
925
765
  export async function mapTurboWorkspace(
926
766
  root: string,
927
767
  metas: readonly PackageMeta[],
@@ -937,6 +777,7 @@ export async function mapTurboWorkspace(
937
777
  }
938
778
  const rootCfg = withGlobal(await readTurboJson(rootFile, root))
939
779
  const pnp = yarnPnp(root)
780
+ const hookMemo = new Map<string, Owner>()
940
781
  const rootTasks = tasksOf(rootCfg)
941
782
 
942
783
  // Turbo 1 lists an env var as `$NAME` among `globalDependencies` (and a
@@ -989,7 +830,7 @@ export async function mapTurboWorkspace(
989
830
  // 1032).
990
831
  const rootMeta = metas.find((m) => path.resolve(m.dir) === path.resolve(root))
991
832
  const globalFiles = [
992
- ...globalDeps.filter((d) => envDependency(d) === null),
833
+ ...globalDeps.filter((d) => envDependency(d) === null).map(turboGlobstar),
993
834
  ...(rootCfg.globalDotEnv ?? []),
994
835
  // Turbo 2 added the root's dependencies to its global hash; 1.13.4's
995
836
  // leaves them out (trigger.dev: a Prisma migration re-keyed only the
@@ -1177,7 +1018,7 @@ export async function mapTurboWorkspace(
1177
1018
  // over its files, so `test` keys on its dependencies' sources. Dropped,
1178
1019
  // vx keyed `test` on its own files alone: a dependency's edit was a hit.
1179
1020
  // Each package has it as a key-only task, a keyed group, as nx()'s
1180
- // `nx-input:*` twins are.
1021
+ // `inputs:*` twins are.
1181
1022
  const withScript = new Set<string>()
1182
1023
  for (const set of runnable.values()) for (const name of set) withScript.add(name)
1183
1024
  const sameRefs = new Set<string>()
@@ -1334,7 +1175,10 @@ export async function mapTurboWorkspace(
1334
1175
  }
1335
1176
 
1336
1177
  const projects: TurboMappedProject[] = []
1337
- const literals = { tasks: [] as Literals[], globalIgnored }
1178
+ // Turbo 2.11's `global.inputs` joins each task's own inputs, where its
1179
+ // `!` entries reach them; `globalDependencies` is hashed apart.
1180
+ const globalPrepended = new Set(rootCfg.global?.inputs ?? [])
1181
+ const literals = { tasks: [] as Literals[], globalIgnored, globalPrepended }
1338
1182
  for (const meta of metas) {
1339
1183
  const scripts = packageScripts(meta)
1340
1184
  const own = runnable.get(meta.name)!
@@ -1446,7 +1290,16 @@ export async function mapTurboWorkspace(
1446
1290
  }
1447
1291
  continue
1448
1292
  }
1449
- const command = override ?? scriptCommand(name, script as string, scripts, pnp)
1293
+ // Turbo runs `<pm> run <name>`: the hooks fold only where the manager runs them.
1294
+ const command =
1295
+ override ??
1296
+ scriptCommand(
1297
+ name,
1298
+ script as string,
1299
+ scripts,
1300
+ pnp,
1301
+ runsScriptHooks(meta.dir, hookMemo) !== null,
1302
+ )
1450
1303
  const mapped = buildTask(
1451
1304
  literals,
1452
1305
  name,
@@ -1480,6 +1333,35 @@ export async function mapTurboWorkspace(
1480
1333
  // not, and a test cached on one platform hit on another. The reader
1481
1334
  // keys those files by a probe at the workspace root.
1482
1335
  const metaNamed = new Map(metas.map((m) => [m.name, m]))
1336
+
1337
+ // An entry group's `//#task` edge is the root's, and the root has no
1338
+ // group of that name (it runs only `//#` tasks): left to `entryEdges`,
1339
+ // `deploy → //#format` ran no format, where `turbo run deploy` runs it.
1340
+ // The root gets the group, edges to its own tasks only, unless it
1341
+ // declares the name itself.
1342
+ const rootProject =
1343
+ rootMeta === undefined || singlePackage
1344
+ ? undefined
1345
+ : projects.find((p) => p.name === rootMeta.name)
1346
+ if (rootProject !== undefined) {
1347
+ const rootRuns = emitted.get(rootProject.name)!
1348
+ const groups = new Map<string, string[]>()
1349
+ for (const id of entry) {
1350
+ const at = id.indexOf('#')
1351
+ const pkg = metaNamed.get(id.slice(0, at))
1352
+ const name = id.slice(at + 1)
1353
+ if (pkg === undefined || pkg === rootMeta || rootRuns.has(name)) continue
1354
+ for (const d of definitions(pkg).defFor(name)?.dependsOn ?? []) {
1355
+ const task = d.slice(ROOT.length + 1)
1356
+ if (!d.startsWith(`${ROOT}#`) || !rootRuns.has(task)) continue
1357
+ const edges = groups.get(name) ?? []
1358
+ if (!edges.includes(task)) edges.push(task)
1359
+ groups.set(name, edges)
1360
+ }
1361
+ }
1362
+ for (const [name, dependsOn] of groups)
1363
+ rootProject.tasks.push({ name, todos: [], task: { dependsOn }, uses: new Set() })
1364
+ }
1483
1365
  for (const meta of metas) {
1484
1366
  const { defFor } = definitions(meta)
1485
1367
  for (const t of projects.find((p) => p.name === meta.name)?.tasks ?? []) {
@@ -1563,6 +1445,7 @@ export async function mapTurboWorkspace(
1563
1445
  excludeWorkspaceOutputs(root, projects, (e) => opaque.get(JSON.stringify(e)) ?? [])
1564
1446
  pruneUnreachedPersistentNotes(projects, metas, opts.persistentTodo)
1565
1447
  pruneOrphanPersistentNotes(projects, opts.persistentTodo)
1448
+ refuseTaskNames(projects, 'task')
1566
1449
  return { projects, notes, globals }
1567
1450
  }
1568
1451
 
@@ -1789,7 +1672,11 @@ function keyOnlyGroup(t: TurboMappedTask): TurboMappedTask {
1789
1672
  }
1790
1673
 
1791
1674
  function buildTask(
1792
- literals: { readonly tasks: Literals[]; readonly globalIgnored: readonly string[] },
1675
+ literals: {
1676
+ readonly tasks: Literals[]
1677
+ readonly globalIgnored: readonly string[]
1678
+ readonly globalPrepended: ReadonlySet<string>
1679
+ },
1793
1680
  name: string,
1794
1681
  def: TurboTask,
1795
1682
  command: string,
@@ -2005,9 +1892,9 @@ function buildTask(
2005
1892
  let pkgDotenvDeep = false
2006
1893
  // The root-relative `.env` globs, probed at the workspace root.
2007
1894
  const wsDotenv = [...rootDotenv]
2008
- // globalDependencies are workspace-root-relative by definition —
2009
- // they map to inputs.workspaceFiles, not project-relative files.
2010
- const wsFiles: unknown[] = [...global('inputs')]
1895
+ // The task's own root-relative inputs; globalDependencies, root-relative
1896
+ // by definition, join them in inputs.workspaceFiles.
1897
+ const wsFiles: string[] = []
2011
1898
  // Root-relative path → the entry naming it.
2012
1899
  const rels = new Map<string, Named>(literals.globalIgnored.map((f) => [f, [null, f]]))
2013
1900
  const atRoot = pkgDir === '' || pkgDir === '.'
@@ -2017,7 +1904,7 @@ function buildTask(
2017
1904
  // in the package and an edit replayed a stale build (item 936).
2018
1905
  files.push('**/*')
2019
1906
  } else {
2020
- for (const i of def.inputs) {
1907
+ for (const i of def.inputs.map(turboGlobstar)) {
2021
1908
  if (i === '$TURBO_DEFAULT$') {
2022
1909
  files.push('**/*')
2023
1910
  continue
@@ -2086,9 +1973,27 @@ function buildTask(
2086
1973
  // project's own globs at every member (D-39), so as `files` a root
2087
1974
  // `oxlint` over the repo replayed green after a member file broke.
2088
1975
  // A root task's globs are the workspace's, outputs too.
2089
- if (atRoot) wsFiles.unshift(...files.splice(0))
1976
+ const head = atRoot ? files.splice(0) : []
1977
+ const joined = (g: string) => literals.globalPrepended.has(g)
1978
+ const [apartKept, ownKept] = apartTakeBacks<unknown>(
1979
+ globals.inputs.filter((g) => !joined(g)),
1980
+ [...globals.inputs.filter(joined), ...head, ...wsFiles],
1981
+ )
1982
+ const kept = new Set([...apartKept, ...ownKept])
1983
+ const globalKept = globals.inputs.filter((g) => kept.has(g))
1984
+ // A global exclusion dropped for this task cannot leave an opaque
1985
+ // preset spread: the task lists the global values itself.
1986
+ const inline = globalKept.length !== globals.inputs.length
1987
+ const allWs = uniq(
1988
+ [
1989
+ ...head.filter((e) => typeof e !== 'string' || kept.has(e)),
1990
+ ...(inline ? globalKept : global('inputs')),
1991
+ ...wsFiles.filter((e) => kept.has(e)),
1992
+ ],
1993
+ inline ? [] : hidden('inputs'),
1994
+ )
2090
1995
  const inputs: Record<string, unknown> = { files: withoutTakenBack(files) }
2091
- if (wsFiles.length > 0) inputs.workspaceFiles = withoutTakenBack(uniq(wsFiles, hidden('inputs')))
1996
+ if (allWs.length > 0) inputs.workspaceFiles = withoutTakenBack(allWs)
2092
1997
  if (keyEnv.length > 0) inputs.env = keyEnv
2093
1998
  if (pkgDotenv) inputs.runtime = [pkgDotenvDeep ? DOTENV_PROBE : DOTENV_PROBE_TOP]
2094
1999
  if (wsDotenv.length > 0) inputs.workspaceRuntime = [dotenvGlobsProbe(wsDotenv) ?? DOTENV_PROBE]
@@ -2105,7 +2010,7 @@ function buildTask(
2105
2010
  for (const raw of def.outputs ?? []) {
2106
2011
  // Turbo's grammar: an extglob in `dist/**/*.@(js|mjs)` matched
2107
2012
  // nothing, so a hit restored nothing (item 1031).
2108
- const mapped = minimatchToVx(raw, raw.startsWith('!'))
2013
+ const mapped = minimatchToVx(turboGlobstar(raw), raw.startsWith('!'))
2109
2014
  if (mapped === null) {
2110
2015
  todos.push(
2111
2016
  `output ${JSON.stringify(raw)}: glob syntax vx cannot take — task runs uncached; ` +
@@ -2139,7 +2044,7 @@ function buildTask(
2139
2044
  }
2140
2045
 
2141
2046
  // With git's file list known, a wildcard-first output stays cached as
2142
- // nx() keeps it (owner, 2026-10-03; turbo() 2026-10-14): core never
2047
+ // nx() keeps it (2026-10-03; turbo() 2026-10-14): core never
2143
2048
  // cleans a committed file. Turbo never cleans, so the untracked files
2144
2049
  // it reaches are named. An X added for `X/**` is judged with it.
2145
2050
  const wild = wildcardOutput(
@@ -2160,7 +2065,7 @@ function buildTask(
2160
2065
  if (!covers(outFiles, rel)) outFiles.push(literalGlob(rel))
2161
2066
  // An output over the project's own package.json (trpc's builds list
2162
2067
  // it: tsdown rewrites `exports`) is taken back and keeps the cache, as
2163
- // nx() does (owner, 2026-10-03). The build rewrites an input the key
2068
+ // nx() does (2026-10-03). The build rewrites an input the key
2164
2069
  // folds, so its identical rewrite counts as unchanged ('mutable').
2165
2070
  const takeBacks = ownFileTakeBacks(outFiles, opts.ownConfig?.(pkgDir))
2166
2071
  outFiles.push(...takeBacks)