Add complete downstream API gate
Some checks failed
Rust API gates / api-gates (push) Has been cancelled

This commit is contained in:
2026-08-08 13:11:55 +02:00
parent 643762fd1d
commit c8a953996e
16 changed files with 183714 additions and 295 deletions

165
tools/check_api_coverage.py Normal file
View File

@@ -0,0 +1,165 @@
#!/usr/bin/env python3
"""Audit catalog, mapping, exported shims, and the downstream compile fixture."""
from __future__ import annotations
import argparse
import csv
import json
import re
from collections import Counter
from pathlib import Path
import generate_api_shims as shims
import generate_rust_mapping as mapping
ROOT = Path(__file__).resolve().parents[1]
REPORT = ROOT / "api/API-COVERAGE.md"
FIXTURE = ROOT / "tests/api-compile/src/lib.rs"
FORBIDDEN = {
"ShimValue": r"\bShimValue\b",
"dynamic argument bag": r"\b(?:DynamicArgumentBag|ArgumentBag)\b",
"invented variadic": r"\b(?:Variadic|VarArgs)\b|\.\.\.",
"erased Any argument vector": r"Vec\s*<\s*Box\s*<\s*dyn\s+(?:std::any::)?Any",
}
def require_exact(label: str, expected: set[str], actual: Counter[str]) -> None:
actual_ids = set(actual)
missing = expected - actual_ids
stale = actual_ids - expected
duplicates = sorted(item for item, count in actual.items() if count != 1)
if missing or stale or duplicates:
raise ValueError(
f"{label}: missing={len(missing)}, stale={len(stale)}, duplicates={len(duplicates)}"
)
def matches(paths: list[Path], pattern: str) -> Counter[str]:
found: Counter[str] = Counter()
regex = re.compile(pattern, re.MULTILINE)
for path in paths:
found.update(regex.findall(path.read_text()))
return found
def audit() -> str:
catalog = json.loads(mapping.CATALOG.read_text())
assemblies = {assembly["identity"]["name"]: assembly for assembly in catalog["assemblies"]}
catalog_types = {
item["doc_id"]
for assembly in catalog["assemblies"]
for item in assembly["types"]
}
catalog_members = {
member["doc_id"]
for assembly in catalog["assemblies"]
for item in assembly["types"]
for member in item["members"]
}
external_types = {item["doc_id"] for item in catalog["external_types"]}
support_types = {f"T:{name}" for name in mapping.SUPPORT_TYPES}
type_rows = list(csv.DictReader(mapping.TYPE_LEDGER.open(), delimiter="\t"))
member_rows = list(csv.DictReader(mapping.MEMBER_LEDGER.open(), delimiter="\t"))
require_exact(
"type mapping",
catalog_types | external_types | support_types,
Counter(row["csharp_type_id"] for row in type_rows),
)
require_exact(
"member mapping",
catalog_members,
Counter(row["csharp_id"] for row in member_rows),
)
duplicate_destinations = [
item
for item, count in Counter(row["rust_item_path"] for row in member_rows).items()
if count != 1
]
if duplicate_destinations:
raise ValueError(f"duplicate Rust member destinations: {len(duplicate_destinations)}")
generated = [shims.MAIN_TARGET, *shims.TARGETS.values()]
require_exact(
"exported types",
catalog_types,
matches(generated, r"^\s*/// C# type: `(T:.+)`\.$"),
)
require_exact(
"exported members",
catalog_members,
matches(generated, r"^\s*/// C# member: `(.+)`\.$"),
)
require_exact(
"fixture types",
catalog_types,
matches([FIXTURE], r"^\s*// C# type: `(T:.+)`\.$"),
)
require_exact(
"fixture members",
catalog_members,
matches([FIXTURE], r"^\s*// C# member: `(.+)`\.$"),
)
fixture_text = FIXTURE.read_text()
if len(re.findall(r"^fn type_", fixture_text, re.MULTILINE)) != len(catalog_types):
raise ValueError("fixture type probe count does not match the catalog")
if len(re.findall(r"^fn member_", fixture_text, re.MULTILINE)) != len(catalog_members):
raise ValueError("fixture member probe count does not match the catalog")
scan_text = "\n".join(
path.read_text() for path in [*generated, FIXTURE, mapping.MEMBER_LEDGER]
)
forbidden_hits = [name for name, pattern in FORBIDDEN.items() if re.search(pattern, scan_text)]
if forbidden_hits:
raise ValueError("forbidden erased shim patterns: " + ", ".join(forbidden_hits))
mapping.validate_generated_shims()
lines = [
"# Complete API gate coverage",
"",
"Generated by `python3 tools/check_api_coverage.py --write`; do not edit by hand.",
"",
"| Assembly | Catalog types | Mapped/exported/fixture types | Catalog members | Mapped/exported/fixture members |",
"|---|---:|---:|---:|---:|",
]
for name, assembly in assemblies.items():
type_count = len(assembly["types"])
member_count = sum(len(item["members"]) for item in assembly["types"])
lines.append(
f"| `{name}` | {type_count:,} | {type_count:,} | {member_count:,} | {member_count:,} |"
)
lines += [
f"| **Total** | **{len(catalog_types):,}** | **{len(catalog_types):,}** | **{len(catalog_members):,}** | **{len(catalog_members):,}** |",
"",
"## Exclusions and boundary records",
"",
"- Public catalog type exclusions: **0**.",
"- Public catalog member exclusions: **0**.",
f"- External signature types: **{len(external_types):,}** mapped to core/std, adopted cross-platform crates, or project-owned boundary types; their external member APIs are intentionally not copied.",
f"- Referenced non-public support traits: **{len(support_types):,}** mapped because public inheritance signatures require them.",
f"- Intentional delegate APM differences: **{sum(row['status'] == 'intentional_difference' for row in member_rows):,}**; every replacement is exported and compiled by the fixture.",
"- Forbidden erased shim patterns: **0** (`ShimValue`, dynamic argument bags, invented variadics, or `Vec<Box<dyn Any>>` argument bags).",
"",
"The fixture is a standalone Rust workspace with path dependencies only. Building it requires neither .NET nor compiled LibreMetaverse assemblies.",
"",
]
return "\n".join(lines)
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--write", action="store_true")
args = parser.parse_args()
report = audit()
if args.write:
REPORT.write_text(report)
print(f"generated {REPORT.relative_to(ROOT)}")
elif not REPORT.exists() or REPORT.read_text() != report:
raise SystemExit(f"stale coverage report: {REPORT.relative_to(ROOT)}")
else:
print("API coverage matches the catalog, mappings, exports, and fixture")
if __name__ == "__main__":
main()

