Ctrl K
ComunidadGuías…

effects.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
What a command will change, measured on this machine before it runs, so the approver sees a number, not a guess.

  git push --force      -> the commits on the remote that the push would remove (as of your last fetch)
  git reset --hard      -> the files with uncommitted changes it would throw away, and the commits it would leave
  git clean -f          -> the untracked files it would delete (git's own dry run)
  git checkout/restore  -> the changed files it would overwrite
  rm -r, Remove-Item, rmdir /s, del -> how many files and how much data the paths hold

  psql / mysql / sqlite3 DELETE, UPDATE, TRUNCATE, DROP TABLE -> how many rows, counted with the same WHERE
  terraform destroy / apply  -> how many resources go, and which of them hold data (databases, buckets, volumes)
  aws s3 rb / rm --recursive -> how many objects and how much data
  aws rds delete-db-*        -> the database's engine and size, and whether a final snapshot and backups are kept
  kubectl delete             -> the objects that match, and the cluster they're in

Used by the hooks (claude_hook.py, agent_hook.py), which run on the developer's machine: the gateway can't see
the repository, the database or the cloud account. Only reads: git commands here are local (no fetch, no network),
dry runs, or counts. Database counts run in a read-only session with a statement timeout; cloud and cluster
checks are list/describe calls with the same credentials the command itself would use. Everything is bounded in time
and size, and anything that fails is simply left out.
"""
from __future__ import annotations

import glob
import json
import os
import re
import shutil
import subprocess
import tempfile
import time
from pathlib import Path

import commands

BUDGET_SECONDS = 2.0        # for all local predictions on one command line
REMOTE_SECONDS = float(os.getenv("SQUIDBRAKE_EFFECTS_SECONDS", "10"))  # databases, clouds, clusters (only for those)
PLAN_SECONDS = float(os.getenv("SQUIDBRAKE_PLAN_SECONDS", "20"))       # a terraform plan, when it needs one
WALK_LIMIT = 20000          # files counted under the paths a delete names, at most
DELETE_PROGRAMS = {"rm", "remove-item", "ri", "rmdir", "rd", "del", "erase", "rimraf"}
WINDOWS_FLAG = re.compile(r"/[a-zA-Z]$")


def _git(cwd: str, *args: str, timeout: float = 2.0) -> str | None:
    try:
        r = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, timeout=timeout,
                           env={**os.environ, "GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0"})
    except (OSError, subprocess.SubprocessError):
        return None
    return r.stdout if r.returncode == 0 else None


def _names(lines: list[str], n: int = 3) -> str:
    shown = ", ".join(lines[:n])
    return shown + (f" and {len(lines) - n} more" if len(lines) > n else "")


def _size(n: float) -> str:
    for unit in ("bytes", "KB", "MB", "GB"):
        if n < 1024 or unit == "GB":
            return (f"{n:.0f} byte" + ("" if n == 1 else "s")) if unit == "bytes" else f"{n:.1f} {unit}"
        n /= 1024
    return f"{n:.1f} GB"


# --------------------------------------------------------------------------- git

def _push(words: list[str], cwd: str) -> str | None:
    args = words[2:]
    force = any(a in ("-f", "--force") or a.startswith("--force-with-lease") or a.startswith("+") for a in args)
    if not force:
        return None
    plain = [a.lstrip("+") for a in args if not a.startswith("-")]
    remote = plain[0] if plain else "origin"
    branch = plain[1].split(":")[-1] if len(plain) > 1 else (_git(cwd, "rev-parse", "--abbrev-ref", "HEAD") or "").strip()
    local = plain[1].split(":")[0] if len(plain) > 1 and plain[1].split(":")[0] else "HEAD"
    if not branch or branch == "HEAD":
        return None
    target = f"{remote}/{branch}"
    if _git(cwd, "rev-parse", "--verify", "--quiet", f"refs/remotes/{target}") is None:
        return f"{target} isn't known on this machine yet, so nothing on it can be counted"
    lost = (_git(cwd, "log", "--format=%h %s", f"{local}..{target}") or "").splitlines()
    if not lost:
        return f"No commits on {target} would be lost (as of your last fetch)"
    return (f"Removes {len(lost)} commit{'s' if len(lost) != 1 else ''} from {target} that this branch doesn't have "
            f"(as of your last fetch): {_names([l.split(' ', 1)[-1] for l in lost])}")


def _changed_files(cwd: str, paths: list[str] | None = None) -> list[str]:
    out = _git(cwd, "status", "--porcelain", "--untracked-files=no", *(["--", *paths] if paths else []))
    return [l[3:] for l in (out or "").splitlines() if l.strip()]


def _reset(words: list[str], cwd: str) -> str | None:
    if "--hard" not in words:
        return None
    parts = []
    changed = _changed_files(cwd)
    if changed:
        parts.append(f"throws away uncommitted changes in {len(changed)} file{'s' if len(changed) != 1 else ''}: {_names(changed)}")
    ref = next((w for w in words[2:] if not w.startswith("-")), None)
    if ref:
        left = (_git(cwd, "log", "--format=%s", f"{ref}..HEAD") or "").splitlines()
        if left:
            parts.append(f"moves the branch off {len(left)} commit{'s' if len(left) != 1 else ''}: {_names(left)}")
    return ("It " + "; it ".join(parts)) if parts else "No uncommitted changes would be lost"


def _clean(words: list[str], cwd: str) -> str | None:
    flags = [w for w in words[2:] if w.startswith("-")]
    if not any("f" in f.lstrip("-") or f == "--force" for f in flags):
        return None
    keep = []  # the same flags as a dry run: -fdx -> -dx (plus -n), --force dropped
    for f in flags:
        if f.startswith("--"):
            if f not in ("--force", "--interactive", "--dry-run"):
                keep.append(f)
        elif rest := re.sub(r"[fni]", "", f[1:]):
            keep.append("-" + rest)
    out = _git(cwd, "clean", "-n", *keep, *[w for w in words[2:] if not w.startswith("-")])
    if out is None:
        return None
    files = [l.removeprefix("Would remove ").strip() for l in out.splitlines() if l.strip()]
    if not files:
        return "No untracked files would be deleted"
    return f"Deletes {len(files)} untracked file{'s' if len(files) != 1 else ''} git has never saved: {_names(files)}"


def _checkout(words: list[str], cwd: str) -> str | None:
    sub = words[1]
    if sub == "checkout" and "--" not in words and "." not in words:
        return None
    paths = [w for w in words[words.index("--") + 1:]] if "--" in words else [w for w in words[2:] if not w.startswith("-")]
    if sub == "restore" and ("--staged" in words and "--worktree" not in words):
        return None
    changed = _changed_files(cwd, paths or None)
    if not changed:
        return None
    return f"Overwrites uncommitted changes in {len(changed)} file{'s' if len(changed) != 1 else ''}: {_names(changed)}"


# --------------------------------------------------------------------------- deletes

def delete_targets(cmd: commands.Command, cwd: str) -> list[Path]:
    """The existing paths a delete command names (globs and ~ expanded)."""
    words, prog = cmd.words[1:], cmd.program
    if prog not in DELETE_PROGRAMS:
        return []
    out: list[Path] = []
    skip_next = False
    for w in words:
        if skip_next:
            skip_next = False
            continue
        if w.startswith("-"):
            if prog in ("remove-item", "ri") and w.lower() in ("-path", "-literalpath"):
                continue
            if prog in ("remove-item", "ri") and w.lower() in ("-include", "-exclude", "-filter"):
                skip_next = True
            continue
        if prog in ("rmdir", "rd", "del", "erase") and WINDOWS_FLAG.match(w):
            continue
        w = os.path.expandvars(os.path.expanduser(w.strip("'\"")))
        p = w if os.path.isabs(w) else os.path.join(cwd, w)
        for m in (glob.glob(p) if any(c in p for c in "*?[") else [p]):
            if os.path.lexists(m):
                out.append(Path(m))
    return out


def walk(paths: list[Path], deadline: float) -> tuple[int, int, bool]:
    """(files, bytes, complete) under the paths, stopping at WALK_LIMIT files or the deadline."""
    files = size = 0
    for p in paths:
        if p.is_file() or p.is_symlink():
            files += 1
            size += p.lstat().st_size
            continue
        for root, _dirs, names in os.walk(p):
            for n in names:
                files += 1
                try:
                    size += os.lstat(os.path.join(root, n)).st_size
                except OSError:
                    pass
                if files >= WALK_LIMIT or time.monotonic() > deadline:
                    return files, size, False
    return files, size, True


def _delete(cmd: commands.Command, cwd: str, deadline: float) -> str | None:
    targets = delete_targets(cmd, cwd)
    if not targets:
        return None
    files, size, complete = walk(targets, deadline)
    def shown(t: Path) -> str:  # inside the project: the short path the developer typed
        try:
            return os.path.relpath(t, cwd) if Path(t).resolve().is_relative_to(Path(cwd).resolve()) else str(t)
        except ValueError:      # another drive on Windows
            return str(t)
    where = _names([shown(t) for t in targets], 2)
    more = "+" if not complete else ""
    return f"Deletes {files:,}{more} file{'s' if files != 1 else ''} ({_size(size)}{more}) in {where}"


# --------------------------------------------------------------------------- running other tools (read-only calls)

def _left(deadline: float) -> float:
    return deadline - time.monotonic()


def _env(cmd: commands.Command, extra: dict | None = None) -> dict:
    """The environment the command would run with: ours, plus any FOO=bar it sets in front of itself."""
    env = dict(os.environ)
    for w in commands._words(cmd.raw or ""):
        m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$", w)
        if not m:
            break
        env[m.group(1)] = os.path.expandvars(m.group(2))
    return {**env, **(extra or {})}


def _run(args: list[str], timeout: float, env: dict | None = None, cwd: str | None = None) -> tuple[str | None, bool]:
    """(stdout, finished) of a read-only call. stdout is None if the program isn't here or failed; when time runs
    out, it's whatever was printed so far and finished is False."""
    exe = shutil.which(args[0])
    if not exe or timeout <= 0:
        return None, False
    try:
        r = subprocess.run([exe, *args[1:]], capture_output=True, text=True, timeout=timeout, env=env, cwd=cwd,
                           stdin=subprocess.DEVNULL)
    except subprocess.TimeoutExpired as e:
        out = e.output
        return (out.decode(errors="replace") if isinstance(out, bytes) else out), False
    except (OSError, subprocess.SubprocessError):
        return None, False
    return (r.stdout if r.returncode == 0 else None), True


def _plural(n: int, word: str) -> str:
    return f"{n:,} {word}{'' if n == 1 else 's'}"


# --------------------------------------------------------------------------- databases: count the rows first

SQL_PROGRAMS = {"psql", "mysql", "mariadb", "sqlite3"}
IDENT = r'(?:"[^"]+"|`[^`]+`|\[[^\]]+\]|[A-Za-z_][\w$]*)'
TABLE = rf"{IDENT}(?:\.{IDENT}){{0,2}}"
# Never run as part of a count, even read-only: functions that act, wait, or reach outside the database
SQL_SIDE_EFFECTS = re.compile(r"\b(pg_terminate_backend|pg_cancel_backend|pg_sleep\w*|pg_reload_conf|pg_read_\w+|"
                              r"pg_ls_dir|pg_advisory\w*|pg_notify|lo_\w+|dblink\w*|set_config|nextval|setval|copy|"
                              r"load_file|sleep|benchmark|outfile|dumpfile|load_extension|writefile|readfile|"
                              r"attach|pragma)\b", re.I)


def _statements(sql: str) -> list[str]:
    out, buf, quote = [], [], None
    for ch in sql:
        if quote:
            quote = None if ch == quote else quote
        elif ch in "'\"`":
            quote = ch
        elif ch == ";":
            out.append("".join(buf)); buf = []
            continue
        buf.append(ch)
    out.append("".join(buf))
    return [s.strip() for s in out if s.strip()]


def _top_level(sql: str, word: str) -> int:
    """Where `word` first appears as a keyword outside quotes and brackets, or -1."""
    depth, quote = 0, None
    for i, ch in enumerate(sql):
        if quote:
            quote = None if ch == quote else quote
        elif ch in "'\"`":
            quote = ch
        elif ch == "(":
            depth += 1
        elif ch == ")":
            depth -= 1
        elif depth == 0 and sql[i:i + len(word)].lower() == word and (i == 0 or not (sql[i - 1].isalnum() or sql[i - 1] == "_")) \
                and not (sql[i + len(word):i + len(word) + 1].isalnum() or sql[i + len(word):i + len(word) + 1] == "_"):
            return i
    return -1


def sql_targets(sql: str) -> list[tuple[str, str, str | None]] | None:
    """(verb, table, where) for each statement, or None unless every statement is one we can count safely:
    DELETE, UPDATE, TRUNCATE or DROP TABLE, with no side-effect functions in the WHERE."""
    stmts = _statements(sql)
    if not stmts or len(stmts) > 3:
        return None
    out: list[tuple[str, str, str | None]] = []
    for s in stmts:
        if (cut := _top_level(s, "returning")) >= 0:
            s = s[:cut].rstrip()
        if m := re.match(rf"(?is)delete\s+from\s+(?:only\s+)?({TABLE})(\s+(?:as\s+)?(?!where\b)\w+)?\s*(.*)$", s):
            table, alias, rest = m.group(1), m.group(2) or "", m.group(3).strip()
            if rest and not re.match(r"(?is)where\b", rest):
                return None                                     # USING, LIMIT, ...: not counted
            out.append(("delete", table + alias, rest[5:].strip() or None if rest else None))
        elif m := re.match(rf"(?is)update\s+(?:only\s+)?({TABLE})(\s+(?:as\s+)?(?!set\b)\w+)?\s+set\s+(.*)$", s):
            table, alias, rest = m.group(1), m.group(2) or "", m.group(3)
            if _top_level(rest, "from") >= 0:
                return None                                     # UPDATE ... FROM: not counted
            w = _top_level(rest, "where")
            out.append(("update", table + alias, rest[w + 5:].strip() or None if w >= 0 else None))
        elif m := re.match(rf"(?is)truncate\s+(?:table\s+)?(?:only\s+)?({TABLE}(?:\s*,\s*{TABLE})*)"
                           r"(?:\s+(?:cascade|restrict|restart\s+identity|continue\s+identity))*$", s):
            out += [("truncate", t.strip(), None) for t in m.group(1).split(",")]
        elif m := re.match(rf"(?is)drop\s+table\s+(?:if\s+exists\s+)?({TABLE}(?:\s*,\s*{TABLE})*)(?:\s+(?:cascade|restrict))?$", s):
            out += [("drop", t.strip(), None) for t in m.group(1).split(",")]
        else:
            return None
    if any(w and SQL_SIDE_EFFECTS.search(w) for _, _, w in out):
        return None
    return out[:5]


def _count_query(table: str, where: str | None) -> str:
    if where:
        return f"SELECT (SELECT count(*) FROM {table} WHERE {where}), (SELECT count(*) FROM {table})"
    return f"SELECT count(*) FROM {table}"


def _rows_message(verb: str, table: str, where: str | None, counts: list[int], label: str) -> str:
    name = table.split()[0]
    at = f" ({label})" if label else ""
    if where and len(counts) == 2:
        hit, total = counts
        every = hit == total and total > 0
        if verb == "delete":
            return (f"Deletes all {_plural(total, 'row')} in {name}{at}: the WHERE matches every row" if every
                    else f"Deletes {_plural(hit, 'row')} of {total:,} in {name}{at}")
        return (f"Changes all {_plural(total, 'row')} in {name}{at}: the WHERE matches every row" if every
                else f"Changes {_plural(hit, 'row')} of {total:,} in {name}{at}")
    n = counts[0]
    return {"delete": f"Deletes all {_plural(n, 'row')} in {name}{at}: there's no WHERE",
            "update": f"Changes all {_plural(n, 'row')} in {name}{at}: there's no WHERE",
            "truncate": f"Empties {name}{at}: {_plural(n, 'row')}",
            "drop": f"Drops table {name}{at} with its {_plural(n, 'row')}"}[verb]


def _sql_text(words: list[str], cwd: str, flags: tuple[str, ...], file_flags: tuple[str, ...]) -> str | None:
    """The SQL a client runs from -c/-e or a -f file (small local files only)."""
    for i, w in enumerate(words):
        nxt = words[i + 1] if i + 1 < len(words) else ""
        if w in flags or re.fullmatch(r"-[A-Za-z]*[" + "".join(f[1] for f in flags if len(f) == 2) + "]", w):
            return nxt
        for f in flags:
            if f.startswith("--") and w.startswith(f + "="):
                return w.split("=", 1)[1]
        if w in file_flags or any(f.startswith("--") and w.startswith(f + "=") for f in file_flags):
            path = Path(cwd, w.split("=", 1)[1] if "=" in w else nxt)
            try:
                return path.read_text(encoding="utf-8", errors="replace") if path.stat().st_size < 100_000 else None
            except OSError:
                return None
    return None


def _client_args(words: list[str], value_flags: set[str], drop_flags: set[str], drop_letters: str,
                 attached: tuple[str, ...] = ()) -> list[str]:
    """Only the connection arguments of a database client command: where to connect and as whom.
    drop_letters: short flags whose value is SQL or a file, also when combined (`psql -tAc "..."`)."""
    out, i = [], 0
    while i < len(words):
        w = words[i]
        if w in value_flags:
            out += words[i:i + 2]; i += 2; continue
        if any(w.startswith(f + "=") for f in value_flags if f.startswith("--")) or \
                any(w.startswith(f) and len(w) > 2 for f in value_flags | set(attached) if len(f) == 2):
            out.append(w); i += 1; continue
        if w in drop_flags or re.fullmatch(rf"-[A-Za-z]*[{drop_letters}]", w):
            i += 2; continue
        if not w.startswith("-"):
            out.append(w)
        i += 1
    return out


def _label(conn: list[str]) -> str:
    """Which database, for the approver: host/dbname from a URL or -h/-d flags."""
    for w in conn:
        if m := re.match(r"^\w+://(?:[^@/]*@)?([^/:?]+)(?::\d+)?/([^?]+)", w):
            return f"{m.group(2)} on {m.group(1)}"
    host = next((conn[i + 1] for i, w in enumerate(conn[:-1]) if w in ("-h", "--host")), "")
    db = next((conn[i + 1] for i, w in enumerate(conn[:-1]) if w in ("-d", "--dbname", "-D", "--database")), "")
    return " on ".join(x for x in (db, host) if x)


def _sql(cmd: commands.Command, cwd: str, deadline: float) -> list[str]:
    words = [os.path.expandvars(w) for w in cmd.words[1:]]
    prog = cmd.program
    if prog == "sqlite3":
        pos = [w for w in words if not w.startswith("-")]
        if len(pos) < 2:
            return []
        db, sql = Path(cwd, pos[0]), pos[1]
        targets = sql_targets(sql)
        if not targets or not db.is_file():
            return []
        out = []
        import sqlite3   # here, not at the top: every hook call loads this file, few touch a SQLite database
        con = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True, timeout=1)
        try:
            con.set_progress_handler(lambda: 1 if time.monotonic() > deadline else 0, 10000)
            for verb, table, where in targets:
                counts = list(con.execute(_count_query(table, where)).fetchone())
                out.append(_rows_message(verb, table, where, counts, db.name))
        except sqlite3.Error:
            pass
        finally:
            con.close()
        return out
    if prog == "psql":
        sql = _sql_text(words, cwd, ("-c", "--command"), ("-f", "--file"))
        conn = _client_args(words, {"-h", "--host", "-p", "--port", "-U", "--username", "-d", "--dbname"},
                            {"-c", "--command", "-f", "--file", "-o", "--output", "-L", "--log-file", "-v", "--set",
                             "--variable", "-P", "--pset"}, "cf")
        options = (os.environ.get("PGOPTIONS", "") + " -c default_transaction_read_only=on -c statement_timeout=5000").strip()
        env = _env(cmd, {"PGOPTIONS": options, "PGCONNECT_TIMEOUT": "3"})
        run = lambda q: _run(["psql", *conn, "-X", "-q", "-A", "-t", "-v", "ON_ERROR_STOP=1", "-c", q],
                             _left(deadline), env, cwd)
        split = "|"
    else:                                                       # mysql, mariadb
        sql = _sql_text(words, cwd, ("-e", "--execute"), ())
        conn = _client_args(words, {"-h", "--host", "-P", "--port", "-u", "--user", "-D", "--database", "-S", "--socket",
                                    "--password", "--defaults-file", "--defaults-extra-file"},
                            {"-e", "--execute"}, "e", attached=("-p",))   # -pSECRET only; a bare -p would prompt
        env = _env(cmd)
        # MAX_EXECUTION_TIME stops the count on the server too (MySQL); MariaDB reads the hint as a comment
        run = lambda q: _run([prog, *conn, "-N", "-B", "--connect-timeout=3", "-e", "SET SESSION TRANSACTION READ ONLY; "
                              + q.replace("SELECT ", "SELECT /*+ MAX_EXECUTION_TIME(5000) */ ", 1)], _left(deadline), env, cwd)
        split = "\t"
    targets = sql_targets(sql or "")
    if not targets:
        return []
    out = []
    for verb, table, where in targets:
        text, done = run(_count_query(table, where))
        line = next((l for l in (text or "").splitlines() if l.strip()), "") if done else ""
        try:
            counts = [int(x) for x in line.strip().split(split)]
        except ValueError:
            continue
        out.append(_rows_message(verb, table, where, counts, _label(conn)))
    return out


