@vzn/vx-migrate 0.0.646 → 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.
@@ -20,9 +20,10 @@
20
20
  // the shims `yarn run` writes, so inlined, `node -e "require('left-pad')"`
21
21
  // was MODULE_NOT_FOUND (2026-09-28). Every script runs as `yarn run <name>`.
22
22
 
23
- import { readFileSync } from 'node:fs'
23
+ import { existsSync, readFileSync } from 'node:fs'
24
24
  import path from 'node:path'
25
25
  import { foldScriptHooks } from '@vzn/vx'
26
+ import { shellQuote } from './nx-command.js'
26
27
 
27
28
  const YARN_RUN_BUILTIN = /(^|&&|\|\||;|\(|\|)\s*run\s/
28
29
  const LIFECYCLE = /^(pre|post)(install|publish|pack|version)$|^(prepare|prepublishOnly|install)$/
@@ -36,15 +37,22 @@ export function scriptCommand(
36
37
  body: string,
37
38
  scripts: Readonly<Record<string, unknown>> = {},
38
39
  pnp = false,
40
+ /** `runsScriptHooks`: false where the manager runs no `pre` / `post` hook. */
41
+ hooks = true,
39
42
  ): string {
40
- if (pnp) return `yarn run ${name}`
43
+ if (pnp) return yarnRun(name)
41
44
  const hook = (h: string): string | undefined =>
42
- !LIFECYCLE.test(h) && usable(scripts[h]) ? scripts[h] : undefined
45
+ hooks && !LIFECYCLE.test(h) && usable(scripts[h]) ? scripts[h] : undefined
43
46
  const parts = [hook(`pre${name}`), body, hook(`post${name}`)]
44
- if (parts.some((p) => p !== undefined && YARN_RUN_BUILTIN.test(p))) return `yarn run ${name}`
47
+ if (parts.some((p) => p !== undefined && YARN_RUN_BUILTIN.test(p))) return yarnRun(name)
45
48
  return foldScriptHooks(parts[0], body, parts[2])
46
49
  }
47
50
 
51
+ /** `yarn run <name>`, the name one shell word: `a b` ran script `a` with an argument. */
52
+ export function yarnRun(name: string): string {
53
+ return `yarn run ${shellQuote(name)}`
54
+ }
55
+
48
56
  /**
49
57
  * Yarn ≥ 2 links Plug'n'Play unless `.yarnrc.yml` names another `nodeLinker`.
50
58
  * Read once per mapping; synchronous so every mapper, the synchronous ones
@@ -67,3 +75,101 @@ export async function yarnrcText(root: string): Promise<string> {
67
75
  .text()
68
76
  .catch(() => '\0absent')
69
77
  }
78
+
79
+ /**
80
+ * The package manager that owns `dir`, when it runs `pre` / `post` hooks
81
+ * around a script; null when it runs none. The manager is the nearest `packageManager` field or
82
+ * lockfile, and its settings are read beside it, as each reads them for a
83
+ * member too. Probed with a `prebuild` / `build` / `postbuild` trio: Yarn
84
+ * 2+ runs none (D-31); npm runs none under `ignore-scripts=true` in
85
+ * `.npmrc`; pnpm runs none under `enable-pre-post-scripts=false` there or
86
+ * `enablePrePostScripts: false` in `pnpm-workspace.yaml` (D-33); Bun and
87
+ * Yarn 1 run them whatever those say. No manager found is npm's default.
88
+ */
89
+ export function runsScriptHooks(dir: string, memo: Map<string, Owner>): string | null {
90
+ const { manager, at } = ownerOf(dir, memo)
91
+ const read = (f: string): string | undefined => {
92
+ try {
93
+ return readFileSync(path.join(at, f), 'utf8')
94
+ } catch {
95
+ return undefined
96
+ }
97
+ }
98
+ const npmrc = read('.npmrc') ?? ''
99
+ const set = (key: string, value: string): boolean =>
100
+ new RegExp(`^\\s*${key}\\s*=\\s*${value}\\s*$`, 'm').test(npmrc)
101
+ const runs =
102
+ manager === 'berry'
103
+ ? false
104
+ : manager === 'npm'
105
+ ? !set('ignore-scripts', 'true')
106
+ : manager === 'pnpm'
107
+ ? !set('enable-pre-post-scripts', 'false') && !pnpmSkipsHooks(read('pnpm-workspace.yaml'))
108
+ : true
109
+ return runs ? manager : null
110
+ }
111
+
112
+ /**
113
+ * `enablePrePostScripts: false` in `pnpm-workspace.yaml`, read as pnpm reads
114
+ * it, as YAML: a line regex missed `false # hooks off` and a flow mapping,
115
+ * which pnpm 10 honours, and folded hooks pnpm does not run.
116
+ */
117
+ function pnpmSkipsHooks(yaml: string | undefined): boolean {
118
+ if (yaml === undefined) return false
119
+ try {
120
+ const parsed = Bun.YAML.parse(yaml) as { enablePrePostScripts?: unknown } | null
121
+ return parsed?.enablePrePostScripts === false
122
+ } catch {
123
+ return false
124
+ }
125
+ }
126
+
127
+ /** The package manager that owns a directory, and the directory that says so. */
128
+ export interface Owner {
129
+ manager: string
130
+ at: string
131
+ }
132
+
133
+ /**
134
+ * The nearest `packageManager` field or lockfile from `dir` up: `berry` for
135
+ * Yarn 2+, `yarn` for Yarn 1, else the manager's name; none found is npm.
136
+ */
137
+ export function ownerOf(dir: string, memo: Map<string, Owner>): Owner {
138
+ const known = memo.get(dir)
139
+ if (known !== undefined) return known
140
+ const read = (f: string): string | undefined => {
141
+ try {
142
+ return readFileSync(path.join(dir, f), 'utf8')
143
+ } catch {
144
+ return undefined
145
+ }
146
+ }
147
+ let manager: string | undefined
148
+ try {
149
+ // A byte-order mark is stripped, as npm, pnpm and discovery strip it:
150
+ // JSON.parse threw on one and the field went unread.
151
+ const manifest = (read('package.json') ?? '').replace(/^\uFEFF/, '')
152
+ const pm = (JSON.parse(manifest) as { packageManager?: unknown }).packageManager
153
+ // A manager vx knows nothing of (zod's `nub@0.8.3`) says nothing about
154
+ // hooks: the lockfile beside it does, and "nub ran `postbuild`" was a
155
+ // claim nothing had checked (D-96).
156
+ if (typeof pm === 'string' && /^(npm|pnpm|yarn|bun)@/.test(pm))
157
+ manager = /^yarn@([2-9]|\d{2,})/.test(pm) ? 'berry' : pm.split('@')[0]
158
+ } catch {}
159
+ if (manager === undefined) {
160
+ const yarnLock = read('yarn.lock')
161
+ if (yarnLock !== undefined) manager = yarnLock.includes('\n__metadata:') ? 'berry' : 'yarn'
162
+ else if (existsSync(path.join(dir, 'pnpm-lock.yaml'))) manager = 'pnpm'
163
+ else if (existsSync(path.join(dir, 'package-lock.json'))) manager = 'npm'
164
+ else if (['bun.lock', 'bun.lockb'].some((f) => existsSync(path.join(dir, f)))) manager = 'bun'
165
+ }
166
+ const up = path.dirname(dir)
167
+ const owner =
168
+ manager !== undefined
169
+ ? { manager, at: dir }
170
+ : up === dir
171
+ ? { manager: 'npm', at: dir }
172
+ : ownerOf(up, memo)
173
+ memo.set(dir, owner)
174
+ return owner
175
+ }
@@ -23,6 +23,7 @@
23
23
  // file exists to prevent (item 445).
24
24
 
25
25
  import { isLiteralPattern, outputsOverlap, type GeneratedTask } from '@vzn/vx'
26
+ import { withoutTakenBack } from './glob-grammar.js'
26
27
  import { relPosix } from './paths.js'
27
28
  import type { TrackedKinds } from './tracked-outputs.js'
28
29
 
@@ -612,7 +613,12 @@ export function excludeWorkspaceOutputs(
612
613
  }
613
614
  }
