check-db-naming.py (6677B)
1 #!/usr/bin/env python3 2 """Enforce the database layer naming convention. 3 4 Rules checked: 5 6 1. every public function's name starts with the name of the file it lives in 7 2. the file's own name uses a prefix from the closed set, or is exempt 8 3. iterate_ iff the function takes a caller-supplied callback; get_ iff not 9 4. a .h exists beside every .c, and every .sql that is not schema or 10 boilerplate is named after the .c that runs it 11 5. every prepared statement is named after the function preparing it 12 6. no prepared statement name is used in two files 13 7. no numeric suffixes and no repeated library prefix 14 8. no orphaned header: every .h in the API directory has an implementation 15 16 Run from the top of the source tree; exits non-zero on any violation. 17 """ 18 import re 19 import sys 20 import pathlib 21 22 # ---------------------------------------------------------------- CONFIG -- 23 # (impl dir, header dir, symbol prefix, .c basename prefix) 24 LAYERS = [ 25 ("src/stasis", "src/include/anastasis/anastasis-database", 26 "ANASTASIS_DB_", ""), 27 ] 28 29 # Prefixes that describe what the caller gets. 30 PREFIXES = ("get_", "get_count_", "get_exists_", "iterate_", 31 "insert_", "update_", "update_to_", "delete_", "do_") 32 33 # Not CRUD: transaction control, DDL, events, memory, session plumbing, 34 # cancellable handle APIs, and multi-function helper modules. Keep this list 35 # short and justified -- every entry is a rule the checker stops enforcing. 36 EXEMPT = { 37 # transaction control (declared in transaction.h, implemented in 38 # anastasis-db_pg.c) 39 "start", "commit", "rollback", "check_connection", "preflight", 40 # DDL and maintenance 41 "create_tables", "drop_tables", "gc", "gc_challenge_codes", 42 # events (declared in event.h, implemented in anastasis-db_pg.c) 43 "event_listen", "event_listen_cancel", "event_notify", 44 # subsystem lifecycle 45 "init", "init_admin", "fini", 46 } 47 48 # Headers that hold shared declarations rather than one public function. 49 HDR_EXEMPT = {"common", "event", "transaction"} 50 51 # Files that are not part of the API surface at all. Listed by name on 52 # purpose: a "skip anything called test_*" rule would silently exempt any 53 # future API function whose name starts with test_, which is exactly how 54 # ANASTASIS_DB_test_auth_iban_payment() and 55 # ANASTASIS_DB_test_challenge_code_satisfied() escaped notice. 56 SKIP = {"anastasis-db_pg", "anastasis-dbinit", "test_anastasis_db"} 57 58 # SQL that belongs to the schema or to the amalgamation boilerplate rather 59 # than to one API function. 60 SQL_NOT_A_FUNCTION = {"versioning", "drop", "commit", "procedures-prelude"} 61 SQL_SCHEMA_RE = re.compile(r"^stasis-\d+$") 62 # ------------------------------------------------------------ END CONFIG -- 63 64 PREP = re.compile(r'PREPARE\s*\(\s*"([A-Za-z0-9_]+)"', re.S) 65 66 errors = [] 67 68 69 def check_layer(impl_dir, hdr_dir, prefix, fpfx): 70 impl, hdr = pathlib.Path(impl_dir), pathlib.Path(hdr_dir) 71 if not impl.is_dir(): 72 return 73 decl = re.compile(r"^" + re.escape(prefix) + r"(\w+)\s*\((.*?)\)\s*;", 74 re.S | re.M) 75 stmt_owner = {} 76 stems = set() 77 78 for c in sorted(impl.glob("*.c")): 79 if c.stem in SKIP: 80 continue 81 if fpfx and not c.stem.startswith(fpfx): 82 continue 83 stem = c.stem[len(fpfx):] if fpfx else c.stem 84 stems.add(stem) 85 86 # 2. prefix from the closed set, or exempt 87 if stem not in EXEMPT and not stem.startswith(PREFIXES): 88 errors.append(f"{c}: '{stem}' uses no approved prefix") 89 90 # 7. numeric suffix 91 if re.search(r"\d$", stem): 92 errors.append(f"{c}: '{stem}' ends in a digit") 93 94 # 4. matching header 95 h = hdr / (stem + ".h") 96 if stem not in EXEMPT and not h.exists(): 97 errors.append(f"{c}: no matching header {h}") 98 99 text = c.read_text(errors="replace") 100 101 # 5. prepared statements named after the function 102 for s in dict.fromkeys(PREP.findall(text)): 103 if s != stem and not s.startswith(stem + "_"): 104 errors.append( 105 f"{c}: prepared statement '{s}' is not named after " 106 f"'{stem}'") 107 # 6. cross-file uniqueness 108 if s in stmt_owner and stmt_owner[s] != str(c): 109 errors.append( 110 f"{c}: prepared statement '{s}' also prepared in " 111 f"{stmt_owner[s]}") 112 stmt_owner[s] = str(c) 113 114 if not h.exists(): 115 continue 116 htext = re.sub(r"/\*.*?\*/", "", h.read_text(errors="replace"), 117 flags=re.S) 118 for name, params in decl.findall(htext): 119 # 1. declared names belong to their file. Exempt entries are the 120 # documented multi-function modules, so this does not apply there. 121 if (stem not in EXEMPT 122 and name != stem and not name.startswith(stem + "_")): 123 errors.append( 124 f"{h}: declares '{name}', which does not belong to " 125 f"'{stem}'") 126 # 3. iterate_ iff callback 127 has_cb = bool(re.search(r"\w*(Callback|Iterator)\s+\w+", params)) 128 if name.startswith("iterate_") and not has_cb: 129 errors.append( 130 f"{h}: '{name}' is iterate_ but takes no callback") 131 if name.startswith("get_") and has_cb: 132 errors.append(f"{h}: '{name}' takes a callback; use iterate_") 133 134 # 4. an amalgamated .sql is named after the .c that runs it, so that a 135 # renamed function cannot leave its stored procedure behind. 136 for s in sorted(impl.glob("*.sql")): 137 if s.stem in SQL_NOT_A_FUNCTION or SQL_SCHEMA_RE.match(s.stem): 138 continue 139 if s.stem not in stems: 140 errors.append(f"{s}: no {impl}/{s.stem}.c runs this SQL") 141 142 # 8. every header belongs to an implementation 143 for h in sorted(hdr.glob("*.h")): 144 if h.stem in HDR_EXEMPT: 145 continue 146 if not (impl / (fpfx + h.stem + ".c")).exists(): 147 errors.append(f"{h}: no implementation {impl / (fpfx + h.stem)}.c") 148 149 # 7. repeated library prefix anywhere in the tree 150 head = prefix.split("_")[0] 151 for f in list(impl.glob("*.[ch]")) + list(hdr.glob("*.h")): 152 if "unc-backup" in f.name: 153 continue 154 if re.search(r"\b" + re.escape(head) + r"_" + re.escape(prefix), 155 f.read_text(errors="replace")): 156 errors.append(f"{f}: repeated '{prefix}' prefix") 157 158 159 for a in LAYERS: 160 check_layer(*a) 161 162 for e in errors: 163 print("ERROR:", e) 164 print(f"\ndb-naming: {len(errors)} violation(s)") 165 sys.exit(1 if errors else 0)