@vzn/vx-migrate 0.0.591 → 0.0.594

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/README.md CHANGED
@@ -58,7 +58,7 @@ Rules:
58
58
  - `dependsOn`: `^x` passes through when some package runs `x`, and is dropped when none does (Turbo gives it no edges; core refuses a `^x` no project declares as a typo); `pkg#task` is kept only when `pkg` emits `task` (else a todo: edge dropped); a same-package task Turbo defines but the package has no script for is Turbo's no-op node, so its own edges pass through in its place (`test → codegen → ^build` with no `codegen` script is `test → ^build`); another package's `^x` or `pkg#x` reaches such a node through a group task when its edges name a task of its own package or another (with-tailwind's `ui` has no `build` script; its `build` builds `build:styles` and `build:components`, and `web#build` waits on them), and so does one whose `^` edge names another task (rallly's script-less `build` holds `^db:generate`), while one whose only edge is `^` to its own name needs none, since core's `^x` walks past a package without `x`; a no-op node nothing else reaches runs nothing, its own edges included, where Turbo still runs them (kitchen-sink's `turbo run test` builds `@repo/ui` for `admin`, which has no `test` script; `vx run test --all` does not, and `vx run build test --all` does both); a name Turbo does not define has no edge; `$TURBO_ROOT$` deps are a todo. Root `//#task`s run in the workspace root, as under Turbo: `turbo()` names the root a project through core's `discover` stage when turbo.json declares one (no `vx.config` needed; a root no member glob lists), and `//#x` in `dependsOn` is an edge to it, named by the root `package.json` `name`; a root task's globs are the workspace's (`workspaceFiles`), since Turbo hashes a root task over the whole repo; with no root project (a root `package.json` with no `name`, or one a package holds) they are a note and such an edge a todo. Turbo runs no plain task in the root package of a monorepo, and neither does `turbo()`; in a single-package repo (no workspaces, Turbo's `non-monorepo` example) turbo.json's plain tasks run on the root package, as under Turbo.
59
59
  - A transit node (Turbo's documented pattern, its with-vitest example: `transit: { dependsOn: ["^transit"] }` with no script anywhere, and `test: { dependsOn: ["transit"] }`) is a key-only task in each package that defines it: it runs `true`, cached and keyed on its inputs, with its `^transit` edge, so a dependant's `test` re-keys on its dependencies' sources as Turbo re-hashes it. A `^` task that some package has a script for, or that nothing depends on, is no transit node. A package without the script of a `^` task others run gets the same key-only task, as Turbo hashes a no-op node there: an edit to it re-keys its dependants. A `build` whose only edge is `^build` is not written: core gives every project with no `build` that node (solid's three script-less packages each got a `build` running `true`).
