#!/usr/bin/env python3
"""clarity_prescan.py — deterministic Clarity (Stacks) contract pre-scan.

ARION service artifact. Scans Clarity source (.clar), Clarinet.toml and
deployment plans for the shapes audit findings are made of on Stacks:
unguarded public entry points that move value or mutate state,
as-contract drains, unguarded minting, dynamically-targeted
contract-call?, miner-influenceable randomness, panic-unwraps on
untrusted data, discarded contract-call? responses, dead asserts,
deprecated block-height reads, and keys committed to config.

Form-aware: top-level s-expressions are split by paren depth (strings
and ;; comments blanked first), so a public function is judged on the
whole body — including whether any caller gate (tx-sender /
contract-caller, or a call to a checker helper that itself asserts on
the sender) exists inside it.

Findings (base severity):
  CLR-TRANSFER-NOAUTH  HIGH    public fn moves funds that are not the
                               caller's — a transfer/burn whose sender
                               is the contract (as-contract, stored
                               principal, literal or param) rather than
                               the caller — with no caller gate and no
                               caller identity anywhere in the body;
                               note tx-sender INSIDE as-contract rebinds
                               to the contract and counts as contract
                               funds, not a caller
                       MEDIUM  same shape on a caller-referencing path —
                               the caller's identity is in play but no
                               gate was found; verify the check lives
                               upstream
  CLR-MINT-NOAUTH      HIGH    public fn contains ft-mint?/nft-mint?
                               with no caller gate — unguarded supply
  CLR-AS-CONTRACT      HIGH    as-contract block wrapping a value or
                               mint op inside a public fn with neither
                               a caller gate nor any caller identity —
                               the contract's own balance is callable
                               by anyone
                       MEDIUM  same shape with a gate or caller
                               reference — every path still warrants
                               review; as-contract drops the caller
                               principal and acts with the contract's
                               full authority
  CLR-STATE-NOAUTH     MEDIUM  public fn writes state — var-set,
                               map-set, map-delete, map-insert — with
                               no caller gate, no caller identity, and
                               no value op (a gated value op flags
                               under CLR-TRANSFER-NOAUTH)
                       LOW     same write on a caller-referencing path —
                               likely caller-scoped; verify
  CLR-DYNAMIC-CALL     MEDIUM  contract-call? whose target resolves to
                               a caller-supplied principal arg with no
                               trait bound, or an identifier that is
                               neither a declared trait arg nor a
                               pinned constant — a trait bound pins
                               the interface, not the identity
                       LOW     callee held in a data-var — re-pointable
                               by contract logic; verify who can set it
                               (literal `.name` / 'name targets,
                               trait-bounded args, match-bound trait
                               locals, and define-constant names stay
                               silent)
  CLR-BLOCK-RAND       MEDIUM  miner-influenceable selection —
                               get-block-info? vrf-seed / id-header-hash
                               / time, or block-height /
                               burn-block-height / stacks-block-height
                               arithmetic feeding mod or a pick
  CLR-UNWRAP-PANIC     MEDIUM  aggregated per file — panic-unwraps
                               (unwrap-panic/unwrap-err-panic) counted
                               by inner fallible source; a forced abort
                               bricks the call path — prefer unwrap!/
                               asserts! with typed errors
  CLR-CALL-IGNORED     LOW     (contract-call? ...) in statement
                               position — the response value is
                               discarded; the compiler warns, the
                               pattern still ships — a failed upstream
                               call then looks successful downstream
  CLR-ASSERT-TRUE      LOW     asserts! on a tautology — asserts! true
                               or is-eq of a term with itself — the
                               guard cannot fail; whatever it was meant
                               to check is unchecked
  CLR-DEPRECATED-HEIGHT LOW    bare block-height read — deprecated in
                               Clarity 3 in favour of
                               stacks-block-height; burn-block-height
                               stays for burnchain comparisons
  CLR-SECRET-IN-FILE   HIGH    private-key/mnemonic/seed literal in a
                               mainnet-named config or a .clar constant
                               named *key*secret*seed*mnemonic*
                       MEDIUM  the same shape in Clarinet.toml or a
                               generic deploy plan — devnet mnemonics
                               are expected; verify this is the
                               throwaway default, not a funded key

Context rules:
  * ;; comments are stripped; string literals are blanked to spaces so
    prose cannot trigger signatures and parens cannot unbalance a form
  * private and read-only forms never flag for missing gates — they are
    not entry points (private fns still count as checker helpers when
    they assert on the sender)
  * a public fn whose body references tx-sender/contract-caller, or
    calls a helper fn that asserts on the sender, is treated as gated
    — a pre-scan cannot prove the check is the right one
  * test/spec/fixture paths downgrade one tier
  * literal static calls (contract-call? .name / 'name) stay silent
  * absence is not evidence: an unflagged contract can still be wrong —
    post-conditions, trait identity, and economics live outside this
    file's reach

Scope: deterministic pre-scan for human review — flags the shapes
worth a second look before a full audit; clean output != a safe
contract.

  clarity_prescan.py <file-or-dir> [--json]
  clarity_prescan.py --selftest
Stdlib only. Exit 0 = no HIGH/MEDIUM findings, 1 = findings, 2 = usage.
"""
import json
import os
import re
import sys