View File

@@ -17,6 +17,7 @@ ROOT = Path(__file__).resolve().parents[1]
CATALOG = ROOT / "api/public-api.json"
LEDGER = ROOT / "api/RUST-MAPPING.tsv"
COVERAGE = ROOT / "api/SHIM-COVERAGE.md"
COMPILE_FIXTURE = ROOT / "tests/api-compile/src/lib.rs"
TARGETS = {
"LibreMetaverse.Imaging.Abstractions": ROOT / "crates/libremetaverse-imaging/src/generated.rs",
@@ -327,6 +328,124 @@ def generic_suffix(names: list[str]) -> str:
return "<" + ", ".join(names) + ">" if names else ""
def generic_declarations(item: dict, mapper: mapping.Mapper, owner_names: list[str]) -> list[str]:
generics = set(owner_names) | set(generic_names(item))
declarations = []
for parameter in item.get("generic_parameters", []):
bounds = [mapper.constraint(constraint, generics) for constraint in parameter.get("constraints", [])]
if parameter.get("default_constructor"):
bounds.append("Default")
declarations.append(parameter["name"] + ((": " + " + ".join(bounds)) if bounds else ""))
return declarations
def function_name(signature: str) -> str:
match = re.search(r"\bfn ([A-Za-z_][A-Za-z0-9_]*)", signature)
if not match:
raise ValueError(f"invalid mapped function signature: {signature}")
return match.group(1)
def render_compile_fixture(
catalog: dict,
type_by_id: dict[str, dict[str, str]],
member_rows: dict[str, dict[str, str]],
mapper: mapping.Mapper,
) -> str:
lines = [
"// @generated by tools/generate_api_shims.py; do not edit by hand.",
"#![allow(clippy::all, clippy::pedantic, dead_code, unused_variables)]",
"#![allow(non_camel_case_types, non_snake_case)]",
"",
'fn argument<T>() -> T { panic!("compile-only API fixture") }',
"",
]
type_index = member_index = 0
for assembly in catalog["assemblies"]:
for item in assembly["types"]:
type_index += 1
type_row = type_by_id[item["doc_id"]]
owner_names = generic_names(item)
owner_path = type_row["rust_path"]
owner_type = owner_path + generic_suffix(owner_names)
owner_expression = owner_path + (("::<" + ", ".join(owner_names) + ">") if owner_names else "")
trait = item["kind"] == "interface"
type_parameters = [*owner_names]
if trait:
type_parameters.append(f"__Probe: {owner_type}")
type_argument = "&__Probe"
else:
type_argument = f"&{owner_type}"
declarations = generic_suffix(type_parameters)
lines += [
f"// C# type: `{item['doc_id']}`.",
f"fn type_{type_index:04}{declarations}(_: {type_argument}) {{}}",
"",
]
for member in item["members"]:
member_index += 1
row = member_rows[member["doc_id"]]
method_names = generic_names(member)
parameters = [*owner_names, *generic_declarations(member, mapper, owner_names)]
if trait:
parameters.append(f"__Probe: {owner_type}")
declaration = generic_suffix(parameters)
instance = not member.get("static") and member["kind"] not in {
"constructor",
"constant",
"enum_value",
}
value_parameter = (
f"value: &mut {'__Probe' if trait else owner_type}" if instance else ""
)
body = []
if member["kind"] == "field" and not member.get("static"):
field = re.fullmatch(r"pub ([^:]+): .+", row["rust_signature"])
if not field:
raise ValueError(f"invalid mapped field signature: {row['rust_signature']}")
body.append(f"let _ = &value.{field.group(1)};")
elif member["kind"] in {"constant", "enum_value"}:
pattern = r"pub const ([A-Za-z_][A-Za-z0-9_]*)" if member["kind"] == "constant" else r"([A-Za-z_][A-Za-z0-9_]*):"
name = re.match(pattern, row["rust_signature"])
if not name:
raise ValueError(f"invalid mapped value signature: {row['rust_signature']}")
body.append(f"let _ = {owner_expression}::{name.group(1)};")
else:
signatures = row["rust_signature"].split(" ; ")
property_argument_counts = []
if member["kind"] in {"property", "indexer"}:
accessors = {accessor["kind"] for accessor in member.get("accessors", [])}
if "get" in accessors:
property_argument_counts.append(len(member.get("parameters", [])))
if "set" in accessors:
property_argument_counts.append(len(member.get("parameters", [])) + 1)
for signature_index, signature in enumerate(signatures):
name = function_name(signature)
generic_call = (("::<" + ", ".join(method_names) + ">") if method_names else "")
if instance:
target = f"value.{name}{generic_call}"
elif trait:
target = f"<__Probe as {owner_type}>::{name}{generic_call}"
else:
target = f"{owner_expression}::{name}{generic_call}"
argument_count = len(member.get("parameters", []))
if member["kind"] == "event":
argument_count = 1
elif property_argument_counts:
argument_count = property_argument_counts[signature_index]
arguments = ", ".join("argument()" for _ in range(argument_count))
body.append(f"let _ = {target}({arguments});")
lines += [
f"// C# member: `{member['doc_id']}`.",
f"fn member_{member_index:05}{declaration}({value_parameter}) {{",
*[f" {line}" for line in body],
"}",
"",
]
return format_rust("\n".join(lines).rstrip() + "\n")
def flags_enum(item: dict) -> bool:
return any(attribute["type"] == "System.FlagsAttribute" for attribute in item.get("attributes", []))
@@ -603,6 +722,7 @@ def generate_sources(catalog: dict) -> tuple[dict[Path, str], dict[str, tuple[in
)
outputs[TARGETS.get(name, MAIN_TARGET)] = source
coverage[name] = (len(selected), sum(len(item["members"]) for item in selected), len(selected) == len(assembly["types"]))
outputs[COMPILE_FIXTURE] = render_compile_fixture(catalog, type_by_id, member_rows, mapper)
return outputs, coverage

View File

@@ -170,6 +170,9 @@ SUPPORT_TYPES = {
MEMBER_RETURN_OVERRIDES = {
"M:LibreMetaverse.Caps.Capabilities": "Vec<String>",
}
TYPE_NAME_OVERRIDES = {
"T:LibreMetaverse.LslTools.Error": "LslError",
}
BOXED_FIELD_IDS = {
"F:LibreMetaverse.Caps.Simulator",
"F:LibreMetaverse.Simulator.Caps",
@@ -296,7 +299,9 @@ def declared_type_path(assembly: str, item: dict) -> tuple[str, str, str]:
base, _, _ = type_parts(item["signature"])
tail = base[len(item["namespace"]) :].lstrip(".")
# Flatten nested type ownership into the type name; the containing type keeps paths unique.
type_name = "".join(pascal(part) for part in tail.split("."))
type_name = TYPE_NAME_OVERRIDES.get(
item["doc_id"], "".join(pascal(part) for part in tail.split("."))
)
module = "::".join(modules)
path = "::".join([crate.replace("-", "_"), *modules, type_name])
return crate, module, path
@@ -639,7 +644,9 @@ def member_signature(mapper: Mapper, owner: dict, item: dict, rust_name: str) ->
return " ; ".join(pieces), ",".join([*self_ownership, *own]) or "none", asyncness, error_model, "accessor_methods"
if kind == "event":
event_type = mapper.type(item["type"], item.get("nullability"), generics)
return f"pub fn subscribe_{rust_name}(&self, handler: {event_type}) -> libremetaverse_types::compat::Subscription", "stored_callback", asyncness, error_model, "subscription_guard"
arguments = f"handler: {event_type}" if item.get("static") else f"&self, handler: {event_type}"
ownership = "stored_callback" if item.get("static") else "shared_self,stored_callback"
return f"pub fn subscribe_{rust_name}({arguments}) -> libremetaverse_types::compat::Subscription", ownership, asyncness, error_model, "subscription_guard"
self_argument, self_ownership = method_receiver(owner, item)
arguments = ", ".join(filter(None, [self_argument, parameters]))
ownership = ",".join(([self_ownership] if self_ownership != "none" else []) + own) or "none"