scripts.check_graph_write_swallow

Reference › scripts › check_graph_write_swallow

The graph-write-swallow gate: a graph write failure must reach the caller. It fails a graph write inside a try body whose except handler cannot inform the caller of the failure, and it works as a ratchet against a baseline so existing findings are tolerated but new ones fail. tests/scripts/test_check_graph_write_swallow.py imports it.

Invocation: main takes one of three mutually exclusive modes: --check (the default; exit 1 on findings that are new against the baseline), --list (print every finding, including waived and baselined ones, and exit 0) and --update-baseline (rewrite the baseline to the current, non-waived findings). Further options are --repo-root, --baseline (default reports/graph-write-swallow-baseline.json under the repo root) and --roots (directories to scan).

scan parses each file and a _ScopeTracker visits every try statement. _try_has_graph_write and _call_is_graph_write decide whether the body writes to the graph, by resolving the Cypher text of a call (_resolve_cypher_text) and recognizing session-like receivers. _handler_informs_caller accepts a handler that re-raises or otherwise outlives the failure to the caller (_name_outlives_handler). A line may waive a finding (_is_waived). Each remaining Finding has a stable id for the baseline file (load_baseline, write_baseline).

At a glance

Source scripts/check_graph_write_swallow.py
Lines of code 549
Classes 2
Functions 14
Imports (internal) none
Imported by none
Tests that import it tests/scripts/test_check_graph_write_swallow.py

Class diagram

Class diagram scripts.check_graph_write_swallow

Dependency diagram

No internal imports and no importers.

API

Classes

Finding (dataclass)

Decorators: dataclass · Line 119

One flagged (or waived) try/except over a graph write.

Attribute Type Default
path str
scope str
ordinal int
lineno int
detail str
waived bool False
Method Signature Summary
finding_id property finding_id() -> str
as_dict as_dict() -> dict

_ScopeTracker

Bases: ast.NodeVisitor · Line 324

Walks a module tracking the enclosing function/class qualname, the enclosing function AST node (for the outlives/Cypher-variable resolution helpers), and a per-scope ordinal counter, collecting…

Method Signature Summary
__init__ __init__(relpath: str, lines: list[str], module: ast.Module) -> None
_scope_name _scope_name() -> str
_container _container() -> ast.AST The nearest enclosing function, or the module for top-level code — the scope _resolve_cypher_text/_name_outlives_handler search for prior bindings/returns.
_push _push(name: str, node: ast.AST) -> None
visit_FunctionDef visit_FunctionDef(node: ast.FunctionDef) -> None
visit_AsyncFunctionDef visit_AsyncFunctionDef(node: ast.AsyncFunctionDef) -> None
visit_ClassDef visit_ClassDef(node: ast.ClassDef) -> None
_check_try _check_try(node: ast.Try | ast.TryStar) -> None
visit_Try visit_Try(node: ast.Try) -> None
visit_TryStar visit_TryStar(node: ast.TryStar) -> None

Functions