SEV_ORDER = {"HIGH": 0, "MEDIUM": 1, "LOW": 2, "INFO": 3}
DOWN = {"HIGH": "MEDIUM", "MEDIUM": "LOW", "LOW": "INFO", "INFO": "INFO"}

TARGET_EXT = (".clar", ".toml", ".yaml", ".yml")
TARGET_NAMES = {"Clarinet.toml", "Mainnet.toml", "Testnet.toml",
                "Devnet.toml", "Simnet.toml"}

SKIP_DIRS = {
    "node_modules", ".git", "vendor", "dist", "build", "target",
    "__pycache__", ".venv", "venv", "coverage", ".next", "out",
    "cache", ".history",
}

TEST_PATH = re.compile(
    r"(^|/)(test|tests|spec|specs|fixtures?|mocks?|__tests__|__mocks__|"
    r"e2e|cypress|playwright)(/|$)|(_test|_spec|\.test|\.spec)\.",
    re.I)

MAINNET_NAME = re.compile(r"mainnet|prod", re.I)

# --- config / file-level secrets ------------------------------------------

CFG_SECRET = re.compile(
    r"^\s*[\w.-]*(?:mnemonic|private[_-]?key|secret[_-]?key|"
    r"seed[_-]?phrase|secretkey|signer[_-]?key)[\w.-]*\s*[=:]\s*"
    r"[\"']?[A-Za-z0-9][A-Za-z0-9 .\-_/+]{15,}", re.I)

# constant named like a secret holding a long literal (pubkey/address
# names are public material — never secrets)
CLAR_CONST_SECRET = re.compile(
    r"\(\s*define-constant\s+([^\s()]*"
    r"(?:key|secret|seed|mnemonic|private)[^\s()]*)\s+"
    r"(0x[0-9a-fA-F]{20,}|\"[^\"\n]{20,}\")", re.I)
PUB_NAME = re.compile(r"pub|public|address|principal|sender|recipient",
                      re.I)

# --- form-level vocabulary --------------------------------------------------

DEF_HEAD = re.compile(
    r"\(\s*(define-public|define-private|define-read-only)\s*"
    r"\(\s*([A-Za-z0-9_!?*+<>=./-]+)")

VALUE_OP = re.compile(
    r"\(\s*(?:stx-transfer\?|ft-transfer\?|nft-transfer\?|"
    r"stx-burn\?|ft-burn\?|nft-burn\?)(?=[\s()])")
MINT_OP = re.compile(r"\(\s*(?:ft-mint\?|nft-mint\?)(?=[\s()])")
STATE_OP = re.compile(r"\(\s*(?:var-set|map-set|map-delete|map-insert)\b")
SENDER_REF = re.compile(r"\b(?:tx-sender|contract-caller)\b")
AS_CONTRACT = re.compile(r"\(\s*as-contract\b")

# a sender ref counts as a caller gate only when a check-shaped form
# encloses it — `(stx-transfer? amt (as-contract tx-sender) tx-sender)`
# merely *names* the sender as a party, it does not verify the caller
GATE_OPEN = re.compile(
    r"\(\s*(?:(?:asserts!|unwrap!|try!|map-get\?|>=|<=|>|<|=)"
    r"(?=[\s()])|(?:is-eq|if|when|unless|and|or|not|is-standard|"
    r"is-some|is-none)\b)")


def has_gate(form, ac_spans):
    """True when a tx-sender/contract-caller token sits inside (or right
    after) a check-shaped opener — asserts!/is-eq/if/map-get?
    membership etc. Sender refs inside an as-contract span name the
    contract, not the caller, and never count as a gate."""
    for m in SENDER_REF.finditer(form):
        if _in_spans(m.start(), ac_spans):
            continue
        window = form[max(0, m.start() - 200):m.start()]
        if GATE_OPEN.search(window):
            return True
    return False


