Visibility, builds and versions: the scorecard
This page is a detailed companion to Design visibility, builds and versions together. It scores 15 pairs of compiler and build system, and Vx, against six strict tests. Each table gives the score, the reason and a source for one pair of compiler and build system. The scores are from 25 October 2026.
The six tests:
- Function: the compiler and the build system read only declared inputs.
- Interface: when the compiler compiles a package, it reads only the interface files of the dependencies. No part of an interface file comes from a function body.
- Cutoff: a dependency changes only a private item, or the body of a function that is not generic or inline. Then the build system compiles no other package again.
- Graph: the build system gets the package graph without a scan of the source files.
- Visibility: what is visible does not depend on which files the build system compiles together.
- Bump: the package manager calculates the version bump from the difference between the two interfaces.
✓ passes, ◐ passes partially, ✗ fails, – does not apply. No pair of the 16 passes all six tests.
Elm (elm make, elm publish): 4 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ◐ | Elm has no compile-time code, and packages cannot use ports or kernel (JavaScript) code. When Elm builds a package, it selects the dependency versions from the registry, and it uses no lock file. | Parse/Module.hs, Elm/Details.hs, ports guide |
| Interface | ✓ | Package documentation refuses exposing (..), and it refuses an exposed value that has no type annotation. A module inside an application can still have an interface that Elm infers. | Elm/Docs.hs, Error/Docs.hs |
| Cutoff | ✓ | The build compares the new .elmi file with the old one. When they are the same, the build keeps the dependents in its cache. The .elmi file holds only exposed values. | Build.hs, Interface.hs |
| Graph | ◐ | elm.json gives the package graph, but the build parses each source file to find its imports. | Build.hs |
| Visibility | ✓ | The exposing list of the module and exposed-modules set visibility. Two files for one module name cause an error. | Interface.hs |
| Bump | ✓ | elm publish compares the old interface with the new interface, and it refuses a version number that does not agree. | Publish.hs |
Java + Bazel: 4 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ◐ | The sandbox shows an action only its declared inputs. But processes can still read the file system of the host, and Bazel allows network access by default. | Sandboxing, command-line reference |
| Interface | ✓ | Dependents compile against header jars. Turbine and ijar remove the method code and the private members. | Turbine RemovePrivateMembers, ijar README |
| Cutoff | ✓ | Skyframe skips the dependents when a value that it built again did not change. So Bazel compiles A again only when the header jar of B changed. | Skyframe, compiler-dev mail |
| Graph | ✓ | The deps lists in BUILD files give the graph. | Java rules |
| Visibility | ✓ | The Java source sets access. The visibility attribute of Bazel adds a declared gate at the target level. | Visibility |
| Bump | ✗ | Bazel modules have versions, but the documentation says that the meaning of a version increase is “not enforced”. | Modules |
Java + Gradle: 3 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ✗ | Build scripts are code that can read environment variables and files, and Gradle does not put tasks in a sandbox. | Configuration cache, incremental build |
| Interface | ◐ | Java signatures state all types, but javac reads the full class files of the dependency. | Java library plugin |
| Cutoff | ✓ | Compile avoidance ignores method bodies and private members. The hash still includes package-private members and constants. | Compile avoidance |
| Graph | ✓ | Gradle gets the graph from declared dependencies, although the build file is code. | Build lifecycle |
| Visibility | ✓ | The Java source sets access. The build file declares api and implementation. | JLS 6.6 |
| Bump | ◐ | Only japicmp, a tool from another project, suggests the bump. | japicmp |
Rust + Cargo: 2 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ✗ | build.rs and procedural macros can read any file or environment variable. The interface that records these reads is available only in nightly Rust. | Build scripts, proc_macro::tracked |
| Interface | ✗ | Auto traits such as Send pass through -> impl Trait and async fn, so the body decides a part of the interface. | RFC 1522 |
| Cutoff | ✗ | The fingerprint of a crate includes the input hashes of its dependencies. So each change in a dependency causes its dependents to compile again. Early cutoff is an open issue. | fingerprint/mod.rs, cargo#17439 |
| Graph | ✓ | Cargo.toml and Cargo.lock give the graph. Cargo still finds targets such as src/bin/* from the directory layout. | Specifying dependencies, targets |
| Visibility | ✓ | Visibility follows the module tree that the mod declarations make, from the crate root. | Visibility and privacy |
| Bump | ◐ | Only cargo-semver-checks, a separate tool, checks the bump. A 2026 project goal is to move it into cargo publish. | Project goal |
Rust + Buck2: 2 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ◐ | Buck2 runs build.rs as an action with an explicit environment, but it does not put local actions in a sandbox. | cargo_buildscript.bzl, Buck2 |
| Interface | ✗ | Buck2 uses the same rustc metadata as Cargo. | build_params.bzl |
| Cutoff | ✗ | The DICE engine has early cutoff, but the .rmeta file changes at each source change, so the cutoff cannot occur. A test with rustc 1.95.0 showed this for the body of a private function. | DICE incrementality |
| Graph | ✓ | BUCK files declare the dependencies. Reindeer generates them from Cargo.toml. | Buck2 |
| Visibility | ✓ | The same Rust module tree as Cargo. | Visibility and privacy |
| Bump | ✗ | Buck2 and Reindeer have no semantic-version step. | Reindeer manual |
Rust + Bazel (rules_rust): 2 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ◐ | Bazel runs actions in a sandbox, but it allows network access by default, and build scripts get the default shell environment. | Sandboxing, rules_rust settings |
| Interface | ✗ | rules_rust uses the same rustc metadata as Cargo. | RFC 1522 |
| Cutoff | ✗ | Pipelined compilation is off by default, so dependents read the full .rlib file. When it is on, the .rmeta file still changes at each change. | Skyframe, rules_rust settings |
| Graph | ✓ | BUILD files declare each direct dependency. | Dependencies |
| Visibility | ✓ | The same Rust module tree, and the srcs list names each source file. | Visibility and privacy |
| Bump | ✗ | rules_rust has no semantic-version feature. | Modules |
C++ headers + Bazel: 1 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ◐ | The same sandbox limits as Java + Bazel: actions can read the file system of the host, and Bazel allows network access by default. | Sandboxing |
| Interface | ✗ | Dependents compile the headers, which hold private members and inline and template bodies. The body decides an auto return type. | C/C++ rules, cppreference: function |
| Cutoff | ◐ | A change to a .cc file does not cause dependents to compile again. Each change to a header causes this, also a change to a private member. | Skyframe, command-line reference |
| Graph | ✓ | BUILD files declare headers in hdrs and srcs. Include scanning is off by default. | C/C++ rules |
| Visibility | ◐ | To keep srcs headers private, Bazel needs the layering_check of clang, and that check covers only direct includes. | C/C++ rules |
| Bump | ✗ | The same as Java + Bazel. | Modules |
C++20 modules + CMake and Ninja: 0 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ✗ | Ninja has no sandbox, and it finds headers from the depfiles of the compiler, by the time of each file. CMake gets the compiler from the CXX environment variable. | Ninja manual, CMake: CXX |
| Interface | ◐ | Importers read a compiled module interface (BMI), but Clang may open the input files of a BMI. Reduced BMIs, without non-inline bodies, became the default only in Clang 22. | Clang: C++ modules |
| Cutoff | ◐ | The CMake documentation says that compilers write the BMI again at each compilation. So each change to an interface unit causes all importers to compile again. An implementation unit has no BMI. | cmake-cxxmodules |
| Graph | ✗ | CMake scans the source files during the build (P1689 format) and gives the result to Ninja. | cmake-cxxmodules |
| Visibility | ◐ | The source declares export. But Clang alone decides whether internal partitions are reachable, and CMake does not check private module sets. | Clang: C++ modules, cmake-cxxmodules |
| Bump | ✗ | The author selects the compatibility mode manually. | CMakePackageConfigHelpers |
Kotlin + Gradle: 1 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ✗ | The same as Java + Gradle: build scripts are code, and Gradle does not put tasks in a sandbox. | Incremental build |
| Interface | ✗ | A single-expression function gets its return type from the body, unless explicit API mode is on. | Functions, What's new in 1.4 |
| Cutoff | ◐ | Gradle starts the Kotlin compiler after each change in a dependency. Then ABI snapshots limit what compiles again. Inline bodies and const val values are part of the ABI. | JetBrains blog, 2022 |
| Graph | ✓ | The same as Java + Gradle. | Build lifecycle |
| Visibility | ✗ | internal means “a set of Kotlin files compiled together”, for example a Gradle source set. | Visibility modifiers |
| Bump | ◐ | The experimental ABI validation fails when the ABI changes, but it ignores the version number. | Binary compatibility validation |
Go + the go command: 0 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ◐ | Go has no build scripts or compile-time code. But cgo reads CC, CGO_CFLAGS and pkg-config, and the go command finds go.work from the current directory. | cgo, Modules reference |
| Interface | ◐ | Dependents read export data. It holds the bodies of inlinable functions and generic functions, and of the unexported helpers that they use. | noder/linker.go, generics design |
| Cutoff | ✗ | Go 1.27 keys each dependent on a hash of the full archive, which includes object code. A key on export data came to the development branch in August 2026, and no release has it yet. Export data also records line positions. | exec.go, Go 1.27, commit 1dacc62658, go#15752 |
| Graph | ✗ | The package graph comes from the import lines of each source file. go.mod gives only the module requirements. | imports/read.go |
| Visibility | ✗ | The specification lets the tool decide which files are in a package, and internal/ is a rule about directory paths. | Go spec, cmd/go |
| Bump | ◐ | gorelease suggests a version, but it is experimental and nothing enforces it. | gorelease |
Zig + zig build: 1 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ✗ | build.zig can be any code, with access to the environment and the file system. The compiler itself is pure: comptime cannot call external functions. | std/Build.zig, 0.17.0 release notes |
| Interface | ✗ | A project is one compilation of all modules from source. An inferred error set gets a part of the return type from the body. | Language reference |
| Cutoff | ✗ | By default, each change runs the full compilation again. Incremental compilation for each declaration needs -fincremental. | Compilation.zig, 0.17.0 release notes |
| Graph | ◐ | build.zig.zon lists packages as data, but the module graph exists only after build.zig runs. | build.zig.zon |
| Visibility | ✓ | A declaration without pub stays in its file, and files join only through @import. | import outside module path |
| Bump | ✗ | version is a string that nothing checks. | build.zig.zon |
Nix: 1 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ◐ | On Linux, a build sees only its store inputs and has no network access. Fixed-output derivations keep network access, and the sandbox is off by default on other systems. | nix.conf |
| Interface | ✗ | Inputs are the full outputs of other derivations. There is no interface. | Derivations |
| Cutoff | ✗ | An output path includes a hash of the derivation of each dependency, so each change causes each dependent to build again. Content-addressed derivations are experimental, and they need outputs that are identical bit for bit. | Input addressing, experimental features |
| Graph | ✓ | Each derivation declares all of its inputs. Import-from-derivation is the exception. | Derivations, import from derivation |
| Visibility | – | Nix has no item visibility. The compiler that Nix runs decides. | Derivations |
| Bump | – | A hash pins each dependency, and there are no version ranges. | Derivations |
OCaml + dune: 0 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ✗ | The file inputs of a preprocessor count only if the author lists them. The dune sandbox is optional, and it keeps the environment variables. | Sandboxing, preprocessing |
| Interface | ◐ | Dependents read the .cmi file, which can come from an .mli file that a person wrote. Without an .mli file, the compiler infers the types. Native code also reads the .cmx file, unless the build uses -opaque. | Batch compiler, native compiler |
| Cutoff | ◐ | The dev profile uses -opaque and content hashes. The release profile, which opam uses, does not use -opaque. A .cmi file also records the digests of all interfaces that it imported. | Profiles, persistent_env.ml |
| Graph | ◐ | The libraries field gives the library graph, but ocamldep scans the source files to find module dependencies. | ocamldep, Discuss report |
| Visibility | ◐ | The .mli file sets visibility. But by default, a library holds each module file in its directory. | library stanza |
| Bump | ✗ | opam has no semantic-version rule and no interface check. | opam manual |
Haskell GHC + Cabal: 0 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ✗ | Template Haskell runIO can do any IO at compile time, and it is optional to declare the files that it reads. A custom Setup.hs can run any code. | Template Haskell, package description |
| Interface | ◐ | Top-level type signatures are optional, and above -O0 the .hi file holds function bodies. | Warnings, optimisation |
| Cutoff | ◐ | GHC keeps a fingerprint for each declaration. But Cabal builds with -O1 by default, so a change to a body gets to the dependents. | Separate compilation, cabal.project |
| Graph | ◐ | build-depends gives the package graph. ghc --make follows the imports to find modules. | Separate compilation |
| Visibility | ◐ | Export lists set visibility, but GHC can use a module from disk that the package does not list. | Warnings |
| Bump | ✗ | The PVP gives the rules, but Hackage does not check them. | PVP |
Swift + SwiftPM: 0 of 6
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ✗ | Package.swift can read each environment variable and the git status through an official interface. The macOS sandbox allows all file reads. | Context.swift, Sandbox.swift |
| Interface | ◐ | Dependents read a .swiftmodule file. Library evolution is off by default, so clients see each layout. The body decides the concrete type behind some P. | SE-0260, SE-0244 |
| Cutoff | ◐ | Debug builds use fingerprints across modules. Release builds compile each module as one unit, and a change to a private stored property changes the interface. | swift-driver, SwiftModuleBuildDescription |
| Graph | ◐ | Targets come from the manifest. But an import that the manifest does not declare compiles if an earlier build left the module. | Options.swift, forum thread |
| Visibility | ✗ | internal means the module, and the module is the set of files that the build target holds. | Access control, Target.swift |
| Bump | ◐ | swift package diagnose-api-breaking-changes exists. It is optional, and it reports breaks but does not calculate the bump. | Documentation |
Vx today: 0 of 6
This table uses the compiler source on main at commit e66345d3. The post describes the design. This table describes what exists.
| Test | Score | Reason | Source |
|---|---|---|---|
| Function | ✗ | The compiler reads 19 environment variables, and 15 of them can change the program it builds or the tool that builds it. It finds z3 and the LLVM tools through PATH. Without VX_STD_PATH, it looks for the standard library next to the compiler, then in the current directory. | module_loader.rs, jit.rs, solver.rs, VxLowering.cpp |
| Interface | ◐ | A signature is the full contract, and vxc --emit-interface writes a .vxlib interface file. The file also holds function bodies, and the parallel compiler (-j) does not read it yet. | driver.rs |
| Cutoff | ✗ | There is no build system yet, so nothing skips a compilation. | |
| Graph | ◐ | Each package comes from a --package name=dir flag. There is no manifest yet. | driver.rs |
| Visibility | ◐ | pub items and private items exist, but pkg and ws do not exist yet. Imports use search paths that include the current directory. | module_loader.rs |
| Bump | ✗ | There is no package manager yet. |