# --------------------------------------------------------------------------- terraform: what a plan would destroy

DATA_TYPES = re.compile(r"(db_instance|rds_cluster|docdb|neptune|redshift|dynamodb_table|elasticache|memorydb|"
                        r"opensearch|elasticsearch|s3_bucket$|storage_bucket$|storage_account$|ebs_volume|"
                        r"efs_file_system|fsx|backup_vault|sql_database|sql_server|database|bigquery_dataset|"
                        r"bigquery_table|bigtable|spanner|firestore|cosmosdb|compute_disk|managed_disk|kms_key|"
                        r"secretsmanager_secret|key_vault)", re.I)


def _tf_type(address: str) -> str:
    parts = re.sub(r"\[[^\]]*\]", "", address).split(".")
    return parts[-2] if len(parts) >= 2 else ""


def _holding_data(addresses: list[str]) -> list[str]:
    return [a for a in addresses if DATA_TYPES.search(_tf_type(a))]


def _tf_summary(verb: str, addresses: list[str], where: str) -> str:
    data = _holding_data(addresses)
    msg = f"{verb} {_plural(len(addresses), 'resource')}{where}"
    if data:
        return msg + f", including {len(data)} that hold{'s' if len(data) == 1 else ''} data: {_names(data)}"
    return msg + f": {_names(addresses)}"