def _children(src, i):
    """src[i] == '(' — yield (start,end) spans of each immediate child
    form or atom inside it."""
    depth = 0
    j = i
    n = len(src)
    child_start = None
    while j < n:
        c = src[j]
        if c == "(":
            if depth == 1 and child_start is None:
                child_start = j
            depth += 1
        elif c == ")":
            depth -= 1
            if depth == 1 and child_start is not None:
                yield child_start, j + 1
                child_start = None
            if depth == 0:
                return
        elif depth == 1 and not c.isspace():
            k = j
            while k < n and not src[k].isspace() and src[k] not in "()":
                k += 1
            yield j, k
            j = k
            continue
        j += 1


def discarded_calls(form):
    """Indices (into form) of contract-call? forms whose response is
    dropped — non-final children of a begin block. A call in tail
    position returns its response and is silent."""
    out = []
    for m in re.finditer(r"\(\s*begin\b", form):
        kids = list(_children(form, m.start()))
        for s, e in kids[1:-1]:
            if re.match(r"\(\s*contract-call\?", form[s:e]):
                out.append(s)
    return out


# (transfer/burn amount SENDER ...) — sender in arg-2 position.
# tx-sender/contract-caller here means callers move their own balance —
# self-authorized by definition. Anything else (as-contract rebound
# sender, stored principal, literal, param) moves funds that are not
# the caller's, so it needs a gate.
# sender position differs: stx-transfer?/stx-burn? take
# (amount sender ...) while ft-*/nft-* take (asset amount sender ...)
XFER = re.compile(
    r"\(\s*(?:stx-transfer\?|stx-burn\?)\s+[^\s()]*\s+"
    r"([^\s()]+|\([^()]*\))|"
    r"\(\s*(?:ft-transfer\?|ft-burn\?|nft-transfer\?|nft-burn\?)\s+"
    r"[^\s()]*\s+[^\s()]*\s+([^\s()]+|\([^()]*\))")


AS_CONTRACT_BLOCK = re.compile(r"\(\s*as-contract\??\b")


def _form_end(src, i):
    """src[i] == '(' — return index just past its balanced close."""
    depth = 0
    for j in range(i, len(src)):
        if src[j] == "(":
            depth += 1
        elif src[j] == ")":
            depth -= 1
            if depth == 0:
                return j + 1
    return len(src)


def as_contract_spans(form):
    """(start,end) spans of every as-contract block. Inside them,
    tx-sender/contract-caller rebind to the contract principal — a
    sender that looks caller-owned actually moves contract funds."""
    return [(m.start(), _form_end(form, m.start()))
            for m in AS_CONTRACT_BLOCK.finditer(form)]


def _in_spans(pos, spans):
    return any(s <= pos < e for s, e in spans)


def dangerous_value(form, ac_spans):
    for m in XFER.finditer(form):
        sender = m.group(1) or m.group(2)
        if sender not in ("tx-sender", "contract-caller"):
            return True
        if _in_spans(m.start(), ac_spans):
            return True     # rebound sender = contract funds
    return False


def caller_refs_outside(form, ac_spans):
    return any(not _in_spans(m.start(), ac_spans)
               for m in SENDER_REF.finditer(form))

# --- line-level vocabulary --------------------------------------------------

DYN_CALL = re.compile(r"\(\s*contract-call\?\s+([^\s()]+)")

VRF_INFO = re.compile(
    r"\(\s*get-block-info\?[^\n)]*"
    r"(?:vrf-seed|id-header-hash|time|header-hash)")
HEIGHT_MOD = re.compile(
    r"\(\s*(?:mod|rem)\b[^\n]*"
    r"(?:block-height|burn-block-height|stacks-block-height)")
DEPRECATED_HEIGHT = re.compile(r"(?<![\w-])block-height\b")

UNWRAP_PANIC = re.compile(
    r"\(\s*(unwrap-panic|unwrap-err-panic)\s*\(\s*([^\s()]+)?")
ASSERT_TRUE = re.compile(
    r"\(\s*asserts!\s+(?:true\b|\(\s*is-eq\s+([^\s()]+)\s+\1\s*\))")

DEF_CONST = re.compile(r"\(\s*define-constant\s+([^\s()]+)")
DEF_VAR = re.compile(r"\(\s*define-data-var\s+([^\s()]+)")

# helper names that ARE the gate even when they never read tx-sender —
# signature/passkey auth (is-authorized), role tables, owner checks.
# Body must still contain an asserts!/err shape so a name like
# `is-authorized-view` can't whitewash a fn it never gates.
AUTH_NAME = re.compile(
    r"auth|owner|admin|guard|only-|allow|permit|role|access",
    re.I)
ASSERT_BODY = re.compile(r"\(\s*(?:asserts!(?=[\s()])|err\b)")


