#!/usr/bin/env python3
"""reslimit_prescan.py — deterministic unbounded-resource pre-scan
(CWE-770 allocation of resources without limits, CWE-400 uncontrolled
resource consumption).

ARION service artifact. Scans source files for request-influenced
size/count values reaching allocation, read, loop-bound, pagination
and delay sinks — the per-request burn a rate limiter does NOT stop
(companion to ratelimit_prescan.py, which checks endpoint throttling).
Line-wise, fully offline, no inference.

Findings (base severity):
  RL-REQ-ALLOC    HIGH    request-derived value as the SIZE argument of
                          an allocation / repeat / buffer build —
                          bytes(n) / bytearray(n) / 'x'*n / [0]*n /
                          new Array(n) / new Uint8Array(n) /
                          Buffer.alloc(n) / s.repeat(n) / padStart(n) /
                          new byte[n] / new char[n] / StringBuilder(n) /
                          ByteArray(n) / arrayOfNulls(n) / make([]T, n)
                          / vec![0; n] / with_capacity(n) /
                          str_repeat(, n) / array_fill(,, n) /
                          Array.new(n) / seq|head -c|dd|truncate
  RL-REQ-READ     HIGH    request-derived n into a byte-count consume —
                          read(n) / recv(n) / readline(n) / readBytes /
                          readNBytes / io.CopyN / io.LimitReader /
                          .take(n) on streams / fread(, n) — the
                          "send me N bytes" amplification
  RL-REQ-LOOP     MEDIUM  request-derived value as a loop bound —
                          range(n) / for (i<n) / while (i<n) /
                          n.times / seq $n — CPU linear in attacker
                          input
  RL-REQ-PAGE     MEDIUM  request-derived page/limit/per_page into a
                          result-set cap — .limit(n) / .take(n) /
                          .slice(0, n) / subList(0, n) / LIMIT concat —
                          unbounded DB read + serialisation
  RL-REQ-SLEEP    MEDIUM  request-derived delay into sleep / usleep /
                          Thread.sleep / time.Sleep / setTimeout /
                          setInterval — worker-slot hold; trivially
                          stacks into exhaustion under concurrency
  RL-VAR-ALLOC    LOW     a size-named variable (size, count, n, len,
                          num, limit, max, depth, width, height, cap)
                          of unseen provenance reaches an alloc/read/
                          page sink — provenance not visible here;
                          verify a bound is applied upstream
  RL-DECOMP       LOW     declared / header-supplied size trusted for
                          pre-allocation — Content-Length / x-size /
                          declared_length into alloc or read without a
                          sanity cap (declared != actual)

Precision model:
  * request-taint is tracked through simple assignments
    (var = <expr with a request marker>); numeric casts
    (int()/parseInt/atoi/strconv.Atoi/Integer()/to_i/Number()/
    parseFloat/Long.parse) propagate the taint — the canonical
    `n = int(request.args['n'])` flow; Go `n, _ := ...` multi-assign
    and Kotlin `val n = ...` handled
  * a value that reaches a var only through an opaque CALL's
    arguments (var = f(tainted)) is "call-derived" — the callee may
    transform or clamp it — MEDIUM, never HIGH
  * a reassignment of the variable before the sink breaks the taint;
    a reassignment to a literal silences it entirely
  * bound-guard vocabulary on the sink line or within three lines
    above (min( / Math.min / clamp / bounded / capped / MAX /
    _MAX / max_len|maxSize|maxLen / `if n > <num>` both c-style
    and python-style comparisons) suppresses RL-REQ-* rows —
    the cap is claimed applied. RL-VAR-ALLOC and RL-DECOMP are
    never suppressed
  * numeric literals as the size arg are silent
  * 'params[' counts as a WEAK request marker (Rails params vs plain
    dicts) — it produces MEDIUM rows, never HIGH
  * shell sinks check the SIZE argument only (`head -c 200 $f` is
    capped even though $f is external); positional params
    ($1..$9/$@/$*) count as request markers, named $vars of unseen
    provenance are call-tier — .sh files only
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped (quote-aware:
    '//' inside a string literal is not a comment)

Scope: deterministic pre-scan for human review — flags dataflow
shapes; it cannot prove internet reachability, and a clean report is
not a resource-safety guarantee. This tool checks PER-REQUEST burn;
endpoint rate limiting is ratelimit_prescan.py's job.

  reslimit_prescan.py <file-or-dir> [--json]
  reslimit_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}

TARGET_EXT = (
    ".py", ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
    ".php", ".rb", ".go", ".java", ".kt", ".cs", ".rs",
    ".c", ".h", ".cpp", ".cc", ".cxx", ".groovy", ".sh",
)

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

IMPORT_LINE = re.compile(
    r"^\s*(?:from\s+[\w.]+\s+import\b|import\s+[\w.{*]|"
    r"(?:const|let|var)\s+[\w${},\s]*=\s*require\s*\(|"
    r"\buse\s+[\w:]+|require\s*\(['\"][\w./-]+['\"]\s*\)?\s*;?\s*$|"
    r"package\s+\w+\s*$|#include\s*<)")

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

REQ_SRC = re.compile(r"""(?ix)
    request\.(?:args|form|GET|POST|data|json|values|files|body|params|
        query|cookies|headers|get_json|getParameter|getQueryString|
        content_length|contentLength|CONTENT_LENGTH)                |
    req\.(?:query|params|body|headers|ip|url|hostname|cookies)|req\[   |
    ctx\.(?:query|request|params|param)|c\.(?:Query|QueryParam|
        FormValue|Param|PostForm|BodyParser|GetHeader)               |
    \$_GET|\$_POST|\$_REQUEST|\$_COOKIE|\$_FILES|\$_SERVER            |
    r\.(?:URL\.Query|FormValue|PostForm|Form\[|PostFormValue|Header)   |
    getParameterMap|event\.(?:queryStringParameters|body|
        pathParameters|multiValue)                                   |
    self\.request|flask\.request|query_params|HttpServletRequest|
    params\s*\[|param\s*\(|\bARGV\b|\bargv\b
""")

REQ_WEAK = re.compile(r"\bparams\s*\[|\bparam\s*\(")

SHELL_ARG = re.compile(r"\$(?:\d|@|\*|[A-Za-z_]\w*|\{[^}]*\})")

NUMCAST = re.compile(
    r"(?i)\bint\s*\(|\bfloat\s*\(|\bparseInt\s*\(|\bparseFloat\s*\(|"
    r"\bNumber\s*\(|\bBigInt\s*\(|Integer\.(?:parseInt|valueOf)\s*\(|"
    r"Long\.(?:parseLong|valueOf)\s*\(|strconv\.(?:Atoi|ParseInt|"
    r"ParseUint|ParseFloat)\s*\(|\batoi\s*\(|\batol\s*\(|\bstrtol\s*\(|"
    r"strtoul\s*\(|\bto_i\b|\bto_f\b|\.parse\s*\(|intValue\s*\(|"
    r"longValue\s*\(|\buint\s*\(|Convert\.To\w+\s*\(|"
    r"\.toInt\s*\(|\.toLong\s*\(|\.toUInt|as\s+(?:usize|i32|i64|u32|u64|"
    r"isize|int)\b|\bInt\s*\(|\bintval\s*\(")

SIZE_NAME = re.compile(
    r"(?i)^(?:n|num|size|count|len|length|amount|limit|max|capacity|"
    r"cap|depth|width|height|dim|dims|items|per_page|perpage|page_?size|"
    r"chunk|bufsize|buf_size|nbytes|num_bytes|qty|quantity|total|times|"
    r"repeats?|iterations?|rounds?|workers?|batch|batch_?size|"
    r"top_k|k|rows|records|entries|nb|cnt|n_items|nbytes|how_many)$")

DECL_NAME = re.compile(
    r"(?i)(content_?length|contentLength|declared_?(?:size|len|length)|"
    r"x[_-]?(?:size|length|count)|expected_?(?:size|len|length)|"
    r"file_?size|upload_?size)")

GUARD = re.compile(
    r"(?i)\bmin\s*\(|\bmax\s*\(\s*\d|Math\.min\s*\(|Math\.max\s*\(|"
    r"fmin\s*\(|std::min|\.min\s*\(|Min\s*\(|clamp|bound(?:ed|ary|s)|"
    r"capped|_MAX\b|\bMAX_|max_?(?:len|size|count|bytes|items|depth|"
    r"page|limit|results)|maxLen|maxSize|maxCount|maxDepth|maxItems|"
    r"limit_to|sanitize_?int|"
    r"if\s*\([^)]*(?:<=|>=|<|>)\s*\d{1,12}|"
    r"if\s+\w[\w.]*\s*(?:<=|>=|<|>)\s*\d{1,12}|"
    r"\d{1,12}\s*(?:<=|>=|<|>)\s*\w+|"
    r"\b(?:assert|check|verify|require)\w*\s*\([^)]*(?:<=|>=|<|>)")

# ---- sinks ------------------------------------------------------------------
# (regex, code, severity, note, style) — style 'call' extracts paren
# args; 'line' checks the rest of the line (shell / LIMIT concat).
SINKS = [
    (re.compile(r"\b(?:time\.)?sleep\s*\(|\busleep\s*\(|\bnsleep\s*\(|"
                r"\bThread\.sleep\s*\(|\bTimeUnit\.\w+\.sleep\s*\(|"
                r"\bTask\.Delay\s*\(|\btime\.Sleep\s*\(|"
                r"\bstd::this_thread::sleep_for\s*\(|"
                r"\b(?:setTimeout|setInterval)\s*\("),
     "RL-REQ-SLEEP", "MEDIUM",
     "request-derived delay — worker-slot hold", "call"),
    (re.compile(r"(?<![\w.])(?:bytes|bytearray)\s*\("),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized bytes()/bytearray() allocation", "call"),
    (re.compile(r"\b(?:range|xrange)\s*\("),
     "RL-REQ-LOOP", "MEDIUM",
     "request-derived loop bound range()", "call"),
    (re.compile(r"(?<![\w.])islice\s*\("),
     "RL-REQ-READ", "HIGH",
     "request-derived islice count — unbounded consume", "call"),
    (re.compile(r"\.(?:read|recv|readline|readBytes|readNBytes|"
                r"readFully|read_exact|take|skip)\s*\("),
     "RL-REQ-READ", "HIGH",
     "request-derived byte-count consume", "call"),
    (re.compile(r"\bio\.(?:CopyN|LimitReader|ReadAtLeast|ReadFull)\s*\("),
     "RL-REQ-READ", "HIGH",
     "request-derived byte-count consume (io.CopyN/LimitReader)",
     "call"),
    (re.compile(r"\bnew\s+(?:Array|Int8Array|Uint8Array|"
                r"Uint8ClampedArray|Int16Array|Uint16Array|Int32Array|"
                r"Uint32Array|Float32Array|Float64Array|BigInt64Array|"
                r"ArrayBuffer|SharedArrayBuffer|Buffer)\s*\(|"
                r"\bArray\s*\(|\bBuffer\.alloc(?:Unsafe|UnsafeSlow)?"
                r"\s*\(|\bBuffer\s*\("),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized array/buffer allocation", "call"),
    (re.compile(r"\.(?:repeat|padStart|padEnd)\s*\("),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized string repeat/pad — memory blowup", "call-js"),
    (re.compile(r"\.(?:limit|first|per|pageSize|setMaxResults|"
                r"setFetchSize|setMaxRows|SetMaxOpenConns|top)\s*\("),
     "RL-REQ-PAGE", "MEDIUM",
     "request-derived result-set cap — unbounded read", "call"),
    (re.compile(r"\.(?:slice|subList|substring|substr)\s*\("),
     "RL-REQ-PAGE", "MEDIUM",
     "request-derived slice bound", "call"),
    (re.compile(r"\bnew\s+(?:byte|char|int|short|long|float|double|"
                r"boolean|String|Object)\s*\[\s*|"
                r"\bnew\s+(?:StringBuilder|StringBuffer|ArrayList|"
                r"LinkedList|HashMap|HashSet|Vector|ArrayDeque)\s*\(|"
                r"(?<![\w.])(?:ByteArray|CharArray|IntArray|LongArray|"
                r"FloatArray|DoubleArray|BooleanArray|ShortArray|"
                r"UByteArray)\s*\(|"
                r"\barrayOfNulls\s*\(|\bArrays\.copyOf\w*\s*\(|"
                r"\bEnumerable\.(?:Range|Repeat)\s*\("),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized array/collection allocation", "call"),
    (re.compile(r"\bstr_repeat\s*\(|\barray_fill\w*\s*\(|\bstr_pad\s*\("),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized string/array build", "call"),
    (re.compile(r"\bfread\s*\(|\bfgets\s*\(|\bstream_get_contents\s*\(|"
                r"\bsocket_read\s*\("),
     "RL-REQ-READ", "HIGH",
     "request-derived byte-count read", "call"),
    (re.compile(r"\bArray\.new\s*\(|\bString\.new\s*\("),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized array/string allocation", "call"),
    (re.compile(r"\bmake\s*\(\s*(?:\[\]|map\b|chan\b)"),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized make() allocation", "call"),
    (re.compile(r"::with_capacity\s*\(|vec!\s*\[|\.repeat_n\s*\("),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized vec/string build", "call"),
    (re.compile(r"\b(?:malloc|calloc|realloc|alloca)\s*\(|"
                r"\bnew\s+(?:char|int|byte|short|long)\s*\["),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized heap allocation", "call"),
    (re.compile(r"(?i)\bLIMIT\s*(?:['\"`]?\s*(?:\+|\|\||%)|\?)"
                r"|(?:SELECT|INSERT|UPDATE|DELETE)\b.{0,90}?\bLIMIT\s+"),
     "RL-REQ-PAGE", "MEDIUM",
     "LIMIT clause built with a request-derived bound", "line"),
    (re.compile(r"\bseq\s+|\bhead\s+-(?:c|n)\s*|\bdd\s+.*\bcount\s*=|"
                r"\byes\b.*\bhead\s+-n\s*|\btruncate\s+-s\s*|"
                r"\bfallocate\s+-l\s*"),
     "RL-REQ-ALLOC", "HIGH",
     "request-sized shell expand/read", "shell"),
    (re.compile(r"(?<![\w./-])sleep\s+"),
     "RL-REQ-SLEEP", "MEDIUM",
     "request-derived shell sleep — worker-slot hold", "shell"),
]

BRACKET_ALLOC = re.compile(
    r"\bnew\s+(?:byte|char|int|short|long|float|double|boolean)\s*\["
    r"\s*([^\]]{1,60}?)\s*\]|vec!\s*\[[^;\]]{0,40};\s*([^;\]]{1,40}?)\s*\]")

INFIX_ALLOC = re.compile(
    r"(?:'[^'\n]{0,8}'|\"[^\"\n]{0,8}\"|\[[^\]\n]{0,20}\])\s*\*\s*"
    r"([A-Za-z_$][\w$.]*)|([A-Za-z_$][\w$.]*)\s*\*\s*"
    r"(?:'[^'\n]{0,8}'|\"[^\"\n]{0,8}\"|\[[^\]\n]{0,20}\])")

LOOP_BOUND = re.compile(
    r"(?:for|while)\s*\([^)]*?[A-Za-z_$][\w$]*\s*(?:<|<=|!=)\s*"
    r"([A-Za-z_$][\w$.()]*)\s*[;)]")

FORIN_COUNT = re.compile(
    r"\bfor\s+\w+\s+in\s+range\s*\(|(\w+)\s*\.\s*(?:times|upto|downto)"
    r"\s*(?:do\b|\{|\()")

ASSIGN = re.compile(
    r"(?<![\w.])(?:const\s+|let\s+|var\s+|val\s+)?(?:final\s+|static\s+|"
    r"(?:String|long|int|var|auto|string|val|public|private|"
    r"size_t|ssize_t|uint\w*_t|int\w*_t|char|byte|float|double|"
    r"Integer|Long|mut)\s+)*(?:\*\s*|&\s*)?"
    r"([A-Za-z_@$][\w$]*(?:\s*,\s*[A-Za-z_@$][\w$]*)*(?:\.\w+)?)"
    r"\s*(?::[^=\n]*)?:?=\s*(?!=|>)(.+)$")

IDENT = re.compile(r"(?<![\w.'$])([A-Za-z_$][\w$]{0,40})(?![\w'])")

KWARG = re.compile(r"\b[A-Za-z_][\w$]*\s*=")

SKIP_IDENT = {
    "self", "this", "cls", "true", "false", "True", "False", "None",
    "null", "nil", "undefined", "GET", "POST", "PUT", "HEAD", "DELETE",
    "PATCH", "OPTIONS", "new", "return", "const", "let", "var", "val",
    "static", "final", "public", "private", "err", "fn", "func",
    "sizeof", "min", "max", "int", "uint", "long", "float", "double",
    "char", "byte", "bool", "string", "void", "auto", "mut", "ref",
    "require", "include", "use", "import", "package", "echo", "print",
    "elif", "else", "if", "for", "while", "do", "then", "end", "in",
    "is", "not", "and", "or", "async", "await", "read", "open",
    "seq", "dd", "head", "yes", "sleep", "truncate", "fallocate",
    "make", "new2", "vec", "str", "repr", "format", "print",
}

FUNC_DECL = re.compile(
    r"^\s*(?:(?:async|static|public|private|protected|internal|"
    r"override|virtual|abstract|export|extern)\s+)*"
    r"(?:def|func|function|fn|sub)\s+[A-Za-z_]\w*\s*\(|"
    r"^\s*(?:public|private|protected|internal)\s+"
    r"(?:static\s+)?[\w<>\[\],.?&*\s]*\w+\s*\(")

NUMLIT = re.compile(r"^\s*[\d.,\s*+\-/xXoObB()uUlLfFmMkKgG]+\s*$")

# shell command → size-argument extraction
SHELL_SIZE = [
    re.compile(r"\bseq\s+(?:[^|\s;&]+\s+)*([^\s|;&]+)"),
    re.compile(r"\bhead\s+-(?:c|n|b)\s*\"?([^\s\"'|;&]+)"),
    re.compile(r"\btruncate\s+-s\s*\"?([^\s\"'|;&]+)"),
    re.compile(r"\bfallocate\s+-l\s*\"?([^\s\"'|;&]+)"),
    re.compile(r"\bcount\s*=\s*([^\s|;&]+)"),
    re.compile(r"\bbs\s*=\s*([^\s|;&]+)"),
    re.compile(r"\bsleep\s+\"?([^\s\"'|;&]+)"),
]


def _shell_size_args(rest):
    out = []
    for pat in SHELL_SIZE:
        m = pat.search(rest)
        if m:
            out.append(m.group(1))
    return out


def _strip_line_comment(code, ext):
    """strip // or # tail comments, quote-aware: a '//' or '#' inside a
    string literal is kept (http:// literals are not comments)."""
    hashes = ext in (".py", ".rb", ".sh", ".pl", ".r", ".yaml", ".yml")
    q = None
    for k, ch in enumerate(code):
        if q:
            if ch == q and code[k - 1] != "\\":
                q = None
            continue
        if ch in "\"'`":
            q = ch
            continue
        if hashes and ch == "#":
            return code[:k]
        if not hashes and ch == "/" and k + 1 < len(code) \
                and code[k + 1] == "/":
            return code[:k]
    return code


def _extract_args(code, open_idx):
    """top-level arguments of the call whose '(' sits at open_idx"""
    depth = 0
    q = None
    args = []
    cur = []
    for k in range(open_idx, len(code)):
        ch = code[k]
        if q:
            cur.append(ch)
            if ch == q and code[k - 1] != "\\":
                q = None
            continue
        if ch in "\"'`":
            q = ch
            cur.append(ch)
            continue
        if ch == "(":
            depth += 1
            if depth == 1:
                continue
        elif ch == ")":
            depth -= 1
            if depth == 0:
                args.append("".join(cur))
                return args
        elif ch == "," and depth == 1:
            args.append("".join(cur))
            cur = []
            continue
        if depth >= 1:
            cur.append(ch)
    if cur:
        args.append("".join(cur))
    return args


def _is_test_path(rel):
    return bool(TEST_PATH.search("/" + rel))


def _guard_near(codes, i, back=3):
    lo = max(0, i - back)
    return bool(GUARD.search("\n".join(codes[lo:i + 1])))


def _reassigned(codes, var, lo, hi):
    pat = re.compile(
        r"(?<![\w.])" + re.escape(var) +
        r"\s*(?::?=|<<=|>>=|\+=|-=|\*=|/=)\s*(?!=)")
    for j in range(lo, hi):
        if pat.search(codes[j]):
            return True
    return False


def _req_marker(code, ext):
    if REQ_SRC.search(code):
        return True
    return ext == ".sh" and bool(SHELL_ARG.search(code))


class Finding:
    def __init__(self, code, sev, path, line_no, line, note):
        self.code = code
        self.sev = sev
        self.path = path
        self.line_no = line_no
        self.line = line.strip()[:200]
        self.note = note

    def as_dict(self):
        return {"code": self.code, "severity": self.sev,
                "file": self.path, "line": self.line_no,
                "text": self.line, "note": self.note}


def scan_file(path, rel):
    ext = os.path.splitext(path)[1].lower()
    try:
        with open(path, "r", encoding="utf-8", errors="replace") as f:
            lines = f.readlines()
    except OSError:
        return []
    findings = []
    seen = set()
    in_block = False
    codes = []
    for raw in lines:
        line = raw.rstrip("\n")
        j = 0
        out = []
        while j < len(line):
            if in_block:
                hit = False
                for closer in ("*/", "-->"):
                    k = line.find(closer, j)
                    if k >= 0:
                        j = k + len(closer)
                        in_block = False
                        hit = True
                        break
                if not hit:
                    j = len(line)
                continue
            ko = -1
            for opener in ("/*", "<!--"):
                k = line.find(opener, j)
                if k >= 0 and (ko < 0 or k < ko):
                    ko = k
            if ko < 0:
                out.append(line[j:])
                break
            out.append(line[j:ko])
            j = ko
            in_block = True
            hit = False
            for closer in ("*/", "-->"):
                k = line.find(closer, j + 2)
                if k >= 0:
                    out.append(" ")
                    j = k + len(closer)
                    in_block = False
                    hit = True
                    break
        code = _strip_line_comment("".join(out), ext)
        codes.append(code)

    test = _is_test_path(rel)

    def add(code_, sev, i, note):
        key = (code_, i)
        if key in seen:
            return
        seen.add(key)
        if test and SEV_ORDER[sev] < SEV_ORDER["LOW"]:
            sev = "MEDIUM" if sev == "HIGH" else "LOW"
        findings.append(Finding(code_, sev, rel, i + 1,
                                lines[i].rstrip("\n"), note))

    # ---- pass 1: assignments → taint ------------------------------------
    # kinds: req (request marker), weak (params[]), size (size-named or
    # numeric-cast unknown), decl (declared-size name), lit (literal —
    # silences earlier taint)
    taint = {}
    for i, code in enumerate(codes):
        if not code.strip() or IMPORT_LINE.search(code):
            continue
        am = ASSIGN.search(code)
        if not am:
            continue
        lhs = am.group(1)
        expr = am.group(2)
        kind = None
        if _req_marker(expr, ext):
            kind = "req"
        elif REQ_WEAK.search(expr):
            kind = "weak"
        elif NUMLIT.match(expr.strip().rstrip(";").strip()):
            kind = "lit"
        else:
            scan = re.sub(r"'[^']*'|\"[^\"]*\"|`[^`]*`", " ", expr)
            best = None
            for ident in IDENT.findall(scan):
                ident = ident.lstrip("$@").split(".")[-1]
                if ident in SKIP_IDENT:
                    continue
                tks = taint.get(ident)
                if not tks:
                    continue
                tk = tks[-1][0]
                if tk == "lit":
                    continue
                if best is None or tk == "req":
                    best = tk
            if best:
                # a tainted value that only reaches the var through an
                # opaque CALL's arguments is "call-derived" — the callee
                # may transform or clamp it; flag for review, not HIGH
                if best == "req" and not NUMCAST.search(expr) \
                        and re.search(r"\w+\s*\(", expr) \
                        and not re.search(r"[+*/%\x2d]", expr):
                    kind = "call"
                else:
                    kind = best
            elif NUMCAST.search(expr):
                kind = "size"
        for var in lhs.split(","):
            var = var.strip().lstrip("$@").split(".")[-1]
            if not var or var == "_":
                continue
            if kind:
                taint.setdefault(var, []).append((kind, i))
            elif SIZE_NAME.match(var):
                taint.setdefault(var, []).append(("size", i))
            if DECL_NAME.search(var):
                taint.setdefault(var, []).append(("decl", i))

    def _ident_hits(scan):
        """idents in blob, skipping all-caps constants, keywords and
        identifiers used as a call name (immediately followed by '(')."""
        for m in IDENT.finditer(scan):
            tail = scan[m.end():].lstrip()
            if tail.startswith("("):
                continue
            ident = m.group(1).lstrip("$@").split(".")[-1]
            if ident in SKIP_IDENT or re.fullmatch(r"[A-Z_]{3,}", ident):
                continue
            yield ident

    def _arg_taint(arg, i):
        if REQ_SRC.search(arg):
            return ("req", None, i)
        scan = re.sub(r"'[^']*'|\"[^\"]*\"|`[^`]*`", " ", arg)
        scan = KWARG.sub(" ", scan)
        for ident in _ident_hits(scan):
            tks = [t for t in taint.get(ident, ()) if t[1] < i]
            if not tks:
                continue
            tk = tks[-1]
            if tk[0] == "lit":
                continue
            if _reassigned(codes, ident, tk[1] + 1, i):
                continue
            return (tk[0], ident, tk[1])
        if ext == ".sh":
            # positional/special params are external input (req);
            # a named $var of unseen provenance is call-tier
            if re.search(r"\$(?:\d|@|\*|\{\d+|\{[@*])", arg):
                return ("req", None, i)
            if SHELL_ARG.search(arg):
                return ("call", None, i)
        return None

    # ---- pass 2: sinks ---------------------------------------------------
    def report(codename, sev, note, argline, i):
        var_ok = codename == "RL-REQ-ALLOC"
        if NUMLIT.match(argline.strip().rstrip(";").strip()):
            return
        tk = _arg_taint(argline, i)
        if tk:
            kind, ident, sidx = tk
            if kind == "req":
                if not _guard_near(codes, i):
                    add(codename, sev, i,
                        note + (" (from line %d)" % (sidx + 1)
                                if ident else ""))
                return
            if kind == "weak":
                if not _guard_near(codes, i):
                    add(codename, "MEDIUM", i,
                        note + " — params-style request value '%s' "
                        "(line %d); verify it is not a plain dict"
                        % (ident, sidx + 1))
                return
            if kind == "call":
                if not _guard_near(codes, i):
                    add(codename, "MEDIUM", i,
                        note + " — call-derived value '%s' (line %d): "
                        "the callee may transform or clamp it; verify "
                        "a bound" % (ident, sidx + 1))
                return
            if kind == "decl":
                add("RL-DECOMP", "LOW", i,
                    "declared-size '%s' (line %d) reaches a "
                    "consume/alloc — declared != actual; cap "
                    "before use" % (ident, sidx + 1))
                return
            if kind == "lit":
                return
            if var_ok:
                add("RL-VAR-ALLOC", "LOW", i,
                    "size-named '%s' reaches this sink — provenance "
                    "not visible here; verify a bound is applied "
                    "upstream" % ident)
            return
        # no taint — flag only a size-named unknown-provenance ident,
        # and only at allocation sinks (read/loop/page would flood)
        if not var_ok:
            return
        scan = re.sub(r"'[^']*'|\"[^\"]*\"|`[^`]*`", " ", argline)
        scan = KWARG.sub(" ", scan)
        for ident in _ident_hits(scan):
            tks = [t for t in taint.get(ident, ()) if t[1] < i]
            if tks and tks[-1][0] == "lit":
                continue
            if SIZE_NAME.match(ident):
                add("RL-VAR-ALLOC", "LOW", i,
                    "size-named '%s' reaches this sink — provenance "
                    "not visible here; verify a bound is applied "
                    "upstream" % ident)
                return

    for i, code in enumerate(codes):
        if not code.strip() or IMPORT_LINE.search(code):
            continue
        is_decl = bool(FUNC_DECL.search(code))

        for sink_re, codename, sev, note, style in SINKS:
            if is_decl:
                break
            if style in ("line", "shell") and ext != ".sh" \
                    and codename != "RL-REQ-PAGE":
                continue
            if style == "shell" and ext != ".sh":
                continue
            if style == "call-js" and ext not in (
                    ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs",
                    ".rs", ".java", ".kt"):
                continue
            for m in sink_re.finditer(code):
                rest = code[m.start():]
                if style == "shell":
                    # flag only when the SIZE argument itself is
                    # non-literal — `head -c 200 $f` is capped even
                    # though $f is external
                    for blob in _shell_size_args(rest):
                        report(codename, sev, note, blob, i)
                    continue
                if style == "line":
                    argline = rest[:140]
                else:
                    oi = rest.find("(")
                    if oi < 0:
                        argline = rest[:80]
                    else:
                        args = _extract_args(rest, oi)
                        argline = " ".join(args)
                        if not argline.strip():
                            argline = rest[:80]
                report(codename, sev, note, argline, i)

        for m in BRACKET_ALLOC.finditer(code):
            blob = " ".join(g for g in m.groups() if g)
            if blob.strip():
                report("RL-REQ-ALLOC", "HIGH",
                       "request-sized bracket allocation", blob, i)

        for m in INFIX_ALLOC.finditer(code):
            blob = " ".join(g for g in m.groups() if g)
            if blob.strip():
                report("RL-REQ-ALLOC", "HIGH",
                       "request-sized infix repeat/multiply", blob, i)

        lm = LOOP_BOUND.search(code)
        if lm:
            blob = lm.group(1)
            if not NUMLIT.match(blob):
                tk = _arg_taint(blob, i)
                if tk and tk[0] == "req" and not _guard_near(codes, i):
                    add("RL-REQ-LOOP", "MEDIUM", i,
                        "request-derived loop bound — CPU linear in "
                        "attacker input" +
                        (" (from line %d)" % (tk[2] + 1)
                         if tk[1] else ""))
                elif tk and tk[0] == "weak" and not _guard_near(codes, i):
                    add("RL-REQ-LOOP", "MEDIUM", i,
                        "params-style loop bound '%s' (line %d) — "
                        "verify it is not a plain dict"
                        % (tk[1], tk[2] + 1))
                elif tk and tk[0] == "call" and not _guard_near(codes, i):
                    add("RL-REQ-LOOP", "MEDIUM", i,
                        "call-derived loop bound '%s' (line %d) — the "
                        "callee may transform or clamp it; verify a "
                        "bound" % (tk[1], tk[2] + 1))
        fm = FORIN_COUNT.search(code)
        if fm:
            rest = code[max(0, fm.start() - 50):]
            oi = rest.find("(")
            if oi >= 0:
                args = _extract_args(rest, oi)
                blob = " ".join(args)
            else:
                blob = rest[:80]
            if blob.strip() and not NUMLIT.match(blob):
                tk = _arg_taint(blob, i)
                if tk and tk[0] == "req" and not _guard_near(codes, i):
                    add("RL-REQ-LOOP", "MEDIUM", i,
                        "request-derived iteration count" +
                        (" (from line %d)" % (tk[2] + 1)
                         if tk[1] else ""))
                elif tk and tk[0] == "weak" and not _guard_near(codes, i):
                    add("RL-REQ-LOOP", "MEDIUM", i,
                        "params-style iteration count '%s' (line %d) "
                        "— verify it is not a plain dict"
                        % (tk[1], tk[2] + 1))
                elif tk and tk[0] == "call" and not _guard_near(codes, i):
                    add("RL-REQ-LOOP", "MEDIUM", i,
                        "call-derived iteration count '%s' (line %d) "
                        "— the callee may transform or clamp it; "
                        "verify a bound" % (tk[1], tk[2] + 1))

    return findings


def find_targets(root):
    if os.path.isfile(root):
        return [root]
    out = []
    for dirpath, dirs, files in os.walk(root):
        dirs[:] = [d for d in dirs if d not in SKIP_DIRS]
        for fn in files:
            if os.path.splitext(fn)[1].lower() in TARGET_EXT:
                out.append(os.path.join(dirpath, fn))
    return sorted(out)


def render(findings, targets):
    findings.sort(key=lambda f: (SEV_ORDER[f.sev], f.path, f.line_no))
    lines = ["reslimit_prescan report — %d file(s), %d finding(s)"
             % (len(targets), len(findings)), ""]
    for f in findings:
        lines.append("%-6s %-15s %s:%d  %s"
                     % (f.sev, f.code, f.path, f.line_no, f.note))
        lines.append("       | %s" % f.line)
    if not findings:
        lines.append("no HIGH/MEDIUM/LOW findings")
    lines.append("")
    lines.append("scope: deterministic pre-scan — flags per-request "
                 "resource-burn shapes for review; not an audit, "
                 "clean != exhaustion-free")
    return "\n".join(lines)


SELFTEST = [
    ("a.py", "n = int(request.args['n'])\nbuf = bytes(n)\n",
     {"RL-REQ-ALLOC"}),
    ("a.py", "n = int(request.args['n'])\ndata = sock.recv(n)\n",
     {"RL-REQ-READ"}),
    ("a.py", "n = int(request.args['n'])\nfor i in range(n):\n    work()\n",
     {"RL-REQ-LOOP"}),
    ("a.py", "n = int(request.args['n'])\ntime.sleep(n)\n",
     {"RL-REQ-SLEEP"}),
    ("a.py", "n = request.args.get('n')\nq.limit(n)\n",
     {"RL-REQ-PAGE"}),
    ("a.py", "u = request.args['n']\nn = int(u)\nbuf = bytearray(n)\n",
     {"RL-REQ-ALLOC"}),
    ("a.py", "n = int(request.args['n'])\nmsg = 'x' * n\n",
     {"RL-REQ-ALLOC"}),
    ("a.py", "n = int(request.args['n'])\narr = [0] * n\n",
     {"RL-REQ-ALLOC"}),
    ("a.py", "n = int(request.args['n'])\nn = min(n, 1000)\n"
             "buf = bytes(n)\n", set()),
    ("a.py", "n = int(request.args['n'])\nif n > 1000: return 400\n"
             "buf = bytes(n)\n", set()),
    ("a.py", "buf = bytes(4096)\n", set()),
    ("a.py", "size = get_chunk_size()\nbuf = bytes(size)\n",
     {"RL-VAR-ALLOC"}),
    ("a.py", "size = get_chunk_size()\ndata = f.read(size)\n",
     set()),  # read-sink VAR would flood — alloc-sink only
    ("a.py", "data = f.read(1024)\n", set()),
    ("a.py", "n = int(request.args['n'])\nn = 16\nbuf = bytes(n)\n",
     set()),
    ("a.js", "const n = parseInt(req.query.n);\nconst a = new Array(n);\n",
     {"RL-REQ-ALLOC"}),
    ("a.js", "const n = req.body.count;\nres.send('x'.repeat(n));\n",
     {"RL-REQ-ALLOC"}),
    ("a.js", "setTimeout(job, req.body.delay);\n", {"RL-REQ-SLEEP"}),
    ("a.js", "const n = Number(req.query.limit);\ndb.find().limit(n);\n",
     {"RL-REQ-PAGE"}),
    ("a.js", "const n = req.query.n;\nconst b = Buffer.alloc(n);\n",
     {"RL-REQ-ALLOC"}),
    ("a.js", "const a = new Array(10);\n", set()),
    ("a.js", "const n = parseInt(req.query.n);\nif (n > 100) n = 100;\n"
             "const a = new Array(n);\n", set()),
    ("a.js", "// const a = new Array(req.query.n);\n", set()),
    ("a.java", "int n = Integer.parseInt(request.getParameter(\"n\"));\n"
               "byte[] b = new byte[n];\n", {"RL-REQ-ALLOC"}),
    ("a.java", "int n = Integer.parseInt(request.getParameter(\"n\"));\n"
               "Thread.sleep(n);\n", {"RL-REQ-SLEEP"}),
    ("a.cs", "int n = int.Parse(Request.Query[\"n\"]);\n"
             "var buf = new byte[n];\n", {"RL-REQ-ALLOC"}),
    ("a.kt", "val n = req.param(\"n\")!!.toInt()\n"
             "val a = ByteArray(n)\n", {"RL-REQ-ALLOC"}),
    ("a.go", "n, _ := strconv.Atoi(r.URL.Query().Get(\"n\"))\n"
             "buf := make([]byte, n)\n", {"RL-REQ-ALLOC"}),
    ("a.go", "n, _ := strconv.Atoi(r.URL.Query().Get(\"n\"))\n"
             "io.CopyN(w, src, n)\n", {"RL-REQ-READ"}),
    ("a.rs", "let n: usize = params[\"n\"].parse().unwrap();\n"
             "let v = vec![0u8; n];\n", {"RL-REQ-ALLOC"}),
    ("a.php", "<?php\n$n = intval($_GET['n']);\n$s = str_repeat('x', $n);\n",
     {"RL-REQ-ALLOC"}),
    ("a.php", "<?php\n$n = intval($_GET['n']);\n$d = fread($h, $n);\n",
     {"RL-REQ-READ"}),
    ("a.rb", "n = params[:n].to_i\nn.times { work }\n", {"RL-REQ-LOOP"}),
    ("a.c", "int n = atoi(argv[1]);\nchar *b = malloc(n);\n",
     {"RL-REQ-ALLOC"}),
    ("a.sh", "n=$1\nseq 1 $n | while read i; do work; done\n",
     {"RL-REQ-ALLOC"}),
    ("a.sh", "sleep $2\n", {"RL-REQ-SLEEP"}),
    ("a.c", "int n = atoi(getenv(\"N\"));\nfor (int i = 0; i < n; i++)\n",
     set()),
    ("a.java", "int n = Integer.parseInt(request.getParameter(\"n\"));\n"
               "for (int i = 0; i < n; i++) { work(); }\n",
     {"RL-REQ-LOOP"}),
    ("a.py", "n = params['n']\nbuf = bytes(n)\n", {"RL-REQ-ALLOC"}),
    ("a.py", "cl = request.headers.get('content-length')\n"
             "n = int(cl)\nbuf = bytes(n)\n", {"RL-REQ-ALLOC"}),
    ("a.py", "expected_size = meta.get('len')\ndata = f.read(expected_size)\n",
     {"RL-DECOMP"}),
    ("a.py", "# buf = bytes(n)\nimport os\n", set()),
    ("a.java", "/* byte[] b = new byte[n]; */\n", set()),
    ("a.py", "q = \"SELECT * FROM t LIMIT \" + request.args['n']\n",
     {"RL-REQ-PAGE"}),
    ("a.py", "n = int(request.args['n'])\nbuf = bytes(n + 1)\n",
     {"RL-REQ-ALLOC"}),
    ("a.js", "const n = req.query.count;\nconst s = new Uint8Array(n);\n",
     {"RL-REQ-ALLOC"}),
    ("a.js", "const source = await load(slug);\n"
             "return new Uint8Array(source.bytes);\n",
     set()),  # slug never request-bound → silent
    ("a.py", "slug = request.args['slug']\n"
             "src = load_doc(slug)\nbuf = bytes(src.size)\n",
     {"RL-REQ-ALLOC"}),  # call-derived → MEDIUM via REQ-ALLOC code
    ("a.sh", "echo \"$(cat $f | head -c 200)\"\n", set()),
    ("a.sh", "for i in $(seq 1 \"$POLLS\"); do work; done\n",
     {"RL-REQ-ALLOC"}),
]


def run_selftest():
    import tempfile
    fails = []
    for idx, (name, src, want) in enumerate(SELFTEST):
        d = tempfile.mkdtemp()
        p = os.path.join(d, name)
        with open(p, "w") as fh:
            fh.write(src)
        got = {f.code for f in scan_file(p, name)}
        if want != got:
            fails.append((idx, name, sorted(want), sorted(got)))
    return fails


def main():
    args = [a for a in sys.argv[1:] if a != "--json"]
    as_json = "--json" in sys.argv
    if "--selftest" in sys.argv:
        fails = run_selftest()
        if fails:
            for idx, name, exp, got in fails:
                print("FAIL %d %s want=%s got=%s" % (idx, name, exp, got))
            print("selftest: %d/%d failed" % (len(fails), len(SELFTEST)))
            sys.exit(2)
        print("selftest: %d/%d passed" % (len(SELFTEST), len(SELFTEST)))
        sys.exit(0)
    if not args:
        sys.stderr.write(__doc__)
        sys.exit(2)
    targets = find_targets(args[0])
    findings = []
    for t in targets:
        findings.extend(scan_file(t, t))
    if as_json:
        print(json.dumps([f.as_dict() for f in findings], indent=1))
    else:
        print(render(findings, targets))
    sys.exit(1 if any(f.sev in ("HIGH", "MEDIUM") for f in findings)
             else 0)


if __name__ == "__main__":
    main()