def _terraform(cmd: commands.Command, cwd: str, deadline: float) -> str | None:
    words = [os.path.expandvars(w) for w in cmd.words]
    tf, chdir = words[0], [w for w in words[1:] if w.startswith("-chdir=")]
    rest = [w for w in words[1:] if not w.startswith("-chdir=")]
    sub = next((w for w in rest if not w.startswith("-")), None)
    if sub not in ("destroy", "apply"):
        return None
    args = rest[rest.index(sub) + 1:]
    plan_args, targets, positional, i = [], [], [], 0
    while i < len(args):
        a = args[i]
        if a in ("-var", "-var-file", "-target", "-replace") and i + 1 < len(args):
            plan_args += [f"{a}={args[i + 1]}"]; i += 2; continue
        if re.match(r"-(var|var-file|target|replace)=", a):
            plan_args.append(a)
        elif not a.startswith("-"):
            positional.append(a)
        i += 1
    targets = [a.split("=", 1)[1] for a in plan_args if a.startswith("-target=")]
    env = _env(cmd, {"TF_IN_AUTOMATION": "1", "TF_INPUT": "0"})
    ws, _ = _run([tf, *chdir, "workspace", "show"], min(3, _left(deadline)), env, cwd)
    ws = (ws or "").strip()
    where = f" in workspace {ws}" if ws and ws != "default" else ""

    if sub == "destroy" or "-destroy" in args:
        text, done = _run([tf, *chdir, "state", "list", *targets], _left(deadline), env, cwd)
        if text is None or not done:
            return None
        gone = [l.strip() for l in text.splitlines() if l.strip() and not re.match(r"(.*\.)?data\.", l.strip())]
        if not gone:
            return f"Destroys nothing{where}: the state has no matching resources"
        return _tf_summary("Destroys", gone, where)

    plan_file, tmp = (positional[0] if positional else None), None
    try:
        if not plan_file:                                       # plan against the saved state (no refresh, no lock)
            fd, tmp = tempfile.mkstemp(suffix=".tfplan")
            os.close(fd)
            _, done = _run([tf, *chdir, "plan", "-refresh=false", "-lock=false", "-input=false", "-no-color",
                            f"-out={tmp}", *plan_args], PLAN_SECONDS, env, cwd)
            if not done:
                return None
            plan_file = tmp
        text, done = _run([tf, *chdir, "show", "-json", plan_file], 10, env, cwd)
        if not text or not done:
            return None
        changes = json.loads(text).get("resource_changes") or []
    finally:
        if tmp:
            try:
                os.unlink(tmp)
            except OSError:
                pass
    acts = [(c.get("address", "?"), (c.get("change") or {}).get("actions") or []) for c in changes if c.get("mode") != "data"]
    gone = [a for a, x in acts if x == ["delete"]]
    replaced = [a for a, x in acts if "delete" in x and "create" in x]
    if not gone and not replaced:
        adds = sum(1 for _, x in acts if x == ["create"])
        updates = sum(1 for _, x in acts if x == ["update"])
        return f"Destroys nothing{where}: adds {adds:,} and changes {updates:,} resources (plan against the saved state)"
    parts = []
    if gone:
        parts.append(_tf_summary("Destroys", gone, where))
    if replaced:
        parts.append(_tf_summary("Replaces (destroys, then creates again)", replaced, "" if gone else where))
    return "; ".join(parts) + " (plan against the saved state)"


