#!/usr/bin/env python3 # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc """Static no-Python-spawn lint for the migrated native sol surface.""" from __future__ import annotations import re from dataclasses import dataclass from pathlib import Path try: from scripts.build_native_sol_inventory import REPO_ROOT, discover except ModuleNotFoundError: # pragma: no cover - direct script execution path. from build_native_sol_inventory import REPO_ROOT, discover # type: ignore[no-redef] ALLOWLIST: dict[tuple[str, str], str] = {} CLIENT_CRATES = ( REPO_ROOT / "core/crates/solstone-core-sol-client", REPO_ROOT / "core/crates/solstone-core-sol-client-cli", ) SEAM_FILE = REPO_ROOT / "core/crates/solstone-core-sol-client/src/seam.rs" CLI_LIB_FILE = REPO_ROOT / "core/crates/solstone-core-sol-client-cli/src/lib.rs" PARITY_TEST_FILE = ( REPO_ROOT / "core/crates/solstone-core-sol-client-cli/tests/parity.rs" ) FORBIDDEN_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = ( ("direct-std-process-command", re.compile(r"\bstd::process::Command\b")), ("direct-tokio-process", re.compile(r"\btokio::process\b")), ("direct-process-command", re.compile(r"\bprocess::Command\b")), ("direct-command-new", re.compile(r"\bCommand::new\s*\(")), ("direct-spawn-call", re.compile(r"\.spawn\s*\(")), ("direct-output-call", re.compile(r"\.output\s*\(")), ("direct-exec-call", re.compile(r"\bexec(?:[lv][pe]?|ve)?\s*\(")), ("pyo3-reference", re.compile(r"\b(?:pyo3|PyO3)\b")), ("cpython-reference", re.compile(r"\b(?:cpython|CPython)\b")), ("python-fallback-symbol", re.compile(r"\bpython_(?:fallback|dispatch)\b")), ("compat-dispatch-symbol", re.compile(r"\bcompat(?:ibility)?_dispatch\b")), ("fallback-to-python-symbol", re.compile(r"\bfallback_to_python\b")), ( "python-fallback-string", re.compile(r"\b(?:fallback|dispatch)[^\n\"]*python3?\b"), ), ) @dataclass(frozen=True) class Violation: file: str kind: str detail: str @property def key(self) -> tuple[str, str]: return (self.file, self.kind) def rel(path: Path) -> str: return path.relative_to(REPO_ROOT).as_posix() def rust_files_to_scan() -> list[Path]: files: set[Path] = set() files.update(authority_sources()) for crate in CLIENT_CRATES: files.update(crate.rglob("*.rs")) return sorted(path for path in files if path.is_file()) def authority_sources() -> set[Path]: return {entry.source for entry in discover(REPO_ROOT)} def collect_violations() -> list[Violation]: violations: list[Violation] = [] for path in rust_files_to_scan(): text = path.read_text(encoding="utf-8") violations.extend(scan_forbidden_patterns(path, text)) violations.extend(scan_process_spawner_import(path, text)) violations.extend(check_required_spawn_guard_tests()) return violations def scan_forbidden_patterns(path: Path, text: str) -> list[Violation]: violations: list[Violation] = [] for kind, pattern in FORBIDDEN_PATTERNS: if pattern.search(text): violations.append( Violation( rel(path), kind, "migrated native sol code must not spawn or dispatch to Python", ) ) return violations def scan_process_spawner_import(path: Path, text: str) -> list[Violation]: if path == SEAM_FILE: return [] if re.search(r"\bProcessSpawner\b", text): return [ Violation( rel(path), "process-spawner-outside-seam", "ProcessSpawner is only allowed in the shared seam definition; " "host implementations belong in the process shell", ) ] return [] def check_required_spawn_guard_tests() -> list[Violation]: checks = { SEAM_FILE: ( "missing-failing-spawner-test", "failing_spawner_always_errors", ), CLI_LIB_FILE: ( "missing-unsupported-without-spawn-test", "classifies_unported_call_as_unsupported_without_spawn_path", ), PARITY_TEST_FILE: ( "missing-native-parity-test", "native_matches_sol_call_parity_vectors", ), } violations: list[Violation] = [] for path, (kind, needle) in checks.items(): text = path.read_text(encoding="utf-8") if needle in text: continue violations.append( Violation( rel(path), kind, f"required Rust proof {needle} is missing", ) ) return violations def main() -> int: violations = collect_violations() actual = {violation.key: violation for violation in violations} allowed = set(ALLOWLIST) unexpected = sorted(set(actual) - allowed) stale = sorted(allowed - set(actual)) if unexpected or stale: if unexpected: print("native sol no-python-spawn violations:") for key in unexpected: violation = actual[key] print(f"- {violation.file}: {violation.kind}: {violation.detail}") if stale: print("stale native sol no-python-spawn allowlist entries:") for file, kind in stale: print(f"- {file}: {kind}: {ALLOWLIST[(file, kind)]}") return 1 print("native sol no-python-spawn ok") return 0 if __name__ == "__main__": raise SystemExit(main())