Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

rules_dx

rules_dx is a Bazel developer platform with one dx CLI for build, test, lint, typecheck, format, generate, audit, and release tasks. Install Bazel via Bazelisk, then run dx through Bazel:

bazel run @rules_dx//:dx -- --help

Documentation

Start here to use dx. There is no dx install step: dx runs through Bazel. Install Bazel via Bazelisk (see .bazelversion), then follow the command and CI guides below.

Commands

CI And Examples

  • GitHub CI: run dx in GitHub Actions.
  • Examples: starter callers and per-language adoption workspaces.
  • Docs site: rendered reference.

Command Reference

Every dx command runs through Bazel. Put dx flags after the command, Bazel flags after --. @rules_dx//:dx is the public launcher; inside this checkout //:dx is the same target.

Locally, Bazel reads ~/.bazelrc and the system rc for every dx call. Under CI=true and inside Bazel tests, dx passes --nohome_rc --nosystem_rc.

bazel run @rules_dx//:dx -- build //...
bazel run @rules_dx//:dx -- lint --check //... -- --jobs=4

Scope rules live in Scope Defaults.

Airgapped hosts install from a vendored bundle: see Offline Bundle.

A .dx/version pin that disagrees with the rules_dx version dx was built from stops most commands. See Version Skew.

Build Slots

dx build, test, coverage, deploy, lint, typecheck, format, check, fix, generate, codegen, env, security, license, docs and list-tests take a build slot before they call Bazel. dx run holds a slot only while it builds and frees it before the app starts. dx watch takes one slot per iteration and holds none while it waits for changes. dx update takes one slot at its first updater that runs Bazel and holds it until its updaters finish. dx update --check and updaters that do not run Bazel take none. Slots are shared by every worktree and every user session on the machine. The slot count is the machine memory, or the cgroup memory limit if lower, divided by 3 GiB. There is always at least one and never more than 16. The count has no setting.

When the Bazel config sets a non-empty --remote_executor, dx offers more slots: the memory divided by 1.5 GiB, at most 16. dx reads this from the rc files Bazel reads, their --config sections, and the flags you pass. A run takes an extra slot only when no regular slot is free, the host is not under memory pressure, and no other run took an extra slot in the last 10 seconds. On Linux, memory pressure means /proc/pressure/memory some avg10 is 10 or more, or MemAvailable is below 1.5 GiB when that file is missing. macOS and Windows get no extra slots. A run that waits only because of memory pressure says so in its waiting notice.

While a slot is held, dx adds two flags right after build, test, coverage and run:

--local_resources=cpu=<n> --local_resources=memory=<mb>

n is the CPU count divided by the slots held right now, extra slots and this one included, and never less than 2. mb is the memory, less the memory kept for nested Bazel test servers, divided by the same number, in MiB. The values are fixed when dx takes the slot. Nested dx calls reuse them through DX_BUILD_SHARE.

dx adds neither flag when an rc file Bazel reads sets that resource to a value other than the vendored preset default. It also skips a resource you pass after --:

dx build //... -- --local_resources=cpu=4

CI runs and dx runs inside a Bazel test add neither flag.

When all slots are taken, or other runs already wait, dx waits and prints one line to stderr:

dx: waiting for a build slot (held by /path/to/worktree and 5 more, 2 ahead in line)

The line names one current holder, how many other slots are held, and how many runs wait ahead. Waiting runs get a slot in the order they started waiting. A waiter that exits or is stopped leaves the line. A waiter suspended for over 30 seconds does not hold up the line until it resumes. If dx cannot use the queue directory next to the slot files, it waits without a line.

With --output=json, dx also writes one notice event with code waiting_for_build_slot right after command_started. The slot frees when the Bazel work ends or dx exits, including when it crashes.

While dx waits, it keeps the worktree's running Bazel server from shutting down for idleness. It never starts a server.

If dx cannot write a slot file, it locks it read-only. If it still cannot use a slot file, it prints dx: skipped build slot <path>: <error> once and runs with one slot fewer. With --output=json it also writes one warning notice event with code build_slot_skipped.

If dx can use no slot file, it prints dx: build slots disabled: <error> and runs without a slot. With --output=json it also writes one notice event with code build_slots_disabled.

Ctrl-C or SIGTERM during the wait stops dx at once. dx prints dx: cancelled: stopped by signal N and exits 128 plus the signal number.

status, version, worktree, completion and the other commands that do not run Bazel never take a slot. A dx call started while DX_BUILD_SLOT is set, or from inside a Bazel test, takes no second slot. While dx holds a slot, the Bazel and tool processes it starts see DX_BUILD_SLOT=1 and DX_BUILD_SHARE set to cpu=<n>,memory=<mb>. dx run apps start after the slot frees.

Disk Space

Before each Bazel build it starts, dx checks free space on the workspace disk, the Bazel output base, the temp dir and the home dir. When one has under 1 GiB free, dx prints one line to stderr and keeps going:

dx: disk_low: 512.0 MiB free at /path; free disk space with `dx worktree prune --apply`

With --output=json, dx also writes one notice event with code disk_low and level warning.

dx run and dx deploy check before the build, not before the app starts, and an app's exit code is never reported as disk_full.

When the disk holding the worktree pool has under 20 GiB and under 10% free, or under 1 GiB free, dx drops orphaned output bases, repository cache downloads unused for 30 days, and free slots' output bases each time it takes a build slot: once for each command above, for each watch iteration, for the build run does before the app starts, and for update when it runs Bazel. This also happens when build slots are disabled. It never drops the output base of the workspace being built. dx prints one of these lines to stderr only when it removed something or could not reclaim:

dx: low disk: <free> free; removed <what>; <free> free now
dx: low disk, cannot reclaim output bases: <error>

With --output=json, dx also writes one notice event with code disk_low and level warning, after command_started and before that build's output.

dx worktree add and dx worktree hook create also refuse with code disk_full when the pool's disk has under 1 GiB free after that reclaim.

Inspecting The Graph

dx has no owners, deps, or why command. Use native Bazel. Quote every expression.

bazel query 'rdeps(//..., //pkg:target, 1)'
bazel query 'deps(//pkg:target)'
bazel query 'somepath(//app:server, //lib:core)'
bazel cquery 'deps(//pkg:target)' --platforms=//platforms:linux_x86_64
bazel query 'kind(rule, rdeps(//..., @repo//pkg:target, 1))'

query reads the unconfigured graph. cquery reads the graph for the selected platform and flags. An empty result is not a pass: check the exit code.

Global Flags

Every command takes these:

  • --help (-h): print help.
  • --version (-V): print the dx version.
  • --workspace <dir>: run in another workspace. A relative dir starts from the current directory.
  • --quiet[=bool] (-q), --verbose[=bool] (-v): less or more output. Bare means true, and =false turns off a default inherited from the environment or a config file. Short flags cluster (-qv); repeating -v changes nothing.
  • --color auto|always|never: color output. Default auto.
  • --bazel-startup-option <token>: Bazel startup option, repeatable. Takes --output_base=<path> or --output_user_root=<path>. Inserted before the Bazel verb for every managed invocation. Isolated output bases isolate concurrent runs.

Every other flag belongs to one command family. dx <command> --help lists only the flags that command takes, and a flag from another family is a usage error.

Read commands take --output text|json (-o). -o json, -ojson and short clusters such as -qojson all work. Commands that can print a patch also take diff. Commands that only print text take no --output.

Check commands take --fail-on info|warning|error (default warning) and --report <format>=<dest>. Repeat --report for more files. A relative <dest> anchors at the workspace root wherever dx runs; an absolute <dest> writes outside the workspace as given. Two flags resolving to the same file fail before anything runs, including ./ and parent/../ spellings of one path. Parent directories must already exist. A failed write names the resolved file and the cause, exits nonzero, and keeps the previous file bytes. Each command page lists its report formats. Coverage also takes --min-coverage <percent> and fails below it.

Network commands take --offline and --frozen. --offline means no network: use pinned cached snapshots or fail. --frozen means no manifest or lock resolution changes and may fetch already pinned content. Combine them for both policies. Older dx versions spelled offline as --frozen: pass --offline for that behavior.

--check, --apply, --debug, --release, --here, and --affected are documented on the command pages that take them. --here limits a command to the current directory tree. --affected limits it to targets your changes reach. --apply never combines with --check. Bare --debug or --release means dev, except for deploy, where it means release.

Exit codes: 0 success, 2 usage error, 1 failed check.

A usage error names the offending token, prints a tip: a similar ... line when a near match exists, then prints the usage line above. With no command, the usage error prints the command list and a see dx <command> --help hint.

JSON Output

--output=json writes one JSON object per line to stdout. Every object has an event name and a schema with major and minor. The schema is 3.0. Readers accept any 3.x and reject other majors. The stream starts with one command_started and ends with one command_finished. Diagnostics and logs go to stderr. A failure before the command starts emits error and then command_finished with no command_started; that command_finished carries the process exit code and results_complete: false. If dx cannot build an event, it writes error with code invalid_result, then command_finished, and exits 1. DX_OUTPUT=json and output = "json" in dx.toml give the same stream, also when the rest of dx.toml is invalid. A stop signal ends the run with no error and no command_finished; dx exits 128 plus the signal number. A closed stdout exits 141.

bazel run @rules_dx//:dx -- lint --check //... --output=json

Agent sessions

When CLAUDECODE, CODEX_THREAD_ID, CURSOR_AGENT, GEMINI_CLI, or AI_AGENT is set, the default output is json for commands that support it, and Bazel progress stays off stderr. --output and DX_OUTPUT still win, and so does output in dx.toml. In CI these variables are ignored.

Empty optional fields are left out of test_outcome and build_failure events. A plain pass is not an event. dx test and dx coverage count passed, failed, cached, no_cases, and skipped tests in command_finished and write every test to the results report. Each count is per result, so a target with four shards counts four. Equal build_failure and diagnostic events from different targets and configurations appear once, with a count.

  • command_started: the command and its mode.
  • build_failure: one failure Bazel reported while building, from dx build, dx test, and dx coverage. It has record_version, target, configuration, target_kind, stage, category, action, tool, message, and diagnostics, whose items may carry a related list. Facts Bazel did not report read unknown. The same failure from several targets or configurations is one event with a count and up to 20 other targets.
  • capability: one declared capability, with name, source, availability, detail, flags, outputs, reports, scope_policy, effect, and passthrough.
  • operation: a phase, with the scopes it resolved to.
  • selection: the resolved environment, as setup_id, environment_id, and codegen_id digests.
  • status: one check, with name, status, detail, and hint.
  • slot: one worktree slot, with action, path, state, and server, plus optional slot, branch, holder, reason, base, and disk_bytes. reason says why a slot or worktree was kept.
  • summary: the pool totals from dx worktree list, with slots, servers, and servers_rss_bytes, plus optional disk_bytes and disk_cache_bytes.
  • diagnostic: one finding, with severity, tool, message, location, snapshot, fixable, and count, plus optional rule, path, range, package, resolution, baseline, origins, and related. location is unknown, file, bytes, or lines. A range with lines location adds start_line, start_column, end_line, and end_column, all 1-based. count is how many identical findings this one stands for. baseline is suppressed when the baseline holds the finding. Each of origins names the producer, target, and configuration that reported it. package names the dependency of a vuln or license finding, with name and version, plus fixed for a vulnerability and license for a license finding. An empty fixed list means no fixed version exists.
  • change: one file edit, with path, kind, edit_count, and effect. effect is proposed in check mode, and applied or refused with --apply. A refused change carries a reason. --include-edits adds source_digest and the full edits.
  • phase_started: a dx check or dx fix phase began, with command, phase, mode, and correlation. Every event of the phase carries the same command, phase, and correlation.
  • phase_finished: a phase ended, with its exit_code and counts.
  • notice: a note, with level, code, message, and optional related_command, scope, path, language, and import.
  • report: a report written, with format, path, and results_complete. format is junit, lcov, failures, or results.
  • test_outcome: one test result except a plain pass, with target and outcome, plus optional configuration, status, cached, run, shard, attempt, final, duration_millis, cases_total, cases_failed, cases_error, cases_skipped, cases_omitted, artifacts, and evidence_complete. evidence_complete and artifacts appear only when evidence is missing. final is false only on an attempt that a later attempt replaced. A failing result adds failing_cases (up to 10, each with classname, name, outcome, and a message cut to 1024 bytes), failing_cases_omitted, log_path, and log_tail (the last 40 lines, at most 8 KiB of the test log).
  • error: a failure, with code, message, and optional cause, status, excerpt, truncated, path, flag, and phase. cause is one of input, defaults, query_launch, query, analysis, bazel, test, application, tool_unavailable, collection, report, host, operational, or unknown. bazel_failed has cause bazel; tests_failed has cause test; application_failed is a dx run application that exited nonzero and has cause application. disk_full has cause host: a Bazel run or query exited 36 and a disk it writes to has under 1 GiB free. dx keeps exit 36 and prints a next: line suggesting dx worktree prune --apply. status is the exit code of the failed Bazel run or application. excerpt holds the last lines Bazel printed to stderr, and truncated is true when lines were cut.
  • command_finished: the exit_code, plus any results_complete, run_id, diagnostics, tests, and changes counts. tests has passed, failed, cached, no_cases, and skipped. Each counts results (per target, configuration, run and shard), so a target with four shards counts four. failed counts results whose final attempt has outcome failed or timeout; earlier failed attempts of a retried test still appear as test_outcome events with final: false and are not counted. cached counts the passed tests that came from the cache. no_cases counts passed tests whose report had no test cases; they are also in passed. skipped counts tests with outcome skipped or filtered. changes has create and modify, and with --apply also applied and refused.

dx run and dx update add correlation to their events, so you can group them by target or set.

Environment

Every variable below supplies a default. A flag on the command line wins, and --quiet=false, and --verbose=false turn an inherited default off.

  • DX_WORKSPACE=<dir>: default for --workspace.
  • DX_QUIET=<bool>: default for --quiet.
  • DX_VERBOSE=<bool>: default for --verbose.
  • DX_COLOR=<mode>: default for --color.
  • DX_OUTPUT=<mode>: default for --output.
  • DX_FAIL_ON=<level>: default for --fail-on.
  • NO_COLOR=<any>: disables color in auto mode, even when empty. --color always still colors.
  • BUILD_WORKSPACE_DIRECTORY=<dir>: workspace start under bazel run.
  • BUILD_WORKING_DIRECTORY=<dir>: current directory under bazel run.

A <bool> is on for true and off for false, case-insensitive and trimmed. Any other non-empty value is a usage error. An empty value is unset. A DX_OUTPUT or DX_FAIL_ON value that a command does not support is ignored for that command, as in the config file.

Config File

dx.toml (committed) and dx.local.toml (local-only, gitignored) set the same defaults. A key is the flag name without --.

[dx]
workspace = "/path/to/repo"
quiet = true
verbose = true
color = "never"
output = "json"
fail-on = "error"

Values are TOML, so a boolean key takes true or false. An empty value is unset. Defaults live only under [dx] with the hyphenated flag names. A top-level default or fail_on is a usage error. An unknown key is a usage error that names the key and the keys it accepts. Other supported sections in the same file stay valid.

Unsupported defaults are ignored per command. An output default that a command does not support falls back to the command's default output: json in an agent session for commands with JSON output, else text. For example, with output = "diff" an agent session runs dx test with json. An unsupported fail-on default falls back to warning. The same value as a flag is a usage error. An invalid value is always a usage error.

Commit shared defaults in dx.toml and keep personal overrides in dx.local.toml. Local beats committed. No other file is read for defaults. The nearest file to the working directory wins, and its values sit below the environment. The search reads the working directory and each directory above it, so a neighboring or nested workspace tree never supplies defaults. Under bazel run the search starts at the workspace root. In CI the local file and DX_ variables are ignored. Flags still win. CI never implies --apply.

The command line wins over the environment, and the environment wins over the file. Defaults load from the selected workspace: --workspace or DX_WORKSPACE selects it before the file is read. A workspace key in the file redirects once to that workspace. A relative workspace key starts from the workspace start. -h, --help, help, -V, and --version print without reading a broken config. dx new and dx completion run outside a workspace. Deleting .dx keeps committed behavior. Read-only commands never rewrite config.

Supported Surface

@rules_dx//:dx runs every command.

Language rules:

  • @rules_dx//android/rules:defs.bzl: Android libraries.
  • @rules_dx//apple/ios:app.bzl: iOS apps, tests and frameworks.
  • @rules_dx//apple/ios:defs.bzl: iOS libraries.
  • @rules_dx//cc/rules:defs.bzl: C and C++.
  • @rules_dx//csharp/rules:defs.bzl: C#.
  • @rules_dx//cuda/rules:defs.bzl: CUDA.
  • @rules_dx//cue/rules:defs.bzl: CUE.
  • @rules_dx//elixir/rules:defs.bzl: Elixir.
  • @rules_dx//erlang/rules:defs.bzl: Erlang.
  • @rules_dx//fsharp/rules:defs.bzl: F#.
  • @rules_dx//gherkin/rules:defs.bzl: Gherkin.
  • @rules_dx//gleam/rules:defs.bzl: Gleam.
  • @rules_dx//go/rules:defs.bzl: Go.
  • @rules_dx//html_template/rules:defs.bzl: HTML templates.
  • @rules_dx//java/rules:defs.bzl: Java.
  • @rules_dx//javascript/rules:defs.bzl: JavaScript.
  • @rules_dx//javascript/rules:web.bzl: web apps and dev servers.
  • @rules_dx//jsonnet/rules:defs.bzl: Jsonnet.
  • @rules_dx//kotlin/rules:defs.bzl: Kotlin.
  • @rules_dx//lua/rules:defs.bzl: Lua.
  • @rules_dx//perl/rules:defs.bzl: Perl.
  • @rules_dx//pkl/rules:defs.bzl: Pkl.
  • @rules_dx//powershell/rules:defs.bzl: PowerShell.
  • @rules_dx//python/rules:defs.bzl: Python.
  • @rules_dx//qt/rules:defs.bzl: Qt.
  • @rules_dx//qt/rules:rust.bzl: Rust libraries for Qt.
  • @rules_dx//ruby/rules:defs.bzl: Ruby.
  • @rules_dx//rust/rules:defs.bzl: Rust.
  • @rules_dx//scala/rules:defs.bzl: Scala.
  • @rules_dx//sql/rules:defs.bzl: SQL.
  • @rules_dx//terraform/rules:defs.bzl: Terraform.
  • @rules_dx//typescript/rules:defs.bzl: TypeScript.
  • @rules_dx//vue/rules:defs.bzl: Vue.
  • @rules_dx//xml/rules:defs.bzl: XML.
  • @rules_dx//yaml/rules:defs.bzl: YAML.
  • @rules_dx//zig/rules:defs.bzl: Zig.

Packaging and deploy:

  • @rules_dx//android/packaging:defs.bzl: APKs, App Bundles and adb.
  • @rules_dx//appimage:defs.bzl: AppImage bundles.
  • @rules_dx//apple/ios:device.bzl: signed iOS IPA export.
  • @rules_dx//apple/provisioning:defs.bzl: Apple provisioning.
  • @rules_dx//apple/provisioning:probe.bzl: Apple toolchain probe.
  • @rules_dx//deploy/offline:offline.bzl: offline bundles.
  • @rules_dx//deploy/rules:archive.bzl: release archives.
  • @rules_dx//deploy/rules:cloudflare.bzl: Cloudflare deploys.
  • @rules_dx//deploy/rules:crates.bzl: crates.io publish.
  • @rules_dx//deploy/rules:github.bzl: GitHub releases.
  • @rules_dx//deploy/rules:maven.bzl: Maven publish.
  • @rules_dx//deploy/rules:npm.bzl: npm publish.
  • @rules_dx//deploy/rules:nuget.bzl: NuGet publish.
  • @rules_dx//deploy/rules:oci.bzl: OCI images.
  • @rules_dx//deploy/rules:octopus.bzl: Octopus Deploy.
  • @rules_dx//deploy/rules:promotion.bzl: release promotion.
  • @rules_dx//deploy/rules:pypi.bzl: PyPI publish.
  • @rules_dx//msix:defs.bzl: MSIX packages.

SDK and toolchain extensions:

  • @rules_dx//android/sdk:repos.bzl: Android SDK.
  • @rules_dx//android/toolchain:repos.bzl: Android NDK toolchain.
  • @rules_dx//apple/ios:simulator.bzl: iOS simulator.
  • @rules_dx//elixir/rules:hex.bzl: Hex packages for Elixir.
  • @rules_dx//gleam/rules:hex.bzl: Hex packages for Gleam.
  • @rules_dx//powershell/rules:gallery.bzl: PowerShell Gallery modules.
  • @rules_dx//msix:repos.bzl: MSIX tools.
  • @rules_dx//qt/sdk:repos.bzl: Qt SDK.

API docs:

  • @rules_dx//tools/docs/typescript:defs.bzl: TypeScript API doc shards.

Quality config:

  • @rules_dx//quality:native_config.bzl: native linter and formatter configs.
  • @rules_dx//quality:policy.bzl: quality families and workspace policy.

Every other file stays private. A private file is not a dependency. Patch releases keep every supported label and flag. Minor releases add without breaking old uses. A breaking change needs a new major version and a migration. Readers tolerate added fields and fail on a new major schema. Bazel 9.2.0 is the tested baseline.

rules_dx does not provide debugger acquisition, launch or attach integration, Debug Adapter Protocol adapters, or generated debugger configurations. These are unsupported and not planned. Debug build profiles and editor language-server settings do not imply debugger support. You can set up external debuggers yourself.

Build, Test, And Coverage Commands

bazel run @rules_dx//:dx -- build //...
bazel run @rules_dx//:dx -- test //...
bazel run @rules_dx//:dx -- coverage //...

No scope means //.... Pass a label, pattern, file, or dir to narrow it. Use --here for the current dir tree, or --affected for the targets your git changes reach (see Scope Defaults). File scopes resolve to the owning targets. For dx test and dx coverage, a file runs the tests that depend on its owners. A test tagged manual runs only when it owns the file. Args after -- go to Bazel unchanged. dx build, dx test, dx coverage, dx run and dx deploy also pass --@rules_dx//config:validate=false, --@rules_dx//config:clippy_output_diagnostics=true and --@rules_dx//config:rustc_output_diagnostics=true. Another value for one of them, or for the matching @rules_rust//rust/settings flag, fails before Bazel starts with exit 2. dx spells them with the name your MODULE.bazel gives rules_dx, for example --@my_dx//config:validate=false when repo_name = "my_dx".

Ctrl-C, SIGTERM or SIGHUP stops a run. A SIGHUP that was ignored when dx started, as under nohup, stays ignored. Ctrl-C, and a signal sent to dx itself, even from outside a container, reach Bazel or the app once. On macOS in a terminal, a SIGINT or SIGHUP sent to dx alone is not passed on, and dx stops once Bazel or the app ends; send SIGTERM to stop it right away. Outside a terminal, every process dx started gets it too, and any still running after 10 seconds is killed. A stopped run prints dx: cancelled: stopped by signal N, exits 128 plus the signal number, and emits no command_finished event. The shared Bazel server keeps running. If Bazel dies by a signal dx did not get, dx reports a bazel_signalled error and exits 1.

Outside a terminal, if dx is killed with SIGKILL, Linux stops the process dx started. Anything else in that process group keeps running until the next dx run in that worktree stops it, prints dx: stopped leftover process group <id> from dx run <pid>, which is gone, and with --output=json emits a notice event with code orphan_process_group_stopped, after command_started. On macOS the next run stops the whole tree. In a terminal, a SIGKILL to dx alone leaves its children running. Outside a terminal, when the process dx started exits, dx stops anything still running in its process group. A dx that exits on its own never leaves anything for the next run to stop.

dx build And dx test

dx build [--here] [--affected] [--debug|--release] [--report failures=<path>] [scope...] [-- bazel-options...]
dx test [--here] [--affected] [--debug|--release] [--report junit=<path>] [--report failures=<path>] [--report results=<path>] [scope...] [-- bazel-options...]

Builds or tests the scope. --debug uses the dx_debug profile, --release uses dx_release. No flag uses dx_dev. The two flags conflict. --debug changes compilation only. It does not start or attach a debugger. See Supported Surface. Exec tools build at fastbuild; --release builds them at opt. Extra --config values pass through after the dx profile in their original order. Only a conflicting dx profile is rejected. dx adds its own report flags after your Bazel options, so a --config cannot change them. dx test and dx coverage reject --remote_download_minimal, --remote_download_toplevel and any --remote_download_outputs other than all. dx coverage also rejects any --combined_report other than lcov. dx writes its build event file with --build_event_json_file_path_conversion=false, so it rejects --build_event_json_file and any other --build_event_json_file_path_conversion value. A rejected flag fails before Bazel starts with exit 2.

dx forces these flags and treats them as protected: --skip_incompatible_explicit_targets (build, test and coverage), the three --@rules_dx//config: settings for validation and rustc and clippy diagnostics, --remote_download_outputs=all (test and coverage), --combined_report=lcov (coverage), and --build_event_json_file. Another value for one of them, and Bazel startup options after --, exit 2.

Output: --output text|json. Reports: dx test writes --report junit=<path> JUnit reports. Repeat the flag for more files. Use - for stdout. A relative path anchors at the workspace root; an absolute one writes as given. Colliding destinations fail before anything runs, parents must exist, and a failed write keeps the previous file bytes. JUnit case names gain [run=N] or [config=<id>] when a target has several runs or configurations, and [shard=N,attempt=N] for later shards and attempts. A test.xml with invalid UTF-8 still counts; dx replaces the bad bytes and prints a dx: warning: on stderr. dx test --output=json adds one test_outcome event per test result except a plain pass (passed or no_cases with complete evidence), with its Bazel status, run, shard, attempt, cached state, duration, and case counts. A failing result also carries failing_cases (up to 10, each with classname, name, outcome and message), log_path, and log_tail (the last 40 lines, at most 8 KiB of test.log). artifacts and evidence_complete appear only when evidence is missing. final is false on an attempt that a later attempt of the same target, configuration, run and shard replaced, and appears only then. Plain passes are not events; passed_after_retry is. When dx collected any test result, command_finished has tests, counted once per result (per target, configuration, run and shard, so a target with four shards counts four), with passed (every result that passed, including passed_after_retry and no_cases, matching the passed entries in the results report), failed (results whose final attempt has outcome failed or timeout), cached (passed results that came from the cache), and no_cases (passed results whose report had no test cases; part of passed), and skipped (tests with outcome skipped or filtered). Earlier attempts of a retried test still appear as test_outcome events with final: false and are not counted.

A test that Bazel skipped has outcome skipped. A test that a tag or size filter removed from the run has outcome filtered. Neither changes how the run ends. When any test was filtered, dx emits one notice event with code tests_filtered that gives the count and the first labels. In text mode it prints dx: tests_filtered: ... on stderr.

--report results=<path> writes one JSON file with every test result, passed or not. It follows the same path rules as the other reports. With --output=json and no results report, dx writes it to .dx/results.json in the workspace and emits a report event with the path. A failed write of that default file fails the run, like a failed explicit --report results=<path>: dx keeps Bazel's nonzero code or else exits nonzero, results_complete is false, and a report_failed error is emitted. To write the file somewhere else, pass --report results=<path>. Such a run removes the old .dx/results.json first, even when it stops before Bazel starts. Text runs and runs with a results report leave it alone. The file has:

  • record_version: 1, exit_code: Bazel's code.
  • run_id: identifies the run that wrote the file; command_finished carries the same run_id. Concurrent runs share .dx/results.json, so check that run_id matches command_finished.run_id, or give each run its own path with --report results=<path>.
  • complete: false when results are incomplete.
  • results: one entry per test result, with the test_outcome fields, every case as classname, name, outcome and message, and log_path when the result did not pass. A replaced attempt carries final: false.

dx build, dx test and dx coverage report each failure Bazel records when it exits nonzero. --output=text prints one dx: build failure: line per failure on stderr. A repeated failure lists its target and up to twenty others in that line, then xN, the total across targets and configurations. Its rustc diagnostics follow it. Each diagnostic lists its labels, notes and helps indented below it. --output=json adds one build_failure event per failure, before command_finished. The same failure from several targets or configurations is one event with a count and up to 20 other targets. Each event has:

  • record_version: 1. New fields are only ever added.
  • target, configuration, target_kind (test, other or unknown).
  • stage: analysis, tool_acquisition, compilation, linking, test_execution, other or unknown.
  • count and targets: only when the failure repeats.
  • category: analysis_failed, loading_failed, tool_unavailable, tool_missing, compile_failed, link_failed, test_execution_failed, action_failed, target_not_built, skipped, cancelled or aborted.
  • action: mnemonic, primary_output and, when known, exit_code.
  • tool: clippy for Clippy actions and rustc for other Rust actions.
  • message: the first line of Bazel's reason, for failures with no action.
  • diagnostics: status, total and up to 50 items with severity, code, message, path, line, column and an ordered related list. Each related item has role (label, note or help), message, and when known path, line, column, replacement and applicability. Labels read from rendered output have a line but no column. Related items are not counted in total. Link errors have none.

Anything Bazel did not report reads unknown. An analysis failure has no action, so its action fields are unknown. A test target that fails to build emits a build_failure with target_kind test and no test case in the JUnit report. It also gets a test_outcome with outcome failed_to_build, no case counts, and status FAILED_TO_BUILD only when Bazel reports that summary. The run's results are incomplete. Environment variables and command lines are never reported.

Diagnostics come from rustc's JSON or rendered output in the failed action's stderr. status is parsed when diagnostics were read, none when the stderr held none, unavailable when the stderr is unreadable or named only on a remote cache, and unknown for non-Rust actions. A Rust action whose diagnostics include a linking with error is reported as linking.

The failures report: --report failures=<path> on dx build, dx test and dx coverage writes one JSON file with the failures of this run. It follows the same path rules as the other reports and may share a run with junit or lcov. Use - for stdout in text mode. The file is written on success too, with an empty failures list. A failed write is a report_failed error. It fails a run Bazel passed and never changes Bazel's own code.

The file has:

  • record_version: 1, exit_code: Bazel's code.
  • complete: false when the build events end early or cannot be read, with the reason in problem.
  • truncated: true when any stderr was cut at a limit.
  • failures_omitted: failures past the first 200.
  • failures: the build_failure fields above, each with a stderr object.

stderr has status, bytes, truncated, redacted_lines and, for captured output, text. status is:

  • structured for Rust actions whose diagnostics were parsed. Read diagnostics instead.
  • captured or truncated for other actions. Each failure keeps at most 64 KiB and the file at most 1 MiB. Later failures read omitted_limit.
  • remote when the build events still name the stderr only on a remote cache. dx asks Bazel to name local stderr files, so this is rare.
  • absent when Bazel named no stderr, unreadable when the local file is gone.

Only this run's failed actions are read. No directory is scanned and no history is kept. Lines that start a command line ((cd or exec env) are dropped and counted in redacted_lines. Environment variables and command lines are never reported. Positions come only from rustc diagnostics.

bazel run @rules_dx//:dx -- build --report failures=build-failures.json //...
bazel run @rules_dx//:dx -- test --report junit=junit.xml --report failures=failures.json
bazel run @rules_dx//:dx -- test --report results=results.json //...