def signature_args(form):
    """(trait_bound, plain) arg-name sets from a define-* form's
    signature list. `(t <ft-trait>)` is trait-bounded; `(p principal)`
    is not."""
    hm = DEF_HEAD.match(form)
    if not hm:
        return set(), set()
    sig_open = form.find("(", form.find(hm.group(1)) +
                         len(hm.group(1)))
    if sig_open < 0:
        return set(), set()
    traits, plains = set(), set()
    for s, e in _children(form, sig_open):
        child = form[s:e]
        cm = re.match(r"\(\s*([^\s()]+)\s+(.*)\)\s*$", child, re.S)
        if not cm:
            continue
        (traits if "<" in cm.group(2) else plains).add(cm.group(1))
    return traits, plains


def match_traits(form, traits):
    """`(match x bound ...)` over a trait-typed x makes `bound` a
    trait-typed local — add it."""
    out = set(traits)
    for mm in re.finditer(r"\(\s*match\s+([^\s()]+)\s+([^\s()]+)", form):
        if mm.group(1) in out:
            out.add(mm.group(2))
    return out


class Finding:
    __slots__ = ("sev", "code", "where", "line", "msg")

    def __init__(self, sev, code, where, line, msg):
        self.sev = sev
        self.code = code
        self.where = where
        self.line = line
        self.msg = msg

    def as_dict(self):
        return {"severity": self.sev, "code": self.code,
                "file": self.where, "line": self.line, "msg": self.msg}


def strip_clarity(src):
    """Blank ;; comments and string contents, preserving positions and
    newlines so line numbers and paren depth stay truthful."""
    out = []
    i, n = 0, len(src)
    while i < n:
        c = src[i]
        if c == '"':
            out.append('"')
            i += 1
            while i < n and src[i] != '"':
                if src[i] == "\\" and i + 1 < n:
                    out.append(" ")
                    i += 1
                if i < n:
                    out.append("\n" if src[i] == "\n" else " ")
                    i += 1
            if i < n:
                out.append('"')
                i += 1
        elif c == ";" and i + 1 < n and src[i + 1] == ";":
            while i < n and src[i] != "\n":
                i += 1
        else:
            out.append(c)
            i += 1
    return "".join(out)


def top_forms(src):
    """Split cleaned source into top-level s-expressions.

    Returns [(start_line, form_text)]."""
    forms = []
    depth = 0
    start = None
    start_line = 1
    line = 1
    for i, c in enumerate(src):
        if c == "\n":
            line += 1
        elif c == "(":
            if depth == 0:
                start, start_line = i, line
            depth += 1
        elif c == ")":
            depth -= 1
            if depth == 0 and start is not None:
                forms.append((start_line, src[start:i + 1]))
                start = None
    return forms


def _word_re(name):
    return re.compile(r"(?<![A-Za-z0-9_!?*+<>=./-])"
                      + re.escape(name)
                      + r"(?![A-Za-z0-9_!?*+<>=./-])")