614
615
 
615
- /** A `!` output with no positive beside it takes back nothing, and core refuses a list of them alone. */
616
+ /**
617
+ * A `!` output with no positive beside it takes back nothing, and core
618
+ * refuses a list of them alone; a literal one takes back was never saved,
619
+ * and core refuses it too (trpc's `package.json`, taken back).
620
+ */
616
621
  export function takingBack(globs: readonly string[]): string[] {
617
- return globs.some((g) => !g.startsWith('!')) ? [...globs] : []
622
+ const kept = withoutTakenBack(globs)
623
+ return kept.some((g) => !g.startsWith('!')) ? kept : []
618
624
  }
@@ -222,7 +222,7 @@ interface Outputs {
222
222
  /**
223
223
  * Fit a mapped task's outputs to the committed files they cover, in place,
224
224
  * and return one `[pkg#task, todo]` per task this leaves uncached. Core
225
- * never cleans a committed file (owner, 2026-10-14), so
225
+ * never cleans a committed file (2026-10-14), so
226
226
  * the outputs keep them; what is left is the readers' `!` that hides one.
227
227
  * `tracked` is root-relative.
228
228
  */
@@ -14,7 +14,7 @@ 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'
@@ -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,17 +195,19 @@ 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
201
  const dirs = [root, ...projects.map((m) => m.dir)]
196
- const [metas, configs, yarnrc, rootManifest] = await Promise.all([
202
+ const [metas, configs, yarnrc, rootManifest, hookConfigs] = await Promise.all([
197
203
  withManifests(projects),
198
204
  Promise.all(
199
205
  dirs.flatMap((d) => ['turbo.json', 'turbo.jsonc'].map((f) => textOf(path.join(d, f)))),
200
206
  ),
201
207
  textOf(path.join(root, '.yarnrc.yml')),
202
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)))),
203
211
  ])