# --------------------------------------------------------------------------- AWS: what a delete takes with it

AWS_GLOBALS = ("--profile", "--region", "--endpoint-url")
AWS_SWITCHES = {"--recursive", "--force", "--dryrun", "--quiet", "--only-show-errors", "--skip-final-snapshot",
                "--delete-automated-backups", "--no-delete-automated-backups", "--debug", "--no-verify-ssl",
                "--no-paginate", "--no-sign-request", "--no-cli-pager"}


def _aws(cmd: commands.Command, cwd: str, deadline: float) -> str | None:
    words = [os.path.expandvars(w) for w in cmd.words[1:]]
    keep, pos, flags, i = [], [], {}, 0
    while i < len(words):
        w = words[i]
        if w in AWS_GLOBALS and i + 1 < len(words):
            keep += words[i:i + 2]; i += 2; continue
        if any(w.startswith(g + "=") for g in AWS_GLOBALS):
            keep.append(w); i += 1; continue
        if w.startswith("--"):
            name, _, value = w.partition("=")
            if name not in AWS_SWITCHES and not value and i + 1 < len(words) and not words[i + 1].startswith("-"):
                value = words[i + 1]; i += 1
            flags[name] = value
        else:
            pos.append(w)
        i += 1
    env = _env(cmd)
    if pos[:2] in (["s3", "rb"], ["s3", "rm"]) and len(pos) > 2 and pos[2].startswith("s3://"):
        if (pos[1] == "rb" and "--force" not in flags) or (pos[1] == "rm" and "--recursive" not in flags) \
                or "--dryrun" in flags:
            return None                                         # an empty bucket, one object, or a dry run
        text, done = _run(["aws", "s3", "ls", pos[2], "--recursive", "--summarize", *keep], _left(deadline), env, cwd)
        if text is None:
            return None
        if done and (m := re.search(r"Total Objects:\s*(\d+)\s*Total Size:\s*(\d+)", text)):
            files, size = int(m.group(1)), int(m.group(2))
            more = ""
        else:                                                   # still listing when time ran out: what it saw so far
            rows = [l.split(None, 3) for l in text.splitlines()]
            rows = [r for r in rows if len(r) == 4 and r[2].isdigit()]
            files, size, more = len(rows), sum(int(r[2]) for r in rows), "+"
        what = "the bucket and all" if pos[1] == "rb" else "all"
        return f"Deletes {what} {files:,}{more} object{'s' if files != 1 else ''} ({_size(size)}{more}) in {pos[2]}"
    if pos[:2] in (["rds", "delete-db-instance"], ["rds", "delete-db-cluster"]):
        cluster = pos[1] == "delete-db-cluster"
        name = flags.get("--db-cluster-identifier" if cluster else "--db-instance-identifier")
        if not name:
            return None
        parts = [f"Deletes RDS {'cluster' if cluster else 'database'} {name}"]
        text, done = _run(["aws", "rds", "describe-db-clusters" if cluster else "describe-db-instances",
                           "--db-cluster-identifier" if cluster else "--db-instance-identifier", name,
                           "--output", "json", *keep], _left(deadline), env, cwd)
        protected = False
        try:
            db = json.loads(text)["DBClusters" if cluster else "DBInstances"][0] if text and done else {}
            if db:
                size = f", {db['AllocatedStorage']} GB" if db.get("AllocatedStorage") else ""
                parts[0] += f" ({db.get('Engine', '?')}{size})"
                protected = bool(db.get("DeletionProtection"))
        except (ValueError, KeyError, IndexError, TypeError):
            pass
        if "--skip-final-snapshot" in flags:
            parts.append("no final snapshot is taken (--skip-final-snapshot)")
        elif flags.get("--final-db-snapshot-identifier"):
            parts.append(f"a final snapshot {flags['--final-db-snapshot-identifier']} is kept")
        if "--no-delete-automated-backups" not in flags:
            parts.append("its automated backups are removed with it (AWS's default)")
        if protected:
            parts.append("deletion protection is on, so AWS refuses until someone turns it off")
        return "; ".join(parts)
    return None


