#!/usr/bin/env python3 # SPDX-License-Identifier: AGPL-3.0-only # Copyright (c) 2026 sol pbc """Built-in ``sol call`` tools HTTP-only lint.""" from __future__ import annotations import argparse import ast import sys from pathlib import Path ROOT = Path(__file__).resolve().parent.parent TARGETS: tuple[str, ...] = ( "solstone/think/tools/health.py", "solstone/think/tools/ledger.py", "solstone/think/tools/profile.py", ) ALLOW_SET: frozenset[str] = frozenset( { "solstone.think.convey_client", "solstone.convey.reasons", "solstone.convey.readiness_snapshot", "solstone.think.pipeline_health", } ) FLAGGED_NAMESPACES: tuple[str, ...] = ( "solstone.think", "solstone.apps", "solstone.convey", ) IO_METHODS: frozenset[str] = frozenset( { "read_text", "read_bytes", "write_text", "write_bytes", "open", "unlink", "mkdir", "rmdir", "touch", "glob", "iterdir", "rglob", } ) OS_FS_FUNCS: frozenset[str] = frozenset( { "remove", "unlink", "mkdir", "makedirs", "rmdir", "removedirs", "rename", "renames", "replace", "symlink", "link", "listdir", "scandir", "walk", "chmod", "chown", "truncate", "mkfifo", "mknod", "open", } ) SHUTIL_FS_FUNCS: frozenset[str] = frozenset( { "move", "copy", "copy2", "copyfile", "copytree", "rmtree", "copyfileobj", "make_archive", "unpack_archive", "copymode", "copystat", "chown", } ) def _is_under_namespace(module: str) -> bool: return any( module == namespace or module.startswith(f"{namespace}.") for namespace in FLAGGED_NAMESPACES ) def _collect_bindings( tree: ast.AST, ) -> tuple[set[str], dict[str, str], set[str], dict[str, str]]: os_aliases: set[str] = set() os_direct_names: dict[str, str] = {} shutil_aliases: set[str] = set() shutil_direct_names: dict[str, str] = {} for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: bound = alias.asname or alias.name if alias.name == "os": os_aliases.add(bound) elif alias.name == "shutil": shutil_aliases.add(bound) elif isinstance(node, ast.ImportFrom): module = node.module or "" for alias in node.names: bound = alias.asname or alias.name if module == "os" and alias.name in OS_FS_FUNCS: os_direct_names[bound] = alias.name elif module == "shutil" and alias.name in SHUTIL_FS_FUNCS: shutil_direct_names[bound] = alias.name return os_aliases, os_direct_names, shutil_aliases, shutil_direct_names def _scan_import(node: ast.Import | ast.ImportFrom) -> list[tuple[int, str, str]]: findings: list[tuple[int, str, str]] = [] if isinstance(node, ast.Import): for alias in node.names: module = alias.name if _is_under_namespace(module) and module not in ALLOW_SET: findings.append((node.lineno, "import", module)) return findings module = node.module or "" if not _is_under_namespace(module): return findings if module in ALLOW_SET: return findings if len(node.names) == 1 and f"{module}.{node.names[0].name}" in ALLOW_SET: return findings return [(node.lineno, "import", module)] def _scan_call( node: ast.Call, os_aliases: set[str], os_direct_names: dict[str, str], shutil_aliases: set[str], shutil_direct_names: dict[str, str], ) -> tuple[int, str, str] | None: func = node.func if isinstance(func, ast.Name): if func.id == "open": return node.lineno, "fs", "open" if func.id in os_direct_names: return node.lineno, "fs", f"os.{os_direct_names[func.id]}" if func.id in shutil_direct_names: return node.lineno, "fs", f"shutil.{shutil_direct_names[func.id]}" return None if not isinstance(func, ast.Attribute): return None if func.attr in IO_METHODS: return node.lineno, "fs", f"Path.{func.attr}" if ( isinstance(func.value, ast.Name) and func.value.id in os_aliases and func.attr in OS_FS_FUNCS ): return node.lineno, "fs", f"os.{func.attr}" if ( isinstance(func.value, ast.Name) and func.value.id in shutil_aliases and func.attr in SHUTIL_FS_FUNCS ): return node.lineno, "fs", f"shutil.{func.attr}" return None def scan_source(source: str, filename: str = "") -> list[tuple[int, str, str]]: tree = ast.parse(source, filename=filename) os_aliases, os_direct_names, shutil_aliases, shutil_direct_names = ( _collect_bindings(tree) ) findings: list[tuple[int, str, str]] = [] for node in ast.walk(tree): if isinstance(node, (ast.Import, ast.ImportFrom)): findings.extend(_scan_import(node)) elif isinstance(node, ast.Call): finding = _scan_call( node, os_aliases, os_direct_names, shutil_aliases, shutil_direct_names, ) if finding: findings.append(finding) findings.sort() return findings def scan_file(path: Path) -> list[tuple[int, str, str]]: return scan_source(path.read_text(encoding="utf-8"), filename=str(path)) def discover_modules(root: Path) -> list[Path]: return [Path(target) for target in TARGETS if (root / target).is_file()] def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="built-in sol call tools HTTP-only lint" ) parser.add_argument( "--root", type=Path, default=ROOT, help="Repository root to scan (defaults to the checkout root).", ) args = parser.parse_args(argv) findings: list[str] = [] for rel in discover_modules(args.root): for lineno, kind, detail in scan_file(args.root / rel): findings.append(f"{rel.as_posix()}:{lineno}: {kind} {detail}") if findings: for finding in findings: print(finding, file=sys.stderr) return 1 print("tools-http-only: pass") return 0 if __name__ == "__main__": raise SystemExit(main())