bugclass-detectors/bugclass_detectors/core.py
Elite Agentic Solutions 7ee5300d43 bugclass-detectors v0.1.0 — five silent-failure bug-class checks (MIT)
AST-aware, stdlib-only static checks: swallowed-exception-around-write (C1),
undefined-name (C2), hardcoded-secret-literal (C3), fail-open-flag (C4),
rotated-secret-captured-once (C5). Tests + CI + examples included.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-12 01:29:50 -07:00

665 lines
26 KiB
Python

#!/usr/bin/env python3
"""bugclass-detectors — the five generic silent-failure bug-class checks.
These are the bug classes that look fine in review, pass tests, and keep a
process "green" while its real job silently fails. They are the classes a
fast-moving (and especially an AI-assisted) codebase reintroduces over and over.
C1 SWALLOWED_EXCEPTION_AROUND_WRITE
A bare/catch-all `except` that swallows an exception wrapping a WRITE
(DB/cache/file/network). The producer stays alive, the data is lost,
nothing logs. The most dangerous silent-failure shape there is.
C2 UNDEFINED_NAME
Use of a name that is never bound in its scope and is not a builtin,
import, parameter, global, comprehension var, or closure var -> a
NameError that only fires on the unlucky code path. (pyflakes F821 class,
no pyflakes dependency.)
C3 HARDCODED_SECRET_LITERAL
A password/token/key assigned to an inline string literal (in code or a
systemd `Environment=` line) instead of being read from the environment /
a secrets store. Diverges silently the moment the real secret rotates.
C4 FAIL_OPEN_FLAG
A safety / kill / arming / feature gate read from a backing store that is
repointable by a single env var (HOST/PORT/URL), with no independent
presence assertion. If the flag is ABSENT on the repointed store it reads
as falsy -> the gate fails OPEN instead of closed.
C5 ROTATED_SECRET_CAPTURED_ONCE
A long-lived consumer (a daemon / `while True` loop) that captures a
secret ONCE at module/global scope. When that secret rotates, the
long-lived process keeps using the stale value and silently 401/403s.
Every check is AST/tokenizer-aware, READ-ONLY, stdlib-only, and returns a uniform
finding dict: {check, severity, file, lineno, snippet, why}.
Disable any check with an env flag, e.g. BUGCLASS_DISABLE=C1,C4 (no-op for those).
"""
from __future__ import annotations
import ast
import io
import os
import re
import sys
import json
import glob
import argparse
import builtins
import tokenize
__version__ = "0.1.0"
SKIP_SUBSTR = (
"/__pycache__/", "/.git/", "/node_modules/", "/.venv/", "/venv/",
"/site-packages/", "/dist-packages/", ".bak", ".pyc",
)
# --------------------------------------------------------------------------- #
# Shared helpers
# --------------------------------------------------------------------------- #
def F(check, severity, file, lineno, snippet, why):
return {"check": check, "severity": severity, "file": file,
"lineno": lineno, "snippet": snippet, "why": why}
def _read(path):
try:
with open(path, "r", encoding="utf-8", errors="replace") as fh:
return fh.read()
except Exception:
return ""
def _snippet(src, lineno, width=160):
lines = src.splitlines()
if 1 <= lineno <= len(lines):
return lines[lineno - 1].strip()[:width]
return ""
def _code_line_set(src):
"""1-based line numbers that are EXECUTABLE code (not inside a comment or a
string/docstring). Returns None on tokenizer failure (treat all lines as code,
the conservative choice)."""
code_lines, str_only = set(), set()
try:
for tok in tokenize.generate_tokens(io.StringIO(src).readline):
t, (srow, _), (erow, _) = tok.type, tok.start, tok.end
if t == tokenize.STRING:
# a string spanning multiple lines is "inside a string" on its
# interior lines (docstring body); the start line may still be code.
str_only.update(range(srow + 1, erow + 1))
elif t == tokenize.COMMENT:
pass # a comment never makes a line non-code by itself
elif t not in (tokenize.NL, tokenize.NEWLINE, tokenize.INDENT,
tokenize.DEDENT, tokenize.ENCODING, tokenize.ENDMARKER):
code_lines.add(srow)
except Exception:
return None
# a line is code if it carries any real token, even if it also has a string.
return code_lines - (str_only - code_lines)
def _iter_files(roots, exts):
for root in roots:
if os.path.isfile(root):
if root.endswith(exts):
yield root
continue
for dp, dns, fns in os.walk(root):
dns[:] = [d for d in dns if d not in (
".git", "__pycache__", "node_modules", ".venv", "venv")]
for fn in fns:
if not fn.endswith(exts):
continue
p = os.path.join(dp, fn)
if any(s in p for s in SKIP_SUBSTR):
continue
yield p
# ========================================================================== #
# C1 — SWALLOWED_EXCEPTION_AROUND_WRITE
# ========================================================================== #
DB_CACHE_WRITE_METHODS = {
# cache / kv
"hset", "hmset", "set", "setex", "setnx", "psetex", "mset", "msetnx",
"lpush", "rpush", "lset", "linsert", "xadd", "zadd", "zincrby", "sadd",
"incr", "incrby", "decr", "hincrby", "hincrbyfloat", "expire", "pexpire",
"expireat", "append", "getset", "hdel", "delete", "ltrim", "publish",
# sql / db / orm
"insert", "executemany", "commit", "insert_df", "insert_dataframe",
"bulk_create",
}
FILE_WRITE_METHODS = {"write", "writelines", "writerow", "writerows", "flush"}
MODULE_WRITERS = {
("json", "dump"), ("pickle", "dump"), ("os", "replace"), ("os", "rename"),
("shutil", "copy"), ("shutil", "copy2"), ("shutil", "move"),
}
PRIMARY_NAME_HINTS = (
"emit", "record", "write", "snapshot", "flush", "persist", "save",
"publish", "store", "commit", "dump", "push", "send", "report",
"upload", "ingest", "sink", "export",
)
BEST_EFFORT_HINTS = ("expire", "pexpire", "ttl", "heartbeat", "close",
"cleanup", "metric")
def _is_catch_all(handler):
if handler.type is None:
return True
t = handler.type
if isinstance(t, ast.Name) and t.id in ("Exception", "BaseException"):
return True
if isinstance(t, ast.Tuple):
return any(isinstance(e, ast.Name) and e.id in ("Exception", "BaseException")
for e in t.elts)
return False
def _handler_swallow_kind(handler):
"""'continue' (per-item skip), 'abandon' (pass/return/break/...), or None
(the handler logs / re-raises / does real work)."""
kind, saw = "abandon", False
for node in handler.body:
if isinstance(node, ast.Pass):
saw = True
elif isinstance(node, ast.Continue):
kind, saw = "continue", True
elif isinstance(node, ast.Break):
saw = True
elif isinstance(node, ast.Return):
if node.value is None or isinstance(node.value, ast.Constant):
saw = True
else:
return None
elif (isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant)
and node.value.value is Ellipsis):
saw = True
else:
return None
return kind if saw else None
def _handler_logs_or_reraises(handler):
for node in ast.walk(handler):
if isinstance(node, ast.Raise):
return True
if isinstance(node, ast.Call):
fn = node.func
name = fn.attr.lower() if isinstance(fn, ast.Attribute) else (
fn.id.lower() if isinstance(fn, ast.Name) else "")
if any(k in name for k in ("log", "print", "warn", "error", "alert",
"exception", "critical", "capture",
"notify", "sentry", "report_error")):
return True
return False
def _writes_in_body(body):
hits = []
for stmt in body:
for node in ast.walk(stmt):
if not isinstance(node, ast.Call):
continue
fn = node.func
if isinstance(fn, ast.Attribute):
ml = fn.attr.lower()
if ml in DB_CACHE_WRITE_METHODS:
hits.append((node.lineno, fn.attr, "db/cache", ml))
elif ml in FILE_WRITE_METHODS:
hits.append((node.lineno, fn.attr, "file", ml))
elif (isinstance(fn.value, ast.Name)
and (fn.value.id, fn.attr) in MODULE_WRITERS):
hits.append((node.lineno, f"{fn.value.id}.{fn.attr}", "file", ml))
elif isinstance(fn, ast.Name) and fn.id == "open":
for a in node.args[1:]:
if (isinstance(a, ast.Constant) and isinstance(a.value, str)
and any(c in a.value for c in ("w", "a", "x", "+"))):
hits.append((node.lineno, "open(...,w)", "file", "open"))
return hits
def _enclosing_func(tree, target):
best = None
for node in ast.walk(tree):
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
if getattr(node, "lineno", 0) <= target.lineno <= getattr(node, "end_lineno", 0):
if best is None or node.lineno > best.lineno:
best = node
return best
def _func_has_other_effects(func, try_node):
if func is None:
return True
other = 0
for node in ast.walk(func):
if isinstance(node, ast.Return) and node.value is not None \
and not isinstance(node.value, ast.Constant):
other += 1
if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute):
nm = node.func.attr.lower()
if (nm in DB_CACHE_WRITE_METHODS or nm in FILE_WRITE_METHODS
or "log" in nm or "print" in nm):
if not (getattr(try_node, "lineno", 0) <= node.lineno
<= getattr(try_node, "end_lineno", 1 << 30)):
other += 1
return other > 0
def check_C1_swallowed_write(path):
src = _read(path)
try:
tree = ast.parse(src)
except Exception:
return []
lines, out = src.splitlines(), []
for node in ast.walk(tree):
if not isinstance(node, ast.Try):
continue
writes = _writes_in_body(node.body)
if not writes:
continue
for handler in node.handlers:
if not _is_catch_all(handler) or _handler_logs_or_reraises(handler):
continue
kind = _handler_swallow_kind(handler)
if kind is None:
continue
w_line, w_name, w_kind, w_method = writes[0]
func = _enclosing_func(tree, node)
fname = func.name if func else "<module>"
in_loop = any(
isinstance(n, (ast.For, ast.While, ast.AsyncFor))
and getattr(n, "lineno", 0) <= node.lineno <= getattr(n, "end_lineno", 0)
for n in ast.walk(tree))
primary = any(h in fname.lower() for h in PRIMARY_NAME_HINTS)
best_effort = (any(h in w_method for h in BEST_EFFORT_HINTS)
or any(h in w_name.lower() for h in BEST_EFFORT_HINTS))
has_other = _func_has_other_effects(func, node)
snip = (lines[handler.lineno - 1].strip()
if 0 < handler.lineno <= len(lines) else "except: <swallow>")
wsnip = lines[w_line - 1].strip() if 0 < w_line <= len(lines) else w_name
if best_effort and not primary:
sev = "LOW"
why = (f"catch-all swallow around a best-effort {w_kind} write "
f"`{w_method}` in {fname}() — failure is invisible but the write "
f"is non-primary (ttl/heartbeat/cleanup class).")
elif kind == "continue" and in_loop and not primary:
sev = "MED"
why = (f"`except: continue` skips one item's {w_kind} write `{w_method}` "
f"in a loop in {fname}() — fine per item, but a SYSTEMATIC failure "
f"drops the whole batch with no log. Count failures and alert.")
elif primary or not has_other:
sev = "HIGH"
reason = ("function name marks it a primary producer"
if primary else "the write is the function's ONLY output")
kn = " and the handler ABANDONS it (pass/return)" if kind == "abandon" else ""
why = (f"catch-all/bare except SILENTLY SWALLOWS a {w_kind} write "
f"`{w_method}` ({wsnip[:60]}) — {reason}{kn}. The producer looks "
f"alive, the data is lost, nothing logs. Add logging + re-raise/alert.")
else:
sev = "MED"
why = (f"catch-all swallow around a {w_kind} write `{w_method}` in "
f"{fname}() — partially visible (other effects exist) but the "
f"write can still vanish silently.")
out.append(F("C1_SWALLOWED_WRITE", sev, path, handler.lineno, snip, why))
return out
# ========================================================================== #
# C2 — UNDEFINED_NAME
# ========================================================================== #
_BUILTINS = set(dir(builtins)) | {
"__file__", "__name__", "__doc__", "__package__", "__spec__", "__loader__",
"__builtins__", "__class__", "self", "cls", "_"}
class _ScopeCollector(ast.NodeVisitor):
def __init__(self):
self.bound = set()
def _t(self, target):
for n in ast.walk(target):
if isinstance(n, ast.Name):
self.bound.add(n.id)
def visit_FunctionDef(self, node):
self.bound.add(node.name)
visit_AsyncFunctionDef = visit_FunctionDef
def visit_ClassDef(self, node):
self.bound.add(node.name)
def visit_Assign(self, node):
for t in node.targets:
self._t(t)
self.generic_visit(node)
def visit_AnnAssign(self, node):
if node.target:
self._t(node.target)
self.generic_visit(node)
def visit_AugAssign(self, node):
self._t(node.target)
self.generic_visit(node)
def visit_NamedExpr(self, node):
self._t(node.target)
self.generic_visit(node)
def visit_For(self, node):
self._t(node.target)
self.generic_visit(node)
visit_AsyncFor = visit_For
def visit_With(self, node):
for item in node.items:
if item.optional_vars:
self._t(item.optional_vars)
self.generic_visit(node)
visit_AsyncWith = visit_With
def visit_Import(self, node):
for a in node.names:
self.bound.add((a.asname or a.name).split(".")[0])
def visit_ImportFrom(self, node):
for a in node.names:
self.bound.add(a.asname or a.name)
def visit_Global(self, node):
self.bound.update(node.names)
def visit_Nonlocal(self, node):
self.bound.update(node.names)
def visit_ExceptHandler(self, node):
if node.name:
self.bound.add(node.name)
self.generic_visit(node)
def visit_comprehension(self, node):
self._t(node.target)
self.generic_visit(node)
def _scope_bound(node):
c = _ScopeCollector()
for child in ast.iter_child_nodes(node):
c.visit(child)
args = getattr(node, "args", None)
if args:
for a in (args.posonlyargs + args.args + args.kwonlyargs):
c.bound.add(a.arg)
if args.vararg:
c.bound.add(args.vararg.arg)
if args.kwarg:
c.bound.add(args.kwarg.arg)
return c.bound
def _collect_loads(node):
loads = []
def walk(n):
for child in ast.iter_child_nodes(n):
if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
continue
if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load):
loads.append(child)
walk(child)
walk(node)
return loads
def check_C2_undefined_name(path):
src = _read(path)
try:
tree = ast.parse(src)
except Exception:
return []
mod_bound = _scope_bound(tree) | _BUILTINS
parents = {}
for parent in ast.walk(tree):
for child in ast.iter_child_nodes(parent):
parents[child] = parent
def enclosing_func_bound(fn):
acc, node = set(), parents.get(fn)
while node is not None:
if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
acc |= _scope_bound(node)
node = parents.get(node)
return acc
out = []
for fn in ast.walk(tree):
if not isinstance(fn, (ast.FunctionDef, ast.AsyncFunctionDef)):
continue
avail = _scope_bound(fn) | enclosing_func_bound(fn) | mod_bound
for nm in _collect_loads(fn):
if nm.id not in avail:
out.append(F("C2_UNDEFINED_NAME", "HIGH", path, nm.lineno,
_snippet(src, nm.lineno),
f"name `{nm.id}` is used in {fn.name}() but never bound in "
f"this scope and is not a builtin/import/param/closure var "
f"-> NameError on this code path."))
return out
# ========================================================================== #
# C3 — HARDCODED_SECRET_LITERAL
# ========================================================================== #
_INLINE_ENV_SECRET = re.compile(
r'(?im)^\s*Environment=.*(PASS(WORD)?|SECRET|TOKEN|AUTH|API_?KEY)\s*=')
_PY_INLINE_SECRET = re.compile(
r'(?i)(password|passwd|secret|api_?key|auth_?token|access_?token|'
r'private_?key|client_?secret)\s*=\s*'
r'[\'"][A-Za-z0-9+/=_-]{16,}[\'"]')
# avoid flagging obvious placeholders / reads-from-env
_PLACEHOLDER = re.compile(
r'(?i)(xxx|placeholder|your[_-]?|example|changeme|<.*>|os\.environ|getenv|'
r'\$\{|process\.env|settings\.|config\.|^[\'"]{2}$)')
def check_C3_hardcoded_secret(path):
src = _read(path)
out = []
is_unit = path.endswith((".service", ".conf", ".env")) or "/systemd/" in path
code = _code_line_set(src) if path.endswith(".py") else None
for i, ln in enumerate(src.splitlines(), 1):
if is_unit and _INLINE_ENV_SECRET.search(ln):
out.append(F("C3_HARDCODED_SECRET", "HIGH", path, i, ln.strip()[:120],
"inline unit-file secret diverges from the rotated secret on the "
"next rotation — use EnvironmentFile= / a secrets store."))
elif path.endswith(".py") and _PY_INLINE_SECRET.search(ln):
if code is not None and i not in code:
continue
if _PLACEHOLDER.search(ln):
continue
masked = re.sub(r'[\'"][A-Za-z0-9+/=_-]{16,}[\'"]', '"<masked>"', ln.strip())
out.append(F("C3_HARDCODED_SECRET", "HIGH", path, i, masked[:120],
"secret assigned to an inline literal — it diverges silently the "
"moment the real secret rotates. Read it from the environment / a "
"secrets store instead."))
return out
# ========================================================================== #
# C4 — FAIL_OPEN_FLAG
# ========================================================================== #
_GATE_KEY = re.compile(
r"(?ix)(kill_switch|killswitch|live_kill|paper_mode|arming|armed|disabled|"
r"enabled|feature_flag|safety|maintenance_mode|read_only|allow_)")
_ENV_REPOINT_CONN = re.compile(
r"(?ix)(redis\.(Strict)?Redis|redis\.from_url|ConnectionPool|\.Redis\(|"
r"connect\(|Sentinel\(|create_client\()"
r".*?(os\.environ(?:\.get)?\(['\"][A-Z_]*(PORT|HOST|URL|REDIS|DSN)|"
r"getenv\(['\"][A-Z_]*(PORT|HOST|URL|REDIS|DSN))")
_PRESENCE_ASSERT = re.compile(
r"(?ix)(is None|== *None|!= *['\"]?true|== *['\"]?true|\bexists\b|key.*missing|"
r"absent|assert\b|raise\b|fail[_-]?closed|default.*true|or\s+['\"]?true|"
r"not\s+\w+.*:\s*(return|raise|sys\.exit)|\bif\s+not\b)")
def check_C4_fail_open_flag(path):
src = _read(path)
if not (_GATE_KEY.search(src) and _ENV_REPOINT_CONN.search(src)):
return []
lines, out = src.splitlines(), []
code = _code_line_set(src)
for i, ln in enumerate(lines, 1):
if code is not None and i not in code:
continue
if not _GATE_KEY.search(ln):
continue
if not re.search(r"(?i)(get|mget|hget|read|fetch)", ln):
continue
if ln.lstrip().startswith("#"):
continue
lo, hi = max(0, i - 6), min(len(lines), i + 6)
if _PRESENCE_ASSERT.search("\n".join(lines[lo:hi])):
continue
out.append(F("C4_FAIL_OPEN_FLAG", "HIGH", path, i, ln.strip()[:160],
"a safety / kill / feature gate is read from an env-repointable store "
"(HOST/PORT/URL) with no independent presence assert — if the flag is "
"ABSENT on the repointed store it reads falsy and the gate fails OPEN. "
"Re-assert the flag exists, or pin the flag-store separately."))
return out
# ========================================================================== #
# C5 — ROTATED_SECRET_CAPTURED_ONCE
# ========================================================================== #
_LONGLIVED = re.compile(
r"(?ix)(while\s+True|asyncio\.run|\.run_forever|Consumer\(|consume\(|"
r"app\.run\(|serve_forever|schedule\.|\.start\(\))")
# A MODULE-LEVEL (column-0) capture of a secret-named variable from the env.
_MODULE_SECRET_CAPTURE = re.compile(
r"^(?P<name>[A-Za-z_][A-Za-z0-9_]*)\s*=\s*(os\.environ|getenv|open\()")
_SECRET_NAME = re.compile(r"(?i)(pw|pass|passwd|secret|token|auth|api_?key)")
def _loop_line_ranges(tree):
"""(start, end) line spans of every long-lived loop construct."""
spans = []
for n in ast.walk(tree):
if isinstance(n, (ast.While, ast.AsyncFor)):
spans.append((n.lineno, getattr(n, "end_lineno", n.lineno)))
return spans
def check_C5_rotated_secret(path):
src = _read(path)
if not _LONGLIVED.search(src):
return []
try:
tree = ast.parse(src)
except Exception:
tree = None
# collect names referenced inside long-lived loops (so we only flag captures
# the loop actually USES — a module read never touched in the loop is fine).
used_in_loop = set()
loop_spans = _loop_line_ranges(tree) if tree else []
if tree:
for node in ast.walk(tree):
if isinstance(node, ast.Name) and isinstance(node.ctx, ast.Load):
if any(lo <= node.lineno <= hi for lo, hi in loop_spans):
used_in_loop.add(node.id)
out = []
for i, ln in enumerate(src.splitlines(), 1):
if ln[:1] in (" ", "\t"): # module level only (column 0)
continue
m = _MODULE_SECRET_CAPTURE.match(ln)
if not m or not _SECRET_NAME.search(m.group("name")):
continue
# only a real risk if the long-lived loop actually consumes this name
if used_in_loop and m.group("name") not in used_in_loop:
continue
out.append(F("C5_ROTATED_SECRET", "MED", path, i, ln.strip()[:120],
"a long-lived consumer captures a secret ONCE at module/global "
"scope; when it rotates the process keeps the stale value and "
"silently 401/403s. Re-read the secret per (re)connection, or "
"ensure a rotation-aware reconnect covers this consumer."))
return out
# --------------------------------------------------------------------------- #
# Registry + runner
# --------------------------------------------------------------------------- #
CHECKS = {
"C1": ("SWALLOWED_EXCEPTION_AROUND_WRITE", check_C1_swallowed_write, (".py",)),
"C2": ("UNDEFINED_NAME", check_C2_undefined_name, (".py",)),
"C3": ("HARDCODED_SECRET_LITERAL", check_C3_hardcoded_secret,
(".py", ".service", ".conf", ".env")),
"C4": ("FAIL_OPEN_FLAG", check_C4_fail_open_flag, (".py",)),
"C5": ("ROTATED_SECRET_CAPTURED_ONCE", check_C5_rotated_secret, (".py",)),
}
_SEV_RANK = {"HIGH": 0, "MED": 1, "LOW": 2}
def run(roots, checks=None, max_n=None, min_sev="LOW"):
"""Run the selected checks over `roots`. Returns a list of finding dicts."""
disabled = {c.strip() for c in os.environ.get("BUGCLASS_DISABLE", "").split(",") if c.strip()}
checks = [c for c in (checks or list(CHECKS)) if c in CHECKS and c not in disabled]
findings = []
for cid in checks:
_name, fn, exts = CHECKS[cid]
for path in _iter_files(roots, exts):
try:
findings.extend(fn(path))
except Exception:
continue
allow = {s for s in _SEV_RANK if _SEV_RANK[s] <= _SEV_RANK.get(min_sev, 2)}
findings = [f for f in findings if f["severity"] in allow]
findings.sort(key=lambda f: (_SEV_RANK.get(f["severity"], 9), f["check"],
f["file"], f["lineno"]))
return findings[:max_n] if max_n else findings
def main(argv=None):
ap = argparse.ArgumentParser(
prog="bugclass-detectors",
description="AST-aware static checks for silent-failure bug classes.")
ap.add_argument("roots", nargs="*", default=["."],
help="files or directories to scan (default: .)")
ap.add_argument("--checks", default=",".join(CHECKS),
help="comma list of check ids, e.g. C1,C3,C4")
ap.add_argument("--json", action="store_true", help="machine-readable output")
ap.add_argument("--lint", action="store_true",
help="exit 1 if any HIGH finding (CI gate mode)")
ap.add_argument("--max", type=int, default=None)
ap.add_argument("--min-sev", default="LOW", choices=["HIGH", "MED", "LOW"])
args = ap.parse_args(argv)
checks = [c.strip() for c in args.checks.split(",") if c.strip() in CHECKS]
findings = run(args.roots or ["."], checks, args.max, args.min_sev)
if args.json:
print(json.dumps(findings, indent=2))
else:
hi = sum(1 for f in findings if f["severity"] == "HIGH")
md = sum(1 for f in findings if f["severity"] == "MED")
lo = sum(1 for f in findings if f["severity"] == "LOW")
print(f"bugclass-detectors {__version__}: {len(findings)} findings "
f"(HIGH={hi} MED={md} LOW={lo}) roots={args.roots or ['.']}\n")
for f in findings:
print(f" {f['severity']:4} [{f['check']}] {f['file']}:{f['lineno']}")
print(f" {f['snippet'][:88]}")
print(f" -> {f['why'][:150]}")
if args.lint and any(f["severity"] == "HIGH" for f in findings):
return 1
return 0
if __name__ == "__main__":
sys.exit(main())