# --------------------------------------------------------------------------- Kubernetes: what a delete matches

KUBE_DELETE_ONLY = {"--grace-period", "--force", "--now", "--wait", "--cascade", "--ignore-not-found", "--timeout",
                    "--dry-run", "-i", "--interactive", "--all", "--raw"}
KUBE_VALUED = {"--grace-period", "--timeout", "--raw"}      # the others take a value only as --flag=value


def _kubectl(cmd: commands.Command, cwd: str, deadline: float) -> str | None:
    words = [os.path.expandvars(w) for w in cmd.words]
    rest = words[1:]
    at = next((i for i, w in enumerate(rest) if not w.startswith("-") and (i == 0 or not rest[i - 1] in
               ("-n", "--namespace", "--context", "--kubeconfig", "-l", "--selector", "-f", "--filename"))), None)
    if at is None or rest[at].lower() != "delete" or any(w.startswith("--dry-run") for w in rest):
        return None
    args, after, i = rest[:at] + ["get"], rest[at + 1:], 0
    while i < len(after):                                       # the same selection, without delete's own options
        w = after[i]
        if w.split("=", 1)[0] in KUBE_DELETE_ONLY:
            if "=" not in w and w in KUBE_VALUED and i + 1 < len(after) and not after[i + 1].startswith("-"):
                i += 1
        else:
            args.append(w)
        i += 1
    env = _env(cmd)
    text, done = _run([words[0], *args, "-o", "name", "--ignore-not-found"], _left(deadline), env, cwd)
    if text is None or not done:
        return None
    names = [l.strip() for l in text.splitlines() if l.strip()]
    if not names:
        return "Nothing matches it right now"
    get = lambda flag: next((rest[i + 1] for i, w in enumerate(rest[:-1]) if w == flag), None)
    ns = "all namespaces" if any(w in ("-A", "--all-namespaces") for w in rest) else \
        f"namespace {get('-n') or get('--namespace')}" if (get('-n') or get('--namespace')) else ""
    context = get("--context") or (_run([words[0], "config", "current-context"], min(2, _left(deadline)), env, cwd)[0] or "").strip()
    where = " in " + " on ".join(x for x in (ns, f"cluster {context}" if context else "") if x) if ns or context else ""
    inside = " and everything in them" if any(n.startswith("namespace/") for n in names) else ""
    return f"Deletes {_plural(len(names), 'Kubernetes object')}{where}{inside}: {_names(names)}"


