Picture a Go repository with a go.work file and a handful of services. At the root there is also a package.json, because someone wanted Prettier and a commit hook. You turn on Go workspaces in turbo, run turbo run build, and nothing happens. Turbo found no packages to build.
The services are right there. Turbo never looked at them, because the root package.json had already told it what kind of repository this was. Two questions sit under almost every command: what is in this repository, and what did this commit touch? Both were first answered for JavaScript, and Cargo, Go and uv workspaces showed where that shows through.
Deciding what the repository is
Turbo finds the root by walking up from the current directory and asking each ancestor whether it could be one. For a long time that meant "has a package.json". Native toolchains were only asked to contribute packages afterwards, so in the repo above the Prettier file made the root a single-package JavaScript repository.
The fix (#14444) was not to teach that one check about Go. The next piece of code asking "is this a root?" would have needed the same special case. Instead, RepositoryBootstrap::observe(root) runs the JavaScript check and every enabled toolchain's probe once. A probe reads only the root's own definition files and never lists members or runs a binary. Root inference, graph construction, setup and the watcher all use that one observation, because two probes taken at different moments can disagree.
JavaScript is an optional input here, not an adapter. A missing package.json is an error only if no toolchain recognized the root, and an incidental one no longer erases a native workspace.
The cost is that every adapter has to follow the contract. Probes can't spawn processes, and the plan is a snapshot rebuilt when the process or configuration changes, and after graph-defining changes in watch mode. That is more machinery than a branch per toolchain. The crate's README states the rule as no ecosystem branches in inference or graph construction, and a fake fourth adapter in the tests holds it.
Deciding what a commit touched
--affected, --filter=...[ref] and turbo query affectedPackages all start the same way: source control reports changed files and a ChangeMapper turns them into packages, each with a reason.
The lockfile branch is the hard part, because one file belongs to many packages. Calling the whole repo changed is always safe and rarely useful. Doing better means resolving each package's dependencies under the old lockfile and the new one and comparing. Each toolchain reports which files can change its external resolution, and reading them is a dispatch on file name. Only Cargo.lock and uv.lock have their own readers. Everything else goes to the JavaScript path.
go.sum is still in that position. It is registered as a Go resolution path but has no branch of its own, so a change to it falls through to the JavaScript path and then to the fallback. Every package is affected: safe and wide. Per-toolchain readers are what would make it narrow.
When Cargo.lock changed everything
That dispatch used to send everything except uv.lock to the JavaScript parser, and the JavaScript parser can't read Cargo.lock. Attribution failed, and a failure returns every package with the reason LockfileChangeDetectionFailed. Everything still built correctly, just slower, so the bug stayed hidden.
Cargo.lock now resolves both versions into a closure per crate and hands them to the same comparison the JavaScript path uses. If the Cargo data is missing or incomplete, the branch reports it as unavailable and the fallback still applies.
The fallback is the right default, even though it made this bug invisible. A missing answer is not an empty answer. Returning "nothing changed" on a parse failure would turn a tooling bug into a green CI run on broken code. Returning "everything changed" turns it into a slow one. The price is that nothing fails when it fires: a lockfile no reader understands looks exactly like one that changed everywhere, and the only trace is a reason in turbo query.
The same problem in the hash
The same pattern showed up twice more. With affectedUsingTaskInputs, --affected matched changed files against each task's inputs globs as written, while the hasher also includes package.json and turbo.json and expands src to src/**. A change that altered the cache key could select no task. Both sides now share the same input rules.
Separately, a global !NEXT_PUBLIC_* exclusion could drop a variable listed in a task's own env for framework packages. Global exclusions now apply to inferred variables only.