def scan_clar(path, rel, raw):
    findings = []
    is_test = bool(TEST_PATH.search(rel.replace(os.sep, "/")))

    def emit(sev, code, ln, msg):
        if is_test:
            sev = DOWN[sev]
        findings.append(Finding(sev, code, rel, ln, msg))

    # config-shaped secrets inside a .clar constant
    for m in CLAR_CONST_SECRET.finditer(raw):
        if PUB_NAME.search(m.group(1)):
            continue
        emit("HIGH", "CLR-SECRET-IN-FILE", 0,
             "constant `%s` holds a long literal under a secret-shaped "
             "name — committed key material" % m.group(1))

    cleaned = strip_clarity(raw)
    forms = top_forms(cleaned)

    # statically-pinned callee names: constants are fixed for the life
    # of the contract; data-vars can be re-pointed by contract logic
    pinned = set(DEF_CONST.findall(cleaned))
    revars = set(DEF_VAR.findall(cleaned))

    # pass 1 — index defined fns; a checker helper is any fn that gates
    # on a caller identity (sender ref inside a check shape)
    fns = []           # (kind, name, start_line, text)
    checker_names = []
    for start_line, form in forms:
        hm = DEF_HEAD.match(form)
        if not hm:
            continue
        kind, name = hm.group(1), hm.group(2)
        fns.append((kind, name, start_line, form))

    # checker fixpoint: a fn is a caller-gate witness when it (a) gates
    # a sender ref itself, (b) is an assert-shaped auth-named helper
    # (signature/passkey auth never reads tx-sender), or (c) calls a
    # known checker — auth helpers often delegate a hop or two
    checker_set = set()
    for _ in range(3):
        grown = False
        for kind, name, start_line, form in fns:
            if name in checker_set:
                continue
            if has_gate(form, as_contract_spans(form)) or \
                    (AUTH_NAME.search(name) and ASSERT_BODY.search(form)) \
                    or any(_word_re(c).search(form) for c in checker_set):
                checker_set.add(name)
                grown = True
        if not grown:
            break
    checker_res = [_word_re(n) for n in checker_set]

    # pass 2 — judge each defined fn on its whole body; public fns take
    # the gate findings, every fn contributes dynamic-call review
    for kind, name, start_line, form in fns:
        if kind == "define-public":
            ac_spans = as_contract_spans(form)
            authed = has_gate(form, ac_spans) or \
                any(r.search(form) for r in checker_res)
            caller_seen = caller_refs_outside(form, ac_spans)
            has_value = bool(VALUE_OP.search(form))
            has_mint = bool(MINT_OP.search(form))
            danger = dangerous_value(form, ac_spans)
            if has_mint and not authed:
                emit("HIGH", "CLR-MINT-NOAUTH", start_line,
                     "public fn `%s` mints (ft-mint?/nft-mint?) with no "
                     "caller gate — unguarded supply" % name)
            if danger and not authed:
                emit("HIGH" if not caller_seen else "MEDIUM",
                     "CLR-TRANSFER-NOAUTH", start_line,
                     "public fn `%s` moves funds that are not the "
                     "caller's (contract or stored principal as "
                     "sender) %s" % (name,
                     "with no caller identity anywhere in the body" if
                     not caller_seen else
                     "on a caller-referencing path with no gate — "
                     "verify the caller check lives upstream"))
            if AS_CONTRACT.search(form) and (has_value or has_mint):
                if not authed and not caller_seen:
                    emit("HIGH", "CLR-AS-CONTRACT", start_line,
                         "`%s` moves contract-held funds via "
                         "as-contract with no caller identity in the "
                         "body — the contract balance is callable by "
                         "anyone" % name)
                else:
                    emit("MEDIUM", "CLR-AS-CONTRACT", start_line,
                         "`%s` wraps a value/mint op in as-contract — "
                         "the call acts with the contract's own "
                         "balance; verify every entry path is gated"
                         % name)
            if STATE_OP.search(form) and not authed \
                    and not (has_value or has_mint):
                emit("MEDIUM" if not caller_seen else "LOW",
                     "CLR-STATE-NOAUTH", start_line,
                     "public fn `%s` writes state (var-set/map-set/"
                     "map-delete/map-insert) %s" % (name,
                     "with no caller identity or gate in the body" if
                     not caller_seen else
                     "on a caller-referencing path with no explicit "
                     "gate — likely caller-scoped; verify"))
            for idx in discarded_calls(form):
                emit("LOW", "CLR-CALL-IGNORED",
                     start_line + form[:idx].count("\n"),
                     "contract-call? in statement position — the "
                     "response is discarded; a failed upstream call "
                     "reads as success downstream")

        traits, plains = signature_args(form)
        traits = match_traits(form, traits)
        for dm in DYN_CALL.finditer(form):
            target = dm.group(1)
            if target.startswith((".", "'", "(")):
                continue
            if target in traits or target in pinned:
                continue
            ln = start_line + form[:dm.start()].count("\n")
            if target in revars:
                emit("LOW", "CLR-DYNAMIC-CALL", ln,
                     "contract-call? target `%s` is a re-pointable "
                     "data-var principal — verify who can re-point it"
                     % target)
            elif target in plains:
                emit("MEDIUM", "CLR-DYNAMIC-CALL", ln,
                     "contract-call? target `%s` is a caller-supplied "
                     "principal arg with no trait bound — any contract "
                     "can sit behind it" % target)
            else:
                emit("MEDIUM", "CLR-DYNAMIC-CALL", ln,
                     "contract-call? target `%s` is not a declared "
                     "trait arg or pinned constant — verify the callee "
                     "identity" % target)

    # pass 3 — line-level signatures on the cleaned source; panic-unwrap
    # and deprecated-height aggregate per file (volume would drown the
    # per-line report)
    panic_src = {}
    height_lines = []
    for i, ln in enumerate(cleaned.splitlines(), 1):
        s = ln.strip()
        if not s:
            continue

        if VRF_INFO.search(ln) or HEIGHT_MOD.search(ln):
            emit("MEDIUM", "CLR-BLOCK-RAND", i,
                 "miner-influenceable selection — block-derived value "
                 "used as entropy or a pick; validators can nudge it")

        for pm in UNWRAP_PANIC.finditer(ln):
            panic_src.setdefault(pm.group(2) or "?", []).append(i)

        if ASSERT_TRUE.search(ln):
            emit("LOW", "CLR-ASSERT-TRUE", i,
                 "asserts! on a tautology — the guard cannot fail; "
                 "the check it was meant to hold is missing")

        if DEPRECATED_HEIGHT.search(ln):
            height_lines.append(i)

    if panic_src:
        parts = ", ".join("%s x%d" % (k, len(v))
                          for k, v in sorted(
                              panic_src.items(), key=lambda x: -len(x[1])))
        emit("MEDIUM", "CLR-UNWRAP-PANIC", panic_src[
            sorted(panic_src, key=lambda k: -len(panic_src[k]))[0]][0],
             "%d panic-unwraps on fallible sources (%s) — a forced "
             "abort bricks the call path; prefer unwrap!/asserts! with "
             "typed errors" % (sum(map(len, panic_src.values())), parts))
    if height_lines:
        emit("LOW", "CLR-DEPRECATED-HEIGHT", height_lines[0],
             "%d bare block-height read(s) at lines %s — deprecated "
             "under Clarity 3; use stacks-block-height (or "
             "burn-block-height for burnchain comparisons)"
             % (len(height_lines),
                ", ".join(map(str, height_lines[:8])) +
                ("..." if len(height_lines) > 8 else "")))

    return findings


