from __future__ import annotations import re import sys from dataclasses import dataclass from pathlib import Path ROOT = Path(__file__).resolve().parents[1] GAS_FILES = sorted(ROOT.glob("gas*.gs")) @dataclass class FunctionDef: name: str min_arity: int max_arity: int path: Path line: int @dataclass class CallSite: name: str arity: int path: Path line: int def sanitize_js(text: str) -> str: out: list[str] = [] i = 0 n = len(text) state = "normal" while i < n: ch = text[i] nxt = text[i + 1] if i + 1 < n else "" if state == "normal": if ch == "/" and nxt == "/": out.extend(" ") i += 2 state = "line_comment" continue if ch == "/" and nxt == "*": out.extend(" ") i += 2 state = "block_comment" continue if ch == "'": out.append(" ") i += 1 state = "single_quote" continue if ch == '"': out.append(" ") i += 1 state = "double_quote" continue if ch == "`": out.append(" ") i += 1 state = "template" continue out.append(ch) i += 1 continue if state == "line_comment": if ch == "\n": out.append("\n") state = "normal" else: out.append(" ") i += 1 continue if state == "block_comment": if ch == "*" and nxt == "/": out.extend(" ") i += 2 state = "normal" else: out.append("\n" if ch == "\n" else " ") i += 1 continue if state in {"single_quote", "double_quote", "template"}: if ch == "\\": out.append("S") if i + 1 < n: out.append("S" if text[i + 1] != "\n" else "\n") i += 2 continue if (state == "single_quote" and ch == "'") or ( state == "double_quote" and ch == '"' ) or (state == "template" and ch == "`"): out.append(" ") i += 1 state = "normal" else: out.append("\n" if ch == "\n" else "S") i += 1 continue return "".join(out) def line_of(text: str, index: int) -> int: return text.count("\n", 0, index) + 1 def find_matching(text: str, start: int, opener: str, closer: str) -> int: depth = 0 for idx in range(start, len(text)): ch = text[idx] if ch == opener: depth += 1 elif ch == closer: depth -= 1 if depth == 0: return idx return -1 def count_arguments(chunk: str) -> int: if not chunk.strip(): return 0 depth_paren = 0 depth_brace = 0 depth_bracket = 0 count = 1 for ch in chunk: if ch == "(": depth_paren += 1 elif ch == ")": depth_paren -= 1 elif ch == "{": depth_brace += 1 elif ch == "}": depth_brace -= 1 elif ch == "[": depth_bracket += 1 elif ch == "]": depth_bracket -= 1 elif ch == "," and depth_paren == 0 and depth_brace == 0 and depth_bracket == 0: count += 1 return count def parse_param_bounds(params: str) -> tuple[int, int]: tokens = [p.strip() for p in params.split(",") if p.strip()] if not tokens: return 0, 0 min_arity = 0 max_arity = len(tokens) for token in tokens: if "=" not in token and not token.startswith("..."): min_arity += 1 return min_arity, max_arity def extract_function_defs(path: Path) -> list[FunctionDef]: raw = path.read_text(encoding="utf-8") text = sanitize_js(raw) defs: list[FunctionDef] = [] for match in re.finditer(r"\bfunction\s+([A-Za-z_$][\w$]*)\s*\(", text): name = match.group(1) open_idx = text.find("(", match.start()) close_idx = find_matching(text, open_idx, "(", ")") if close_idx == -1: continue params = text[open_idx + 1 : close_idx] min_arity, max_arity = parse_param_bounds(params) defs.append( FunctionDef( name=name, min_arity=min_arity, max_arity=max_arity, path=path, line=line_of(raw, match.start()), ) ) return defs def extract_calls(path: Path, function_names: set[str]) -> list[CallSite]: raw = path.read_text(encoding="utf-8") text = sanitize_js(raw) calls: list[CallSite] = [] for name in sorted(function_names): pattern = re.compile(rf"\b{re.escape(name)}\s*\(") for match in pattern.finditer(text): prefix = text[max(0, match.start() - 16) : match.start()] if re.search(r"\bfunction\s+$", prefix): continue open_idx = text.find("(", match.start()) close_idx = find_matching(text, open_idx, "(", ")") if close_idx == -1: continue args = text[open_idx + 1 : close_idx] calls.append(CallSite(name=name, arity=count_arguments(args), path=path, line=line_of(raw, match.start()))) return calls def main() -> int: if not GAS_FILES: print("No gas*.gs files found.") return 1 defs: list[FunctionDef] = [] for path in GAS_FILES: defs.extend(extract_function_defs(path)) def_map = {item.name: item for item in defs} errors: list[str] = [] for path in GAS_FILES: for call in extract_calls(path, set(def_map)): defined = def_map.get(call.name) if defined is None: continue if not (defined.min_arity <= call.arity <= defined.max_arity): errors.append( f"{path.name}:{call.line}: {call.name} call arity={call.arity} " f"outside definition range={defined.min_arity}..{defined.max_arity} " f"at {defined.path.name}:{defined.line}" ) if errors: print("GAS CALL ARITY VALIDATION FAILED") for err in errors: print(f"- {err}") return 1 print(f"GAS CALL ARITY OK: checked {len(def_map)} functions across {len(GAS_FILES)} files") return 0 if __name__ == "__main__": sys.exit(main())