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.

ToolThe question it asksWhat it must know
Visibility rules (in the compiler)Can this code call that function?Which items are public, and for which code
Build systemA file changed. Which files must I compile again?Whether the change touched an interface that other code uses
Package managerCan 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:

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:

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.

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:

  1. The compiler reads only declared inputs. vxc reads 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.
  2. Packages see each other only through interface files. When vxc compiles 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, so vxc can write this file before it checks a function body. The packages that depend on it can then start early.
  3. The package is the unit of the build system. The build system schedules packages, not files. Inside a package, vxc checks 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.
  4. Only the manifests give the package graph. An import such as import textkit::runs must name a package that the manifest lists, or vxc reports 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 itemWhat the build system compiles againVersion bump
private or pkgNothing outside the packagePatch at most
wsThe members of the workspaceNo semantic-version rule: one shared version
pubEach package that depends on itMajor 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.

LevelSymbol in a shared library
privateInternal, never exported
pkgHidden
wsExported, with a private version tag, like glibc's GLIBC_PRIVATE
pubExported, 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.

  1. Function: the compiler and the build system read only declared inputs.
  2. 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.
  3. 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.
  4. Graph: the build system gets the package graph without a scan of the source files.
  5. Visibility: what is visible does not depend on which files the build system compiles together.
  6. Bump: the package manager calculates the version bump from the difference between the two interfaces.
PairFunctionInterfaceCutoffGraphVisibilityBumpPasses
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

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.