From 19e6c050ed675197bfb254481915e5f1043e2bb4 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 20:15:19 +0200 Subject: [PATCH] mind-body: pin the library's public surface as a whole tests/public_surface.rs reads the library source the way a consumer sees it and compares every pub item with its signature, pub fields, derives, impl headers and every path-looking string literal to tests/public_surface.txt. A new pub static or pub fn under any name, a derive onto Grant, or a root read from another path fails it. It replaces the three compile_fail doctests that named with_root, TEST_ROOT and control_root. Dev-dependencies syn, quote and proc-macro2, already in the graph through serde's derive. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 3 + crates/mind-body/Cargo.toml | 6 + crates/mind-body/src/control.rs | 13 -- crates/mind-body/tests/public_surface.rs | 273 ++++++++++++++++++++++ crates/mind-body/tests/public_surface.txt | 72 ++++++ 5 files changed, 354 insertions(+), 13 deletions(-) create mode 100644 crates/mind-body/tests/public_surface.rs create mode 100644 crates/mind-body/tests/public_surface.txt diff --git a/Cargo.lock b/Cargo.lock index a54912a..04c05d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1122,8 +1122,11 @@ dependencies = [ "eureka-state", "huginn-mind", "libc", + "proc-macro2", + "quote", "rust_decimal", "serde", + "syn 2.0.119", "tempfile", ] diff --git a/crates/mind-body/Cargo.toml b/crates/mind-body/Cargo.toml index 06ffbba..3825871 100644 --- a/crates/mind-body/Cargo.toml +++ b/crates/mind-body/Cargo.toml @@ -30,7 +30,13 @@ serde = { version = "1", features = ["derive"] } [target.'cfg(unix)'.dependencies] libc = "0.2" +# `syn`, `quote` and `proc-macro2` read the library's own source for +# tests/public_surface.rs, which pins its public surface. All three are already +# in the workspace's graph (serde's derive uses them); they are test-only. [dev-dependencies] +proc-macro2 = "1" +quote = "1" +syn = { version = "2", features = ["full"] } tempfile = "3" [lib] diff --git a/crates/mind-body/src/control.rs b/crates/mind-body/src/control.rs index 0febc8e..77c4a5b 100644 --- a/crates/mind-body/src/control.rs +++ b/crates/mind-body/src/control.rs @@ -263,19 +263,6 @@ pub fn load_state(source: &impl ControlSource) -> Result { /// let _ = mind_body::control::Grant::for_test; /// ``` /// -/// ```compile_fail -/// // the test seam that moves the root is not in the library build, public or not -/// use mind_body::control::with_root; -/// ``` -/// -/// ```compile_fail -/// use mind_body::control::TEST_ROOT; -/// ``` -/// -/// ```compile_fail -/// use mind_body::control::control_root; -/// ``` -/// /// The same preamble with the real call compiles: /// /// ``` diff --git a/crates/mind-body/tests/public_surface.rs b/crates/mind-body/tests/public_surface.rs new file mode 100644 index 0000000..1f2c39b --- /dev/null +++ b/crates/mind-body/tests/public_surface.rs @@ -0,0 +1,273 @@ +//! The library's public surface, pinned as a whole. +//! +//! The brake's rule is that no caller can name a root, a source or a way to make +//! a `Grant`. A test that names the routes it knows (`with_root`, `TEST_ROOT`) +//! passes on every route it does not name. This one reads the library's source +//! the way a consumer sees it and compares everything a consumer could reach to +//! `public_surface.txt`: every `pub` item with its signature, every `pub` field, +//! the derives and the header of every impl, and every string literal that looks +//! like a path. A new `pub static`, a new `pub fn` under any name, a `Clone` or +//! `Default` derived onto `Grant`, a root read from a file other than the +//! constant: each is a line this file does not have, and the test fails. +//! +//! Items under `#[cfg(test)]` are not the library and are skipped; `cfg(not(test))` +//! and the `unix`/`windows` splits are the library and are read. Any other `cfg`, a `#[path]`, an item macro, a +//! `macro_rules!`, an `extern` block or an exported symbol attribute would hide +//! an item from this reader, so each fails the test outright. A seam that is +//! `pub(crate)` and not `cfg(test)` is outside this reader's concern and inside +//! clippy's: it is dead code in the library build, and `-D warnings` refuses it. +//! +//! A deliberate change to the surface is a change to `public_surface.txt` in the +//! same commit, which is where a reviewer sees it. The failure prints the full +//! current surface to copy from. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use proc_macro2::{Delimiter, Group, TokenStream, TokenTree}; +use quote::ToTokens; +use syn::{Attribute, Fields, ImplItem, Item, Visibility}; + +const SNAPSHOT: &str = include_str!("public_surface.txt"); + +/// Attributes that would put an item outside this reader's sight or export it by name. +const HIDING: [&str; 7] = ["path", "no_mangle", "export_name", "link_section", "macro_export", "macro_use", "cfg_attr"]; + +fn is_pub(visibility: &Visibility) -> bool { + matches!(visibility, Visibility::Public(_)) +} + +fn without_docs(stream: TokenStream) -> TokenStream { + let tokens: Vec = stream.into_iter().collect(); + let mut out = Vec::new(); + let mut at = 0; + while at < tokens.len() { + if matches!(&tokens[at], TokenTree::Punct(p) if p.as_char() == '#') { + let mut group_at = at + 1; + if matches!(tokens.get(group_at), Some(TokenTree::Punct(p)) if p.as_char() == '!') { + group_at += 1; + } + if let Some(TokenTree::Group(group)) = tokens.get(group_at) { + let is_doc = group.delimiter() == Delimiter::Bracket + && matches!(group.stream().into_iter().next(), Some(TokenTree::Ident(name)) if name == "doc"); + if is_doc { + at = group_at + 1; + continue; + } + } + } + match &tokens[at] { + TokenTree::Group(group) => out.push(TokenTree::Group(Group::new(group.delimiter(), without_docs(group.stream())))), + other => out.push(other.clone()), + } + at += 1; + } + out.into_iter().collect() +} + +fn text(node: impl ToTokens) -> String { + without_docs(node.to_token_stream()).to_string() +} + +/// The string literals in `stream` that look like a path. +fn path_literals(stream: TokenStream, out: &mut BTreeSet) { + for token in stream { + match token { + TokenTree::Group(group) => path_literals(group.stream(), out), + TokenTree::Literal(literal) => { + let rendered = literal.to_string(); + if rendered.starts_with('"') && rendered.contains('/') { + out.insert(format!("path-literal {rendered}")); + } + } + _ => {} + } + } +} + +fn attributes(item: &Item) -> &[Attribute] { + match item { + Item::Const(i) => &i.attrs, + Item::Enum(i) => &i.attrs, + Item::ExternCrate(i) => &i.attrs, + Item::Fn(i) => &i.attrs, + Item::ForeignMod(i) => &i.attrs, + Item::Impl(i) => &i.attrs, + Item::Macro(i) => &i.attrs, + Item::Mod(i) => &i.attrs, + Item::Static(i) => &i.attrs, + Item::Struct(i) => &i.attrs, + Item::Trait(i) => &i.attrs, + Item::TraitAlias(i) => &i.attrs, + Item::Type(i) => &i.attrs, + Item::Union(i) => &i.attrs, + Item::Use(i) => &i.attrs, + _ => &[], + } +} + +/// Whether the library build contains what these attributes sit on. +fn in_library(attrs: &[Attribute], at: &str) -> bool { + let mut keep = true; + for attr in attrs { + let rendered = attr.to_token_stream().to_string().replace(' ', ""); + assert!(!HIDING.iter().any(|word| attr.path().is_ident(*word)) && !rendered.contains("no_mangle") && !rendered.contains("export_name"), "{at}: `{rendered}` hides an item from the surface test"); + if attr.path().is_ident("cfg") { + match rendered.as_str() { + "#[cfg(test)]" => keep = false, + // Both sides of a platform split are the library, and both are read. + "#[cfg(not(test))]" | "#[cfg(unix)]" | "#[cfg(not(unix))]" | "#[cfg(windows)]" | "#[cfg(not(windows))]" => {} + other => panic!("{at}: `{other}`: the surface test reads only cfg(test), cfg(not(test)) and the unix/windows splits"), + } + } + } + keep +} + +fn kept_fields(fields: &Fields) -> Vec { + match fields { + Fields::Named(named) => named.named.iter().filter(|f| is_pub(&f.vis)).map(|f| format!("{}: {}", text(&f.ident), text(&f.ty))).collect(), + Fields::Unnamed(unnamed) => unnamed.unnamed.iter().enumerate().filter(|(_, f)| is_pub(&f.vis)).map(|(i, f)| format!("{i}: {}", text(&f.ty))).collect(), + Fields::Unit => Vec::new(), + } +} + +fn derives(attrs: &[Attribute]) -> String { + attrs.iter().filter(|a| a.path().is_ident("derive") || a.path().is_ident("repr")).map(text).collect::>().join(" ") +} + +fn walk(items: &[Item], path: &str, dir: &Path, out: &mut BTreeSet) { + for item in items { + if !in_library(attributes(item), path) { + continue; + } + if !matches!(item, Item::Impl(_)) { + path_literals(without_docs(item.to_token_stream()), out); + } + match item { + Item::Fn(f) if is_pub(&f.vis) => { + out.insert(format!("{path}: {}", text(&f.sig))); + } + Item::Const(c) if is_pub(&c.vis) => { + out.insert(format!("{path}: const {} : {}", c.ident, text(&c.ty))); + } + Item::Static(s) if is_pub(&s.vis) => { + out.insert(format!("{path}: static {} : {} mutable={}", s.ident, text(&s.ty), matches!(s.mutability, syn::StaticMutability::Mut(_)))); + } + Item::Struct(s) if is_pub(&s.vis) => { + out.insert(format!("{path}: struct {}{} {} pub-fields[{}]", s.ident, text(&s.generics), derives(&s.attrs), kept_fields(&s.fields).join(", "))); + } + Item::Enum(e) if is_pub(&e.vis) => { + out.insert(format!("{path}: {}", text(Item::Enum(e.clone())))); + } + Item::Union(u) if is_pub(&u.vis) => { + out.insert(format!("{path}: {}", text(Item::Union(u.clone())))); + } + Item::Trait(t) if is_pub(&t.vis) => { + out.insert(format!("{path}: {}", text(Item::Trait(t.clone())))); + } + Item::TraitAlias(t) if is_pub(&t.vis) => { + out.insert(format!("{path}: {}", text(Item::TraitAlias(t.clone())))); + } + Item::Type(t) if is_pub(&t.vis) => { + out.insert(format!("{path}: {}", text(Item::Type(t.clone())))); + } + Item::Use(u) if is_pub(&u.vis) => { + out.insert(format!("{path}: {}", text(u))); + } + Item::Impl(i) => { + let header = match &i.trait_ { + Some((bang, tr, _)) => format!("impl{} {}{} for {}", text(&i.generics), text(bang), text(tr), text(&i.self_ty)), + None => format!("impl{} {}", text(&i.generics), text(&i.self_ty)), + }; + out.insert(format!("{path}: {header}")); + path_literals(without_docs(i.generics.to_token_stream()), out); + for member in &i.items { + let attrs = match member { + ImplItem::Const(m) => &m.attrs, + ImplItem::Fn(m) => &m.attrs, + ImplItem::Type(m) => &m.attrs, + ImplItem::Macro(m) => &m.attrs, + _ => &[][..], + }; + if !in_library(attrs, path) { + continue; + } + path_literals(without_docs(member.to_token_stream()), out); + if i.trait_.is_none() { + match member { + ImplItem::Fn(f) if is_pub(&f.vis) => { + out.insert(format!("{path}: impl {}: {}", text(&i.self_ty), text(&f.sig))); + } + ImplItem::Const(c) if is_pub(&c.vis) => { + out.insert(format!("{path}: impl {}: const {} : {}", text(&i.self_ty), c.ident, text(&c.ty))); + } + ImplItem::Type(t) if is_pub(&t.vis) => { + out.insert(format!("{path}: impl {}: {}", text(&i.self_ty), text(t))); + } + ImplItem::Macro(_) | ImplItem::Verbatim(_) => panic!("{path}: an impl member the surface test cannot read"), + _ => {} + } + } + } + } + Item::Mod(m) => { + let name = m.ident.to_string(); + if is_pub(&m.vis) { + out.insert(format!("{path}: mod {name}")); + } + let inner = format!("{path}::{name}"); + let below = dir.join(&name); + match &m.content { + Some((_, content)) => walk(content, &inner, &below, out), + None => { + let file = [dir.join(format!("{name}.rs")), below.join("mod.rs")].into_iter().find(|candidate| candidate.exists()); + let file = file.unwrap_or_else(|| panic!("{inner}: no source file")); + let parsed = syn::parse_file(&fs::read_to_string(&file).unwrap()).unwrap_or_else(|e| panic!("{}: {e}", file.display())); + if in_library(&parsed.attrs, &inner) { + walk(&parsed.items, &inner, &below, out); + } + } + } + } + Item::Macro(_) => panic!("{path}: an item macro in library source: the surface test cannot see what it makes"), + Item::ForeignMod(_) | Item::ExternCrate(_) | Item::Verbatim(_) => panic!("{path}: an item the surface test cannot read"), + _ => {} + } + } +} + +fn surface() -> BTreeSet { + let src = Path::new(env!("CARGO_MANIFEST_DIR")).join("src"); + let parsed = syn::parse_file(&fs::read_to_string(src.join("lib.rs")).unwrap()).unwrap(); + let mut out = BTreeSet::new(); + walk(&parsed.items, "mind_body", &src, &mut out); + out +} + +#[test] +fn the_librarys_public_surface_is_the_committed_one() { + let actual = surface(); + let committed: BTreeSet = SNAPSHOT.lines().filter(|line| !line.is_empty()).map(str::to_string).collect(); + let added: Vec<_> = actual.difference(&committed).collect(); + let removed: Vec<_> = committed.difference(&actual).collect(); + assert!( + added.is_empty() && removed.is_empty(), + "the public surface changed.\nadded:\n{}\nremoved:\n{}\nthe full current surface, for tests/public_surface.txt:\n{}", + added.iter().map(|l| format!(" {l}")).collect::>().join("\n"), + removed.iter().map(|l| format!(" {l}")).collect::>().join("\n"), + actual.iter().cloned().collect::>().join("\n"), + ); +} + +#[test] +fn the_reader_sees_what_it_claims_to() { + // The snapshot must hold the brake's own names, or the reader is blind to them. + let actual = surface(); + for needle in ["mind_body::control: fn read_effective", "mind_body::control: struct Grant", "path-literal \"/etc/gamecult/minds\""] { + assert!(actual.iter().any(|line| line.contains(needle)), "{needle} is not in the surface"); + } + // And nothing under cfg(test) leaked in. + assert!(!actual.iter().any(|line| line.contains("for_test") || line.contains("with_root") || line.contains("TEST_ROOT")), "a cfg(test) item is in the surface"); +} diff --git a/crates/mind-body/tests/public_surface.txt b/crates/mind-body/tests/public_surface.txt new file mode 100644 index 0000000..cc15f2f --- /dev/null +++ b/crates/mind-body/tests/public_surface.txt @@ -0,0 +1,72 @@ +mind_body: mod cli +mind_body: mod control +mind_body: mod launch +mind_body: mod queue +mind_body::cli: fn operator_name (sudo_user : Option < String > , user : Option < String >) -> String +mind_body::cli: fn run (args : & [String] , root : & Path , set_by : & str , now : DateTime < Utc > , out : & mut impl Write) -> Result < () > +mind_body::control: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum HeldReason { BrakeAbsent , BrakeHeld , DialAbsent , DialOutOfBounds , Undecodable , } +mind_body::control: # [derive (Debug , PartialEq , Eq)] pub enum Effective { Held { reason : HeldReason } , Released (Grant) , } +mind_body::control: const BRAKE_KEY : & str +mind_body::control: const CONTROL_ROOT : & str +mind_body::control: const DIAL_KEY : & str +mind_body::control: fn control_path (root : & Path , instance : & Slug) -> Result < PathBuf > +mind_body::control: fn heat_max () -> Decimal +mind_body::control: fn heat_min () -> Decimal +mind_body::control: fn load_state (source : & impl ControlSource) -> Result < ControlState > +mind_body::control: fn read_effective (instance : & Slug) -> Effective +mind_body::control: impl BurnRate +mind_body::control: impl BurnRate: fn in_bounds (& self) -> bool +mind_body::control: impl ControlSource for FileSource +mind_body::control: impl ControlState +mind_body::control: impl ControlWriter +mind_body::control: impl ControlWriter: fn open (path : impl Into < PathBuf >) -> Result < Self > +mind_body::control: impl ControlWriter: fn set_brake (& mut self , released : bool , set_at : DateTime < Utc > , set_by : & str) -> Result < () > +mind_body::control: impl ControlWriter: fn set_dial (& mut self , dial : BurnRate) -> Result < () > +mind_body::control: impl FileSource +mind_body::control: impl FileSource: fn new (path : impl Into < PathBuf >) -> Self +mind_body::control: impl Grant +mind_body::control: impl Grant: fn cadence (& self) -> Duration +mind_body::control: impl Grant: fn instance (& self) -> & Slug +mind_body::control: impl Grant: fn run_cap_usd (& self) -> Decimal +mind_body::control: impl std :: error :: Error for UnsafeStore +mind_body::control: impl std :: fmt :: Display for UnsafeStore +mind_body::control: pub trait ControlSource { fn snapshot (& self) -> Result < Vec < CultCacheEnvelope > > ; } +mind_body::control: struct Brake # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[released: bool, set_at: DateTime < Utc >, set_by: String] +mind_body::control: struct BurnRate # [derive (Clone , Debug , PartialEq , Eq , DatabaseEntry)] pub-fields[heat: Decimal, base_cooldown_s: u32, base_run_usd: Decimal, set_at: DateTime < Utc >, set_by: String] +mind_body::control: struct ControlState # [derive (Clone , Debug , PartialEq , Eq , Default)] pub-fields[brake: Option < Brake >, dial: Option < BurnRate >] +mind_body::control: struct ControlWriter pub-fields[] +mind_body::control: struct FileSource pub-fields[] +mind_body::control: struct Grant # [derive (Debug , PartialEq , Eq)] pub-fields[] +mind_body::control: struct UnsafeStore # [derive (Debug)] pub-fields[path: PathBuf, mode: u32, uid: u32] +mind_body::launch: # [derive (Clone , Debug , PartialEq , Eq)] pub enum Declined { Admission (MindRefusal) , Conflict , AlreadyAdmitted , StartFailed , GrantForOtherInstance , PersonaRunTakesNoClaims , } +mind_body::launch: # [derive (Clone , Debug , PartialEq , Eq)] pub enum LaunchOutcome { Launched { run : PipelineRef , } , NotQueued { item : PipelineRef , } , Tripped { item : PipelineRef , question : PipelineRef , } , Busy { run : PipelineRef , } , Refused (Declined) , } +mind_body::launch: const START_GRACE_S : i64 +mind_body::launch: fn open_and_launch (ports : & Ports , request : LaunchRequest) -> Result < LaunchOutcome > +mind_body::launch: fn run_label (now : DateTime < Utc >) -> Label +mind_body::launch: impl Clock for SystemClock +mind_body::launch: impl Default for SystemdLauncher +mind_body::launch: impl Launcher for SystemdLauncher +mind_body::launch: impl Systemctl for RealSystemctl +mind_body::launch: impl SystemdLauncher +mind_body::launch: impl SystemdLauncher: fn new () -> Self +mind_body::launch: pub trait Clock { fn now (& self) -> DateTime < Utc > ; } +mind_body::launch: pub trait Launcher { fn start (& self , instance : & Slug , turn : RunTurn) -> Result < () > ; fn alive (& self , instance : & Slug , turn : RunTurn) -> bool ; } +mind_body::launch: pub trait Systemctl { fn run (& self , args : & [& str]) -> Result < SystemctlReply > ; } +mind_body::launch: struct LaunchRequest pub-fields[turn: RunTurn, claims: Vec < PipelineRef >, agent: String, grant: Grant] +mind_body::launch: struct Ports< 'a > pub-fields[mind: & 'a dyn MindPort, launcher: & 'a dyn Launcher, clock: & 'a dyn Clock, host: & 'a str] +mind_body::launch: struct SystemClock pub-fields[] +mind_body::launch: struct SystemctlReply pub-fields[success: bool, stdout: String] +mind_body::launch: struct SystemdLauncher pub-fields[] +mind_body::queue: # [derive (Clone , Copy , Debug , PartialEq , Eq)] pub enum Breaker { Clear , Trip , } +mind_body::queue: const CONSECUTIVE : usize +mind_body::queue: const EMPTY_SHARE : usize +mind_body::queue: const WINDOW : usize +mind_body::queue: fn breaker (mind : & dyn MindPort , item : & PipelineRef , ending : & [PipelineRef]) -> Result < Breaker > +mind_body::queue: fn queue (mind : & dyn MindPort , instance : & Slug , ending : & [PipelineRef]) -> Result < Vec < PipelineRef > > +mind_body::queue: impl HuginnMind < 'static > +mind_body::queue: impl HuginnMind < 'static >: fn new (client : HuginnClient) -> Self +mind_body::queue: impl MindPort for HuginnMind < '_ > +mind_body::queue: impl< 'a > HuginnMind < 'a > +mind_body::queue: pub trait MindPort { fn instance (& self) -> & Slug ; fn query (& self , selection : & Selection) -> Result < PipelineSelectionPage > ; fn admit (& self , agent : & str , session : & str , documents : Vec < PipelineDocument >) -> Result < PipelineAdmissionOutcome > ; } +mind_body::queue: struct HuginnMind< 'a > pub-fields[] +path-literal "/etc/gamecult/minds"