771 lines
34 KiB
Python
771 lines
34 KiB
Python
#!/usr/bin/env python3
|
|
"""Generate traceable Rust shims and test/sample parity ledgers from LibreMetaverse.
|
|
|
|
This is deliberately a source inventory generator, not a C# parser. It handles
|
|
the declaration and NUnit forms present in the pinned upstream checkout and
|
|
fails loudly when its expected counts drift.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import keyword
|
|
import re
|
|
import subprocess
|
|
import tempfile
|
|
from collections import Counter, defaultdict
|
|
from dataclasses import dataclass, field
|
|
from pathlib import Path
|
|
|
|
UPSTREAM_COMMIT = "2aa70bb68513b39795da5d13c88f31b86e85a3ba"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Project:
|
|
source: str
|
|
crate: str
|
|
namespace: str
|
|
|
|
|
|
PROJECTS = (
|
|
Project("LibreMetaverse.Types", "libremetaverse-types", "LibreMetaverse"),
|
|
Project("LibreMetaverse.StructuredData", "libremetaverse-structured-data", "LibreMetaverse.StructuredData"),
|
|
Project("LibreMetaverse.Imaging.Abstractions", "libremetaverse-imaging", "LibreMetaverse.Imaging"),
|
|
Project("LibreMetaverse.Imaging.Skia", "libremetaverse-imaging-skia", "LibreMetaverse.Imaging.Skia"),
|
|
Project("PrimMesher", "libremetaverse-prim-mesher", "LibreMetaverse.PrimMesher"),
|
|
Project("LibreMetaverse", "libremetaverse", "LibreMetaverse"),
|
|
Project("LibreMetaverse.Rendering.Simple", "libremetaverse-rendering-simple", "LibreMetaverse.Rendering"),
|
|
Project("LibreMetaverse.Rendering.MeshFoundry", "libremetaverse-rendering-mesh-foundry", "LibreMetaverse.Rendering"),
|
|
Project("LibreMetaverse.LslTools", "metacrate-lsl-tools", "LibreMetaverse.LslTools"),
|
|
Project("LibreMetaverse.RLV", "libremetaverse-rlv", "LibreMetaverse.RLV"),
|
|
Project("LibreMetaverse.Utilities", "libremetaverse-utilities", "LibreMetaverse.Utilities"),
|
|
Project("LibreMetaverse.Voice.Vivox", "libremetaverse-voice-vivox", "LibreMetaverse.Voice.Vivox"),
|
|
Project("LibreMetaverse.Voice.WebRTC", "libremetaverse-voice-webrtc", "LibreMetaverse.Voice.WebRTC"),
|
|
)
|
|
|
|
TYPE_RE = re.compile(
|
|
r"^\s*public\s+(?:(?:static|abstract|sealed|partial|readonly|unsafe|ref|new)\s+)*"
|
|
r"(?P<kind>class|struct|interface|enum|delegate|record(?:\s+(?:class|struct))?)\s+"
|
|
r"(?P<name>@?[A-Za-z_][A-Za-z0-9_]*)(?P<generic><[^>{}()]+>)?",
|
|
re.MULTILINE,
|
|
)
|
|
NAMESPACE_RE = re.compile(r"^\s*namespace\s+([A-Za-z_][A-Za-z0-9_.]*)", re.MULTILINE)
|
|
TEST_ATTR_RE = re.compile(r"\[(Test|TestCase)(?:\((.*?)\))?\]", re.DOTALL)
|
|
CATEGORY_RE = re.compile(r'\[Category\("([^"]+)"\)\]')
|
|
METHOD_RE = re.compile(
|
|
r"\b(?:public|internal)\s+(?:static\s+)?(?:async\s+)?"
|
|
r"(?:void|Task(?:\s*<[^>]+>)?|ValueTask(?:\s*<[^>]+>)?)\s+"
|
|
r"([A-Za-z_][A-Za-z0-9_]*)\s*\(",
|
|
re.DOTALL,
|
|
)
|
|
CLASS_RE = re.compile(
|
|
r"\b(?:(?:public|internal|private|protected|sealed|partial|abstract|static)\s+)*"
|
|
r"class\s+([A-Za-z_][A-Za-z0-9_]*)"
|
|
)
|
|
PARITY_MARKER_RE = re.compile(
|
|
r"^// parity-case: (?P<id>\S+) (?P<body_sha256>[0-9a-f]{64}) "
|
|
r"(?P<status>translated|ignored-live|benchmark)$",
|
|
re.MULTILINE,
|
|
)
|
|
RUST_TEST_RE = re.compile(r"(?:#\[[^\]]+\]\s*)*fn\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(", re.MULTILINE)
|
|
FORBIDDEN_REVIEW_BODY_RE = re.compile(
|
|
r"\bpending\s*\(|\bassert_[A-Za-z0-9_]*case\s*\(|"
|
|
r"\b(?:todo|unimplemented|unimplemented_api)\s*!|\bnot_implemented\s*\("
|
|
)
|
|
EXPECTED_TESTS = 1289
|
|
PARITY_FILES = (
|
|
Path("tests/compat/tests/generated_parity.rs"),
|
|
Path("tests/PARITY.md"),
|
|
Path("tests/upstream-tests.json"),
|
|
)
|
|
|
|
|
|
def snake(name: str) -> str:
|
|
name = name.lstrip("@").replace("-", "_")
|
|
name = re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name)
|
|
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name).lower()
|
|
name = re.sub(r"[^a-z0-9_]", "_", name)
|
|
if not name or name[0].isdigit():
|
|
name = f"item_{name}"
|
|
if keyword.iskeyword(name) or name in {"crate", "self", "super", "type", "match", "mod", "move", "ref", "use", "where", "loop", "async", "await", "dyn"}:
|
|
name += "_"
|
|
return name
|
|
|
|
|
|
def rust_type_name(name: str) -> str:
|
|
name = name.lstrip("@")
|
|
replacements = {
|
|
"Self": "SelfType",
|
|
"box": "BoxShim",
|
|
"type": "TypeShim",
|
|
}
|
|
if name in replacements:
|
|
return replacements[name]
|
|
return re.sub(r"[^A-Za-z0-9_]", "_", name)
|
|
|
|
|
|
def source_files(root: Path) -> list[Path]:
|
|
return sorted(p for p in root.rglob("*.cs") if "obj" not in p.parts and "bin" not in p.parts)
|
|
|
|
|
|
def mask_csharp_comments(text: str) -> str:
|
|
"""Blank comments without shifting source offsets or touching string literals."""
|
|
masked = list(text)
|
|
index = 0
|
|
state = "code"
|
|
while index < len(text):
|
|
char = text[index]
|
|
following = text[index + 1] if index + 1 < len(text) else ""
|
|
if state == "code":
|
|
if char == '"':
|
|
state = "string"
|
|
elif char == "'":
|
|
state = "char"
|
|
elif char == "/" and following == "/":
|
|
masked[index] = masked[index + 1] = " "
|
|
index += 1
|
|
state = "line_comment"
|
|
elif char == "/" and following == "*":
|
|
masked[index] = masked[index + 1] = " "
|
|
index += 1
|
|
state = "block_comment"
|
|
elif state in {"string", "char"}:
|
|
if char == "\\":
|
|
index += 1
|
|
elif (state == "string" and char == '"') or (state == "char" and char == "'"):
|
|
state = "code"
|
|
elif state == "line_comment":
|
|
if char == "\n":
|
|
state = "code"
|
|
else:
|
|
masked[index] = " "
|
|
elif state == "block_comment":
|
|
if char == "*" and following == "/":
|
|
masked[index] = masked[index + 1] = " "
|
|
index += 1
|
|
state = "code"
|
|
elif char != "\n":
|
|
masked[index] = " "
|
|
index += 1
|
|
return "".join(masked)
|
|
|
|
|
|
def mask_rust_noncode(text: str) -> str:
|
|
"""Blank Rust comments and string/character contents without shifting offsets."""
|
|
masked = list(text)
|
|
index = 0
|
|
state = "code"
|
|
block_depth = 0
|
|
raw_hashes = 0
|
|
while index < len(text):
|
|
char = text[index]
|
|
following = text[index + 1] if index + 1 < len(text) else ""
|
|
if state == "code":
|
|
raw = re.match(r'(?:b|c)?r(#{0,255})"', text[index:])
|
|
if raw:
|
|
raw_hashes = len(raw.group(1))
|
|
for offset in range(len(raw.group(0))):
|
|
masked[index + offset] = " "
|
|
index += len(raw.group(0)) - 1
|
|
state = "raw_string"
|
|
elif char == '"':
|
|
masked[index] = " "
|
|
state = "string"
|
|
elif char == "'" and re.match(r"'(?:\\.|[^\\'\n])'", text[index:]):
|
|
masked[index] = " "
|
|
state = "char"
|
|
elif char == "/" and following == "/":
|
|
masked[index] = masked[index + 1] = " "
|
|
index += 1
|
|
state = "line_comment"
|
|
elif char == "/" and following == "*":
|
|
masked[index] = masked[index + 1] = " "
|
|
index += 1
|
|
block_depth = 1
|
|
state = "block_comment"
|
|
elif state in {"string", "char"}:
|
|
masked[index] = " "
|
|
if char == "\\" and index + 1 < len(text):
|
|
masked[index + 1] = " "
|
|
index += 1
|
|
elif (state == "string" and char == '"') or (state == "char" and char == "'"):
|
|
state = "code"
|
|
elif state == "raw_string":
|
|
masked[index] = " "
|
|
ending = '"' + "#" * raw_hashes
|
|
if text.startswith(ending, index):
|
|
for offset in range(len(ending)):
|
|
masked[index + offset] = " "
|
|
index += len(ending) - 1
|
|
state = "code"
|
|
elif state == "line_comment":
|
|
if char == "\n":
|
|
state = "code"
|
|
else:
|
|
masked[index] = " "
|
|
elif state == "block_comment":
|
|
masked[index] = " "
|
|
if char == "/" and following == "*":
|
|
masked[index + 1] = " "
|
|
index += 1
|
|
block_depth += 1
|
|
elif char == "*" and following == "/":
|
|
masked[index + 1] = " "
|
|
index += 1
|
|
block_depth -= 1
|
|
if block_depth == 0:
|
|
state = "code"
|
|
index += 1
|
|
return "".join(masked)
|
|
|
|
|
|
def rust_test_body(text: str, rust_test: re.Match[str]) -> str:
|
|
masked = mask_rust_noncode(text)
|
|
brace = masked.find("{", rust_test.end())
|
|
if brace < 0:
|
|
raise RuntimeError(f"Rust test {rust_test.group(1)} has no body")
|
|
depth = 0
|
|
for index in range(brace, len(masked)):
|
|
if masked[index] == "{":
|
|
depth += 1
|
|
elif masked[index] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
return text[brace + 1 : index]
|
|
raise RuntimeError(f"Rust test {rust_test.group(1)} has an unterminated body")
|
|
|
|
|
|
def validate_reviewed_body(path: Path, rust_test: re.Match[str], body: str) -> str:
|
|
code = mask_rust_noncode(body).strip()
|
|
if not code:
|
|
raise RuntimeError(f"Reviewed parity test has an empty body: {path}:{rust_test.group(1)}")
|
|
forbidden = FORBIDDEN_REVIEW_BODY_RE.search(code)
|
|
if forbidden:
|
|
raise RuntimeError(
|
|
f"Reviewed parity test uses forbidden placeholder/dispatcher `{forbidden.group(0)}`: "
|
|
f"{path}:{rust_test.group(1)}"
|
|
)
|
|
if not re.search(r"\bassert(?:_[A-Za-z0-9_]+)?!|\.expect\s*\(|\.unwrap(?:_err)?\s*\(|\?", code):
|
|
raise RuntimeError(
|
|
f"Reviewed parity test has no explicit assertion or checked API result: "
|
|
f"{path}:{rust_test.group(1)}"
|
|
)
|
|
return hashlib.sha256(body.encode()).hexdigest()
|
|
|
|
|
|
def namespace_at(text: str, position: int) -> str:
|
|
matches = [m for m in NAMESPACE_RE.finditer(text) if m.start() <= position]
|
|
return matches[-1].group(1) if matches else ""
|
|
|
|
|
|
def generic_arity(generic: str | None) -> int:
|
|
if not generic:
|
|
return 0
|
|
return len([p for p in generic[1:-1].split(",") if p.strip()])
|
|
|
|
|
|
@dataclass
|
|
class ModuleNode:
|
|
types: dict[str, tuple[str, int, str]] = field(default_factory=dict)
|
|
children: dict[str, "ModuleNode"] = field(default_factory=dict)
|
|
|
|
|
|
def add_type(root: ModuleNode, modules: list[str], name: str, kind: str, arity: int, csharp_name: str) -> None:
|
|
node = root
|
|
for module in modules:
|
|
node = node.children.setdefault(module, ModuleNode())
|
|
node.types.setdefault(name, (kind, arity, csharp_name))
|
|
|
|
|
|
def render_type(name: str, kind: str, arity: int, csharp_name: str, indent: str) -> list[str]:
|
|
rust_name = rust_type_name(name)
|
|
kind_label = kind.replace(" ", "_")
|
|
if kind == "interface":
|
|
params = "" if arity == 0 else "<" + ", ".join(f"T{i} = ()" for i in range(arity)) + ">"
|
|
return [
|
|
f"{indent}/// Shim for C# `{csharp_name}`.",
|
|
f"{indent}pub trait {rust_name}{params} {{",
|
|
f"{indent} const CSHARP_NAME: &'static str = \"{csharp_name}\";",
|
|
f"{indent}}}",
|
|
]
|
|
params = "" if arity == 0 else "<" + ", ".join(f"T{i} = ()" for i in range(arity)) + ">"
|
|
marker = "" if arity == 0 else "(" + "std::marker::PhantomData<(" + ", ".join(f"T{i}" for i in range(arity)) + ",)>" + ")"
|
|
declaration = f"pub struct {rust_name}{params}" + (";" if not marker else f"{marker};")
|
|
return [
|
|
f"{indent}/// `{kind}` shim for C# `{csharp_name}`; members remain intentionally unimplemented.",
|
|
f"{indent}#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]",
|
|
f"{indent}{declaration}",
|
|
f"{indent}impl{('<' + ', '.join(f'T{i}' for i in range(arity)) + '>') if arity else ''} {rust_name}{('<' + ', '.join(f'T{i}' for i in range(arity)) + '>') if arity else ''} {{",
|
|
f"{indent} pub const CSHARP_NAME: &'static str = \"{csharp_name}\";",
|
|
f"{indent} pub const CSHARP_KIND: &'static str = \"{kind_label}\";",
|
|
f"{indent}}}",
|
|
]
|
|
|
|
|
|
def render_node(node: ModuleNode, indent: str = "") -> list[str]:
|
|
lines: list[str] = []
|
|
for name, (kind, arity, csharp_name) in sorted(node.types.items()):
|
|
lines.extend(render_type(name, kind, arity, csharp_name, indent))
|
|
lines.append("")
|
|
for name, child in sorted(node.children.items()):
|
|
lines.append(f"{indent}pub mod {name} {{")
|
|
lines.extend(render_node(child, indent + " "))
|
|
lines.append(f"{indent}}}")
|
|
lines.append("")
|
|
return lines
|
|
|
|
|
|
def generate_apis(upstream: Path, output: Path) -> tuple[int, int]:
|
|
surface_lines = ["project\tfile\tline\tdeclaration_sha256\tdeclaration"]
|
|
type_lines = ["crate\tnamespace\trust_path\tkind\tcsharp_name\tfile\tline"]
|
|
total_types = 0
|
|
total_members = 0
|
|
for project in PROJECTS:
|
|
project_root = upstream / project.source
|
|
tree = ModuleNode()
|
|
for path in source_files(project_root):
|
|
text = path.read_text(encoding="utf-8-sig")
|
|
relative = path.relative_to(upstream).as_posix()
|
|
for line_no, line in enumerate(text.splitlines(), 1):
|
|
if re.match(r"^\s*public\s+", line):
|
|
declaration = " ".join(line.strip().split())
|
|
digest = hashlib.sha256(declaration.encode()).hexdigest()
|
|
surface_lines.append(f"{project.source}\t{relative}\t{line_no}\t{digest}\t{declaration}")
|
|
total_members += 1
|
|
for match in TYPE_RE.finditer(text):
|
|
namespace = namespace_at(text, match.start())
|
|
remainder = namespace
|
|
if remainder == project.namespace:
|
|
remainder = ""
|
|
elif remainder.startswith(project.namespace + "."):
|
|
remainder = remainder[len(project.namespace) + 1 :]
|
|
modules = [snake(part) for part in remainder.split(".") if part]
|
|
name = match.group("name").lstrip("@")
|
|
kind = match.group("kind")
|
|
arity = generic_arity(match.group("generic"))
|
|
csharp_name = ".".join(filter(None, [namespace, name]))
|
|
add_type(tree, modules, name, kind, arity, csharp_name)
|
|
rust_path = "::".join(modules + [rust_type_name(name)])
|
|
line_no = text.count("\n", 0, match.start()) + 1
|
|
type_lines.append(f"{project.crate}\t{namespace}\t{rust_path}\t{kind}\t{csharp_name}\t{relative}\t{line_no}")
|
|
total_types += 1
|
|
generated = [
|
|
"// @generated by tools/generate_surface.py; do not edit by hand.",
|
|
f"// Source: LibreMetaverse {UPSTREAM_COMMIT}",
|
|
"#![allow(non_camel_case_types)]",
|
|
"",
|
|
] + render_node(tree)
|
|
crate_file = output / "crates" / project.crate / "src" / "generated.rs"
|
|
crate_file.parent.mkdir(parents=True, exist_ok=True)
|
|
crate_file.write_text("\n".join(generated).rstrip() + "\n")
|
|
api_dir = output / "api"
|
|
api_dir.mkdir(parents=True, exist_ok=True)
|
|
(api_dir / "SURFACE.tsv").write_text("\n".join(surface_lines) + "\n")
|
|
(api_dir / "TYPES.tsv").write_text("\n".join(type_lines) + "\n")
|
|
return total_types, total_members
|
|
|
|
|
|
def enclosing_class_match(text: str, position: int) -> re.Match[str] | None:
|
|
containing: list[re.Match[str]] = []
|
|
for match in CLASS_RE.finditer(text, 0, position):
|
|
brace = text.find("{", match.end())
|
|
if brace == -1 or brace >= position:
|
|
continue
|
|
depth = 0
|
|
for index in range(brace, len(text)):
|
|
if text[index] == "{":
|
|
depth += 1
|
|
elif text[index] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
if position < index:
|
|
containing.append(match)
|
|
break
|
|
return containing[-1] if containing else None
|
|
|
|
|
|
def attributes_before(text: str, position: int) -> str:
|
|
match = re.search(r"((?:\s*\[[^\]]+\]\s*)+)$", text[:position], re.DOTALL)
|
|
return match.group(1) if match else ""
|
|
|
|
|
|
def method_body_hash(text: str, method_start: int) -> str:
|
|
brace = text.find("{", method_start)
|
|
arrow = text.find("=>", method_start)
|
|
if arrow != -1 and (brace == -1 or arrow < brace):
|
|
end = text.find(";", arrow)
|
|
body = text[method_start : end + 1]
|
|
elif brace != -1:
|
|
depth = 0
|
|
end = brace
|
|
for end in range(brace, len(text)):
|
|
if text[end] == "{":
|
|
depth += 1
|
|
elif text[end] == "}":
|
|
depth -= 1
|
|
if depth == 0:
|
|
break
|
|
body = text[method_start : end + 1]
|
|
else:
|
|
body = text[method_start : method_start + 500]
|
|
return hashlib.sha256(body.encode()).hexdigest()
|
|
|
|
|
|
def stable_case_id(source: str, fixture: str, method: str, attribute: str, parameters: str | None) -> str:
|
|
test_id = f"{source}::{fixture}.{method}"
|
|
if attribute == "Test":
|
|
return f"{test_id}::test"
|
|
normalized = " ".join((parameters or "").split())
|
|
digest = hashlib.sha256(normalized.encode()).hexdigest()[:16]
|
|
return f"{test_id}::case:{digest}"
|
|
|
|
|
|
def helper_types(roots: tuple[Path, ...], upstream: Path) -> dict[str, str]:
|
|
helpers: dict[str, str] = {}
|
|
for root in roots:
|
|
for path in source_files(root):
|
|
text = path.read_text(encoding="utf-8-sig")
|
|
searchable = mask_csharp_comments(text)
|
|
if TEST_ATTR_RE.search(searchable):
|
|
continue
|
|
relative = path.relative_to(upstream).as_posix()
|
|
for match in CLASS_RE.finditer(searchable):
|
|
helpers.setdefault(match.group(1), relative)
|
|
return helpers
|
|
|
|
|
|
def extract_tests(upstream: Path) -> list[dict[str, object]]:
|
|
roots = (upstream / "LibreMetaverse.Tests", upstream / "LibreMetaverse.Rendering.Tests")
|
|
helpers = helper_types(roots, upstream)
|
|
tests: list[dict[str, object]] = []
|
|
for root in roots:
|
|
for path in source_files(root):
|
|
text = path.read_text(encoding="utf-8-sig")
|
|
searchable = mask_csharp_comments(text)
|
|
relative = path.relative_to(upstream).as_posix()
|
|
dependencies = sorted(
|
|
helper_path
|
|
for helper, helper_path in helpers.items()
|
|
if helper_path != relative and re.search(rf"\b{re.escape(helper)}\b", text)
|
|
)
|
|
attrs = list(TEST_ATTR_RE.finditer(searchable))
|
|
for attr_index, attr in enumerate(attrs):
|
|
next_attr = attrs[attr_index + 1].start() if attr_index + 1 < len(attrs) else len(text)
|
|
method = METHOD_RE.search(searchable, attr.end(), next_attr + 1200)
|
|
if method is None:
|
|
method = METHOD_RE.search(searchable, attr.end())
|
|
if method is None:
|
|
raise RuntimeError(f"No test method after {relative}:{text.count(chr(10), 0, attr.start()) + 1}")
|
|
if attr.group(1) == "Test" and TEST_ATTR_RE.search(searchable, attr.end(), method.start()):
|
|
continue
|
|
name = method.group(1)
|
|
class_match = enclosing_class_match(searchable, method.start())
|
|
fixture = class_match.group(1) if class_match else "UnknownFixture"
|
|
attr_text = " ".join(attr.group(0).split())
|
|
parameters = " ".join((attr.group(2) or "").split()) or None
|
|
line = text.count("\n", 0, attr.start()) + 1
|
|
category_text = searchable[attr.start() : method.start()]
|
|
if class_match:
|
|
category_text += attributes_before(searchable, class_match.start())
|
|
categories = sorted(set(CATEGORY_RE.findall(category_text)))
|
|
tests.append(
|
|
{
|
|
"id": stable_case_id(relative, fixture, name, attr.group(1), parameters),
|
|
"csharp_test_id": f"{relative}::{fixture}.{name}",
|
|
"parameter_case": parameters,
|
|
"source": relative,
|
|
"line": line,
|
|
"fixture": fixture,
|
|
"method": name,
|
|
"attribute": attr_text,
|
|
"body_sha256": method_body_hash(text, method.start()),
|
|
"categories": categories,
|
|
"fixture_dependencies": dependencies,
|
|
}
|
|
)
|
|
ids = [str(test["id"]) for test in tests]
|
|
duplicates = sorted(case_id for case_id, count in Counter(ids).items() if count > 1)
|
|
if duplicates:
|
|
raise RuntimeError("Duplicate stable NUnit case IDs: " + ", ".join(duplicates))
|
|
return tests
|
|
|
|
|
|
def reviewed_tests(root: Path) -> dict[str, dict[str, object]]:
|
|
reviews: dict[str, dict[str, object]] = {}
|
|
review_roots = (root / "tests" / "compat" / "tests", root / "crates")
|
|
for path in sorted(path for review_root in review_roots for path in review_root.rglob("*.rs")):
|
|
if path.name == "generated_parity.rs":
|
|
continue
|
|
text = path.read_text()
|
|
for marker in PARITY_MARKER_RE.finditer(text):
|
|
next_marker = PARITY_MARKER_RE.search(text, marker.end())
|
|
rust_test = RUST_TEST_RE.search(text, marker.end(), next_marker.start() if next_marker else len(text))
|
|
if rust_test is None:
|
|
raise RuntimeError(f"Parity marker has no following Rust test: {path}:{text.count(chr(10), 0, marker.start()) + 1}")
|
|
body_sha256 = validate_reviewed_body(path, rust_test, rust_test_body(text, rust_test))
|
|
case_id = marker.group("id")
|
|
if case_id in reviews:
|
|
raise RuntimeError(f"Duplicate reviewed parity case: {case_id}")
|
|
reviews[case_id] = {
|
|
"body_sha256": marker.group("body_sha256"),
|
|
"rust_body_sha256": body_sha256,
|
|
"status": marker.group("status"),
|
|
"rust_file": path.relative_to(root).as_posix(),
|
|
"rust_line": text.count("\n", 0, marker.start()) + 1,
|
|
"rust_test": rust_test.group(1),
|
|
}
|
|
return reviews
|
|
|
|
|
|
def render_parity_report(tests: list[dict[str, object]]) -> str:
|
|
status_counts = {status: sum(test["status"] == status for test in tests) for status in ("pending", "translated", "ignored-live", "benchmark", "drifted")}
|
|
live_candidates = sum("RequiresLiveServer" in test["categories"] for test in tests)
|
|
benchmark_candidates = sum("Benchmark" in test["categories"] for test in tests)
|
|
lines = [
|
|
"# NUnit to Rust parity ledger",
|
|
"",
|
|
f"Generated from LibreMetaverse `{UPSTREAM_COMMIT}`. Stable IDs identify one NUnit `[Test]` or `[TestCase]` invocation; the body hash covers the original C# method declaration and body.",
|
|
"",
|
|
"## Status",
|
|
"",
|
|
f"- Total: **{len(tests):,}**",
|
|
f"- Pending/unreviewed: **{status_counts['pending']:,}**",
|
|
f"- Translated/reviewed: **{status_counts['translated']:,}**",
|
|
f"- Ignored live/reviewed: **{status_counts['ignored-live']:,}** ({live_candidates:,} upstream live candidates)",
|
|
f"- Benchmarks/reviewed: **{status_counts['benchmark']:,}** ({benchmark_candidates:,} upstream benchmark candidates)",
|
|
f"- Drifted: **{status_counts['drifted']:,}**",
|
|
"",
|
|
"Reviewed Rust tests live outside `generated_parity.rs` and carry a `parity-case` marker. Regeneration preserves those files and fails if their source body hash drifts.",
|
|
"",
|
|
"| Stable case ID | C# test | Parameter case | Source | Categories | Fixtures | Rust location | Status | Body SHA-256 |",
|
|
"|---|---|---|---|---|---|---|---|---|",
|
|
]
|
|
for test in tests:
|
|
parameter = str(test["parameter_case"] or "").replace("|", "|")
|
|
categories = ", ".join(test["categories"])
|
|
fixtures = ", ".join(test["fixture_dependencies"])
|
|
rust_location = (
|
|
f"{test['rust_file']}:{test['rust_line']} (`{test['rust_test']}`)"
|
|
if test["status"] != "pending"
|
|
else "pending source translation"
|
|
)
|
|
lines.append(
|
|
f"| `{test['id']}` | `{test['fixture']}.{test['method']}` | `{parameter}` | `{test['source']}:{test['line']}` | `{categories}` | `{fixtures}` | `{rust_location}` | `{test['status']}` | `{test['body_sha256']}` |"
|
|
)
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def generate_tests(upstream: Path, output: Path, review_root: Path | None = None) -> int:
|
|
tests = extract_tests(upstream)
|
|
reviews = reviewed_tests(review_root or output)
|
|
tests_by_id = {str(test["id"]): test for test in tests}
|
|
stale = sorted(set(reviews) - set(tests_by_id))
|
|
if stale:
|
|
raise RuntimeError("Reviewed cases missing from pinned upstream: " + ", ".join(stale))
|
|
drifted = sorted(
|
|
case_id
|
|
for case_id, review in reviews.items()
|
|
if review["body_sha256"] != tests_by_id[case_id]["body_sha256"]
|
|
)
|
|
if drifted:
|
|
raise RuntimeError("Reviewed cases have drifted C# bodies: " + ", ".join(drifted))
|
|
|
|
rust_lines = [
|
|
"// @generated by tools/generate_surface.py; do not edit by hand.",
|
|
f"// Source: LibreMetaverse {UPSTREAM_COMMIT}",
|
|
"// Pending cases are catalog entries, not fake executable Rust tests.",
|
|
]
|
|
for test in tests:
|
|
review = reviews.get(str(test["id"]))
|
|
if review:
|
|
test.update(review)
|
|
test["semantic_review"] = "reviewed"
|
|
continue
|
|
if len(rust_lines) == 3:
|
|
rust_lines.append("")
|
|
test.update(
|
|
{
|
|
"rust_file": "tests/compat/tests/generated_parity.rs",
|
|
"rust_line": len(rust_lines) + 1,
|
|
"rust_test": "",
|
|
"rust_body_sha256": None,
|
|
"status": "pending",
|
|
"semantic_review": "unreviewed",
|
|
}
|
|
)
|
|
rust_lines.append(f"// pending-parity-case: {test['id']} {test['body_sha256']}")
|
|
generated_dir = output / "tests" / "compat" / "tests"
|
|
generated_dir.mkdir(parents=True, exist_ok=True)
|
|
(generated_dir / "generated_parity.rs").write_text("\n".join(rust_lines) + "\n")
|
|
(output / "tests" / "PARITY.md").write_text(render_parity_report(tests))
|
|
report = {
|
|
"pending": sum(test["status"] == "pending" for test in tests),
|
|
"translated": sum(test["status"] == "translated" for test in tests),
|
|
"ignored_live": sum(test["status"] == "ignored-live" for test in tests),
|
|
"benchmark": sum(test["status"] == "benchmark" for test in tests),
|
|
"drifted": 0,
|
|
"unreviewed": sum(test["semantic_review"] == "unreviewed" for test in tests),
|
|
}
|
|
catalog = {
|
|
"schema_version": 2,
|
|
"upstream_commit": UPSTREAM_COMMIT,
|
|
"expected_cases": EXPECTED_TESTS,
|
|
"report": report,
|
|
"tests": tests,
|
|
}
|
|
(output / "tests" / "upstream-tests.json").write_text(json.dumps(catalog, indent=2) + "\n")
|
|
return len(tests)
|
|
|
|
|
|
def scan_parity_markers(root: Path) -> dict[str, list[dict[str, object]]]:
|
|
return {case_id: [review] for case_id, review in reviewed_tests(root).items()}
|
|
|
|
|
|
def check_test_parity(root: Path, require_reviewed: bool = False) -> dict[str, int]:
|
|
catalog_path = root / "tests" / "upstream-tests.json"
|
|
catalog = json.loads(catalog_path.read_text())
|
|
if catalog.get("schema_version") != 2:
|
|
raise RuntimeError("Unsupported test parity catalog schema")
|
|
tests = catalog.get("tests", [])
|
|
expected = catalog.get("expected_cases")
|
|
if expected != EXPECTED_TESTS or len(tests) != EXPECTED_TESTS:
|
|
raise RuntimeError(f"Expected {EXPECTED_TESTS} cataloged NUnit invocations, found {len(tests)}")
|
|
if catalog.get("upstream_commit") != UPSTREAM_COMMIT:
|
|
raise RuntimeError("Test parity catalog targets the wrong upstream commit")
|
|
|
|
ids = [test["id"] for test in tests]
|
|
duplicate_ids = sorted(case_id for case_id, count in Counter(ids).items() if count > 1)
|
|
if duplicate_ids:
|
|
raise RuntimeError("Duplicate catalog case IDs: " + ", ".join(duplicate_ids))
|
|
by_id = {test["id"]: test for test in tests}
|
|
markers = scan_parity_markers(root)
|
|
reviewed_ids = {case_id for case_id, test in by_id.items() if test["status"] != "pending"}
|
|
missing = sorted(reviewed_ids - set(markers))
|
|
stale = sorted(set(markers) - set(by_id))
|
|
premature = sorted(set(markers) - reviewed_ids)
|
|
duplicates = sorted(case_id for case_id, entries in markers.items() if len(entries) != 1)
|
|
if missing:
|
|
raise RuntimeError("Missing Rust parity cases: " + ", ".join(missing))
|
|
if stale:
|
|
raise RuntimeError("Stale Rust parity cases: " + ", ".join(stale))
|
|
if premature:
|
|
raise RuntimeError("Catalog still marks reviewed Rust cases pending: " + ", ".join(premature))
|
|
if duplicates:
|
|
raise RuntimeError("Duplicate Rust parity cases: " + ", ".join(duplicates))
|
|
|
|
drifted: list[str] = []
|
|
mismatched: list[str] = []
|
|
for case_id in reviewed_ids:
|
|
test = by_id[case_id]
|
|
marker = markers[case_id][0]
|
|
if marker["body_sha256"] != test["body_sha256"]:
|
|
drifted.append(case_id)
|
|
for key in ("status", "rust_file", "rust_line", "rust_test", "rust_body_sha256"):
|
|
if marker[key] != test[key]:
|
|
mismatched.append(f"{case_id}:{key}")
|
|
if drifted:
|
|
raise RuntimeError("Drifted reviewed C# test bodies: " + ", ".join(drifted))
|
|
if mismatched:
|
|
raise RuntimeError("Stale parity catalog metadata: " + ", ".join(mismatched))
|
|
|
|
report = {
|
|
"pending": sum(test["status"] == "pending" for test in tests),
|
|
"translated": sum(test["status"] == "translated" for test in tests),
|
|
"ignored_live": sum(test["status"] == "ignored-live" for test in tests),
|
|
"benchmark": sum(test["status"] == "benchmark" for test in tests),
|
|
"drifted": 0,
|
|
"unreviewed": sum(test["semantic_review"] != "reviewed" for test in tests),
|
|
}
|
|
if catalog.get("report") != report:
|
|
raise RuntimeError("Stale parity summary report")
|
|
if require_reviewed and report["unreviewed"]:
|
|
raise RuntimeError(f"{report['unreviewed']} NUnit invocations remain semantically unreviewed")
|
|
return report
|
|
|
|
|
|
def verify_upstream(upstream: Path) -> None:
|
|
commit = subprocess.run(
|
|
["git", "-C", str(upstream), "rev-parse", "HEAD"],
|
|
check=True,
|
|
capture_output=True,
|
|
text=True,
|
|
).stdout.strip()
|
|
if commit != UPSTREAM_COMMIT:
|
|
raise RuntimeError(f"Expected LibreMetaverse {UPSTREAM_COMMIT}, found {commit}")
|
|
|
|
|
|
def check_test_regeneration(upstream: Path, output: Path) -> None:
|
|
with tempfile.TemporaryDirectory() as temporary:
|
|
generated = Path(temporary)
|
|
count = generate_tests(upstream, generated, review_root=output)
|
|
if count != EXPECTED_TESTS:
|
|
raise RuntimeError(f"Expected {EXPECTED_TESTS} NUnit invocations at {UPSTREAM_COMMIT}, found {count}")
|
|
stale = [str(path) for path in PARITY_FILES if not (output / path).exists() or (output / path).read_bytes() != (generated / path).read_bytes()]
|
|
if stale:
|
|
raise RuntimeError("Stale generated test parity files: " + ", ".join(stale))
|
|
|
|
|
|
def generate_program_manifest(upstream: Path, output: Path) -> int:
|
|
roots = [
|
|
upstream / "Programs" / "VivoxTest",
|
|
upstream / "Programs" / "WebRtcTest",
|
|
*sorted((upstream / "Programs" / "examples").iterdir()),
|
|
*sorted((upstream / "Programs" / "tools").iterdir()),
|
|
]
|
|
entries = []
|
|
for root in roots:
|
|
if not root.is_dir() or not list(root.glob("*.csproj")):
|
|
continue
|
|
files = []
|
|
for path in source_files(root):
|
|
data = path.read_bytes()
|
|
files.append({"path": path.relative_to(upstream).as_posix(), "sha256": hashlib.sha256(data).hexdigest()})
|
|
entries.append({"project": root.name, "files": files})
|
|
(output / "programs" / "upstream-programs.json").write_text(json.dumps({"upstream_commit": UPSTREAM_COMMIT, "projects": entries}, indent=2) + "\n")
|
|
command_files = source_files(upstream / "Programs" / "examples" / "TestClient" / "Commands")
|
|
command_names = [path.stem for path in command_files]
|
|
command_rs = [
|
|
"// @generated by tools/generate_surface.py; do not edit by hand.",
|
|
"pub const TEST_CLIENT_COMMANDS: &[&str] = &[",
|
|
*[f" {json.dumps(name)}," for name in command_names],
|
|
"];",
|
|
"",
|
|
]
|
|
(output / "programs" / "src" / "commands.rs").write_text("\n".join(command_rs))
|
|
return len(entries)
|
|
|
|
|
|
def main() -> None:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--upstream", type=Path, default=Path("../libremetaverse"))
|
|
parser.add_argument("--output", type=Path, default=Path("."))
|
|
parser.add_argument("--tests-only", action="store_true")
|
|
parser.add_argument("--check", action="store_true")
|
|
args = parser.parse_args()
|
|
upstream = args.upstream.resolve()
|
|
output = args.output.resolve()
|
|
verify_upstream(upstream)
|
|
if args.check:
|
|
check_test_regeneration(upstream, output)
|
|
report = check_test_parity(output)
|
|
print(f"test parity is current: {report}")
|
|
return
|
|
if args.tests_only:
|
|
tests = generate_tests(upstream, output)
|
|
if tests != EXPECTED_TESTS:
|
|
raise RuntimeError(f"Expected {EXPECTED_TESTS} NUnit invocations at {UPSTREAM_COMMIT}, found {tests}")
|
|
print(f"generated {tests} test parity cases")
|
|
return
|
|
types, members = generate_apis(upstream, output)
|
|
tests = generate_tests(upstream, output)
|
|
programs = generate_program_manifest(upstream, output)
|
|
if tests != EXPECTED_TESTS:
|
|
raise RuntimeError(f"Expected {EXPECTED_TESTS} NUnit invocations at {UPSTREAM_COMMIT}, found {tests}")
|
|
print(f"generated {types} type declarations, {members} public declaration lines, {tests} tests, and {programs} program manifests")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|