Function Signature Summary
_is_waived _is_waived(lines: list[str], lineno: int) -> bool True if the 1-based lineno (or the line above) carries a gate-ok waiver.
_is_session_like _is_session_like(value: ast.AST) -> bool True if value looks like a Neo4j/Memgraph driver session object — a Name or attribute access whose identifier contains "session".
_resolve_cypher_text _resolve_cypher_text(expr: ast.expr | None, container: ast.AST, before_lineno: int, _depth: int = 0) -> str | None Best-effort static resolution of a Cypher argument to its literal text — enough to classify read vs. write by keyword content, never a real interpreter.
_call_is_graph_write _call_is_graph_write(call: ast.Call, container: ast.AST) -> bool True if call is a graph write — the execute_write/ unit_of_work methods unconditionally, session.run/ execute_autocommit by resolved Cypher content (conservatively a write when the…
_try_has_graph_write _try_has_graph_write(body: list[ast.stmt], container: ast.AST) -> bool
_assign_targets _assign_targets(node: ast.Assign | ast.AugAssign | ast.AnnAssign) -> list[ast.expr]
_name_outlives_handler _name_outlives_handler(name: str, container: ast.AST, before_lineno: int) -> bool True if name is bound elsewhere in container (a function body, or the module body for module-level code) in a way that lets a handler's assignment/aug-assignment to that bare NAME reach the…
_handler_informs_caller _handler_informs_caller(handler: ast.ExceptHandler, container: ast.AST, try_lineno: int) -> bool True if handler gives the caller some way to observe the failure: a re-raise, a return, an outliving assignment/mutation to an attribute/ subscript/non-local NAME, a global/nonlocal declaration…
_scan_file _scan_file(py_file: Path, repo_root: Path) -> list[Finding]
scan scan(roots: list[Path], repo_root: Path = REPO_ROOT) -> list[Finding] Scan every *.py file under roots (relative to repo_root unless already absolute) and return the findings, sorted by path.
load_baseline load_baseline(path: Path) -> set[str]
write_baseline write_baseline(path: Path, findings: list[Finding]) -> None
_print_finding _print_finding(f: Finding, tag: str) -> None
main main(argv: list[str] | None = None) -> int

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/scripts/check_graph_write_swallow.py

#!/usr/bin/env python3
"""Graph-write-swallow gate — a graph write failure must reach the caller.

WHY: a failed graph write caught and reduced to a log line makes contention
on the shared Memgraph indistinguishable from a bug. The caller gets no
signal, a read-back returns empty, and the failing test asserts on nothing
("assert record is not None"). Two confirmed instances motivated this gate:
``knowledge_graph/synchronizer.py`` (fixed) and
``knowledge_graph/cxp_auditor.py`` (``log_packet``/``log_routing``, which
swallow to a bare ``print()``).

DETECTION RULE (ast-based, not regex). Flag an ``ast.Try``/``ast.TryStar``
when BOTH hold:

  (a) its ``body`` contains a graph WRITE:
        - an unconditional write call — ``execute_write``/``unit_of_work``
          (the method name is the contract; content never exempts them);
        - a ``session.run``/``execute_autocommit`` call whose Cypher
          argument, when statically resolvable (a literal, an f-string, an
          implicit/explicit concatenation of literals, or a single local
          variable assigned one of those forms earlier in scope), contains
          a mutating clause — CREATE / MERGE / DELETE / SET / REMOVE / DROP
          / DETACH (case-insensitive, word-boundary). When the argument
          canNOT be resolved statically (a call result, an unexplained
          parameter, ...), the call stays conservatively classified as a
          write — the gate must not go blind;
        - OR a string constant anywhere in the body matching MERGE /
          CREATE ( / DETACH DELETE / SET (case-insensitive, word-boundary)
          — independent of the two call-based checks above, e.g. a Cypher
          literal passed to a helper the gate doesn't otherwise recognize;
  (b) it has a handler that CANNOT inform the caller: no ``raise``, no
      ``return``, and no assignment/mutation that outlives the handler —
      an attribute assignment, a subscript assignment, ``global``/
      ``nonlocal``, a call to a mutating collection method such as
      ``.append``/``.add``/``.update``, OR an ``ast.Assign``/
      ``ast.AugAssign`` to a bare NAME that is not local to the handler
      (the name is bound earlier in the same function scope — a parameter,
      a prior assignment, a loop variable — before the ``try``, or the name
      is read inside a ``return`` anywhere in the function, so the
      handler's write to it can reach the caller). A handler consisting
      only of logging/``print`` calls and/or ``pass`` is the textbook
      failing case, but this is the general rule: anything that neither
      raises, returns, nor mutates caller-visible state is a swallow.

A handler that re-raises, returns a falsy/None-vs-value signal, sets a
flag/attribute, increments a counter the function later returns, or appends
to an errors collection is COMPLIANT — the point is that the caller can
OBSERVE the failure, not that it must raise.

EXEMPTION: the repo's existing ``# gate-ok: <reason>`` waiver convention
(see ``scripts/gate_parallel_safety.py``) — a comment on the flagged
``try``/``try*`` line, or the line directly above it, waives that finding. A
reason is required; a bare ``# gate-ok:`` does not waive.

BASELINE / RATCHET (mirrors ``scripts/pyright_ratchet.py``'s "fail only on
NEW findings" contract (now in codetools:
src/codetools/refactor/pyright/ratchet.py), adapted to identifier-keyed findings the way
``scripts/check_ai_attribution.py`` keys its baseline on commit SHAs rather
than line numbers): findings are stable-identified by
``<relpath>:<enclosing-scope-qualname>:<ordinal-within-scope>`` — never a
raw line number, since those shift on unrelated edits. The baseline lives at
``reports/graph-write-swallow-baseline.json``. ``--check`` (default) exits 1
only for findings whose id is absent from the baseline; baselined findings
are reported but do not fail. ``--update-baseline`` rewrites the baseline to
the current (non-waived) finding set. ``--list`` prints every finding,
including waived and baselined ones, and always exits 0.

Roots scanned: ``src/`` and ``scripts/``.
"""

from __future__ import annotations

import argparse
import ast
import json
import re
from dataclasses import dataclass
from pathlib import Path

REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_ROOTS = ("src", "scripts")

# ``# gate-ok: <reason>`` on the flagged line (or the line directly above it)
# waives that one finding. The reason is mandatory — ``\S`` refuses bare tags.
# (same convention + regex as scripts/gate_parallel_safety.py)
_WAIVER_RE = re.compile(r"#\s*gate-ok:\s*\S")

# Unconditional write calls — the method name IS the contract; Cypher
# content never exempts them (a SELECT run inside execute_write is still a
# managed-transaction write call as far as the caller's failure-handling
# contract is concerned).
_UNCONDITIONAL_WRITE_CALL_ATTRS = {"execute_write", "unit_of_work"}
# ``session.run``/``execute_autocommit`` are classified by CONTENT (see
# _call_is_graph_write) since both are also used for reads.
_CONTENT_CLASSIFIED_CALL_ATTRS = {"execute_autocommit"}
_CYPHER_WRITE_RE = re.compile(
    r"\bMERGE\b|\bCREATE\s*\(|\bDETACH\s+DELETE\b|\bSET\b", re.IGNORECASE
)
# Content classifier for a resolved session.run/execute_autocommit Cypher
# string — broader than _CYPHER_WRITE_RE (adds REMOVE/DROP) since this is
# the sole signal once a call is known to target a session/autocommit path.
_MUTATING_CLAUSE_RE = re.compile(
    r"\b(?:CREATE|MERGE|DELETE|SET|REMOVE|DROP|DETACH)\b", re.IGNORECASE
)
# Calls on a mutating collection (``errors.append(exc)``) let a handler's
# side effect outlive it, same as an attribute/subscript assignment does.
_OUTLIVING_CALL_ATTRS = {
    "append",
    "add",
    "update",
    "extend",
    "put",
    "setdefault",
    "insert",
}


@dataclass
class Finding:
    """One flagged (or waived) try/except over a graph write."""

    path: str  # relpath, forward slashes
    scope: str  # enclosing qualname, "<module>" at top level
    ordinal: int  # 1-based occurrence within (path, scope) — NOT a line number
    lineno: int  # for human display only; never part of the identifier
    detail: str
    waived: bool = False

    @property
    def finding_id(self) -> str:
        return f"{self.path}:{self.scope}:{self.ordinal}"

    def as_dict(self) -> dict:
        return {
            "id": self.finding_id,
            "path": self.path,
            "scope": self.scope,
            "line_hint": self.lineno,
            "detail": self.detail,
        }


def _is_waived(lines: list[str], lineno: int) -> bool:
    """True if the 1-based *lineno* (or the line above) carries a gate-ok waiver."""
    for idx in (lineno - 1, lineno - 2):
        if 0 <= idx < len(lines) and _WAIVER_RE.search(lines[idx]):
            return True
    return False


def _is_session_like(value: ast.AST) -> bool:
    """True if *value* looks like a Neo4j/Memgraph driver session object —
    a ``Name`` or attribute access whose identifier contains "session"."""
    if isinstance(value, ast.Name):
        return "session" in value.id.lower()
    if isinstance(value, ast.Attribute):
        return "session" in value.attr.lower()
    return False


def _resolve_cypher_text(
    expr: ast.expr | None, container: ast.AST, before_lineno: int, _depth: int = 0
) -> str | None:
    """Best-effort static resolution of a Cypher argument to its literal
    text — enough to classify read vs. write by keyword content, never a
    real interpreter. Handles: a literal string, an f-string (only its
    literal segments — an interpolated ``{...}`` expression contributes
    nothing towards the match, since a mutating clause is never itself
    interpolated in this codebase), an implicit/explicit ``+``
    concatenation of resolvable parts, or a single local variable assigned
    one of those forms earlier (by line number) in *container*. Anything
    else — a call result, an unresolved parameter, an ambiguous multi-
    assignment — returns ``None`` so the caller stays conservative.
    """
    if expr is None or _depth > 8:
        return None
    if isinstance(expr, ast.Constant) and isinstance(expr.value, str):
        return expr.value
    if isinstance(expr, ast.JoinedStr):
        return "".join(
            v.value
            for v in expr.values
            if isinstance(v, ast.Constant) and isinstance(v.value, str)
        )
    if isinstance(expr, ast.BinOp) and isinstance(expr.op, ast.Add):
        left = _resolve_cypher_text(expr.left, container, before_lineno, _depth + 1)
        right = _resolve_cypher_text(expr.right, container, before_lineno, _depth + 1)
        if left is None or right is None:
            return None
        return left + right
    if isinstance(expr, ast.Name):
        candidate: ast.Assign | None = None
        for node in ast.walk(container):
            if not isinstance(node, ast.Assign) or len(node.targets) != 1:
                continue
            target = node.targets[0]
            if not (isinstance(target, ast.Name) and target.id == expr.id):
                continue
            if node.lineno > before_lineno:
                continue
            if candidate is None or node.lineno > candidate.lineno:
                candidate = node
        if candidate is None:
            return None
        return _resolve_cypher_text(
            candidate.value, container, before_lineno, _depth + 1
        )
    return None


def _call_is_graph_write(call: ast.Call, container: ast.AST) -> bool:
    """True if *call* is a graph write — the ``execute_write``/
    ``unit_of_work`` methods unconditionally, ``session.run``/
    ``execute_autocommit`` by resolved Cypher content (conservatively a
    write when the content can't be resolved)."""
    if not isinstance(call.func, ast.Attribute):
        return False
    attr = call.func.attr
    if attr in _UNCONDITIONAL_WRITE_CALL_ATTRS:
        return True
    content_classified = attr in _CONTENT_CLASSIFIED_CALL_ATTRS or (
        attr == "run" and _is_session_like(call.func.value)
    )
    if not content_classified:
        return False
    cypher_arg = call.args[0] if call.args else None
    text = _resolve_cypher_text(cypher_arg, container, call.lineno)
    if text is None:
        return True  # unresolvable Cypher argument — stay conservative
    return bool(_MUTATING_CLAUSE_RE.search(text))


def _try_has_graph_write(body: list[ast.stmt], container: ast.AST) -> bool:
    for stmt in body:
        for sub in ast.walk(stmt):
            if isinstance(sub, ast.Call) and _call_is_graph_write(sub, container):
                return True
            if (
                isinstance(sub, ast.Constant)
                and isinstance(sub.value, str)
                and _CYPHER_WRITE_RE.search(sub.value)
            ):
                return True
    return False


def _assign_targets(node: ast.Assign | ast.AugAssign | ast.AnnAssign) -> list[ast.expr]:
    if isinstance(node, ast.Assign):
        return list(node.targets)
    return [node.target]


def _name_outlives_handler(name: str, container: ast.AST, before_lineno: int) -> bool:
    """True if *name* is bound elsewhere in *container* (a function body, or
    the module body for module-level code) in a way that lets a handler's
    assignment/aug-assignment to that bare NAME reach the caller:

      - *name* is bound before *before_lineno* (the enclosing ``try``'s line)
        by a prior assignment, a function parameter, or a ``for``/``async
        for`` loop variable — the handler is updating an EXISTING binding
        the rest of the function (or a caller closing over it) can observe;
      - OR *name* is read inside any ``return`` statement's value anywhere
        in *container* — the handler's write can reach the caller through
        that return, regardless of where the binding started.

    A conservative ast-level approximation ("bound-before-try OR read-in-a-
    return"), not full dataflow analysis — sufficient to distinguish the
    real motivating case (a counter incremented in a loop's except clause,
    returned in a report dataclass once the loop ends) from a throwaway
    local that dies with the handler.
    """
    for node in ast.walk(container):
        if isinstance(node, ast.Return) and node.value is not None:
            for sub in ast.walk(node.value):
                if isinstance(sub, ast.Name) and sub.id == name:
                    return True
    for node in ast.walk(container):
        lineno = getattr(node, "lineno", None)
        if lineno is None or lineno >= before_lineno:
            continue
        if isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
            for target in _assign_targets(node):
                if isinstance(target, ast.Name) and target.id == name:
                    return True
        elif isinstance(node, (ast.For, ast.AsyncFor)) and isinstance(
            node.target, ast.Name
        ):
            if node.target.id == name:
                return True
        elif isinstance(node, ast.arg) and node.arg == name:
            return True
    return False


def _handler_informs_caller(
    handler: ast.ExceptHandler, container: ast.AST, try_lineno: int
) -> bool:
    """True if *handler* gives the caller some way to observe the failure:
    a re-raise, a return, an outliving assignment/mutation to an attribute/
    subscript/non-local NAME, a global/nonlocal declaration, or a call to a
    mutating collection method."""
    for stmt in handler.body:
        for sub in ast.walk(stmt):
            if isinstance(sub, ast.Raise):
                return True
            if isinstance(sub, ast.Return):
                return True
            if isinstance(sub, (ast.Global, ast.Nonlocal)):
                return True
            if isinstance(sub, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
                for target in _assign_targets(sub):
                    if isinstance(target, (ast.Attribute, ast.Subscript)):
                        return True
                    if isinstance(target, ast.Name) and _name_outlives_handler(
                        target.id, container, try_lineno
                    ):
                        return True
            if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute):
                if sub.func.attr in _OUTLIVING_CALL_ATTRS:
                    return True
    return False


class _ScopeTracker(ast.NodeVisitor):
    """Walks a module tracking the enclosing function/class qualname, the
    enclosing function AST node (for the outlives/Cypher-variable
    resolution helpers), and a per-scope ordinal counter, collecting
    graph-write-swallow findings."""

    def __init__(self, relpath: str, lines: list[str], module: ast.Module) -> None:
        self.relpath = relpath
        self.lines = lines
        self._module = module
        self._stack: list[str] = []
        self._func_stack: list[ast.FunctionDef | ast.AsyncFunctionDef] = []
        self._counters: dict[str, int] = {}
        self.findings: list[Finding] = []

    def _scope_name(self) -> str:
        return ".".join(self._stack) if self._stack else "<module>"

    def _container(self) -> ast.AST:
        """The nearest enclosing function, or the module for top-level code
        — the scope ``_resolve_cypher_text``/``_name_outlives_handler``
        search for prior bindings/returns."""
        return self._func_stack[-1] if self._func_stack else self._module

    def _push(self, name: str, node: ast.AST) -> None:
        self._stack.append(name)
        self.generic_visit(node)
        self._stack.pop()

    def visit_FunctionDef(self, node: ast.FunctionDef) -> None:
        self._func_stack.append(node)
        self._push(node.name, node)
        self._func_stack.pop()

    def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None:
        self._func_stack.append(node)
        self._push(node.name, node)
        self._func_stack.pop()

    def visit_ClassDef(self, node: ast.ClassDef) -> None:
        self._push(node.name, node)

    def _check_try(self, node: ast.Try | ast.TryStar) -> None:
        container = self._container()
        if _try_has_graph_write(node.body, container):
            noncompliant = [
                h
                for h in node.handlers
                if not _handler_informs_caller(h, container, node.lineno)
            ]
            if noncompliant:
                scope = self._scope_name()
                self._counters[scope] = self._counters.get(scope, 0) + 1
                ordinal = self._counters[scope]
                waived = _is_waived(self.lines, node.lineno)
                self.findings.append(
                    Finding(
                        path=self.relpath,
                        scope=scope,
                        ordinal=ordinal,
                        lineno=node.lineno,
                        detail=(
                            "graph write in try body swallowed by a handler "
                            "that cannot inform the caller"
                        ),
                        waived=waived,
                    )
                )
        self.generic_visit(node)

    def visit_Try(self, node: ast.Try) -> None:
        self._check_try(node)

    def visit_TryStar(self, node: ast.TryStar) -> None:
        self._check_try(node)


def _scan_file(py_file: Path, repo_root: Path) -> list[Finding]:
    try:
        source = py_file.read_text()
    except OSError, UnicodeDecodeError:
        return []
    try:
        tree = ast.parse(source, filename=str(py_file))
    except SyntaxError:
        return []
    lines = source.splitlines()
    relpath = py_file.relative_to(repo_root).as_posix()
    tracker = _ScopeTracker(relpath, lines, tree)
    tracker.visit(tree)
    return tracker.findings


def scan(roots: list[Path], repo_root: Path = REPO_ROOT) -> list[Finding]:
    """Scan every ``*.py`` file under *roots* (relative to *repo_root* unless
    already absolute) and return the findings, sorted by path."""
    findings: list[Finding] = []
    for root in roots:
        root_path = root if root.is_absolute() else repo_root / root
        if not root_path.exists():
            continue
        for py_file in sorted(root_path.rglob("*.py")):
            findings.extend(_scan_file(py_file, repo_root))
    return findings


def load_baseline(path: Path) -> set[str]:
    if not path.exists():
        return set()
    data = json.loads(path.read_text())
    return {entry["id"] for entry in data.get("findings", [])}


def write_baseline(path: Path, findings: list[Finding]) -> None:
    payload = {
        "generated_by": "scripts/check_graph_write_swallow.py --update-baseline",
        "findings": [f.as_dict() for f in sorted(findings, key=lambda f: f.finding_id)],
    }
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(json.dumps(payload, indent=2) + "\n")


def _print_finding(f: Finding, tag: str) -> None:
    print(f"[{tag}] {f.path}:{f.lineno} ({f.scope}) — {f.detail} [id={f.finding_id}]")


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(
        description=(
            "Gate: fail a graph write in a try body whose except handler "
            "cannot inform the caller of failure (ratchet vs. baseline)."
        )
    )
    mode = parser.add_mutually_exclusive_group()
    mode.add_argument(
        "--check",
        action="store_true",
        help="Ratchet check: exit 1 on findings NEW vs. the baseline (default).",
    )
    mode.add_argument(
        "--list",
        action="store_true",
        help="Print every finding, including waived/baselined ones. Exit 0.",
    )
    mode.add_argument(
        "--update-baseline",
        action="store_true",
        help="Rewrite the baseline to the current (non-waived) finding set.",
    )
    parser.add_argument(
        "--repo-root",
        type=Path,
        default=REPO_ROOT,
        help=f"Repo root to scan/relativize against (default: {REPO_ROOT}).",
    )
    parser.add_argument(
        "--baseline",
        type=Path,
        default=None,
        help=(
            "Baseline JSON path (default: <repo-root>/reports/"
            "graph-write-swallow-baseline.json)."
        ),
    )
    parser.add_argument(
        "--roots",
        nargs="+",
        default=list(DEFAULT_ROOTS),
        help=f"Directories to scan, relative to --repo-root (default: {DEFAULT_ROOTS}).",
    )
    args = parser.parse_args(argv)

    repo_root = args.repo_root.resolve()
    baseline_path = (
        args.baseline
        if args.baseline is not None
        else repo_root / "reports" / "graph-write-swallow-baseline.json"
    )
    roots = [Path(r) for r in args.roots]
    findings = scan(roots, repo_root)
    active = [f for f in findings if not f.waived]

    if args.list:
        if not findings:
            print("[graph-write-swallow] no findings.")
        for f in findings:
            _print_finding(f, "WAIVED" if f.waived else "FLAGGED")
        print(
            f"[graph-write-swallow] {len(findings)} total finding(s), "
            f"{len(active)} active (non-waived)."
        )
        return 0

    if args.update_baseline:
        write_baseline(baseline_path, active)
        print(
            f"[graph-write-swallow] baseline updated: {len(active)} finding(s) "
            f"-> {baseline_path}"
        )
        return 0

    # Default mode: --check (ratchet).
    baseline_ids = load_baseline(baseline_path)
    new_findings = [f for f in active if f.finding_id not in baseline_ids]
    baselined_count = len(active) - len(new_findings)

    if new_findings:
        print(
            f"[graph-write-swallow] FAIL: {len(new_findings)} NEW finding(s) "
            f"not in the baseline ({baselined_count} baselined, not counted):"
        )
        for f in new_findings:
            _print_finding(f, "NEW")
        print(
            "  Fix the swallow (raise/return/record the failure), waive it "
            "with '# gate-ok: <reason>' on the try line or the line above, "
            "or run --update-baseline if this is accepted debt."
        )
        return 1

    print(f"[graph-write-swallow] OK: {baselined_count} baselined finding(s), 0 new.")
    return 0


if __name__ == "__main__":
    raise SystemExit(main())