def scan_config(path, rel, raw):
    findings = []
    is_test = bool(TEST_PATH.search(rel.replace(os.sep, "/")))
    hi = bool(MAINNET_NAME.search(os.path.basename(rel)))

    def emit(sev, code, ln, msg):
        if is_test:
            sev = DOWN[sev]
        findings.append(Finding(sev, code, rel, ln, msg))

    for i, ln in enumerate(raw.splitlines(), 1):
        s = ln.strip()
        if not s or s.startswith(("#", ";", "//")):
            continue
        if CFG_SECRET.search(ln):
            if hi:
                emit("HIGH", "CLR-SECRET-IN-FILE", i,
                     "key/mnemonic literal in a mainnet-named config — "
                     "committed secret material")
            else:
                emit("MEDIUM", "CLR-SECRET-IN-FILE", i,
                     "key/mnemonic literal in config — devnet defaults "
                     "are expected; verify this is the throwaway "
                     "account, not a funded key")
    return findings


def scan_file(path, rel):
    try:
        raw = open(path, "r", encoding="utf-8", errors="replace").read()
    except OSError:
        return []
    base = os.path.basename(rel)
    if rel.endswith(".clar"):
        return scan_clar(path, rel, raw)
    if base in TARGET_NAMES or rel.endswith((".toml", ".yaml", ".yml")):
        return scan_config(path, rel, raw)
    return []


def find_targets(root):
    if os.path.isfile(root):
        return [root]
    out = []
    for dp, dns, fns in os.walk(root):
        dns[:] = [d for d in dns if d not in SKIP_DIRS]
        for fn in fns:
            if fn.endswith(TARGET_EXT) or fn in TARGET_NAMES:
                out.append(os.path.join(dp, fn))
    return sorted(out)


def render(findings, targets):
    findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.where, f.line))
    rows = ["# clarity_prescan report", "",
            "files scanned: %d — findings: %d" % (len(targets),
                                                  len(findings)), ""]
    cur = None
    for f in findings:
        if f.sev != cur:
            cur = f.sev
            rows.append("## %s" % cur)
        rows.append("- `%s` %s:%d — %s" % (f.code, f.where, f.line,
                                           f.msg))
    if not findings:
        rows.append("no HIGH/MEDIUM/LOW signatures found — this is a "
                    "pre-scan; clean output != a safe contract")
    return "\n".join(rows)


