Skip to content

Introducing any2bazel - an OSS tool to migrate to Bazel from other build systems

Back in June I was sitting on a train to Zurich. The plan was to meet colleagues and work together on figuring out how to migrate CMake projects to Bazel. I was looking forward to chatting with my colleagues in person, but I was not optimistic about the migration process. Truth be told, I was expecting to spend the week walking through the problems parsing the custom CMake language and CMake being Turing complete, maybe even look at a few concrete cases like generated files and configuration flags, finally conclude it couldn't be done, and return home with not much to show. Oh boy was I wrong.

Why migrate between build systems at all? The companies we talk to value Bazel for its hermeticity, test automation, introspection, multi-language support, and remote execution support, and we do too. It's not without flaws, of course. CMake is ubiquitous for C/C++, and there are other more-or-less language-specific build systems for Rust (Cargo), Go, Java, JavaScript, TypeScript, etc. Often these are better at their niche.

But what prevents an automated migration between any two arbitrary build systems?

My colleagues Damien Buhl (alias daminetreg) and Yannic Staudt (previously https://tipi.build/, now EngFlow) in Zurich are CMake experts: they have been consulting on CMake work and started Tipi to accelerate CMake builds. Even more relevant, one of Tipi's offerings is an early AI that automatically generates CMake build files for C++ builds. I previously worked on Bazel, so between us, we should have all the knowledge required to figure this out.

We did not build a CMake-to-Bazel migration tool. Instead, we came up with a generic process to migrate between any two build systems. This is the story of the any2bazel project.

Generic Build System Migration

Conceptually, we extract the action graph from both build systems and then verify that they are identical. This isn't a new idea. In the context of Bazel migration, SpaceX talked about it in Building Real-time Systems with Bazel back at the first-ever BazelCon in 2017, saying they "compare every flag on every command-line" as well as check for "binary equivalence of every object file". Even before that, Chromium migrated from GYP to GN between 2014 and 2016, using a flag comparison script to verify it. Naively, this does not solve the problem entirely because someone still needs to write the build files for the target build system (e.g., Bazel). Still, it gives us a deterministic success heuristic.

Our hypothesis: we could plausibly pair an outer LLM loop that drives an iterative build system migration using the action graph comparison to check progress and correct mistakes.

Extracting the Action Graph

When I arrived in Zurich, my colleague Yannic Staudt pitched the idea that we could extract the action graph from CMake with additional annotations, and then use that to generate Bazel BUILD files. So, what is an action graph?

Nearly every build system boils down to running various tools (e.g. gcc, javac, rustc) with the right arguments, environment variables, and input files to produce one or more output file(s). Individually, we call those tool runs actions. However, many actions depend on the outputs of other actions; a link action may require .o files generated from source compilations, or a JS bundler action may depend on a type check action. These dependencies between actions form a graph: the action graph.

One of the oldest build systems is make, and you can use a Makefile to specify actions and their dependencies. This is an example Makefile:

Makefile
1
2
3
4
5
readme.md: readme.txt
    pandoc readme.txt -o readme.md

documentation.pdf: readme.md
    pandoc -o documentation.pdf readme.md --pdf-engine=weasyprint

This example has two actions, one to generate a readme.md file from an existing readme.txt file, and then a second action to compile that readme.md file into a documentation.pdf file.

Extracting the action graph varies in difficulty by build system:

  • Make does not have any built-in APIs to extract the action graph, and requires either writing a custom implementation or running the existing build and capturing the tool invocations. CMake has the file API, the --trace option, a configure_file to extract different parts of the action graph, and requires some post-processing to put everything together.
  • Bazel's aquery command is basically exactly what we want, and requires only minimal post-processing.

Comparing the Action Graph

I liked Yannic's idea, and to test it out, I wrote simple extractors for CMake and Bazel. Then I used the extractors to compare the action graphs on projects that already have both CMake and Bazel configuration files, specifically BoringSSL and Abseil. Sounds pretty straightforward?

CMake auto-detects the local C/C++ toolchain and directly runs the compiler and linker. Here are the actions from an example project built with both CMake and Bazel (using rules_cc) on macOS and Linux:

CMake:

Text Only
1
2
3
4
5
$XCODE/usr/bin/c++ -arch arm64 -isysroot $SDK -mmacosx-version-min=14.7 -MD -MT CMakeFiles/greet.dir/src/greet.cc.o -MF CMakeFiles/greet.dir/src/greet.cc.o.d -o CMakeFiles/greet.dir/src/greet.cc.o -c src/greet.cc
$XCODE/usr/bin/ar qc libgreet.a CMakeFiles/greet.dir/src/greet.cc.o
$XCODE/usr/bin/ranlib libgreet.a
$XCODE/usr/bin/c++ -arch arm64 -isysroot $SDK -mmacosx-version-min=14.7 -MD -MT CMakeFiles/hello.dir/src/main.cc.o -MF CMakeFiles/hello.dir/src/main.cc.o.d -o CMakeFiles/hello.dir/src/main.cc.o -c src/main.cc
$XCODE/usr/bin/c++ -arch arm64 -isysroot $SDK -mmacosx-version-min=14.7 -Wl,-search_paths_first -Wl,-headerpad_max_install_names CMakeFiles/hello.dir/src/main.cc.o -o hello libgreet.a

Bazel:

Text Only
1
2
3
4
$CC_WRAPPER -U_FORTIFY_SOURCE -fstack-protector -Wall -Wthread-safety -Wself-assign -Wunused-but-set-parameter -Wno-free-nonheap-object -fcolor-diagnostics -fno-omit-frame-pointer '-std=c++17' '-frandom-seed=bazel-out/darwin_arm64-fastbuild/bin/_objs/greet/greet.o' '-mmacosx-version-min=15.2' -MD -MF bazel-out/darwin_arm64-fastbuild/bin/_objs/greet/greet.d -iquote . -iquote bazel-out/darwin_arm64-fastbuild/bin -Ibazel-out/darwin_arm64-fastbuild/bin/_virtual_includes/greet -c src/greet.cc -o bazel-out/darwin_arm64-fastbuild/bin/_objs/greet/greet.o -no-canonical-prefixes -Wno-builtin-macro-redefined '-D__DATE__="redacted"' '-D__TIMESTAMP__="redacted"' '-D__TIME__="redacted"'
/usr/bin/libtool -D -no_warning_for_no_symbols -static -o bazel-out/darwin_arm64-fastbuild/bin/libgreet.a bazel-out/darwin_arm64-fastbuild/bin/_objs/greet/greet.o
$CC_WRAPPER -U_FORTIFY_SOURCE -fstack-protector -Wall -Wthread-safety -Wself-assign -Wunused-but-set-parameter -Wno-free-nonheap-object -fcolor-diagnostics -fno-omit-frame-pointer '-std=c++17' '-frandom-seed=bazel-out/darwin_arm64-fastbuild/bin/_objs/hello/main.o' '-mmacosx-version-min=15.2' -MD -MF bazel-out/darwin_arm64-fastbuild/bin/_objs/hello/main.d -iquote . -iquote bazel-out/darwin_arm64-fastbuild/bin -iquote external/rules_cc+ -iquote bazel-out/darwin_arm64-fastbuild/bin/external/rules_cc+ -iquote external/bazel_tools -iquote bazel-out/darwin_arm64-fastbuild/bin/external/bazel_tools -Ibazel-out/darwin_arm64-fastbuild/bin/_virtual_includes/greet -c src/main.cc -o bazel-out/darwin_arm64-fastbuild/bin/_objs/hello/main.o -no-canonical-prefixes -Wno-builtin-macro-redefined '-D__DATE__="redacted"' '-D__TIMESTAMP__="redacted"' '-D__TIME__="redacted"'
$CC_WRAPPER -o bazel-out/darwin_arm64-fastbuild/bin/hello bazel-out/darwin_arm64-fastbuild/bin/_objs/hello/main.o bazel-out/darwin_arm64-fastbuild/bin/libgreet.a -Wl,-S '-mmacosx-version-min=15.2' -no-canonical-prefixes -fobjc-link-runtime -headerpad_max_install_names -lc++ -lm

CMake:

Text Only
1
2
3
4
5
/usr/bin/c++ -MD -MT CMakeFiles/greet.dir/src/greet.cc.o -MF CMakeFiles/greet.dir/src/greet.cc.o.d -o CMakeFiles/greet.dir/src/greet.cc.o -c src/greet.cc
/usr/bin/ar qc libgreet.a CMakeFiles/greet.dir/src/greet.cc.o
/usr/bin/ranlib libgreet.a
/usr/bin/c++ -MD -MT CMakeFiles/hello.dir/src/main.cc.o -MF CMakeFiles/hello.dir/src/main.cc.o.d -o CMakeFiles/hello.dir/src/main.cc.o -c src/main.cc
/usr/bin/c++ -Wl,--dependency-file=CMakeFiles/hello.dir/link.d CMakeFiles/hello.dir/src/main.cc.o -o hello libgreet.a

Bazel:

Text Only
1
2
3
4
/usr/bin/gcc -U_FORTIFY_SOURCE -fstack-protector -Wall -Wunused-but-set-parameter -Wno-free-nonheap-object -fno-omit-frame-pointer '-std=c++17' -MD -MF bazel-out/k8-fastbuild/bin/_objs/greet/greet.pic.d '-frandom-seed=bazel-out/k8-fastbuild/bin/_objs/greet/greet.pic.o' -fPIC -iquote . -iquote bazel-out/k8-fastbuild/bin -Ibazel-out/k8-fastbuild/bin/_virtual_includes/greet -c src/greet.cc -o bazel-out/k8-fastbuild/bin/_objs/greet/greet.pic.o -fno-canonical-system-headers -Wno-builtin-macro-redefined '-D__DATE__="redacted"' '-D__TIMESTAMP__="redacted"' '-D__TIME__="redacted"'
/usr/bin/ar rcsD bazel-out/k8-fastbuild/bin/libgreet.a bazel-out/k8-fastbuild/bin/_objs/greet/greet.pic.o
/usr/bin/gcc -U_FORTIFY_SOURCE -fstack-protector -Wall -Wunused-but-set-parameter -Wno-free-nonheap-object -fno-omit-frame-pointer '-std=c++17' -MD -MF bazel-out/k8-fastbuild/bin/_objs/hello/main.pic.d '-frandom-seed=bazel-out/k8-fastbuild/bin/_objs/hello/main.pic.o' -fPIC -iquote . -iquote bazel-out/k8-fastbuild/bin -iquote external/rules_cc+ -iquote bazel-out/k8-fastbuild/bin/external/rules_cc+ -iquote external/bazel_tools -iquote bazel-out/k8-fastbuild/bin/external/bazel_tools -Ibazel-out/k8-fastbuild/bin/_virtual_includes/greet -c src/main.cc -o bazel-out/k8-fastbuild/bin/_objs/hello/main.pic.o -fno-canonical-system-headers -Wno-builtin-macro-redefined '-D__DATE__="redacted"' '-D__TIMESTAMP__="redacted"' '-D__TIME__="redacted"'
/usr/bin/gcc -o bazel-out/k8-fastbuild/bin/hello -Wl,-S -B/usr/bin -Wl,-no-as-needed -Wl,-z,relro,-z,now -pass-exit-codes bazel-out/k8-fastbuild/bin/_objs/hello/main.pic.o bazel-out/k8-fastbuild/bin/libgreet.a -Wl,--push-state,-as-needed -lstdc++ -Wl,--pop-state -Wl,--push-state,-as-needed -lm -Wl,--pop-state

As you can see, even on an identical project, these actions are not identical. On macOS, Bazel uses a wrapper script and runs the more modern libtool, compared to CMake's direct tool invocations using the older ar and ranlib tools. On Linux, Bazel uses ar. The exact command line flags differ on both platforms. We handle this by post-processing the corresponding action graphs to remove the build system idiosyncrasies, and unifying to a standardized action description.

This includes normalizing tool and file paths, expanding parameter files, deduplicating flags, and removing irrelevant flags.

For file paths, that means:

CMake Bazel Normalized
Source file src/greet.cc src/greet.cc src/greet.cc
Generated source file cmake-build/version.h bazel-out/k8-fastbuild/bin/version.h $(GENDIR)/version.h
Output file CMakeFiles/greet.dir/src/greet.cc.o bazel-out/k8-fastbuild/bin/_objs/greet/greet.pic.o $(GENDIR)/greet.o

In our first version, we only handled C/C++ compilation and tests, and we have been continuously increasing the space of tools and languages that the extractor/differ can handle. We also confirmed that Abseil and RE2 builds were equivalent, at least based on the data we extracted so far.

Closing the Loop: LLM-based One-shot Migrations

This brings us back to our previous claim that action graph comparison can be used as a deterministic oracle. In computer science and mathematics, an oracle is a conceptual entity that can answer questions about a problem. In this context, we want to know whether the LLM generation is making progress, whether it's correct, and when it is complete. The action graph comparison gives us exactly those answers - we can tell if we have covered all the actions, and, if not, which are missing, or which flags or environment variables are mismatched.

To automate checks against our oracle, we provide an agent an initial prompt, the original build system, and the original source code as inputs (although not necessarily all at once), and then ask it to generate the configuration files for the target build system. We are not the first to try an AI migration, but having a deterministic oracle makes the migration reliable.

Together in Zurich, we built this as a Claude Code skill: a set of instructions and tools to perform action graph extraction, compare the action graphs, and output a diff of what is still missing or incorrect. This turns the LLM into an improvement loop: make a small change, check progress, make another small change, check progress, and so on until done.

So does it work?

With the skill in hand, we successfully ran single-shot conversions of FMT, Spdlog, TinyXML2, and ZLib from CMake to Bazel. Additionally, we used the skill to verify equivalence between the existing CMake/Bazel builds in Abseil, RE2, and BoringSSL, uncovering minor inconsistencies along the way.

With positive initial signs, we attempted several more ambitious migrations:

These more complex cases required some interaction with the agents, but the end results were the same: successful migrations with working results.

By the end of my trip to Zurich, we did not have a polished migration tool, but we had something that might even be better: confidence in a generic process to migrate between any two build systems.

What about Quality?

After covering the core concepts of the any2bazel project, there is a question I keep getting: Does it generate high-quality Bazel BUILD files?

The short answer is: for C/C++ and Java, it uses the standard rules and generates idiomatic BUILD files, although it typically puts everything into the top-level BUILD file (see the Dolphin migration results). For other languages, it gets more complicated, and we have only seen a few examples so far. Overall, there is room for improvement.

The deterministic oracle is focused on correctness, not quality, readability, or some other measure of the generated configuration files. We eyeballed the generated files and we find the generated C/C++ and Java rules acceptable, although custom flags often end up in copts which may or may not be desirable.

For VS Code (migration results), we decided not to use any existing rules due to the structure of the build - we will post about our experience separately and you can disagree with that decision then. We had two examples of Qt migrations, testing both rules_qt and rules_qt6, and had more success with the former than the latter, but your mileage may vary.

We think that any2bazel can help with improving the quality over time by verifying that quality improvements do not affect the semantics of the code. However, sometimes quality requires semantic changes, which is an unsolved problem.

But this is just the beginning

When the LLM-based migration works, it feels like magic. But you can't just point it at any repo and expect ideal results. There are a few shortcomings we're aware of, and we're excited to explore and further refine this tool.

First and foremost, it does not support multiple platforms. That is to say, the action graph extraction and comparison happens on one platform, so the output only covers that one platform. You can run it multiple times on different platforms, but this isn't a first-class feature. So if your project builds on Linux, macOS, and Windows, you can only check one build graph at a time.

Second, the migration process does not handle feature flags. Some projects allow customizing the build to enable or disable features, link against different libraries, or build different outputs. Again, the action graph extraction and comparison happens for a specific set of feature flags. Worse, the set of configurations can grow exponentially with the number of feature flags, so this may be difficult to solve generically.

Third, it does not have any specific support for third-party libraries. It works, kind of, but sometimes heads down the wrong direction. Some ecosystems prefer building against system libraries, others from source, or against specific versions.

Can these be closed? We don't know yet, but I'm feeling optimistic. So far, we have run migrations for a few larger projects based on these concepts, and we are improving the underlying tooling whenever we find problems that we cannot quite solve yet.

You can try any2bazel yourself by directing your agents to use the new /any2bazel skill hosted at https://github.com/EngFlow/any2bazel. We welcome feedback, and look forward to seeing how everyone puts it to use.

Stay tuned for more blog posts on these topics.