In an agent session (CLAUDECODE, CODEX_THREAD_ID, CURSOR_AGENT, GEMINI_CLI or AI_AGENT set), dx build, dx test, dx coverage and dx run default to --output=json. They pass --noshow_progress, --noshow_loading_progress, --noshow_timestamps, --curses=no, --ui_event_filters=-info,-debug and --test_summary=none to Bazel before your own Bazel options, so Bazel prints no progress on stderr. Those quiet Bazel flags apply only when the output is JSON. For dx run, args after -- still go only to the app. --output, DX_OUTPUT and the output setting in dx.toml still win. In CI these variables are ignored.

If Bazel fails and its event file is missing or ends early, dx still returns Bazel's code and reports a build_events_incomplete notice. dx test and dx coverage write the reports they can, and the results report has complete: false. If Bazel succeeds and dx test or dx coverage cannot read the event file, dx exits 1 with unreadable_bep or invalid_bep.

Exit codes: 0 success. 2 usage, scope or owner errors. 1 operational failures, including no_tests and query_launch_failed. Bazel failures keep Bazel's code. Results are incomplete when a result has no test.xml, a test.xml is missing, truncated or invalid, Bazel announces a result it never reports, a target has a summary but no result, or the build events end before Bazel's last event. Another test's evidence never makes up for it. Incomplete results fail the run, even when Bazel passes. --output=text prints dx: incomplete_results: <reason> on stderr, --output=json emits an incomplete_results error, the JUnit report gets a dx.infrastructure error case, and results_complete reads false. Failing tests with every result collected keep Bazel's code 3 and read results_complete: true. Other Bazel failures read false. With exit 3 and at least one failed result, --output=json emits a tests_failed error with cause test and Bazel's status. Other Bazel failures emit bazel_failed. dx coverage does the same.

bazel run @rules_dx//:dx -- build //cli/...
bazel run @rules_dx//:dx -- test --here
bazel run @rules_dx//:dx -- test --affected
bazel run @rules_dx//:dx -- test //... -- --jobs=4
bazel run @rules_dx//:dx -- test //cli/process/... -- --test_filter=workflow_argv

--affected builds or tests the changed targets and their reverse dependencies. A Cargo.lock or crate_universe lockfile change selects the users of the changed crates. Other lock files and patches named in MODULE.bazel widen to //...; see scope defaults. dx test --affected and dx coverage --affected run only the affected test targets. With nothing affected they print no affected targets and exit 0. Usage errors exit 2. Affected targets that do not fit the host platform are skipped; see Scope Defaults.

Args after -- reach the test binary through Bazel. dx test and dx coverage accept repeatable --test_arg tokens, in equals and two-token form, with spaces and Unicode kept verbatim. --test_filter stays a Bazel option. When the selected tests are rust_test targets, optionally with their rustfmt_test and rust_clippy_test checks, dx also passes the last --test_filter value to the test binaries as a libtest filter, so only matching cases run. Other commands reject --test_arg; run those cases with bazel directly.

Tests run once by default. Only tests marked flaky in their build definition retry, up to three attempts. Diagnose one test with retries through dx test <label> -- --flaky_test_attempts=3, or through bazel test --config=retry <label>. A test that passes after retry is reported as passed_after_retry with its attempt number.

dx run

dx run [--debug|--release] <label...> [-- args...]

Builds the runnable targets in scope, then launches them in scope order. Explicit labels and patterns run sequentially. File and directory scopes must resolve to exactly one runnable target. Args after -- go to the app. The default profile is development; --release selects release. dx run works with CI=true. dx run --apply is a usage error. The build holds a build slot. The slot frees before the app starts.

Output: --output text|json. dx run has no report format. With --output=json, an app that exits nonzero emits an error with code application_failed, cause application, the app's exit code as status, and correlation run:<label>. A failed build emits an error with code bazel_failed and starts no app.

Exit codes: 0 success, 2 usage or scope errors, 1 no runnable target or a launch failure, or a file or directory scope that matches more than one runnable target (ambiguous). A failed build keeps Bazel's exit code. The app's own code is the exit code, and the first app that exits nonzero stops the later targets.

bazel run @rules_dx//:dx -- run @rules_dx//:dx -- --help
bazel run @rules_dx//:dx -- run --release @rules_dx//:dx -- --help

dx deploy

dx deploy [--apply] [--debug|--release] <label> [-- args...]

Builds one deployable target without publishing it. Pass --apply to run the deployment. Takes exactly one label. The profile is the flag if given, else the target's profile attribute, else the command default. With --apply, dx sets DX_PROFILE for the deploy tool. Args after -- go to the app, and only reach it on an --apply launch.

Output: text only.

Exit codes: 0 success, 2 usage or scope errors, 1 a build or launch failure. The target's own code is the exit code. A passing check names the --apply command that publishes.

dx coverage

dx coverage [--here] [--affected] [--min-coverage <percent>] [--report lcov=<path>] [--report failures=<path>] [--report results=<path>] [scope...] [-- bazel-options...]

Collects LCOV coverage over the scope. --min-coverage takes a whole number from 0 to 100 and fails below that percent. The gate compares one line rate (covered over executable lines, across all files) against it. Without the flag, coverage collects without enforcing. A passing gate prints coverage <percent>% (<covered>/<eligible> lines) meets minimum <n>%. A failing one prints coverage_below_minimum: coverage <percent>% (<covered>/<eligible> lines) below minimum <n>% on stderr, or emits an error event with code coverage_below_minimum with --output=json. A collected LCOV with zero executable lines also fails. C and C++ tests report line coverage through LLVM, with no setup needed.

Output: --output text|json. Reports: --report lcov=<path> writes combined LCOV. Repeat the flag for more files. Use - for stdout. A relative path anchors at the workspace root; an absolute one writes as given. Colliding destinations fail before anything runs, parents must exist, and a failed write keeps the previous file bytes. dx coverage --output=json adds one test_outcome event per test result except a plain pass, as for dx test. A failing result carries failing_cases, log_path and log_tail as in dx test; a missing or invalid test.xml there does not make coverage incomplete. command_finished counts tests as for dx test.

--report failures=<path> and --report results=<path> work here too, as described above.

Coverage sources

dx coverage also lists the production sources each cell owns. The source list comes from the instrumented files of the targets in scope, so --instrumentation_filter decides which files count. Each source gets one disposition:

  • eligible: a production source in a covered language that the LCOV reports with executable lines.
  • support: a test or testonly source, a TypeScript declaration file (.d.ts, .d.mts, .d.cts), a source the LCOV reports with no executable lines, or a C or C++ header the LCOV does not report.
  • excluded: a source the LCOV does not report, with one valid LCOV_EXCL_START / LCOV_EXCL_STOP range over every line that is not blank or a comment.
  • unsupported: a source in a language the gate does not read. These are counted, never dropped.

In text mode dx prints one counts line on stderr. With --output=json the same text is the message of a notice event with code coverage_sources and level info:

dx: coverage sources <cell>: <raw> raw, <excluded> excluded, <eligible> eligible lines; <n> eligible, <n> support, <n> excluded, <n> unsupported files

<cell> is <os>_<cpu>, such as linux_x86_64. A production source missing from the LCOV, or an LCOV path no target owns, prints dx: coverage_sources_incomplete: <message> on stderr, or emits a notice event with code coverage_sources_incomplete and level warning with --output=json. These notices do not change the exit code. --build_tests_only is refused, because it hides library targets from the source list. dx coverage also passes --build_tests_only=false after your options, so a .bazelrc entry or --config that turns it on has no effect.

Exit codes: 0 success. 2 usage, scope or owner errors. 1 operational failures, coverage below minimum, or incomplete coverage. Bazel failures keep Bazel's code. Coverage is incomplete when Bazel reports a coverage.dat artifact that is missing or invalid, when no coverage artifacts are reported, when a target's coverage source list is missing or unreadable, or when the build events are incomplete as for dx test. Incomplete coverage fails the run, blocks --min-coverage, and marks a written LCOV report incomplete. A retried test counts only its final attempt. Tests without coverage outputs are not expected to produce coverage. Empty but valid coverage.dat files are accepted and contribute no lines.

bazel run @rules_dx//:dx -- coverage //...
bazel run @rules_dx//:dx -- coverage --min-coverage 100 //...

Coverage ignores use LCOV_EXCL_LINE or LCOV_EXCL_START / LCOV_EXCL_STOP with a short reason: and issue:. Bare ignores without a reason fail the gate. reason: goes on the marker line or the line before and holds at most 120 characters. issue: must contain a digit.

The comment syntax depends on the language: // for Rust, Go, JS/TS, the JVM languages (including Kotlin scripts, .kts), .NET and C/C++; # for Python and Ruby; % for Erlang (.erl and .hrl). Markers in other sources (.bzl, .pyi, .cu/.cuh, .d.ts) are not read.

Line And Branch Metrics

This section describes //tools/coverage:coverage_bin, not dx coverage.

The coverage gate enforces exact executable-line coverage. Branch outcomes are measured separately and never change the gate. Each file with branch records reports raw, ignored, eligible and taken counts with its language. Branches on lines covered by LCOV_EXCL_LINE or a START/STOP range are counted as ignored and are left out of eligible, taken, missed and the rate. The declared branch summary is still checked against all records. Files without branch records report unavailable instead of a rate. A declared branch summary that does not match its records fails the report as incomplete.

Quality Commands

bazel run @rules_dx//:dx -- lint //...
bazel run @rules_dx//:dx -- typecheck //...
bazel run @rules_dx//:dx -- format //...

No scope means //.... Use --here for the current dir tree, or --affected for the targets your git changes reach (see Scope Defaults). With nothing affected they print no affected targets and exit 0. All three check files by default and report drift without writing. Pass --apply to write validated fixes. Args after -- go to Bazel, and dx always adds --keep_going and --remote_download_outputs=all after them. All three also pass --@rules_dx//config:validate=false, --@rules_dx//config:clippy_output_diagnostics=true and --@rules_dx//config:rustc_output_diagnostics=true. Another value for one of them, or for the matching @rules_rust//rust/settings flag, fails before Bazel starts with exit 2. dx spells them with the name your MODULE.bazel gives rules_dx, for example --@my_dx//config:validate=false when repo_name = "my_dx". These args fail the same way: --aspects, --output_groups, --build_event_json_file, any --build_event_json_file_path_conversion other than false, --nokeep_going, any --keep_going other than bare --keep_going, --remote_download_minimal, --remote_download_toplevel, any --remote_download_outputs other than all, --test_arg, and the startup options --bazelrc, --home_rc, --nohome_rc, --system_rc, --nosystem_rc, --output_base, --output_user_root, --host_jvm_args and --server_jvm_out. Pass --output_base or --output_user_root with --bazel-startup-option instead. With --@rules_rust//rust/settings:pipelined_compilation=true in your .bazelrc, dx typecheck reads library diagnostics from the metadata build and skips their codegen.

dx lint [--here] [--affected] [--check] [--apply] [--fail-on info|warning|error] [--output text|diff|json] [--include-edits] [--report sarif=<dest>] [scope...] [-- bazel-options ...]
dx typecheck [--here] [--affected] [--check] [--apply] [--fail-on info|warning|error] [--output text|diff|json] [--include-edits] [--report sarif=<dest>] [scope...] [-- bazel-options ...]
dx format [--here] [--affected] [--check] [--apply] [--fail-on info|warning|error] [--output text|diff|json] [--include-edits] [scope...] [-- bazel-options ...]
  • --check: report findings without writing files. Same as the default.
  • --apply: write validated fixes and refresh the configured baseline. Without it files stay untouched. After --apply, findings point at files as written. Unwritten files keep their checked positions.
  • --fail-on info|warning|error: severity that fails. Default warning.
  • --report sarif=<dest>: write a SARIF report for lint and typecheck. Repeatable. dx format has no report format. A relative destination anchors at the workspace root; an absolute one writes as given. Colliding destinations fail before anything runs, parents must exist, and a failed write keeps the previous file bytes. Use - for stdout. Only one stdout report is allowed, and only with text output: --output json or --output diff exits 2. With a stdout report, text findings and summaries go to stderr. SARIF results carry partialFingerprints (dx/v1), ruleIndex, and fixes for machine-applicable replacements. Runs set columnKind to unicodeCodePoints.
  • dx lint without --apply also fails with exit 1 when the checkout holds build output or a file over 1 MiB, as described for dx check.
  • --output text|diff|json: result shape.
  • --include-edits: add source_digest and full edits to JSON change events. Needs --output json; otherwise exits 2.

Text prints one line per finding: path:line:column: severity: message [tool/rule]. Lines and columns count Unicode characters in the checked file. A finding without a verified position prints path:; a finding without a file prints (no file):. Identical findings print once with (xN). --quiet never hides findings or failures.

--output diff prints a unified patch of the proposed fixes on stdout and no finding lines. Not applied:, Conflict: and Baseline stale: lines go to stderr.

When a check finds changes, text prints N file(s) need changes. Apply them with: dx <command> --apply ... with your workspace, scope, and Bazel options quoted for the shell. Credential-bearing options are left out. Failures print a next: line with the fix and a check command to run again. Inside dx check and dx fix, these lines name dx fix --apply or the umbrella command with your scope instead.

JSON prints one compact change per file with path, kind, edit_count, and effect (proposed, applied, or refused with a reason). The reason is stale_source, unreadable_source, invalid_edits, incomplete_collection, conflicting_digests or conflicting_candidates. Identical findings from several targets print once, with a count and every target in origins.

When several targets fix the same file, identical fixes apply once. Fixes that disagree leave the file unchanged and print a Conflict: line naming the targets on stderr. JSON reports a change with effect refused and reason conflicting_candidates or conflicting_digests. Other files still apply.

The result set is incomplete when a target fails, a result is invalid, Bazel announces a target or aspect it never reports, an output group is marked incomplete, or the build events end before Bazel's last event. Targets Bazel skips as incompatible are not missing. lint, typecheck, and format skip targets that do not fit the target platform, even named or affected ones; see Scope Defaults. An incomplete run prints dx: incomplete_results: <reason> on stderr, or an incomplete_results error event in JSON, and never applies fixes.

A failed Bazel build lists each failed action the way dx build does: a build_failure event in JSON, or a dx: build failure: line on stderr. A Rust compile error in a Clippy or Rustc action carries its path, line, column and message; see dx build. When every failed action is a compile error, the run reports bazel_failed with <command> build failed with Bazel exit <n>; results are incomplete, and text output adds next: fix the compile errors above, then run: .... Other failed builds report analysis_failed. The results are incomplete either way.

A target whose fixes oscillate or hit the ten-round limit did not converge. The run fails even without findings and prints dx: not_converged: <target> in configuration <id> did not converge: <cause> after <n> round(s) of <tools> on stderr, or a not_converged error event in JSON. The cause is OSCILLATION or ITERATION_LIMIT. SARIF marks that tool's run executionSuccessful: false with the same message. Such a target proposes no fixes, and --apply does not refresh the baseline. Fixes from other targets still apply.

Exit codes: 0 success, 2 usage or scope errors, 1 findings at or above --fail-on, a check (no --apply) with any proposed change, whatever --fail-on says, an incomplete result set, a target that did not converge, a conflicting fix, a failed report write, or a missing, malformed, or stale baseline. Bazel failures report 1, not Bazel's code.

bazel run @rules_dx//:dx -- lint --check //...
bazel run @rules_dx//:dx -- format --here
bazel run @rules_dx//:dx -- lint --affected
bazel run @rules_dx//:dx -- typecheck --fail-on error //cli/...
bazel run @rules_dx//:dx -- format --apply //cli/...

Policy

The three commands check with the workspace policy that --@rules_dx//config:workspace names. Without that flag the policy is @rules_dx//quality:default_workspace_policy. Pass another target after -- to select your own.

bazel run @rules_dx//:dx -- lint -- --@rules_dx//config:workspace=//quality:my_policy
bazel run @rules_dx//:dx -- format --check //... -- --@rules_dx//config:workspace=//quality:my_policy

A policy is a workspace_policy of quality_family sections, loaded from @rules_dx//quality:policy.bzl. Every family states each capability it uses: a tool ID, or the capability in disabled.

load("@rules_dx//quality:policy.bzl", "quality_family", "workspace_policy")

quality_family(
    name = "toml_family",
    family_id = "toml",
    disabled = ["typecheck"],
    format = ["taplo"],
    lint = ["taplo"],
)

workspace_policy(
    name = "my_policy",
    families = [":toml_family"],
)
  • A family that leaves a capability neither selected nor disabled fails the analysis with the capability named.
  • A family that selects a tool with no wired executable fails with the wired tools listed for that capability.
  • disabled = ["format"] runs no formatter for that family.

Default policy

default_workspace_policy selects these tools. A capability marked disabled runs no tool for that family.

FamilyFormatLintTypecheck
ccclang_formatdisableddisabled
graphqlprettierdisableddisabled
htmlprettierdisableddisabled
javagoogle_java_formatcheckstyle, pmd, spotbugsdisabled
javascriptbiomebiomedisabled
jsonprettierbiomedisabled
kotlinktfmtktlintdisabled
markdowndisabledmarkdown_check, valedisabled
pythonruffpydoclint, ruffty
rustrustfmtclippyrustc
scalascalafmtdisableddisabled
shellshfmtshellcheckdisabled
starlarkbuildifierbuildifierdisabled
textdisabledkeep_sorteddisabled
tomltaplotaplodisabled
typescriptbiomebiomedisabled

Wired tools

Every wired tool, with the verbs it serves and the families its classes belong to.

Tool IDVerbsFamilies
biomeformat, lintjavascript, json, typescript
bufformat, lintprotobuf
buildifierformat, lintstarlark
checkstylelintjava
clang_formatformatcc, cuda
clang_tidylintcc
clippylintrust
cppchecklintcc
csharpierformatcsharp
cueformatcue
djlintformat, linthtml_template
erlfmtformaterlang
errchecklintgo
eslintlintjavascript
fantomasformatfsharp
flake8lintpython
fsharplintlintfsharp
gleam_formatformatgleam
gofumptformatgo
google_java_formatformatjava
govetlintgo
jsonnetfmtformatjsonnet
keep_sortedlinttext
ktfmtformatkotlin
ktlintlintkotlin
markdown_checklintmarkdown
mix_formatformatelixir
modfmtformatgo_module
pklformatpkl
pmdlintjava
prettierformatcss, gherkin, graphql, html, javascript, json, sql, typescript, xml
psscriptanalyzerlintpowershell
pydoclintlintpython
pylintlintpython
qmlformatformatqml
qmllintlintqml
roslynlintcsharp
rubocoplintruby
ruffaudit, format, lintpython
rustctypecheckrust
rustfmtformatrust
scalafixlintscala
scalafmtformatscala
shellchecklintshell
shfmtformatshell
spotbugslintjava
standardrbformatruby
staticchecklintgo
stylelintlintcss
taploformat, linttoml
terraformformatterraform
tytypecheckpython
valelintmarkdown
yamlfmtformatyaml
yamllintlintyaml
zig_fmtformatzig

OCaml (ocaml) has no formatter and no linter. OCaml targets are tagged no-format, no-lint and no-typecheck, so dx format, dx lint and dx typecheck skip them.

Tool overrides

The three commands run every wired tool from its managed executable. Pass another target after -- to select your own build of one tool. The override keeps the tool ID, so findings read the same.

bazel run @rules_dx//:dx -- lint -- --@rules_dx//config:tool_ruff=//tools:my_ruff
  • ruff, terraform, qmlformat, qmllint, zig_fmt, erlfmt, mix_format and gleam_format have override flags.
  • The ruff and terraform alternates must be a single executable file. Anything else fails the analysis with the tool named.
  • The qmlformat, qmllint, zig_fmt, erlfmt, mix_format and gleam_format alternates must be executable targets, such as @dx_qt//:qmlformat, @dx_qt//:qmllint, @rules_dx//zig/tools:zig, @rules_dx//erlang/tools:erlfmt, @rules_dx//elixir/tools:mix_format and @rules_dx//gleam/tools:gleam_format. A target without an executable fails the analysis.
  • An override that prints another diagnostic shape fails the run with the tool named.

QML formatting

qmlformat has no default executable. Enable the managed Qt SDK from qt/README.md, select qmlformat in a qml family, and point the flag at the SDK tool.

bazel run @rules_dx//:dx -- format -- --@rules_dx//config:tool_qmlformat=@dx_qt//:qmlformat
bazel run @rules_dx//:dx -- format --apply -- --@rules_dx//config:tool_qmlformat=@dx_qt//:qmlformat

Without the flag, a policy that selects qmlformat fails the analysis and names the flag. To set formatting options, bind a qmlformat_config to the QML targets with aspect_hints. The file must be named .qmlformat.ini. dx passes it to qmlformat with -s.

Zig formatting

zig_fmt uses the pinned Zig compiler that zig_library, zig_binary and zig_test carry. Select zig_fmt in a zig family. No flag is needed.

quality_family(
    name = "zig_family",
    family_id = "zig",
    disabled = ["audit", "lint", "typecheck"],
    format = ["zig_fmt"],
)
bazel run @rules_dx//:dx -- format
bazel run @rules_dx//:dx -- format --apply

To use your own compiler, set the flag:

bazel run @rules_dx//:dx -- format -- --@rules_dx//config:tool_zig_fmt=@rules_dx//zig/tools:zig

A Zig-class target that is not one of those three wrappers fails the analysis and names the flag. The check runs zig fmt --check and never writes. --apply runs zig fmt. A file with a syntax error reports an error at its line and column and stays untouched. Use the wrappers from @rules_dx//zig/rules:defs.bzl.

Erlang formatting

erlfmt has no default executable. Select erlfmt in an erlang family and point the flag at the pinned erlfmt 1.8.0 escript.

quality_family(
    name = "erlang_family",
    family_id = "erlang",
    disabled = ["audit", "lint", "typecheck"],
    format = ["erlfmt"],
)
bazel run @rules_dx//:dx -- format -- --@rules_dx//config:tool_erlfmt=@rules_dx//erlang/tools:erlfmt
bazel run @rules_dx//:dx -- format --apply -- --@rules_dx//config:tool_erlfmt=@rules_dx//erlang/tools:erlfmt

Without the flag, a policy that selects erlfmt fails the analysis and names the flag. The check runs erlfmt --check and never writes. --apply runs erlfmt --write. A file with a syntax error reports an error at its line and column and stays untouched. A %% erlfmt-ignore comment keeps the next form as written. Erlang sources come from the rules in @rules_dx//erlang/rules:defs.bzl.

Elixir formatting

mix_format has no default executable. Select mix_format in an elixir family and point the flag at the pinned Elixir 1.20.4 formatter.

quality_family(
    name = "elixir_family",
    family_id = "elixir",
    disabled = ["audit", "lint", "typecheck"],
    format = ["mix_format"],
)
bazel run @rules_dx//:dx -- format -- --@rules_dx//config:tool_mix_format=@rules_dx//elixir/tools:mix_format
bazel run @rules_dx//:dx -- format --apply -- --@rules_dx//config:tool_mix_format=@rules_dx//elixir/tools:mix_format

Without the flag, a policy that selects mix_format fails the analysis and names the flag. The check runs mix format --check-formatted with the default formatter options and never writes. --apply runs mix format. A file with a syntax error reports an error at its line and column and stays untouched. The formatter reads no .formatter.exs, Mix project or Hex home. Elixir sources come from the rules in @rules_dx//elixir/rules:defs.bzl.

Gleam formatting

gleam_format has no default executable. Select gleam_format in a gleam family and point the flag at the pinned Gleam 1.19.2 formatter.

quality_family(
    name = "gleam_family",
    family_id = "gleam",
    disabled = ["audit", "lint", "typecheck"],
    format = ["gleam_format"],
)
bazel run @rules_dx//:dx -- format -- --@rules_dx//config:tool_gleam_format=@rules_dx//gleam/tools:gleam_format
bazel run @rules_dx//:dx -- format --apply -- --@rules_dx//config:tool_gleam_format=@rules_dx//gleam/tools:gleam_format

Without the flag, a policy that selects gleam_format fails the analysis and names the flag. The check runs gleam format --check and never writes. --apply runs gleam format. A file with a syntax error reports an error at its line and column and stays untouched. The formatter reads no gleam.toml or Hex cache. Gleam sources come from the rules in @rules_dx//gleam/rules:defs.bzl.

QML linting

qmllint has no default executable. Enable the managed Qt SDK from qt/README.md, select qmllint in a qml family, and point the flag at the SDK tool.

bazel run @rules_dx//:dx -- lint -- --@rules_dx//config:tool_qmllint=@dx_qt//:qmllint

Without the flag, a policy that selects qmllint fails the analysis and names the flag. Findings keep the line and column qmllint reports. Info, warning and critical messages show as info, warning and error. A file that qmllint does not report fails the run.

Import context comes from the qmldir files in the QML target and all its transitive deps, including external repositories. dx stages them and passes their module roots with -I. A qmldir whose directory does not end in its module URI is not importable and is skipped. QML_IMPORT_PATH is not used. To set lint options, bind a config ending in .qmllint.ini to the QML targets with aspect_hints. dx copies it next to each linted file as .qmllint.ini. Without one, dx runs qmllint with --ignore-settings.

Java lint

Java lint runs checkstyle, pmd and spotbugs.

checkstyle has no default ruleset. Bind a checkstyle_config to each Java target, or lint fails. pmd uses rulesets/java/quickstart.xml unless a pmd_config is bound. For both, src must be a checked-in .xml file and data lists any extra files it reads. A target takes at most one config per tool.

load("@rules_java//java:defs.bzl", "java_library")
load("@rules_dx//quality:native_config.bzl", "checkstyle_config", "pmd_config")

checkstyle_config(
    name = "checkstyle_cfg",
    src = "checkstyle.xml",
)

pmd_config(
    name = "pmd_cfg",
    src = "pmd-ruleset.xml",
)

java_library(
    name = "lib",
    srcs = ["Lib.java"],
    aspect_hints = [
        ":checkstyle_cfg",
        ":pmd_cfg",
    ],
)

Scala linting

scalafix runs from the managed pin. It is not in the default Scala lint set. Select it in a scala family.

quality_family(
    name = "scala_family",
    family_id = "scala",
    disabled = ["audit", "format", "typecheck"],
    lint = ["scalafix"],
)

Register the SemanticDB toolchain in MODULE.bazel:

register_toolchains("@rules_dx//scala/toolchain:semanticdb")

That toolchain is a plain scala_toolchain. If you already register your own, keep it and set these two attributes on it instead:

scala_toolchain(
    name = "my_scala_toolchain",
    enable_semanticdb = True,
    semanticdb_bundle_in_jar = True,
)

Without one of the two, the analysis fails and names the fix. Bind a scalafix_config to every Scala target with aspect_hints. The file is your .scalafix.conf. A Scala target without one fails the analysis.

bazel run @rules_dx//:dx -- lint //app/...
bazel run @rules_dx//:dx -- lint --apply //app/...

dx gives scalafix the compiled SemanticDB of the target and its dependencies, so semantic rules such as ExplicitResultTypes work. This includes compile-only (neverlink) dependencies and test targets. Lint messages keep the rule ID, line and column. A rule that rewrites code shows as a Patch error at the first changed line, and --apply writes the rewrite. A run that exits with a parse error, missing SemanticDB, an unknown rule or an empty rule list fails with the cause named. It never counts as clean.

C# formatting

csharpier runs from the managed pin. Select it in a csharp family.

quality_family(
    name = "csharp_family",
    family_id = "csharp",
    disabled = ["audit", "lint", "typecheck"],
    format = ["csharpier"],
)
bazel run @rules_dx//:dx -- format //app/...
bazel run @rules_dx//:dx -- format --apply //app/...

A check never writes. --apply rewrites only the C# files the selected targets own. To change options, bind a csharpier_config to the C# targets with aspect_hints. An unformatted file is reported at the start of the file. A syntax error is reported at its line and column with its compiler code and never counts as clean.

C# lint

roslyn compiles the target again with the SDK analyzers and reports their findings. The compile uses the target's own sources, references, defines and compiler options. It needs no install step. Select it in a csharp family.

quality_family(
    name = "csharp_family",
    family_id = "csharp",
    disabled = ["audit", "typecheck"],
    format = ["csharpier"],
    lint = ["roslyn"],
)
bazel run @rules_dx//:dx -- lint //app/...

The analysis uses the target framework, references, and analyzers the target builds with. The default is the SDK analysis level for that framework. To change severities, pass a .globalconfig file in analyzer_configs on the C# target. A compile error is reported at its line and column with its compiler code and never counts as clean. Findings in generated files under bazel-out or in external repositories are not reported, but a compile error there still fails the check. An analyzer that fails to run fails the check. Diagnostics suppressed in source with #pragma warning disable or [SuppressMessage] are not reported.

Roslyn is check only. lint --apply changes no files. Supported on linux_x86_64.

F# formatting and lint

fantomas formats and fsharplint lints. Both run from the managed pin with no install step. Select them in an fsharp family.

quality_family(
    name = "fsharp_family",
    family_id = "fsharp",
    disabled = ["audit", "typecheck"],
    format = ["fantomas"],
    lint = ["fsharplint"],
)
bazel run @rules_dx//:dx -- format //app/...
bazel run @rules_dx//:dx -- format --apply //app/...
bazel run @rules_dx//:dx -- lint //app/...

A format check never writes. --apply rewrites only the F# files the selected targets own. An unformatted file is reported at the start of the file. fsharplint has no fixes, so lint --apply changes nothing.

fsharplint checks each target with its sources in compile order, its dependency assemblies, its defines and its langversion. Rules that need type information see the same code the compiler sees. Findings keep the FL rule ID, line and column.

To change lint rules, bind an fsharplint_config to the F# targets with aspect_hints.

load("@rules_dx//quality:native_config.bzl", "fsharplint_config")

fsharplint_config(
    name = "relaxed",
    src = "relaxed.fsharplint.json",
    visibility = ["//visibility:public"],
)

fsharp_library(
    name = "core",
    aspect_hints = ["//:relaxed"],
    srcs = ["Greeter.fs"],
)

A syntax error is reported by both tools as an error at its line and column with its FS compiler code. It never counts as clean. A tool that fails to run, or prints output dx cannot read, fails the run with the cause named.

Protocol Buffers

buf runs from the managed pin for format and lint. Select it in a protobuf family. It runs on Linux x86_64 and needs diff in /usr/bin or /bin.

quality_family(
    name = "protobuf_family",
    family_id = "protobuf",
    format = ["buf"],
    lint = ["buf"],
)
bazel run @rules_dx//:dx -- format --apply //api/...
bazel run @rules_dx//:dx -- lint //api/...

Targets are proto_library rules. Imports resolve through the target's deps. A check never writes. --apply rewrites only the .proto files the selected targets own. Lint uses the upstream STANDARD rules and reports each finding at its line and column with the rule ID. A syntax error or an import that does not resolve fails format and lint with the cause named and never counts as clean. A syntax error in an imported .proto fails lint of every target that imports it, with that file, line and column named. An unformatted file is reported at the start of the file. To change the rules, bind a buf_config to the proto_library targets with aspect_hints. The file is your buf.yaml.

CUE

cue fmt runs from the managed pin for format. Select it in a cue family. It runs on Linux x86_64.

quality_family(
    name = "cue_family",
    family_id = "cue",
    format = ["cue"],
)
bazel run @rules_dx//:dx -- format //config/...
bazel run @rules_dx//:dx -- format --apply //config/...

Own CUE files with cue_library from @rules_dx//cue/rules:defs.bzl. List the package files in srcs and the libraries it imports in deps. Put the cue.mod/module.cue of your module in the srcs of a cue_library and list that library in deps. Both are staged as import context. A check never writes. --apply rewrites only the .cue files the selected targets own. An unformatted file is reported at the start of the file. A syntax error fails the run with the file, line and column. It never counts as clean. cue takes no config file.