# --- selftest ---------------------------------------------------------------
SELFTEST = [
    # --- CLR-TRANSFER-NOAUTH ---
    ("vault.clar",
     "(define-public (skim (amt uint))\n"
     "    (stx-transfer? amt tx-sender 'SP000))\n", set()),
    ("vault.clar",
     "(define-public (skim (amt uint))\n"
     "    (begin (stx-transfer? amt (as-contract tx-sender) tx-sender)"
     " (ok true)))\n", {"CLR-TRANSFER-NOAUTH", "CLR-AS-CONTRACT"}),
    ("vault.clar",
     "(define-public (pay (amt uint) (to principal))\n"
     "    (stx-transfer? amt tx-sender to))\n", set()),
    ("vault.clar",
     "(define-public (drain (amt uint) (to principal))\n"
     "    (as-contract (stx-transfer? amt tx-sender to)))\n",
     {"CLR-TRANSFER-NOAUTH", "CLR-AS-CONTRACT"}),  # rebound sender
    ("vault.clar",
     "(define-public (rebate (amt uint))\n"
     "    (begin (asserts! (is-eq tx-sender OWNER) (err u1))\n"
     "        (as-contract (stx-transfer? amt tx-sender tx-sender))))\n",
     {"CLR-AS-CONTRACT"}),   # gated + rebound => MEDIUM as-contract only
    ("vault.clar",
     "(define-constant OWNER tx-sender)\n"
     "(define-public (skim (amt uint))\n"
     "    (begin\n"
     "        (asserts! (is-eq tx-sender OWNER) (err u401))\n"
     "        (try! (stx-transfer? amt (as-contract tx-sender) "
     "tx-sender))\n"
     "        (ok true)))\n",
     {"CLR-AS-CONTRACT"}),   # gated -> MEDIUM as-contract only
    # --- CLR-MINT-NOAUTH ---
    ("tok.clar",
     "(define-public (mint (to principal) (amt uint))\n"
     "    (ft-mint? coin amt to))\n",
     {"CLR-MINT-NOAUTH"}),
    ("tok.clar",
     "(define-public (mint (to principal) (amt uint))\n"
     "    (begin (asserts! (is-eq tx-sender CONTRACT-OWNER) (err u1))"
     " (ft-mint? coin amt to)))\n", set()),
    # --- CLR-STATE-NOAUTH ---
    ("reg.clar",
     "(define-data-var count uint u0)\n"
     "(define-public (bump) (begin (var-set count (+ u1 "
     "(var-get count))) (ok true)))\n",
     {"CLR-STATE-NOAUTH"}),
    ("reg.clar",
     "(define-public (bump)\n"
     "    (begin (asserts! (is-eq contract-caller OWNER) (err u1))\n"
     "        (var-set count u1) (ok true)))\n", set()),
    # helper-suppression: private asserts-owner is a checker
    ("reg2.clar",
     "(define-private (assert-owner)\n"
     "    (asserts! (is-eq tx-sender OWNER) (err u401)))\n"
     "(define-public (bump)\n"
     "    (begin (try! (assert-owner)) (var-set count u1) "
     "(ok true)))\n", set()),
    # private / read-only never flag for gates
    ("ro.clar",
     "(define-read-only (peek) (var-get count))\n"
     "(define-private (setit) (var-set count u9))\n", set()),
    # --- CLR-DYNAMIC-CALL ---
    ("dex.clar",
     "(define-public (swap (token <sip-010-trait>))\n"
     "    (contract-call? token transfer u1 tx-sender tx-sender "
     "none))\n", set()),                       # trait-bounded = pinned iface
    ("dex.clar",
     "(define-public (swap (token principal))\n"
     "    (contract-call? token settle))\n",
     {"CLR-DYNAMIC-CALL"}),                    # plain principal arg
    ("dex.clar",
     "(define-constant CORE .jing-core)\n"
     "(define-public (go)\n"
     "    (contract-call? CORE settle))\n", set()),  # pinned constant
    ("dex.clar",
     "(define-data-var CORE principal .jing-core)\n"
     "(define-public (go)\n"
     "    (contract-call? CORE settle))\n",
     {"CLR-DYNAMIC-CALL"}),                    # re-pointable data-var
    ("dex.clar",
     "(define-public (go)\n"
     "    (contract-call? .pool settle))\n", set()),
    ("dex.clar",
     "(define-public (go (p principal))\n"
     "    (contract-call? 'SP123.pool settle))\n", set()),
    # --- CLR-CALL-IGNORED ---
    ("fwd.clar",
     "(define-public (fwd)\n"
     "    (begin\n"
     "        (contract-call? .pool settle)\n"
     "        (ok true)))\n",
     {"CLR-CALL-IGNORED"}),
    ("fwd.clar",
     "(define-public (fwd)\n"
     "    (try! (contract-call? .pool settle)))\n", set()),
    ("fwd.clar",
     "(define-public (fwd)\n"
     "    (let ((r (contract-call? .pool settle))) (ok r)))\n", set()),
    # --- CLR-BLOCK-RAND ---
    ("lottery.clar",
     "(define-public (draw)\n"
     "    (let ((s (unwrap-panic (get-block-info? vrf-seed "
     "u1))))\n"
     "        (ok s)))\n",
     {"CLR-BLOCK-RAND", "CLR-UNWRAP-PANIC"}),
    ("lottery.clar",
     "(define-public (pick (n uint))\n"
     "    (ok (mod block-height n)))\n",
     {"CLR-BLOCK-RAND", "CLR-DEPRECATED-HEIGHT"}),
    ("lottery.clar",
     "(define-public (exp (h uint))\n"
     "    (ok (> stacks-block-height h)))\n", set()),
    # --- CLR-UNWRAP-PANIC ---
    ("u.clar", "(define-public (g) (ok (unwrap-panic (map-get? m "
     "{k: u1}))))\n", {"CLR-UNWRAP-PANIC"}),
    ("u.clar", "(define-public (g) (ok (unwrap! (map-get? m {k: u1})"
     " (err u404))))\n", set()),
    ("u.clar", "(define-public (g) (ok (unwrap-err-panic "
     "(ft-transfer? t u1 a b))))\n",
     {"CLR-UNWRAP-PANIC", "CLR-TRANSFER-NOAUTH"}),
    # --- CLR-ASSERT-TRUE ---
    ("a.clar", "(define-public (f) (begin (asserts! true (err u1)) "
     "(ok true)))\n", {"CLR-ASSERT-TRUE"}),
    ("a.clar", "(define-public (f) (begin (asserts! (is-eq x x) "
     "(err u1)) (ok true)))\n", {"CLR-ASSERT-TRUE"}),
    ("a.clar", "(define-public (f (p principal)) (begin (asserts! "
     "(is-eq p tx-sender) (err u1)) (ok true)))\n", set()),
    # --- CLR-DEPRECATED-HEIGHT ---
    ("h.clar", "(define-constant DEPLOY-HEIGHT block-height)\n",
     {"CLR-DEPRECATED-HEIGHT"}),
    ("h.clar", "(define-constant H stacks-block-height)\n", set()),
    ("h.clar", "(define-constant H burn-block-height)\n", set()),
    # --- CLR-SECRET-IN-FILE ---
    ("Clarinet.toml",
     "[accounts.deployer]\nmnemonic = \"twelve words go here and more "
     "words follow them ok\"\n", {"CLR-SECRET-IN-FILE"}),
    ("Mainnet.toml",
     "[accounts.deployer]\nmnemonic = \"twelve words go here and more "
     "words follow them ok\"\n", {"CLR-SECRET-IN-FILE"}),
    ("Clarinet.toml",
     "[accounts.deployer]\nbalance = \"100000000\"\n", set()),
    ("settings/Devnet.toml",
     "secret_key = \"9f4a2cbe00112233aabbccddeeff00112233445566778899"
     "aabbccddeeff01\"\n", {"CLR-SECRET-IN-FILE"}),
    ("k.clar",
     "(define-constant signing-key 0x9f4a2cbe00112233aabbccddeeff00112"
     "23344556677889900)\n", {"CLR-SECRET-IN-FILE"}),
    ("k.clar",
     "(define-constant pubkey 0x02f4a2cbe00112233aabbccddeeff001122334"
     "45566778899001122334455667788990)\n", set()),
    # --- interplay / FP guards ---
    ("t.clar", ";; (define-public (evil) (stx-transfer? u1 a b))\n",
     set()),
    ("t.clar", "(define-constant MSG \"(define-public (x) "
     "(stx-transfer? u1 a b))\")\n", set()),
    ("tests/test_vault.clar",
     "(define-public (skim (amt uint)) (stx-transfer? amt a b))\n",
     {"CLR-TRANSFER-NOAUTH"}),   # downgrades HIGH->MEDIUM
    ("clean.clar",
     "(define-public (f (p principal))\n"
     "    (begin (asserts! (is-eq p tx-sender) (err u1)) (ok p)))\n",
     set()),
]


