Skip to content

Dependency Inference

Most monorepos state their internal dependency graph twice. Once for the language’s package manager:

crates/greet/Cargo.toml
[dependencies]
format = { path = "../format" }

and once for Grog:

crates/greet/BUILD.star
cargo_crate(name = "greet", deps = ["//crates/format"])

Naturally, this isn’t great because the Grog graph can get out of sync causing cache-misses or incorrect cache hits.

This is why Grog provides customizable dependency resolvers that allow syncing language dependencies into the Grog build graph.

Declare a resolver in any BUILD file (typically the workspace root) to use it.

cargo is built in, so the declaration is a name and a command:

# BUILD.yaml at the workspace root
dependency_resolvers:
- name: cargo
command: builtin::cargo

Every crate in the workspace now has an implicit target //crates/<name>:_cargo_package that groups the relevant input files. Its inputs come from the crate’s layout and its dependencies from the crate’s path entries, and it exists whether or not the crate has a BUILD file:

Terminal window
$ grog deps //crates/cli:_cargo_package
//crates/greet:_cargo_package

A resolver is a small program that reports, for each package directory, the directories it depends on and, optionally, the files that make it up. Grog uses this information to build pseudo targets on the fly for its dependency graph.

Paths are relative to the package the resolver is declared in. //:cargo at the repo root reports workspace-relative paths, while crates/format from //tools/rust:cargo means tools/rust/crates/format.

flowchart TD
  M["crates/greet/Cargo.toml<br/>format = { path = &quot;../format&quot; }"] --> R["//:cargo resolver"]
  R -->|"crates/greet → crates/format"| G
  subgraph buildGraph["Build graph"]
    T["//crates/greet:test<br/>written by you"] -->|depends on| G["//crates/greet:_cargo_package<br/>resolved"]
    G -->|depends on| F["//crates/format:_cargo_package<br/>resolved"]
  end

Your own targets only depend on their package’s filegroup, e.g. //crates/greet:test on :_cargo_package. The edge to crates/format comes from the resolver, so a change there still invalidates the test.

For each package the resolver reports inputs for, Grog generates a filegroup, e.g. //crates/format:_cargo_package, and wires the edges between them. Its name is the resolver’s generated_target_name, which defaults to _<resolver name>_package. This works in directories without a BUILD file. A package reported without inputs gets no filegroup, which is only an error once something depends on it.

If you know a package’s inputs better than the resolver, e.g. because it has a generated directory, register your own target in its place:

crates/greet/BUILD.star
target(
name = "lib",
inputs = ["src/**/*.rs", "gen/**/*.rs", "Cargo.toml"],
dependency_resolvers = ["//:cargo"],
)

//crates/greet:lib now replaces :_cargo_package: it receives the package’s edges, and other packages’ edges point at it. The resolver’s inputs for the package are ignored. Only one target per package may register a given resolver, but a target may register with several and receives the union of their edges.

Grog ships these resolvers. Use one with command = "builtin::<name>" and omit inputs to get the defaults listed below. Every built-in declares inputs for the packages it reports, so workspace members don’t need a BUILD file.

NameReadsNeeds a toolchain
cargoThe workspace Cargo.toml members and each member’s [dependencies] / [build-dependencies]no
uvuv.lock — packages with an editable or directory source are workspace membersno
pnpmpnpm-workspace.yaml, each member’s package.json and the links in pnpm-lock.yamlno
aubeaube-workspace.yaml, each member’s package.json and the links in aube-lock.yamlno
npm, yarnworkspaces in the root package.json and each member’s package.jsonno
gogo list -deps -e -json ./..., keeping imports under the module pathyes (go)

All but go parse manifests and lockfiles directly, so grog changes works on a CI image without Cargo, uv or Node.

Members are [workspace] members minus exclude, plus the root package if the root manifest has a [package] table. Edges are path dependencies between members, including workspace = true entries resolved through [workspace.dependencies].

[dev-dependencies] are skipped. Cargo allows a dev-dependency to point back at a dependant, which would be a cycle in Grog. If a test needs a crate that isn’t a normal dependency, add its label to the test target by hand.

