Add complete downstream API gate
Some checks failed
Rust API gates / api-gates (push) Has been cancelled
Some checks failed
Rust API gates / api-gates (push) Has been cancelled
This commit is contained in:
165
tools/check_api_coverage.py
Normal file
165
tools/check_api_coverage.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user