Jsonnet

jsonnetfmt runs from the managed pin for format. Select it in a jsonnet family. It runs on Linux x86_64.

quality_family(
    name = "jsonnet_family",
    family_id = "jsonnet",
    format = ["jsonnetfmt"],
)
bazel run @rules_dx//:dx -- format //config/...
bazel run @rules_dx//:dx -- format --apply //config/...

Own Jsonnet files with jsonnet_library from @rules_dx//jsonnet/rules:defs.bzl. List the .jsonnet and .libsonnet files of the package in srcs and the libraries it imports in deps. The imported libraries are staged beside the entry file. A check never writes. --apply rewrites only the files the selected targets own. An unformatted file is reported at the start of the file. A syntax error fails the run with the file, line and column. It never counts as clean. jsonnetfmt stops at the first syntax error, so fix it and run again to see the next one. jsonnetfmt takes no config file.

Pkl

pkl format runs from the managed pin for format. Select it in a pkl family. It runs on Linux x86_64.

quality_family(
    name = "pkl_family",
    family_id = "pkl",
    format = ["pkl"],
)
bazel run @rules_dx//:dx -- format //config/...
bazel run @rules_dx//:dx -- format --apply //config/...

Own Pkl files with pkl_library from @rules_dx//pkl/rules:defs.bzl. List the .pkl files of the package in srcs and the libraries it imports in deps. A check never writes. --apply rewrites only the files the selected targets own. An unformatted file is reported at the start of the file. A syntax error fails the run with the file, line and column. It never counts as clean. pkl format checks syntax only and does not resolve imports. It takes no config file.

To use another Pkl executable, point tool_pkl at it.

bazel run @rules_dx//:dx -- format -- --@rules_dx//config:tool_pkl=//tools:pkl

Terraform

terraform fmt runs from the managed pin for format. Select it in a terraform family. It runs on Linux x86_64. It never runs init, reads state or contacts a backend.

quality_family(
    name = "terraform_family",
    family_id = "terraform",
    format = ["terraform"],
)
bazel run @rules_dx//:dx -- format //infra/...
bazel run @rules_dx//:dx -- format --apply //infra/...

Own Terraform files with terraform_module from @rules_dx//terraform/rules:defs.bzl. List the .tf, .tfvars, .tftest.hcl and .tfmock.hcl files of the module in srcs and the modules it calls in deps. A check never writes. --apply rewrites only the files the selected targets own. An unformatted file is reported at the start of the file. A syntax error fails the run with the file and line. It never counts as clean. terraform fmt takes no config file.

To use OpenTofu, point tool_terraform at a tofu executable file. The findings keep the terraform tool ID.

bazel run @rules_dx//:dx -- format -- --@rules_dx//config:tool_terraform=@tofu//:tofu

CSS, SCSS and Less

stylelint runs from the managed pin for lint. It runs on Linux x86_64. Select it in the css family. Format stays with prettier.

quality_family(
    name = "css_family",
    family_id = "css",
    format = ["prettier"],
    lint = ["stylelint"],
)
bazel run @rules_dx//:dx -- lint //web/...
bazel run @rules_dx//:dx -- format --apply //web/...

Own stylesheets with javascript_library. It accepts .css, .scss and .less in srcs. Lint is check-only: lint --apply never rewrites a stylesheet. Each finding shows its line and column with the rule name. The default rules are the Stylelint correctness checks. They accept CSS Modules :global, :local, :export, :import and composes. SCSS and Less files parse with postcss-scss and postcss-less.

A syntax error, an invalid config or a tool that fails to run fails the run with the cause named. It never counts as clean. To change the rules, bind a stylelint_config to the library with aspect_hints. The file is a JSON config. It replaces the default rules, so add an overrides entry with customSyntax for *.scss and *.less.

HTML, GraphQL, JSON5 and JSONC

prettier runs from the managed pin for format. It runs on Linux x86_64. Select it in the html, graphql and json families. JSON5 and JSONC files belong to the json family.

quality_family(
    name = "graphql_family",
    disabled = ["lint", "typecheck"],
    family_id = "graphql",
    format = ["prettier"],
)
bazel run @rules_dx//:dx -- format //web/...
bazel run @rules_dx//:dx -- format --apply //web/...

Own these files with javascript_library. It accepts .html, .graphql, .gql, .json5 and .jsonc in srcs. A check never writes. --apply rewrites only the files the selected targets own. Comments in JSON5 and JSONC stay, and both gain trailing commas. Scripts inside HTML are formatted too. An unformatted file is reported at the start of the file. A syntax error fails the run with the file, line and column. It never counts as clean. prettier runs with its default options for these files. .prettierrc, .editorconfig and other user config files are ignored.

SQL, XML and Gherkin

prettier runs from the managed pin with the prettier-plugin-sql, @prettier/plugin-xml and prettier-plugin-gherkin plugins for format. It runs on Linux x86_64. Select it in the sql, xml and gherkin families.

quality_family(
    name = "sql_family",
    disabled = ["lint", "typecheck"],
    family_id = "sql",
    format = ["prettier"],
)
bazel run @rules_dx//:dx -- format //db/...
bazel run @rules_dx//:dx -- format --apply //db/...

Own the files with sql_library from @rules_dx//sql/rules:defs.bzl, xml_library from @rules_dx//xml/rules:defs.bzl and gherkin_library from @rules_dx//gherkin/rules:defs.bzl. They accept .sql; .xml, .xsd, .xsl, .xslt and .wsdl; and .feature in srcs, and other libraries in deps. A check never writes. --apply rewrites only the files the selected targets own. An unformatted file is reported at the start of the file. A syntax error fails the run with the file, line and column where the tool gives one. It never counts as clean.

SQL files use the generic sql dialect. To pick a dialect, put it before the extension: schema.postgresql.sql. The dialects are bigquery, clickhouse, db2, db2i, hive, mariadb, mysql, n1ql, plsql, postgresql, redshift, singlestoredb, snowflake, spark, sqlite, transactsql, trino and tsql. The pinned options keep SQL keywords in the case you wrote them. XML namespaces, prefixes, attribute quotes, attribute order and text stay as written. Gherkin keeps its step text and tags. .prettierrc, .editorconfig and other user config files are ignored.

PowerShell

psscriptanalyzer runs from the managed PSScriptAnalyzer 1.25.0 pin for lint. It runs on Linux x86_64. Select it in the powershell family.

quality_family(
    name = "powershell_family",
    family_id = "powershell",
    lint = ["psscriptanalyzer"],
)
bazel run @rules_dx//:dx -- lint //scripts/...

Own scripts and modules with pwsh_library and pwsh_binary from @rules_dx//powershell/rules:defs.bzl. pwsh_library accepts .ps1, .psm1 and .psd1 in srcs. pwsh_binary accepts .ps1. A using module or Import-Module of a module in deps resolves in lint against the target's declared modules only. Lint never searches modules installed on the host. When you run or test the target, the host's module path is still searched after the declared modules. For Pester tests, add @rules_dx//powershell/gallery:pester to the deps of a pwsh_test.

Lint is check-only: lint --apply never rewrites a script. Each finding shows its line and column with the rule name. A script that does not parse is reported as an error.

An invalid config or a tool that fails to run fails the run with the cause named. It never counts as clean. To change the rules, bind a psscriptanalyzer_config to the target with aspect_hints. The file is a .psd1 settings file. Unknown rule names in IncludeRules or ExcludeRules are errors.

Ruby formatting and lint

standardrb formats and rubocop lints. Both run from the gems of a rules_ruby bundle you declare, never from ambient gems or Bundler. Linux x86_64 only. Select them in a ruby family. Only standardrb formats.

Copy third_party/ruby/Gemfile, Gemfile.lock and its gem_checksums into your module, then point the tools at the bundle in .bazelrc.

ruby = use_extension("@rules_ruby//ruby:extensions.bzl", "ruby")
ruby.bundle_fetch(
    name = "dx_bundle",
    gem_checksums = {...},
    gemfile = "//:Gemfile",
    gemfile_lock = "//:Gemfile.lock",
)
use_repo(ruby, "dx_bundle")
build --@rules_dx//quality/tools/ruby:rubocop=@dx_bundle//bin:rubocop
build --@rules_dx//quality/tools/ruby:standardrb=@dx_bundle//bin:standardrb

Without these flags the Ruby tools exit 2 and say which flag is missing. Other languages are unaffected.

quality_family(
    name = "ruby_family",
    family_id = "ruby",
    disabled = ["audit", "typecheck"],
    format = ["standardrb"],
    lint = ["rubocop"],
)
bazel run @rules_dx//:dx -- format //app/...
bazel run @rules_dx//:dx -- format --apply //app/...
bazel run @rules_dx//:dx -- lint //app/...

A check never writes. --apply rewrites only the Ruby files the selected targets own. rubocop has no fixes, so lint --apply changes nothing. Lint findings keep the cop name and line. A syntax error is reported as an error at its line and never counts as clean. An unreadable config fails the run. A cop crash or a configuration error fails the target, even when every offense is baselined.

To change options, bind a rubocop_config or standardrb_config to the Ruby targets with aspect_hints.

load("@rules_dx//quality:native_config.bzl", "rubocop_config")

rubocop_config(
    name = "relaxed",
    src = "relaxed.rubocop.yml",
    visibility = ["//visibility:public"],
)

ruby_library(
    name = "core",
    aspect_hints = ["//:relaxed"],
    srcs = ["greeter.rb"],
)

YAML

yamlfmt formats and yamllint lints. Both run from managed pins on Linux x86_64. Select them in a yaml family. Only yamlfmt has fixes.

quality_family(
    name = "yaml_family",
    family_id = "yaml",
    format = ["yamlfmt"],
    lint = ["yamllint"],
)

yamllint runs from a Python 3.12 toolchain. Declare the same toolchain in your MODULE.bazel as rules_dx does: python_interpreters.toolchain and python.toolchain with python_version = "3.12", plus rules_python and aspect_rules_py.

bazel run @rules_dx//:dx -- format //config/...
bazel run @rules_dx//:dx -- format --apply //config/...
bazel run @rules_dx//:dx -- lint //config/...

Own YAML files with yaml_library from @rules_dx//yaml/rules:defs.bzl. List the .yaml and .yml files of the package in srcs. A check never writes. --apply rewrites only the files the selected targets own. yamllint is check-only. Multiple documents and anchors are supported.

An unformatted file is a warning. A parse error is an error with the file and line, and it never counts as clean. yamllint findings keep the rule name, line and column.

To change options, bind a yamlfmt_config or yamllint_config to the YAML targets with aspect_hints.

load("@rules_dx//quality:native_config.bzl", "yamlfmt_config", "yamllint_config")

yamlfmt_config(
    name = "indent4",
    src = "indent4.yamlfmt",
    visibility = ["//visibility:public"],
)

yamllint_config(
    name = "relaxed",
    src = "relaxed.yamllint",
    visibility = ["//visibility:public"],
)

yaml_library(
    name = "app",
    aspect_hints = [
        "//:indent4",
        "//:relaxed",
    ],
    srcs = ["app.yaml"],
)

HTML templates

djlint formats and lints HTML templates. It runs from a managed pin on Linux x86_64. Select it in an html_template family. Only the format capability has fixes. Lint is check-only.

quality_family(
    name = "html_template_family",
    family_id = "html_template",
    format = ["djlint"],
    lint = ["djlint"],
)

djlint runs from a Python 3.12 toolchain. Declare the same toolchain in your MODULE.bazel as rules_dx does: python_interpreters.toolchain and python.toolchain with python_version = "3.12", plus rules_python and aspect_rules_py.

bazel run @rules_dx//:dx -- format //web/...
bazel run @rules_dx//:dx -- format --apply //web/...
bazel run @rules_dx//:dx -- lint //web/...

Own templates with html_template_library from @rules_dx//html_template/rules:defs.bzl. List the .html, .htm, .jinja, .jinja2, .j2, .njk, .hbs, .gohtml and .tmpl files of the package in srcs. Use deps for libraries that your templates include from. A check never writes. --apply rewrites only the files the selected targets own.

An unformatted file is a warning. Lint findings keep the rule code, line and column. A profile that djlint does not support, or a config file it cannot load, is an error and never counts as clean.

To change options, bind a djlint_config to the template targets with aspect_hints. The file is named djlint.toml or .djlintrc. Set the dialect with profile: all, angular, askama, django, golang, handlebars, html, jinja, liquid, nunjucks or tera. Without a bound config, configs in the workspace are ignored.

load("@rules_dx//quality:native_config.bzl", "djlint_config")

djlint_config(
    name = "indent2",
    src = ".djlintrc",
    visibility = ["//visibility:public"],
)

html_template_library(
    name = "web",
    aspect_hints = ["//:indent2"],
    srcs = ["templates/page.html"],
)

Shell

shfmt formats and shellcheck lints shell sources. Both run in the shell family.

quality_family(
    name = "shell_family",
    family_id = "shell",
    format = ["shfmt"],
    lint = ["shellcheck"],
)

Today only targets that feed the shell family explicitly (for example through real_source_target) reach shellcheck and shfmt. Plain rules_shell sh_binary, sh_library and sh_test sources are not classified as shell yet, so dx lint does not lint them.

shellcheck lint is check-only: lint --apply never rewrites a script. shfmt formatting is the part that --apply rewrites.

shellcheck never reads a .shellcheckrc from the host. A file in a parent directory of the script is ignored, so a lint result does not depend on the machine it runs on. To change the rules, bind a shellcheck_config to the target with aspect_hints. The file is passed with --rcfile. Without a config, --norc is passed. The user-level ~/.shellcheckrc and XDG config are ignored too. Name the file something other than .shellcheckrc.

load("@rules_dx//quality:native_config.bzl", "shellcheck_config")

shellcheck_config(
    name = "shellcheck_rc",
    src = "lint.shellcheckrc",
)

Baselines

Opt in to a baseline to keep known findings while new ones fail. Set the file in dx.toml:

[quality]
baseline = "quality/baseline.json"

dx.local.toml can override the selection; CI reads only dx.toml.

The path stays inside the workspace and names a .json file. Commit the file with the selection. A missing file fails checks. Pass --apply once to write it.

The file lists known diagnostics with tool, rule, path, message, context, and count:

{
  "schema_version": 1,
  "entries": [
    {
      "tool": "ruff",
      "rule": "F401",
      "path": "src/app.py",
      "message": "unused import",
      "context": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
      "count": 1
    }
  ]
}

A finding without a path records the Bazel target that produced it:

{
  "tool": "audit",
  "rule": "",
  "path": "",
  "target": "//app:lint",
  "message": "known advisory",
  "context": "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
  "count": 1
}

Identity is tool, rule, path, message, and source context, plus the target for a finding without a path. Line numbers alone never match, so moving a line keeps its entry. A rename or a rule change needs a refresh instead.

Checks print Baseline <file>: <total> total, <new> new, <suppressed> suppressed. Suppressed findings print with (baselined) and stay in JSON and SARIF output. JSON marks them with "baseline": "suppressed" and carries the counts in command_finished. SARIF carries the counts in each run. New findings fail as usual. Fixed entries turn stale and fail. Entries outside the checked tool and file scope, or tool and target scope for a finding without a path, stay unevaluated. An incomplete run never goes green through a baseline.

Pass --apply to refresh the file. Refresh adds new findings, prunes fixed entries in scope, and keeps the rest. An entry is in scope only for the tool and file that run analyzed, or the tool and target for an entry without a path. Refresh never writes after an incomplete run, a result that did not converge, or a finding whose source file cannot be read; the file stays byte for byte as it was. A malformed file fails instead of being rewritten.

Adopt in small steps. Run the check, review the findings, record them with --apply, then keep the file current on later runs.

dx check, dx fix, And dx clean

bazel run @rules_dx//:dx -- check //...
bazel run @rules_dx//:dx -- fix //...
bazel run @rules_dx//:dx -- clean

dx check And dx fix

dx check [--here] [--affected] [--check] [--fail-on info|warning|error] [--report sarif=<dest>] [scope...] [-- bazel-options...]
dx fix [--here] [--affected] [--check] [--apply] [--fail-on info|warning|error] [--report sarif=<dest>] [scope...] [-- bazel-options...]

Before any phase, dx check and dx fix fail when your change adds build output: a new file under target/, bazel-*, node_modules/, __pycache__/, .gradle/, or a build/ directory outside a source tree, or a file over 1 MiB. The error names each file and exits 1.

  • Your change is untracked unignored files, staged and unstaged edits, and commits since the merge base with the default branch (origin/HEAD, origin/main, main, origin/master, master).
  • Ignored files never count.
  • A file that already exists at the merge base is only checked for size.
  • List a path or a dir/ prefix you want to keep in .dx-large-files, one per line. Listed paths skip both rules.
  • Symlinks and Git LFS files are skipped.
  • With no default branch or merge base (a shallow clone), only the working tree is checked. In text output dx prints a note on stderr.
  • Outside a Git checkout the check is skipped.
  • If Git fails, the command exits 2 with code build_output_git_failed.

Runs format, then lint, then typecheck, then generate in order. --affected limits the first three to targets your git changes reach (see Scope Defaults), runs generate on the packages of those targets, or on the whole repo when the scope widens to //..., and makes dx check run the affected tests as a last test phase. It skips that phase when no test is affected. With --output=json, affected.tests on command_started lists the tests dx chose. With nothing affected, dx check --affected and dx fix --affected run no phase, generate included, and skip the build-output check. They print no affected targets and exit 0. With --output=json they emit only command_started (with affected) and command_finished; mode is check unless --apply is given. Affected targets that do not fit the host platform are skipped. Every phase, including test, passes the same Bazel settings: --@rules_dx//config:validate=false, --@rules_dx//config:clippy_output_diagnostics=true and --@rules_dx//config:rustc_output_diagnostics=true. Another value for one of them, or for the matching @rules_rust//rust/settings flag, fails before Bazel starts with exit 2. dx spells them with the name your MODULE.bazel gives rules_dx, for example --@my_dx//config:validate=false when repo_name = "my_dx". --check passes through to every phase. --fail-on applies to format, lint, and typecheck. --report sarif=<dest> merges the lint and typecheck phases into one document. generate and test take neither. A relative destination anchors at the workspace root; an absolute one writes as given. Colliding destinations fail before anything runs, parents must exist, and a failed write keeps the previous file bytes. dx check is always a check, so --check is implied. - for stdout is rejected because the phases share one document.

dx check and dx fix run every phase. A phase with findings or drift, a failed build or tool, a target that does not converge, incomplete results, or a report that cannot be written does not stop the next phase, so one run reports every phase. The run still exits nonzero, and a later clean phase never clears it. The run stops, and marks the remaining phases skipped, only on a bad scope or configuration, when Bazel cannot run (it fails to launch, is killed by a signal, or the disk is full), or on a cancel. A phase whose only failures are compile errors does not stop dx check, but it stops dx fix, generate included: the remaining phases are skipped with <phase> skipped after <phase> failed to compile. The skipped phases are named in the JSON umbrella_phase_skipped notice events. In text output, a run with a failed phase ends with a dx: phases: line on stderr that says how each phase ended.

Every phase that ran and owed a capture must leave a readable SARIF document behind. A missing, unreadable, or malformed capture is a collection_failed error: the run fails and the report is marked incomplete. Findings from the phases that did land are still merged. The written line names every phase that failed, was skipped, or lost its capture.

A phase that fails still keeps the findings it wrote: its SARIF is merged and the report is marked incomplete. A phase that only reports findings leaves the report complete. The run exits with the code of the phase that stopped it, or of the first failed phase, even when the report cannot be written. A failed phase that wrote nothing adds no collection_failed error.

Output: --output text|diff|json. JSON keeps one command_started and one command_finished for the run. Each phase sits between a phase_started and a phase_finished, and its events carry command, phase, and a correlation such as check/format. dx fix --apply verification phases use verify/<phase>. A verify/<phase> that writes events dx cannot read adds a correlated collection_failed error and fails the run. A phase that reported findings adds a correlated notice (umbrella_phase_findings), a skipped phase adds a correlated notice (umbrella_phase_skipped), and a capture the run could not collect adds a correlated error. --include-edits adds full edits to JSON change events and needs --output json.

Findings keep the tool's labels, notes, help, and related locations. Text prints them under the finding, JSON adds an ordered related list, and SARIF adds relatedLocations. They are never counted as findings or applied as edits.

SARIF results carry partialFingerprints (dx/v1), ruleIndex, and fixes for machine-applicable replacements. Runs set columnKind to unicodeCodePoints.

dx check only reports. dx fix checks by default; dx fix --apply writes validated fixes, then re-checks read-only every phase up to the last one that changed or refused a file. The re-check runs only when every phase passed. When no phase changed anything, no re-check runs. dx fix --apply succeeds only when that re-check passes; remaining findings, a target that did not converge, or a failed verification stay visible and fail the run.

When a phase finds changes, text prints N file(s) need changes. Apply them with: dx fix --apply ... with the scope you passed, --affected included. next: lines name dx check or dx fix with that scope.

Exit codes: 0 success, 2 usage or scope errors, 1 a phase failed or reported findings, a capture could not be collected, the post-apply verification failed, or a report could not be written. A phase that stopped the run keeps its code, so a generate phase keeps Bazel's code. Otherwise the first failed phase sets the code.

The test phase also keeps Bazel's code. 3 with every test result collected means some affected tests failed: the run counts that as findings and its results are complete. 3 or 1 with results incomplete is an incomplete phase, not findings: the reason is test failed and the results are incomplete. Results are incomplete when the build failed, a result has no test.xml, a test.xml is missing, truncated or invalid, Bazel announces a result it never reports, a target has a summary but no result, or the build events end before Bazel's last event. 2 and 4 mean the scope selected no runnable tests.

bazel run @rules_dx//:dx -- check //...
bazel run @rules_dx//:dx -- fix --here
bazel run @rules_dx//:dx -- check --affected
bazel run @rules_dx//:dx -- fix --apply //...

dx clean

dx clean [--check] [--apply] [--prune-unobserved]

Reports unselected managed state under .dx by default and deletes nothing. --check is that default: it reports and deletes nothing. Pass --apply to prune it. A generation a live reader still holds is never pruned. When process observation is unavailable, dx keeps unobserved state and reports it; pass --prune-unobserved to prune it anyway; this never overrides a held lease. Never touches Bazel outputs; run bazel clean for those. Takes no scopes.

Output: --output text|json. --quiet silences text output.

Text, check mode, when something can be pruned or is preserved:

  • dx clean: reclaimable managed state
  • process observation unavailable: unobserved state is preserved, never assumed idle
  • prune setup record: .dx/setups/<hex> (<n> bytes)
  • prune generation: .dx/<environments|generated>/<hex> (<n> bytes)
  • preserve current: .dx/setups/<hex>, or no current selection
  • preserve unobserved setup record: .dx/setups/<hex> and preserve unobserved generation: .dx/<environments|generated>/<hex>
  • refuse unmanaged path: <path>
  • reclaimable total: <n> bytes
  • run `dx clean --apply` to prune it, or run `dx clean --apply --prune-unobserved` to prune it anyway when only preserved state is listed

When nothing can be pruned or preserved it prints dx clean: nothing to prune.

Text, --apply: dx clean: pruned <n> setup records and <m> generations (<b> bytes reclaimed), followed by ; preserved <k> leased generations when a live reader holds one. When process observation is unavailable it prints dx clean: preserved <n> unobserved setup records and <m> unobserved generations (process observation unavailable) and run `dx clean --apply --prune-unobserved` to prune it anyway. With nothing to prune it prints dx clean: nothing to prune.

JSON: command_started has mode check, or default with --apply. Then one operation event, collect. Then one info notice per path. Each notice has a path under .dx/ and the byte size in message:

  • clean_planned: would be pruned. Check mode.
  • clean_preserved_unobserved: kept because process observation is unavailable. Check mode and --apply, when process observation is unavailable.
  • clean_pruned: pruned. --apply.
  • clean_preserved_leased: kept because a live reader holds it. --apply.

A failure emits an error event with code clean_failed. command_finished ends the stream.

Exit codes: 0 success, 2 usage errors including any scope, 1 drift or a prune failure. State kept only because process observation is unavailable exits 0.

bazel run @rules_dx//:dx -- clean --check
bazel run @rules_dx//:dx -- clean --apply
bazel run @rules_dx//:dx -- clean --apply --prune-unobserved

dx generate

bazel run @rules_dx//:dx -- generate
bazel run @rules_dx//:dx -- generate --apply //...
bazel run @rules_dx//:dx -- generate --here
bazel run @rules_dx//:dx -- generate --output json //pkg/...
bazel run @rules_dx//:dx -- generate --apply //... -- --jobs=4

Updates BUILD declarations from source with Gazelle over the scope. To materialize declared generated source, use dx codegen. No scope means the whole repo. Use --here for the current dir tree. Checks by default and reports stale files without writing. Pass --apply to write them. Bazel options go after --.

dx generate [--here] [--check] [--apply] [scope...] [-- bazel-options...]
  • --check: fail if files are stale instead of writing them. Same as the default.
  • --apply: write the updated BUILD files. Without it files stay untouched.
  • --fail-on, --report, --affected: rejected as usage errors (exit 2).
  • --output text|diff|json: result shape. diff prints one unified patch per stale file, or per file --apply wrote or did not apply.

Exit codes: 0 success, 2 usage or scope errors, 1 stale files with --check or the default, a file --apply did not write, incomplete results, a missing or invalid generation result (invalid_result), or a patch that fails to render (diff_failed). Bazel failures keep Bazel's code.

Output

Text starts with Running generate for <scope>. --quiet hides it. Then it prints Created <path> or Modified <path> for each written file, or each stale file with --check, sorted by path. Each ignored import prints Ignored import '<import>' in <path> [<language>]: Static import intentionally contributes no Bazel dependency. Files that were not written go to stderr as Not applied: <path> (<reason>). An incomplete --apply run still prints the files it wrote and the Not applied lines. An incomplete --check run, or a Bazel failure that leaves no result, prints no file lines.

JSON starts with command_started with mode check, or default with --apply. Then one change per file with path, kind (create or modify), edit_count, and effect (proposed, applied, or refused). A refused change has a reason: write_mismatch, missing_file, or unreadable_file. Each ignored import emits a notice with code ignored_import, level warning, path, language, and import. A Bazel failure with --apply, or one that leaves results incomplete, emits a bazel_failed error. So does a Gazelle failure with --check, including a nonzero exit that does not come with a Gazelle diff. command_finished ends the stream with results_complete, exit_code, and changes with create and modify counts, plus applied and refused with --apply. An incomplete --apply run still emits its change events and changes with applied and refused. An incomplete --check run, or a Bazel failure that leaves no result, emits no change events and no changes.

Directives

# gazelle:dx_ignore_import <language> <import> skips one import in this directory and below. dx generate fails when no literal reference matches it, when the language is unknown, or when the value is malformed.

Accepted languages: astro, cc, csharp, fsharp, gleam, go, java, javascript, kotlin, mdx, python, ruby, rust, scala, svelte, typescript, vue, zig.

# gazelle:dx_cuda_archs <spec> <spec>... sets the archs of a generated cuda_library. It applies to this directory and below, and a deeper directive replaces it. Separate specs with spaces, because a spec can contain commas, as in compute_89:compute_89,sm_89. The cc language generates a <dir>_cuda target for .cu and .cuh files that no hand-written CUDA rule lists, and it fails when the directive is missing. Gazelle never touches hand-written cuda_library, cuda_binary, cuda_test or cuda_gpu_test targets. It sets archs only when it creates the target, so a hand-edited value survives later runs. Sources named *_test.cu and sources that define main are not generated, and main fails the run.

# gazelle:lang must list dx_dispatch (for example # gazelle:lang go,dx_dispatch), or dx generate fails.

# gazelle:resolve <language> <import> <label> maps one import to an exact label.

# gazelle:dx_ignore_import python requests
# gazelle:resolve python yaml @pypi//pyyaml

For java, kotlin and scala, a resolve mapping names the fully qualified import or the import's own package, and a dx_ignore_import names the fully qualified import. A wildcard import such as kotlinx.coroutines.* resolves through its package. A bare class name such as ImmutableList no longer matches, so a stale bare-name entry fails the run.

# gazelle:resolve kotlin kotlinx.coroutines @maven//:org_jetbrains_kotlinx_kotlinx_coroutines_core
# gazelle:resolve kotlin com.google.common.collect.ImmutableList @maven//:com_google_guava_guava

dx docs

dx docs [--check] [--serve [--port <n>] [--host <addr>] [--open]] [--here] [scope...] [-- bazel-options...]

