personal memory agent
0

Configure Feed

Select the types of activity you want to include in your feed.

solstone / scripts / check_journal_config_owner.py
19 kB 511 lines
1#!/usr/bin/env python3 2# SPDX-License-Identifier: AGPL-3.0-only 3# Copyright (c) 2026 sol pbc 4 5"""Journal config single-owner transaction lint. 6 7Invariant: ``config/journal.json`` is a single-owner transactional domain. 8Production code outside ``solstone.think.journal_config`` must not import or 9call the private raw serializer, compose its own read/lock/write cycle, replace 10the config file directly, re-export or wrap the private serializer, or create a 11second lock for the same file. 12 13Design decisions: 14 15 D1 - Import-binding resolution. Bare-name matching is forbidden. The scanner 16 records direct imports, module aliases, and dotted imports before deciding 17 whether a call or assignment targets ``_write_journal_config``, 18 ``get_journal_config_path``, ``atomic_replace``, ``hold_lock``, or 19 ``fcntl.flock``. 20 21 D2 - Config-path-specific replacement. ``atomic_replace`` is legitimate for 22 many other domains. This gate flags replacement only when the replacement 23 target resolves to ``get_journal_config_path(...)`` or a path expression 24 ending in ``config/journal.json``. 25 26 D3 - Owner boundary. The only production owner file is 27 ``solstone/think/journal_config.py``. Tests are excluded via the same 28 ``_is_test_file`` convention as sibling gates. 29 30 D4 - Empty allowlist. The migrated tree must be green without production 31 exceptions. A new violation fails immediately. 32 33Exit codes: 34 0 - no production violations 35 1 - any production violation is found 36""" 37 38from __future__ import annotations 39 40import argparse 41import ast 42import sys 43from pathlib import Path 44 45ROOT = Path(__file__).resolve().parent.parent 46 47OWNER_FILES: frozenset[str] = frozenset({"solstone/think/journal_config.py"}) 48ALLOWLIST: dict[tuple[str, str], int] = {} 49CONFIG_FILENAME = "journal.json" 50SIDECAR_LOCK_FILENAME = "." + CONFIG_FILENAME + ".lock" 51 52 53def _is_owner(rel: Path) -> bool: 54 return rel.as_posix() in OWNER_FILES 55 56 57def _is_test_file(rel: Path) -> bool: 58 return ( 59 "tests" in rel.parts 60 or rel.name == "conftest.py" 61 or (rel.name.startswith("test_") and rel.suffix == ".py") 62 ) 63 64 65def discover_modules(root: Path) -> list[Path]: 66 """Return posix-relative non-owner, non-test modules under ``solstone/``.""" 67 scope = root / "solstone" 68 if not scope.is_dir(): 69 return [] 70 71 found: list[Path] = [] 72 for path in sorted(scope.rglob("*.py")): 73 rel = path.relative_to(root) 74 if "__pycache__" in rel.parts: 75 continue 76 if _is_test_file(rel): 77 continue 78 if _is_owner(rel): 79 continue 80 found.append(rel) 81 return found 82 83 84def _dotted_name(node: ast.AST) -> str | None: 85 if isinstance(node, ast.Name): 86 return node.id 87 if isinstance(node, ast.Attribute): 88 base = _dotted_name(node.value) 89 if base: 90 return f"{base}.{node.attr}" 91 return None 92 93 94class _Bindings: 95 def __init__(self) -> None: 96 self.private_serializer_names: set[str] = set() 97 self.journal_config_modules: set[str] = set() 98 self.get_path_names: set[str] = set() 99 self.atomic_replace_names: set[str] = set() 100 self.journal_io_modules: set[str] = set() 101 self.hold_lock_names: set[str] = set() 102 self.fcntl_modules: set[str] = set() 103 self.flock_names: set[str] = set() 104 self.os_modules: set[str] = set() 105 self.os_replace_names: set[str] = set() 106 107 108def _collect_bindings(tree: ast.AST) -> _Bindings: 109 bindings = _Bindings() 110 for node in ast.walk(tree): 111 if isinstance(node, ast.Import): 112 for alias in node.names: 113 bound = alias.asname or alias.name 114 if alias.name == "solstone.think.journal_config": 115 bindings.journal_config_modules.add(bound) 116 elif alias.name == "solstone.think.journal_io": 117 bindings.journal_io_modules.add(bound) 118 elif alias.name == "solstone.think.journal_io.atomic": 119 bindings.journal_io_modules.add(bound) 120 elif alias.name == "solstone.think.journal_io.locking": 121 bindings.journal_io_modules.add(bound) 122 elif alias.name == "fcntl": 123 bindings.fcntl_modules.add(bound) 124 elif alias.name == "os": 125 bindings.os_modules.add(bound) 126 elif isinstance(node, ast.ImportFrom): 127 module = node.module or "" 128 for alias in node.names: 129 bound = alias.asname or alias.name 130 if module == "solstone.think" and alias.name == "journal_config": 131 bindings.journal_config_modules.add(bound) 132 elif module == "solstone.think.journal_config": 133 if alias.name == "_write_journal_config": 134 bindings.private_serializer_names.add(bound) 135 elif alias.name == "get_journal_config_path": 136 bindings.get_path_names.add(bound) 137 elif module in { 138 "solstone.think.journal_io", 139 "solstone.think.journal_io.atomic", 140 }: 141 if alias.name == "atomic_replace": 142 bindings.atomic_replace_names.add(bound) 143 elif alias.name == "hold_lock": 144 bindings.hold_lock_names.add(bound) 145 elif module == "solstone.think.journal_io.locking": 146 if alias.name == "hold_lock": 147 bindings.hold_lock_names.add(bound) 148 elif module == "fcntl" and alias.name == "flock": 149 bindings.flock_names.add(bound) 150 elif module == "os" and alias.name == "replace": 151 bindings.os_replace_names.add(bound) 152 return bindings 153 154 155def _called_attr(func: ast.expr, modules: set[str], attr: str, full_name: str) -> bool: 156 if not isinstance(func, ast.Attribute) or func.attr != attr: 157 return False 158 if isinstance(func.value, ast.Name) and func.value.id in modules: 159 return True 160 return _dotted_name(func) == full_name 161 162 163def _is_private_serializer_expr(node: ast.AST, bindings: _Bindings) -> bool: 164 if isinstance(node, ast.Name): 165 return node.id in bindings.private_serializer_names 166 if isinstance(node, ast.Attribute) and node.attr == "_write_journal_config": 167 if isinstance(node.value, ast.Name) and node.value.id in ( 168 bindings.journal_config_modules 169 ): 170 return True 171 return ( 172 _dotted_name(node) == "solstone.think.journal_config._write_journal_config" 173 ) 174 return False 175 176 177def _is_private_serializer_call(node: ast.Call, bindings: _Bindings) -> bool: 178 return _is_private_serializer_expr(node.func, bindings) 179 180 181def _is_get_config_path_call(node: ast.AST, bindings: _Bindings) -> bool: 182 if not isinstance(node, ast.Call): 183 return False 184 func = node.func 185 if isinstance(func, ast.Name) and func.id in bindings.get_path_names: 186 return True 187 return _called_attr( 188 func, 189 bindings.journal_config_modules, 190 "get_journal_config_path", 191 "solstone.think.journal_config.get_journal_config_path", 192 ) 193 194 195def _constant_path_parts(node: ast.AST) -> list[str]: 196 if isinstance(node, ast.Constant) and isinstance(node.value, str): 197 return [node.value] 198 if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): 199 return _constant_path_parts(node.left) + _constant_path_parts(node.right) 200 if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): 201 left = "".join(_constant_path_parts(node.left)) 202 right = "".join(_constant_path_parts(node.right)) 203 if left or right: 204 return [left + right] 205 if isinstance(node, ast.Call): 206 parts: list[str] = [] 207 for arg in node.args: 208 parts.extend(_constant_path_parts(arg)) 209 return parts 210 return [] 211 212 213def _parts_end_with_config_path(parts: list[str]) -> bool: 214 normalized: list[str] = [] 215 for part in parts: 216 normalized.extend( 217 piece for piece in part.replace("\\", "/").split("/") if piece 218 ) 219 return len(normalized) >= 2 and normalized[-2:] == ["config", CONFIG_FILENAME] 220 221 222def _parts_contain_sidecar_lock(parts: list[str]) -> bool: 223 for part in parts: 224 if SIDECAR_LOCK_FILENAME in part.replace("\\", "/").split("/"): 225 return True 226 return False 227 228 229def _assigned_path_names( 230 tree: ast.AST, bindings: _Bindings 231) -> tuple[set[str], set[str]]: 232 config_path_names: set[str] = set() 233 sidecar_lock_names: set[str] = set() 234 changed = True 235 while changed: 236 changed = False 237 for node in ast.walk(tree): 238 value: ast.AST | None = None 239 targets: list[ast.expr] = [] 240 if isinstance(node, ast.Assign): 241 value = node.value 242 targets = list(node.targets) 243 elif isinstance(node, ast.AnnAssign): 244 value = node.value 245 targets = [node.target] 246 if value is None: 247 continue 248 is_config = _is_config_path_expr(value, bindings, config_path_names) 249 is_sidecar = _is_sidecar_lock_expr(value, sidecar_lock_names) 250 for target in targets: 251 if not isinstance(target, ast.Name): 252 continue 253 if is_config and target.id not in config_path_names: 254 config_path_names.add(target.id) 255 changed = True 256 if is_sidecar and target.id not in sidecar_lock_names: 257 sidecar_lock_names.add(target.id) 258 changed = True 259 return config_path_names, sidecar_lock_names 260 261 262def _is_config_path_expr( 263 node: ast.AST, 264 bindings: _Bindings, 265 config_path_names: set[str], 266) -> bool: 267 if isinstance(node, ast.Name): 268 return node.id in config_path_names 269 if _is_get_config_path_call(node, bindings): 270 return True 271 return _parts_end_with_config_path(_constant_path_parts(node)) 272 273 274def _is_sidecar_lock_expr(node: ast.AST, sidecar_lock_names: set[str]) -> bool: 275 if isinstance(node, ast.Name): 276 return node.id in sidecar_lock_names 277 return _parts_contain_sidecar_lock(_constant_path_parts(node)) 278 279 280def _called_atomic_replace(func: ast.expr, bindings: _Bindings) -> bool: 281 if isinstance(func, ast.Name): 282 return func.id in bindings.atomic_replace_names 283 return _called_attr( 284 func, 285 bindings.journal_io_modules, 286 "atomic_replace", 287 "solstone.think.journal_io.atomic_replace", 288 ) 289 290 291def _called_hold_lock(func: ast.expr, bindings: _Bindings) -> bool: 292 if isinstance(func, ast.Name): 293 return func.id in bindings.hold_lock_names 294 return _called_attr( 295 func, 296 bindings.journal_io_modules, 297 "hold_lock", 298 "solstone.think.journal_io.hold_lock", 299 ) 300 301 302def _called_flock(func: ast.expr, bindings: _Bindings) -> bool: 303 if isinstance(func, ast.Name): 304 return func.id in bindings.flock_names 305 return _called_attr(func, bindings.fcntl_modules, "flock", "fcntl.flock") 306 307 308def _called_os_replace(func: ast.expr, bindings: _Bindings) -> bool: 309 if isinstance(func, ast.Name): 310 return func.id in bindings.os_replace_names 311 return _called_attr(func, bindings.os_modules, "replace", "os.replace") 312 313 314def _is_path_replace_call(node: ast.Call) -> bool: 315 return isinstance(node.func, ast.Attribute) and node.func.attr == "replace" 316 317 318def scan_source(source: str, filename: str = "<source>") -> list[tuple[int, str, str]]: 319 """Return sorted ``(lineno, kind, detail)`` production violations.""" 320 tree = ast.parse(source, filename=filename) 321 bindings = _collect_bindings(tree) 322 config_path_names, sidecar_lock_names = _assigned_path_names(tree, bindings) 323 findings: list[tuple[int, str, str]] = [] 324 325 for node in ast.walk(tree): 326 if isinstance(node, ast.ImportFrom): 327 if node.module == "solstone.think.journal_config": 328 for alias in node.names: 329 if alias.name == "_write_journal_config": 330 findings.append( 331 ( 332 node.lineno, 333 "private_serializer", 334 "_write_journal_config import", 335 ) 336 ) 337 elif isinstance(node, (ast.Assign, ast.AnnAssign)): 338 value = node.value 339 if value is not None and _is_private_serializer_expr(value, bindings): 340 findings.append( 341 ( 342 node.lineno, 343 "private_serializer_wrapper", 344 "_write_journal_config alias or re-export", 345 ) 346 ) 347 elif isinstance(node, ast.FunctionDef): 348 for child in ast.walk(node): 349 if isinstance(child, ast.Call) and _is_private_serializer_call( 350 child, bindings 351 ): 352 findings.append( 353 ( 354 child.lineno, 355 "private_serializer_wrapper", 356 f"{node.name} wraps _write_journal_config", 357 ) 358 ) 359 elif isinstance(node, ast.Call): 360 if _is_private_serializer_call(node, bindings): 361 findings.append( 362 ( 363 node.lineno, 364 "private_serializer", 365 "_write_journal_config call", 366 ) 367 ) 368 elif _called_atomic_replace(node.func, bindings): 369 if node.args and _is_config_path_expr( 370 node.args[0], bindings, config_path_names 371 ): 372 findings.append( 373 ( 374 node.lineno, 375 "journal_config_replace", 376 "atomic_replace targets config/journal.json", 377 ) 378 ) 379 elif _called_os_replace(node.func, bindings): 380 if len(node.args) >= 2 and _is_config_path_expr( 381 node.args[1], bindings, config_path_names 382 ): 383 findings.append( 384 ( 385 node.lineno, 386 "journal_config_replace", 387 "os.replace targets config/journal.json", 388 ) 389 ) 390 elif _is_path_replace_call(node): 391 if node.args and _is_config_path_expr( 392 node.args[0], bindings, config_path_names 393 ): 394 findings.append( 395 ( 396 node.lineno, 397 "journal_config_replace", 398 "Path.replace targets config/journal.json", 399 ) 400 ) 401 elif _called_hold_lock(node.func, bindings): 402 if node.args and _is_config_path_expr( 403 node.args[0], bindings, config_path_names 404 ): 405 findings.append( 406 ( 407 node.lineno, 408 "second_config_lock", 409 "hold_lock targets config/journal.json", 410 ) 411 ) 412 elif _called_flock(node.func, bindings): 413 if ( 414 sidecar_lock_names 415 or SIDECAR_LOCK_FILENAME in source 416 or any( 417 _is_sidecar_lock_expr(arg, sidecar_lock_names) 418 for arg in node.args 419 ) 420 ): 421 findings.append( 422 ( 423 node.lineno, 424 "second_config_lock", 425 "fcntl.flock targets journal config sidecar lock", 426 ) 427 ) 428 429 findings.sort() 430 return findings 431 432 433def scan_file(path: Path) -> list[tuple[int, str, str]]: 434 return scan_source(path.read_text(encoding="utf-8"), filename=str(path)) 435 436 437def count_violations(root: Path) -> dict[tuple[str, str], int]: 438 """Map ``(posix-relpath, kind)`` -> occurrence count across production.""" 439 counts: dict[tuple[str, str], int] = {} 440 for rel in discover_modules(root): 441 for _lineno, kind, _detail in scan_file(root / rel): 442 key = (rel.as_posix(), kind) 443 counts[key] = counts.get(key, 0) + 1 444 return counts 445 446 447def evaluate( 448 root: Path, 449 allowlist: dict[tuple[str, str], int], 450) -> tuple[list[str], list[str]]: 451 """Return ``(over, tracked)`` human-readable lines.""" 452 over: list[str] = [] 453 tracked: list[str] = [] 454 455 for rel in discover_modules(root): 456 rel_str = rel.as_posix() 457 by_kind: dict[str, list[tuple[int, str]]] = {} 458 for lineno, kind, detail in scan_file(root / rel): 459 by_kind.setdefault(kind, []).append((lineno, detail)) 460 for kind, entries in sorted(by_kind.items()): 461 count = len(entries) 462 allowed = allowlist.get((rel_str, kind), 0) 463 if count > allowed: 464 details = ", ".join( 465 f"line {lineno} ({detail})" for lineno, detail in entries 466 ) 467 over.append( 468 f"{rel_str}: {kind} count {count} exceeds allowed {allowed}: " 469 f"{details}" 470 ) 471 elif allowed: 472 tracked.append(f"{rel_str}: {count}/{allowed} {kind} (allowlisted)") 473 return over, tracked 474 475 476def main(argv: list[str] | None = None) -> int: 477 parser = argparse.ArgumentParser(description="Journal config owner lint") 478 parser.add_argument( 479 "--root", 480 type=Path, 481 default=ROOT, 482 help="Repository root to scan (defaults to the checkout root).", 483 ) 484 args = parser.parse_args(argv) 485 486 over, tracked = evaluate(args.root, ALLOWLIST) 487 488 if tracked: 489 print("journal-config-owner: known violations (allowlisted):") 490 for line in tracked: 491 print(f" {line}") 492 print() 493 494 if over: 495 print("journal-config-owner: NEW violations:", file=sys.stderr) 496 for line in over: 497 print(f" {line}", file=sys.stderr) 498 print(file=sys.stderr) 499 print( 500 "Route config/journal.json writes through " 501 "solstone.think.journal_config.mutate_journal_config.", 502 file=sys.stderr, 503 ) 504 return 1 505 506 print("journal-config-owner: pass") 507 return 0 508 509 510if __name__ == "__main__": 511 raise SystemExit(main())