204
212
  const envNames = envNamesThatMap(configs, envNamesNow())
205
213
  // The tsconfigs a `tsc` script reads are the mapping's inputs too.
@@ -218,7 +226,9 @@ async function run(root: string, projects: readonly ProjectMeta[]): Promise<Adop
218
226
  process.env['TURBO_ENV_MODE'] ?? '',
219
227
  ...configs,
220
228
  yarnrc,
221
- // Its dependencies are global inputs, a member or not.
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.
222
232
  rootManifest,
223
233
  JSON.stringify(microfrontendsConfigs(root, dirs)),
224
234
  // A config file added beside mapped tasks changes what an output may cover.
@@ -13,9 +13,10 @@
13
13
  import path from 'node:path'
14
14
  import { isLiteralPattern, pruneOrphanPersistentNotes, UserError } from '@vzn/vx'
15
15
  import type { PackageMeta } from '../packages.js'
16
- 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'
17
18
  import { shellQuote } from '../nx-command.js'
18
- import { scriptCommand, yarnPnp } from '../script-command.js'
19
+ import { type Owner, runsScriptHooks, scriptCommand, yarnPnp } from '../script-command.js'
19
20
  import {
20
21
  ownFileTakeBacks,
21
22
  excludeSiblingOutputs,
@@ -299,6 +300,14 @@ function covers(globs: readonly string[], rel: string): boolean {
299
300
  )
300
301
  }
301
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
+
302
311
  /** No glob syntax: one path, which git may ignore. */
303
312
  const isLiteral = (glob: string): boolean => !/[*?[\]{}()!]/.test(glob)
304
313
 
@@ -768,6 +777,7 @@ export async function mapTurboWorkspace(
768
777
  }
769
778
  const rootCfg = withGlobal(await readTurboJson(rootFile, root))
770
779
  const pnp = yarnPnp(root)
780
+ const hookMemo = new Map<string, Owner>()
771
781
  const rootTasks = tasksOf(rootCfg)
772
782
 
773
783
  // Turbo 1 lists an env var as `$NAME` among `globalDependencies` (and a