Builds the documentation site with Bazel and the pinned upstream mdBook: every dx command page, the GitHub CI guide, and the examples. With no scope it builds the repository site (//tools/docs/site:user_site, //tools/docs/site:user_site_check in --check). Args after -- go to Bazel unchanged. The build also passes --@rules_dx//config:validate=false, --@rules_dx//config:clippy_output_diagnostics=true and --@rules_dx//config:rustc_output_diagnostics=true. Another value for one of them, or for the matching @rules_rust//rust/settings flag, fails before Bazel starts with exit 2. dx spells them with the name your MODULE.bazel gives rules_dx, for example --@my_dx//config:validate=false when repo_name = "my_dx".

Scopes: Bazel labels or patterns (//..., //pkg:target, @repo//...), or workspace-relative files or dirs resolved through Bazel query. --here builds the current directory tree (//path/..., or //... at the root) and cannot be combined with explicit scopes (exit 2).

  • --check: validate the book without rendering it. Builds the generated summary and landing page only.
  • --serve: build, then preview the built site tree locally with python3 -m http.server. Needs python3 on PATH and exactly one built site tree with an index.html entry page.
  • --port <n>: preview port, 1-65535, default 8000. 0 is a usage error.
  • --host <addr>: preview bind address, non-empty, default 127.0.0.1.
  • --open: open the preview URL in a browser through python3. If that fails, dx prints a warning and the preview keeps running.

--port, --host, and --open need --serve.

Output: --output text|json. --output diff is a usage error (exit 2). Text prints Running docs build for <scope> (mdBook render), or Running docs check for <scope> (no render) with --check. With --serve it then prints Serving docs at <url> (<dir>), and Opening <url> with --open. JSON starts with command_started (mode check or default), then operation events (summary, plus render without --check). On failure one error follows: launch_failed when Bazel cannot be launched, bazel_failed, disk_full, bazel_signalled, or serve_failed. The stream ends with command_finished. A usage, option, or scope error fails before the command starts: one error with invalid_arguments, then command_finished, and no command_started. Under --serve, command_finished comes when the preview server exits.

Reports: none. --report is a usage error.

Exit codes: 0 success, 2 usage or scope errors, including --here with an explicit scope, --output diff, and --report. 1 a launch, signal, or preview failure, including launch_failed (Bazel cannot be launched), serve_failed (no single site tree, a missing index.html, or a preview that fails to launch) and a signalled preview. Bazel failures keep Bazel's code. disk_full keeps exit 36: Bazel exited 36 and a disk it writes to has under 1 GiB free. dx then suggests dx worktree prune --apply. Under --serve the preview server's code is the exit code.

bazel run @rules_dx//:dx -- docs --check
bazel run @rules_dx//:dx -- docs
bazel run @rules_dx//:dx -- docs --serve --port 8080
bazel run @rules_dx//:dx -- docs --here
bazel run @rules_dx//:dx -- docs --serve --host 0.0.0.0 --port 8080 --open //tools/docs/site:user_site
bazel run @rules_dx//:dx -- docs --check --output=json
bazel run @rules_dx//:dx -- docs --check -- --config=ci

The build never changes sources. Serve runs a local preview only. The site renders Markdown to HTML with syntax-highlighted code, a sidebar, a search index, and one route per page.

TypeScript API reference

typescript_doc_shard from @rules_dx//tools/docs/typescript:defs.bzl runs the pinned TypeDoc over one TypeScript project and produces a docs IR shard with language typescript. Pass the shard to docs_site beside any Rust shards, and the shared pipeline renders the pages. Symbol ids have the form typescript:<package>:<name>.

load("@rules_dx//tools/docs/typescript:defs.bzl", "typescript_doc_shard")

typescript_doc_shard(
    name = "api_docs",
    srcs = glob(["src/*.ts"]),
    entry_points = ["src/index.ts"],
    package = "mylib",
    tsconfig = "tsconfig.json",
)

Build the shard's site with the scope of the target:

bazel run @rules_dx//:dx -- docs //lib:api_docs

Exit codes: 0 success, 2 usage or scope errors, 1 a launch, signal or preview failure. Bazel failures keep Bazel's code, so a TypeDoc error in the shard fails the build.

The rule owns the options plugin, options, tsconfig, entryPoints, json, out and basePath, and fails at load time if options sets one of them. It also passes an empty --options file, so a stray typedoc.json is never read. typedocOptions in tsconfig.json or package.json is still loaded by TypeDoc, and it can load JavaScript plugins. Keep plugin out of those files.

Audit And Update Commands

dx security And dx license

dx security [--here] [--affected] [--offline] [--frozen] [--fail-on info|warning|error] [--report sarif=<dest>] [scope...]
dx license [--here] [--affected] [--offline] [--frozen] [--fail-on info|warning|error] [--report sarif=<dest>|spdx=<dest>] [scope...]

dx security checks secrets plus dependency vulnerabilities. dx license checks dependency licenses. No scope means //.... Use --here for the current dir tree, or --affected for the sets that own targets your git changes reach (see Scope Defaults). With no changes, or for dx license no owned set, they print no affected targets and exit 0. When files changed but no set owns them, dx security runs only the secrets scan. Neither command changes files. --check and Bazel options do not apply.

dx security reads the lockfile of each set it owns and matches it against .dx/advisory/<family>.json. Sets: cargo, go, maven, npm, npm-adopt, npm-adopt-polyglot, npm-tools, nuget, powershell, ruby. The npm-adopt, npm-adopt-polyglot, and npm-tools sets share the npm snapshot, and the ruby set uses the rubygems snapshot. The powershell set has no advisory snapshot. A run that selects it fails with failed to assess powershell when PSGallery.lock.json lists modules, unless security.toml accepts it with an [[uncovered]] entry, and lists it under no advisory coverage otherwise. Sets without a snapshot are listed as no advisory coverage for <sets>.

Create or refresh the snapshots from the workspace root:

bazel run @rules_dx//cli/advisory_prep --

A snapshot is fresh only on the day it was prepared. A missing or stale snapshot fails with advisory_refresh_failed and names this command. See Advisory Snapshots for its flags.

A scope selects sets by path, and a path can select more than one. A ... scope selects the set of the nearest path above it in the table plus every set nested under it, so //quality/... selects cargo, npm-tools, and uv-tools, and //third_party/..., a path the table does not name, selects go, maven, nuget, powershell, and ruby. A package under a path in the table selects that path's set, so //cli/cli selects cargo. Each set then reads its own lockfile, so a scope narrows which sets run, never which packages inside a lockfile.

//cli/..., //tools/docs/..., //env/..., //generation/..., //quality/..., //rust/...      cargo
//examples/adopt-rust/...                                                             cargo
//csharp/..., //fsharp/..., //third_party/dotnet/...                                  nuget
//examples/adopt-csharp/..., //examples/adopt-fsharp/...                              nuget
//go/..., //third_party/go/...                                                         go
//examples/adopt-go/...                                                               go
//powershell/..., //third_party/powershell/..., //examples/adopt-powershell/...      powershell
//ruby/..., //third_party/ruby/..., //examples/adopt-ruby/...                        ruby
//java/..., //kotlin/..., //scala/..., //third_party/jvm/...                          maven
//examples/adopt-java/..., //examples/adopt-kotlin/..., //examples/adopt-scala/...    maven
//javascript/..., //typescript/..., //astro/..., //svelte/..., //vue/..., //mdx/...   npm
//quality/tools/javascript/...                                                        npm-tools
//examples/adopt-js-ts/...                                                            npm-adopt
//examples/adopt-polyglot/...                                                         npm-adopt-polyglot
//examples/adopt-polyglot/...                                                         uv-adopt-polyglot
//python/..., //python/tests/fixtures/hello/...                                       uv
//examples/adopt-python/...                                                           uv-adopt
//quality/tools/python/...                                                            uv-tools

A scope that owns no set exits 2 and names the audited sets. //... and MODULE.bazel select every set. The four uv sets resolve but have no snapshot, so they report no advisory coverage. A path the table does not name, and that has no path from the table under it, owns no set.

dx security and dx license read pnpm-lock.yaml, package-lock.json, and yarn.lock for the npm set, and the lockfile of every other set. The ruby set reads third_party/ruby/Gemfile.lock and examples/adopt-ruby/Gemfile.lock. The powershell set reads third_party/powershell/PSGallery.lock.json. Each finding names the lockfile it was read from.

dx security reads security.toml from the workspace root. Without the file no finding is exempted. Each [[exception]] needs advisory, package, set, versions, reason, and expires. schema_version is 1.

[[exception]]
advisory = "GHSA-aaaa-bbbb-cccc"
package = "some-package"
set = "npm"
versions = ">=1.2.0, <2.0.0"
reason = "Reviewed; no reachable code path."
expires = "2027-03-01"

An expires date on or before the audit date fails the run. An exception that matches no current finding in its own set fails the run, so remove it when the finding goes away. versions uses the range syntax of the set ecosystem.

An [[uncovered]] entry accepts a set that has no advisory source. It needs set, reason, and expires. The run stays complete and still lists the set under no advisory coverage. An expires date on or before the audit date fails the run.

[[uncovered]]
set = "powershell"
reason = "PowerShell Gallery publishes no advisories."
expires = "2027-04-24"

Advisory freshness and exception windows are judged against today's UTC date. Set DX_AUDIT_TODAY to YYYY-MM-DD to pin that date and make a run reproducible. A value that is not ten digits and dashes is ignored.

dx security scans secrets with the pinned @dx_tools//:gitleaks artifact. When DX_GITLEAKS_BIN holds an absolute path, dx runs that file. When it is unset, dx resolves the label through Bazel in the workspace. A relative path is rejected and PATH is never searched. If no artifact is found, the run exits 1 with secrets auditor unavailable and prints this command, which exports the path in the current shell:

export "$(bazel run @rules_dx//cli/tool_resolve -- --label @dx_tools//:gitleaks --var DX_GITLEAKS_BIN --print)"

dx license does not read it.

A .gitleaks.toml in the workspace root is passed to the scan. It is the only config the scan reads. Each scan that uses it reports a warning naming the file, because the config carries no hash pin. A secret finding is an error whose rule is the Gitleaks rule id, for example gitleaks/aws-key.

dx license reads licenses.toml from the workspace root. Without the file every scope is distributed and only MIT, Apache-2.0, BSD-2-Clause, BSD-3-Clause, ISC, and Unlicense pass. schema_version is 1 and an unknown key fails the run.

[policy] holds blocked, [policy.distributed] holds allow, review, and deny, and [policy.sets.<set>] adds review ids for one set. An id in two lists fails the run. [distribution] internal lists label patterns. //... covers every scope in the main repo, //pkg/... covers pkg and everything under it, //pkg:all covers the targets in pkg, and any other entry matches one label. A run is internal only when an entry covers every scope, including the --here and --affected scopes, and every other run is distributed. An internal scope fails only on blocked, so review, deny, and unlisted ids pass. A distributed scope fails on all of them. Its SARIF rule is license/<license> and its message names the package, version, license, and tier.

MIT, Apache-2.0, BSD-2-Clause, and BSD-3-Clause also need their text. Without it a distributed scope fails with the SARIF rule license/missing-notice-text.

schema_version = 1

[policy]
blocked = ["AGPL-3.0-only", "SSPL-1.0"]

[policy.distributed]
allow = [
  "MIT",
  "Apache-2.0",
  "BSD-2-Clause",
  "BSD-3-Clause",
  "ISC",
  "Unlicense",
]
review = ["LGPL-3.0-only", "MPL-2.0"]
deny = ["GPL-3.0-only", "GPL-3.0-or-later"]

[policy.sets.ruby]
review = ["Ruby"]

[distribution]
internal = ["//..."]

[[inventory]]
package = "some-gem"
set = "ruby"
license = "MIT"
versions = ">=1.0.0, <3.0.0"
text_present = true

Each [[inventory]] needs package, set, license, and versions, and text_present says the license text ships with the package. It overrides the license the cargo and npm sets read from cargo-bazel-lock.json and package-lock.json, and it is the only license source for every other set, so a package with no entry is UNKNOWN.

Each [[exception]] needs package, set, license, versions, reason, and expires, and covers a finding only when package, set, and license all match. An expires date on or before the audit date fails the run. An exception that matches no finding in its own set fails the run too. Exceptions for sets outside the run are still checked for fields and expiry, then skipped. versions uses the range syntax of the set ecosystem.

Output: --output text|json. Text prints one line per finding: <path>: <severity>: <message> [<tool>/<rule>]. The lines go to stderr when a report goes to stdout. JSON emits one diagnostic event per finding with tool, rule, path, severity, and message. A vulnerability has tool vuln, rule vuln/<advisory>, the lockfile as path, and a message that names <package>@<version>, the advisory, its severity, and the fixed version. Its package has name, version, and fixed, the list of fixed versions, empty when none exists. A license finding has tool license, rule license/<license>, a message that starts with <package>@<version> license <license>, and a package with name, version, and license. A failed family emits an error event. Its code is advisory_refresh_failed for a missing, stale, or corrupt advisory snapshot, offline_required when --offline blocks a refresh, and audit_failed for findings or any other incomplete assessment. Reports: dx security writes --report sarif=<dest>. dx license writes --report sarif=<dest> or --report spdx=<dest>. Repeat the flag for more files. Use - for stdout. A relative destination anchors at the workspace root; an absolute one writes as given. Colliding destinations fail before anything runs, parents must exist, and a failed write keeps the previous file bytes.

bazel run @rules_dx//:dx -- security //...
bazel run @rules_dx//:dx -- license --fail-on error //...

--offline forbids network: the run uses pinned cached snapshots and fails with offline_required when they are missing. --frozen forbids manifest and lock resolution changes while still allowing already pinned fetches. Combine both flags for both policies. Exit codes: 0 success, 2 usage or scope errors, 1 findings at or above --fail-on, an incomplete assessment, or an operational failure.

dx update

dx update [--check] [--apply] [--offline] [--frozen] [selector ...]

Updates dependencies per set through the qualified resolvers. Checks by default; --apply writes updates. Sets: cargo, go, maven, npm, npm-adopt, npm-adopt-polyglot, npm-tools, nuget, powershell, ruby, uv, uv-adopt, uv-adopt-polyglot, uv-tools. Selectors are set, set:package, or a label/path. go, powershell, and ruby pins are manual: update reports them pinned and changes nothing. ruby pins come from bundle lock on the seed host, powershell pins are hand-written in third_party/powershell/PSGallery.lock.json, and go pins track Gazelle and widen through dx bump with a go selector. --check validates the selected sets without writing. The uv set runs a read-only lockfile check and reports current, and a stale lock reports failed. go, powershell, and ruby report pinned. Every other resolvable set reports unsupported and fails the run; refresh those with dx update --apply <set>. No selector checks all sets, so a bare dx update reports the unsupported sets and exits 1. Checks never take a build slot. --apply takes one only for cargo, maven, npm, npm-tools and nuget, whose updaters run Bazel. update never touches the Bazelrc preset fragment: verify it with bazel run //tools/bazelrc:preset_update -- --verify-only and regenerate it with bazel run //tools/bazelrc:preset_update.

The preset sets these resource defaults:

  • startup --host_jvm_args=-XX:MaxHeapFreeRatio=30 and -XX:MinHeapFreeRatio=10: idle servers return heap to the OS.
  • startup --host_jvm_args=-XX:MaxRAMPercentage=25: the server heap is up to 25% of RAM, or of the cgroup limit when there is one.
  • common --local_resources=memory=HOST_RAM*.5 and common --local_resources=cpu=HOST_CPUS*.75: actions use half the RAM and three quarters of the cores.
  • startup --max_idle_secs=900: a server shuts down after 15 minutes idle.
  • startup --shutdown_on_low_sys_mem: an idle server shuts down when system memory runs low. It needs --max_idle_secs.
  • common --experimental_disk_cache_gc_max_age=14d: prunes disk cache entries older than 14 days. It only applies when a disk cache is set.
  • common --experimental_disk_cache_gc_max_size=20G: caps the disk cache at 20G. It only applies when a disk cache is set.
  • common --repo_contents_cache_gc_max_age=60d: keeps repo contents for 60 days. dx worktree prune cleans the downloads in the repository cache.
  • build --experimental_shrink_worker_pool and build --experimental_total_worker_memory_limit_mb=4096: cap worker memory per server.

Override any of them in user.bazelrc.

--offline forbids network: the run uses pinned cached snapshots and fails with offline_required when they are missing. --frozen forbids manifest and lock resolution changes: --apply fails with frozen_locked when a resolver would rewrite them. --check writes nothing, so --frozen does not change it. Combine both flags for both policies. --fail-on, --report, and Bazel options do not apply. --bazel-startup-option applies to the cargo, maven, npm, npm-tools, and nuget sets, which run Bazel. Output: --output text|json. json emits command_started, then per set either one notice (update_set_success, update_set_current, update_set_pinned, or update_set_blocked) or one error (update_failed, update_set_unsupported, offline_required, frozen_locked, or backend_unavailable). An applied set also emits one change event per edited file, before its success notice. A failed run then emits an update_recovery notice, and every run ends with command_finished. Text prints dx: <code>: <message> on stderr for every failure plus a summary count of updated, current, pinned, unsupported, failed, and blocked sets. A failed run prints an update_recovery notice with the rerun command. Text goes to stderr; JSON is a notice whose scope lists the sets to retry. After --apply the command is dx update --apply <sets>, plus git checkout -- <locks> to discard kept successes. After --check it is dx update <sets>. In a consumer workspace the restore command uses the lock paths declared in dx.toml. Exit codes: 0 success, 2 usage or scope errors, 1 when any set is unsupported, failed, or blocked, or on an operational failure.

bazel run @rules_dx//:dx -- update --check
bazel run @rules_dx//:dx -- update --apply go
bazel run @rules_dx//:dx -- update --apply ruby
bazel run @rules_dx//:dx -- update --apply powershell
bazel run @rules_dx//:dx -- update --apply uv uv-tools
bazel run @rules_dx//:dx -- update --apply npm-tools
bazel run @rules_dx//:dx -- update --apply npm-adopt npm-adopt-polyglot uv-adopt uv-adopt-polyglot

dx bump

dx bump [--apply] [--offline] [--frozen] <set:package> <version>

Widens one declared requirement to a new version. Checks by default; --apply widens. Without --apply, bump exits 0 when the manifest already declares the version and exits 1 with bump drift when a widen is pending. Takes exactly one set:package plus one version. Sets: bazel, cargo, github-actions, go, maven, npm, nuget. The package must already be declared in the manifest, or the run fails without writing. A bazel bump rewrites the one literal version of the matching top-level bazel_dep in MODULE.bazel and keeps every other byte. Duplicate declarations, a missing or computed version or name, and a MODULE.bazel that does not parse fail without writing. A github-actions version is the resolved commit SHA, never a tag, and the pin is rewritten in every .github/workflows file that declares the action.

A configured bazel set from dx.toml is selected as <set>:<module>. It rewrites the one literal version of that bazel_dep in the set's own MODULE.bazel. A read-only set and <set>:.bazelversion fail without writing. Configured uv and npm sets fail with bump is not supported.

cargo, go, maven, npm, and nuget refresh automatically through dx update --apply <set>. A failed refresh keeps the widen and exits 1. bazel and github-actions are file-only and refresh nothing. Review the pin diff and run bazel build //....

A set that needs a refresh under --offline fails before widening with offline_required. --frozen always fails before widening with frozen_locked, because a bump changes resolution by definition. A major bump also needs dx migrate --from <old> --to <new>.

--check, --fail-on, --report, and Bazel options do not apply. --bazel-startup-option applies to the Bazel run that refreshes the lock. Output: --output text|json. Exit codes: 0 success, 2 usage or scope errors, 1 a pending widen without --apply or an operational failure.

bazel run @rules_dx//:dx -- bump --apply cargo:anyhow 1.0.100
bazel run @rules_dx//:dx -- bump --apply go:github.com/google/go-cmp 0.7.0
bazel run @rules_dx//:dx -- bump --apply bazel-modules:rules_cc 0.3.0

dx add

dx add [--apply] [--offline] [--frozen] <set:package> <version>

Declares one new requirement at an exact version. Checks by default; --apply adds. Without --apply, add exits 1 with add pending and writes nothing. Takes exactly one set:package plus one version, never a batch. Sets: bazel, cargo, go, maven, npm, nuget. The package must not be declared yet: a declared package fails in both modes and names the dx bump command that changes its version.

Each set writes the same manifest dx bump widens and keeps every other byte:

  • bazel: a bazel_dep after the last top-level bazel_dep in MODULE.bazel.
  • cargo: a [dependencies] entry, kept sorted when the table already is.
  • go: a require line in the first direct require block of go.mod.
  • maven: a coordinate at the end of the artifacts of the maven.install that feeds the default @maven repository.
  • npm: a dependencies entry in package.json.
  • nuget: a nuget line in the main group of paket.dependencies.

A configured bazel set from dx.toml is selected as <set>:<module> and adds to the set's own MODULE.bazel. A read-only set fails without writing. Configured uv and npm sets fail with add is not supported. github-actions and bazel:.bazelversion are usage errors: add the uses: step by hand, or change the pin with dx bump.

cargo, maven, npm, and nuget refresh automatically through dx update --apply <set>. go refreshes as a no-op and does not update go.sum. bazel is file-only. A failed refresh keeps the add and exits 1. A set that needs a refresh under --offline fails before writing with offline_required. --frozen always fails before writing with frozen_locked.

--check, --fail-on, --report, and Bazel options do not apply. --bazel-startup-option applies to the Bazel run that refreshes the lock. Output: --output text|json. Exit codes: 0 success, 2 usage or scope errors, 1 a pending add without --apply, a declared package, or an operational failure.

bazel run @rules_dx//:dx -- add --apply cargo:serde 1.0.200
bazel run @rules_dx//:dx -- add --apply npm:vue 3.5.42
bazel run @rules_dx//:dx -- add --apply bazel-modules:rules_go 0.50.0

Consumer dependency sets (dx.toml)

A workspace outside rules_dx declares its own dependency sets in a committed dx.toml at the workspace root. When dx.toml declares at least one [[dependency_set]], update, security, license, and bump resolve names and scopes from it instead of the built-in sets. No built-in set is selected. A dx.toml with only [dx] or [quality] tables keeps the built-in sets. A dx.toml with neither sets nor those tables fails with exit 2.

schema_version = 1

[[dependency_set]]
name = "frontend"
ecosystem = "uv"
manifests = ["apps/frontend/pyproject.toml"]
locks = ["apps/frontend/uv.lock"]
scopes = ["apps/frontend"]

Each record names one set: a stable name, an ecosystem backend kind (uv, npm, or bazel), workspace-relative manifests, locks, and owning scopes. Manifests are named for the ecosystem: pyproject.toml for uv, package.json for npm, MODULE.bazel for bazel; any other name fails with exit 2. Two sets may share one ecosystem. Paths stay inside the workspace: absolute paths, .., and \ fail. A manifest or lock whose symlink resolves outside the workspace fails security and license for that set with exit 1. Two writable sets cannot own the same manifest or lock; mark shared read-only ownership with writable = false. schema_version must be 1. Unknown keys, unknown ecosystems, duplicate names, empty records, and manifests spread across directories fail with exit 2 before anything runs.

A bazel set audits the resolved Bazel module graph. Point manifests at MODULE.bazel and locks at MODULE.bazel.lock.

[[dependency_set]]
name = "bazel-modules"
ecosystem = "bazel"
manifests = ["MODULE.bazel"]
locks = ["MODULE.bazel.lock"]
scopes = ["."]

dx security and dx license list every selected module from the lock's source.json entries, plus archive_override, git_override, local_path_override, and literal http_archive, http_file, and http_jar calls in MODULE.bazel and the files it pulls in with include. Downloads through any other use_repo_rule, such as git_repository, are listed as repo_rule inputs named by commit, sha256, or tag, and unpinned when none is set. Each input is named by registry and source.json digest, archive sha256 or integrity, git commit, or local path. Modules that lost version selection are not listed. Repos that module extensions record under moduleExtensions in MODULE.bazel.lock, from any module in the graph, are listed as module_extension inputs under their repo name. Each is named by sha256, integrity, or commit, and unpinned when none is set. Generated repos with no URL are skipped. Extensions marked reproducible are not in the lock, so their downloads are not listed.

No advisory source maps Bazel modules. A run with only bazel sets exits 0 with clean; no advisory coverage for <name> and an info diagnostic with the input counts per source, including the module extension count. Read the diagnostic: nothing was checked for advisories. A missing or unreadable lock, a bazel_dep absent from the lock, a declared version above the selected one, an override still resolved from a registry, a git_override without a commit, an include that is not a // label naming a .MODULE.bazel file, cannot be read, or points outside the workspace, an archive without sha256 or integrity, a computed module_name, a malformed sha256 or integrity on an extension repo, and two extensions that generate one repo at the same version from different URLs fail the set with failed to assess <name> and exit 1.

dx license reports UNKNOWN for every input unless licenses.toml declares it with [[inventory]] and set = "bazel". The SPDX report carries each input's origin as an external reference. A module that two bazel sets share is listed once with every origin.

[[inventory]]
package = "rules_cc"
set = "bazel"
license = "Apache-2.0"
versions = "0.2.1"
text_present = true

dx update --check reports a bazel set as pinned. dx update --apply changes nothing; change a pin with dx bump <set>:<module> <version> and refresh the lock with bazel mod deps.

An npm set locks one package.json with the workspace's own pnpm. The root MODULE.bazel must load it:

bazel_dep(name = "aspect_rules_js", version = "3.5.2")

pnpm = use_extension("@aspect_rules_js//npm:extensions.bzl", "pnpm")
use_repo(pnpm, "pnpm")
[[dependency_set]]
name = "web"
ecosystem = "npm"
manifests = ["apps/web/package.json"]
locks = ["apps/web/pnpm-lock.yaml"]
scopes = ["apps/web"]

dx update --apply runs bazel run @pnpm//:pnpm -- --dir <absolute set directory> install --lockfile-only from the workspace root and writes that set's pnpm-lock.yaml only; no node_modules is created. set:package selectors such as web:@types/node run update <packages> --lockfile-only instead. dx update --check runs install --lockfile-only --frozen-lockfile, writes nothing, and fails the set with updater exited <code> when the lock is stale. --check --offline adds pnpm --offline and Bazel --repository_disable_download. --apply --offline fails with offline_required, --apply --frozen fails with frozen_locked, and --check with a set:package selector is unsupported. --bazel-startup-option values reach the bazel call. pnpm runs with npm_config_globalconfig and npm_config_userconfig set to /dev/null (NUL on Windows), so user and global .npmrc files are ignored; a .npmrc in the set directory still applies. The set is unsupported before pnpm starts when locks does not list <set directory>/pnpm-lock.yaml or when a parent directory inside the workspace holds a pnpm-workspace.yaml.

dx security matches every pnpm-lock.yaml package against .dx/advisory/npm.json; a missing snapshot fails the set with failed to assess <name>. dx license reports UNKNOWN for npm packages unless licenses.toml declares them with [[inventory]] and set = "npm".

Selectors are set names (frontend), set:package (frontend:anyio), or scopes (apps/frontend, //apps/frontend/..., //...). No selector selects every configured set. A scope selects every set whose scope covers it; overlapping scopes select every owner and the run names them all. Unknown names and unowned paths fail with exit 2.

dx update and dx update --check run the pinned uv 0.12.23. Set DX_UV_BIN to the absolute path of the @dx_tools//:uv artifact and DX_UV_PYTHON to the absolute path of the @rules_dx//cli/update:python_interpreter interpreter. A relative path is rejected and PATH is never searched. A uv that does not report 0.12.23 is rejected. Without a usable DX_UV_BIN or DX_UV_PYTHON the set fails with backend_unavailable and the run exits 1; JSON reports an error event with that code. It is never reported current. In CI, bazel run @rules_dx//cli/tool_resolve -- --label @dx_tools//:uv --var DX_UV_BIN exports the uv path and the same command with --label @rules_dx//cli/update:python_interpreter --var DX_UV_PYTHON exports the interpreter.

dx update --apply runs uv lock in the set directory and writes that set's lock only. dx update --check runs uv lock --check, writes nothing, and fails the set with updater exited 1 when the lock is stale. Both pass --no-config --no-cache --no-python-downloads --python <DX_UV_PYTHON> and start uv with an empty environment, so HOME, XDG_CONFIG_HOME, UV_* variables, and user uv.toml files are ignored. --check --offline adds --offline; a lock that needs a download to verify then fails the set with updater exited 2. --apply --offline fails with offline_required and --apply --frozen fails with frozen_locked; --check --frozen runs. A uv.toml in the set directory or any parent inside the workspace, or a [tool.uv] key other than sources, workspace, dev-dependencies, default-groups, conflicts, package, managed, override-dependencies, or constraint-dependencies, makes the set unsupported before uv starts. Selective set:package updates are unsupported for uv: the set refreshes as a whole. Updating a read-only set fails; checking one works. dx security and dx license assess the configured locks. dx security matches every uv.lock package, direct and transitive, against .dx/advisory/pypi.json, using PEP 440 versions and PEP 503 names. Workspace members are skipped. Packages from git, path, url, directory, or a registry other than pypi.org are reported as unassessed and the run exits 1, as does an unparseable locked version. A missing lock fails the family. A stale, missing, or failed advisory snapshot fails the set with failed to assess <name>, and --offline fails with offline_required when the snapshot is missing. A security.toml exception uses set = "<name>" and a PEP 440 versions range. dx bump refuses configured uv and npm sets; configured bazel sets widen as described under dx bump.

bazel run @rules_dx//:dx -- update --check frontend
bazel run @rules_dx//:dx -- update --apply worker
bazel run @rules_dx//:dx -- update --apply web:@types/node
bazel run @rules_dx//:dx -- security apps/frontend
bazel run @rules_dx//:dx -- license --output=json

Environment And Codegen Commands

bazel run @rules_dx//:dx -- env
bazel run @rules_dx//:dx -- codegen
dx env [--check] [--apply] [<label>] [-- bazel-options...]
dx codegen [--check] [--apply] [<label>] [-- bazel-options...]

dx env collects the managed tools and env values. It upgrades nothing. dx codegen materializes declared generated source. Both are repository-wide by default.

With one exact // or @ label, dx env acts on that target only. dx codegen <label> builds that label plus every codegen shard that depends on it. For example, dx codegen //proto:api also builds each codegen shard that depends on //proto:api. dx finds the shards in the workspace rule graph (bazel query "kind('rule', //...)") and does not load external repos.

A pattern, a relative label, a path, an empty value or a second label is a usage error and exits 2.

Both check the intended selection by default and report drift without writing. Pass --apply to select it. A missing selection is drift, not success. Checking never selects a new generation, populates managed bins, or commits records.

Both commands also pass --@rules_dx//config:validate=false, --@rules_dx//config:clippy_output_diagnostics=true and --@rules_dx//config:rustc_output_diagnostics=true. Another value for one of them, or for the matching @rules_rust//rust/settings flag, fails before Bazel starts with exit 2. These args fail the same way: --aspects, --output_groups, --remote_download_minimal, --remote_download_outputs=minimal, --build_event_json_file, any --build_event_json_file_path_conversion other than false, --test_arg, and the startup options --bazelrc, --home_rc, --nohome_rc, --system_rc, --nosystem_rc, --output_base, --output_user_root, --host_jvm_args and --server_jvm_out. Pass --output_base or --output_user_root with --bazel-startup-option instead. They pass --remote_download_outputs=toplevel after your options, so a --config cannot stop the plan outputs from downloading. Pass --remote_download_outputs=all or --remote_download_all to keep downloading all outputs.

These commands keep managed state under .dx/, which is gitignored. dx clean reports unselected managed state. dx clean --apply prunes it. They take no --fail-on or --report.

To run both, run env first, then codegen. Stop on the first failure.

bazel run @rules_dx//:dx -- env --apply
bazel run @rules_dx//:dx -- codegen --apply

Output: --output text|json.

Text output starts with Running <command> for <roots>. Then dx prints one result line, where <pair> is (environment <id>, generated <id>):

  • Up to date: dx <command>: already selected setup <setup> <pair>.
  • Drift with a selection: dx <command>: stale selection: current setup <setup> want setup <setup> <pair>.
  • Drift with no selection: dx <command>: no current selection: intended setup <setup> <pair>.
  • With --apply: dx <command>: selected setup <setup> <pair>, or already selected setup when nothing changed.

Drift adds run `dx <command> --apply [<label>]` to select it. --quiet hides all of these lines, drift included. The exit status stays the same.

JSON output writes these events in order:

  • command_started, with mode check or default (--apply).
  • operation, with phase collect. Its scope is the label, or the codegen roots after expansion. The whole repository has no scope.
  • On drift, a notice with code managed_drift.
  • With --apply, a selection with setup, environment and generated.
  • When Bazel fails, an error with code bazel_failed and phase collect.
  • Last, command_finished.

Other failures write an error with its code, then command_finished. Codes include invalid_arguments, invalid_result, no_capability, unreadable_bep and managed_commit_failed.

Exit codes: 0 success. 2 usage or scope errors: a bad scope, a conflicting validate or diagnostics flag, a rejected arg after -- (--aspects, --output_groups, --build_event_json_file, --test_arg or a startup option), or a failed codegen label query. The JSON error keeps the query status. 1 drift, an invalid collection result (invalid_result), no capability (no_capability), a non-UTF-8 temporary event path (unreadable_bep), a commit failure (managed_commit_failed), or a Bazel launch failure. Bazel failures keep Bazel's code.

bazel run @rules_dx//:dx -- env //a:one
bazel run @rules_dx//:dx -- codegen //proto:api
bazel run @rules_dx//:dx -- codegen --apply
bazel run @rules_dx//:dx -- env --apply

dx init

bazel run @rules_dx//:dx -- init
bazel run @rules_dx//:dx -- init --apply
bazel run @rules_dx//:dx -- init --apply my_module
tools/dx init

dx init

dx init [--apply] [module-name]

Adopts dx into an existing tree. Use dx new to start a new project. Checks by default; --apply scaffolds. Takes an optional module name, which defaults to my_project. The module name starts with [a-z0-9] and uses [a-z0-9._-] only. An invalid module name fails before any file is written. Writes each file only when it is missing. The exceptions are the marked block in AGENTS.md and stale launchers (see below). It writes .dx/version, .gitignore, .bazelignore, .envrc, .devcontainer/devcontainer.json, .vscode/settings.json, .vscode/extensions.json, .github/workflows/ci.yml, MODULE.bazel.snippet, the launcher (tools/dx and tools/dx.cmd), and .claude/settings.json with the dx worktree hooks. The hooks run tools/dx, which also runs in a Git Bash or MSYS2 shell on Windows. It also writes a marked block to AGENTS.md, creating the file when it is missing (see below). If .claude/settings.json already exists, it is left alone and the missing hook entries are printed to stderr. The CI caller pins the rules_dx reusable workflow at a reviewed commit and passes rules_dx_version matching the snippet. When the tree has .cu or .cuh files, the snippet also carries the pinned CUDA toolkit and host-compiler stanza and the opt-in cuda-eula license line (see dx new cuda). The .gitignore lists target/, /bazel-*, node_modules/, __pycache__/, *.pyc, .gradle/, /build/, /.dx/ide/ and /.vscode/.rules_rust_analyzer/. The .bazelignore lists .dx, so Bazel skips the IDE output bases. When a .bazelignore already exists it is left alone, and dx init --apply prints dx: .bazelignore lacks .dx; add it so Bazel skips the IDE output base to stderr if it does not list .dx. Editor settings point at the managed interpreter and tools with host-native names. For Rust they set up rust-analyzer with the rules_rust discover and flycheck targets. Both run with bazel run on separate output bases under .dx/ide/, and the file watcher and search exclude bazel-* and .dx/ide/. They configure language servers, not debuggers. See Supported Surface. Flags: --apply. Scopes: optional single module name.

Output: text only. Check mode looks for .dx/version, .devcontainer/devcontainer.json, .gitignore, .bazelignore, .envrc, .vscode/settings.json, .vscode/extensions.json, .github/workflows/ci.yml, MODULE.bazel.snippet, tools/dx and tools/dx.cmd. It does not inspect .claude/settings.json or AGENTS.md. It also flags a dx-written launcher whose version differs from .dx/version, and an empty or non-semver .dx/version. When all of them exist, no launcher is stale and the pin is valid, it prints init current: <module> to stdout. Otherwise it prints dx: init_failed: init drift: missing <paths>; stale <paths>; invalid .dx/version ("<pin>") to stderr (only the parts that apply), followed by the dx init <module> --apply command to run. An invalid pin is not fixed by apply: write the module version to .dx/version, which the message names. Apply prints wrote <path> to stdout for each file it creates or refreshes, and wrote AGENTS.md when the block changes. For each scaffold or launcher file that already exists and is kept, it prints dx: <path> (absent-only, left untouched) to stderr. --quiet hides the stdout lines. The stderr lines still print.

Exit codes: 0 check passed or apply succeeded, 2 usage error, including extra positionals or an invalid module name, 1 check found missing files or apply failed.

Agent commands block

dx init --apply writes this block between <!-- dx:begin --> and <!-- dx:end --> markers in AGENTS.md:

<!-- dx:begin -->
- Start: `tools/dx worktree add <branch>`. Finish: `tools/dx worktree release`.
- Fix first: `tools/dx fix --apply --affected`.
- Then verify: `tools/dx test --affected`.
- Read the `--output=json` result.
<!-- dx:end -->

An existing file gets the block appended. A block that is out of date is replaced. Text outside the markers is never touched, and CRLF files keep CRLF. A begin marker without an end marker is an error and the file is left as is. dx status reports the agents check as warn when the block is missing or out of date, or a hook is missing from .claude/settings.json.

Launcher

tools/dx (sh) and tools/dx.cmd run dx without Bazel. They read .dx/version, download the matching standalone binary and its .sha256 file from the GitHub release into ~/.cache/dx/bin/<version>/ (%USERPROFILE%\.cache\dx\bin\<version>\ on Windows), check the binary against that sha256, and run it. A cached binary is only run. No Bazel server starts.

tools/dx version
tools/dx check //...

Binaries exist for Linux x86_64 and arm64, macOS arm64, and Windows x86_64 and arm64. The download launcher exits 1 and prints the bazel run @rules_dx//:dx -- ... command to use instead when the download fails or the platform has no binary. A binary that does not match its sha256 is deleted and never run.

When .dx/version differs from the launcher's version, the launcher exits 1 and prints:

dx: tools/dx is pinned to dx <v> but .dx/version is '<pin>'; refresh it: bazel run @rules_dx//:dx -- init --apply

dx init --apply writes a missing launcher and rewrites one whose VERSION line differs from .dx/version. With no .dx/version it uses the running dx version. dx treats a launcher as its own when it starts with the template's first three lines (#!/bin/sh, set -eu, VERSION="<v>", or @echo off, setlocal, set "VERSION=<v>"). Any other launcher is yours and is never touched. A dx launcher is rewritten when stale, so hand edits to it are lost. When a launcher would change and .dx/version is not semver, init fails with init_failed before writing any file. dx rollback --apply refreshes existing launchers the same way.

Git hooks

dx does not install or manage Git hooks. For a commit or push hook, write a plain hook or use a hook tool, and have it run:

bazel run @rules_dx//:dx -- check //...

Enforce the same check in CI. See GitHub CI.

dx migrate

dx migrate [--apply] --from <version> --to <version> [scope...]

Rewrites breaking changes between releases. Checks by default; --apply migrates. Both versions are Cargo semver. Target must be newer than source. A major dx bump names dx migrate.

No manifests exist yet, so check and apply fail closed. Flags: --from <version> --to <version>, --apply. --check, --fail-on, --report, --offline, --frozen, --here, --affected, and Bazel options do not apply. Scopes: explicit Bazel labels/patterns or workspace-relative files/dirs. No scope selects //.... Scopes are accepted but not read until a migration ships.

Output: --output text|json. Text prints the failure on stderr: dx: migrate_failed: no qualified migration <from> -> <to>: manifest <manifest> is not shipped. The manifest is migrate-vA-to-vB.json for a major bump and migrate-v<from>-to-v<to>.json for a minor or patch bump. -v first prints Would migrate <from> -> <to> via <manifest> on stdout. json emits command_started (mode check, or default with --apply), one error event (migrate_failed), and command_finished. A usage error prints dx: <message> and the usage line on stderr; json adds an error event (invalid_arguments) and command_finished.

Reports: none. --report is a usage error.

Exit codes: 0 success, which no run reaches until a migration ships. 2 usage errors: missing, empty, or non-semver versions, a target that is not newer (including equal versions), --report, and flags or Bazel options that do not apply. 1 migrate_failed, returned by every valid upgrade today.

bazel run @rules_dx//:dx -- migrate --from 1.0.0 --to 2.0.0
bazel run @rules_dx//:dx -- migrate --from 1.0.0 --to 2.0.0 --output=json

dx new, dx upgrade And dx rollback

bazel run @rules_dx//:dx -- new --apply rust my_project
bazel run @rules_dx//:dx -- upgrade --from 1.0.0 --to 2.0.0
bazel run @rules_dx//:dx -- rollback

dx new

dx new [--apply] <language> [name]

Starts a new project. Use dx init to adopt an existing tree. Checks by default; --apply scaffolds. Scaffolds a minimal project for one language: rust, python, javascript, typescript, go, java, kotlin, scala, csharp, fsharp, c, cpp, rust-web, ruby, powershell, astro, svelte, vue, mdx, zig, elixir, gleam, erlang, or cuda. Never overwrites existing files. There is no --force. Runs outside a workspace.

c scaffolds a standalone C consumer and cpp a standalone C++ consumer. Both build with the managed LLVM toolchain.

Removed spellings fail with exit 2 and name the replacement:

  • cc is now cpp.
  • c# and c-sharp are now csharp.
  • f# and f-sharp are now fsharp.

Every starter also writes .dx/version, .devcontainer/devcontainer.json, .gitignore, .bazelignore, .envrc, .vscode/settings.json, .vscode/extensions.json, .github/workflows/ci.yml and MODULE.bazel.snippet. Check mode reports them when they are missing.

Check mode prints this and exits 0 when every file exists:

new current: <language> <name>

When files are missing it fails with new_failed on stderr and exits 1:

dx: new_failed: new drift: missing <files> (run `dx new <language> <name> --apply`)

--apply prints one line per file it writes, and one stderr line per file that already exists:

wrote <path>
dx: <path> (absent-only, left untouched)

Output: text only.

Exit codes: 0 success, 2 usage errors including an unknown language, an invalid destination or package identity, or extra positionals, 1 missing files in check mode or scaffolding failed.

The [name] is the destination directory and defaults to my_project. Destinations stay inside the workspace. Absolute paths, .. segments, empty segments, backslashes, control characters, and the characters < > : " | ? * are rejected. Segments never end with a space or ., and reserved device names such as CON and NUL are rejected. Nested destinations such as teams/demo are allowed. Spaces and Unicode are allowed in the destination.

The package identity derives from the final destination segment. It is lowercased. ASCII letters, digits, ., - and _ are kept. Every other run of characters folds to one -. Leading and trailing -, _ and . are trimmed. My App scaffolds the my-app package. The derived identity must satisfy the language rule:

  • rust takes Cargo names.
  • python takes dotted names.
  • javascript takes npm names.
  • typescript takes npm names.
  • go takes module paths.
  • java takes Maven artifact ids.
  • kotlin takes Maven artifact ids.
  • scala takes Maven artifact ids.
  • csharp takes NuGet ids.
  • fsharp takes NuGet ids.
  • c takes Bazel module names.
  • cpp takes Bazel module names.
  • rust-web takes Cargo names.
  • ruby takes Bazel module names.
  • powershell takes Bazel module names.
  • astro takes Bazel module names.
  • svelte takes Bazel module names.
  • vue takes Bazel module names.
  • mdx takes Bazel module names.
  • zig takes Bazel module names.
  • elixir takes Bazel module names.
  • gleam takes Bazel module names.
  • erlang takes Bazel module names.
  • cuda takes Bazel module names.

A Bazel module name uses lowercase letters, digits, dots, hyphens and underscores, starts with a letter, and ends with a letter or digit. Rejected names fail before any file is written.

rust-web scaffolds a shared core with native and browser targets: Cargo.toml, src/lib.rs, src/main.rs, BUILD.bazel, MODULE.bazel, .bazelversion, and README.md.

dx new ruby, dx new powershell, dx new zig, dx new elixir, dx new gleam And dx new erlang

dx new [--apply] ruby [name]
dx new [--apply] powershell [name]
dx new [--apply] zig [name]
dx new [--apply] elixir [name]
dx new [--apply] gleam [name]
dx new [--apply] erlang [name]

Scaffolds a standalone consumer with a library, a binary and a test: MODULE.bazel, .bazelversion, .bazelrc, BUILD.bazel, sources, and README.md. Ruby, PowerShell, Zig, Elixir, Gleam and Erlang come from the rules_dx toolchains, so nothing needs to be installed. The PowerShell targets are Linux only. The Elixir, Gleam and Erlang targets need Linux x86_64. The Zig library imports as hello. The Elixir starter is the OTP application hello with sources in lib/, an escript, and an ExUnit test in test/. The Gleam starter has the module hello in src/, a gleeunit test in test/, and a binary, library and test for both Erlang and JavaScript. The JavaScript targets end in _js and run on Node.js. The Erlang starter is the OTP application hello in src/, with an escript and an EUnit test in test/.

bazel run @rules_dx//:dx -- new --apply ruby demo

Build, run and test inside the new project:

bazel build //...
bazel run :demo_bin
bazel test //...

Output: text only.

Exit codes: 0 success, 2 usage errors including an invalid destination or package identity, 1 missing files in check mode or scaffolding failed.

Scaffolding into an existing directory adds absent files only and leaves custom files untouched.

dx new cuda

dx new [--apply] cuda [name]

Scaffolds a standalone CUDA consumer: MODULE.bazel, .bazelversion, .bazelrc, BUILD.bazel, scale.cu, scale.cuh, scale.h, main.cc, scale_test.cc, and README.md. Linux x86_64 only.

rules_cuda and toolchains_llvm only read their tags from the root module, so MODULE.bazel carries the pinned CUDA 13.4 toolkit components and the LLVM 22 host compiler for nvcc. No CUDA install is needed to build.

The stanza also pins cuBLAS, cuFFT and cuRAND. Bazel fetches them only when a target depends on //cuda/components:cublas, :cufft or :curand. A workspace with an older stanza must copy the stanza again to use them.

The stanza also contains cuda_license.license(accept = ["cuda-eula"]), which accepts the NVIDIA CUDA Toolkit EULA for the build. Remove that line to decline; CUDA targets then fail to build.

The test checks host-side logic and launches no kernel, so bazel test //... passes without a GPU. The binary runs the kernel when a GPU is present.

dx init adds the same stanza to MODULE.bazel.snippet when the workspace has .cu or .cuh files.

Output: text only.

Exit codes: 0 success, 2 usage errors including an invalid destination or module name, 1 missing files in check mode or scaffolding failed.

dx new c And dx new cpp

dx new [--apply] c [name]
dx new [--apply] cpp [name]

Scaffolds a standalone consumer with a library, a binary and a test: MODULE.bazel, .bazelversion, .bazelrc, BUILD.bazel, hello.h, README.md, and the sources. c writes hello.c, main.c and hello_test.c and compiles them as C. cpp writes hello.cc, main.cc and hello_test.cc and compiles them as C++. Both use the standard library only. The compiler comes from the managed LLVM toolchain, so nothing needs to be installed. A destination such as 1demo is rejected because the module name must start with a letter.

bazel run @rules_dx//:dx -- new --apply c demo

Build, run and test inside the new project:

bazel build //...
bazel run :demo_bin
bazel test //...

Output: text only.

Exit codes: 0 success, 2 usage errors including a removed spelling such as cc, an invalid destination or package identity, 1 missing files in check mode or scaffolding failed.

Scaffolding into an existing directory adds absent files only and leaves custom files untouched.

Framework Starters

dx new [--apply] astro [name]
dx new [--apply] svelte [name]
dx new [--apply] vue [name]
dx new [--apply] mdx [name]

Scaffolds a standalone consumer with one component, a helper and a test: MODULE.bazel, .bazelversion, .bazelrc, .bazelignore, .npmrc, BUILD.bazel, package.json, pnpm-lock.yaml, pnpm-workspace.yaml, sources, and README.md. The only npm dependency is the framework package, pinned to the version rules_dx qualifies. Node comes from the rules_dx toolchain, so nothing needs to be installed.

bazel run @rules_dx//:dx -- new --apply astro demo

Build and test inside the new project:

bazel build //...
bazel test //...

Output: text only.

Exit codes: 0 success, 2 usage errors including an invalid destination or package identity, 1 missing files in check mode or scaffolding failed.

Scaffolding into an existing directory adds absent files only and leaves custom files untouched.

dx new rust-web

dx new [--apply] rust-web [name]

Scaffolds a standalone shared-Rust consumer: one core library, one native binary, one unit test suite, one web bundle, and one manual browser test. Versions come from the rules_dx pins. Never overwrites existing files.

bazel run @rules_dx//:dx -- new --apply rust-web demo

Build and test inside the new project:

bazel build //...
bazel test //...

Run the browser test with a local driver:

geckodriver --port 4444 &
bazel test :demo_browser_test --test_env=GECKODRIVER_REMOTE=http://127.0.0.1:4444

Output: text only.

Exit codes: 0 success, 2 usage errors including an invalid destination or package identity, 1 missing files in check mode or scaffolding failed.

The browser test is manual. It needs Firefox and geckodriver on a qualified host. Scaffolding into an existing directory adds absent files only and leaves custom files untouched.

dx upgrade

dx upgrade [--apply] --from <version> --to <version>

Runs the pin, migrate, env, and codegen steps in one go with a recovery pointer. A failed step prints the commands to rerun or undo it, with your --workspace and --apply choices kept. Checks by default; --apply upgrades. Both versions are required. No manifests exist yet, so check and apply fail closed and change nothing. Flags: --from <version> --to <version>, --apply. Scopes: none.

A failure prints dx: upgrade_failed: <message> on stderr. With --output=json, an upgrade_failed error event follows command_started, then command_finished.

Output: --output text|json.

Exit codes: 0 success, 2 usage errors including missing versions or a target that is not newer, 1 a step failed.

dx rollback

dx rollback [--apply]

Restores the previous recorded pin in .dx/version. Checks by default; --apply restores the last recorded pin, or removes .dx/version when no earlier pin was recorded. The check reports the pin it would restore and exits 1 until you apply. It runs even when the pin and MODULE.bazel disagree. Refuses without a record, after manual pin edits, or after the record is deleted. A second --apply undoes the rollback. Flags: --apply. Scopes: none.

A pending rollback fails with status_pin_mismatch on stderr and exits 1:

dx: status_pin_mismatch: pin "<v>" can roll back to "<previous>" (run `dx rollback --apply`)
dx: status_pin_mismatch: pin "<v>" has no earlier pin (run `dx rollback --apply` to remove it)

Refusals use the same status_pin_mismatch code. When the recorded previous pin is the current pin, the check prints pin current: rollback <v> and exits 0. --apply prints one of:

pinned <v> (rollback)
removed pin <v> (rollback)

After restoring a pin, --apply rewrites each existing dx-written launcher (tools/dx, tools/dx.cmd) whose version differs, and prints wrote <path> for each one. It never creates a launcher, never touches one without a VERSION line, and leaves launchers alone when it removes the pin. A previous pin that is not semver refuses before anything is written when a launcher would change. If the refresh fails, the pin and its record stay restored and it refuses with:

dx: status_pin_mismatch: pinned <v> but launchers not refreshed: <error> (run `bazel run @rules_dx//:dx -- init --apply`)

With --output=json, dx streams command_started, one status event named pin (status ok, detail the pin, hint dx rollback), then command_finished. Refusals and pending rollbacks emit a status_pin_mismatch error event instead of the pin event.

Output: --output text|json.

Exit codes: 0 success, 2 usage errors, 1 the pin can roll back or the rollback was refused.

bazel run @rules_dx//:dx -- rollback
bazel run @rules_dx//:dx -- rollback --apply

Offline Bundle

offline_bundle assembles a vendored bundle for an airgapped host. The bootstrap_offline tool reads that bundle, installs Bazel from it, and populates the advisory snapshots under .dx/advisory. It is not a dx command; the rest of dx runs through @rules_dx//:dx:

bazel run @rules_dx//:dx -- version

The Macro

load("@rules_dx//deploy/offline:offline.bzl", "offline_bundle")

offline_bundle(
    name = "offline_demo",
    advisory = {"cargo": ":demo/cargo.json"},
    launchers = [":demo/bazelisk-linux-amd64"],
)
  • name: the bundle target and the tree directory it produces.
  • advisory: a dict of advisory set to snapshot label. Sets are cargo, npm, maven, nuget, go, rubygems. Another set fails at load time.
  • launchers: Bazelisk launcher labels. Each file must be named for the host it serves: bazelisk-darwin-amd64, bazelisk-darwin-arm64, bazelisk-linux-amd64, bazelisk-linux-arm64 or bazelisk-windows-amd64.exe. Another name fails at load time. Ship every host you install on.

The Output Tree

Build the bundle and read it from bazel-bin/<pkg>/<name>/:

bazel build //deploy/offline:offline_demo
<name>/advisory/<set>.json
<name>/advisory/SHA256SUMS
<name>/bazelisk/<launcher>
<name>/bazelisk/SHA256SUMS

Each SHA256SUMS lists the files next to it as <sha256> <file>.

Installing From A Bundle

bootstrap_offline --bundle DIR [--install-dir DIR] [--workspace DIR]
  • --bundle DIR: the bundle tree. Required.
  • --install-dir DIR: where bazel (bazel.exe on Windows) is installed. Defaults to $HOME/.local/bin.
  • --workspace DIR: the workspace that receives .dx/advisory. Defaults to the current directory.
  • -h, --help: print the usage line on stdout and exit 0.

The tool checks every SHA256SUMS, installs the launcher for the host, copies each snapshot to .dx/advisory/<set>.json, and writes .dx/advisory/<set>.meta.json with the set, the file:// URL of the snapshot in the bundle, its sha256, the retrieval date and the workspace path. The URL is percent-encoded, so any path the host allows is recorded faithfully.

It prints three bootstrap-offline: lines on stdout: the installed launcher with its sha256, the number of snapshots populated, and a note that the first Bazel module fetch still needs network once.

Refusals And Exit Codes

  • 0: success, or --help.
  • 1: a refusal or a bad argument. The reason goes to stderr as bootstrap-offline: <reason>; a bad argument adds the usage line. Nothing goes to stdout.

Refusals include: a missing or absent --bundle, a bundle without bazelisk/ or advisory/, an unsupported host, a missing launcher for the host, a manifest that names a missing file or no files, a checksum mismatch, an advisory snapshot the manifest does not pin, and an unknown advisory set.

dx status And dx version

bazel run @rules_dx//:dx -- status
bazel run @rules_dx//:dx -- status --output=json
bazel run @rules_dx//:dx -- version

dx status

dx status

Reports toolchain, Bazel, module, host, exec_tools, tools, pin, and agents health. With --verbose it adds one capability record per dx command. Takes no scopes and no per-command flags. Runs anywhere with a workspace. It is read-only: it downloads, installs, repairs, and runs nothing.

Output: --output text|json. --verbose adds the capability records.

Exit codes: 0 all checks pass, 2 usage error, 1 a check failed or .dx/version is missing.

Failure Explainer

dx status prints one line per check, for example:

toolchain: ok (available: rust 1.98.0 via rules_rust 0.74.0 declared in MODULE.bazel) hint: bazel build //...

Inside a dx worktree slot it adds a lease check that names the holder and branch.

The checks are toolchain, bazel, module, host, exec_tools, tools, pin, and agents. With a dx launcher there is also launcher. Inside a worktree slot there is also lease.

  • toolchain: reads the rust toolchain versions and the rules_rust version declared in MODULE.bazel. ok when they match the versions dx is tested with. warn when MODULE.bazel is missing, does not parse, declares no rust toolchain, takes rules_rust from an override, or declares other versions. It never fails dx status, and it reports what MODULE.bazel declares, not what Bazel resolves.
  • bazel: reads .bazelversion and looks for bazel or bazelisk on PATH.
  • module: compares the rules_dx version in MODULE.bazel, or in a file it include()s, with the dx version. It reports error with the parser message when either cannot be read or parsed. A single_version_override version wins over bazel_dep. An override without a version is warn.
  • host: checks the machine is a v1 execution platform: linux_x86_64, linux_arm64, macos_arm64, or windows_x86_64. The target platform never selects tools. This is not native qualification.
  • exec_tools: reads .bazelrc and the vendored preset. warn when --host_compilation_mode=fastbuild is not set for build or dx_dev, so exec tools build at opt.
  • tools: reads the managed tools dx env --apply selected under .dx/setups/current. warn when none is selected. error when the selection is foreign, its environment or values.json is missing or corrupt, or a tool link is missing, is not a link, or points at a missing Bazel artifact. Pinned quality tools resolve at build time and are not probed.
  • pin: compares .dx/version with the rules_dx module version this dx was built from. An empty or invalid (non-semver) pin hints write <module version> to .dx/version.
  • launcher: shown only when tools/dx or tools/dx.cmd is a dx launcher (see dx init). error when that version differs from .dx/version, with detail tools/dx pins dx <v>, .dx/version is <pin> and hint bazel run @rules_dx//:dx -- init --apply. It fails dx status with status_check_failed.
  • agents: warn when AGENTS.md has no dx block or an out-of-date one, or .claude/settings.json lacks a dx worktree hook. dx init --apply writes it.

ok passes. warn means unknown and does not fail. error fails, and the hint is the fix, for example dx upgrade --from <version> --to <version>. The toolchain, bazel, module, host, exec_tools, and tools detail text starts with available:, unavailable:, unknown:, or not applicable:. Unknown is never healthy.

With --verbose, text output then prints one capability <name> line per command, then capability workspace:tools.

--output=json streams command_started, one status event per check with name, status, detail, and hint, then command_finished. With --verbose it adds one capability event per command and one for workspace:tools before command_finished. A capability event has name, source, availability, detail, flags, outputs, reports, scope_policy, effect, and passthrough. source is cli-grammar for commands and workspace-record for workspace:tools. availability is available, unavailable, or unknown, and workspace:tools follows the tools check. A grammar record says the command is accepted, not that its tools are installed.

A failure emits exactly one error event and exits 1. If pin fails, the event is status_pin_mismatch, even when other checks fail too. Otherwise any failed check adds status_check_failed, which names the failed checks. An empty .dx/version fails the pin check.

A missing .dx/version stops dx status with status_pin_mismatch (read version pin: ...) before any check runs. It prints no check lines. JSON output has only command_started, error, and command_finished.

dx: status_check_failed: failed checks: module, tools (see hint)

dx version

dx version [--check]

Prints the version. Read-only: it never writes the pin. Bare dx version also prints the rules_dx dependency the workspace declares, including any override and its source.

  • --check: verify the pin without changing it.

To change the pin, run dx upgrade. To restore the previous pin, run dx rollback.

Flags: --check. Scopes: none.

Output: --output text|json. Bare text prints one line each: dx <version>, rules_dx <version>, pin <pin>, and dependency <dependency>.

Bare JSON emits binary, module, pin, and dependency status events. pin is warn when the pin differs from the module version. dependency is warn when MODULE.bazel is missing or unreadable, has no rules_dx dependency, or declares or overrides a different version. When MODULE.bazel or an included file cannot be parsed, dependency prints unknown (<reason>). Bare dx version still exits 0 on these warnings.

dx version --check prints version ok: <pin> on stdout. On drift it prints dx: status_pin_mismatch: version drift: <pin> != <module> on stderr and exits 1. JSON emits command_started, one pin status event (ok or error), then command_finished. On drift a status_pin_mismatch error event comes before command_finished.

A missing .dx/version makes both forms print no version lines and fail with status_pin_mismatch.

Exit codes: 0 success, 2 usage errors including removed flags, 1 pin drift under --check or a missing .dx/version.

bazel run @rules_dx//:dx -- version
bazel run @rules_dx//:dx -- version --check

Version Skew

Every command compares the pin in .dx/version with the rules_dx module version built into dx before it runs. A mismatch is version skew. For example, MODULE.bazel and .dx/version say 1.2.0 and the binary is 1.3.0, so dx build stops with version skew: binary 1.3.0 pin 1.2.0 module 1.3.0.

  • Runs anyway: version, status, rollback, completion, worktree.
  • Warns and runs: check, security, license, list-tests.
  • Stops with exit code 1: every other command.

watch answers for the command it wraps. A missing or empty .dx/version is not skew, so other commands run. An empty pin fails the pin check in dx status and dx version --check. A missing file makes dx status and dx version exit 1 with status_pin_mismatch.

A stop prints dx: version skew: binary <version> pin <pin> module <version> on stderr and names the fix. Under --output=json it adds a version_skew error event, then command_finished with exit_code 1 and results_complete false.

bazel run @rules_dx//:dx -- version --check
bazel run @rules_dx//:dx -- rollback
bazel run @rules_dx//:dx -- build //...

dx watch

dx watch [--apply] [--debug|--release] [flags] <build|test|run|lint|typecheck|format|check|fix> [scope ...] [-- bazel-options|--app-args ...]

Reruns one command when files change. Scope is re-resolved each iteration. Flags go to the wrapped command and are checked as dx <command> checks them: dx watch test --release, dx watch lint --report sarif=out.sarif. A flag the wrapped command rejects fails before watching starts. Short options work wherever the long option does. Bare lint, typecheck, format, and fix only check; pass --apply to write on every iteration. Args after -- reach the wrapped command unchanged.

  • Wraps only build, test, run, lint, typecheck, format, check, and fix. Other commands fail with not watchable.
  • Takes --output, --check, --apply, --fail-on, --report, --include-edits, --here, --affected, --debug, and --release, plus the global flags, when the wrapped command takes them.
  • --here resolves once from the current directory. --affected re-resolves each iteration.
  • Local only. Refuses CI=true. No daemon, cache, or remote execution.
  • Ignores changes under .dx/, .git/, and bazel-* inside the workspace. Parent directories with those names do not matter. Also ignores the wrapped command's --report files and their temp files, matched by path relative to the workspace. Edits to dx.toml, dx.local.toml, BUILD and MODULE.bazel files rerun the command.

Except under run, changes made while the command runs queue one more iteration. Iterations never overlap. A failing command does not stop the watch. Each iteration takes a build slot and, on low disk, drops free slots' output bases first. Watch holds none while it waits for changes. See Build Slots and Disk Space.

watch run restarts the app on each change. The running app and every process it started get SIGTERM. Any still running after 10 seconds is killed. The replacement builds and launches only after the old app is gone. An app that exits on its own is relaunched on the next change. The app gets no stdin.

Output: --output text|diff|json, when the wrapped command takes that mode. Each iteration writes the wrapped command's own output. Watch adds one line per iteration and one line per trigger, naming the first changed absolute path. --quiet drops these lines. With --output json, --output diff, or --report <format>=-, they go to stderr so stdout holds only the wrapped command's output: under JSON, one event stream per iteration, each ending in command_finished. For a restarted app, command_finished reports 143.

scope= shows the given scopes, //... when none are given, or --affected.

watch:test:debounce=200ms scope=//... iteration=1
watching (Ctrl-C to stop)
changed /work/repo/src/main.rs (+1 more)
watch:test:debounce=200ms scope=//... iteration=2
stopped

Ctrl-C, SIGTERM or SIGHUP stops the watch. No new iteration starts after the signal. A running command gets the signal and is awaited first. Removing the workspace directory stops the watch with watch_failed.

Watch runs until stopped and has no success exit. Exit codes: 2 usage error, not watchable, or CI=true; 1 operational failures such as watch_failed; 141 when stdout closes; 129 after SIGHUP, 130 after Ctrl-C, 143 after SIGTERM.

bazel run @rules_dx//:dx -- watch build //...
bazel run @rules_dx//:dx -- watch test //cli/...
bazel run @rules_dx//:dx -- watch test --affected
bazel run @rules_dx//:dx -- watch lint --output json
bazel run @rules_dx//:dx -- watch run //app:bin

dx worktree

dx worktree add <branch> [--create=<ref>] [--force] [--output=path]
dx worktree release [<path>] [--force]
dx worktree list [--disk]
dx worktree path <branch>
dx worktree prune [--apply] [--bases]
dx worktree hook create|remove|stop|guard

Keeps a small pool of warm checkouts, called slots, so a new worktree reuses a slot that already has a Bazel server, output base and caches. Slots live at <cache>/dx/worktrees/<repo>/<n>. <cache> is $XDG_CACHE_HOME, else $HOME/.cache, else %LOCALAPPDATA%. Lease records live in <cache>/dx/worktrees/<repo>/.dx/. <repo> is the basename of the main checkout, or <repo>-2, <repo>-3 when another repository already uses that name.

A bare dx worktree runs list.

Flags: --create=<ref> (add only), --force (add and release), --disk (list only), --apply (prune only). A flag on the wrong subcommand exits 2. Scopes: none. dx worktree takes a subcommand and its arguments. Reports: none.

--output text|json|path. path works with add only. Text prints the lines above. add prints dx worktree: <branch> is in slot <n> at <path>. When add starts a new branch, it first prints dx worktree: started <branch> from <base>, on stderr with --output=path. <base> is the --create ref, else origin/HEAD, origin/main, or origin/master. release prints dx worktree: released slot <n> at <path>, or dx worktree: removed <path> for an external worktree. Releasing a slot that is already free prints dx worktree: slot <n> is already free and exits 0. JSON reports it as a slot event with slot, action release, and state free.

--output=json streams command_started, one slot event per slot, and command_finished. list adds a summary event after the slots. prune emits slot events with action prune, action prune_base for output bases, and action prune_downloads for repository cache downloads.

When git fetch origin fails, add still uses local refs. Text prints dx worktree: cannot fetch origin, using local refs: <error> before the slot line. JSON emits a notice event with level warning and code fetch_failed after command_started. hook create prints the same line on stderr. When the caller already holds a slot on <branch>, add returns that slot without fetching origin.

When the pool's disk or the disk of a Bazel output root has under 20 GiB and under 10% free, or under 1 GiB free whatever its size, add and hook create (after they fetch and pick a slot) and release drop orphaned output bases, then repository cache downloads unused for 30 days, then free slots' output bases, each oldest first, and print one of these lines before the slot line. add and hook create keep the output base of the slot they lease, so it stays warm for the build that follows.

  • dx worktree: low disk: <free> free; removed <what>; <free> free now
  • dx worktree: low disk: <free> free; nothing to reclaim
  • dx worktree: low disk, cannot reclaim output bases: <error>

The first two end with ; run dx worktree prune --apply while disk stays low, so a pool with nothing to reclaim prints low disk: <free> free; nothing to reclaim; run dx worktree prune --apply. All three use the disk_low notice code. JSON emits a notice event with level warning and code disk_low before the slot event. Reclaim stops the idle Bazel server of a free slot before it deletes that slot's output base; a leased slot, one with a build running, and one whose server will not stop are never touched.

When the pool's disk still has under 1 GiB free after that reclaim and the slot they picked is a free slot, add and hook create stop that slot's idle Bazel server and drop its output bases as a last resort. The disk_low line then ends with disk full: <free> free; removed the output bases of slot <n>, which this lease reuses; <free> free now. When the disk is still that full, they refuse before they check anything out, with code disk_full, exit 1, and the message disk full: <free> free at <path>; cannot check out <branch>; free disk space with `dx worktree prune --apply --bases` . A refusal emits no slot event. A branch you already hold still resolves to its slot.

slot fields: action (add, release, list, path, prune, prune_base, prune_downloads), path, state, server, and optional slot, branch, holder, reason, base, disk_bytes, and entries. base is set on add when it starts a new branch. disk_bytes is set for prune, prune_base, and prune_downloads events other than kept and skipped, and for list with --disk. entries is set for prune_downloads: the number of downloads, and path is the repository cache's content_addressable directory.

state values:

  • held: add, path, and list for a leased slot.
  • free: release and prune of a slot, and list for an unleased slot.
  • reclaimable: list for a held slot that add may take over.
  • external: list and prune for a worktree outside the pool.
  • removed: release of an external worktree.
  • free: also prune_base for the output base of a free slot, with its slot.
  • kept: prune --apply for a worktree it skipped, and prune_base for an orphaned output base it keeps, with its reason.
  • orphaned: prune_base for an output base whose workspace is gone.
  • skipped: prune for a slot, and prune_base for an output base (with its slot when it belongs to a free slot), that prune --apply could not delete, with a reason (server, held, or busy). prune events in this state have no disk_bytes.
  • stale: prune_downloads for repository cache downloads unused for 30 days.

summary fields: slots, servers, servers_rss_bytes, plus disk_bytes and disk_cache_bytes with --disk.

Exit codes: 0 success, 2 usage errors (unknown subcommand or hook name, wrong arguments, an invalid branch name (invalid_branch), a path that is not a slot (not_a_slot, including a bare release from the main checkout), a flag on the wrong subcommand, an unknown --create ref, --create on an existing branch, release of a slot another holder holds without --force, any other dx command in a slot leased to another session or in a slot that add is switching, and running outside a git repository, in a bare repository, or where the main checkout cannot be found), 1 operational failures (a branch already checked out elsewhere or held by another holder without --force, a dirty or unpushed slot or external worktree on release without --force, slot_busy when a dx command runs in the slot that add --force or release wants, even with --force, a busy pool lock, no_home when no cache directory can be found, lease_changed when another holder took the slot during the command, path finds no checkout, prune finds work without --apply, no default branch to start a new branch from, and any hook create, hook remove, or hook stop failure), and 128 plus the signal number when Ctrl-C, SIGTERM, or SIGHUP stops list --disk or prune (for example 143 after SIGTERM), with stderr dx: cancelled: stopped by signal N.

Subcommands

add <branch> leases a slot, checks out <branch>, and prints where it is. It picks the slot that last held the branch, then a slot with the same MODULE.bazel.lock, then the most recently used, whatever the free disk. When disk is low, under 20 GiB and under 10% free or under 1 GiB free, it then deletes orphaned output bases, then repository cache downloads unused for 30 days, then the output bases of the other free slots, least recently used first, until each output base's disk is no longer low. It keeps the output base of the slot it picked. It reuses a free slot before it creates one. Files matched by .worktreeinclude in the main checkout are copied in. --create=<ref> starts a new branch from <ref>. Without it, add uses the local <branch>, then origin/<branch>, and otherwise starts <branch> from the default branch (origin/HEAD, else origin/main, else origin/master). --output=path prints only the path on stdout and writes the dx worktree: ... warning lines to stderr. Submodule checkouts and --separate-git-dir clones work too.

add refuses when the branch is already checked out in a slot held by another holder or in a worktree outside the pool, and when --create names a branch that exists. add returns your own slot for <branch> as is. add --force hands a slot that holds <branch> for another holder to you as is, without resetting it. add takes over a held slot, including one that holds <branch>, only when its holder is gone, no dx command runs in it, the tree is clean, and no commit is unpushed. It does not wait. Otherwise it refuses and says which condition fails. add never hands out, resets, or deletes a slot while a dx command runs in it, free or held. add also deletes free slots idle for 30 days.

release [<path>] frees the slot at <path>, or the slot you are in. A relative <path> is read from the directory you ran dx in, including under bazel run. It stops the slot's Bazel server and detaches the checkout. The branch and its commits stay in the repository. It refuses a dirty tree or unpushed commits unless you pass --force; uncommitted changes are then wiped by the next add of that slot. Only the holder can release a held slot, unless it is reclaimable or you pass --force. Another agent in the same session is another holder. While a dx command runs in the slot, release exits 1 with slot_busy, even with --force. When disk is low, release deletes orphaned output bases, then the output bases of free slots, least recently used first, until each output base's disk is no longer low.

release <path> on an external worktree of this repository, such as an old .claude/worktrees/<task> checkout, stops its Bazel server, removes the checkout and its output base, and keeps the branch. It refuses a dirty tree or unpushed commits unless you pass --force, which removes it anyway. The main checkout is never removed.

Each refusal of a subcommand ends with the next step, such as pass --create=<ref>, run the command again, pass a path from dx worktree list, or run dx worktree add <branch>.

list prints one line per slot and a totals line:

1	feature-x	session 7f3a agent a91c pid 4121	held	server	-	/home/me/.cache/dx/worktrees/app/1
2	(detached)	-	free	no server	-	/home/me/.cache/dx/worktrees/app/2
3	review-y	pid 4180 (gone)	held	no server	-	/home/me/.cache/dx/worktrees/app/3
1 bazel server(s) running, 2.1 GiB resident

A holder that has exited shows (gone) after its name.

The columns are slot, branch, holder, state, server, disk use, and path. The disk column shows - unless you pass --disk, which measures each slot and its output base and adds the total to the last line, followed by the size of the shared disk cache (0 B when it does not exist). It is slow on a large pool.

$ dx worktree list --disk
1	feature-x	session 7f3a agent a91c pid 4121	held	server	1.2 GiB	/home/me/.cache/dx/worktrees/app/1
2	(detached)	-	free	no server	900.0 MiB	/home/me/.cache/dx/worktrees/app/2
1 bazel server(s) running, 2.1 GiB resident, 2.1 GiB across slots, 3.4 GiB disk cache

state is held, free, reclaimable (holder gone, and the reclaim rule above would allow taking it), or external (a worktree outside the pool). A held slot whose holder is gone but which has uncommitted changes or unpushed commits shows why after held, such as held (holder gone, commits are not pushed).

path <branch> prints the path of the checkout that holds <branch>, and fails with exit 1 when none does.

prune lists free slots idle for 30 days, external worktrees that are clean, fully pushed, not locked, without a running Bazel server, and on a branch that tracks origin/<branch> and is merged into the default branch, or whose origin/<branch> is deleted, and Bazel output bases of removed worktrees, including checkouts removed from .claude/worktrees. It also lists the output bases of the other free slots whose disk is low, least recently used first, as output base of free slot <n>, <size>: <dir>. --bases lists the output bases of every free slot, whatever the disk state, least recently used first, except the slots already listed as idle. --bases works with prune only. It also lists the downloads in Bazel's repository cache that no build used for 30 days. Only the default cache, cache/repos/v1/content_addressable under each Bazel output root, is checked. A cache moved with --repository_cache is not cleaned, and the contents directory next to it is not touched. A deleted download is fetched again when a build needs it. --apply deletes the idle free slots and stops each slot's Bazel server and deletes those output bases; the other slots stay. --apply removes the external worktrees with their output bases and keeps the branches. It deletes nothing without --apply. It exits 1 when something would be pruned and --apply is absent, and 0 when there is nothing to prune.

Without --apply, text prints one line per item, and a closing line dx worktree: <size> to reclaim; run dx worktree prune --apply. The item lines are slot <n> idle <days> days, <size>: <path>, external worktree <branch>, <size>: <path>, output base of free slot <n>, <size>: <dir>, output base of removed <workspace>, <size>: <dir>, and repository cache downloads unused for 30 days, <n> entries, <size>: <dir>, one per cache. With --apply the closing line is dx worktree: pruned <size>, and dx worktree: nothing to prune replaces everything when nothing qualifies.

--apply finds and deletes everything under the pool lock. It checks each external worktree again. One that became locked, got a Bazel server, uncommitted changes, or unpushed commits is kept and printed as kept external worktree <branch> (<reason>): <path>, with reason locked, server, dirty, unpushed, or busy (the delete failed and part of it is still on disk). JSON reports it with state kept and that reason. It still exits 0.

An output base whose workspace is gone but that a Bazel server still uses is kept and printed as kept output base of removed <workspace> (server): <dir>. JSON reports it as prune_base with state kept and reason server.

A free slot's output base whose Bazel server will not stop, whose slot was taken, or whose slot runs a dx command, is kept and printed as skipped output base of free slot <n> (<reason>): <dir>, with reason server, held, or busy. An orphaned output base that cannot be deleted is printed as skipped output base of removed <workspace> (busy): <dir>; this also covers the output base of a slot that prune deleted while the base stayed. A slot that was taken after the plan or is in use is printed as skipped slot <n> (<reason>): <path>. So is a slot whose checkout is still partly on disk after the delete; dx worktree add no longer leases it, and the next prune or add tries to delete it again. Skipped items are not counted in pruned <size>, and the command still exits 0.

--apply checks each download again right before it deletes it. One that a build used since the check is kept without a line and is not counted in pruned <size>.

Shared disk cache

The main checkout and every slot share one Bazel disk cache at <cache>/dx/disk-cache, so a branch built in one checkout is not rebuilt in another. dx build, test, coverage, run, and deploy add --disk_cache=<cache>/dx/disk-cache after the Bazel verb. They skip it when the workspace .bazelrc, a file it imports, ~/.bazelrc, or the system rc sets --disk_cache or --nodisk_cache. Under CI=true and inside Bazel tests no disk cache is added. Turn it off for one run with -- --disk_cache=.

bazel run @rules_dx//:dx -- build //... -- --disk_cache=

Disk cache size

When dx adds the disk cache, it also bounds it, using the same values as the vendored preset:

--experimental_disk_cache_gc_max_size=20G
--experimental_disk_cache_gc_max_age=14d

Bazel then deletes the oldest entries once the cache is over 20G, and entries older than 14 days. dx adds both flags or neither. It leaves them out when an rc file it reads sets either flag, or when the run's arguments set either flag or --disk_cache. Your own values always win.

Bazel cleans the cache only while a server is idle. The cleanup starts after --experimental_disk_cache_gc_idle_delay, which is 5 minutes by default, and only if the server's last command carried the bound. A server that shuts down sooner, because of a short startup --max_idle_secs, never cleans. So when an rc file sets startup --max_idle_secs below 900, dx also adds --experimental_disk_cache_gc_idle_delay=<max_idle_secs / 3>s. It does this even when you set your own cache and bound, and skips it when an rc file or the run's arguments set the idle delay.

Change the bound for every run in ~/.bazelrc:

common --experimental_disk_cache_gc_max_size=40G
common --experimental_disk_cache_gc_max_age=30d

Or for one run:

bazel run @rules_dx//:dx -- build //... -- --experimental_disk_cache_gc_max_size=40G

Hooks

hook create, hook remove, hook stop, and hook guard read one JSON object from stdin for Claude Code hooks.

Run as exactly dx worktree hook <kind>, hooks ignore dx.toml and DX_* variables and always print text. create, remove, and stop exit 1 on any failure, including empty or invalid stdin. When create, remove, or stop gets input without its field, the message names the field and the hook event that runs it.

  • create reads name and cwd. Without cwd, it uses the current directory. It leases a slot on branch worktree-<name>, where characters other than letters, digits, -, _, and . become -, so my task gives worktree-my-task. It prints only the path on stdout. Warnings such as dx worktree: cannot fetch origin, using local refs: <error> go to stderr and do not change the exit code. It reuses a local branch with that name. A new branch starts from the default branch. If only origin has worktree-<name>, it uses worktree-<name>-2 (or the next free number). Subagent worktrees get no remove hook, so create first frees the session's earlier worktree-* slots that are at least a minute old, clean, fully pushed, and not running a dx command. A slot freed this way goes to a later create, never the one that freed it. A slot that another agent leased in the meantime is left alone. The slot is held by worktree-<name> until the first dx worktree add or release that the same session runs inside the slot. That agent then becomes the holder.
  • remove reads worktree_path and releases that slot. Paths outside the pool are ignored.
  • stop reads agent_id for the SubagentStop event. It marks the slots that agent holds as held by a gone holder. Slots no agent has claimed are not marked; a later create frees them.
  • guard reads tool_input.command. It exits 2 with a message on stderr when the command runs git worktree add, remove, or prune. When agent_id is set and the command runs dx worktree, it prints an updatedInput that exports DX_WORKTREE_HOLDER=<agent_id> before the command. When stdin is empty or not valid JSON, it prints a note on stderr and exits 0. It exits 0 otherwise.

Commit .claude/settings.json. The hooks run the repo launcher, so dx does not need to be on PATH:

{
  "hooks": {
    "WorktreeCreate": [{"hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/tools/dx worktree hook create"}]}],
    "WorktreeRemove": [{"hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/tools/dx worktree hook remove"}]}],
    "SubagentStop": [{"hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/tools/dx worktree hook stop"}]}],
    "PreToolUse": [{"matcher": "Bash", "hooks": [{"type": "command", "command": "\"${CLAUDE_PROJECT_DIR:-.}\"/tools/dx worktree hook guard"}]}]
  }
}