def run_selftest():
    import tempfile
    fails = []
    for i, (name, src, expect) in enumerate(SELFTEST):
        with tempfile.TemporaryDirectory() as td:
            p = os.path.join(td, name)
            os.makedirs(os.path.dirname(p) or td, exist_ok=True)
            open(p, "w").write(src)
            got = {f.code for f in scan_file(p, name)}
            missing = expect - got
            extra = got - expect
            if missing or extra:
                fails.append(("v%d %s" % (i, name),
                              "missing=%s extra=%s" % (missing, extra)))
    if fails:
        for f_ in fails:
            print("SELFTEST FAIL", f_)
        return 1
    print("SELFTEST PASS (%d vectors)" % len(SELFTEST))
    return 0


def main():
    args = [a for a in sys.argv[1:] if a != "--json"]
    want_json = "--json" in sys.argv
    if "--selftest" in sys.argv:
        return run_selftest()
    if not args:
        print(__doc__)
        return 2
    root = args[0]
    targets = find_targets(root)
    findings = []
    for p in targets:
        rel = os.path.relpath(p, root) if os.path.isdir(root) \
            else os.path.basename(p)
        findings += scan_file(p, rel)
    if want_json:
        print(json.dumps({"findings": [f.as_dict() for f in findings],
                          "scanned": len(targets)}, indent=1))
    else:
        print(render(findings, targets))
    return 1 if any(f.sev in ("HIGH", "MEDIUM") for f in findings) else 0


if __name__ == "__main__":
    sys.exit(main())
