Draw the boundary once
TL;DR — Visibility rules, the build system and the package manager all use one thing: the public interface of a package. When a language designs the three tools separately, they disagree about where that interface is. The disagreement then stays for the life of the language. This post shows examples in C++, Java, Rust, Swift and Go. It writes the contract between a compiler and a build system as one equation. Then it tests 15 pairs of compiler and build system against six tests that come from the equation. No pair passes all six tests. Vx does not pass them yet either.
A programming language has three tools that control what can cross a boundary in a
program. Visibility rules (pub, private, crate-visible) control which code can call a
function. The build system controls what it compiles again after a file changes. The package manager
controls which versions of the dependencies it uses together.
All three tools use the same thing: the public interface of a package. When a language designs them separately, they disagree about where that interface is. The disagreement then stays as long as the language exists. I am the designer of Vx, so I want to draw that boundary once.
Three tools, one interface
Each tool asks one question. The answer to each question depends on the public interface of a package.
| Tool | The question it asks | What it must know |
|---|---|---|
| Visibility rules (in the compiler) | Can this code call that function? | Which items are public, and for which code |
| Build system | A file changed. Which files must I compile again? | Whether the change touched an interface that other code uses |
| Package manager | Can version 1.4 replace version 1.3? | Whether the public interface changed in a way that breaks callers |
If the language defines the interface exactly, the build system and the package manager can calculate their answers from it. If the language does not define it, each tool guesses. The guesses do not agree, and the programmer must fix each disagreement manually.
Where the boundaries drift
Each popular language has at least one place where these tools disagree. You can still see each example below in that language today.
C++: private does not mean hidden
A private field is in the header file, together with the public fields. When you change a private field, the build system compiles each file that includes the header again. Each binary that uses the old layout may also break. The language calls the field private, but the build system and the binary interface treat it as public. Programmers hide the field manually with the pimpl idiom. They move the private fields into a separate struct, and the header keeps only a pointer to it.
C++ also has three visibility systems, and each one ignores the other two. Access specifiers
(public, private) control names. Linkage (static, unnamed
namespaces) controls object files. Symbol visibility (-fvisibility=hidden,
__declspec(dllexport)) controls shared libraries. The language has no packages.
C++20 modules tried to add a boundary. Each build system then had to add a new step: before it compiles any file, it must scan each file for its imports. CMake added stable support in version 3.28, in December 2023, three years after the standard.
Java: the jar had no boundary
Java had package-private visibility from the start. But the jar, the unit that people released,
had no boundary. Each public class in each jar on the classpath was visible to all other jars.
Libraries started to use JDK internals such as sun.misc.Unsafe.
Java 9 added modules, with an exports
list, in 2017. That was 21 years after Java 1.0. At that time, so many libraries used
sun.misc.Unsafe that Java 9 kept it
available. A Maven dependency and a Java module still have two different names.
Rust: build speed decides the public interface
Rust is close to one boundary. A crate is the unit of compilation, pub(crate) is a
visibility level, and a Cargo package usually holds one library crate. The problem starts when a
crate becomes large. A large crate compiles slowly, so teams divide it into several crates. Rust has
no visibility level for “the crates in my workspace”. So each pub(crate)
item that another new crate uses must become pub. When the authors publish the library,
crates.io requires
them to publish each new crate too. A decision about build speed makes the public interface
larger, and the package manager must give it a version.
Macros have the same problem. A derive macro generates code inside the crate of the user, so each
helper that this code calls must be pub. serde keeps these helpers in a module that its
documentation hides: #[doc(hidden)] pub mod __private. Since version 1.0.225, the name
of this module also contains the patch number of serde, as in __private228.
Cargo also does not check semantic versions. Someone requested that check in 2014, and the request is still open. Today a separate tool, cargo-semver-checks, checks semantic versions.
Swift: a new visibility level to match the package
In Swift, internal means “this module”. A Swift package can hold many
modules. Code that two modules of one package shared had to be public, so each user of
the package could see it. Swift 5.9 added a package visibility level in 2023
(SE-0386),
nine years after Swift 1.0. The language changed its visibility rules to match its package
manager.
Go: one boundary, with versions added later
Go is the closest to one boundary. A package is a directory, and it is also the unit of
compilation and the unit of visibility. Go exports each name that starts with a capital letter. The
go command enforces internal/ directories, so the build system itself checks a
visibility rule. An import path also tells where to get the code.
Versions came last. Go had no versions for six years. Modules came as an experiment in
Go 1.11, in 2018. Then Go put the major version into the
module path, so each import path has it too. From version 2, a module path ends in
/v2, /v3 and so on. For
the compiler, a new major version is a new package. That keeps the three tools in agreement. The
cost is that each import must change at each major release.
The pattern
Each example has the same shape. One tool draws a boundary that the other two tools cannot see. The programmer then pays in one of three ways:
- Visibility becomes wider to help the build. Rust teams make items
pubwhen they divide a slow crate. Swift code becamepublicwhen it had to cross a module boundary. - The build does more work because visibility does not say enough. When a C++ header changes, the build system compiles files that do not use the changed field.
- The package manager trusts a promise that nobody checks. A version number says “compatible”, and a person decided that from a diff.
These costs stay because code starts to depend on each boundary. When a thousand libraries call
sun.misc.Unsafe, the language cannot hide it. Java needed 21 years to add modules, and
Swift needed nine years to add package. C++ continues to move to modules. A
language draws its boundaries once, before it has users. After that, it can only add
exceptions.
The rule that serves all three
The build system and the package manager can calculate an answer from the interface only if the code states the interface completely. Vx already has the rule for this: a signature is the full contract. The programmer writes each parameter type and each return type in the signature. No part of the interface of a function comes from its body.
Vx made this rule for its parallel compiler. The compiler first collects all signatures, types and trait implementations. Then it checks all function bodies in parallel against that fixed table. The same rule gives the other two tools what they need:
- Build system. The public interface is the set of public signatures, type layouts and trait implementations. No part of it comes from a function body. When a change keeps that set the same, the build system can skip the checks of the code that depends on it. Only generic and inline bodies need new machine code.
- Package manager. A tool can compare two versions by their signatures and type layouts. It does not have to read a function body.
Rust shows what happens without this rule. An async fn hides its real return type,
and the body decides whether that type is Send. If you change one line in the body, the
returned future can stop being Send. Then each caller that sends the future to another
thread fails to compile, but the signature did not change. The Rust design for
impl Trait calls this “a silent breaking change”
(RFC
1522). A tool that compares signatures cannot see it. C++ has the same problem with
auto return types, and with templates, whose bodies are in headers.
How Vx draws the boundary once
Today, Vx has pub items and private items. The remaining part of this section is the
design, and most of it does not exist yet.
One manifest, one set of levels
The manifests define two units. A package is a set of modules that the compiler checks as one unit and the registry publishes as one unit. A workspace is a set of packages that the registry publishes together, at one version. The manifest of each member names its workspace, and the manifest of the workspace sets the version. The visibility levels use the same units:
fn helper() // private: this module only (the default)
pkg fn plan() // any module in this package
ws fn lower() // any package in this workspace
pub fn compile() // exported: each package that depends on this one
A workspace is a fact about visibility and release, and no build system can change it. You can build one package alone, the full workspace, or a large repository with many workspaces. In each case, you get the same errors and the same program. Membership also does not depend on where the files are on disk. Cargo finds the workspace of a member when it searches the parent directories. Vx reads the name of the workspace from the manifest of the member.
Kotlin fails this test. Its internal level means “the same module”, and a
module is the set of files
that the build system compiles together.
The word “workspace” also has a stronger meaning in Vx than in Rust. A Cargo
workspace is a build unit: its members share a lock file and an output directory. A shared version
is optional, and each member can have its own version. A Vx workspace requires one shared version,
because ws depends on it.
For example, package A 1.2 calls a ws function in package B, and B 1.3 changes that
function. If the versions were separate, a user could get A 1.2 together with B 1.3. Then the build
would break, and the version numbers would give no warning. With one version, that pair cannot
exist. The compiler also checks this: a ws access requires the same workspace name and
version in both packages.
So a team can divide a slow package inside its workspace, and use ws where Rust
requires pub.
pub means exported, and the compiler checks it
Each pub item must be reachable from outside the package, or the compiler reports an
error. In Rust, a pub item inside a private module is not reachable, and only an
optional lint (unreachable_pub) reports it. A signature can name only types that are
at least as visible as the signature.
Code that a macro generates gets the visibility of the module that defines the macro. So a macro
never forces a helper to be pub. The serde derive macro forces this. The public interface
is then exactly the set of pub items, and a tool can print it.
A tool calculates the version bump
A tool compares the public interfaces of two releases. It reports the smallest safe version bump: the part of the version number that must increase. The bump depends on two things: the type of change, and the visibility of the changed item.
- A change to a private item or a
pkgitem needs a patch release at most. - A
wsitem crosses packages, but its workspace releases at one version. So awsitem has no semantic-version promise. - Only a breaking change to a
pubitem forces a major release.
Elm already does this for source code. elm publish accepts only a version number
that agrees with the difference between the two interfaces
(Elm compiler
source). The first version of the Vx tool is in review as
#2258:
vx-api diff old.vxlib new.vxlib. Until pkg and ws exist, it
treats each item in the interface as public.
Binary compatibility needs a second check, because the layout of a type is also important. A new private field in a public struct does not break source code, but it changes the size of the struct. That is the C++ header problem again. So the tool will check source compatibility and binary compatibility separately.
Where the build system fits
The compiler is a function, and the build system calls it. The language defines the inputs and the outputs. For a package p, one equation states the full contract:
(Op, Ip) = vxc(Sp, Mp, c, { view(Id, p) | d ∈ deps(p) })
The inputs are on the right. Sp is the source files of p, Mp is its manifest, and c is the build settings, for example the target. deps(p) is the set of packages that Mp lists. The standard library is one of them.
The outputs are on the left. Op is the machine code. Ip is the interface: each signature, type layout and trait implementation in p, with its visibility level. Ip also holds the bodies of generic and inline functions, because the packages that use them generate machine code from these bodies.
A dependency d shows only a part of its interface to p:
view(Id, p) = { x ∈ Id | vis(x) = pub }
∪ { x ∈ Id | vis(x) = ws ∧ W(d) = W(p) }
W(p) is the workspace of p: its name and its version. So p sees
each pub item of d. If both packages are in the same workspace at the same
version, p also sees the ws items of d.
The build system calculates the first equation for each package. It can use any order that puts each package after its dependencies. vxc is a function in the mathematical sense, so the order, the parallel work and the cache cannot change the result. Four rules keep vxc a function:
- The compiler reads only declared inputs.
vxcreads the source files of a package, its manifest, and the interface files of its dependencies. It reads nothing more: it does not search the current directory, it runs no build scripts, and no comptime code reads files. - Packages see each other only through interface files. When
vxccompiles a package, it reads only the interface file of each dependency. This file records the package, its workspace name and version, and each item with its visibility level. A signature is the full contract, sovxccan write this file before it checks a function body. The packages that depend on it can then start early. - The package is the unit of the build system. The build system schedules
packages, not files. Inside a package,
vxcchecks function bodies in parallel. Inside one crate, much of the rustc front end uses one thread. That is an important reason why Rust teams divide crates. A Vx package can use all processor cores, so its size alone is not a reason to divide it. - Only the manifests give the package graph. An import such as
import textkit::runsmust name a package that the manifest lists, orvxcreports an error. The build system never scans source files to find dependencies. C++20 modules require that scan.
Parts of this exist today. vxc gets each dependency from a
--package textkit=../textkit flag, and vxc --emit-interface writes an
interface file (.vxlib).
The rebuild rule comes from the equation. The build system compiles p again when one of its inputs changes, and only then:
rebuild(p) ⇔ ΔSp ∨ ΔMp ∨ Δc ∨ ∃ d ∈ deps(p) : Δview(Id, p)
A private item or a pkg item is in no view, so after a change to it, the build system
compiles nothing outside its package. A ws item is only in the views of workspace members.
The package manager uses the same views. It compares the view of a package outside the
workspace, which holds only the pub items, in the old version and in the new
version:
bump(p) = max { b(δ) | δ ∈ diff(viewout(Ipold), viewout(Ipnew)) }
b(δ) is the bump that one change δ needs: patch, minor or major. If no pub item
changes, the bump is a patch. So the rebuild scope and the version bump follow the same order:
| Changed item | What the build system compiles again | Version bump |
|---|---|---|
private or pkg | Nothing outside the package | Patch at most |
ws | The members of the workspace | No semantic-version rule: one shared version |
pub | Each package that depends on it | Major only if the change breaks callers |
So vx build is one implementation of a written contract. Bazel or Buck2 can call
vxc directly and get the same result.
C libraries are the open problem. Rust finds them with a build script, build.rs,
which can run any code during the build. Vx can keep rule 1 with a manifest entry that names the
library and its header files.
Visibility sets the exported symbols
The four visibility levels also decide which symbols a shared library exports.
| Level | Symbol in a shared library |
|---|---|
| private | Internal, never exported |
pkg | Hidden |
ws | Exported, with a private version tag, like glibc's GLIBC_PRIVATE |
pub | Exported, with a public version tag |
A build setting can make a symbol less visible than its level, but never more visible. An executable hides all symbols except its entry points. The linker gets its visibility rules from the language. C++ has a separate system for symbol visibility, but Vx needs none.
How today's tools score
The equations give six tests. I tested 15 well-known pairs of compiler and build system against them. No pair passed all 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.
| Pair | Function | Interface | Cutoff | Graph | Visibility | Bump | Passes |
|---|---|---|---|---|---|---|---|
| Elm (elm make, elm publish) | ◐ | ✓ | ✓ | ◐ | ✓ | ✓ | 4 |
| Java + Bazel | ◐ | ✓ | ✓ | ✓ | ✓ | ✗ | 4 |
| Java + Gradle | ✗ | ◐ | ✓ | ✓ | ✓ | ◐ | 3 |
| Rust + Cargo | ✗ | ✗ | ✗ | ✓ | ✓ | ◐ | 2 |
| Rust + Buck2 | ◐ | ✗ | ✗ | ✓ | ✓ | ✗ | 2 |
| Rust + Bazel | ◐ | ✗ | ✗ | ✓ | ✓ | ✗ | 2 |
| C++ headers + Bazel | ◐ | ✗ | ◐ | ✓ | ◐ | ✗ | 1 |
| Kotlin + Gradle | ✗ | ✗ | ◐ | ✓ | ✗ | ◐ | 1 |
| Zig + zig build | ✗ | ✗ | ✗ | ◐ | ✓ | ✗ | 1 |
| Nix | ◐ | ✗ | ✗ | ✓ | – | – | 1 |
| OCaml + dune | ✗ | ◐ | ◐ | ◐ | ◐ | ✗ | 0 |
| Haskell GHC + Cabal | ✗ | ◐ | ◐ | ◐ | ◐ | ✗ | 0 |
| Swift + SwiftPM | ✗ | ◐ | ◐ | ◐ | ✗ | ◐ | 0 |
| C++20 modules + CMake | ✗ | ◐ | ◐ | ✗ | ◐ | ✗ | 0 |
| Go + go command | ◐ | ◐ | ✗ | ✗ | ✗ | ◐ | 0 |
| Vx today | ✗ | ◐ | ✗ | ◐ | ◐ | ✗ | 0 |
✓ passes, ◐ passes partially, ✗ fails, – does not apply. The scores are from 25 October 2026. Some partial scores are judgments. The scorecard page gives the reason and a source for each cell.
No pair passes the first test. Each pair has a hidden input in some place. The Bazel sandbox still lets an action read the file system of the host, and Bazel allows network access by default. When Elm builds a package, it selects the dependency versions from the live registry.
Each of the other tests has a winner, but no pair has all the wins. The two leaders get their scores in opposite ways. Elm has one author for its compiler, its build system and its package manager, and both of its checks are short. Bazel gets a cutoff for Java because it makes an interface from the bytecode. Java never defined an interface of this kind.
Some cutoffs fail between the compiler and the build system. Clang can write a module interface file that is identical to the previous file, byte for byte. But compilers write the file again each time they compile, and Ninja uses the time of the file to decide. So each change to a C++ interface unit causes all importers to compile again. The Rust metadata file records private items and a hash of each source file. A change to the body of a private function changes this file, so the cutoff in Bazel and Buck2 cannot occur.
Vx scores 0 today too. The compiler reads 19 environment variables, and 15 of them can change the
program it builds or the tool that builds it. It also finds tools through PATH, and it
searches the current directory for the standard library. The design must pass the two tests that the
leaders fail: Function and Bump. Two changes in review start this work.
With #2255, vxc no longer searches the
current directory for the standard library or for imports.
#2258 adds vx-api diff.
#2250 tracks the other hidden inputs.
The three tools can stay separate programs with separate jobs. They agree because they read one definition of the interface, and the programmer writes that definition once.
Sources
- C++: cppreference: PImpl · CMake 3.28 release notes · Kitware: “import CMake; the Experiment is Over!” · P1689R5, the format for module dependencies
- Java: JDK 9 project page · JEP 261: Module System · JEP 260: Encapsulate Most Internal APIs · JEP 471 · Sun's Java 1.0 press release (mailing-list copy)
- Rust: Cargo: specifying dependencies · Cargo: workspaces · serde lib.rs at v1.0.219 · serde build.rs · Cargo issue 374 · cargo-semver-checks · unreachable_pub lint · RFC 1522
- Swift: SE-0386 · Swift 5.9 released · Swift CHANGELOG
- Go: Go release history · Go 1.5 release notes · Go 1.11 release notes · Go modules reference
- Elm: Publish.hs · Main.hs
- Kotlin: Visibility modifiers
- Vx: Language rules for a parallel compiler
The sources for each score are on the scorecard page.
Vx is Apache 2.0 with the LLVM exception — install it and try the examples.