dx init --apply writes this file when it is absent and never overwrites it. If .claude/settings.json exists, it prints the missing entries. It also writes the launcher and the agent commands block in AGENTS.md; see dx init. dx status reports the agents check as warn when a hook is missing.

Leases

The holder is the agent session (CLAUDE_CODE_SESSION_ID or CODEX_THREAD_ID), the agent (DX_WORKTREE_HOLDER, set by hook guard), and a process: CLAUDE_PID when it is an ancestor of dx, else the nearest ancestor that is not a shell or a wrapper such as timeout, env, nice, nohup, setsid, stdbuf, time, or xargs. Two agents in one session are different holders, and list shows each one. A slot from hook create goes to the first agent of the same session that runs dx worktree add or release inside it. A holder is gone when its process exits or hook stop marks it. Any other dx command run inside a slot leased to a different session exits 2 and names the holder. dx status inside a slot shows the lease. Every other dx command marks the slot busy while it runs, so no add, release, or hook create resets or frees it. While add switches a slot, such a command waits, and exits 2 with slot N is being switched by dx worktree; run the command again if the switch takes longer than two minutes. worktree, version, and completion always run.

Examples

bazel run @rules_dx//:dx -- worktree add feature-x
bazel run @rules_dx//:dx -- worktree add feature-x --create=origin/main
bazel run @rules_dx//:dx -- worktree add feature-x --output=path
bazel run @rules_dx//:dx -- worktree release
bazel run @rules_dx//:dx -- worktree list --output=json
bazel run @rules_dx//:dx -- worktree prune
bazel run @rules_dx//:dx -- worktree prune --apply

