Driver detection
A task named build has to become a real shell command. The tool that supplies
that command is the workspace’s driver.
The evidence that identifies a language rarely identifies a driver. A lone
package.json says JavaScript. It does not say whether build means
pnpm run build, yarn build, npm run build, or bun run build, and those
four commands can behave differently. Lattice looks for evidence that names one
tool, and halts when it finds none.
The evidence ladder
For each workspace, Lattice gathers candidate tools from three kinds of evidence. A tool named by more than one kind keeps the highest-ranked:
| Rung | Evidence | Examples |
|---|---|---|
| 1. Declaration | A driver named in the workspace’s resolved engines map, meaning its own entries merged over the root’s |
"engines": { "pnpm": ">=8" } |
| 2. Native file | A file the developer wrote to pin a tool | packageManager in package.json, .tool-versions, a [tools] table in mise.toml or .mise.toml, .nvmrc, rust-toolchain.toml or rust-toolchain, .python-version, .ruby-version or a ruby directive in a Gemfile, .java-version, a toolchain line in go.mod, gradlew, mvnw, deno.json or deno.jsonc |
| 3. Lockfile | A lockfile or wrapper that only one tool produces | pnpm-lock.yaml, bun.lockb, Cargo.lock, poetry.lock, turbo.json |
The rung does not decide which candidate drives the workspace. The candidates’ roles do. Rung rank settles two narrower questions: which evidence gets recorded for a tool that several rungs name, and, when two candidates hold the same role, whether a rung-1 declaration breaks the tie.
A bare ecosystem marker is not evidence of a driver. These files identify a
language and no tool: package.json, Cargo.toml, go.mod, pyproject.toml,
requirements.txt, setup.py, Gemfile, pom.xml, build.gradle,
build.gradle.kts, composer.json, mix.exs, pubspec.yaml, Package.swift,
stack.yaml, cabal.project, and any .sln, .csproj, .fsproj, or
.vbproj file. None of them is a driver fingerprint. They feed only the
candidate list an ambiguity error prints, so the halt message can name the tools
that could plausibly have been meant.
Rung 3 covers the fingerprints in the driver registry. A sample:
| Tool | Fingerprint | Invoke form |
|---|---|---|
pnpm |
pnpm-lock.yaml |
pnpm run {task} |
cargo |
Cargo.lock, rust-toolchain.toml, rust-toolchain |
cargo {task} |
uv |
uv.lock |
uv run {task} |
just |
justfile, .justfile |
just {task} |
turbo |
turbo.json |
turbo run {task} |
All 34 built-in drivers, with every fingerprint and invoke form, are in the
driver table on Toolchains. Two of them, pip and
kotlin, have no fingerprint, because no file on disk belongs to them alone.
They are reachable from rung 1 or rung 2 only.
Roles: composition and conflict
Every driver declares one or more roles. Roles are what let several tools coexist in one workspace, and they decide which one drives:
| Role | Rank | Examples |
|---|---|---|
| Runtime | 0 | node, python, ruby, java, kotlin |
| Build tool | 1 | cargo, go, gradle, maven, dotnet, swift, stack, cabal |
| Package manager | 2 | pnpm, npm, yarn, bun, uv, poetry, pdm, pipenv, pip, bundler, nuget, pod, composer, dart |
| Task runner | 3 | just, task, turbo, nx, deno, rake, mix |
The candidate holding the highest-ranked role drives the workspace. A tool with
several roles competes with its highest one and no other. deno is a runtime, a
package manager, and a task runner, so it competes as a task runner. bun is a
runtime and a package manager, so it competes as a package manager. mix is
Elixir’s package manager and task runner, so it competes as a task runner.
Different roles compose. A .nvmrc (node, a runtime) beside a
pnpm-lock.yaml (pnpm, a package manager) is not a conflict. pnpm drives, and
node stays in the resolved engine map so it can still be version-checked or
provisioned. A turbo.json over a pnpm-lock.yaml resolves to turbo the same
way, because a task runner outranks a package manager.
The same role conflicts. Two package managers in one workspace, say
pnpm-lock.yaml and bun.lockb, leave Lattice nothing to prefer, so it halts:
Error: workspace 'app' has an ambiguous or undeclared driver.
Candidate drivers: bun, pnpm
Declare the driver in lattice.json, under this workspace:
"engines": { "bun": ">=0.0.0" }
Two build tools, say stack.yaml.lock beside cabal.project.freeze, behave the
same. A declaration naming exactly one of the tied candidates resolves it, even
with the other’s lockfile still on disk. A declaration for a lower-ranked role
does not: declaring node in a workspace that has a pnpm-lock.yaml still
resolves to pnpm, because pnpm’s role outranks node’s.
A runtime cannot drive a named task alone. There is no universal
node build or python test, because a runtime runs a file rather than a task
by name. When every candidate in a workspace is a runtime, Lattice halts as if
it had found nothing.
When Lattice halts
An auto workspace with no unambiguous driver raises an ambiguity error and
stops the run before any task starts. Here it is for a workspace containing only
a package.json:
Error: workspace 'app' has an ambiguous or undeclared driver.
Candidate drivers: pnpm, npm, yarn, bun
Declare the driver in lattice.json, under this workspace:
"engines": { "pnpm": ">=0.0.0" }
With no ecosystem marker either, as in a workspace holding only a .nvmrc, the
candidate list is empty and the message says so:
Error: workspace 'app' has an ambiguous or undeclared driver.
Lattice detected no driver. The directory holds no lockfile, no wrapper, and no native declaration.
Declare the driver in lattice.json, under this workspace:
"auto": false, "scripts": { "build": "<command>" }
The suggested line differs between the two because a runtime cannot drive tasks.
Where a candidate tool exists, Lattice names one, and declaring it resolves the
halt. Where none does, no engines entry would help, so the message suggests
declaring the commands instead.
Either error clears in one of three ways: declare the tool that should run the
tasks in that workspace’s engines, declare it in the root engines map, or
set auto: false and write the commands yourself. The >=0.0.0 in the first
suggestion is a placeholder that matches any version. See Engines and
provisioning for a real constraint.
auto: false
The ladder is how an auto: true workspace, the default, resolves a driver
without you declaring commands. auto: false turns off its consequences.
Lattice may still detect a driver, but it infers no command from one, and an
ambiguous or undetectable result never halts the run. Only scripts supplies
commands.
{
"workspaces": [
{ "name": "app", "path": "apps/app", "auto": false }
],
"tasks": {
"build": {}
}
}
Such a workspace needs a scripts entry for every task it participates in.
Asking for one it does not list fails rather than being skipped:
Error: workspace 'app' has "auto": false and declares no command for task 'build'. Add the command under this workspace's "scripts" map in lattice.json
The skip applies only to an auto: true workspace whose driver has no command
for a given task. See Workspaces for the full
scripts and auto reference.
What a driver can run
Resolving a driver is not the same as resolving a command. A driver falls into one of two groups, and the group decides where a task’s command comes from.
One group takes the task name on its command line. cargo, go, make,
just, gradle, and most of the table work this way, and the invoke form is the
command. lattice run test in a cargo workspace runs cargo test whether or
not such a target exists, and cargo reports the missing target rather than
Lattice.
Within that group, a task runner is treated differently for one thing:
persistent: true. just, task, turbo, nx, rake, and mix run the
tasks the repo declared to them, and dev is normally one of those, so a
persistent task infers a command there — dev in a turbo.json workspace
resolves to turbo run dev. The rest of the group cannot, because there is no
cargo dev, so a persistent task in a cargo, go, or gradle workspace
needs a scripts entry naming the command.
The other group reads its tasks out of a manifest. npm, pnpm, yarn, and
bun read scripts in package.json, and deno reads tasks in deno.json
or deno.jsonc. Such a driver can run only a script that manifest declares. A
requested task the manifest leaves out does not run in that workspace at all. The
task drops out of the graph, and the run carries on with the workspaces that do
declare it.
That skip is deliberate. A types-only package with no build script has nothing
to build, and an invented npm run build for it fails. Lattice used to invent
one.
A missing script and a mistyped one look the same from outside, so Lattice warns when a manifest declares a script map without the task you asked for:
warn web declares scripts but no "build", so the task was skipped. Did you mean "biuld"?
Lattice stays quiet when the manifest declares no scripts at all, and when the
workspace has auto: false. See Errors
for every shape that warning takes, and for what Lattice says about a manifest
it cannot parse.
A scripts entry in lattice.json overrides both groups. It supplies the
command directly, so it is how you run a task in a workspace whose manifest does
not declare it.