#!/usr/bin/env python3 """Enforce the two akerror control-flow rules whose failure mode is silent. AGENTS.md states both, and both have already cost this library a leak: 1. Never use a ``*_RETURN`` macro inside an ``ATTEMPT`` block. It returns past ``CLEANUP``, so every release, ``fclose`` and free in ``CLEANUP`` is skipped. ``akgl_get_json_tilemap_property`` leaked two pooled strings on every *successful* lookup this way. 2. Never ``return`` from inside a ``HANDLE`` block. ``FINISH`` is what carries ``RELEASE_ERROR``, so leaving early never gives the handled context back to ``AKERR_ARRAY_ERROR``. That cost ``akgl_path_relative`` one slot per call and killed the process on the 129th. Neither produces a compiler diagnostic, and neither shows up in a passing test run until the pool it drains is empty. Hence a linter. Usage: check_error_protocol.py [...] Exits 0 when clean, 1 on a violation. """ import os import re import sys # A ``*_RETURN`` macro invocation: SUCCEED_RETURN, FAIL_RETURN, FAIL_ZERO_RETURN, # FAIL_NONZERO_RETURN, and anything later shaped like them. RETURN_MACRO = re.compile(r"\b[A-Z][A-Z0-9_]*_RETURN\s*\(") BARE_RETURN = re.compile(r"\breturn\b") ATTEMPT_OPEN = re.compile(r"\bATTEMPT\s*\{") CLEANUP_OPEN = re.compile(r"\}\s*CLEANUP\s*\{") HANDLE_OPEN = re.compile(r"\}\s*HANDLE(?:_GROUP)?\s*\(") FINISH_ANY = re.compile(r"\}\s*FINISH(?:_NORETURN)?\s*\(") def strip_comments(text): """Blank out comments, keeping line numbering intact. Comments are removed rather than skipped because several of these keywords appear in prose -- "which should never return AKERR_KEY" is a sentence, not a violation. """ out = [] i = 0 n = len(text) while i < n: ch = text[i] if ch == '"' or ch == "'": quote = ch out.append(ch) i += 1 while i < n: if text[i] == "\\" and (i + 1) < n: out.append(" ") i += 2 continue out.append(text[i]) if text[i] == quote: i += 1 break i += 1 continue if text.startswith("/*", i): j = text.find("*/", i + 2) j = n if j == -1 else (j + 2) out.append("".join(c if c == "\n" else " " for c in text[i:j])) i = j continue if text.startswith("//", i): j = text.find("\n", i) j = n if j == -1 else j out.append(" " * (j - i)) i = j continue out.append(ch) i += 1 return "".join(out) def check(path): findings = [] lines = strip_comments(open(path, encoding="utf-8", errors="replace").read()).split("\n") in_attempt = False in_handle = False for number, line in enumerate(lines, 1): if ATTEMPT_OPEN.search(line): in_attempt = True continue if CLEANUP_OPEN.search(line): in_attempt = False if HANDLE_OPEN.search(line): in_handle = True continue if FINISH_ANY.search(line): in_handle = False continue if in_attempt and RETURN_MACRO.search(line): findings.append( (number, "a *_RETURN macro inside an ATTEMPT block returns past CLEANUP; " "use the matching *_BREAK variant")) if in_handle and BARE_RETURN.search(line): findings.append( (number, "returning from inside a HANDLE block skips the RELEASE_ERROR that " "FINISH carries; set a flag and act on it after FINISH")) return findings def sources(targets): for target in targets: if os.path.isdir(target): for root, _dirs, files in os.walk(target): for name in sorted(files): if name.endswith(".c") and not name.endswith("~"): yield os.path.join(root, name) else: yield target def main(argv): if len(argv) < 2: print(__doc__, file=sys.stderr) return 1 total = 0 for path in sources(argv[1:]): for number, message in check(path): print("%s:%d: %s" % (path, number, message), file=sys.stderr) total += 1 if total: print("", file=sys.stderr) print("check_error_protocol: %d violation(s). See the Error-Handling Protocol " "section of AGENTS.md." % total, file=sys.stderr) return 1 print("check_error_protocol: clean") return 0 if __name__ == "__main__": sys.exit(main(sys.argv))