dx completion

dx completion <shell>

Prints the completion script for one shell: bash, zsh, fish, or powershell. Runs outside a workspace.

  • Takes exactly one shell and prints its script to stdout.
  • No shell, more than one shell, --check, --output=json, --output=diff, or -- Bazel options is a usage error.
  • Unknown shells fail with unknown-shell.

The script asks dx for candidates as you type, so keep dx on your PATH. Candidates cover every command, every flag, the per-command slots, the package labels of the selected workspace, and the targets of one package.

Candidates come from the workspace a run would use: --workspace, then DX_WORKSPACE (ignored in CI), then workspace in dx.toml, then the current workspace. While you type, a broken dx.toml falls back to the current workspace without output. dx completion <shell> itself fails with invalid_defaults when dx.toml is invalid. Nested workspaces and .bazelignore entries never offer labels.

Target completion runs one bazel query for the typed package, with the typed --bazel-startup-option tokens before query. It gives up after about 1.5 seconds or when another Bazel command holds the server, and then offers package patterns only. A cold bazel query can start a Bazel server that keeps running after completion gives up. On Windows that server outlives the terminal or tool that ran dx, unless that tool's job forbids breakaway. The query's client and any wrapper such as Bazelisk stop when completion gives up. Completion prints no errors while you type.

Output: text only. --output=json and --output=diff are usage errors. DX_OUTPUT=json and output = "json" in dx.toml fall back to text.

Exit codes: 0 success, 2 usage errors (missing or unknown shell, rejected flags) or an invalid dx.toml, 1 stdout could not be written, 141 stdout was closed early.

bazel run @rules_dx//:dx -- completion bash > ~/.cache/dx-completion.bash

To load completion in every new shell:

  • Bash: add source <(dx completion bash) to ~/.bashrc.
  • Zsh: add source <(dx completion zsh) to ~/.zshrc after compinit.
  • Fish: add dx completion fish | source to ~/.config/fish/config.fish.
  • PowerShell: add dx completion powershell | Out-String | Invoke-Expression to $PROFILE.

The script calls dx from your PATH.

dx list-tests

dx list-tests [scope ...] [-- bazel-options ...]
dx list-tests --cases [scope ...] [-- bazel-options ...]
dx list-tests --configured [scope ...] [-- bazel-options ...]
dx list-tests --here [-- bazel-options ...]
dx list-tests --affected [-- bazel-options ...]

Lists the configured test targets for a scope. It never builds or runs them unless you pass --cases. Use dx test to run tests. The inventory reads Bazel facts through the tests() query function, so a custom test rule counts when Bazel marks it a test, even when its name does not end in _test. An explicit label that is not a test, and a pattern that matches no tests, fail instead of reporting an empty suite.