Default inputs: Cargo.toml, Cargo.lock, and <pattern>/Cargo.toml for every members pattern, with target/** excluded.

Each crate’s :_cargo_package filegroup covers Cargo.toml, build.rs, src/**/*, tests/**/*, benches/**/*, examples/**/*, [lib] path, [[bin]] path and [package] include, minus target/**. Files a crate doesn’t have are skipped, so the list errs broad: an extra glob is free, a missing one under-invalidates.

Workspace members are the uv.lock packages listed in [manifest] members; edges are their dependencies and optional-dependencies that name other members. Dev groups are excluded for the same reason as Cargo’s [dev-dependencies]: uv does not forbid cycles between members, and a test-only edge back to a dependant is the common way to get one. The root project is not a member: it has no directory of its own.

Each member’s synthesized :_uv_package covers pyproject.toml and its module tree, minus **/.venv/** and **/__pycache__/**. With [tool.uv.build-backend] the modules are known exactly (module-root, default src, and module-name, which may be a list). Any other build backend may package more than one directory, so the inputs are every .py and .pyi file in the package plus the full tree of a src/<name> or <name> directory matching the normalised project name. Package data outside that tree is not covered — register a filegroup with your own inputs for those packages.

Default inputs: uv.lock, pyproject.toml, and every member’s pyproject.toml, which decides its layout, with **/.venv/** and **/__pycache__/** excluded.

Pick the resolver for the package manager your workspace uses; each reads only its own files, so a stray lockfile from another manager changes neither the graph nor the cache key. pnpm takes its members from pnpm-workspace.yaml and fails the load without one; npm and yarn take them from workspaces in the root package.json, as a list or as {"packages": [...]}. For a project managed by aube, use pnpm if it kept your pnpm files, else aube, which reads aube-workspace.yaml and aube-lock.yaml in the same format.

Edges are dependencies, devDependencies, peerDependencies and optionalDependencies entries that point at another member:

  • by key, as with workspace:* or a plain version range,
  • by alias, workspace:<name>@<range> or npm:<name>@<range>,
  • by path, link:<path> or file:<path>.

pnpm and aube also add every importer dependency the lockfile resolves to link:<path>, which covers overrides and anything else the manifests don’t spell out. Before the first install there is no lockfile, and the manifests alone decide.

The root package.json is a package of its own, covering just that file, so a root target registering the resolver depends on the members it names. None of the package managers forbid cycles between members, and one through any of these fields fails the load. A synthesized filegroup covers the whole member, **/*, minus **/node_modules/**.

Default inputs: package.json, the workspace file and lockfile (pnpm and aube only), and <pattern>/package.json for every member glob, so a new member is picked up without touching the declaration. **/node_modules/** and every negated member glob are excluded.

builtin::node, which guessed the package manager, was split into these resolvers; a declaration still using it fails the load.

Edges are imports between packages inside your module(s), as reported by go list. Every .go file counts, including ones excluded by build constraints, so the graph is the same for every GOOS, GOARCH and tag set. Test-only imports are skipped for the same cycle reason as Cargo’s [dev-dependencies].

This is the only built-in that shells out to its toolchain, so it’s the slowest on a large repo and gains the most from caching.

Default inputs: go.mod, go.sum, go.work, **/*.go.

FieldTypeDescription
dependency_resolverslabel[]Resolvers this target registers with. Their edges for its package are added to dependencies.

Normal label rules apply: ":cargo" is a resolver in the same package, "//:cargo" one at the workspace root. Supported in every BUILD format and in Grog script annotations.

Resolvers are declared in a BUILD file next to targets, aliases and resources:

dependency_resolvers:
- name: cargo
command: builtin::cargo
inputs:
- Cargo.toml
- Cargo.lock
- crates/*/Cargo.toml
FieldTypeDescription
namestringUnique within the package, shared namespace with targets, aliases and resources.
commandstringShell command that prints the mapping to stdout, or builtin::<name>.
inputsstring[]Globs relative to the declaring package that form the cache key. Defaults to the built-in’s.
exclude_inputsstring[]Globs dropped from inputs before hashing. A pattern ending in /** skips the whole directory.
timeoutstringDefaults to 60s. Resolvers run on read-only commands like grog deps, so they must not hang.
generated_target_namestringName of the filegroup synthesized for a package that registers no target. Defaults to _<name>_package.

The default globs are broad and resolved on every load; a ** pattern over a large repo is a second tree walk. Narrow them where you can:

dependency_resolver(
name = "cargo",
command = "builtin::cargo",
inputs = ["Cargo.toml", "Cargo.lock", "crates/*/Cargo.toml"],
)

When a ** glob is unavoidable, exclude the directories it shouldn’t walk. A pattern ending in /** stops Grog from entering the directory at all:

dependency_resolver(
name = "npm",
command = "python3 tools/resolve_npm.py",
inputs = ["tools/resolve_npm.py", "**/package.json"],
exclude_inputs = ["**/node_modules/**"],
)

Declare another resolver in that workspace’s package. Its paths are relative to where it’s declared, so nothing else needs configuring:

