from __future__ import annotations
import ast
import json
import re
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
MANIFEST = ROOT / "spec" / "api-surface.json"
LANGUAGES = ("rust", "python", "typescript", "go")
def fail(messages: list[str]) -> None:
print("api-surface validation failed:", file=sys.stderr)
for m in messages:
print(f" - {m}", file=sys.stderr)
raise SystemExit(1)
def extract_rust() -> tuple[set[str], set[str]]:
text = (ROOT / "rust" / "src" / "lib.rs").read_text()
always: set[str] = set()
gated: set[str] = set()
lines = text.splitlines()
i = 0
prev_nonblank = ""
while i < len(lines):
line = lines[i]
stripped = line.strip()
m = re.match(r"pub use (\w+)::\{", stripped)
if m:
block = [stripped]
while "};" not in block[-1] and "}" not in block[-1].split("::", 1)[-1]:
i += 1
block.append(lines[i].strip())
if "}" in lines[i]:
break
full = " ".join(block)
inner = full.split("{", 1)[1].rsplit("}", 1)[0]
target = gated if prev_nonblank.startswith("#[cfg(") else always
for ident in inner.split(","):
ident = ident.strip()
if ident:
target.add(ident)
else:
m_single = re.match(r"pub use (?:\w+::)+(\w+);", stripped)
if m_single:
target = gated if prev_nonblank.startswith("#[cfg(") else always
target.add(m_single.group(1))
if stripped:
prev_nonblank = stripped
i += 1
return always, gated
def extract_python() -> set[str]:
text = (ROOT / "python" / "agent_first_data" / "__init__.py").read_text()
m = re.search(r"__all__\s*=\s*(\[.*?\])", text, re.DOTALL)
if not m:
fail(["could not find __all__ in python/agent_first_data/__init__.py"])
return set(ast.literal_eval(m.group(1)))
def extract_typescript() -> set[str]:
text = (ROOT / "typescript" / "src" / "index.ts").read_text()
names: set[str] = set()
for block in re.findall(r"export \{(.*?)\} from", text, re.DOTALL):
for entry in block.split(","):
entry = entry.strip()
if not entry:
continue
entry = re.sub(r"^type\s+", "", entry)
entry = entry.split(" as ")[0].strip()
if entry:
names.add(entry)
return names
def extract_go() -> set[str]:
names: set[str] = set()
for filename in ("afdata.go", "afdata_cli.go", "afdata_decode.go"):
text = (ROOT / "go" / filename).read_text()
for m in re.finditer(r"^func (New)?([A-Z]\w*)", text, re.MULTILINE):
names.add((m.group(1) or "") + m.group(2))
for m in re.finditer(r"^type ([A-Z]\w*)", text, re.MULTILINE):
names.add(m.group(1))
return names
EXTRACTORS = {
"python": extract_python,
"typescript": extract_typescript,
"go": extract_go,
}
def main() -> int:
manifest = json.loads(MANIFEST.read_text())
groups = manifest["groups"]
real: dict[str, set[str]] = {lang: EXTRACTORS[lang]() for lang in EXTRACTORS}
gated: dict[str, set[str]] = {lang: set() for lang in LANGUAGES}
real["rust"], gated["rust"] = extract_rust()
declared: dict[str, set[str]] = {lang: set() for lang in LANGUAGES}
failures: list[str] = []
for group in groups:
gid = group["id"]
for lang in LANGUAGES:
name = group.get(lang)
if name is None:
continue
declared[lang].add(name)
if name not in real[lang] and name not in gated[lang]:
failures.append(
f"{lang}: manifest entry '{gid}' names '{name}', which is not "
f"in the SDK's actual exports (renamed, deleted, or manifest is stale)"
)
for lang in LANGUAGES:
undeclared = sorted(real[lang] - declared[lang])
if undeclared:
failures.append(
f"{lang}: exported but not in spec/api-surface.json: {', '.join(undeclared)} "
f"(add deliberately alongside its equivalents in the other languages, "
f"or it should not be public)"
)
if failures:
fail(failures)
print(f"api-surface ok: {len(groups)} shared symbols cross-checked across {len(LANGUAGES)} languages")
return 0
if __name__ == "__main__":
raise SystemExit(main())