146 lines
4.6 KiB
Python
146 lines
4.6 KiB
Python
|
|
#!/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 <file-or-directory> [...]
|
||
|
|
|
||
|
|
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))
|