A bare run lists tests(//...). Label and pattern scopes pass through. A workspace-relative file resolves to the tests that own it or depend on its owners; a test tagged manual is kept only when it owns the file. Workspace-relative directories resolve to their owning tests first. --here lists the current directory tree instead and never combines with explicit scopes. --affected lists only the tests your git changes reach (see Scope Defaults); with none it prints no affected targets and exits 0. It never combines with scopes or --here. Trailing -- options reach every Bazel invocation behind the command, including the inventory query.

The default inventory is unconfigured (bazel query). --configured reads configured targets through bazel cquery instead. File ownership resolution stays unconfigured in both modes, so owners are found without configuration. With --configured, a file scope keeps only the tests that reach its owners in the configured graph (cquery rdeps). Such a scope can drop tests or fail with no_tests.

--cases enumerates test cases for Rust targets (rust_test and the dx_wrap forwarder) by building each test and running it with --list. Listing never runs test bodies, but --cases builds the selected tests and executes their binaries and initialization code; pass it only for targets you trust. The --list run also passes --@rules_dx//config:validate=false, --@rules_dx//config:clippy_output_diagnostics=true and --@rules_dx//config:rustc_output_diagnostics=true. Another value for one of them, or for the matching @rules_rust//rust/settings flag, fails before Bazel starts with exit 2. Every other framework reports unsupported_framework with its rule kind, never an empty suite. Case output is sorted and deterministic, with no cap. It ends with a completeness line (complete or incomplete) on stderr, or a list-tests-summary status event (ok or warn) with --output json. Inventory stays useful without case enumeration.

Output: --output text|json. dx list-tests has no report format. Text prints one label per line, or one label: case line per case with --cases; problems go to stderr. JSON starts with command_started, adds one status event per label (detail is the label, hint names the scope and the query verb), one status event per case with --cases (detail is the case, hint is the label), one error event per problem, and ends with command_finished. Each error event carries code, message, cause, phase, and status when a Bazel run exited (bazel_failed, case_failed).

Exit codes: 0 success, 2 usage errors including an external scope, a relative label, an unreadable path, a missing path (invalid_scope), a file no target owns (no_owner), or a Bazel option --cases cannot pass to bazel run, 1 operational failures including no_tests, bazel_failed, query_failed, query_launch_failed, launch_failed, unsupported_framework, no_cases, or case_failed. A partial match still exits 1 when any scope contributed no tests.

bazel run @rules_dx//:dx -- list-tests //cli/test_scratch/...
bazel run @rules_dx//:dx -- list-tests --cases //cli/test_scratch:dx_test_scratch_test_upstream
bazel run @rules_dx//:dx -- list-tests --cases //cli/test_scratch:dx_test_scratch_test_upstream --output=json
bazel run @rules_dx//:dx -- list-tests --configured //cli/test_scratch/... -- --config=ci

Scope Defaults

Most dx commands take Bazel scopes. This page lists what each command does with a scope.

No Scope Means //...

  • dx build, dx test, dx coverage, dx lint, dx typecheck, dx format, dx check, dx fix, dx security, dx license, dx migrate, dx list-tests

No Scope Means The Repository

  • dx generate: runs Gazelle over the whole repo.
  • dx docs: builds //tools/docs/site:user_site, or //tools/docs/site:user_site_check in --check.

Repository-Wide Or One Exact Label

  • dx env, dx codegen

Patterns, paths, and multiple labels are usage errors.

Set Selectors

  • dx update: set, set:package, label, or path selectors. No selector checks every set; --apply writes.
  • dx bump: exactly one set:package plus one version.
  • dx add: exactly one undeclared set:package plus one version.

Required Arguments

  • dx run: needs a runnable scope.
  • dx deploy: exactly one main-workspace label.
  • dx watch: needs a watchable command plus its scopes.
  • dx new: needs a language, plus an optional name.
  • dx init: optional module name.
  • dx completion: exactly one shell.

No Scopes

  • dx clean, dx status, dx version, dx upgrade, dx rollback, dx worktree

Scope Shapes

  • Labels and patterns: //..., //pkg:target, @repo//....
  • Files and dirs: workspace-relative paths, also under bazel run. A file resolves through one bazel query of the workspace rules to the rules that use it directly. Owners in other packages are included. External repos are not loaded. A dir becomes //dir/....
  • Paths with control characters in a file or directory name are refused.
  • --here: the current directory tree, or BUILD_WORKING_DIRECTORY under bazel run (//path/..., //... at the root). Never combines with explicit scopes. Accepted by dx build, dx test, dx coverage, dx lint, dx typecheck, dx format, dx generate, dx check, dx fix, dx security, dx license, dx list-tests, and dx docs.
  • --affected: the targets your changes reach, described below. Never combines with explicit scopes or --here.
  • Targets that do not fit the target platform are skipped in dx build, dx test, dx coverage, dx lint, dx typecheck, dx format, dx check, and dx fix, even when named, reached through files, or picked by --affected. Skipped tests report the outcome skipped. Pass -- --noskip_incompatible_explicit_targets to fail on them instead. dx run still fails on them.
bazel run @rules_dx//:dx -- lint --here
bazel run @rules_dx//:dx -- build //cli/...

--affected

--affected selects only what your changes can reach. It is accepted by dx build, dx test, dx coverage, dx list-tests, dx lint, dx typecheck, dx format, dx check, dx fix, dx security, and dx license. dx generate and dx docs reject --affected (usage error, exit 2).

  • Changes are the diff from the merge-base with the default branch (origin/HEAD, else origin/main, main, origin/master, master), plus staged, unstaged, and untracked files. A rename counts both paths.
  • When the workspace is a subdirectory of the Git repo, paths are relative to the workspace and changes outside it are ignored. A change under a local_path_override path outside the workspace widens to //....
  • Each changed file maps to its owning targets, then to their reverse dependencies inside //.... Targets tagged manual are left out.
  • dx test, dx coverage, and dx list-tests keep only test targets.
  • dx security and dx license keep only targets a dependency set owns.
  • dx check --affected runs format, lint, typecheck, and generate as usual, then the affected tests. dx fix --affected runs the same phases without tests and only reports fixes; dx fix --apply --affected writes them. Generate and its re-check run Gazelle on the directories of the affected targets.
  • A change to MODULE.bazel, MODULE.bazel.lock, WORKSPACE, WORKSPACE.bazel, .bazelversion, any *.MODULE.bazel file, or any .bazelrc* file, at any depth, widens the scope to //.... So does a file the root .bazelrc reaches through import or try-import %workspace%/..., including nested imports. Other *.bazelrc files do not widen. So does a file that MODULE.bazel or an included segment names by label in an extension tag, repo rule, or override, such as pnpm-lock.yaml, go.mod and its go.sum, maven_install.json, requirements_lock.txt, or a patch, and the included segment itself. A crate.from_cargo lockfile in an included segment counts as a lock input. When dx cannot read every value in MODULE.bazel, any pnpm-lock.yaml, package-lock.json, go.mod, go.sum, go.work, go.work.sum, maven_install.json, or requirements_lock.txt, at any depth, widens to //.... A deleted, removed, or renamed-from file selects the targets that still name it. If no target names it, it widens to its package (for example //pkg:all) plus that package's reverse dependencies. A .bzl file follows the .bzl rule below. An unowned file, or one outside any package, widens to the package tree around it, such as //pkg/....
  • A changed BUILD or BUILD.bazel file selects every target in its package, plus its reverse dependencies. An added BUILD file widens to //...; a deleted one, or a deleted file outside any package, widens to //....
  • A change to a Cargo.lock or crate_universe lockfile named by a crate.from_cargo call in MODULE.bazel selects the targets that use the changed crates, plus the crates that depend on them, and their reverse dependencies. Any other *.lock file, an added or deleted lock, a lock dx cannot read, or a crate_universe lockfile change outside crates widens to //.... A changed crate_universe manifest such as Cargo.toml does not widen; it selects its owners like any other file.
  • A changed .bzl or .scl file selects the packages whose BUILD files load it, directly or through other .bzl files, plus their reverse dependencies. dx reads the BUILD and .bzl files to find them. Targets that own the file, such as a bzl_library, are selected too. It widens to //... when the file is used by a MODULE.bazel extension or repo rule, by an aspect named in .bazelrc or one dx runs, or by the BUILD file of a toolchain or platform package. Those packages are the ones named by register_toolchains or register_execution_platforms in MODULE.bazel, or by --extra_toolchains, --extra_execution_platforms, --platforms, or --host_platform in .bazelrc. It also widens to //... when a BUILD or .bzl file does not parse. A .bzl or .scl file nothing loads selects nothing. A deleted .bzl or .scl file selects the packages that still load it, or nothing.
  • dx prints each widening and its reason on stderr: dx: affected: 12 targets from 3 changed files since 1a2b3c4d5e6f; widened to //... (workspace input MODULE.bazel) or dx: affected: 4 targets from 1 changed files since 1a2b3c4d5e6f; widened to //app:all (loads tools/defs.bzl) or dx: affected: 4 targets from 1 changed files since 1a2b3c4d5e6f; widened to //app:all (build file app/BUILD.bazel). The line lists //... first and names at most three scopes, then and N more, for example dx: affected: 0 targets from 1 changed files since 1a2b3c4d5e6f; widened to //p0:all (loads q/f.bzl), //p1:all (loads q/f.bzl), //p2:all (loads q/f.bzl) and 477 more. --output=json lists every widened scope.
  • Before the queries run, dx prints dx: affected: resolving changes since 1a2b3c4d5e6f on stderr, then the summary. --quiet, --output=diff, and --output=json print neither. An empty selection prints no summary.
  • No changes, or no affected targets, prints no affected targets on stdout and exits 0. dx security is the exception: when any file changed, it runs the secrets scan. With --output=json it emits command_started (with affected) and command_finished; mode is check for dx check, and for dx fix, lint, typecheck and format unless --apply is given; dx build, test, coverage, list-tests, security and license report default.
  • --affected takes no value. It never combines with explicit scopes or --here: both are usage errors with exit 2.
  • --output=json adds affected to the command_started event: {"base": "<sha>", "changed_files": [...], "targets": [...], "tests": [...], "widened": [{"scope": "//...", "reason": "..."}]}. base is the merge-base commit, targets is the selection, and widened is empty when nothing widened. A changed .bzl file adds one //pkg:all entry per loading package. A lock file change adds one entry per lock with repos, the changed crates: {"scope": "rdeps(//..., @crates//:serde)", "reason": "changed crates in Cargo.lock", "repos": ["@crates//:serde"]}. With more than five crates the scope reads rdeps(//..., <n> repos). tests appears only for dx check --affected. It lists the tests the last phase runs: //... when widened, empty when no test is affected. dx writes command_started only after planning finishes.
  • A checkout without a default branch exits 2 with cannot find the default branch: expected origin/HEAD, main, or master.
  • A shallow checkout exits 2 with git history is shallow: fetch full history with `git fetch --unshallow` or pass explicit scopes.
  • A failed bazel query exits 2. The error event has code query_failed and keeps Bazel's exit status and output excerpt. A query that cannot start has code query_launch_failed.
  • dx runs at most one bazel query, kind('rule', //...), to find the owning targets of the changed files and their reverse dependencies. dx test, dx coverage, dx list-tests, and dx check then run one tests(set(...)) query to find the affected tests. A change that widens to //... skips the tests query.
  • dx security --affected runs the secrets scan whenever a file changed. When no dependency set owns a reached target, it checks no set and prints Running audit security for no dependency sets. With no changes it prints no affected targets.
bazel run @rules_dx//:dx -- test --affected
bazel run @rules_dx//:dx -- check --affected --output=json

GitHub CI

Run dx in GitHub Actions with the reusable workflows. Copy the starters into your repo and pin a reviewed commit.

cp examples/consumer-ci/caller.yml .github/workflows/ci.yml
cp examples/docs-ci/caller.yml .github/workflows/docs.yml
bazel_dep(name = "rules_dx", version = "0.0.0")

Pin Check

Every job runs bazel run @rules_dx//tools/ci:module_pin_bin before dx. It checks the rules_dx declaration in MODULE.bazel. Pass that same version as rules_dx_version. A repository that calls the workflow from its own tree declares itself instead:

module(
    name = "rules_dx",
    version = "0.0.0",
)

Bazel resolves rules_dx first. It stops with its own error before the check runs if MODULE.bazel declares no rules_dx, declares it twice, or pins a release without //tools/ci:module_pin_bin.

  • A multiline call, reordered arguments and comments are fine.
  • A commented-out pin never counts.
  • Only the rules_dx declaration counts.
  • A mismatched version, or one that is not a quoted string, fails with the line, the declared version and the expected one.

Consumer Workflow

examples/consumer-ci/ calls reusable-consumer.yml. Nine checks run on every platform in platforms:

  • lint: dx lint --check //...
  • typecheck: dx typecheck --check //...
  • format: dx format --check //...
  • generate: dx generate --check //...
  • security-audit: dx security //...
  • license-audit: dx license //...
  • test: dx test //...
  • build: dx build //...
  • coverage: dx coverage //..., plus --min-coverage <percent> when min_coverage is set. With min_changed_line_coverage it also writes the LCOV report and gates the lines a pull request adds.

--check applies to the four quality checks. dx security, dx license, dx test, dx build, and dx coverage reject it.

The validate (inputs) job runs first. It reads every input, rejects a bad one with the accepted values, and starts no check.

Inputs:

  • rules_dx_version: the version your rules_dx declaration carries. The pin check compares it with MODULE.bazel.
  • disabled_checks: job IDs above to skip, comma-separated. Empty runs all nine. Surrounding spaces are ignored. An unknown ID, a repeated ID and an empty ID fail.
  • platforms: JSON array of platform labels, such as ["linux_x86_64"]. Omit it to run ["linux_x86_64"]. Valid labels: linux_x86_64, linux_arm64, macos_arm64, windows_x86_64, windows_arm64. Malformed JSON, a JSON value that is not an array, an empty array, a repeated label, an unknown label and a label carrying spaces all fail.
  • scheduling_mode: parallel only. sequential fails; checks always run in parallel.
  • code_scanning_opt_in: false only. true fails; no SARIF upload step ships in this workflow.
  • min_coverage: fails below that percent, a whole number from 0 to 100 such as 80. Empty collects without enforcing a threshold. 80%, 80.5, -1, 101, eighty and a leading space fail. Coverage comes from dx coverage.
  • min_changed_line_coverage: fails when fewer than that percent of the executable lines a pull request adds are covered, a whole number from 0 to 100. Empty skips the check. It accepts and rejects the same values as min_coverage, and it never changes min_coverage.
  • invocation_id: 1 to 40 letters, digits, underscores or hyphens. Empty uploads advisory-snapshots, which suits one call per workflow run. Two calls in one workflow run, including matrix cells, each need their own ID, such as api-linux; a repeated ID makes the second upload fail. Anything else fails. The ID also keeps the calls' concurrency groups apart, so one call never cancels another. A rerun reuses the same ID.

Changed-Line Coverage

Set min_changed_line_coverage to require coverage on new code without raising the whole-repo threshold. The coverage job fetches full history, runs dx coverage --report lcov=<path>, then runs diff-cover 10.6.0 over that report. It runs on pull_request events only. Other events print a notice and skip it.

The base is the pull request base commit. The head is the checked out HEAD. Lines come from the diff between their merge-base and HEAD. Renames follow the file, so a pure rename adds no lines. Deleted lines are not counted. Report paths are mapped to workspace-relative paths and must match a changed file exactly. An absolute path maps to the longest tracked path it ends with.

The check prints the base, merge-base and head commits, the threshold, the covered and eligible line counts, whether the evidence is complete, and each uncovered path:line. Lines the report does not list, such as comments and docs, are not eligible. With no eligible line the result is not-applicable, not 100 percent.

It fails when:

  • fewer than the threshold percent of eligible lines are covered. The compare is exact, with no rounding.
  • the base commit is not available, or the history is too shallow to reach a merge-base.
  • the report is missing, empty, or not valid LCOV.
  • the diff-cover output lacks its line totals or per-file stats.
  • a changed file has the extension of files in the report but is not in it. The result is incomplete.
    with:
      rules_dx_version: "0.0.0"
      min_changed_line_coverage: "80"

A rejected input fails in the validate job before any check starts. The nine checks need it, so none of them run. dx-ci (aggregate) names the validation failure and fails with them.

dx-ci (aggregate) passes only when every check succeeds. It fails when a check fails or is cancelled, when a declared check never reports a result, and when a check is skipped without being listed in disabled_checks. A check you disabled stays distinguishable from a check that never ran.

When a pull request or push run is cancelled, the gate is skipped and uses no runner. In a merge queue the gate still runs and fails.

The workflow needs contents: read plus checks: write for check runs. coverage also needs pull-requests: write for its pull-request comment. The comment shows PASS or FAIL for the coverage threshold and the changed-line gate, and every pull-request run updates it, including failed runs. Pull requests use the read-only cache config automatically.

A called workflow can only narrow the calling job's token, so grant those scopes on the calling job. examples/consumer-ci/caller.yml does.

Evidence Workflow

evidence.yml runs Bazel target patterns on hosted runners for native evidence this repository cannot produce locally. Dispatch it per run; it never runs on push:

gh workflow run evidence.yml --ref <branch> -f scope=//cli/cli/...
gh workflow run evidence.yml --ref <branch> -f scope="//cli/process/... //tools/testing/process_probe/..."
  • scope: space-separated target patterns such as //pkg/... or //pkg:target.
  • task: test (default) or build.
  • platforms: JSON array of platform labels. Accepted: linux_x86_64, linux_arm64, macos_arm64, windows_x86_64, windows_arm64. List each platform once.
  • test_timeout: whole seconds each test may run. Default 600.
  • displays: Linux only. xcb, wayland or xcb,wayland. Starts Xvfb and a headless Weston and sets DX_QT_DISPLAYS. Default empty: no displays.

A validate job rejects a bad input naming the accepted values. Each platform cell runs the task, uploads its log as the evidence-<platform> artifact, and writes the tail to the job summary.

Apple Workflow

apple.yml runs the Apple toolchain tests on a hosted macOS runner with Xcode preinstalled. Dispatch it per run:

gh workflow run apple.yml --ref <branch> -f command=test \
  -f targets="//apple/... //apple/tests:macos_consumer_test //apple/tests:macos_app_consumer_test //apple/tests:macos_framework_consumer_test"
  • command: test (default) or build.
  • targets: space-separated Bazel targets. Each must start with //.
  • runner: macos-15 (arm64, default).

Each run uploads the apple-evidence-<runner> artifact with the executor's macOS, Xcode, and SDK identities, the Bazel log, and the test logs and outputs.

Advisory Snapshots

security-audit reads .dx/advisory/. One advisory snapshots (one run, every platform) job fills that directory once per run, uploads it as the advisory-snapshots artifact (advisory-snapshots-<invocation_id> when set), and every platform cell downloads the same bytes. No cell downloads a database of its own.

bazel run @rules_dx//cli/advisory_prep --

The command converts one archive per advisory family the dependency locks need, so a repository with only Cargo.lock prepares the cargo snapshot alone. It writes <out>/<family>.json and <out>/<family>.meta.json. The sidecar holds the set, the source URL, the sha256 of the payload, the stamped date and the payload path. The payload is the same for the same advisories whatever order the archive lists them in.

Relative paths anchor at the workspace root.

  • --workspace <dir>: dependency set locks to read. Default the workspace root.
  • --out <dir>: where the snapshots are written. Default .dx/advisory under it.
  • --date YYYY-MM-DD: date every sidecar carries. Default today, UTC.
  • --max-time <seconds>: wall clock limit for one download. Default 600.
  • --connect-timeout <seconds>: connect limit for one download. Default 30.
  • --retries <count>: attempts one download may spend. Default 3.
  • --retry-delay <seconds>: pause between two attempts. Default 5.
  • --archive FAMILY=PATH: convert a local archive instead of fetching it. Repeatable.
  • --status-file <file>: append status, ecosystems and families as key=value lines. The workflow passes $GITHUB_OUTPUT.

A repository whose locks need no advisory ecosystem prepares nothing. The command prints no advisory ecosystems needed, exits 0 and reports status=empty and ecosystems=0. The job then uploads no artifact, the security-audit cells download none, and secret scanning still runs. Dependency sets without a lock are not audited. A missing artifact for a needed ecosystem still fails the audit and dx-ci (aggregate).

Only https and file URLs are fetched, a redirect stays on https, and an archive entry is refused when its length or checksum disagrees with the archive. Exit codes: 0 success, 2 usage error, 1 preparation failure. A failure prints one line starting with advisory_prepare_failed: naming the family and the archive entry, path or limit it could not use, and writes no snapshot for that family.

The artifact is kept for one day. Each security-audit cell judges freshness against the date the job stamped, passed to dx security as DX_AUDIT_TODAY, so every cell accepts or rejects the same snapshot. security-audit needs the job, so a failed preparation skips every cell and dx-ci (aggregate) fails. A missing, stale or corrupt snapshot fails the audit with advisory_refresh_failed naming the set and the file. The job needs contents: read and no secret, so fork pull requests run it.

Hermetic Tool Resolution

security-audit scans secrets with the pinned Gitleaks artifact, and dx refuses ambient PATH lookup. One Resolve hermetic Gitleaks step resolves the artifact path with a native command instead of shell parsing, so every platform cell exports the same variable without host utilities.

bazel run @rules_dx//cli/tool_resolve -- --label @dx_tools//:gitleaks --var DX_GITLEAKS_BIN

The command runs bazel cquery --output=files for the label, takes the last output line as the artifact path, joins it with bazel info output_base, and refuses a missing file. It appends VAR=path to the file GITHUB_ENV names.

  • --label <label>: pinned tool label to resolve. Required.
  • --var <name>: environment variable receiving the absolute path. Required.
  • --bazel <path>: Bazel binary the resolution runs through. Default bazel.
  • --workspace <dir>: directory the inner Bazel commands run in. Default BUILD_WORKSPACE_DIRECTORY, the bazel run invocation root, else the current directory.
  • --env-file <path>: environment file receiving VAR=path. Default GITHUB_ENV.
  • --print: write VAR=path to standard output instead of a file.
  • Arguments after -- reach the inner cquery unchanged.

Exit codes: 0 success, 2 usage error, 1 resolution failure. A failure prints one line starting with tool_resolve_failed: naming the label and the cquery, output-base, missing-file or environment write it could not use, and exports nothing.

In a local shell, export the same variable with --print:

export "$(bazel run @rules_dx//cli/tool_resolve -- --label @dx_tools//:gitleaks --var DX_GITLEAKS_BIN --print)"

Docs Workflow

examples/docs-ci/ calls reusable-docs.yml. It runs dx lint --check over docs_scope (default //...), runs dx docs --check on site_check when you set it, then renders your site label with dx docs and checks the rendered tree for missing pages, styles, and search index, so broken links fail before deploy.

Inputs:

  • rules_dx_version: the version your rules_dx declaration carries. The pin check compares it with MODULE.bazel.
  • docs_scope: target patterns covered by the docs check, separated by spaces, such as //... //docs:all. Default //.... Each pattern reaches dx as one argument. A value with quotes, shell characters, wildcards, a leading - or anything but a target pattern fails with one error naming it.
  • publish: build the rendered site and deploy to Pages. Default false.
  • environment: Pages environment name. Default github-pages.
  • site: required. One target label in your repository whose output is the rendered mdBook tree, with index.html and searchindex.js at its root.
  • site_check: target label that dx docs --check runs. Default empty, which skips the source check.
  • forbid: text the rendered site must not contain, one entry per line. Default empty.
  • landing: also require a landing page in the rendered site. Default false.
  • smoke_test: test label run with DX_PAGES_URL set to the deployed page URL. Default empty, which runs only the built-in page fetch.

A site, site_check or smoke_test value that is not a single target label fails the docs check with one dx-ci: line naming the input.

Publishing needs pages: write plus id-token: write. The publish job stages and validates the whole site tree, deploys it, fetches index.html and searchindex.js from the reported page URL, then runs smoke_test if you set one, all before the clean-checkout proof. Enable Pages with source GitHub Actions before the first publishing run.

Bump Workflow

.github/workflows/bump.yml opens one dependency PR per run.

Create a GitHub App with contents: write and pull-requests: write. Install it on the repository. Set two secrets:

  • BUMP_APP_ID: the App ID.
  • BUMP_APP_PRIVATE_KEY: the App private key.

Dispatch the workflow with selector and version:

gh workflow run bump.yml -f selector=cargo:anyhow -f version=1.2.3

A run without both inputs lists outdated dependencies and changes nothing.

A dispatched run fails before changing files when the secrets are missing. It pushes the branch and opens the PR as the App, so the PR runs the required checks. Only the manifest, lock and pin files for the selected set are staged. Other changed files fail the run. One run never batches two dependencies. Set automerge to true to merge on the full required-check set.

Repository Settings

Set these once in the repository. Each caller already grants its job permissions, so a read-only repository default is fine.

  • Approval policy: all_external_contributors, so fork pull requests run.
  • Branch protection for the consumer workflow: require dx-ci (aggregate). One green gate covers the whole matrix. GitHub counts a skipped required check as passing, so rerun a cancelled pull request run before merging, or use a merge queue.
  • Branch protection for the docs workflow: require docs-check. Add docs-publish (GitHub Pages) once publish is true.
  • Merge gate: require branches to be up to date, or use a merge queue. Both callers run on merge_group (checks_requested), so the queue gets the required checks for its own SHA. Merge-group runs post no PR comment and never publish Pages.
  • Pages source: GitHub Actions, before the first publishing run.
  • Workflow permissions: read-only by default. Each calling job then grants what its reusable jobs request: the consumer caller checks: write plus pull-requests: write, the docs caller pages: write plus id-token: write.

Caching

Both callers forward repository secrets with secrets: inherit. Set BUILDBUDDY_API_KEY to share the BuildBuddy remote cache. Codecov is optional.

Every job restores the Bazel repository cache that Bazel and dx use. Each platform shares one copy, keyed on MODULE.bazel and MODULE.bazel.lock. On the default branch, coverage saves it. If coverage is disabled, test saves it. If both are disabled, build saves it. With all three disabled, nothing saves it. The saved copy holds only the downloads the saving job used. Pull requests, forks included, merge queues and other branches only restore it. Without BUILDBUDDY_API_KEY, build and test outputs are not cached between runs.

With that secret set, every Bazel and dx call in both workflows runs with --config=ci, plus --config=ci-pr on pull requests. Define both configs in the repository .bazelrc before setting the secret. An undefined config fails the run: Config value 'ci' is not defined in any .rc file.

common:ci --remote_cache=grpcs://remote.buildbuddy.io
common:ci --remote_cache_compression
common:ci --remote_timeout=10m
common:ci --remote_download_outputs=all
common:ci-pr --noremote_upload_local_results

Without the secret, no config flag is passed and no stanza is needed.

Examples

Start a new consumer, adopt an existing tree, or mix frameworks. Each workspace below records exact commands and expected evidence in its own README.md.

Start From CI Templates

  • consumer-ci starter caller for the reusable consumer workflow.
  • docs-ci starter caller for the reusable docs workflow.
  • gitlab-ci starter pipeline for GitLab CI.

Adopt An Existing Tree

  • adopt-rust foreign Cargo workspace adopted by dx generate.
  • adopt-python foreign Python tree adopted by the Python Gazelle extension.
  • adopt-js-ts foreign JS/TS tree adopted by the JavaScript/TypeScript Gazelle extensions.
  • adopt-go foreign Go module adopted by the Go Gazelle extension.
  • adopt-cpp foreign C++ tree adopted by the C++ Gazelle extension.
  • adopt-java foreign Maven-layout tree adopted by the Java Gazelle extension.
  • adopt-kotlin foreign Maven-layout tree adopted by the Kotlin Gazelle extension.
  • adopt-scala foreign sbt-layout tree adopted by the Scala Gazelle extension.
  • adopt-csharp foreign SDK-style tree adopted by the C# Gazelle extension.
  • adopt-fsharp foreign SDK-style tree adopted by the F# Gazelle extension.
  • adopt-ruby foreign Bundler-layout tree adopted by the Ruby Gazelle extension.
  • adopt-powershell foreign PowerShell tree adopted by handwritten wrappers.
  • adopt-polyglot foreign Python+Rust+JS/TS+C++ tree adopted package by package.

Mix Frameworks

  • mixed Vue, Svelte, Astro, and MDX containers over one shared JavaScript helper.

Share One Core

  • shared-rust one Rust task board driving native, browser, WASI, and Android outputs.

Build an Application

  • appimage-app multi-package Linux C app with a shared library and a packaged resource, built as an AppImage and run without FUSE in tests.
  • c-app multi-package pure C stock CLI with a pinned cJSON dependency, a generated header and packaged data.
  • cpp-app multi-package C++17 menu report with templates, a pinned fmt dependency, a generated header and a runfiles resource.
  • elixir-app multi-package Elixir application with a generated unit table, a supervised ledger, a pinned jason Hex package and an escript.
  • erlang-app multi-package Erlang/OTP key-value store with a leex and yecc parser, a supervised application, a pinned thoas dependency and an escript.
  • gleam-app multi-package Gleam stock CLI for Erlang and JavaScript with a generated unit table, a typed ledger and a pinned gleam_json Hex package.
  • java-app multi-package Java invoice CLI with pinned gson and guava dependencies, a generated class and a packaged resource.
  • kotlin-app multi-package Kotlin gradebook CLI with pinned gson and guava dependencies, a generated object and a packaged resource.
  • msix-app Windows console app with a DLL and a packaged resource, built as an MSIX, installed and signed in tests.
  • node-app multi-package TypeScript and JavaScript registry CLI with a pinned semver dependency, a generated module and a packaged resource.
  • perl-app multi-package Perl stock CLI with a typed ledger, a runfiles CSV table, a pinned Text::CSV CPAN dependency and a select() flag.
  • python-service multi-package Python CLI and local service with a pinned click dependency, a generated module and packaged data.
  • qt-app headless Qt app with a QML module, a C++ QML type, a Qt resource and a QtTest suite.
  • qt-rust headless Qt/QML ledger backed by a Rust model through CXX-Qt bindings, with a pinned adler2 dependency, a QtTest suite and a relocatable archive.
  • qt-tasks multi-module Qt/QML task browser with C++ models, a Qt Quick board and Quick Test suite, a pinned fmt dependency, translations, JSON storage and relocatable archives.
  • zig-app multi-package Zig manifest CLI with a pinned xxHash C dependency, a generated module and embedded data.

Consumer CI example

cp caller.yml .github/workflows/ci.yml

Starter caller for the reusable consumer workflow. Pin to a reviewed commit.

bazel_dep(name = "rules_dx", version = "0.0.0")

Every job reads that declaration before dx runs. The version must be a quoted string, and no other dependency may declare rules_dx.

A validate job runs before the checks and rejects a bad input with the accepted values. Only parallel scheduling, false for code_scanning_opt_in and a whole min_coverage or min_changed_line_coverage from 0 to 100 are accepted.

Set approval policy, branch protection, and least-privilege permissions manually.

Default C/C++ and Rust setup

Generate the setup snippet:

bazel run @rules_dx//:dx -- init --apply my_project

Copy MODULE.bazel.snippet into MODULE.bazel. The snippet pins the hermetic LLVM and Rust toolchains and registers them:

bazel_dep(name = "rules_dx", version = "0.0.0")
bazel_dep(name = "rules_cc", version = "0.2.27")
bazel_dep(name = "llvm", version = "0.8.23")
bazel_dep(name = "rules_rust", version = "0.74.0")

rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
rust.toolchain(
    edition = "2021",
    rustfmt_version = "1.98.0",
    versions = ["1.98.0"],
)
use_repo(rust, "rust_toolchains")

register_toolchains("@llvm//toolchain:all")
register_toolchains("@rust_toolchains//:all")

Only Bazel/Bazelisk is required. Ordinary cc_* and rust_* rules need no linker paths or per-target flags. Native host builds are the default. Cross-target and Windows ABI choices are explicit advanced setup. Windows ARM64 builds stay unqualified.

Own toolchains

Keep the bazel_dep lines and drop the register_toolchains lines, then register selected toolchains. Loading the rules never replaces consumer toolchains:

cc_configure = use_extension("@rules_cc//cc:extensions.bzl", "cc_configure_extension")
use_repo(cc_configure, "local_config_cc", "local_config_cc_toolchains")
register_toolchains("@local_config_cc_toolchains//:all")

A consumer that renames the module keeps working. Declare the rename once:

bazel_dep(name = "rules_dx", version = "0.0.0", repo_name = "my_dx")

Then load through the new name:

load("@my_dx//cc/rules:defs.bzl", "cc_binary")

Minimal targets

Load the public rules through the module name. A Rust library looks like this:

load("@rules_dx//rust/rules:defs.bzl", "rust_library")

rust_library(
    name = "greet",
    srcs = ["lib.rs"],
    crate_name = "greet",
    crate_root = "lib.rs",
)

A C++ binary that depends on another repository looks like this:

load("@rules_dx//cc/rules:defs.bzl", "cc_binary")

cc_binary(
    name = "greet",
    srcs = ["main.cc"],
    deps = ["@greet_lib//:greeting"],
)

Run the checks from the module root with the public launcher:

bazel run @rules_dx//:dx -- lint --check //...
bazel run @rules_dx//:dx -- format --check //...

The full input contract lives in the GitHub CI docs.

Docs CI Example

Copy caller.yml to .github/workflows/docs.yml in the consumer repo. Never copy the check or deploy steps. The caller pin is a reviewed commit; bumps are deliberate and reviewed.

cp caller.yml .github/workflows/docs.yml

The consumer module pins the same version the caller passes as rules_dx_version (0.0.0 today):

bazel_dep(name = "rules_dx", version = "0.0.0")

Both jobs read that declaration before dx runs.

docs-check runs dx lint --check over docs_scope (space-separated target patterns, each passed as one argument), dx docs --check, and a render of the site whose pages, styles, and search index are validated. publish: true builds the rendered site via dx docs, validates the staged tree, deploys it to the github-pages environment, then smoke-tests the reported page URL. Enable Pages with source GitHub Actions before the first publishing run.

GitLab CI example

cp .gitlab-ci.yml <your-repo>/.gitlab-ci.yml

Starter pipeline for GitLab. It runs four jobs on Linux runners: check, build, test and coverage. Each job runs one dx line:

bazel run @rules_dx//:dx -- check --report sarif=reports/dx.sarif //...
bazel run @rules_dx//:dx -- build --report failures=reports/failures.json //...
bazel run @rules_dx//:dx -- test --report junit=reports/junit.xml //...
bazel run @rules_dx//:dx -- coverage --report lcov=reports/coverage.lcov //...

The same recipe works on any CI provider:

  1. Install Bazelisk 1.29.0 as bazel and check its SHA-256.
  2. Commit .bazelversion.
  3. Set XDG_CACHE_HOME to .cache inside the checkout.
  4. Run mkdir -p reports, then the dx lines above.

Consumer setup

Declare the module in MODULE.bazel:

bazel_dep(name = "rules_dx", version = "0.0.0")

Commit MODULE.bazel.lock and .bazelversion. The cache key reads both.

Add to .gitignore:

.cache/
reports/
bazel-*
.dx/

Add to .bazelignore:

.cache

Reports

Every job keeps reports/ as an artifact, also when the job fails. A job that hits its timeout keeps nothing.

  • reports/junit.xml shows in the merge request test widget.
  • reports/coverage.lcov, reports/dx.sarif and reports/failures.json are plain artifacts. GitLab coverage reports take only Cobertura or JaCoCo, and the SARIF widget needs GitLab Ultimate.

Exit status

A job exits with the dx exit code. When dx passes, the job still fails with 1 if its report is missing or empty, or if the run changed tracked or untracked files. The job prints the missing path or the changed files.

Caching

Each job caches only the Bazel repository cache under .cache/bazel/. The key is the job name plus MODULE.bazel.lock and .bazelversion. GitLab keeps separate caches for protected and unprotected branches, so merge request pipelines never write the cache that default branch pipelines read. Bazel checks every cached file by its hash. Bazelisk is downloaded fresh in every job.

Remote cache

Opt in with a GitLab CI/CD variable:

  1. Add a variable named DX_REMOTE_BAZELRC of type File. Mark it protected. Put your remote cache flags in it. GitLab cannot mask a multi-line value, so never print the file in a job.
  2. Add this line to the consumer .bazelrc:
try-import %workspace%/.cache/remote.bazelrc

Only protected branches get the file. Merge request pipelines run without it.

Image and runner

The pipeline uses buildpack-deps:noble-scm@sha256:59d4f4d8581d741bb2c31f4bfd00a557ca94d182d082c81bd56d32afbad8dfc9 on the saas-linux-small-amd64 runner.

Adopt C++ example

C++ tree with a greet package and a stdlib-only solo package.

bazel run @rules_dx//:dx -- init //examples/adopt-cpp/...
bazel run @rules_dx//:dx -- generate //examples/adopt-cpp/...
bazel build //examples/adopt-cpp/...
bazel test //examples/adopt-cpp/...

Both tests pass (greet_test, pure_test).

copts reaches the compiler exactly as written, so -std=c++17 here stays C++17. Warnings are errors because the repository selects the treat_warnings_as_errors toolchain feature, not because the wrapper adds a flag. The same feature can also be selected on the command line:

bazel build --features=treat_warnings_as_errors //examples/adopt-cpp/...

See cc/rules/README.md for the wrapper contract.

Adopt C# example

C# SDK-style tree with a greet package and a stdlib-only solo package.

bazel run @rules_dx//:dx -- init //examples/adopt-csharp/...
bazel run @rules_dx//:dx -- generate //examples/adopt-csharp/...
bazel build //examples/adopt-csharp/...
bazel test //examples/adopt-csharp/...

Both tests pass (greet_test, pure_test).

Adopt F# example

F# SDK-style tree with a greet package and a stdlib-only solo package.

bazel run @rules_dx//:dx -- init //examples/adopt-fsharp/...
bazel run @rules_dx//:dx -- generate //examples/adopt-fsharp/...
bazel build //examples/adopt-fsharp/...
bazel test //examples/adopt-fsharp/...

Both tests pass (greet_test, pure_test).

Adopt Go example

Go module with a greet package and a stdlib-only solo package.

bazel run @rules_dx//:dx -- init //examples/adopt-go/...
bazel run @rules_dx//:dx -- generate //examples/adopt-go/...
bazel build //examples/adopt-go/...
bazel test //examples/adopt-go/...

Both tests pass (greet_test, solo_test).

Adopt Java example

Java Maven-layout tree with a greet package and a stdlib-only solo package.

bazel run @rules_dx//:dx -- init //examples/adopt-java/...
bazel run @rules_dx//:dx -- generate //examples/adopt-java/...
bazel build //examples/adopt-java/...
bazel test //examples/adopt-java/...

Both tests pass (greet_test, pure_test).

Adopt JS/TS example

JS/TS tree with an app package and a web TypeScript package.

bazel run @rules_dx//:dx -- init //examples/adopt-js-ts/...
bazel run @rules_dx//:dx -- generate //examples/adopt-js-ts/...
bazel build //examples/adopt-js-ts/...
bazel test //examples/adopt-js-ts/...

All 3 tests pass (greet_test, plain_test, app_test). The TypeScript wrapper emits its own typecheck tests, so bazel test runs more targets than the example declares.

Adopt Kotlin example

Kotlin Maven-layout tree with a greet package and a stdlib-only solo package.

bazel run @rules_dx//:dx -- init //examples/adopt-kotlin/...
bazel run @rules_dx//:dx -- generate //examples/adopt-kotlin/...
bazel build //examples/adopt-kotlin/...
bazel test //examples/adopt-kotlin/...

Both tests pass (greet_test, pure_test).

Adopt polyglot example

Mixed tree with a Python package, a Rust crate, a JavaScript package, a TypeScript package, and a C++ library with a handwritten binary in its own child package.

bazel run @rules_dx//:dx -- init //examples/adopt-polyglot/...
bazel run @rules_dx//:dx -- generate //examples/adopt-polyglot/...
bazel build //examples/adopt-polyglot/...
bazel test //examples/adopt-polyglot/...

All 4 tests pass (shapes_test, widgets_test, totals_test, native_test). The TypeScript wrapper emits its own typecheck tests, so bazel test runs more targets than the example declares.

Adopt PowerShell example

PowerShell tree with a greet package and a stdlib-only solo package.

bazel run @rules_dx//:dx -- init //examples/adopt-powershell/...
bazel run @rules_dx//:dx -- generate //examples/adopt-powershell/...
bazel build //examples/adopt-powershell/...
bazel test //examples/adopt-powershell/...

Both tests pass (greet_test, pure_test).

Adopt Python example

Python tree with an app package, a stdlib-only solo directory, and a tests/ directory.

bazel run @rules_dx//:dx -- init //examples/adopt-python/...
bazel run @rules_dx//:dx -- generate //examples/adopt-python/...
bazel build //examples/adopt-python/...
bazel test //examples/adopt-python/...

All 4 tests pass (handlers_test, pure_test, pure_unittest_test, login_test).

Adopt Ruby example

Ruby tree with a greet package and a stdlib-only solo package.

bazel run @rules_dx//:dx -- init //examples/adopt-ruby/...
bazel run @rules_dx//:dx -- generate //examples/adopt-ruby/...
bazel build //examples/adopt-ruby/...
bazel test //examples/adopt-ruby/...

Both tests pass (greet_spec, solo_test).

Adopt Rust example

Rust workspace with three path crates (api, support, worker) and a manifest-free solo directory.

bazel run @rules_dx//:dx -- init //examples/adopt-rust/...
bazel run @rules_dx//:dx -- generate //examples/adopt-rust/...
bazel build //examples/adopt-rust/...
bazel test //examples/adopt-rust/...

All 5 tests pass (api_test, api_roundtrip_test, common_test, helper_test, solo_test).

Adopt Scala example

Scala sbt-layout tree with a greet package and a stdlib-only solo package.

bazel run @rules_dx//:dx -- init //examples/adopt-scala/...
bazel run @rules_dx//:dx -- generate //examples/adopt-scala/...
bazel build //examples/adopt-scala/...
bazel test //examples/adopt-scala/...

Both tests pass (greet_test, pure_test).

For Scalafix lint, see docs/cli/commands/quality.md.

Mixed hello example

Vue, Svelte, Astro, and MDX containers over one shared JavaScript helper.

bazel build //examples/mixed/hello/...
bazel test //examples/mixed/hello/...

hello_test covers the shared helper edge.

AppImage

Package a target binary as a Linux AppImage. The tools are pinned and fetched by Bazel. No FUSE or distro packages are needed to build or run. The build step is a pinned Rust binary run with an empty PATH. It uses no tool from /bin or /usr/bin.

Cells: linux_x86_64 and linux_arm64. Any other target fails analysis with target cell this platform is unsupported.

Pins

ToolVersionLicense
appimagetool1.9.1MIT
type 2 runtime (x86_64, aarch64)20251108MIT
mksquashfs4.6.1GPL-2.0-or-later

Use

MODULE.bazel needs nothing extra. The tools come from @dx_appimage_tools, declared by rules_dx and fetched on first use.

load("@rules_dx//appimage:defs.bzl", "dx_appimage")

dx_appimage(
    name = "app",
    app_name = "app",
    binary = ":app_bin",
    data = ["greeting.txt"],
    desktop = "app.desktop",
    icon = "app.png",
    libs = [":native_shared"],
    notice = ["NOTICE.txt"],
    version = "1.2.3",
)

Attributes:

  • binary: the executable. Its target-configuration bytes are packaged.
  • app_name: defaults to the target name. desktop must be <app_name>.desktop with Exec=<app_name>. icon must be <app_name>.png or .svg.
  • libs: shared libraries copied to usr/lib. Link the binary with -Wl,-rpath,$ORIGIN/../lib.
  • data: files copied to usr/share/<app_name>. Files keep their path relative to their data target's package. Files a data target collects from another package or repo keep only their file name.
  • notice: license and NOTICE files copied to usr/share/licenses/<app_name>.
  • version: recorded in the identity file.
  • tools, runtime: replace the pinned tools and runtime. The runtime must match the target arch.
  • Two different libs, notice or data files that map to the same path fail analysis. The same file listed twice is packaged once.

Outputs:

  • <name>.AppImage
  • <name>.AppImage.identity.json: app name and version, tool and runtime versions, arch (x86_64 or aarch64), runtime digest, interpreter, minimum glibc, image digest and a digest for every packaged file.

AppRun is a link to usr/bin/<app_name>. The runtime sets $APPDIR.

Build and run

bazel build //pkg:app

Building signs nothing, uploads nothing and needs no credentials.

Run the image without FUSE:

APPIMAGE_EXTRACT_AND_RUN=1 bazel-bin/pkg/app.AppImage
bazel-bin/pkg/app.AppImage --appimage-extract
APPDIR="$PWD/squashfs-root" squashfs-root/AppRun

Supported tuples

Build hostTarget cellRun hostRun routeInstallSign
Linux x86_64 or arm64linux_x86_64Linux x86_64, glibc at least min_glibcFUSE mount, APPIMAGE_EXTRACT_AND_RUN=1 or --appimage-extractnone, copy the fileunsupported, images are unsigned
Linux x86_64 or arm64linux_arm64Linux arm64, glibc at least min_glibcFUSE mount, APPIMAGE_EXTRACT_AND_RUN=1 or --appimage-extractnone, copy the fileunsupported, images are unsigned
macOS or Windowsanynonenonenoneunsupported, the targets are incompatible

min_glibc is in the identity file.

Failures:

  • A missing resource or library fails in the app or the loader, not silently.
  • A truncated or corrupted image exits non-zero.
  • A wrong desktop or icon name fails analysis.
  • Two different files that map to the same path in the image fail analysis.
  • A target that is not Linux x86_64 or arm64 fails analysis.
  • A desktop file without Exec=<app_name> fails the build.

Build for arm64 from any Linux host:

bazel build //pkg:app --platforms=//appimage/platforms:linux_arm64

The image is packaged from the arm64 target bytes. Running it needs an arm64 Linux host.

Test

bazel test //appimage/... //examples/appimage-app/...
# Consumer qualification (manual; needs network and a native bazelisk):
bazel test //tools/testing/hermetic:appimage

examples/appimage-app is a two-package app that is built, relocated and run without FUSE in its tests.

AppImage example

A Linux C app packaged as an AppImage. The app reads a packaged resource and calls a function from a shared library in another package.

PackageRole
appdxappimage, greeting.txt, desktop file, icon, notice, the app image and run_test
greetlibgreet_shared.so

Build on a Linux host:

bazel build //examples/appimage-app/app

Run the image without FUSE:

APPIMAGE_EXTRACT_AND_RUN=1 bazel-bin/examples/appimage-app/app/app.AppImage
bazel-bin/examples/appimage-app/app/app.AppImage --appimage-extract
APPDIR="$PWD/squashfs-root" squashfs-root/AppRun

dxappimage prints:

resource=hello from the packaged resource
library=greeting from libgreet_shared.so
probe=off

Building with --copt=-DDX_CONSUMER_PROBE prints probe=on.

Exit codes: 0 ok, 2 APPDIR is not set, 3 the resource is missing, 4 the resource is empty. Without libgreet_shared.so the loader stops the app before it starts.

app.AppImage.identity.json records the app name and version, arch, minimum glibc, image digest and a digest for every packaged file.

Test:

bazel test //examples/appimage-app/...

run_test copies the image to a new directory and runs it both ways. It checks every extracted file against the identity file, exit codes 2 and 3, a missing library and a truncated image.

Build for arm64:

bazel build //examples/appimage-app/app --platforms=//appimage/platforms:linux_arm64

Supported hosts and run routes: AppImage supported tuples.

C stock example

A multi-package pure C app. It has a stock CLI, a store library that reads JSON through cJSON, a generated store_build.h header and a packaged stock.json.

PackageRole
storeJSON loader and totals, extern "C" header, generated store_build.h
clistock binary with list, total and get NAME
bazel build //examples/c-app/...
bazel test //examples/c-app/...
bazel run //examples/c-app/cli:stock -- total
bazel run //examples/c-app/cli:stock -- get bolt-m6
bazel run //examples/c-app/cli:stock -- --file path/to/stock.json list

Without --file or STOCK_FILE, stock reads the packaged stock.json through runfiles, so the binary runs from any directory. Relative --file paths resolve against the directory you ran bazel run from. Quantities and prices must be whole numbers from 0 to 1000000.

Exit codes: 0 ok, 2 usage, 3 unreadable or malformed stock file, 4 unknown item. exit_codes_test runs the binary through each code.

Sources build as C11 with -std=c11. store_test and cli_test are C programs and fail to compile as C++. linkage_test includes the same header from C++ and links the C library. cJSON is a pinned http_archive declared in MODULE.bazel. format_test checks the sources against the repository .clang-format.

diagnostics_test compiles and lints files with a syntax error, a type error and a lint finding, and expects each to fail.

Another workspace builds, tests, runs and copies both apps with bazel test //cc/tests/consumer:app_consumer_test.

C++ pantry example

A multi-package C++17 app. It has a menu report binary, a templated group_sum, a fmt dependency, a generated build_config.h header and a menu.csv resource found through runfiles.

PackageRole
coreMenu parser, group_sum template, generated build_config.h, menu.csv
apppantry binary and report formatter
bazel build //examples/cpp-app/...
bazel test //examples/cpp-app/...
bazel run //examples/cpp-app/app:pantry
bazel run //examples/cpp-app/app:pantry -- path/to/menu.csv

Without a path, pantry reads the packaged menu.csv through runfiles, so the binary runs from any directory. A relative path resolves against the directory you ran bazel run from.

Exit codes: 0 ok, 3 runfiles unavailable, 4 menu file missing or malformed. exit_codes_test runs the binary through each code.

fmt is a pinned http_archive declared in MODULE.bazel. Tests use GoogleTest. pantry_output_test runs the binary and compares its report. format_test checks the sources against the repository .clang-format.

diagnostics_test compiles and lints files with a syntax error, a type error and a lint finding, and expects each to fail.

Another workspace builds, tests, runs and copies both apps with bazel test //cc/tests/consumer:app_consumer_test.

Elixir app example

An Elixir application split into three packages. It builds and tests on the pinned Elixir and Erlang/OTP runtimes with nothing installed on the host.

PackageRole
catalogcatalog library: item parsing, a unit table generated from units.txt, and JSON through jason
stockstock application: a supervised GenServer ledger with a per-item limit from app_env
clistockctl escript that runs commands against the ledger
bazel build //examples/elixir-app/...
bazel test //examples/elixir-app/...
bazel run //examples/elixir-app/cli:stockctl -- "add 2 dozen pears" "count pears"

The run prints 24, 24 and the snapshot [{"name":"pears","units":24}]. A command that fails prints error: ... and exits 1.

catalog:catalog_test covers parsing, the generated table and the JSON output. stock:ledger_test covers the limit from app_env, the application graph, and supervisor restarts after a kill and after a crash. cli:stockctl_test runs the commands in process and through the stockctl launcher.

jason is a pinned hex_package declared in MODULE.bazel.

Erlang app example

An OTP application split into three packages. It builds and tests on the pinned Erlang/OTP runtime with nothing installed on the host.

PackageRole
protokv_proto library: a leex lexer and a yecc parser for put, get and del commands
storekv_store application: a supervised gen_server with a key limit, watchers and a JSON snapshot through thoas
clikvctl escript that runs commands against the store
bazel build //examples/erlang-app/...
bazel test //examples/erlang-app/...
bazel run //examples/erlang-app/cli:kvctl -- "put apples 3" "get apples"

The run prints ok, 3 and the snapshot {"apples":3}. A command that does not parse prints error: ... and exits 1.

proto:eunit runs the parser tests. store:kv_store_SUITE is a Common Test suite for the limit from app_env, watcher events, supervisor restarts and the snapshot. cli:kvctl_SUITE runs the commands in process and through the kvctl launcher.

thoas is a pinned http_archive declared in MODULE.bazel.

Gleam app example

A Gleam application split into three packages. Each package builds for Erlang on the pinned OTP runtime and for JavaScript on the managed Node.js runtime, with nothing installed on the host.

PackageRole
catalogItem parsing, a unit table generated from units.txt, and JSON through gleam_json
stockTyped stock ledger with a per-item limit, sources in lib/ and the JavaScript package stock_ledger
clistockctl command line on Erlang and stockctl_js on Node.js
bazel build //examples/gleam-app/...
bazel test //examples/gleam-app/...
bazel run //examples/gleam-app/cli:stockctl -- "add 2 dozen pears" "count pears"
bazel run //examples/gleam-app/cli:stockctl_js -- "add 2 dozen pears" "count pears"

Both runs print 24, 24 and the snapshot [{"name":"pears","units":24}]. A command that fails prints error: ... and exits 1.

Every package has a gleeunit test for each target. cli:launchers_test runs both launchers and checks their output and exit codes.

gleam_json is a pinned gleam_hex_package declared in MODULE.bazel.

Java invoice example

A multi-package Java app. It has an invoice CLI, a billing library that reads JSON through gson and groups sums through guava, a generated BuildInfo class and a packaged invoices.json resource.

PackageRole
billingInvoice model, JSON reader, totals, generated BuildInfo, invoices.json
cliinvoice binary with list, total, customers, schema and show ID
bazel build //examples/java-app/...
bazel test //examples/java-app/...
bazel run //:dx -- generate --check //examples/java-app/...
bazel run //examples/java-app/cli:invoice -- total
bazel run //examples/java-app/cli:invoice -- show INV-1001
bazel run //examples/java-app/cli:invoice -- --file path/to/invoices.json list

Without --file or INVOICE_FILE, invoice reads the invoices.json packaged in the billing jar, so the binary runs from any directory. Relative --file paths resolve against the directory you ran bazel run from. Quantities and prices must be whole numbers from 0 to 1000000.

dx generate owns the billing and cli libraries. Main.java, tests and diagnostics stay handwritten.

Exit codes: 0 ok, 2 usage, 3 unreadable or malformed invoice file, 4 unknown invoice. exit_codes_test runs the binary through each code.

gson and guava come from the locked @maven hub. classpath_test checks that the app classes load from the billing jar and the library classes from their own jars. Tests use JUnit 4. Sources build at the repository Java language level with -Xlint:all -Werror.

format_test checks the sources against the pinned google-java-format. diagnostics_test compiles, lints and formats files with a syntax error, a type error, a compiler warning, an unused import and bad formatting, and expects each to fail. It also compiles the billing sources: they fail without gson or guava on the classpath and pass with both.

Kotlin gradebook example

A multi-package Kotlin app. It has a gradebook CLI, a grades library that reads JSON through gson and groups students through guava, a generated BuildInfo object and a packaged grades.json resource.

PackageRole
gradesStudent model, JSON reader, ranking, generated BuildInfo, grades.json
cligrades binary with list, ranks, letters, schema and show NAME
bazel build //examples/kotlin-app/...
bazel test //examples/kotlin-app/...
bazel run //:dx -- generate --check //examples/kotlin-app/...
bazel run //examples/kotlin-app/cli:grades -- ranks
bazel run //examples/kotlin-app/cli:grades -- show ada
bazel run //examples/kotlin-app/cli:grades -- --file path/to/grades.json list

Without --file or GRADES_FILE, grades reads the grades.json packaged in the grades jar, so the binary runs from any directory. Relative --file paths resolve against the directory you ran bazel run from. Scores must be whole numbers from 0 to 100, and every student needs at least one.

dx generate owns the grades and cli libraries. Main.kt, tests and diagnostics stay handwritten.

Exit codes: 0 ok, 2 usage, 3 unreadable or malformed grade file, 4 unknown student. exit_codes_test runs the binary through each code.

gson and guava come from the locked @maven hub. classpath_test checks that the app classes load from the grades jar and the library classes from their own jars. Tests use JUnit 4. Kotlin warnings are errors.

format_test checks the sources against the pinned ktfmt in Kotlin-lang style. diagnostics_test compiles, lints and formats files with a syntax error, a type error, a compiler warning, a wildcard import and bad formatting, and expects each to fail. It also compiles the grades sources: they fail without gson or guava on the classpath and pass with both.

MSIX example

A Windows console app packaged as an MSIX. The app reads a packaged resource, calls a function from a DLL in another package and prints its package identity.

PackageRole
appdxmsix.exe, greeting.txt, logo, notice, the app package and the app_sign signer
greetgreet.dll

Build on a Windows host:

bazel build //examples/msix-app/app

app needs the Windows SDK Build Tools license accepted. rules_dx accepts it for its own builds.

Install and run it with Developer Mode on, in Windows PowerShell:

mkdir $env:TEMP\dxmsix
tar -xf bazel-bin\examples\msix-app\app\app.msix -C $env:TEMP\dxmsix
Add-AppxPackage -Register $env:TEMP\dxmsix\AppxManifest.xml
dxmsix.exe
Get-AppxPackage -Name DxMsix.Example | Remove-AppxPackage

dxmsix.exe prints:

resource=hello from the packaged resource
library=greeting from greet.dll
package=DxMsix.Example_1.2.3.0_x64__kg15hasbevky6
probe=off

Building with --copt=-DDX_CONSUMER_PROBE prints probe=on.

Exit codes: 0 ok, 2 the executable path is unknown, 3 the resource is missing, 4 the resource is empty. Without greet.dll Windows stops the app before it starts.

Sign a copy with a test certificate:

bazel run //examples/msix-app/app:app_sign -- -Pfx C:\certs\test.pfx -Out C:\out\dxmsix.msix

//msix/tests builds, inspects, installs and signs this example.

Node app example

A multi-package TypeScript and JavaScript app. It has a package registry CLI, a registry library that checks versions through semver, a generated build_info module and a packaged packages.json resource.

PackageRole
registryTyped registry model, semver helpers, generated build_info.ts, packages.json
cliregistry binary with list, latest, next, satisfy, schema and where
diagnosticsFiles with deliberate errors, and the checks that must reject them
bazel build //examples/node-app/...
bazel test //examples/node-app/...
bazel run //examples/node-app/cli:registry -- list
bazel run //examples/node-app/cli:registry -- next core minor
bazel run //examples/node-app/cli:registry -- satisfy net '^2.0.0'
bazel run //examples/node-app/cli:registry -- --file path/to/packages.json list

Without --file or REGISTRY_FILE, registry reads the packaged packages.json, so the binary runs from any directory. Relative --file paths resolve against the directory you ran bazel run from. latest and next ignore prerelease versions.

Exit codes: 0 ok, 2 usage or invalid range, 3 unreadable or malformed registry file, 4 unknown package or no matching version. exit_codes_test runs the binary through each code. registry where prints the entry script, the data file and the semver directory the binary resolved.

Tests use both runners. registry_test and cli_test run Jest. plain_test runs node:test. Every TypeScript target also gets a typecheck test with isolatedDeclarations. semver and the test tools come from the locked npm_node_app hub. cli imports registry as the workspace package @node-app/registry, so the lock covers both package.json files. Refresh it with pnpm install --lockfile-only in a copy of package.json, registry/package.json and pnpm-workspace.yaml.

Sources use the repo Biome settings. format_test checks every source and config file against the pinned Biome. diagnostics_test runs tsc and Biome on files with a syntax error, a type error, an inferred export under isolatedDeclarations, an import of a package that is not installed, a lint finding in .ts and .js, and bad formatting, and expects each to fail.

bazel coverage //examples/node-app/... writes LCOV for the Jest tests: registry.js, build_info.js and cli.js. coverage_test checks each report names those files with executed lines. plain_test runs node:test and reports none.

Perl app example

A Perl stock CLI split into three packages. Every target runs on the relocatable Perl 5.40 that rules_perl pins, with nothing installed on the host.

PackageRole
inventoryInventory::Item and Inventory::Ledger, a stock ledger with a per-item limit
reportUnit table from data/units.csv read through runfiles, and CSV output through Text::CSV
clistockctl command line and a launcher test
bazel build //examples/perl-app/...
bazel test //examples/perl-app/...
bazel run //examples/perl-app/cli:stockctl -- "add 2 dozen pears" "count pears"
bazel test //examples/perl-app/... --//examples/perl-app/cli:limit=144

The run prints 24, 24 and the report name,units / pears,24. A command that fails prints error: ... and exits 1. The limit flag selects the per-item limit through select() on env, and the launcher test follows it.

Text::CSV 2.06 comes from CPAN through the rules_perl cpan extension. Its lock is third_party/cpanfile.snapshot.lock.json, and every distribution carries a sha256. Text::CSV runs on its pure-Perl Text::CSV_PP backend. To change the pin, edit third_party/cpanfile and run bazel run //examples/perl-app/third_party:compiler.

PlatformStatus
linux_x86_64Qualified
linux_arm64, macos_arm64, windows_x86_64Not qualified
windows_arm64Unsupported

Python service example

A multi-package Python app. It has a click command line, a local JSON service, a generated build_info module, packaged item data, and a pinned uv.lock.

PackageRole
ledger/coreCatalog, config, packaged items.json, generated build_info.py
ledger/serviceJSON server on 127.0.0.1
ledger/cliledger binary with info, list, quote and serve
bazel build //examples/python-service/...
bazel test //examples/python-service/...
bazel run //examples/python-service/ledger/cli:ledger -- list
bazel run //examples/python-service/ledger/cli:ledger -- quote bolt-m6 --qty 4
bazel run //examples/python-service/ledger/cli:ledger -- serve --port 8080

Config comes from --config FILE or LEDGER_CONFIG. Keys: currency, tax_percent, items_path.

Tests use both runners: catalog_test, server_test, cli_test and broken_test run pytest. build_info_test and cli_unittest_test run unittest. build_info_test sets PIP_NO_INDEX and UV_OFFLINE, and click comes from the locked pypi hub.

Refresh the lock with uv lock --directory examples/python-service.

bazel test //python/tests/consumer:service_consumer_test

Qt app example

A headless Qt 6 app with a QML module, a C++ QML type, a Qt resource, a Widgets form and QtTest and Qt Quick Test suites. It builds against the pinned @dx_qt SDK on Linux x86_64.

PackageRole
tallyqt_qml_module with URI Tally: Counter C++ type, Main.qml and Badge.qml
apptally binary, counter_test and panel_test QtTest suites, tally_quick_test Qt Quick Test suite
bazel build //examples/qt-app/...
bazel test //examples/qt-app/...
bazel run //examples/qt-app/app:tally -- 3

The run prints the banner.txt resource, loads Tally/Main, bumps the counter three times and prints tally 46.

counter_test checks the C++ type, evaluates the Tally module from its embedded resources and loads QtQml.Models from the pinned import tree.

panel_test loads the panel.ui form, clicks its button and renders the label offscreen. tally_quick_test runs tst_tally.qml, which drives the Tally module and grabs a rendered frame with the software backend.

Qt Rust example

A headless Qt 6 ledger whose model is Rust. QML calls the Rust type, reads its properties and handles its signal. It builds against the pinned @dx_qt SDK on Linux x86_64 and packages into a relocatable archive.

PackageRole
ledgerledger crate: positive amounts, a running total and an Adler-32 checksum from the pinned adler2 crate
modelLedger.Model: LedgerModel, a CXX-Qt QObject with read-only count, total and checksum properties, an add invokable and a rejected signal
uiLedger.Ui: Main.qml calls add and records each rejected signal
appledger binary, ledger_test QtTest suite, runtime bundle and ledger_release archive
bazel build //examples/qt-rust/...
bazel test //examples/qt-rust/...
bazel run //examples/qt-rust/app:ledger -- 5 -2 7

ledger takes one or more integer amounts. It prints added <n> or rejected <n> per amount, then a summary such as 2 entries, total 12, adler32 004c000d.

Exit codes: 0 every amount added, 1 the QML module failed to load, 2 usage error, 3 at least one amount was rejected.

Package

bazel build //examples/qt-rust/app:ledger_release_archive
mkdir /tmp/ledger && tar -xzf bazel-bin/examples/qt-rust/app/ledger_release.tar.gz -C /tmp/ledger
env -i /tmp/ledger/ledger 5 7

The archive holds ledger, NOTICE, IDENTITY.txt and resources/ with the Qt libraries, the QML and QtQml import modules and the license texts. The Rust code is linked into ledger. Only glibc 2.34 or newer comes from the host.

Qt tasks example

A Qt 6 task browser. Four QML modules, C++ models, a Qt Quick board, a pinned fmt dependency, a German translation and JSON storage. It builds against the pinned @dx_qt SDK on Linux x86_64 and packages into relocatable archives.

PackageRole
storeTasks.Store: TaskModel, a list model with signals and JSON load and save
catalogTasks.Catalog: AssetCatalog, read from the embedded assets.json resource
browserTasks.Browser: Main.qml drives both models; tasks_de.ts compiles to :/i18n/tasks_de.qm
boardTasks.Board: Board.qml and TaskRow.qml, a Qt Quick list of tasks with click and keyboard toggling
apptasks and board binaries, browser_test QtTest suite, board_quick_test Qt Quick Test suite, runtime and board_runtime bundles, tasks_release and board_release archives
bazel build //examples/qt-tasks/...
bazel test //examples/qt-tasks/...
bazel run //examples/qt-tasks/app:tasks -- --store /tmp/tasks.json add "Draw logo" logo
bazel run //examples/qt-tasks/app:tasks -- --lang de --store /tmp/tasks.json list

tasks takes [--lang <code>] --store <path> and one command:

  • add <title> <asset> adds a task for a catalog asset.
  • done <row> toggles a task.
  • list prints every task.

Each command prints the summary line, such as 1 of 2 tasks open.

Exit codes: 0 success, 1 the QML module failed to load, 2 usage error, unknown asset or row, missing translation or missing catalog resource, 3 storage read or write failure.

board --store <path> <image> renders the task board to an image, such as board.png or board.ppm, and prints <image> <width>x<height> <platform>. It always uses software rendering. It uses the offscreen platform unless QT_QPA_PLATFORM is set.

bazel run //examples/qt-tasks/app:board -- --store /tmp/tasks.json /tmp/board.png

Board exit codes: 0 success, 1 the QML module failed to load, 2 usage error, 3 storage read failure, 4 image write failure.

board_quick_test runs tst_board.qml. It clicks the boxes, moves the highlight with the arrow keys, toggles with Space, checks the rendered pixels and reloads the saved JSON from a temporary directory.

Package

bazel build //examples/qt-tasks/app:tasks_release_archive
mkdir /tmp/tasks && tar -xzf bazel-bin/examples/qt-tasks/app/tasks_release.tar.gz -C /tmp/tasks
env -i /tmp/tasks/tasks --store /tmp/tasks/tasks.json list

The archive holds tasks, NOTICE, IDENTITY.txt and resources/ with the libraries tasks links and needs, the QML and QtQml import modules and the license texts for Qt, fmt and LLVM. Only glibc 2.34 or newer comes from the host.

bazel build //examples/qt-tasks/app:board_release_archive
mkdir /tmp/board && tar -xzf bazel-bin/examples/qt-tasks/app/board_release.tar.gz -C /tmp/board
env -i /tmp/board/board --store /tmp/tasks.json /tmp/board/board.png

The board archive also holds the minimal, offscreen, xcb and wayland platform plugins under resources/plugins/, with the libraries they need, the DejaVu Sans fonts under resources/fonts/ and the QtQuick import modules. board loads those fonts, so it needs no fontconfig setup on the host.

To render on a display, set the platform. It needs a running X or Wayland server:

DISPLAY=:0 QT_QPA_PLATFORM=xcb /tmp/board/board --store /tmp/tasks.json /tmp/board/xcb.png
WAYLAND_DISPLAY=wayland-0 QT_QPA_PLATFORM=wayland /tmp/board/board --store /tmp/tasks.json /tmp/board/wayland.png

Run the display check on a host with a display:

DX_QT_DISPLAYS=xcb,wayland bazel test //qt/tests:qt_display_test

Shared Rust task board

One Rust task board runs as a native CLI, a browser app, a WASI module, and an Android NativeActivity. Every output loads the same board and asset files, applies the same commands, and prints the same replies.

bazel build //examples/shared-rust/... @shared_consumer//...
bazel test //examples/shared-rust/... @shared_consumer//...

Layout

Crate or targetRole
tasks_modelTask and board parsing, title and asset checks.
tasks_coreBoard commands, summaries, and asset digests through the pinned adler2 2.0.1 crate.
tasksNative and WASI CLI: tasks [--asset=PATH]... BOARD [COMMAND]....
shared_taskswasm-bindgen interface (WebBoard, asset_report, core_version, build_profile).
tasks_ffi, tasks_interfaceC interface (ffi/tasks.h) over the same core.
tasks_activity, tasks_apkAndroid NativeActivity and APK.
assets/board.txt, assets/notes.txtResources every output loads.

Commands are add TITLE, done ID, reopen ID, attach ID ASSET, and remove ID. Replies are ok:<event> or err:<kind>.

The CLI exits 0 on success, 1 when a command is rejected, and 2 for a usage error, an unreadable or invalid board, or a missing asset.

Native

bazel run //examples/shared-rust:tasks -- \
  --asset=$PWD/examples/shared-rust/assets/notes.txt \
  $PWD/examples/shared-rust/assets/board.txt "add Ship it" "done 2"
bazel test //examples/shared-rust:native_test //examples/shared-rust:interface_test

native_test copies the binary and assets to a scratch directory and checks output, exit codes, and error text. interface_test drives the C interface.

Browser

bazel run //examples/shared-rust:serve
bazel test //examples/shared-rust:asset_test //examples/shared-rust:browser_test

browser_test serves the relocated release app in Firefox 157.0. It checks the build profile is release, the rendered tasks, summary, asset digest, and load error, then applies commands through the input and button.

WASI

bazel test //examples/shared-rust:wasi_test \
  //examples/shared-rust:wasi_error_test \
  //examples/shared-rust:wasi_capability_test

tasks_wasi is tasks built for wasm32-wasip1 and run under Wasmtime 49.0.2. wasi_error_test expects exit 1 for a rejected command. wasi_capability_test expects exit 2 and err:unreadable: /board/board.txt when the board is not preopened.

Android

export ANDROID_HOME=$HOME/Android/Sdk ANDROID_NDK_REVISION=27.2.12479018
NDK=--extra_toolchains=@android_ndk_toolchain//:ndk_cc_toolchain_device,@android_ndk_toolchain//:ndk_cc_toolchain_emulator
bazel test $NDK --test_env=ANDROID_HOME \
  //examples/shared-rust:inspect_apk_test \
  //examples/shared-rust:inspect_consumer_apk_test
bazel run $NDK //examples/shared-rust:install
bazel run $NDK //examples/shared-rust:start
bazel run $NDK //examples/shared-rust:log

The inspect tests check the package, SDK floor, label, launch activity, byte-identical assets, exported symbols per ABI, no extra ABIs, and the signature. The activity logs under the DxSharedTasks tag: lifecycle=create through lifecycle=destroy, board=loaded tasks=4 or board=invalid, the summary or summary none, the asset report, and input=touch|key <reply> after a tap or key press.

Tools: NDK 27.2.12479018, build-tools 34.0.0, platform-tools 37.0.0, emulator 37.2.12.0, system image android-34;default;x86_64.

Release

bazel test //examples/shared-rust:release_test

tasks_release, tasks_wasi_release, and app_release build with -c opt. release_test relocates the native binary and WASI module and expects identical output plus build=release. It also runs the WASI module with no preopen and expects err:unreadable: /board/board.txt with exit code 2.

Deploy

dx deploy //examples/shared-rust:tasks_deploy
dx deploy --apply //examples/shared-rust:tasks_deploy
bazel test //examples/shared-rust:tasks_deploy_verify

tasks_deploy is an archive_deploy of tasks. The first command builds it and publishes nothing. --apply checks the tarball against its checksum and copies tasks_deploy.tar.gz and tasks_deploy.tar.gz.sha256 into the workspace root, or into the directory given after --:

archive_deploy: released tasks (profile: release)
  tarball:  /path/to/rules_dx/tasks_deploy.tar.gz
  checksum: /path/to/rules_dx/tasks_deploy.tar.gz.sha256

tasks_deploy_verify checks that the tarball holds tasks and matches the checksum.

Independent consumer

@shared_consumer is a separate workspace that owns its HTML, board, notes, Android resources, permissions, features, rustc flags, and WASI runtime wrapper. It reuses tasks_core, shared_tasks, tasks_activity, and the public toolchains with no SDK or linker setup of its own.

bazel test @shared_consumer//... \
  //examples/shared-rust:consumer_browser_test \
  //examples/shared-rust:consumer_native_test

Offline and errors

bazel fetch //examples/shared-rust/... @shared_consumer//...
bazel test //examples/shared-rust/... @shared_consumer//... \
  --repository_disable_download

Tests tagged block-network run without network. With an empty repository cache, --repository_disable_download fails with download is disabled. missing_interface_test expects a web app without its bindgen interface to fail with requires bindgen to provide RustWasmBindgenInfo. missing_asset_test and bad_asset_path_test cover asset errors.

Pending

  • Custom toolchain override in the consumer: it overrides features, rustc flags, and the WASI runtime wrapper only.
  • Automated offline check: the fetch and --repository_disable_download commands above are not run by any test.
  • Emulator run: needs KVM. Without it the emulator boot loops.
  • Physical arm64 device run.
  • Multi-ABI install on a device.
  • Apple simulator and device outputs.

Debugger integration and game-engine portability are not supported.

Zig manifest example

A multi-package Zig app. It has a manifest CLI, a catalog module with a generated schema module and an embedded items.tsv, and a digest module that calls C through a pinned xxHash dependency.

PackageRole
catalogTSV parser and totals, generated schema.zig, embedded items.tsv
digestC wrapper over xxHash, imported from Zig as @import("c")
climanifest binary and report formatter
diagnosticsCompiler and zig fmt failures
bazel build //examples/zig-app/...
bazel test //examples/zig-app/...
bazel run //examples/zig-app/cli:manifest
bazel run //examples/zig-app/cli:manifest -- get cable

manifest prints each item, the total, the schema version and an XXH64 digest of the item lines. get NAME prints one item.

Exit codes: 0 ok, 2 usage, 4 unknown item. exit_codes_test runs the binary through each code.

Zig comes from the pinned rules_zig toolchain. xxHash is a pinned http_archive declared in MODULE.bazel. catalog_test, digest_test and report_test are Zig tests. catalog_test covers malformed input.

diagnostics_test compiles files with a syntax error, a type error and an unused local, and expects each to fail. It also checks every example source with zig fmt --check.