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#

RuleProducesWith
undra_coreper platform: lib<ns>.dylib or .so (host), <ns>.wasm (web), the XCFramework (iOS), jniLibs/<abi>/ (Android), and the symbol filesundra build, the profiles of every other build
undra_bindingsthe Swift package, the Gradle module and the npm package, as outputs, never committed, never staleundra bindgen --library, read from the host build
undra_bindings_testa test that fails when the bindings you committed are not what the schema generates, and <name>.update, which rewrites thembazel_lib's write_source_file
undra_kt_jvm_librarythe Kotlin bindings over the Kotlin runtimerules_kotlin
undra_ts_library (ts.bzl)the TypeScript bindings, type-checked and compiled against the runtime, linked into node_modulesaspect_rules_ts, aspect_rules_js
undra_swift_librarythe Swift bindings and the C module of the core's entry over the Swift runtimerules_swift (macOS)
undra_android_library (android.bzl)the Kotlin bindings as an Android libraryrules_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
MODULE.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/**
)
BUILD.bazel
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 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:

RulesetMinimumWhy not lower
rules_rust0.70.00.69 and older find no C++ toolchain for the wasm standard library ("No matching toolchains found")
rules_kotlin2.3.02.2 pulls a rules_android that Bazel 9 rejects (the CcInfo symbol is gone), and 2.1 uses JavaPluginInfo, which Bazel 9 removed
rules_swift3.4.03.3 and older fail to load on Bazel 9: the Swift worker names a nlohmann_json target that module no longer has
rules_apple4.1.0what rules_swift 3.4.0 asks for, so a lower declaration is raised to it
aspect_rules_ts3.10.0the first release with the typescript module extension the rules use
aspect_rules_js2.0.0what aspect_rules_ts 3.10.0 asks for
rules_nodejs6.7.5the 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#

PlatformNeedsWhere it is built
host, webnothing outside the rulesLinux and macOS, in CI on every change
iosXcode, and apple_support first in your MODULE.bazelmacOS, in CI on every change; the target is manual
androidthe 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_pathLinux, 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.

ChooseWhenWhat the build guarantees
Generated outputs: undra_bindingsYour repository treats generated code as an artifact: reviewers read the schema, not the bindings, and nothing outside Bazel needs the filesThe bindings are a function of the core library, so they cannot be stale
Committed, with the test: undra_bindings and undra_bindings_testYour 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 BazelThe committed directory equals the build's output, or bazel test fails and names the command that fixes it
Show the BUILD.bazel lines
BUILD.bazel
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:

shell
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 date

The 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.

BUILD.bazel
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.jsonper 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.

sh
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 release

Read 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:

sh
bazel run @undra//:cli -- symbolicate \
  --symbols "$PWD/bazel-bin/core_release_host.symbols/symbols" "$PWD/report.json"
text
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:

  1. it loads //:core_release_host (the stripped release library) in a JVM through the generated Kotlin bindings, with an onPanic handler, as an app does;
  2. it calls crash_for_symbols_test, a function of the example's core that exists to panic and that no consumer calls, and takes the UndraPanicReport the handler receives: addresses, the build's image id, and core/src/lib.rs:154:5 as the location;
  3. it writes the report as JSON and runs undra symbolicate --symbols on //:core_release_symbols;
  4. it asserts that a frame is hello_core::crash_for_symbols_test at the line of the panic! in core/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).

TargetWhat it is
//ios:appThe ios_application: a SwiftUI app that loads the core on launch and calls greeting. manual, macOS only.
//ios:helloundra_swift_library over //:bindings, compiled for iOS (the macOS-only //swift:hello is compiled for the machine).
//:mobile_iosThe iOS core. In a -c dbg build it keeps the objects that hold the core's DWARF (keep_debug_objects, below).
//ios:xcodeprojThe 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
BUILD.bazel
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:

shell
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 id

In 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.

lldb
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):

output
(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:24

Expect 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:

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.

FileLinterContent
kotlin/.editorconfigktlint, the IDE formatterone 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.ymlSwiftLintexcluded: 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.jsonESLint 8 and earlier (eslintrc){ "root": false, "ignorePatterns": ["**/*"] }
ts/eslint.config.undra.mjsESLint 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:

undra.toml
[bindings]
lint_exclusions = "none"       # "beside" (the default) writes the four files above; "none" writes none of them

With 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:

.swiftlint.yml
excluded:
  - generated

ktlint, 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.

.editorconfig
[generated/**]
ktlint_standard = disabled
ktlint_experimental = disabled

With 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:

eslint.config.js
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:

.vale.ini
[*.{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#

Edit this page on GitHub