60
60
  - `with` (tasks Turbo runs alongside, `web#dev` with `api#dev`): an edge to each sidecar that is persistent, so vx starts the task once the sidecar has spawned and runs the sidecar only when the task runs; a sidecar that ends would be waited for, so it is a todo instead, and a pair that names each other keeps one edge (two would be a cycle). A task with no script whose `with` names persistent sidecars (Turbo's with-tailwind example: `ui` has no `dev` script, its `dev` starts `dev:styles` and `dev:components`) is a group task that depends on them.
61
- - `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none (vx folds each dependency's key). Then: absent or `[]` → `**/*` (Turbo's default); `$TURBO_DEFAULT$` → `**/*`; exclusions alone narrow `**/*`; `$TURBO_ROOT$/<path>` → `cache.inputs.workspaceFiles` (negation kept); any other `$TURBO_ROOT$` use is a todo. `globalDependencies` and Turbo 1's `globalDotEnv` land in `workspaceFiles` too, and a task's Turbo 1 `dotEnv` in its `files`. Turbo 1's `$NAME` entries, in `globalDependencies` or a task's `dependsOn`, are env vars: they join `cache.inputs.env` and `exec.env.passThrough`. A package nested in the task's package (cal.com's apps inside `@calcom/app-store`) is hashed by Turbo with its parent, and core's globs stop at a project: each glob that reaches one is also listed in `cache.inputs.workspaceFiles`. Turbo's glob grammar is translated as Nx's is (`*.[jt]s` is `*.{[jt],j,t}s`); an input with no safe form widens to `**/*` with a todo. A `.env`-shaped input (`.env*`, `.env.local`, a task's Turbo 1 `dotEnv`, create-turbo's `globalDependencies: ["**/.env.*local"]`) is gitignored as a rule, so it is keyed by a probe that hashes the `.env` files it can name (`cache.inputs.runtime`) or, for a root entry, the files its root-relative globs name, hidden ones too, as Turbo's `*` matches them (`workspaceRuntime`; `**/<name>` is a `find -name` that skips `node_modules`, and another `**` walks the workspace), rather than as a file glob git never reports. A package whose `.env` globs all sit at its root gets a one-shell probe of that directory; one below the root walks the package.
61
+ - `inputs`: a structured entry (Turbo 2.11) is its `globs`, plus `**/*` with `withDefaults`, for `startup` and `jit` alike; `dependencyOutputs` adds none where the producer is cached (vx folds each dependency's key); from a `cache: false` producer (vercel/vercel's `//#generate:cache-keys`, which records the host) the named files, or its outputs, are a `cache.inputs.workspaceRuntime` probe, and `cache.inputs.tasks` leaves the producer out, so core answers the probe after it ran. Then: absent or `[]` → `**/*` (Turbo's default); `$TURBO_DEFAULT$` → `**/*`; exclusions alone narrow `**/*`; `$TURBO_ROOT$/<path>` → `cache.inputs.workspaceFiles` (negation kept); any other `$TURBO_ROOT$` use is a todo. `globalDependencies` and Turbo 1's `globalDotEnv` land in `workspaceFiles` too, and a task's Turbo 1 `dotEnv` in its `files`. Turbo 1's `$NAME` entries, in `globalDependencies` or a task's `dependsOn`, are env vars: they join `cache.inputs.env` and `exec.env.passThrough`. A package nested in the task's package (cal.com's apps inside `@calcom/app-store`) is hashed by Turbo with its parent, and core's globs stop at a project: each glob that reaches one is also listed in `cache.inputs.workspaceFiles`. Turbo's glob grammar is translated as Nx's is (`*.[jt]s` is `*.{[jt],j,t}s`); an input with no safe form widens to `**/*` with a todo. A `.env`-shaped input (`.env*`, `.env.local`, a task's Turbo 1 `dotEnv`, create-turbo's `globalDependencies: ["**/.env.*local"]`) is gitignored as a rule, so it is keyed by a probe that hashes the `.env` files it can name (`cache.inputs.runtime`) or, for a root entry, the files its root-relative globs name, hidden ones too, as Turbo's `*` matches them (`workspaceRuntime`; `**/<name>` is a `find -name` that skips `node_modules`, and another `**` walks the workspace), rather than as a file glob git never reports. A package whose `.env` globs all sit at its root gets a one-shell probe of that directory; one below the root walks the package.
62
62
  - `outputs`: `$TURBO_ROOT$/<path>` → `cache.outputs.workspaceFiles`; a negated output rides beside its positives and takes its paths back from the clean, the save and the restore (Next's `.next/**` minus `!.next/cache/**` keeps the cache), and one with no positive beside it takes back nothing and is dropped. Past its first segment an output takes Turbo's grammar (`dist/**/*.[cm]js`); the first stays a literal (a route directory). An output whose first segment is a wildcard (`**/*.d.ts`, `*/**`) runs the task uncached with a todo: Turbo never cleans an output, vx cleans it before every run, and such a glob reaches the sources. One segment with a literal extension the package tracks no file of (`*.xml`, `*.tsbuildinfo`) reaches only top-level artifacts and stays cached, and so does `**/<dir>/**` when the package tracks nothing under a directory of that name (vercel/ai's `**/dist/**`; `node_modules` is never an output), and so does a first segment no tracked top-level entry matches (tldraw's `dist-*/**`). A wildcard first segment with a literal rest (clerk's `*/package.json`, the subpath stubs it commits) reaches only files of that name: each committed one, up to 16, is taken back with `!` and the task stays cached; more, and it runs uncached. An output that covers the package's own `package.json` (trpc's client build lists it: the build rewrites `exports`) runs the task uncached with a todo: vx would delete the manifest before every run, and core refuses that config. `nx()` takes the manifest back with `!package.json` instead and keeps the cache (owner, 2026-10-03). So does one that covers the package's vx.config; `turbo()` and `nx()` check the config the package has, and `vx-migrate` the one it writes, so sanity's `*.js` shims stay cached beside none. `nx()` keeps a wildcard-first output cached instead: each committed file it reaches is taken back with `!` (past sixteen, uncached with a todo), and the clean removes the rest, what the task writes (owner, 2026-10-03). A committed file under an output (typescript-eslint's `data/sponsors.json` in a cached `data`) is taken back with `!` — Turbo and Nx never clean an output, vx does — so it survives the clean and the task keeps its cache; past sixteen such files the task runs uncached with a todo. `turbo()` and `nx()` both do this, and so do the configs `vx-migrate` writes from turbo.json or an Nx graph (each take-back is a todo there, as it names files a later commit may move).
63
63
  - `env` / `passThroughEnv`: explicit names go to `cache.inputs.env` (env only) and `exec.env.passThrough` (both, plus both globals); a wildcard expands over the run's environment under `turbo()`, which maps where the tasks run (a new variable name such a wildcard matches maps afresh); the CLI, which writes for another environment, lists the names the task's package and its workspace dependencies spell (a root task: the whole repo; clerk's `E2E_*`), noted once per entry, and one nothing spells is a todo; `*` is the only wildcard, as in Turbo, so `\*`, a leading `\!`, `?` and `[` are literal: unkey's `NEXT_PUBLIC_\*` names one variable, which vx cannot key, so it is dropped and reported once for the workspace with the count of tasks naming it (openstatus's root `build` env `\*` had written one todo into each of 45 configs; `turbo()`: only when the run's environment sets it, as Turbo hashes nothing otherwise); and a `!` entry (openstatus' `!NEXT_PUBLIC_VERCEL_URL`) removes the names it matches from its list, with no todo, since vx matches no name it is not given. A name both a global list and the task's own list carry is listed once.
64
64
  - Framework inference (Turbo hashes and passes Next's `NEXT_PUBLIC_*`, Vite's `VITE_*`, … for a package that depends on the framework): `turbo()` expands the prefix over the run's environment; the CLI writes the names the tracked source and `.env.example` of the package and its workspace dependencies spell (the bare prefix, as a `startsWith` test spells it, is none) (Next bundles theirs: cal.com's web, 23 alone, 58 with them), and a note asks for any only an installed dependency reads.
@@ -105,6 +105,7 @@ Nx's own option handling, rendered as one POSIX `sh` line (`vx show` prints it):
105
105
  - **An argument replaces its option:** an option the arguments after `vx run … --` name (`--region=us`, `--region us`, `--no-region`, `-r` for a one-letter option; no camel-case expansion) is not appended, as Nx drops it; the line decides once it has them. `tests/nx-run-commands-override-live.test.ts` holds it to `nx run`.
106
106
  - **Environment:** `env` is `exec.env.define` (so it is in the key), and `color: true` sets `FORCE_COLOR=true`, as Nx does (nx#20465). `envFile` is loaded after the task's `.env` files (below), a name they already set winning, as in Nx (nx#23581).
107
107
  - **`readyWhen`** makes the task persistent with that string as its `readyWhen`, so dependents start once it is printed; several strings, all of which Nx waits for, run the line under `nx-env --ready-when` (one flag per string), which passes the output through and prints `nx-env: ready` once every string has appeared on stdout or stderr; that line is the task's `readyWhen`.
108
+ - **A leading Nx call** (`nx <target> <project>`, `nx run <project>:<target>`, through `npx` / `pnpm` / `yarn` / `bunx` or not) in a serial line, or as its one command, is a `dependsOn` edge to that target (its default configuration, as Nx runs it) and leaves the line: ngrx's `build` opens with `nx build-package <project>`. One Nx would hand options or arguments to, one after another command, and one in a parallel line stay commands.
108
109
  - **Reported, not reproduced:** per-command `prefix` / `color` decoration, `streamOutput: false`. Decoration in a serial run (`parallel: false`), which Nx refuses, is the failing placeholder with Nx's reason, as `readyWhen` there is. Display-only options (`usePty`, `tty`, `verbose`) change nothing here: vx runs every task without a pseudo-terminal.
109
110
 
110
111
  `tests/nx-exec-live.test.ts` holds each of these shapes to Nx's own run-commands executor on the same options and arguments.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vzn/vx-migrate",
3
- "version": "0.0.591",
3
+ "version": "0.0.594",
4
4
  "license": "MIT",
5
5
  "description": "Adopt @vzn/vx from Turborepo or Nx: run the repo unchanged, migrate it to vx.config.ts, and keep your Turbo or Nx remote cache",
6
6
  "keywords": [
@@ -39,7 +39,7 @@
39
39
  "access": "public"
40
40
  },
41
41
  "peerDependencies": {
42
- "@vzn/vx": "^0.0.591"
42
+ "@vzn/vx": "^0.0.594"
43
43
  },
44
44
  "repository": {
45
45
  "type": "git",
@@ -111,7 +111,8 @@ export function adoptionPlugin(
111
111
  // only reads a stage-given task after validating it. The copy was
112
112
  // the per-process memo's guard and cost 10 ms per run at 1,000
113
113
  // projects, 3,000 clones (item 609).
114
- config.tasks[t.name] ??= t.task as unknown as TaskConfig
114
+ if (!Object.hasOwn(config.tasks, t.name))
115
+ config.tasks[t.name] = t.task as unknown as TaskConfig
115
116
  }
116
117
  },
117
118
  // The root files the mapping reads (`turbo.json`, `nx.json`) shape every
package/src/nx/nx-deps.ts CHANGED
@@ -19,7 +19,7 @@ export type TaskNameFor = (project: string, target: string, configuration: strin
19
19
  * "depends on …#codegen but no such task is declared". Dropped here as
20
20
  * Nx drops it, for this project and a named one alike.
21
21
  */
22
- export type HasTarget = (project: string, target: string) => boolean
22
+ type HasTarget = (project: string, target: string) => boolean
23
23
 
24
24
  /**
25
25
  * The graph nodes a `projects` list names, ported from Nx's
package/src/nx/nx-map.ts CHANGED
@@ -800,6 +800,10 @@ function buildTask(
800
800
  meta.packageJson,
801
801
  opts.manifestField,
802
802
  opts.executors,
803
+ (p, t) =>
804
+ !(p === nodeName && t === targetName) &&
805
+ metaByNode.has(p) &&
806
+ Object.hasOwn(nodeMap[p]?.data?.targets ?? {}, t),
803
807
  )
804
808
 
805
809
  const inputs = emptyNxInputs()
@@ -845,6 +849,12 @@ function buildTask(
845
849
  : { node: nodeName, configuration: variant.configuration! },
846
850
  options,
847
851
  )
852
+ // A nested `nx <target>` runs the target's default configuration, whatever this one runs.
853
+ for (const c of mapped?.nxCalls ?? []) {
854
+ const edge =
855
+ c.project === nodeName ? c.target : `${metaByNode.get(c.project)!.name}#${c.target}`
856
+ if (!deps.includes(edge)) deps.push(edge)
857
+ }
848
858
 
849
859
  // Nx's rule, not a guess: a target is cached when it says `cache: true`
850
860
  // (Nx ≥ 17 writes it into the graph from `cacheableOperations` too —
@@ -1003,6 +1013,8 @@ interface MappedCommand {
1003
1013
  readonly envInputs: readonly string[]
1004
1014
  /** A translated executor's `exec.timeout`. */
1005
1015
  readonly timeout?: number
1016
+ /** The Nx targets a leading `nx <target> <project>` ran: dependency edges (nx-command.ts). */
1017
+ readonly nxCalls?: readonly { readonly project: string; readonly target: string }[]
1006
1018
  }
1007
1019
 
1008
1020
  /**
@@ -1025,6 +1037,7 @@ function mapCommand(
1025
1037
  manifest: { readonly name?: unknown; readonly version?: unknown },
1026
1038
  manifestField: MapNxOptions['manifestField'],
1027
1039
  executors: NxExecutors | undefined,
1040
+ isTarget: (project: string, target: string) => boolean,
1028
1041
  ): MappedCommand | null {
1029
1042
  const executor = target.executor
1030
1043
  if (executor === 'nx:noop') {
@@ -1063,7 +1076,7 @@ function mapCommand(
1063
1076
  if (executor === 'nx:run-commands' || plain) {
1064
1077
  const rc = mapRunCommands(
1065
1078
  plain ? { ...options, command: target.command } : options,
1066
- { projectRel, projectName },
1079
+ { projectRel, projectName, isTarget },
1067
1080
  todos,
1068
1081
  )
1069
1082
  if (rc === null) return line(PLACEHOLDER)
@@ -1075,7 +1088,10 @@ function mapCommand(
1075
1088
  : path.posix.isAbsolute(rc.envFile)
1076
1089
  ? rc.envFile
1077
1090
  : relPosix(projectRel, path.posix.normalize(rc.envFile))
1078
- return shell(rc.command, envFile, { env: rc.env, readyWhen: rc.readyWhen }, rc.readyAll)
1091
+ return {
1092
+ ...shell(rc.command, envFile, { env: rc.env, readyWhen: rc.readyWhen }, rc.readyAll),
1093
+ ...(rc.nxCalls === undefined ? {} : { nxCalls: rc.nxCalls }),
1094
+ }
1079
1095
  }
1080
1096
  if (executor === 'nx:run-script') {
1081
1097
  const script = typeof options.script === 'string' ? options.script : targetName
package/src/nx-command.ts CHANGED
@@ -40,13 +40,15 @@ interface NxCommandContext {
40
40
  readonly projectRel: string
41
41
  /** The Nx project name, what `{projectName}` expands to. */
42
42
  readonly projectName: string
43
+ /** Whether a vx task runs Nx project `project`'s `target`: what a leading `nx <target> <project>` may become. */
44
+ readonly isTarget?: (project: string, target: string) => boolean
43
45
  }
44
46
 
45
47
  /** The line `nx-env --ready-when` prints once every string is seen (`READY` in nx-env.cjs). */
46
48
  const NX_ENV_READY = '^nx-env: ready$'
47
49
 
48
50
  /** A run-commands target as vx runs it. */
49
- export interface RunCommandsLine {
51
+ interface RunCommandsLine {
50
52
  readonly command: string
51
53
  /** `exec.env.define`: the target's `env`, and `FORCE_COLOR` under `color`. */
52
54
  readonly env: Readonly<Record<string, string>>
@@ -60,8 +62,18 @@ export interface RunCommandsLine {
60
62
  readonly readyAll?: readonly string[]
61
63
  /** `envFile` as declared: workspace-root-relative, or absolute. */
62
64
  readonly envFile: string | undefined
65
+ /**
66
+ * The Nx targets the line's leading `nx <target> <project>` /
67
+ * `nx run <project>:<target>` commands ran, taken out of the line: each
68
+ * is a dependency edge instead.
69
+ */
70
+ readonly nxCalls?: readonly { readonly project: string; readonly target: string }[]
63
71
  }
64
72
 
73
+ /** `nx <target> <project>` or `nx run <project>:<target>`, through a package runner or not, with nothing else. */
74
+ const NX_CALL =
75
+ /^(?:(?:npx|bunx|pnpm(?: exec)?|yarn)\s+)?nx\s+(?:run\s+([\w@./-]+):([\w:.-]+)|([\w:.-]+)\s+([\w@./-]+))$/
76
+
65
77
  /** The options run-commands consumes itself; every other one is forwarded to each command. */
66
78
  const PROP_KEYS: ReadonlySet<string> = new Set([
67
79
  'command',
@@ -306,8 +318,6 @@ export function mapRunCommands(
306
318
  'nx:run-commands: per-command `prefix` / `color` output decoration is not reproduced',
307
319
  )
308
320
  }
309
- if (entries.length === 0) return { command: NOOP, env, readyWhen: undefined, envFile }
310
-
311
321
  const argsOption = Array.isArray(options['args'])
312
322
  ? options['args'].join(' ')
313
323
  : typeof options['args'] === 'string'
@@ -328,6 +338,27 @@ export function mapRunCommands(
328
338
  ? options['forwardAllArgs']
329
339
  : true
330
340
 
341
+ // A command that runs another target under Nx, ahead of the rest of a
342
+ // serial line (ngrx's `build` opens with `nx build-package <project>`),
343
+ // ran Nx inside the task and kept two cached tasks on one output with no
344
+ // edge between them; as an edge, the target runs first as Nx ran it. One
345
+ // Nx would hand options or arguments to stays a command.
346
+ const nxCalls: { project: string; target: string }[] = []
347
+ if ((!parallel || entries.length === 1) && readyWhen.length === 0 && ctx.isTarget) {
348
+ while (entries.length > 0) {
349
+ const e = entries[0]!
350
+ const m = NX_CALL.exec(e.command.trim())
351
+ if (m === null || (forwardAll(e) && (unknown.length > 0 || argsOption))) break
352
+ const project = m[1] ?? m[4]!
353
+ const target = m[2] ?? m[3]!
354
+ if (!ctx.isTarget(project, target)) break
355
+ nxCalls.push({ project, target })
356
+ entries.shift()
357
+ }
358
+ }
359
+ const calls = nxCalls.length > 0 ? { nxCalls } : {}
360
+ if (entries.length === 0) return { command: NOOP, env, readyWhen: undefined, envFile, ...calls }
361
+
331
362
  const commands: Interpolated[] = []
332
363
  let named = false
333
364
  for (const e of entries) {
@@ -384,7 +415,7 @@ export function mapRunCommands(
384
415
  const only = commands.length === 1 ? commands[0]! : undefined
385
416
  if (only !== undefined && only.runtime === 'append' && only.baked.length === 0) {
386
417
  const line = only.tail === undefined ? only.text : `${only.text} ${only.tail}`
387
- return { command: `${cd}${line}`, env, readyWhen: pattern, envFile, ...ready }
418
+ return { command: `${cd}${line}`, env, readyWhen: pattern, envFile, ...ready, ...calls }
388
419
  }
389
420
  // A subshell per command, as Nx gives each a shell of its own; a comment
390
421
  // in one must not swallow the `)` that closes it.
@@ -437,6 +468,7 @@ export function mapRunCommands(
437
468
  readyWhen: pattern,
438
469
  envFile,
439
470
  ...ready,
471
+ ...calls,
440
472
  }
441
473
  }
442
474
 
package/src/paths.ts CHANGED
@@ -27,10 +27,12 @@ export function relPosix(from: string, to: string): string {
27
27
  * holds: a string or an array would enumerate its INDICES as script names,
28
28
  * and indexing one by a task named `0` yields a single character that reads
29
29
  * as a usable command. Core's `migrate-scripts.ts` guards this read; these
30
- * mappers did not.
30
+ * mappers did not. Null-prototype, so a turbo.json task or Nx target named
31
+ * `constructor` or `toString` finds no script rather than Object's method.
31
32
  */
32
33
  export function packageScripts(meta: ProjectMeta): Record<string, unknown> {
33
34
  const raw = (meta.packageJson as unknown as { scripts?: unknown }).scripts
34
- if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return {}
35
- return raw as Record<string, unknown>
35
+ const scripts: Record<string, unknown> = Object.create(null)
36
+ if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) return scripts
37
+ return Object.assign(scripts, raw)
36
38
  }
@@ -500,6 +500,11 @@ function tasksOf(cfg: TurboJson): Record<string, TurboTask> {
500
500
  return cfg.tasks ?? cfg.pipeline ?? {}
501
501
  }
502
502
 
503
+ /** A turbo.json's own task `key`: a plain index reads `constructor` off Object. */
504
+ function taskAt(tasks: Record<string, TurboTask>, key: string): TurboTask | undefined {
505
+ return Object.hasOwn(tasks, key) ? tasks[key] : undefined
506
+ }
507
+
503
508
  function declares(meta: ProjectMeta, dependency: string): boolean {
504
509
  const pj = meta.packageJson as unknown as Record<string, unknown>
505
510
  // Turbo counts every kind but a peer in a monorepo.
@@ -627,7 +632,7 @@ function taskDefined(
627
632
  const cfg = files.get(at)
628
633
  if (cfg === undefined) return at === ROOT ? 'none' : taskDefined(pkgName, name, files, ROOT, seen)
629
634
  const tasks = tasksOf(cfg)
630
- const def = tasks[`${pkgName}#${name}`] ?? tasks[name]
635
+ const def = taskAt(tasks, `${pkgName}#${name}`) ?? taskAt(tasks, name)
631
636
  if (def !== undefined) return optedOut(def) ? 'excluded' : 'found'
632
637
  if (at === ROOT) return 'none'
633
638
  for (const parent of parentsOf(cfg)) {
@@ -666,7 +671,9 @@ function taskNamesFor(
666
671
  function definitionOf(pkgName: string, name: string, chain: readonly TurboJson[]): TurboTask {
667
672
  const defs = chain.map((cfg, i) => {
668
673
  const tasks = tasksOf(cfg)
669
- return i === 0 ? (tasks[`${pkgName}#${name}`] ?? tasks[name]) : tasks[name]
674
+ return i === 0
675
+ ? (taskAt(tasks, `${pkgName}#${name}`) ?? taskAt(tasks, name))
676
+ : taskAt(tasks, name)
670
677
  })
671
678
  let from = 0
672
679
  for (let i = defs.length - 1; i > 0; i--) {
@@ -676,16 +683,43 @@ function definitionOf(pkgName: string, name: string, chain: readonly TurboJson[]
676
683
  }
677
684
  }
678
685
  let def: TurboTask = {}
686
+ let fromDeps: DependencyOutputs | null = null
679
687
  for (const d of defs.slice(from)) {
680
688
  if (d === undefined) continue
681
689
  const own: TurboTask = { ...d }
682
690
  delete own.extends
683
- if (Array.isArray(own.inputs)) own.inputs = flatInputs(own.inputs)
691
+ if (Array.isArray(own.inputs)) {
692
+ const found = dependencyOutputsOf(own.inputs)
693
+ if (found !== null || !own.inputs.includes(TURBO_EXTENDS)) fromDeps = found
694
+ own.inputs = flatInputs(own.inputs)
695
+ }
684
696
  def = withOverlay(def, own)
685
697
  }
698
+ if (fromDeps !== null) dependencyOutputs.set(def, fromDeps)
686
699
  return def
687
700
  }
688
701
 
702
+ /** A task's `{ mode: "dependencyOutputs" }` input: the producers it names, and which of their files. */
703
+ interface DependencyOutputs {
704
+ from: readonly string[] | null
705
+ globs: readonly string[]
706
+ }
707
+
708
+ /** Held beside a resolved definition, out of the keys the unknown-key check reads. */
709
+ const dependencyOutputs = new WeakMap<TurboTask, DependencyOutputs>()
710
+
711
+ function dependencyOutputsOf(inputs: readonly unknown[]): DependencyOutputs | null {
712
+ for (const i of inputs) {
713
+ if (i === null || typeof i !== 'object') continue
714
+ const { mode, from, globs } = i as { mode?: unknown; from?: unknown; globs?: unknown }
715
+ if (mode !== 'dependencyOutputs') continue
716
+ const strings = (v: unknown): string[] =>
717
+ Array.isArray(v) ? v.filter((g): g is string => typeof g === 'string') : []
718
+ return { from: Array.isArray(from) ? strings(from) : null, globs: strings(globs) }
719
+ }
720
+ return null
721
+ }
722
+
689
723
  /**
690
724
  * Turbo 2.11's structured inputs as the strings they stand for: a
691
725
  * `startup` or `jit` entry is its globs (and every file with
@@ -1381,6 +1415,67 @@ export async function mapTurboWorkspace(
1381
1415
  projects.push({ name: meta.name, dir: meta.dir, tasks })
1382
1416
  }
1383
1417
 
1418
+ // Turbo 2.11's `dependencyOutputs` input hashes the named files after
1419
+ // their producer ran. A cached producer's key already reaches the reader
1420
+ // through `dependsOn`, its outputs following from it; an uncached one's
1421
+ // (vercel/vercel's `//#generate:cache-keys`, which records the host) do
1422
+ // not, and a test cached on one platform hit on another. The reader
1423
+ // keys those files by a probe at the workspace root.
1424
+ const metaNamed = new Map(metas.map((m) => [m.name, m]))
1425
+ for (const meta of metas) {
1426
+ const { defFor } = definitions(meta)
1427
+ for (const t of projects.find((p) => p.name === meta.name)?.tasks ?? []) {
1428
+ const def = defFor(t.name)
1429
+ const dep = def === undefined ? undefined : dependencyOutputs.get(def)
1430
+ const inputs = (t.task?.['cache'] as { inputs?: Record<string, unknown> } | undefined)?.inputs
1431
+ if (dep === undefined || inputs === undefined) continue
1432
+ const rels: string[] = []
1433
+ const unfold = new Set<string>()
1434
+ for (const sel of dep.from ?? def!.dependsOn ?? []) {
1435
+ if (envDependency(sel) !== null) continue
1436
+ const hash = sel.indexOf('#')
1437
+ const producers: [ProjectMeta | undefined, string][] = sel.startsWith('^')
1438
+ ? metas.filter((m) => declares(meta, m.name)).map((m) => [m, sel.slice(1)])
1439
+ : hash === -1
1440
+ ? [[meta, sel]]
1441
+ : [
1442
+ [
1443
+ sel.startsWith(`${ROOT}#`) ? rootMeta : metaNamed.get(sel.slice(0, hash)),
1444
+ sel.slice(hash + 1),
1445
+ ],
1446
+ ]
1447
+ for (const [pm, task] of producers) {
1448
+ if (pm === undefined) continue
1449
+ const pdef = definitions(pm).defFor(task)
1450
+ if (pdef?.cache !== false) continue
1451
+ if (!sel.startsWith('^')) unfold.add(`${pm.name}#${task}`)
1452
+ const at = relPosix(root, pm.dir)
1453
+ for (const g of dep.globs.length > 0 ? dep.globs : (pdef.outputs ?? []))
1454
+ if (!g.startsWith('!')) rels.push(at === '' ? g : `${at}/${g}`)
1455
+ }
1456
+ }
1457
+ if (rels.length === 0) continue
1458
+ const probe = rels.every(isLiteral) ? ignoredFilesProbe(rels) : dotenvGlobsProbe(rels)
1459
+ if (probe === null) {
1460
+ delete t.task!['cache']
1461
+ t.todos.push(
1462
+ `inputs read ${rels.map((r) => JSON.stringify(r)).join(', ')} from an uncached ` +
1463
+ 'dependency, a glob vx cannot key by a probe — task runs uncached; key it in a vx.config to cache it',
1464
+ )
1465
+ continue
1466
+ }
1467
+ const probes = (inputs['workspaceRuntime'] as unknown[] | undefined) ?? []
1468
+ inputs['workspaceRuntime'] = [...probes, probe]
1469
+ // Folded, the producer's key "covers" what it writes, and core answers
1470
+ // the probe before it runs: the previous run's bytes. Left out of the
1471
+ // fold, the probe waits for it (core's late probes, X-34).
1472
+ const deps = (t.task!['dependsOn'] as string[] | undefined) ?? []
1473
+ const id = (d: string) => (d.startsWith('^') || d.includes('#') ? d : `${meta.name}#${d}`)
1474
+ if (deps.some((d) => unfold.has(id(d))))
1475
+ inputs['tasks'] = deps.filter((d) => !unfold.has(id(d)))
1476
+ }
1477
+ }
1478
+
1384
1479
  if (opts.ignored !== undefined) {
1385
1480
  const ignored = await opts.ignored([
1386
1481
  ...new Set(literals.tasks.flatMap((l) => [...l.rels.keys()])),
@@ -24,7 +24,7 @@ export function lockfilePlugin(root: string): { file: string; factory: string }
24
24
  return lock === undefined ? undefined : { file: lock[0], factory: lock[1] }
25
25
  }
26
26
 
27
- export interface AdoptedTool {
27
+ interface AdoptedTool {
28
28
  /** The adoption plugin `vx init` declares: `turbo`, `nx`. */
29
29
  readonly plugin: string
30
30
  /** The file it reads every run: `turbo.json`, `nx.json`. */