@@ -820,7 +830,7 @@ export async function mapTurboWorkspace(
820
830
  // 1032).
821
831
  const rootMeta = metas.find((m) => path.resolve(m.dir) === path.resolve(root))
822
832
  const globalFiles = [
823
- ...globalDeps.filter((d) => envDependency(d) === null),
833
+ ...globalDeps.filter((d) => envDependency(d) === null).map(turboGlobstar),
824
834
  ...(rootCfg.globalDotEnv ?? []),
825
835
  // Turbo 2 added the root's dependencies to its global hash; 1.13.4's
826
836
  // leaves them out (trigger.dev: a Prisma migration re-keyed only the
@@ -1165,7 +1175,10 @@ export async function mapTurboWorkspace(
1165
1175
  }
1166
1176
 
1167
1177
  const projects: TurboMappedProject[] = []
1168
- 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 }
1169
1182
  for (const meta of metas) {
1170
1183
  const scripts = packageScripts(meta)
1171
1184
  const own = runnable.get(meta.name)!
@@ -1277,7 +1290,16 @@ export async function mapTurboWorkspace(
1277
1290
  }
1278
1291
  continue
1279
1292
  }
1280
- 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
+ )
1281
1303
  const mapped = buildTask(
1282
1304
  literals,
1283
1305
  name,
@@ -1311,6 +1333,35 @@ export async function mapTurboWorkspace(
1311
1333
  // not, and a test cached on one platform hit on another. The reader
1312
1334
  // keys those files by a probe at the workspace root.
1313
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
+ }
1314
1365
  for (const meta of metas) {
1315
1366
  const { defFor } = definitions(meta)
1316
1367
  for (const t of projects.find((p) => p.name === meta.name)?.tasks ?? []) {
@@ -1394,6 +1445,7 @@ export async function mapTurboWorkspace(
1394
1445
  excludeWorkspaceOutputs(root, projects, (e) => opaque.get(JSON.stringify(e)) ?? [])
1395
1446
  pruneUnreachedPersistentNotes(projects, metas, opts.persistentTodo)
1396
1447
  pruneOrphanPersistentNotes(projects, opts.persistentTodo)
1448
+ refuseTaskNames(projects, 'task')
1397
1449
  return { projects, notes, globals }
1398
1450
  }
1399
1451
 
@@ -1620,7 +1672,11 @@ function keyOnlyGroup(t: TurboMappedTask): TurboMappedTask {
1620
1672
  }
1621
1673
 
1622
1674
  function buildTask(
1623
- 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
+ },
1624
1680
  name: string,
1625
1681
  def: TurboTask,
1626
1682
  command: string,
@@ -1836,9 +1892,9 @@ function buildTask(
1836
1892
  let pkgDotenvDeep = false
1837
1893
  // The root-relative `.env` globs, probed at the workspace root.
1838
1894
  const wsDotenv = [...rootDotenv]
1839
- // globalDependencies are workspace-root-relative by definition —
1840
- // they map to inputs.workspaceFiles, not project-relative files.
1841
- 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[] = []
1842
1898
  // Root-relative path → the entry naming it.
1843
1899
  const rels = new Map<string, Named>(literals.globalIgnored.map((f) => [f, [null, f]]))
1844
1900
  const atRoot = pkgDir === '' || pkgDir === '.'
@@ -1848,7 +1904,7 @@ function buildTask(
1848
1904
  // in the package and an edit replayed a stale build (item 936).
1849
1905
  files.push('**/*')
1850
1906
  } else {
1851
- for (const i of def.inputs) {
1907
+ for (const i of def.inputs.map(turboGlobstar)) {
1852
1908
  if (i === '$TURBO_DEFAULT$') {
1853
1909
  files.push('**/*')
1854
1910
  continue
@@ -1917,9 +1973,27 @@ function buildTask(
1917
1973
  // project's own globs at every member (D-39), so as `files` a root
1918
1974
  // `oxlint` over the repo replayed green after a member file broke.
1919
1975
  // A root task's globs are the workspace's, outputs too.
1920
- 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
+ )
1921
1995
  const inputs: Record<string, unknown> = { files: withoutTakenBack(files) }
1922
- if (wsFiles.length > 0) inputs.workspaceFiles = withoutTakenBack(uniq(wsFiles, hidden('inputs')))
1996
+ if (allWs.length > 0) inputs.workspaceFiles = withoutTakenBack(allWs)
1923
1997
  if (keyEnv.length > 0) inputs.env = keyEnv
1924
1998
  if (pkgDotenv) inputs.runtime = [pkgDotenvDeep ? DOTENV_PROBE : DOTENV_PROBE_TOP]
1925
1999
  if (wsDotenv.length > 0) inputs.workspaceRuntime = [dotenvGlobsProbe(wsDotenv) ?? DOTENV_PROBE]
@@ -1936,7 +2010,7 @@ function buildTask(
1936
2010
  for (const raw of def.outputs ?? []) {
1937
2011
  // Turbo's grammar: an extglob in `dist/**/*.@(js|mjs)` matched
1938
2012
  // nothing, so a hit restored nothing (item 1031).
1939
- const mapped = minimatchToVx(raw, raw.startsWith('!'))
2013
+ const mapped = minimatchToVx(turboGlobstar(raw), raw.startsWith('!'))
1940
2014
  if (mapped === null) {
1941
2015
  todos.push(
1942
2016
  `output ${JSON.stringify(raw)}: glob syntax vx cannot take — task runs uncached; ` +
@@ -1970,7 +2044,7 @@ function buildTask(
1970
2044
  }
1971
2045
 
1972
2046
  // With git's file list known, a wildcard-first output stays cached as
1973
- // 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
1974
2048
  // cleans a committed file. Turbo never cleans, so the untracked files
1975
2049
  // it reaches are named. An X added for `X/**` is judged with it.
1976
2050
  const wild = wildcardOutput(
@@ -1991,7 +2065,7 @@ function buildTask(
1991
2065
  if (!covers(outFiles, rel)) outFiles.push(literalGlob(rel))
1992
2066
  // An output over the project's own package.json (trpc's builds list
1993
2067
  // it: tsdown rewrites `exports`) is taken back and keeps the cache, as
1994
- // 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
1995
2069
  // folds, so its identical rewrite counts as unchanged ('mutable').
1996
2070
  const takeBacks = ownFileTakeBacks(outFiles, opts.ownConfig?.(pkgDir))
1997
2071
  outFiles.push(...takeBacks)