tools/rust/BUILD.star
dependency_resolver(name = "cargo", command = "builtin::cargo")
tools/rust/crates/codegen/BUILD.star
target(name = "codegen", dependency_resolvers = ["//tools/rust:cargo"], ...)

Point command at your own program:

dependency_resolver(
name = "cargo",
command = "python3 tools/grog/cargo_resolver.py",
inputs = ["Cargo.toml", "crates/*/Cargo.toml"],
)

A resolver is any command that prints a JSON document to stdout. There is no plugin API.

  1. Write a program, in any language, that prints the document.
  2. Declare it with dependency_resolver and list the files its output depends on as inputs.
  3. Either emit inputs per package so Grog synthesizes filegroups, or register a target in each package.
  4. Check the result with grog check (package and edge counts) and grog deps (merged edges).

Requirements. Resolvers run while loading the graph, on every command that reads it, and their output is cached. That makes the following rules:

  1. Side-effect free. Resolvers may run concurrently and don’t run at all on a cache hit, so nothing may rely on them having run. Never write to the workspace; bare cargo metadata, for example, rewrites Cargo.lock unless given --locked.
  2. Deterministic. Same inputs, same bytes. Absolute paths, timestamps or unordered maps in the output churn the cache.
  3. stdout is the document only. Send diagnostics to stderr: Grog prints it verbatim on failure and logs it at debug level on success.
  4. Non-interactive. stdin is the null device. A resolver that waits for input hangs every grog command, including shell completion.
  5. Bounded. Finish within the declaration’s timeout.
  6. Complete. Report every edge whose change should rebuild the importing package, conditional ones included, and every file that belongs to a package you declare inputs for. Nothing downstream can detect an omission, so over-approximate like the built-ins do.

Invocation. The command runs through the same shell as target commands, including the default set -eu (see disable_default_shell_flags). The working directory is the declaring package. The environment matches a target command’s: the process environment, grog.toml environment_variables, the GROG_* variables and GROG_RESOLVER_LABEL.

Output. One JSON document on stdout:

{
"version": 1,
"packages": {
"crates/cli": { "dependencies": ["crates/greet"], "inputs": ["src/**/*", "Cargo.toml"] },
"crates/format": { "dependencies": [], "inputs": ["src/**/*", "Cargo.toml"] },
"crates/greet": { "dependencies": ["crates/format"], "inputs": ["src/**/*", "Cargo.toml"] },
"crates/server": { "dependencies": ["crates/greet"], "inputs": ["src/**/*", "Cargo.toml"] }
}
}
  • Keys are directory paths relative to the declaring package; "" is the declaring package itself.
  • Paths are relative and /-separated, with no leading ./, trailing / or .. segment. Absolute or escaping paths are a load error.
  • dependencies entries are paths too, except that an entry starting with // is a literal Grog label. Use it to point at a specific target instead of a package’s stand-in.
  • inputs are globs relative to the described package, not the declaring one. They’re only used when no target in that package registers the resolver (see how the wiring works); an empty list equals omitting them.
  • exclude_inputs are globs dropped from inputs, like a target’s exclude_inputs; a pattern ending in /** keeps Grog out of the directory entirely.
  • An omitted package is the same as one with no dependencies. List every member anyway so grog check counts are meaningful.
  • Self-dependencies are dropped.
  • Order doesn’t matter; Grog sorts every list, so shuffled output doesn’t churn the cache.
  • Unknown fields are ignored. version is required.

Exit status. Exit zero with a valid document, or non-zero with a message on stderr. There is no partial success: a resolver that can’t determine the whole graph must fail rather than emit a subset.

examples/custom_resolver has three protobuf packages whose imports form a chain, one of them without a BUILD file. Its resolver maps each package to the directories its .proto files import, and declares *.proto as the package’s inputs:

tools/resolve_proto_imports.py
#!/usr/bin/env python3
import json
import re
import sys
from pathlib import Path
IMPORT = re.compile(r'^import "([^"]+)";', re.MULTILINE)
packages = {}
for proto_file in sorted(Path("proto").rglob("*.proto")):
package_directory = proto_file.parent.as_posix()
imported_directories = {
Path(imported).parent.as_posix() for imported in IMPORT.findall(proto_file.read_text())
}
packages[package_directory] = {
"dependencies": sorted(imported_directories - {package_directory}),
"inputs": ["*.proto"],
}
json.dump({"version": 1, "packages": packages}, sys.stdout)

Declare it with the files its output depends on, including the script itself:

# BUILD.yaml at the workspace root
dependency_resolvers:
- name: protos
command: python3 tools/resolve_proto_imports.py
inputs:
- tools/resolve_proto_imports.py
- proto/*/*.proto

