ral-core is pub(crate) by default, so dead_code can see
An item in ral-core is pub because a consumer outside the crate names
it, and pub(crate) otherwise. Visibility is not documentation of intent; it
is what makes the compiler able to tell us an item has no callers left.
Every pub item is invisible to dead_code — the crate might be a library
someone links — so a crate that publishes its internals cannot be told when
one of them dies. Narrowing 657 of core/src’s 1676 pub declarations turns
the compiler back into that witness.
Context
host-embedding-api states the intended
surface exactly, and closes with “No builder, no config bag. The API is
exactly the missing type and the one kernel function.” Counting generously —
BakedPrelude and its four methods, boot_shell, bake_prelude_to_out_dir,
the baked_prelude! macro, HostSurface, the run seam’s twelve names
(run-turn-is-host-api) and the
compile seam’s five — the intent names about 25 items.
core/src declared 1675, of which 1644 were reachable from outside. The
ADR was never wrong; nothing could enforce it. And the price was not
abstraction leakage in the abstract: sandbox::make_command lost its last
caller on 2026-07-02 and was deleted two months later, because pub had
hidden it from dead_code the whole time. Launch::clear_cloexec_on_spawn
was the same story seven days old.
The measurement is a script, not a survey: rewrite every pub in core/src
to pub(crate), compile all five consumers (ral, exarch, synod,
vm-manager, guest-net) with --all-targets --keep-going, widen back
exactly what the compiler names, repeat to a fixed point. dev/ carries the
full measurement.
Decision
pub declarations in core/src fall from 1676 to 1019, pub(crate) rising
from 549 to 1208. What stays pub is
one of five things, and each is a real constraint rather than a concession:
- A consumer names it. The residue after the fixed point.
- A public enum’s variant payload, or a public struct’s field type. A
variant field’s visibility is its enum’s, so
ir::Val::List(Vec<ValListElem>)keepsValListElempublic whether anyone names it or not.rustc’sprivate_interfaceslint — a deny here, since the workspace denies warnings — is what enforces this, and it is the binding constraint on how far the narrowing can go. Chasing it to a fixed point widened 32 items back. - A
#[macro_export]body’s path.dbg_trace!andbaked_prelude!expand in the host crate, so every path inside them must resolve there. - An example or a build script.
ral/build.rsandexarch/build.rscallboot::bake_prelude_to_out_dir;vm-manager/examples/boot-smoke.rsnamesterminal::TerminalStateandprotocol::Machine. CI compiles both, so a narrowing checked withcargo checkon the libraries alone passes and then breaks the build. - The
scheme!family. Its arms expand topub fn, and per-arm visibility would mean threading a$visthrough every call site. Exempted deliberately: a base frame’s row names each member, and a row is a caller, so no member of this family can be stranded silently the way a loose function can. The comment at the macro says so.
Twelve dead items and seven dead re-export names are deleted, each one
named by dead_code or unused_imports the moment its declaration narrowed:
Launch::clear_cloexec_on_spawn and its Windows twins
Launch::{creation_flags, admit_handle} — all three stranded by the same
commit, c86106ba “Pipeline stages are threads: retire re-exec” —
Shell::{with_env, with_handlers, extend_env, native_names},
EnvVars::native_names, Engine::events_shared,
Map::insert, AuditFragment::is_empty, ValMapEntry::value, and the
re-exports builtins::value_to_json, process::watch,
types::{DetachPolicy, Reservation, EnvVarsIter},
typecheck::{CompDiff, CachedFreeVars}. Every one of these types still
exists; it was the re-export that had no reader. GrantStack::is_empty, on
the dead list too, stays: clippy’s len_without_is_empty requires it beside a
public len, which is a caller of a kind.
One island is found and left standing. Narrowing made the Windows
cross-check name a cluster the same c86106ba stranded —
signal::windows::{try_reap_leader, break_pipeline_group, disown_pipeline_group} and the ReapStatus they return, unreferenced by core
or by any consumer on any platform. Retiring a process-group primitive is a
judgement for a host that can run the tests it belongs to, so these stay pub
— the same treatment path::lex::PathShape gets on the other side. The
narrowing found them; a Windows host should finish the job.
An item reached only by platform-conditional or test code says so.
Wake::is_fired becomes #[cfg(windows)] and ExecProjection::carries_veto
#[cfg(target_os = "macos")], next to the poll_beside that was already
#[cfg(unix)]. Those whose only caller is a #[cfg(test)] module carry the
house’s #[cfg_attr(not(test), allow(dead_code))], narrowed to the platform
where that test runs when the test is itself gated. Nothing that a test still
exercises was deleted.
core/tests/* reach internals through one enumerated door,
core::test_access, gated on the test-util feature and turned on for
core’s own dev targets by a self-dev-dependency in core/Cargo.toml. Core’s
integration tests link ral-core as an external crate, so before this an
internal a test asserted on had to be public to every embedder — 19 items
were public for that reason alone. Each door goes through a pub(crate) item
behaviourally, never by handing out core’s representation, per
no-core-repr-leak-into-exarch:
a test is a consumer too. With the feature off the module does not exist, so
the items behind it stay pub(crate) and dead_code still names one whose
last in-crate caller went away.
Twelve tests stop hand-seeding a shell. Shell::seed_default_env_vars was
public because twelve core/tests/* files each built a shell with the
pre-ADR ritual — construct, seed, register — that boot_shell exists to
abolish. They now call one common::fresh_shell over boot_shell — as do
five more that had already spelled that call out for themselves — and the
method is pub(crate): the highest-value item in the residue disappeared
rather than being relabelled.
Consequences
dead_codeis a live signal in core. Had this been in place in July, the commit that movedmake_command’s caller would not have compiled clean.- Narrowing exposes an item to the clippy lints that spare public API.
avoid_breaking_exported_apisilencesunused_self,trivially_copy_pass_by_ref,unnecessary_wraps,struct_field_namesandlen_without_is_emptyonpubitems. Twelve fired on the newly narrowed ones and were fixed rather than allowed, except three where the lint is wrong about this code and says so with a reason.Shell::err/err_hint— two&selfmethods that only calledError::new— are gone. clippy.tomlneeded no change:redundant_pub_crate = "allow"was already set, with the comment “an explicitpub(crate)documents reach more clearly”. The workspace had already decided in this change’s favour.- A narrowed item is dead per platform, so the cross-checks are load-bearing.
An item alive only on one platform reads as dead on the others, and the
workspace denies warnings, so
check-windows/check-macos/check-linuxeach named a set the host build cannot see. Thirty items now carry#[cfg_attr(not(<where it lives>), allow(dead_code))], the annotation this repo already used — one truthful sentence per item about where its callers are, in place of a blanket allow. - The blind spot shrinks by 39%, it does not close. 1019
pubdeclarations remain, anddead_codewill keep missing a stranding among them. The measurement widens by name, so a name needed in one module keeps its homonyms public everywhere; an item-precise pass would go further. - Rustdoc is safe by prior choice:
private_intra_doc_links = "allow", so a[link]into a now-private item still resolves.
Amended 2026-10-06. The embedding surface is now
BakedPrelude(comp,seat),boot_shell,HostSurface(shell,manifest,builtin_table),bake_prelude_to_out_dirand the macro:Shell::new,builtins::registerandbake_runtimeleft it, andShell::rootnever entered it.