# --------------------------------------------------------------------------- entry point

def predict(line: str, cwd: str | None = None) -> list[str]:
    """Plain-English lines saying what the command line would change. Empty when there's nothing to measure."""
    cwd = cwd or os.getcwd()
    start = time.monotonic()
    deadline, remote = start + BUDGET_SECONDS, start + REMOTE_SECONDS
    out: list[str] = []
    try:
        reading = commands.read(line)
    except Exception:
        return out
    for cmd in reading.commands:
        w = [x for x in cmd.words]
        local = cmd.program not in SQL_PROGRAMS | {"terraform", "tofu", "aws", "kubectl", "oc"}
        if time.monotonic() > (deadline if local else remote):
            continue
        try:
            if cmd.program == "git" and len(w) > 1:
                fn = {"push": _push, "reset": _reset, "clean": _clean, "checkout": _checkout, "restore": _checkout}.get(w[1])
                msg = fn(w, cwd) if fn else None
            elif cmd.program in SQL_PROGRAMS:
                msg = _sql(cmd, cwd, remote)
            elif cmd.program in ("terraform", "tofu"):
                msg = _terraform(cmd, cwd, remote)
            elif cmd.program == "aws":
                msg = _aws(cmd, cwd, remote)
            elif cmd.program in ("kubectl", "oc"):
                msg = _kubectl(cmd, cwd, remote)
            else:
                msg = _delete(cmd, cwd, deadline)
        except Exception:
            msg = None
        for m in ([msg] if isinstance(msg, str) else msg or []):
            if m and m[:400] not in out:
                out.append(m[:400])
    return out[:5]