Every package now has a :_protos_package filegroup, and a consuming target depends on nothing else:

proto/order/BUILD.yaml
targets:
- name: generate
command: grep -h '^message' *.proto > messages.txt
dependencies:
- :_protos_package
outputs:
- messages.txt

proto/base has no BUILD file, yet grog build //... reports the critical path //proto/base:_protos_package -> //proto/user:_protos_package -> //proto/order:_protos_package -> //proto/order:generate.

Resolvers run at load time, so they cost something on every grog build, grog test, grog changes and shell completion. Grog caches their output like target output.

The cache key covers the protocol version, the resolver’s label and command, the Grog version for builtin:: commands, and the contents of every file matching inputs. The environment and platform are not part of it, so machines share one mapping. Anything else the output depends on must be listed in inputs, including a custom resolver’s own script; otherwise edits to it keep serving the cached mapping.

The cached value is the resolver’s stdout, stored in the content-addressable store. With a remote cache, a cold CI runner downloads the mapping instead of running go list.

Work
Cache hitHash the inputs files, one cache read, one JSON parse
Cache missThe above plus one subprocess
No resolver declaredNothing

Failed runs are never cached. --enable-cache=false disables resolver caching along with everything else.

To skip running a tool at load time entirely, commit the mapping and cat it:

# BUILD.star at the workspace root
dependency_resolver(
name = "cargo",
command = "cat tools/grog/inferred-deps.json",
inputs = ["tools/grog/inferred-deps.json"],
)

Regenerate it with an ordinary target:

tools/grog/BUILD.yaml
targets:
- name: inferred-deps
command: python3 cargo_resolver.py > inferred-deps.json
inputs:
- cargo_resolver.py
dependencies:
- //:workspace
outputs:
- inferred-deps.json

This needs no toolchain at load time, but the file can drift between regenerations.

Inferred edges are ordinary dependencies, so every query command shows them:

Terminal window
grog deps //crates/cli:cli # direct dependencies, inferred ones included
grog rdeps //crates/format:format # who depends on this crate
grog graph -o mermaid # the whole graph

--log-level debug prints each resolver’s command, cache key, a hit-or-miss summary and the full mapping:

resolver //:cargo: builtin::cargo, cache hit, 4 packages, 3 edges
MessageFix
no dependency resolver at //:cargo2 (referenced by //crates/cli:cli); declared: //:cargo, //:protoFix the label or declare the resolver.
resolver //:cargo registered twice in package crates/cli: //crates/cli:cli and //crates/cli:buildOnly one target per package may register. Register one and have the others depend on it.
resolver //:cargo reports crates/greet depends on crates/format, which has no target registered for //:cargo and no inputs to synthesize one fromRegister a target in crates/format, have the resolver emit inputs for it, or remove it from the workspace.
resolver //:cargo cannot synthesize //crates/format:_cargo_package: a target with that name is defined in crates/format/BUILD.yamlRename the hand-written target, or register it with the resolver so it becomes the endpoint.
resolver //:cargo failed: exec: "cargo": executable file not found in $PATHInstall the tool, warm the remote cache, or use a committed mapping.
cycle detected: //crates/a:a -> //crates/b:b -> //crates/a:aUsually a dev-dependency edge from a custom resolver. See limitations.

A workspace member with no stand-in target is ignored, since a repo may contain packages Grog doesn’t build. It’s only an error once something depends on it.

grog changes loads the graph at the current revision, so inferred edges are always current.

Adding a dependency usually edits a manifest that’s already an input of the dependant target, so the change is detected normally and --dependents=transitive propagates it. For manifests that aren’t any target’s input, such as a root workspace file adding or removing members, Grog falls back to a conservative rule: if any file matched by a resolver’s inputs changed, every target registered with that resolver counts as changed.

  • Dev dependencies and test-only imports are skipped by cargo, uv and go. All three ecosystems allow cycles there, and a package’s edges land on its single stand-in, so importing them would turn a legal workspace into a Grog cycle. Add those labels to the test target by hand.
  • Cycles in the package graph itself fail the load. Cargo and Go forbid them, but uv, npm, yarn and pnpm don’t.
  • Conditional dependencies are included unconditionally. A cfg(windows)-only crate is an edge on Linux too.
  • One registered target per resolver per package. For different inferred graphs per target, split the package or add labels by hand.
  • A package needs a stand-in to be depended on: a registered target, or resolver-declared inputs. A custom resolver that emits edges but no inputs needs a BUILD file in each package.
  • Resolvers can’t depend on build outputs, since they run before the build.