Guides
Bazel
Undra's build is a command line, and a Bazel repository can call it as one: the rules run undra build and undra bindgen in hermetic, offline actions, so the core, its bindings and the code that uses them are targets in your graph.
What you get#
| Rule | Produces | With |
|---|---|---|
undra_core | per platform: lib<ns>.dylib or .so (host), <ns>.wasm (web), the XCFramework (iOS), jniLibs/<abi>/ (Android), and the symbol files | undra build, the profiles of every other build |
undra_bindings | the Swift package, the Gradle module and the npm package, as outputs, never committed, never stale | undra bindgen --library, read from the host build |
undra_bindings_test | a test that fails when the bindings you committed are not what the schema generates, and <name>.update, which rewrites them | bazel_lib's write_source_file |
undra_kt_jvm_library | the Kotlin bindings over the Kotlin runtime | rules_kotlin |
undra_ts_library (ts.bzl) | the TypeScript bindings, type-checked and compiled against the runtime, linked into node_modules | aspect_rules_ts, aspect_rules_js |
undra_swift_library | the Swift bindings and the C module of the core's entry over the Swift runtime | rules_swift (macOS) |
undra_android_library (android.bzl) | the Kotlin bindings as an Android library | rules_kotlin's kt_android_library, the Android SDK |
Set up#
The rules support Bazel 8.8 and 9.x, tested in CI on 8.8.1, the lowest, and 9.2.0, the newest 9.x release, each with the rulesets at their minimums and at recent versions (every target but the debugging example's //ios package, which stays at the recent versions; Ruleset versions). Pin Bazel in .bazelversion and put the rules and the toolchain in MODULE.bazel. The core is an ordinary Cargo crate that depends on undra; undra.toml stays the project file.
If you use undra_ts_library, add common --repo_env=DO_NOT_TRACK=1 to your .bazelrc. The TypeScript rules it loads (aspect_rules_js) report which rulesets and versions your build uses to Aspect unless that variable is set, and only your repository can set it: a module cannot do it for you. The other rules never load them.
Show MODULE.bazel and BUILD.bazel
bazel_dep(name = "undra_rules", version = "1.2.0")
bazel_dep(name = "rules_rust", version = "0.74.0") # 0.70.0 or later: see Ruleset versions
# The compiler Undra's goldens and size gate are recorded on, and the targets the cores are built for.
rust = use_extension("@rules_rust//rust:extensions.bzl", "rust")
rust.toolchain(
edition = "2024",
extra_target_triples = ["wasm32-unknown-unknown"],
versions = ["1.99.0"],
sha256s = {...}, # static.rust-lang.org's .sha256 files: see examples/bazel/MODULE.bazel
)
use_repo(rust, "rust_toolchains")
register_toolchains("@rust_toolchains//:all")
# Where Undra is, and the crates its builds may use (by the checksum Cargo.lock records).
undra = use_extension("@undra_rules//:extensions.bzl", "undra")
undra.source(path = "../undra") # a checkout of the Undra repository, or urls = [..]
undra.vendor(lockfiles = ["//:Cargo.lock"])
use_repo(undra, "undra", "undra_binaryen", "undra_vendor")
# Only for platforms = ["android"]: the Android NDK, as a repository (below), and the Rust standard library of the Android targets.
undra.android_std(
version = "1.99.0", # the release of rust.toolchain above
sha256s = { # static.rust-lang.org's .sha256 files
"aarch64-linux-android": "...",
"x86_64-linux-android": "...",
},
)
use_repo(undra, "undra_android_std")
http_archive = use_repo_rule("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
http_archive( # one per host OS: android-ndk-r27c-darwin.zip for macOS
name = "android_ndk_linux",
urls = ["https://dl.google.com/android/repository/android-ndk-r27c-linux.zip"],
sha256 = "59c2f6dc96743b5daf5d1626684640b20a6bd2b1d85b13156b90333741bad5cc", # android-ndk-r27c-linux.zip
strip_prefix = "android-ndk-r27c",
build_file = "//android:ndk.BUILD", # exports source.properties and toolchains/llvm/prebuilt/**
)load("@undra_rules//undra:defs.bzl", "undra_bindings", "undra_core", "undra_kt_jvm_library")
load("@undra_rules//undra:ts.bzl", "undra_ts_library") # its own file: see the .bazelrc line above
undra_core(
name = "core",
namespace = "hello_core", # [core] namespace of undra.toml
srcs = glob(["core/**"]) + ["Cargo.toml"],
platforms = ["host", "web"], # core_host, core_web; "ios" and "android" too
wasm_opt = "@undra_binaryen//:wasm_opt",
# ndk = "@android_ndk_linux//:ndk", # for "android": the NDK's files, as an input of the action
)
undra_bindings(name = "bindings", core = ":core_host") # the generated trees, as outputs
undra_kt_jvm_library(name = "hello", bindings = ":bindings")
undra_ts_library(name = "hello_ts", bindings = ":bindings", package = "@acme/hello")
# A JVM test loads the host library by its path: jvm_flags = ["-Dundra.native.hello_core.path=$(rootpath :core_host)"]bazel test //... in examples/bazel builds the to-do core for the host and the web, generates the bindings, and runs Kotlin tests against the real core through JNI, TypeScript tests against the wasm module under Node, and, on macOS, a Swift test that loads it in process. CI runs it on Linux for every change, and the Swift test and the iOS core on macOS; a third job builds the Android core and the Android library on Linux.
Why it is hermetic#
The rules call the CLI rather than copy its build: the shim, the release profiles, the wasm-opt pipeline, the path remapping and the symbol files stay in one place, so the web size gate and the schema hash are the CLI's. What makes an action that runs Cargo safe to cache:
- The compiler is Bazel's.
rustc,cargoand each target's standard library come from yourrules_rusttoolchain, pinned by checksum (the Android standard library fromundra.android_std, also by checksum). Norustup. - The crates are the lock file's.
undra_vendordownloads each crates.io package once, by the checksumCargo.lockrecords, and an action unpacks them into a Cargo directory source with the network off. - The CLI is built the same way, from the checkout the core is built against.
- The build sees only what the target declares. The action copies
undra.toml,srcsandworkspaceinto a private directory and builds there, with any spawn strategy, sandboxed or not; a file missing fromsrcsis a build error, not a silent read of your tree. - The build directory is named by the target, because Cargo and
undra buildput the build's path into symbol names: the same target builds in the same place in every checkout, so the same inputs give the same bytes (two clones on one Mac produced byte-identical cores).
The machine's are the C linker (the host's own, as rules_rust uses it) and, for iOS, Xcode, which no Bazel rule distributes: DEVELOPER_DIR comes in through --action_env. The Android NDK is not the machine's: it is a label, an input of the action, so an Android core is cached like the others.
Ruleset versions#
undra_rules declares the lowest version of each ruleset it needs, not the newest. Bazel resolves to the highest version anyone in your graph asks for, so a newer rules_swift of your own wins and the rules run on it; a lower one is raised to the minimum. CI tests both ends on Bazel 8.8.1 and 9.2.0: the example workspace at recent versions (rules_rust 0.74.0, rules_kotlin 2.4.20, rules_swift 4.1.2, rules_apple 5.2.0, aspect_rules_ts 3.10.1, aspect_rules_js 3.5.1), and the same targets with every ruleset at the minimum below (bazel test //..., the Swift consumer and the iOS core, the Android core and library; the debugging example's //ios package alone stays at the recent versions, since rules_xcodeproj sets floors of its own), a run that fails if anything in the graph raises one. A minimum is the lowest that works on both Bazel versions: rules_swift 3.3 still builds on Bazel 8.8, for instance, and the floor is Bazel 9's. The ones that set a floor:
| Ruleset | Minimum | Why not lower |
|---|---|---|
rules_rust | 0.70.0 | 0.69 and older find no C++ toolchain for the wasm standard library ("No matching toolchains found") |
rules_kotlin | 2.3.0 | 2.2 pulls a rules_android that Bazel 9 rejects (the CcInfo symbol is gone), and 2.1 uses JavaPluginInfo, which Bazel 9 removed |
rules_swift | 3.4.0 | 3.3 and older fail to load on Bazel 9: the Swift worker names a nlohmann_json target that module no longer has |
rules_apple | 4.1.0 | what rules_swift 3.4.0 asks for, so a lower declaration is raised to it |
aspect_rules_ts | 3.10.0 | the first release with the typescript module extension the rules use |
aspect_rules_js | 2.0.0 | what aspect_rules_ts 3.10.0 asks for |
rules_nodejs | 6.7.5 | the first release that knows Node 24.18.0, which the example runs (the rules themselves need only what aspect_rules_js asks) |
The rest (apple_support 1.24.2, rules_cc 0.2.17, rules_java 9.3.0, rules_shell 0.6.1, platforms 1.1.0, bazel_skylib 1.8.2, bazel_lib 3.1.0) are what Bazel resolves from the rulesets above; asking for less changes nothing. To check your own graph, run bazel mod graph and look for a ruleset resolved higher than the version you declared.
Platforms#
| Platform | Needs | Where it is built |
|---|---|---|
host, web | nothing outside the rules | Linux and macOS, in CI on every change |
ios | Xcode, and apple_support first in your MODULE.bazel | macOS, in CI on every change; the target is manual |
android | the NDK as a labelled input (undra_core(ndk = ..), an http_archive of the NDK pinned by checksum, or rules_android_ndk's repository) and undra.android_std: no cargo-ndk, no --action_env, no extra_path | Linux, in CI on every change; the target is manual |
The Android core is a Cargo cross-build that undra build links with the NDK's clang, so it is built in the host configuration, with the host's Rust toolchain: there is no Android platform to resolve a C++ toolchain for. The API level of the linker is [android] min_sdk of undra.toml. The archive is per host OS (a select() on @platforms//os:* picks the label) and is fetched only by a build that asks for the Android core, never by bazel test //.... CI gives that build the NDK that sdkmanager installed instead of downloading it through Bazel: examples/bazel/android/use-installed-ndk.sh prints the --override_repository flags that do it (each NDK repository by its name and by the canonical name Bazel 8 needs), which is also the way to use an NDK you already have. The pins themselves are fetched through Bazel, with their checksums checked, by a workflow of their own (.github/workflows/ndk-pins.yml, monthly and on demand), so a stale archive shows there before it shows on a machine.
undra_android_library needs the Android SDK only (rules_android reads ANDROID_HOME), not the NDK. The example's (bazel build //android:hello --config=android) is built in the same CI job; rules_android then downloads its own tools, one of them an archive without a checksum, which is why it stays a manual target and is not part of bazel test //....
Committed bindings#
There are two ways to hold the bindings, and both are checked by the build. undra_bindings alone makes them outputs: nothing is committed and nothing can be stale. A repository whose policy requires generated code in the tree commits them and adds undra_bindings_test, so a pull request cannot change the core's API and leave the committed copy behind.
| Choose | When | What the build guarantees |
|---|---|---|
Generated outputs: undra_bindings | Your repository treats generated code as an artifact: reviewers read the schema, not the bindings, and nothing outside Bazel needs the files | The bindings are a function of the core library, so they cannot be stale |
Committed, with the test: undra_bindings and undra_bindings_test | Your policy puts generated code in the tree: a tool outside Bazel reads it, an IDE or a review wants it on disk, or the app is also built without Bazel | The committed directory equals the build's output, or bazel test fails and names the command that fixes it |
Show the BUILD.bazel lines
load("@undra_rules//undra:defs.bzl", "undra_bindings", "undra_bindings_test")
undra_bindings(name = "bindings", core = ":core_host")
undra_bindings_test(
name = "bindings_check",
bindings = ":bindings",
committed = "generated", # a directory of this package: swift/, kotlin/ and ts/ below it
)Create the files once and refresh them after every change to the core:
bazel run //:bindings_check.update # writes the trees into the source tree; commit the result
bazel test //:bindings_check # fails, naming the line above, when they are out of dateThe comparison is the one undra bindgen --check makes: the same files, byte for byte, nothing normalised (not line endings, not trailing space). undra bindgen --check --out generated accepts the directory the update writes, and a file edited by hand fails both. The rule is stricter in one way: it compares the directory as a whole, so a file that you keep beside the generated ones fails the test (the CLI ignores files its manifest does not list), and the update removes it. Keep the directory for the bindings alone, BUILD.bazel files included: a target that compiles the committed copy lives in the package around it and globs it (glob(["generated/ts/**"])). A directory that does not exist yet fails the test when it runs, with the update command in its message, and the rest of bazel test //... still runs; the first run of the update target creates it.
Symbol files and crash reports#
A release core ships stripped, and a crash report from it holds addresses. The files that turn them into a Rust file and line are the build's symbol files (what they are), and under Bazel they are an output of the same target: the symbols output group of a release undra_core. The web core is always a release build, so core_web has one; the host, iOS and Android cores have it when release = True. A debug core keeps its symbols in itself and has none: a filegroup of its group is empty, without an error, so point it at the release target.
undra_core(
name = "core_release",
namespace = "hello_core",
srcs = glob(["core/**"]) + ["Cargo.toml"],
platforms = ["host"],
release = True, # the build you ship, and the one whose symbols you keep
)
# The group as a label: a directory you can depend on, build, or copy.
filegroup(
name = "core_release_symbols",
srcs = [":core_release_host"],
output_group = "symbols",
)The directory is laid out as undra build --release lays out build/, restricted to the symbol files, so the manifest's relative paths hold and the group is exactly what the CLI would have written:
In bazel-bin/<target>.symbols/ | What it is |
|---|---|
symbols/manifest.json | per image: namespace, core version, schema hash and the image id a crash report carries |
symbols/web/ | web: <ns>.debug.wasm (the shipped code with its names), <ns>.wasm.functions.txt (the function map) and <ns>.dwarf.wasm |
symbols/android/ | Android: <abi>/lib<ns>.so and native-debug-symbols.zip |
host/ | host: lib<ns>.dylib.dSYM (macOS) or lib<ns>.so.debug (Linux), which the CLI keeps beside the library and the manifest names as ../host/.. |
iOS has no file of its own: the Rust frames are in your app's dSYM (--apple_generate_dsym) when the iOS core of that build keeps its debug objects (keep_debug_objects = True, release cores too: Debugging the core, below); without it the dSYM has the Swift frames only. Each target has its own group and its own manifest, so a release of a host and a web core has two directories.
Keep them per release
A report is read against the symbols of the build that shipped, found by the image id in the manifest, so keep each release's directory next to what you published, in the store that holds the rest of its artifacts. Do not count on rebuilding it or on a remote cache: the toolchain, the sources and the build's path must all be the same again for the same file.
bazel build //:core_release_symbols //:core_web_symbols
tar -C bazel-bin -chf symbols-1.4.0.tar core_release_host.symbols core_web.symbols # -h: Bazel's outputs are links
# upload symbols-1.4.0.tar with the releaseRead a report
Give undra symbolicate the report your app's onPanic received (as JSON, its fields) and the symbols/ directory of the build that shipped. The CLI is a target of its own, @undra//:cli, built by the same toolchain. bazel run works from its runfiles, so the paths are absolute:
bazel run @undra//:cli -- symbolicate \
--symbols "$PWD/bazel-bin/core_release_host.symbols/symbols" "$PWD/report.json"0x96d03 hello_core::crash_for_symbols_test (/undra/app/core/src/lib.rs:154)
0x10dd3 hello_core::__undra_dispatch_fn_crash_for_symbols_test (/undra/app/core/src/lib.rs:153)
0x826f7 <undra_runtime::runtime::Runtime>::run_dispatcher (/undra/src/crates/undra-runtime/src/runtime.rs:1937)One line per frame, innermost first (the lines above are three of the report's fourteen). A release build names your project /undra/app, whatever directory Bazel built it in, so the path is the same on every machine and names none. The tool that reads the symbol file is the machine's: atos (Xcode or the command line tools) for a dSYM, llvm-symbolizer (apt install llvm) for the others. The CLI says which it needs when it is missing, and warns when the report's schema hash or version is not the symbols'.
The reference: a test that crashes the core
bazel test //symbols:symbolicate_test in the example does all of the above with a real crash:
- it loads
//:core_release_host(the stripped release library) in a JVM through the generated Kotlin bindings, with anonPanichandler, as an app does; - it calls
crash_for_symbols_test, a function of the example's core that exists to panic and that no consumer calls, and takes theUndraPanicReportthe handler receives: addresses, the build's image id, andcore/src/lib.rs:154:5as the location; - it writes the report as JSON and runs
undra symbolicate --symbolson//:core_release_symbols; - it asserts that a frame is
hello_core::crash_for_symbols_testat the line of thepanic!incore/src/lib.rs, found in the source, which is also the line the core reported.
It waits for no time: the report is a future the handler completes. With the symbols of another build it fails, because the report's image id is not the manifest's, and without the host library's symbols it fails with the CLI's error: what a stale or a missing symbol directory looks like in production.
Debugging the core from a Bazel-built app#
A Bazel-built iOS app can be debugged into Rust: a rules_apple ios_application over the generated Swift bindings and the iOS core, an Xcode project generated by rules_xcodeproj, and a breakpoint on a function of the core that stops with the Rust frame (file and line) under the Swift frames that called it. The example has all three in examples/bazel/ios. It was verified on the iOS simulator (an iPhone 17, iOS 26.5, Xcode 26.6, Bazel 8.8.1, rules_apple 5.2.0, rules_xcodeproj 4.1.0) with LLDB driven from the command line; a physical device was not tried, and neither was Xcode's own editor and debugger UI (the project it opens is the one xcodebuild built here).
| Target | What it is |
|---|---|
//ios:app | The ios_application: a SwiftUI app that loads the core on launch and calls greeting. manual, macOS only. |
//ios:hello | undra_swift_library over //:bindings, compiled for iOS (the macOS-only //swift:hello is compiled for the machine). |
//:mobile_ios | The iOS core. In a -c dbg build it keeps the objects that hold the core's DWARF (keep_debug_objects, below). |
//ios:xcodeproj | The rules_xcodeproj project of the app: bazel run //ios:xcodeproj writes ios/HelloApp.xcodeproj. Xcode builds the app through Bazel. |
The core of a slice is one prelinked object, and Apple's linker does not copy DWARF into it: it writes a debug map that names the object files the DWARF is in (ADR-044). Those files are in the directory the build action works in, /tmp/undra-bazel-<key>/target, which the action removes when it ends, so by default the app's debug information has no Rust in it: LLDB reports the breakpoint as pending and dsymutil writes a dSYM of the Swift code only (the example's //ios:symbols_test fails on a dSYM made that way). undra_core(keep_debug_objects = True) leaves the libundra_core_*.a of each target triple (80 MB each in a debug build) in place, with the sources the DWARF names by their path in that directory (app/, the project's, and undra/, the Undra crates', 15 MB: without them a breakpoint stops at the right file and line but LLDB and Xcode have no source to show; the vendored crates' sources, 295 MB unpacked, are not kept, so a frame inside one of them shows file and line only), and nothing else of the directory, until the next build of the target; the example sets it when the build is -c dbg:
Show the BUILD.bazel lines
undra_core(
name = "mobile",
namespace = "hello_core",
platforms = ["ios", "android"],
keep_debug_objects = select({":dbg": True, "//conditions:default": False}), # config_setting :dbg, -c dbg
)
# ios/BUILD.bazel
undra_swift_library(name = "hello", bindings = "//:bindings", module_name = "HelloCore", ffi_module = "HelloCoreFFI")
ios_application(name = "app", bundle_id = "dev.undra.bazel.hello.app", families = ["iphone"], minimum_os_version = "17.0",
infoplists = ["Info.plist"], deps = [":app_lib"]) # app_lib: the SwiftUI source, over :hello and the core
xcodeproj(name = "xcodeproj", project_name = "HelloApp", top_level_targets = [top_level_target(":app", target_environments = ["simulator"])])Generate the project, build the app with it and start the app waiting for a debugger:
cd examples/bazel
bazel run //ios:xcodeproj # Updated project at "ios/HelloApp.xcodeproj"
xcodebuild -project ios/HelloApp.xcodeproj -scheme app -configuration Debug \
-destination 'platform=iOS Simulator,id=<udid of a booted simulator>' -derivedDataPath /tmp/hello-dd build
APP=$(find /tmp/hello-dd/Build/Products -name app.app | head -1)
xcrun simctl install booted "$APP"
xcrun simctl launch --wait-for-debugger booted dev.undra.bazel.hello.app # prints the process idIn Xcode the same is open ios/HelloApp.xcodeproj and Run. Without Xcode, bazel build //ios:app --ios_multi_cpus=sim_arm64 -c dbg --apple_generate_dsym builds the same app (bazel-bin/ios/app.ipa, and its dSYM next to it): unzip the .ipa and install its Payload/app.app as above. Then attach LLDB from the Bazel execution root of the build, the directory Xcode's own session also uses: the debug map names the Swift objects by paths relative to it, and without it the Swift frames carry no file and line (the Rust frames do not need it). For the project rules_xcodeproj generated that is $(bazel info output_base)/rules_xcodeproj.noindex/build_output_base/execroot/_main; for a plain bazel build it is bazel info execution_root.
xcrun lldb -b \
-o "process attach -p <pid>" \
-o "breakpoint set --name hello_core::greeting" \
-o "continue" \
-o "frame select 47" -o "bt -c 60" -o "detach"What it printed (the frames between the dispatcher and the Swift transport left out; 47 is the frame of HelloApp.start() in this app):
(lldb) breakpoint set --name hello_core::greeting
Breakpoint 1: where = app`hello_core::greeting + 20 at lib.rs:145:5, address = 0x0000000102a6ee14
(lldb) continue
* thread #10, name = 'View.task @ HelloApp/HelloApp.swift:15 (Task 1)', queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
(lldb) frame select 47
frame #47: app`HelloApp.start() at HelloApp.swift:23:24
-> 23 return try greeting(name: "Xcode")
(lldb) bt -c 60
frame #0: app`hello_core::greeting(name=String @ 0x000000016d556b10) at lib.rs:145:5
frame #1: app`hello_core::__undra_dispatch_fn_greeting(__rt=&dyn core::any::Any @ ..., __call=DispatchCall @ ...) at lib.rs:144:8
frame #2: app`<undra_runtime::runtime::Runtime>::run_dispatcher::{closure#0} at runtime.rs:1937:33
...
frame #29: app`undra_ffi::native::undra_call_sync(ptr="", len=26) at native.rs:321:5
frame #30: app`closure #1 in InprocTransport.callSync(bytes=26 values (...)) at InprocTransport.swift:193:26
...
frame #46: app`greeting(name="Xcode", ctx=0x00000001028a36c0) at Objects.swift:11:28
frame #47: app`HelloApp.start() at HelloApp.swift:23:24Expect the Rust frame with its file and line, the generated dispatcher under it, the core's entry points (undra_call_sync) and then Swift: the runtime's InprocTransport, the generated greeting(name:ctx:) and your code. A few things LLDB does that are worth knowing:
- Select the Swift frame before the first
bt. LLDB loads the Swift objects' debug information when a Swift frame is selected; after abtalone the same frames print without a file and line (HelloApp.start() + 228). - Run it from the execution root. The Swift objects are named in the debug map by paths relative to it. The Rust frames do not need that.
- The Rust formatters. Sourcing the
.lldbinitthatundra initwrites (command source <undra checkout>/crates/undra-cli/templates/project/lldbinit) makesframe variable nameprint"Xcode", and a fullbtworks with it sourced for a core built from 1.1. (With a 1.0 core, LLDB crashed on a backtrace of six frames or more once the file was sourced; 1.1 fixed that at its causes: a private variant of a runtime enum is renamed so LLDB's type layout no longer recurses, and the formatter template no longer walks pointer fields or Swift and Objective-C objects. For a 1.0 core, take the full backtrace in a session that did not source it.) - LLDB warns that it has "no plugin for the language rust"; the frames work without it, and a Rust variable prints as its fields.
- The source is in the kept directory.
source list -n hello_core::greetingprints the Rust lines, andsource info -n hello_core::greetingnames the file,/private/tmp/undra-bazel-<key>/app/core/src/lib.rs: that directory is the build's, the newest/tmp/undra-bazel-*whose owner file sayskept. Were the sources ever elsewhere (a copy of the directory),settings set target.source-map /private/tmp/undra-bazel-<key>/app <the project>points LLDB at them. - A breakpoint by
-f lib.rs -l 145works too and also sets a location in every dependency that has alib.rs(24 here), so a function name is the better choice.
Three things to know about the kept objects. They are not a build output, so a core that Bazel restores from its cache after the directory is gone has no Rust frames: build it again, with any new value of a variable the action reads (--action_env=UNDRA_REBUILD=2 rebuilds the iOS core, verified). macOS removes what is in /tmp at a restart and, once a day, files unused for three days, so a debugging session days after the build may need that rebuild too. And the directory belongs to the target and to keep_debug_objects, so a build of the same core without it (the default configuration) does not remove them, but the bytes of the debug core differ from that one's, because the directory's name is in them.
Linters over the whole tree#
A repository whose CI lints everything meets the generated bindings in every linter it runs. undra bindgen writes these four files, beside the tree each one belongs to, and lists them in .undra-generated with the rest (so undra bindgen --check fails when one is missing or edited, and the next run removes one the schema no longer needs). Paths are relative to the directory the bindings are written to (generated/ in a project made by undra init); none of the files names that directory, so a tree that moves is still excluded.
| File | Linter | Content |
|---|---|---|
kotlin/.editorconfig | ktlint, the IDE formatter | one section, [*.{kt,kts}], with ktlint_standard = disabled, ktlint_experimental = disabled and ij_formatter_enabled = false; no root = true, so your own .editorconfig still applies to everything else |
swift/.swiftlint.yml | SwiftLint | excluded: with the package's two source directories, **/Sources/<Module>/Generated and **/Sources/<Module>CoreFFI. SwiftLint ignores the excluded: of a nested configuration, so this is a fragment for your root configuration (swiftlint --config .swiftlint.yml --config generated/swift/.swiftlint.yml), not a switch by itself |
ts/.eslintrc.json | ESLint 8 and earlier (eslintrc) | { "root": false, "ignorePatterns": ["**/*"] } |
ts/eslint.config.undra.mjs | ESLint 9 (flat config) | export default [{ ignores: ["<its own directory>/**"] }], the directory worked out from import.meta.url relative to where ESLint runs. A flat config is read only from that directory, so you spread it into your eslint.config.js: export default [...undraGenerated, /* yours */] |
A tree is written only for a platform the project builds, so a project without web has no ts/ files. Every generated Kotlin file also starts, after its header line, with @file:Suppress("ALL", "ktlint"). That line is not one of the four files: it is part of the Kotlin file itself, it travels with the file when it is copied or packed into a source jar, ktlint 1.x, detekt and IntelliJ read it with no configuration, and it stays whatever you set below. The generated .gitattributes and .gitignore are not lint files either.
ktlint 1.8 reports 90 findings on the example's generated Kotlin without them and none with either the file or the annotation; bazel test //kotlin:lint_test in the example runs that comparison. The annotation does not silence the Kotlin compiler: its warnings about the generated code still show.
A repository that configures its linters in one place
If your root configuration already covers the bindings, or you would rather not have four more files in the tree, say so in undra.toml:
[bindings]
lint_exclusions = "none" # "beside" (the default) writes the four files above; "none" writes none of themWith none the four files are not generated and not in the manifest. Switching an existing project to it makes undra bindgen --check name the old files as stale until you run undra bindgen once, which removes them. Anything that names one of them goes with it: an import of eslint.config.undra.mjs in your eslint.config.js, or a --config generated/swift/.swiftlint.yml in a SwiftLint command, would then fail, so replace it with the root lines below in the same change. Under Bazel the setting flows through the undra.toml you give undra_bindings(config = ..): the rule has no list of these files, so nothing else changes. Any other value is error[undra::C0002], naming the line. Then put the same exclusions in the root configurations. The examples use generated/, the default of [paths] generated; use your path.
SwiftLint, in the root .swiftlint.yml:
excluded:
- generatedktlint, in the root .editorconfig. A section with a slash is anchored to the directory of its .editorconfig, so write the bindings' path from there: [**/generated/**] misses a generated/ right beside the file.
[generated/**]
ktlint_standard = disabled
ktlint_experimental = disabledWith the Gradle ktlint plugin, ktlint { filter { exclude { it.file.path.contains("/generated/") } } } does the same. ESLint 9, in eslint.config.js (an object with only ignores is a global ignore); for ESLint 8, "ignorePatterns": ["generated/"] in the root .eslintrc.json or a line in .eslintignore:
export default [
{ ignores: ["generated/**"] },
// your configuration
];Vale lints the prose in the comments of source files when a section of .vale.ini matches them (for example [*.{swift,kt,ts}]), and the comments of generated code are a generator's, not yours. undra bindgen writes no Vale file at any setting. Give the generated tree an empty style list in a section placed after the ones that match its files:
[*.{swift,kt,ts}]
BasedOnStyles = Vale
[generated/**]
BasedOnStyles =As with the rest of this section, the SwiftLint, ESLint and Vale lines follow each tool's documentation and are not run in Undra's CI; ktlint is (the example's bazel test //kotlin:lint_test).
Good to know#
- Bazel caches a core as a whole. A change to the core rebuilds its dependencies inside the action (seconds for a small core); a remote cache makes that cost the author's alone.
- The host core is built for the machine that runs the bindings.
undra_bindingsbuilds itscorein the execution configuration, because the bindgen action loads it; a test that also loads:core_host(the JVM one, above) builds it once more, in the target configuration. - Swift needs its files listed.
rules_swiftcompiles files, not a directory, soundra_bindings(swift_files = [..])names them; a wrong list fails the build and prints the right one. - Telemetry. Only
ts.bzlloadsaspect_rules_js, whose telemetry is on unless your.bazelrcsets--repo_env=DO_NOT_TRACK=1(Set up, above); the example sets it. - The scratch directory. Each core builds in
/tmp/undra-bazel-<key>, removed when the action ends (all but the core's objects withkeep_debug_objects, Debugging, above). On Linux the sandbox's/tmpis private; on macOS two builds of the same target take turns, and another user's build of it goes to a directory of its own. - The decision, its alternatives and what was measured are in ADR-061.