Some checks failed
API and SemVer surface / api-surface (push) Failing after 1m34s
Native code generation / deterministic (push) Has been cancelled
Concurrency and resource soak audit / soak (push) Has been cancelled
Documentation / documentation (push) Has been cancelled
performance evidence / audit (push) Has been cancelled
First release candidate / non-fuzz-release-gate (push) Has been cancelled
Release platform and feature matrix / audit (push) Has been cancelled
Release platform and feature matrix / matrix (false, linux-stable-minimal, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, macos-stable-portable, x86_64-apple-darwin, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (false, windows-stable-portable, x86_64-pc-windows-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-msrv-portable, x86_64-unknown-linux-gnu, 1.96.0) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-default, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-features, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Release platform and feature matrix / matrix (true, linux-stable-release-surface, x86_64-unknown-linux-gnu, stable) (push) Has been cancelled
Dependency and supply-chain audit / audit (push) Has been cancelled
Native release artifact audit / audit (push) Has been cancelled
Imaging and meshing gate / native (push) Failing after 53s
JPEG 2000 feature / linux (push) Successful in 2m49s
Native Rust workspace compile / compile (push) Failing after 59s
Skia feature / linux (push) Has been cancelled
1339 lines
47 KiB
Rust
1339 lines
47 KiB
Rust
use super::{MatrixError, Result};
|
|
use serde::{Deserialize, Serialize};
|
|
use sha2::{Digest, Sha256};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
use std::fs::{self, OpenOptions};
|
|
use std::io::Write as _;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
const BASELINE_PATH: &str = "api/SEMVER-BASELINE.json";
|
|
const REPORT_PATH: &str = "api/SEMVER-AUDIT.md";
|
|
const MEMBER_PATH: &str = "api/RUST-MAPPING.tsv";
|
|
const TYPE_PATH: &str = "api/RUST-TYPES.tsv";
|
|
const UPSTREAM_COMMIT: &str = "2aa70bb68513b39795da5d13c88f31b86e85a3ba";
|
|
|
|
const PUBLIC_CRATES: [(&str, &str); 15] = [
|
|
("libremetaverse", "crates/libremetaverse"),
|
|
("libremetaverse-types", "crates/libremetaverse-types"),
|
|
(
|
|
"libremetaverse-structured-data",
|
|
"crates/libremetaverse-structured-data",
|
|
),
|
|
("libremetaverse-imaging", "crates/libremetaverse-imaging"),
|
|
(
|
|
"libremetaverse-imaging-skia",
|
|
"crates/libremetaverse-imaging-skia",
|
|
),
|
|
(
|
|
"libremetaverse-prim-mesher",
|
|
"crates/libremetaverse-prim-mesher",
|
|
),
|
|
(
|
|
"libremetaverse-rendering-simple",
|
|
"crates/libremetaverse-rendering-simple",
|
|
),
|
|
(
|
|
"libremetaverse-rendering-mesh-foundry",
|
|
"crates/libremetaverse-rendering-mesh-foundry",
|
|
),
|
|
(
|
|
"libremetaverse-lsl-tools",
|
|
"crates/libremetaverse-lsl-tools",
|
|
),
|
|
("libremetaverse-rlv", "crates/libremetaverse-rlv"),
|
|
(
|
|
"libremetaverse-utilities",
|
|
"crates/libremetaverse-utilities",
|
|
),
|
|
(
|
|
"libremetaverse-voice-vivox",
|
|
"crates/libremetaverse-voice-vivox",
|
|
),
|
|
(
|
|
"libremetaverse-voice-webrtc",
|
|
"crates/libremetaverse-voice-webrtc",
|
|
),
|
|
("libremetaverse-openjpeg", "crates/libremetaverse-openjpeg"),
|
|
("libremetaverse-opus", "crates/libremetaverse-opus"),
|
|
];
|
|
|
|
const REQUIRED_NON_EXHAUSTIVE_ERRORS: [(&str, &str); 7] = [
|
|
("crates/libremetaverse-types/src/shim.rs", "Error"),
|
|
("crates/libremetaverse-openjpeg/src/lib.rs", "Error"),
|
|
(
|
|
"crates/libremetaverse-voice-webrtc/src/native.rs",
|
|
"WebRtcError",
|
|
),
|
|
(
|
|
"crates/libremetaverse/src/client_core.rs",
|
|
"ClientCoreError",
|
|
),
|
|
(
|
|
"crates/libremetaverse/src/udp_transport.rs",
|
|
"UdpTransportError",
|
|
),
|
|
(
|
|
"crates/libremetaverse-voice-vivox/src/protocol.rs",
|
|
"VivoxError",
|
|
),
|
|
(
|
|
"crates/libremetaverse-rlv/src/protocol.rs",
|
|
"RlvParseErrorKind",
|
|
),
|
|
];
|
|
|
|
const REQUIRED_THREAD_SAFE_TRAITS: [(&str, &str); 6] = [
|
|
(
|
|
"crates/libremetaverse/src/generated.rs",
|
|
"pub trait IBakingTextureProvider: std::any::Any + Send + Sync",
|
|
),
|
|
(
|
|
"crates/libremetaverse/src/generated.rs",
|
|
"pub trait IGridClient: Send + Sync",
|
|
),
|
|
(
|
|
"crates/libremetaverse/src/generated.rs",
|
|
"pub trait ICurrentOutfitPolicy: Send + Sync",
|
|
),
|
|
(
|
|
"crates/libremetaverse-rlv/src/generated.rs",
|
|
"pub trait IRlvActionCallbacks: Send + Sync",
|
|
),
|
|
(
|
|
"crates/libremetaverse-rlv/src/generated.rs",
|
|
"pub trait IRlvQueryCallbacks: Send + Sync",
|
|
),
|
|
(
|
|
"crates/libremetaverse-voice-webrtc/src/compatibility.rs",
|
|
"pub trait IVoiceLogger: Send + Sync",
|
|
),
|
|
];
|
|
|
|
const OWNERSHIP_TERMS: [&str; 14] = [
|
|
"none",
|
|
"owned_value",
|
|
"shared_self",
|
|
"shared_or_mutable_self",
|
|
"mutable_self",
|
|
"owned",
|
|
"optional_owned",
|
|
"mutable_borrow",
|
|
"optional_mutable_borrow",
|
|
"mutable_output",
|
|
"shared_borrow",
|
|
"optional_shared",
|
|
"shared",
|
|
"stored_callback",
|
|
];
|
|
|
|
type Row = BTreeMap<String, String>;
|
|
|
|
#[derive(Debug)]
|
|
struct QualityCounts {
|
|
intentional_differences: usize,
|
|
descriptive_overloads: usize,
|
|
async_functions: usize,
|
|
send_boxed_futures: usize,
|
|
thread_safe_callbacks: usize,
|
|
enum_types: usize,
|
|
bitflag_types: usize,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
struct ApiBaseline {
|
|
schema: u32,
|
|
release: String,
|
|
upstream_commit: String,
|
|
mapped_public_types: usize,
|
|
mapped_members: usize,
|
|
external_signature_types: usize,
|
|
support_traits: usize,
|
|
intentional_differences: usize,
|
|
descriptive_overloads: usize,
|
|
async_functions: usize,
|
|
send_boxed_futures: usize,
|
|
thread_safe_callbacks: usize,
|
|
thread_safe_dyn_traits: usize,
|
|
non_exhaustive_error_enums: usize,
|
|
enum_types: usize,
|
|
bitflag_types: usize,
|
|
unexplained_mappings: usize,
|
|
accidental_public_dependency_types: usize,
|
|
type_mapping_sha256: String,
|
|
member_mapping_sha256: String,
|
|
exported_mapping_sha256: String,
|
|
rust_declaration_sha256: String,
|
|
crates: Vec<CrateSurface>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
|
|
struct CrateSurface {
|
|
name: String,
|
|
version: String,
|
|
features: BTreeMap<String, Vec<String>>,
|
|
mapped_types: usize,
|
|
mapped_members: usize,
|
|
public_declarations: usize,
|
|
declaration_sha256: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
struct ApiEvidence<'a> {
|
|
schema: u32,
|
|
release: &'a str,
|
|
upstream_commit: &'a str,
|
|
baseline_sha256: String,
|
|
mapped_public_types: usize,
|
|
mapped_members: usize,
|
|
intentional_differences: usize,
|
|
unexplained_mappings: usize,
|
|
accidental_public_dependency_types: usize,
|
|
thread_safe_dyn_traits: usize,
|
|
non_exhaustive_error_enums: usize,
|
|
downstream_compile_fixture: &'static str,
|
|
representative_port: &'static str,
|
|
status: &'static str,
|
|
}
|
|
|
|
/// Writes the reviewed first-release API/SemVer baseline and audit report.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error when the mapped/exported surface or a quality invariant is
|
|
/// incomplete, or when the deterministic output cannot be written.
|
|
pub fn write_api_baseline(root: &Path) -> Result<()> {
|
|
let baseline = inspect(root)?;
|
|
fs::write(root.join(BASELINE_PATH), baseline_json(&baseline)?)?;
|
|
fs::write(root.join(REPORT_PATH), render_report(&baseline))?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Audits the current API against the checked first-release `SemVer` baseline.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error for missing/unexplained mappings, a changed public surface,
|
|
/// weak async/thread/error contracts, feature drift, leaked dependency types,
|
|
/// stale reports, or evidence I/O failures.
|
|
pub fn audit_api_surface(root: &Path, evidence: &Path) -> Result<()> {
|
|
if evidence.exists() {
|
|
return Err(MatrixError::new(format!(
|
|
"{} already exists; preserve or remove it before rerunning the audit",
|
|
evidence.display()
|
|
)));
|
|
}
|
|
let current = inspect(root)?;
|
|
let baseline_bytes = fs::read(root.join(BASELINE_PATH))?;
|
|
let baseline: ApiBaseline = serde_json::from_slice(&baseline_bytes)?;
|
|
if baseline != current {
|
|
return Err(MatrixError::new(
|
|
"public API differs from api/SEMVER-BASELINE.json; review the SemVer impact before recording a new baseline",
|
|
));
|
|
}
|
|
if fs::read_to_string(root.join(REPORT_PATH))? != render_report(¤t) {
|
|
return Err(MatrixError::new(
|
|
"API/SemVer audit report is stale; run api-baseline-write after review",
|
|
));
|
|
}
|
|
|
|
let record = ApiEvidence {
|
|
schema: 1,
|
|
release: ¤t.release,
|
|
upstream_commit: ¤t.upstream_commit,
|
|
baseline_sha256: sha256(&baseline_bytes),
|
|
mapped_public_types: current.mapped_public_types,
|
|
mapped_members: current.mapped_members,
|
|
intentional_differences: current.intentional_differences,
|
|
unexplained_mappings: current.unexplained_mappings,
|
|
accidental_public_dependency_types: current.accidental_public_dependency_types,
|
|
thread_safe_dyn_traits: current.thread_safe_dyn_traits,
|
|
non_exhaustive_error_enums: current.non_exhaustive_error_enums,
|
|
downstream_compile_fixture: "tests/api-compile",
|
|
representative_port: "tests/semver-port",
|
|
status: "ok",
|
|
};
|
|
if let Some(parent) = evidence.parent() {
|
|
fs::create_dir_all(parent)?;
|
|
}
|
|
let mut file = OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.open(evidence)?;
|
|
serde_json::to_writer_pretty(&mut file, &record)?;
|
|
file.write_all(b"\n")?;
|
|
file.sync_all()?;
|
|
Ok(())
|
|
}
|
|
|
|
fn inspect(root: &Path) -> Result<ApiBaseline> {
|
|
let (type_header, type_rows) = read_tsv(&root.join(TYPE_PATH))?;
|
|
let (member_header, member_rows) = read_tsv(&root.join(MEMBER_PATH))?;
|
|
validate_mapping_headers(&type_header, &member_header)?;
|
|
validate_unique(&type_rows, "csharp_type_id", "type mapping")?;
|
|
validate_unique(&member_rows, "csharp_id", "member mapping")?;
|
|
validate_unique(&member_rows, "rust_item_path", "Rust member destination")?;
|
|
validate_members(&member_rows)?;
|
|
validate_types(&type_rows)?;
|
|
|
|
let (public_type_ids, member_ids) = mapped_ids(&type_rows, &member_rows);
|
|
let (exported_types, exported_members, exported_bytes) = exported_mappings(root)?;
|
|
if public_type_ids != exported_types || member_ids != exported_members {
|
|
return Err(MatrixError::new(format!(
|
|
"mapped/exported coverage mismatch: types {}/{}, members {}/{}",
|
|
exported_types.len(),
|
|
public_type_ids.len(),
|
|
exported_members.len(),
|
|
member_ids.len()
|
|
)));
|
|
}
|
|
|
|
validate_non_exhaustive_errors(root)?;
|
|
validate_thread_safe_traits(root)?;
|
|
validate_dependency_roots(&member_rows)?;
|
|
|
|
let workspace = fs::read_to_string(root.join("Cargo.toml"))?;
|
|
let release = package_value(&workspace, "version")
|
|
.ok_or_else(|| MatrixError::new("workspace package version is missing"))?;
|
|
let (crates, declarations) = inspect_crates(root, &release, &type_rows, &member_rows)?;
|
|
let counts = quality_counts(&type_rows, &member_rows);
|
|
|
|
let baseline = ApiBaseline {
|
|
schema: 1,
|
|
release,
|
|
upstream_commit: UPSTREAM_COMMIT.to_owned(),
|
|
mapped_public_types: public_type_ids.len(),
|
|
mapped_members: member_ids.len(),
|
|
external_signature_types: type_rows
|
|
.iter()
|
|
.filter(|row| {
|
|
!field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
|
|
&& field(row, "source_kind") != "referenced_support_trait"
|
|
})
|
|
.count(),
|
|
support_traits: type_rows
|
|
.iter()
|
|
.filter(|row| field(row, "source_kind") == "referenced_support_trait")
|
|
.count(),
|
|
intentional_differences: counts.intentional_differences,
|
|
descriptive_overloads: counts.descriptive_overloads,
|
|
async_functions: counts.async_functions,
|
|
send_boxed_futures: counts.send_boxed_futures,
|
|
thread_safe_callbacks: counts.thread_safe_callbacks,
|
|
thread_safe_dyn_traits: REQUIRED_THREAD_SAFE_TRAITS.len(),
|
|
non_exhaustive_error_enums: REQUIRED_NON_EXHAUSTIVE_ERRORS.len(),
|
|
enum_types: counts.enum_types,
|
|
bitflag_types: counts.bitflag_types,
|
|
unexplained_mappings: 0,
|
|
accidental_public_dependency_types: 0,
|
|
type_mapping_sha256: sha256(&fs::read(root.join(TYPE_PATH))?),
|
|
member_mapping_sha256: sha256(&fs::read(root.join(MEMBER_PATH))?),
|
|
exported_mapping_sha256: sha256(&exported_bytes),
|
|
rust_declaration_sha256: sha256(declarations.join("\n").as_bytes()),
|
|
crates,
|
|
};
|
|
validate_totals(&baseline)?;
|
|
Ok(baseline)
|
|
}
|
|
|
|
fn validate_mapping_headers(type_header: &[String], member_header: &[String]) -> Result<()> {
|
|
require_columns(
|
|
type_header,
|
|
&[
|
|
"csharp_type_id",
|
|
"csharp_signature",
|
|
"source_kind",
|
|
"rust_crate",
|
|
"rust_path",
|
|
"mapping_decision",
|
|
"inheritance_mapping",
|
|
"enum_mapping",
|
|
"status",
|
|
],
|
|
TYPE_PATH,
|
|
)?;
|
|
require_columns(
|
|
member_header,
|
|
&[
|
|
"csharp_id",
|
|
"csharp_kind",
|
|
"csharp_signature",
|
|
"rust_crate",
|
|
"rust_item_path",
|
|
"rust_signature",
|
|
"ownership",
|
|
"asyncness",
|
|
"error_model",
|
|
"overload_decision",
|
|
"mapping_kind",
|
|
"status",
|
|
],
|
|
MEMBER_PATH,
|
|
)
|
|
}
|
|
|
|
fn mapped_ids(type_rows: &[Row], member_rows: &[Row]) -> (BTreeSet<String>, BTreeSet<String>) {
|
|
let public_type_ids = type_rows
|
|
.iter()
|
|
.filter(|row| {
|
|
field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
|
|
&& field(row, "source_kind") != "referenced_support_trait"
|
|
})
|
|
.map(|row| field(row, "csharp_type_id").to_owned())
|
|
.collect::<BTreeSet<_>>();
|
|
let member_ids = member_rows
|
|
.iter()
|
|
.map(|row| field(row, "csharp_id").to_owned())
|
|
.collect::<BTreeSet<_>>();
|
|
(public_type_ids, member_ids)
|
|
}
|
|
|
|
fn validate_dependency_roots(member_rows: &[Row]) -> Result<()> {
|
|
let accidental_roots = accidental_dependency_roots(member_rows);
|
|
if !accidental_roots.is_empty() {
|
|
return Err(MatrixError::new(format!(
|
|
"mapped public signatures expose dependency roots: {}",
|
|
accidental_roots.into_iter().collect::<Vec<_>>().join(", ")
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn inspect_crates(
|
|
root: &Path,
|
|
release: &str,
|
|
type_rows: &[Row],
|
|
member_rows: &[Row],
|
|
) -> Result<(Vec<CrateSurface>, Vec<String>)> {
|
|
let mut declarations = Vec::new();
|
|
let mut crates = Vec::new();
|
|
for (name, relative) in PUBLIC_CRATES {
|
|
let directory = root.join(relative);
|
|
let manifest = fs::read_to_string(directory.join("Cargo.toml"))?;
|
|
if package_value(&manifest, "name").as_deref() != Some(name) {
|
|
return Err(MatrixError::new(format!(
|
|
"public crate manifest mismatch for {name}"
|
|
)));
|
|
}
|
|
let crate_version = if manifest
|
|
.lines()
|
|
.any(|line| line.trim() == "version.workspace = true")
|
|
{
|
|
release.to_owned()
|
|
} else {
|
|
package_value(&manifest, "version")
|
|
.ok_or_else(|| MatrixError::new(format!("public crate {name} lacks a version")))?
|
|
};
|
|
if crate_version != release {
|
|
return Err(MatrixError::new(format!(
|
|
"public crate {name} has version {crate_version}, expected release {release}"
|
|
)));
|
|
}
|
|
let features = parse_features(&manifest)?;
|
|
validate_features(name, &features)?;
|
|
let crate_declarations = crate_declarations(&directory)?;
|
|
validate_declaration_dependencies(name, &manifest, &crate_declarations)?;
|
|
let declaration_sha256 = sha256(crate_declarations.join("\n").as_bytes());
|
|
declarations.extend(
|
|
crate_declarations
|
|
.iter()
|
|
.map(|declaration| format!("{name}\t{declaration}")),
|
|
);
|
|
crates.push(CrateSurface {
|
|
name: name.to_owned(),
|
|
version: release.to_owned(),
|
|
features,
|
|
mapped_types: type_rows
|
|
.iter()
|
|
.filter(|row| {
|
|
field(row, "rust_crate") == name
|
|
&& field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
|
|
&& field(row, "source_kind") != "referenced_support_trait"
|
|
})
|
|
.count(),
|
|
mapped_members: member_rows
|
|
.iter()
|
|
.filter(|row| field(row, "rust_crate") == name)
|
|
.count(),
|
|
public_declarations: crate_declarations.len(),
|
|
declaration_sha256,
|
|
});
|
|
}
|
|
declarations.sort();
|
|
Ok((crates, declarations))
|
|
}
|
|
|
|
fn quality_counts(type_rows: &[Row], member_rows: &[Row]) -> QualityCounts {
|
|
let intentional_differences = member_rows
|
|
.iter()
|
|
.filter(|row| field(row, "status") == "intentional_difference")
|
|
.count();
|
|
let descriptive_overloads = member_rows
|
|
.iter()
|
|
.filter(|row| field(row, "overload_decision") != "direct_snake_case")
|
|
.count();
|
|
let async_functions = member_rows
|
|
.iter()
|
|
.filter(|row| field(row, "asyncness") == "async")
|
|
.count();
|
|
let send_boxed_futures = member_rows
|
|
.iter()
|
|
.filter(|row| field(row, "asyncness") == "boxed_future")
|
|
.count();
|
|
let thread_safe_callbacks = member_rows
|
|
.iter()
|
|
.filter(|row| field(row, "rust_signature").contains("dyn Fn"))
|
|
.count();
|
|
let enum_types = type_rows
|
|
.iter()
|
|
.filter(|row| {
|
|
field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
|
|
&& field(row, "source_kind") == "enum"
|
|
})
|
|
.count();
|
|
let bitflag_types = type_rows
|
|
.iter()
|
|
.filter(|row| {
|
|
field(row, "csharp_type_id").starts_with("T:LibreMetaverse")
|
|
&& field(row, "enum_mapping").starts_with("bitflags<")
|
|
})
|
|
.count();
|
|
QualityCounts {
|
|
intentional_differences,
|
|
descriptive_overloads,
|
|
async_functions,
|
|
send_boxed_futures,
|
|
thread_safe_callbacks,
|
|
enum_types,
|
|
bitflag_types,
|
|
}
|
|
}
|
|
|
|
fn validate_members(rows: &[Row]) -> Result<()> {
|
|
let ownership_terms = OWNERSHIP_TERMS.into_iter().collect::<BTreeSet<_>>();
|
|
for row in rows {
|
|
let id = field(row, "csharp_id");
|
|
if [
|
|
"csharp_id",
|
|
"csharp_kind",
|
|
"csharp_signature",
|
|
"rust_crate",
|
|
"rust_item_path",
|
|
"rust_signature",
|
|
"ownership",
|
|
"asyncness",
|
|
"error_model",
|
|
"overload_decision",
|
|
"mapping_kind",
|
|
"status",
|
|
]
|
|
.into_iter()
|
|
.any(|name| field(row, name).is_empty())
|
|
{
|
|
return Err(MatrixError::new(format!("mapping {id} has an empty field")));
|
|
}
|
|
if field(row, "ownership")
|
|
.split(',')
|
|
.any(|term| !ownership_terms.contains(term))
|
|
{
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} has an unexplained ownership term"
|
|
)));
|
|
}
|
|
let signature = field(row, "rust_signature");
|
|
validate_rust_naming(id, signature)?;
|
|
match field(row, "asyncness") {
|
|
"sync" if signature.contains("pub async fn") => {
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} marks an async function as synchronous"
|
|
)));
|
|
}
|
|
"async" if !signature.contains("pub async fn") => {
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} lacks an async Rust signature"
|
|
)));
|
|
}
|
|
"boxed_future"
|
|
if !signature.contains("dyn std::future::Future")
|
|
|| !signature.contains("+ Send") =>
|
|
{
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} lacks a Send boxed future"
|
|
)));
|
|
}
|
|
"sync" | "async" | "boxed_future" => {}
|
|
value => {
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} has unknown asyncness {value}"
|
|
)));
|
|
}
|
|
}
|
|
if signature.contains("dyn Fn") && !signature.contains("+ Send + Sync") {
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} exposes a callback without Send + Sync"
|
|
)));
|
|
}
|
|
match field(row, "error_model") {
|
|
"Result<_, crate::Error>" if !signature.contains("Result<") => {
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} declares Result errors without a Result signature"
|
|
)));
|
|
}
|
|
"none" | "infallible_placeholder" if signature.contains("Result<") => {
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} returns Result without a matching error model"
|
|
)));
|
|
}
|
|
"Result<_, crate::Error>" | "none" | "infallible_placeholder" => {}
|
|
value => {
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} has unknown error model {value}"
|
|
)));
|
|
}
|
|
}
|
|
let intentional = field(row, "status") == "intentional_difference";
|
|
if intentional != (field(row, "mapping_kind") == "native_callback_replaces_delegate_apm") {
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} has an unexplained intentional difference"
|
|
)));
|
|
}
|
|
if !matches!(field(row, "status"), "mapped" | "intentional_difference") {
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} has an unresolved status"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_rust_naming(id: &str, signature: &str) -> Result<()> {
|
|
let function = ["pub async fn ", "pub fn ", "async fn ", "fn "]
|
|
.into_iter()
|
|
.find_map(|prefix| signature.strip_prefix(prefix));
|
|
if let Some(function) = function {
|
|
let name = function
|
|
.split(|character: char| !(character.is_ascii_alphanumeric() || character == '_'))
|
|
.next()
|
|
.unwrap_or_default();
|
|
if name.is_empty()
|
|
|| name.starts_with(|character: char| character.is_ascii_digit())
|
|
|| !name.chars().all(|character| {
|
|
character.is_ascii_lowercase() || character.is_ascii_digit() || character == '_'
|
|
})
|
|
{
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} has a non-idiomatic Rust function name: {name}"
|
|
)));
|
|
}
|
|
}
|
|
for prefix in ["pub const ", "pub static "] {
|
|
if let Some(item) = signature.strip_prefix(prefix) {
|
|
let name = item
|
|
.split(|character: char| !(character.is_ascii_alphanumeric() || character == '_'))
|
|
.next()
|
|
.unwrap_or_default();
|
|
if name.is_empty()
|
|
|| !name.chars().all(|character| {
|
|
character.is_ascii_uppercase() || character.is_ascii_digit() || character == '_'
|
|
})
|
|
{
|
|
return Err(MatrixError::new(format!(
|
|
"mapping {id} has a non-idiomatic Rust constant name: {name}"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_types(rows: &[Row]) -> Result<()> {
|
|
for row in rows {
|
|
let id = field(row, "csharp_type_id");
|
|
if [
|
|
"csharp_type_id",
|
|
"csharp_signature",
|
|
"source_kind",
|
|
"rust_crate",
|
|
"rust_path",
|
|
"mapping_decision",
|
|
"inheritance_mapping",
|
|
"enum_mapping",
|
|
"status",
|
|
]
|
|
.into_iter()
|
|
.any(|name| field(row, name).is_empty())
|
|
|| field(row, "status") != "mapped"
|
|
{
|
|
return Err(MatrixError::new(format!(
|
|
"type mapping {id} is incomplete or unresolved"
|
|
)));
|
|
}
|
|
if !matches!(
|
|
field(row, "mapping_decision"),
|
|
"native_metacrate_type"
|
|
| "native_cross_platform_replacement"
|
|
| "stdlib_or_adopted_crate"
|
|
| "language"
|
|
| "native_metacrate_support_trait"
|
|
) {
|
|
return Err(MatrixError::new(format!(
|
|
"type mapping {id} has an unexplained decision"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_totals(baseline: &ApiBaseline) -> Result<()> {
|
|
if baseline.mapped_public_types != 3_066
|
|
|| baseline.mapped_members != 30_789
|
|
|| baseline.external_signature_types != 142
|
|
|| baseline.support_traits != 2
|
|
|| baseline.intentional_differences != 80
|
|
|| baseline.descriptive_overloads != 7_151
|
|
|| baseline.async_functions != 334
|
|
|| baseline.send_boxed_futures != 26
|
|
|| baseline.thread_safe_callbacks != 137
|
|
|| baseline.enum_types != 243
|
|
|| baseline.bitflag_types != 46
|
|
|| baseline.unexplained_mappings != 0
|
|
|| baseline.accidental_public_dependency_types != 0
|
|
{
|
|
return Err(MatrixError::new(
|
|
"API quality totals differ from the reviewed first-release surface",
|
|
));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn exported_mappings(root: &Path) -> Result<(BTreeSet<String>, BTreeSet<String>, Vec<u8>)> {
|
|
let mut types = BTreeSet::new();
|
|
let mut members = BTreeSet::new();
|
|
let mut canonical = Vec::new();
|
|
for (_, relative) in PUBLIC_CRATES {
|
|
let path = root.join(relative).join("src/generated.rs");
|
|
if !path.exists() {
|
|
continue;
|
|
}
|
|
for line in fs::read_to_string(&path)?.lines().map(str::trim) {
|
|
if let Some(id) = marker(line, "/// C# type: `") {
|
|
if !types.insert(id.to_owned()) {
|
|
return Err(MatrixError::new(format!(
|
|
"duplicate exported type marker {id}"
|
|
)));
|
|
}
|
|
canonical.push(format!("type\t{id}"));
|
|
}
|
|
if let Some(id) = marker(line, "/// C# member: `") {
|
|
if !members.insert(id.to_owned()) {
|
|
return Err(MatrixError::new(format!(
|
|
"duplicate exported member marker {id}"
|
|
)));
|
|
}
|
|
canonical.push(format!("member\t{id}"));
|
|
}
|
|
}
|
|
}
|
|
canonical.sort_unstable();
|
|
Ok((types, members, canonical.join("\n").into_bytes()))
|
|
}
|
|
|
|
fn marker<'a>(line: &'a str, prefix: &str) -> Option<&'a str> {
|
|
line.strip_prefix(prefix)?.strip_suffix("`.")
|
|
}
|
|
|
|
fn accidental_dependency_roots(rows: &[Row]) -> BTreeSet<String> {
|
|
let mut roots = BTreeSet::new();
|
|
for row in rows {
|
|
for root in path_roots(field(row, "rust_signature")) {
|
|
if !matches!(root.as_str(), "crate" | "std" | "core" | "self" | "super")
|
|
&& !root.starts_with("libremetaverse")
|
|
{
|
|
roots.insert(root);
|
|
}
|
|
}
|
|
}
|
|
roots
|
|
}
|
|
|
|
fn validate_declaration_dependencies(
|
|
crate_name: &str,
|
|
manifest: &str,
|
|
declarations: &[String],
|
|
) -> Result<()> {
|
|
let dependencies = dependency_roots(manifest);
|
|
for declaration in declarations {
|
|
let surface = dependency_surface(declaration);
|
|
for root in path_roots(&surface).intersection(&dependencies) {
|
|
if root.starts_with("libremetaverse") {
|
|
continue;
|
|
}
|
|
return Err(MatrixError::new(format!(
|
|
"public Rust declaration exposes dependency root {root}: {crate_name}/{declaration}"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn dependency_surface(declaration: &str) -> String {
|
|
let source = declaration
|
|
.split_once('\t')
|
|
.map_or(declaration, |(_, source)| source);
|
|
if !source.starts_with("pub struct ") && !source.starts_with("pub union ") {
|
|
return source.to_owned();
|
|
}
|
|
|
|
let (opening, closing) = if let Some(opening) = source.find('{') {
|
|
(opening, '}')
|
|
} else if let Some(opening) = source.find('(') {
|
|
(opening, ')')
|
|
} else {
|
|
return source.to_owned();
|
|
};
|
|
let Some(closing) = source.rfind(closing).filter(|closing| *closing > opening) else {
|
|
return source.to_owned();
|
|
};
|
|
let mut public = source[..opening].to_owned();
|
|
for field in split_top_level(&source[opening + 1..closing], ',') {
|
|
let field = field.trim();
|
|
if field.starts_with("pub ") {
|
|
public.push(' ');
|
|
public.push_str(field);
|
|
}
|
|
}
|
|
public
|
|
}
|
|
|
|
fn split_top_level(value: &str, separator: char) -> Vec<&str> {
|
|
let mut fields = Vec::new();
|
|
let mut start = 0;
|
|
let mut depth = 0usize;
|
|
for (index, character) in value.char_indices() {
|
|
match character {
|
|
'<' | '(' | '[' | '{' => depth += 1,
|
|
'>' | ')' | ']' | '}' => depth = depth.saturating_sub(1),
|
|
candidate if candidate == separator && depth == 0 => {
|
|
fields.push(&value[start..index]);
|
|
start = index + character.len_utf8();
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
fields.push(&value[start..]);
|
|
fields
|
|
}
|
|
|
|
fn dependency_roots(manifest: &str) -> BTreeSet<String> {
|
|
let mut roots = BTreeSet::new();
|
|
let mut normal_dependencies = false;
|
|
for line in manifest.lines().map(str::trim) {
|
|
if line.starts_with('[') {
|
|
normal_dependencies = line == "[dependencies]"
|
|
|| (line.starts_with("[target.") && line.ends_with(".dependencies]"));
|
|
continue;
|
|
}
|
|
if !normal_dependencies || line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
if let Some((name, _)) = line.split_once('=') {
|
|
roots.insert(name.trim().replace('-', "_"));
|
|
}
|
|
}
|
|
roots
|
|
}
|
|
|
|
fn path_roots(value: &str) -> BTreeSet<String> {
|
|
let bytes = value.as_bytes();
|
|
let mut roots = BTreeSet::new();
|
|
let mut index = 0;
|
|
while index < bytes.len() {
|
|
if bytes[index].is_ascii_lowercase()
|
|
&& (index == 0
|
|
|| (!bytes[index - 1].is_ascii_alphanumeric()
|
|
&& bytes[index - 1] != b'_'
|
|
&& bytes[index - 1] != b':'))
|
|
{
|
|
let start = index;
|
|
index += 1;
|
|
while index < bytes.len()
|
|
&& (bytes[index].is_ascii_alphanumeric() || bytes[index] == b'_')
|
|
{
|
|
index += 1;
|
|
}
|
|
let mut separator = index;
|
|
while separator < bytes.len() && bytes[separator].is_ascii_whitespace() {
|
|
separator += 1;
|
|
}
|
|
if bytes.get(separator..separator + 2) == Some(b"::") {
|
|
roots.insert(value[start..index].to_owned());
|
|
}
|
|
} else {
|
|
index += 1;
|
|
}
|
|
}
|
|
roots
|
|
}
|
|
|
|
fn validate_non_exhaustive_errors(root: &Path) -> Result<()> {
|
|
for (relative, name) in REQUIRED_NON_EXHAUSTIVE_ERRORS {
|
|
let text = fs::read_to_string(root.join(relative))?;
|
|
let declaration = format!("pub enum {name}");
|
|
let Some(position) = text.find(&declaration) else {
|
|
return Err(MatrixError::new(format!(
|
|
"missing public error enum {name}"
|
|
)));
|
|
};
|
|
let prefix = &text[..position];
|
|
let attribute = prefix
|
|
.rsplit_once("#[non_exhaustive]")
|
|
.map(|(_, tail)| tail);
|
|
if attribute.is_none_or(|tail| tail.contains("pub enum")) {
|
|
return Err(MatrixError::new(format!(
|
|
"public error enum {name} must be non_exhaustive before release"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_thread_safe_traits(root: &Path) -> Result<()> {
|
|
for (relative, declaration) in REQUIRED_THREAD_SAFE_TRAITS {
|
|
if !fs::read_to_string(root.join(relative))?.contains(declaration) {
|
|
return Err(MatrixError::new(format!(
|
|
"public callback trait lacks its Send + Sync contract: {declaration}"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_features(name: &str, actual: &BTreeMap<String, Vec<String>>) -> Result<()> {
|
|
let expected = match name {
|
|
"libremetaverse" => BTreeMap::from([
|
|
("dds-bc67".to_owned(), vec!["dep:bcdec_rs".to_owned()]),
|
|
("default".to_owned(), vec!["dds-bc67".to_owned()]),
|
|
(
|
|
"jpeg2000".to_owned(),
|
|
vec!["libremetaverse-imaging/jpeg2000".to_owned()],
|
|
),
|
|
("vorbis".to_owned(), vec!["dep:vorbis_rs".to_owned()]),
|
|
]),
|
|
"libremetaverse-imaging" => BTreeMap::from([
|
|
("default".to_owned(), Vec::new()),
|
|
(
|
|
"jpeg2000".to_owned(),
|
|
vec!["dep:libremetaverse-openjpeg".to_owned()],
|
|
),
|
|
]),
|
|
"libremetaverse-imaging-skia" => BTreeMap::from([
|
|
("default".to_owned(), Vec::new()),
|
|
("skia".to_owned(), vec!["dep:skia-safe".to_owned()]),
|
|
]),
|
|
"libremetaverse-voice-webrtc" => BTreeMap::from([
|
|
("default".to_owned(), Vec::new()),
|
|
("real-audio".to_owned(), vec!["dep:cpal".to_owned()]),
|
|
]),
|
|
_ => BTreeMap::new(),
|
|
};
|
|
if actual != &expected {
|
|
return Err(MatrixError::new(format!(
|
|
"public feature surface changed for {name}: expected {expected:?}, found {actual:?}"
|
|
)));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn parse_features(manifest: &str) -> Result<BTreeMap<String, Vec<String>>> {
|
|
let mut features = BTreeMap::new();
|
|
let mut in_features = false;
|
|
for line in manifest.lines() {
|
|
let line = line.trim();
|
|
if line.starts_with('[') {
|
|
in_features = line == "[features]";
|
|
continue;
|
|
}
|
|
if !in_features || line.is_empty() || line.starts_with('#') {
|
|
continue;
|
|
}
|
|
let (name, values) = line
|
|
.split_once('=')
|
|
.ok_or_else(|| MatrixError::new("feature declaration lacks ="))?;
|
|
let values = serde_json::from_str::<Vec<String>>(values.trim())?;
|
|
if features.insert(name.trim().to_owned(), values).is_some() {
|
|
return Err(MatrixError::new("duplicate feature declaration"));
|
|
}
|
|
}
|
|
Ok(features)
|
|
}
|
|
|
|
fn crate_declarations(directory: &Path) -> Result<Vec<String>> {
|
|
let mut files = Vec::new();
|
|
collect_rs(&directory.join("src"), &mut files)?;
|
|
files.sort();
|
|
let mut declarations = Vec::new();
|
|
for file in files {
|
|
let relative = file.strip_prefix(directory).unwrap_or(&file);
|
|
for declaration in extract_public_declarations(&fs::read_to_string(&file)?) {
|
|
declarations.push(format!("{}\t{declaration}", relative.display()));
|
|
}
|
|
}
|
|
declarations.sort();
|
|
Ok(declarations)
|
|
}
|
|
|
|
fn collect_rs(directory: &Path, files: &mut Vec<PathBuf>) -> Result<()> {
|
|
for entry in fs::read_dir(directory)? {
|
|
let path = entry?.path();
|
|
if path.is_dir() {
|
|
collect_rs(&path, files)?;
|
|
} else if path.extension().and_then(|value| value.to_str()) == Some("rs") {
|
|
files.push(path);
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn extract_public_declarations(source: &str) -> Vec<String> {
|
|
let lines = source.lines().collect::<Vec<_>>();
|
|
let mut declarations = Vec::new();
|
|
let mut index = 0;
|
|
while index < lines.len() {
|
|
let trimmed = lines[index].trim_start();
|
|
if !trimmed.starts_with("pub ") {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
let whole_block = ["pub struct ", "pub enum ", "pub union ", "pub trait "]
|
|
.iter()
|
|
.any(|prefix| trimmed.starts_with(prefix));
|
|
let inline_module = trimmed.starts_with("pub mod ") && trimmed.contains('{');
|
|
let mut chunk = Vec::new();
|
|
let mut braces = 0isize;
|
|
let mut opened = false;
|
|
loop {
|
|
let line = lines[index].trim();
|
|
if !line.starts_with("///") && !line.starts_with("//") {
|
|
let selected = if !whole_block && !inline_module {
|
|
line.split_once('{')
|
|
.map_or(line, |(header, _)| header.trim())
|
|
} else if inline_module {
|
|
line.split_once('{')
|
|
.map_or(line, |(header, _)| header.trim())
|
|
} else {
|
|
line
|
|
};
|
|
if !selected.is_empty() {
|
|
chunk.push(selected);
|
|
}
|
|
}
|
|
if whole_block {
|
|
braces += brace_delta(line);
|
|
opened |= line.contains('{');
|
|
}
|
|
let finished = if whole_block {
|
|
(opened && braces == 0) || (!opened && line.ends_with(';'))
|
|
} else {
|
|
line.contains('{') || line.ends_with(';')
|
|
};
|
|
index += 1;
|
|
if finished || index == lines.len() {
|
|
break;
|
|
}
|
|
}
|
|
let normalized = chunk
|
|
.join(" ")
|
|
.split_whitespace()
|
|
.collect::<Vec<_>>()
|
|
.join(" ");
|
|
if !normalized.is_empty() {
|
|
declarations.push(normalized);
|
|
}
|
|
}
|
|
declarations
|
|
}
|
|
|
|
fn brace_delta(line: &str) -> isize {
|
|
let mut delta = 0;
|
|
let mut quoted = false;
|
|
let mut escaped = false;
|
|
let mut characters = line.chars().peekable();
|
|
while let Some(character) = characters.next() {
|
|
if !quoted && character == '/' && characters.peek() == Some(&'/') {
|
|
break;
|
|
}
|
|
if quoted {
|
|
if escaped {
|
|
escaped = false;
|
|
} else if character == '\\' {
|
|
escaped = true;
|
|
} else if character == '"' {
|
|
quoted = false;
|
|
}
|
|
continue;
|
|
}
|
|
match character {
|
|
'"' => quoted = true,
|
|
'{' => delta += 1,
|
|
'}' => delta -= 1,
|
|
_ => {}
|
|
}
|
|
}
|
|
delta
|
|
}
|
|
|
|
fn read_tsv(path: &Path) -> Result<(Vec<String>, Vec<Row>)> {
|
|
let text = fs::read_to_string(path)?;
|
|
let mut lines = text.lines();
|
|
let header = lines
|
|
.next()
|
|
.ok_or_else(|| MatrixError::new(format!("{} is empty", path.display())))?
|
|
.split('\t')
|
|
.map(str::to_owned)
|
|
.collect::<Vec<_>>();
|
|
let mut rows = Vec::new();
|
|
for line in lines.filter(|line| !line.is_empty()) {
|
|
let values = line.split('\t').collect::<Vec<_>>();
|
|
if values.len() != header.len() {
|
|
return Err(MatrixError::new(format!(
|
|
"{} contains a malformed row",
|
|
path.display()
|
|
)));
|
|
}
|
|
rows.push(
|
|
header
|
|
.iter()
|
|
.cloned()
|
|
.zip(values.into_iter().map(str::to_owned))
|
|
.collect(),
|
|
);
|
|
}
|
|
Ok((header, rows))
|
|
}
|
|
|
|
fn require_columns(header: &[String], required: &[&str], path: &str) -> Result<()> {
|
|
for field in required {
|
|
if !header.iter().any(|candidate| candidate == field) {
|
|
return Err(MatrixError::new(format!(
|
|
"{path} lacks required column {field}"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn validate_unique(rows: &[Row], key: &str, label: &str) -> Result<()> {
|
|
let mut values = BTreeSet::new();
|
|
for row in rows {
|
|
let value = field(row, key);
|
|
if value.is_empty() || !values.insert(value) {
|
|
return Err(MatrixError::new(format!(
|
|
"{label} values must be nonempty and unique: {value}"
|
|
)));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn field<'a>(row: &'a Row, name: &str) -> &'a str {
|
|
row.get(name).map_or("", String::as_str)
|
|
}
|
|
|
|
fn package_value(manifest: &str, key: &str) -> Option<String> {
|
|
let mut in_package = false;
|
|
for line in manifest.lines() {
|
|
let line = line.trim();
|
|
if line.starts_with('[') {
|
|
in_package = matches!(line, "[package]" | "[workspace.package]");
|
|
continue;
|
|
}
|
|
if in_package
|
|
&& let Some(value) = line.strip_prefix(&format!("{key} = \""))
|
|
&& let Some(value) = value.strip_suffix('"')
|
|
{
|
|
return Some(value.to_owned());
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn baseline_json(baseline: &ApiBaseline) -> Result<Vec<u8>> {
|
|
let mut bytes = serde_json::to_vec_pretty(baseline)?;
|
|
bytes.push(b'\n');
|
|
Ok(bytes)
|
|
}
|
|
|
|
fn sha256(bytes: &[u8]) -> String {
|
|
let digest = Sha256::digest(bytes);
|
|
let mut output = String::with_capacity(digest.len() * 2);
|
|
for byte in digest {
|
|
use std::fmt::Write as _;
|
|
write!(output, "{byte:02x}").expect("writing to a String cannot fail");
|
|
}
|
|
output
|
|
}
|
|
|
|
fn render_report(baseline: &ApiBaseline) -> String {
|
|
let crates = baseline
|
|
.crates
|
|
.iter()
|
|
.map(|surface| {
|
|
let features = if surface.features.is_empty() {
|
|
"none".to_owned()
|
|
} else {
|
|
surface
|
|
.features
|
|
.keys()
|
|
.cloned()
|
|
.collect::<Vec<_>>()
|
|
.join(", ")
|
|
};
|
|
format!(
|
|
"| `{}` | {} | {} | {} | {} | `{}` |",
|
|
surface.name,
|
|
surface.mapped_types,
|
|
surface.mapped_members,
|
|
surface.public_declarations,
|
|
features,
|
|
surface.declaration_sha256
|
|
)
|
|
})
|
|
.collect::<Vec<_>>()
|
|
.join("\n");
|
|
format!(
|
|
"# First-release API and SemVer audit\n\n\
|
|
Generated by `metacrate-ci-matrix api-baseline-write`; do not edit by hand.\n\n\
|
|
The compatibility source is pinned at [`{commit}`](https://github.com/cinderblocks/libremetaverse/tree/{commit}). \
|
|
The baseline version is `{release}`.\n\n\
|
|
## Coverage and quality decisions\n\n\
|
|
- Mapped/exported public types: **{types} / {types}**; unexplained mappings: **0**.\n\
|
|
- Mapped/exported public members: **{members} / {members}**; duplicate destinations: **0**.\n\
|
|
- Rust naming and ownership: mapped function/constant names follow Rust conventions and every member uses a reviewed ownership term.\n\
|
|
- External signature types: **{external}**; referenced support traits: **{support}**; accidental dependency types exposed by mapped signatures or public Rust declarations: **0**.\n\
|
|
- Intentional delegate APM replacements: **{differences}**; each uses a native callback and is compiled by the downstream fixture.\n\
|
|
- Descriptively named overloads: **{overloads}**; names remain fixed by the mapping ledger.\n\
|
|
- Native `async fn` mappings: **{async_functions}**; boxed object-safe trait futures with explicit `Send`: **{boxed}**.\n\
|
|
- Callback signatures requiring `Send + Sync`: **{callbacks}**; reviewed dyn-compatible thread-safe boundary traits: **{traits}** (`IGridClient`, `IBakingTextureProvider`, `ICurrentOutfitPolicy`, `IRlvActionCallbacks`, `IRlvQueryCallbacks`, and `IVoiceLogger`).\n\
|
|
- C# enum mappings: **{enums}** ({bitflags} unknown-bit-retaining flag types); stable public error enums marked `non_exhaustive`: **{errors}**.\n\n\
|
|
## Crate baselines\n\n\
|
|
| Crate | Mapped types | Mapped members | Rust declarations | Features | Declaration SHA-256 |\n\
|
|
| --- | ---: | ---: | ---: | --- | --- |\n\
|
|
{crates}\n\n\
|
|
The exact mapping, exported-marker, feature, and normalized Rust declaration hashes are stored in \
|
|
[`SEMVER-BASELINE.json`](SEMVER-BASELINE.json). A change must be reviewed and classified before the baseline is re-recorded.\n\n\
|
|
## Intentional Rust migration recipes\n\n\
|
|
- C# `PascalCase` methods/properties become Rust `snake_case`; overloads use the ledger's stable descriptive suffix, never argument-count dispatch.\n\
|
|
- C# reference ownership becomes owned values, shared or mutable borrows, `Arc`-backed managers, and explicit optional values as recorded per member.\n\
|
|
- `Task<T>` becomes `async fn -> Result<T, Error>`; object-safe async traits return `Pin<Box<dyn Future<Output = Result<...>> + Send + '_>>`.\n\
|
|
- .NET events become `subscribe_*` calls returning an owned `Subscription`; callbacks are `Send + Sync` and dispatch outside internal locks.\n\
|
|
- `IDisposable` becomes explicit idempotent shutdown plus `Drop`; cancellation remains a typed `Error::Cancelled`.\n\
|
|
- Closed C# enums retain fixed discriminants and are SemVer-breaking to extend; flags retain unknown bits, while public Rust error enums are `non_exhaustive`.\n\
|
|
- Optional native backends remain feature-gated (`jpeg2000`, `skia`, `vorbis`, `real-audio`); default and empty-default sets are part of the baseline.\n\
|
|
- Capability HTTP injection uses project-owned `HttpMessageHandler`, `CapsHttpLimits`, and `with_native_transport`; the internal `reqwest` client is not part of the public surface.\n\
|
|
- External C# signature types map to core/std, adopted cross-platform crates, or project-owned boundary types; no third-party crate path leaks through mapped signatures or public Rust declarations.\n\n\
|
|
## Compile consumers\n\n\
|
|
- `tests/api-compile` compiles every mapped type/member from a standalone downstream workspace.\n\
|
|
- `tests/semver-port` is a runnable representative port covering lifecycle, value types, structured data, imaging, RLV, cancellation, dyn trait, and `Send`/`Sync` contracts without network access.\n",
|
|
commit = baseline.upstream_commit,
|
|
release = baseline.release,
|
|
types = baseline.mapped_public_types,
|
|
members = baseline.mapped_members,
|
|
external = baseline.external_signature_types,
|
|
support = baseline.support_traits,
|
|
differences = baseline.intentional_differences,
|
|
overloads = baseline.descriptive_overloads,
|
|
async_functions = baseline.async_functions,
|
|
boxed = baseline.send_boxed_futures,
|
|
callbacks = baseline.thread_safe_callbacks,
|
|
traits = baseline.thread_safe_dyn_traits,
|
|
enums = baseline.enum_types,
|
|
bitflags = baseline.bitflag_types,
|
|
errors = baseline.non_exhaustive_error_enums,
|
|
)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn declaration_extractor_ignores_function_bodies_and_private_items() {
|
|
let source = r"
|
|
struct Private;
|
|
pub struct Public { pub value: i32, hidden: bool }
|
|
impl Public {
|
|
pub fn value(
|
|
&self,
|
|
) -> i32 { self.value }
|
|
fn hidden(&self) -> bool { self.hidden }
|
|
}
|
|
";
|
|
let declarations = extract_public_declarations(source);
|
|
assert_eq!(declarations.len(), 2);
|
|
assert!(declarations[0].starts_with("pub struct Public"));
|
|
assert_eq!(declarations[1], "pub fn value( &self, ) -> i32");
|
|
assert!(!declarations.join(" ").contains("self.value"));
|
|
}
|
|
|
|
#[test]
|
|
fn path_root_parser_ignores_nested_modules() {
|
|
let roots =
|
|
path_roots("pub fn f(value: libremetaverse::packets::Packet) -> std::io::Result<()>;");
|
|
assert_eq!(
|
|
roots,
|
|
BTreeSet::from(["libremetaverse".to_owned(), "std".to_owned()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dependency_root_parser_covers_normal_target_and_renamed_crates() {
|
|
let manifest = r#"
|
|
[dependencies]
|
|
normal-crate = "1"
|
|
[dev-dependencies]
|
|
ignored_dev = "1"
|
|
[target.'cfg(unix)'.dependencies]
|
|
target-crate = "1"
|
|
[build-dependencies]
|
|
ignored_build = "1"
|
|
"#;
|
|
assert_eq!(
|
|
dependency_roots(manifest),
|
|
BTreeSet::from(["normal_crate".to_owned(), "target_crate".to_owned()])
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn dependency_surface_excludes_private_but_retains_public_struct_fields() {
|
|
let declaration = "src/example.rs\tpub struct Example<T> { private: dep::Hidden<T>, pub visible: dep::Visible<(T, T)>, pub(crate) scoped: dep::Scoped, }";
|
|
assert_eq!(
|
|
dependency_surface(declaration),
|
|
"pub struct Example<T> pub visible: dep::Visible<(T, T)>"
|
|
);
|
|
assert_eq!(
|
|
dependency_surface("src/example.rs\tpub struct Tuple(pub dep::Visible, dep::Hidden);"),
|
|
"pub struct Tuple pub dep::Visible"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn naming_audit_accepts_rust_conventions_and_rejects_pascal_case_functions() {
|
|
validate_rust_naming(
|
|
"good",
|
|
"pub async fn request_asset_2() -> Result<(), Error>",
|
|
)
|
|
.unwrap();
|
|
validate_rust_naming("constant", "pub const MAX_BYTES: usize").unwrap();
|
|
assert!(validate_rust_naming("bad", "pub fn RequestAsset() -> bool").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn repository_surface_has_reviewed_totals() {
|
|
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("../..");
|
|
let baseline = inspect(&root).unwrap();
|
|
assert_eq!(baseline.mapped_public_types, 3_066);
|
|
assert_eq!(baseline.mapped_members, 30_789);
|
|
assert_eq!(baseline.unexplained_mappings, 0);
|
|
assert_eq!(baseline.accidental_public_dependency_types, 0);
|
|
}
|
|
}
|