#!/usr/bin/env python3
"""xss_prescan.py — deterministic cross-site-scripting pre-scan.

ARION service artifact. Scans source and template files for XSS-prone
constructions that are checkable offline, line-wise — no browser, no
runtime, no network.

Findings (severity):
  XSS-DOM-SINK       HIGH   non-literal value reaches a raw-HTML DOM
                      sink: innerHTML/outerHTML/insertAdjacentHTML/
                      document.write(ln)/createContextualFragment/
                      srcdoc/dangerouslySetInnerHTML/v-html/
                      [innerHTML]/setInnerHTML/jQuery .html()/
                      bypassSecurityTrust*(Html|Js|Style|Url|ResourceUrl)
  XSS-STRING-EVAL    HIGH   eval() / new Function() / setTimeout() /
                      setInterval() receiving a STRING argument
                      (string-eval executes markup and code)
  XSS-RAW-TEMPLATE   MEDIUM template raw-output constructs that bypass
                      autoescaping: EJS <%-, Handlebars {{{, ERB <%== and
                      raw()/html_safe, Blade {!! !!}, Jinja |safe /
                      Markup()/mark_safe()/{% autoescape false %},
                      Django |safe, Twig |raw, Svelte {@html},
                      Razor @Html.Raw, Go template.HTML/JS/URL casts
  XSS-ECHO           MEDIUM PHP echo/print/<?= emitting a $variable
                      without an escaper on the same line
  XSS-JS-URL         MEDIUM javascript:/vbscript: URL literal in code
                      (assignable to href/src), or a URL built by
                      interpolation into href/src location
  XSS-INLINE-HANDLER MEDIUM an on*="" event-handler attribute being
                      assembled with interpolation (template literal
                      ${} or +-concat inside the attribute value)
  XSS-POSTMESSAGE    LOW    window.postMessage(x, "*") wildcard target
                      origin — data leaks cross-origin and enables
                      DOM-XSS receivers to be reached by anyone

Severity downgrades:
  LOW    the dangerous call is wrapped in a sanitizer on the same line
         (DOMPurify.sanitize, sanitizeHtml, escapeHtml, htmlspecialchars,
         htmlentities, he.encode, _.escape, esc_html/esc_attr/wp_kses,
         bleach.clean, html.escape, encodeURIComponent, textContent
         producer), or the file lives under a test/spec/fixture/mock
         path.

Suppression (no finding):
  - sinks fed a pure literal (innerHTML = "", .html("static")) or an
    explicit safe producer (textContent, createTextNode, innerText).
  - full-line comments and /* */ block-comment bodies; <!-- --> HTML
    comments; lines inside <script> blocks are still scanned.
  - ordinary JSX {expr} containers — React autoescapes; only
    dangerouslySetInnerHTML / href={interp} patterns flag.

Honest scope: PRE-SCAN, not an audit. It cannot prove taint — a flag
means "this line places a non-literal value on a raw-HTML/code path,
review where the value comes from". Contextual escaping done by the
framework (React/Vue/Angular default rendering) is not flagged;
multi-line dataflow, DOM clobbering, mutation-XSS and CSP posture are
out of scope. A clean report is not proof the codebase is XSS-free.

Usage:
  xss_prescan.py <file-or-dir> [--json]
  xss_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 = (
    ".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".html", ".htm",
    ".vue", ".svelte", ".php", ".py", ".rb", ".erb", ".go", ".cs",
    ".ejs", ".hbs", ".handlebars", ".jinja", ".jinja2", ".twig",
)

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

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

# same-line sanitizers that downgrade a finding to LOW
SANITIZER = re.compile(
    r"(DOMPurify\.sanitize|sanitizeHtml|sanitize_html|escapeHtml|"
    r"escape_expression|escapeExpression|htmlspecialchars|htmlentities|"
    r"he\.encode|_\.escape|esc_html|esc_attr|esc_url|wp_kses|"
    r"bleach\.clean|html\.escape|cgi\.escape|encodeURIComponent|"
    r"createTextNode|textContent|innerText|Sanitizer\.sanitize|"
    r"filterXSS|xss\.|rehype-sanitize)",
    re.I)

# --- class 1: DOM sinks -----------------------------------------------------

DOM_SINK_ATTR = re.compile(
    r"\.(innerHTML|outerHTML)\s*[+]?=|"
    r"\.insertAdjacentHTML\s*\(|"
    r"document\.write(?:ln)?\s*\(|"
    r"\.createContextualFragment\s*\(|"
    r"\bdangerouslySetInnerHTML\b|"
    r"\bv-html\s*=|"
    r"\[innerHTML\]\s*=|"
    r"\.setInnerHTML\s*\(|"
    r"\.srcdoc\s*=|"
    r"\$\.html\s*\(|"
    r"\$\([^)]*\)\.html\s*\(|"
    r"\.html\s*\(|"
    r"bypassSecurityTrust(?:Html|Script|Style|Url|ResourceUrl)\s*\(")

# server-side render helpers named .html( — NOT the jQuery DOM sink.
# c.html(tpl) (Hono), res.html(page), ctx.html(...) send rendered output;
# the sink risk lives in the template, caught by other classes.
SSR_RECV = re.compile(
    r"\b(?:c|res|ctx|reply|req|app|server|hono|response|router|self|this|"
    r"next|fastify|res\.locals)\.html\s*\(")
JQ_HTML = re.compile(r"\.html\s*\(")

SAFE_PRODUCER = re.compile(
    r"(textContent|innerText|createTextNode|createElement)\b|=\s*['\"]\s*['\"]"
    r"|=\s*`[^`$]*`\s*;|=\s*['\"][^'\"]*['\"]\s*;")

# --- class 2: string-eval ---------------------------------------------------

STRING_EVAL = re.compile(
    r"\beval\s*\(\s*['\"`]|"
    r"new\s+Function\s*\(|"
    r"\bsetTimeout\s*\(\s*['\"`]|"
    r"\bsetInterval\s*\(\s*['\"`]|"
    r"\beval\s*\(\s*[a-zA-Z_$]|"          # eval(var) — non-literal
    r"new\s+Function\s*\(\s*[a-zA-Z_$]",
    re.I)

# --- class 3: raw-template constructs --------------------------------------

RAW_TEMPLATE = re.compile(
    r"<%[-=]{2}|"                        # ERB <%== raw
    r"<%-|"                              # EJS <%- raw output
    r"\{\{\{|"                           # Handlebars triple-stash
    r"\{!!|!!\}|"                        # Blade {!! !!}
    r"\{@html\b|"                        # Svelte
    r"\bautoescape\s+(false|off)\b",
    re.I)

# template pipes |safe / |raw — only inside template file types; in
# .ts/.rs they collide with union types / bitwise-or (`a | safe`)
TEMPLATE_EXT = {
    ".html", ".htm", ".jinja", ".jinja2", ".twig", ".vue", ".svelte",
    ".hbs", ".handlebars", ".ejs", ".erb", ".php",
}
TEMPLATE_PIPE = re.compile(r"\|\s*(safe|raw)\b", re.I)

# language-scoped raw-output helpers — applied only in their home ext
RAW_TEMPLATE_SCOPED = {
    ".erb": re.compile(r"\braw\s*\(|\.html_safe\b|<%=="),
    ".rb": re.compile(r"\braw\s*\(|\.html_safe\b"),
    ".py": re.compile(r"\bmark_safe\s*\(|\bMarkup\s*\(|"
                      r"django\.utils\.safestring"),
    ".cs": re.compile(r"Html\.Raw\s*\(|@Html\."),
    ".go": re.compile(
        r"template\.(?:HTML|JS|URL|CSS|HTMLAttr|Srcset)\s*\("),
}

# --- class 4: PHP echo ------------------------------------------------------

PHP_ECHO = re.compile(
    r"(<\?=|\becho\b|\bprint\b)[^;]*\$[a-zA-Z_]")

# --- class 5: javascript: URLs / interpolated href --------------------------

JS_URL = re.compile(r"['\"`]\s*(javascript|vbscript)\s*:", re.I)

# URL sinks where the value can control the SCHEME (javascript:) —
# JSX bare binding href={v} / href={obj.v}, interp at position 0
# href={`${v}`}, or location assignment. Helper-wrapped calls like
# href={buildUrl(x)} don't flag — indirection can't be verified
# line-wise (scope note).
JSX_HREF_BARE = re.compile(
    r"(?:href|src)\s*=\s*\{\s*(?:[a-zA-Z_$][\w.$]*\s*\}|"
    r"`\s*\$\{)")
# <form action=...> is a URL sink too, but `action=` on other JSX
# components is just a prop — require <form on the same line
FORM_ACTION = re.compile(
    r"<form[^>]*\baction\s*=\s*(?:\{\s*(?:[a-zA-Z_$][\w.$]*\s*\}|"
    r"`\s*\$\{)|['\"`][^'\"`]*\$\{)")
LOCATION_ASSIGN = re.compile(
    r"(?:window|document)\.location(?:\.\w+)?\s*=\s*[a-zA-Z_$]|"
    r"\blocation\.(?:href|pathname)\s*=\s*[a-zA-Z_$]|"
    r"\blocation\.(?:assign|replace)\s*\(\s*[a-zA-Z_$]|"
    r"(?<![\w$.])location\s*=\s*[a-zA-Z_$][\w.]*")
LOCATION_DECL = re.compile(r"\b(?:const|let|var)\s+location\b")
# SQL UPDATE/INSERT lines assign a `location` column — not navigation
# (verb on the same line, or $N bind-param RHS from a multi-line query)
SQL_LINE = re.compile(
    r"\b(?:update|insert|select|where|coalesce|values)\b|\$\d", re.I)
# raw-string attribute assembled with interp — quote-breakout surface
# (JSX {expr} is autoescaped and only reaches here via the bare/scheme
# patterns above; fixed-path prefixes like href={`/x/${v}`} suppress)
RAW_ATTR_INTERP = re.compile(
    r"(?:href|src|action)\s*=\s*['\"][^'\"]*\$\{")

# --- class 6: inline event handler assembled with interp --------------------

INLINE_HANDLER = re.compile(
    r"on(?:error|load|click|focus|blur|mouseover|mouseenter|submit|input|"
    r"change|keydown|keyup|keypress|pointer\w+|touch\w+|animation\w+|"
    r"transitionend|message)\s*=\s*['\"`][^'\"`]*"
    r"(\$\{|\{\{|\+\s*['\"]|['\"]\s*\+)",
    re.I)

# --- class 7: postMessage wildcard -----------------------------------------

POSTMESSAGE_STAR = re.compile(
    r"\.postMessage\s*\([^)]*,\s*['\"]\*['\"]")

LITERAL_ONLY = re.compile(
    r"=\s*(`[^`$]*`|'[^']*'|\"[^\"]*\"|\d+|true|false|null)\s*;?\s*$")

# call-form sink whose arguments are all string/number literals —
# e.g. document.write("<p>x</p>"), el.insertAdjacentHTML('beforeend','<b>')
CALL_LITERAL = re.compile(
    r"\(\s*(?:'[^']*'|\"[^\"]*\"|`[^`$]*`|\d+)\s*"
    r"(?:,\s*(?:'[^']*'|\"[^\"]*\"|`[^`$]*`|\d+)\s*)*\)")


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 _strip_line_comment(line, ext):
    """Return code part before a full-line/eol comment marker, or None
    if the whole line is a comment."""
    s = line.strip()
    if not s:
        return ""
    if s.startswith("//") or s.startswith("<!--") or s.startswith("*"):
        return None
    if s.startswith("#") and ext in (".py", ".rb"):
        return None
    if s.startswith("{#"):
        return None
    # eol markers — crude but effective for our sink lines
    for marker in ("//", "<!--"):
        i = s.find(marker)
        if i > 0 and s[i - 1] in " \t)]":
            s = s[:i]
            break
    return s


def _is_test_path(rel):
    return bool(TEST_PATH.search(rel.replace(os.sep, "/")))


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 = []
    in_block = False
    for i, raw in enumerate(lines):
        line = raw.rstrip("\n")
        # block-comment tracking (/* */ and <!-- -->)
        j = 0
        out = []
        while j < len(line):
            if in_block:
                for closer in ("*/", "-->"):
                    k = line.find(closer, j)
                    if k >= 0:
                        j = k + len(closer)
                        in_block = False
                        break
                else:
                    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
            # immediate closer on same line?
            for closer in ("*/", "-->"):
                k = line.find(closer, j + 2)
                if k >= 0:
                    out.append(" ")
                    j = k + len(closer)
                    in_block = False
                    break
            else:
                j = len(line)
        code = "".join(out)
        code = _strip_line_comment(code, ext)
        if code is None or not code.strip():
            continue

        def add(cls, sev, note):
            if SANITIZER.search(code) or _is_test_path(rel):
                sev = "LOW" if sev != "LOW" else sev
            findings.append(Finding(cls, sev, rel, i + 1, line, note))

        # class 1 — DOM sinks (only when RHS/args are non-literal);
        # c.html()/res.html() SSR render helpers are not jQuery sinks
        scan_code = SSR_RECV.sub("ssr_send(", code)
        if DOM_SINK_ATTR.search(scan_code):
            if not (LITERAL_ONLY.search(code)
                    or CALL_LITERAL.search(code)):
                add("XSS-DOM-SINK", "HIGH",
                    "non-literal reaches raw-HTML DOM sink")
        # class 2 — string-eval
        if STRING_EVAL.search(code):
            add("XSS-STRING-EVAL", "HIGH",
                "string argument is evaluated as code/markup")
        # class 3 — raw template output (generic + language-scoped)
        if RAW_TEMPLATE.search(code) or (
                ext in TEMPLATE_EXT and TEMPLATE_PIPE.search(code)) or (
                ext in RAW_TEMPLATE_SCOPED
                and RAW_TEMPLATE_SCOPED[ext].search(code)):
            add("XSS-RAW-TEMPLATE", "MEDIUM",
                "template raw-output bypasses autoescaping")
        # class 4 — php echo
        if ext == ".php" and PHP_ECHO.search(code):
            add("XSS-ECHO", "MEDIUM",
                "PHP emits a variable without an escaper")
        # class 5 — javascript: url / scheme-controllable href target
        if JS_URL.search(code):
            add("XSS-JS-URL", "MEDIUM",
                "javascript:/vbscript: URL literal")
        elif JSX_HREF_BARE.search(code) or FORM_ACTION.search(code) or (
                LOCATION_ASSIGN.search(code)
                and not LOCATION_DECL.search(code)
                and not SQL_LINE.search(code)):
            add("XSS-JS-URL", "MEDIUM",
                "URL scheme position attacker-controllable")
        elif RAW_ATTR_INTERP.search(code):
            add("XSS-JS-URL", "MEDIUM",
                "href/src attribute assembled with interpolation")
        # class 6 — inline handler interp
        if INLINE_HANDLER.search(code):
            add("XSS-INLINE-HANDLER", "MEDIUM",
                "event-handler attribute assembled with interpolation")
        # class 7 — postMessage *
        if POSTMESSAGE_STAR.search(code):
            add("XSS-POSTMESSAGE", "LOW",
                "postMessage with wildcard target origin")
    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 = ["xss_prescan report — %d file(s), %d finding(s)"
             % (len(targets), len(findings)), ""]
    for f in findings:
        lines.append("%-6s %-20s %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 patterns for "
                 "review; not an audit, clean != XSS-free")
    return "\n".join(lines)


SELFTEST = [
    # --- DOM sinks ---
    ("a.js", "el.innerHTML = userInput;\n", {"XSS-DOM-SINK"}),
    ("a.js", "el.innerHTML += row;\n", {"XSS-DOM-SINK"}),
    ("a.js", "el.outerHTML = tpl;\n", {"XSS-DOM-SINK"}),
    ("a.js", "el.insertAdjacentHTML('beforeend', html);\n",
     {"XSS-DOM-SINK"}),
    ("a.js", "document.write(res);\n", {"XSS-DOM-SINK"}),
    ("a.tsx", "return <div dangerouslySetInnerHTML={{__html: d}} />;\n",
     {"XSS-DOM-SINK"}),
    ("a.vue", "<div v-html=\"msg\"></div>\n", {"XSS-DOM-SINK"}),
    ("a.ts", "<div [innerHTML]=\"content\"></div>\n", {"XSS-DOM-SINK"}),
    ("a.js", "$(box).html(markup);\n", {"XSS-DOM-SINK"}),
    ("a.ts", "san.bypassSecurityTrustHtml(x);\n", {"XSS-DOM-SINK"}),
    ("a.js", "frame.srcdoc = doc;\n", {"XSS-DOM-SINK"}),
    ("a.js", "el.innerHTML = \"\";\n", set()),          # pure literal
    ("a.js", "el.textContent = userInput;\n", set()),   # safe producer
    ("a.js", "el.innerHTML = DOMPurify.sanitize(d);\n",
     {"XSS-DOM-SINK"}),                                  # -> LOW
    ("tests/t.js", "el.innerHTML = x;\n", {"XSS-DOM-SINK"}),  # -> LOW
    ("a.js", "// el.innerHTML = x;\n", set()),
    ("a.js", "/* el.innerHTML = x; */\n", set()),
    # --- string eval ---
    ("a.js", "eval(userScript);\n", {"XSS-STRING-EVAL"}),
    ("a.js", "eval('alert(1)');\n", {"XSS-STRING-EVAL"}),
    ("a.js", "new Function('return ' + x)();\n", {"XSS-STRING-EVAL"}),
    ("a.js", "setTimeout('doThing(' + id + ')', 500);\n",
     {"XSS-STRING-EVAL"}),
    ("a.js", "setTimeout(doThing, 500);\n", set()),     # fn ref OK
    ("a.js", "setInterval(() => tick(), 1000);\n", set()),
    # --- raw templates ---
    ("a.ejs", "<%- body %>\n", {"XSS-RAW-TEMPLATE"}),
    ("a.hbs", "{{{content}}}\n", {"XSS-RAW-TEMPLATE"}),
    ("a.erb", "<%== note %>\n", {"XSS-RAW-TEMPLATE"}),
    ("a.erb", "<%= raw(note) %>\n", {"XSS-RAW-TEMPLATE"}),
    ("a.erb", "<%= note.html_safe %>\n", {"XSS-RAW-TEMPLATE"}),
    ("a.php", "{!! $html !!}\n", {"XSS-RAW-TEMPLATE"}),
    ("a.jinja", "{{ body|safe }}\n", {"XSS-RAW-TEMPLATE"}),
    ("a.twig", "{{ body|raw }}\n", {"XSS-RAW-TEMPLATE"}),
    ("a.svelte", "{@html frag}\n", {"XSS-RAW-TEMPLATE"}),
    ("a.py", "return mark_safe(html)\n", {"XSS-RAW-TEMPLATE"}),
    ("a.py", "from flask import Markup\nMarkup(x)\n",
     {"XSS-RAW-TEMPLATE"}),
    ("a.cs", "@Html.Raw(Model.Bio)\n", {"XSS-RAW-TEMPLATE"}),
    ("a.go", "t := template.HTML(s)\n", {"XSS-RAW-TEMPLATE"}),
    ("a.jinja", "{% autoescape false %}\n", {"XSS-RAW-TEMPLATE"}),
    ("a.jinja", "{{ body }}\n", set()),                  # autoescaped
    ("a.ejs", "<%= body %>\n", set()),                   # EJS escaped
    # --- php echo ---
    ("a.php", "echo $name;\n", {"XSS-ECHO"}),
    ("a.php", "<?= $bio ?>\n", {"XSS-ECHO"}),
    ("a.php", "echo htmlspecialchars($name);\n", {"XSS-ECHO"}),  # LOW
    ("a.php", "echo 'static';\n", set()),
    # --- js urls / href interp ---
    ("a.js", "a.href = 'javascript:steal()';\n", {"XSS-JS-URL"}),
    ("a.js", "a.href = `javascript:${x}`;\n",
     {"XSS-JS-URL"}),
    ("a.jsx", "<a href={url}>x</a>\n", {"XSS-JS-URL"}),  # bare JSX bind
    ("a.jsx", "<a href={`/agents?skill=${row.skill}`}>x</a>\n",
     set()),                                              # fixed path prefix
    ("a.jsx", "<a href={skillHref(item)}>x</a>\n", set()),  # helper call
    ("a.ts", "const location = locationOf(posting);\n", set()),
    ("a.ts", "    location = coalesce($6, location),\n", set()),
    ("a.ts", "db.run(`update t set location = ${v}`)\n", set()),
    ("a.js", "window.location = dest;\n", {"XSS-JS-URL"}),
    ("a.js", "window.location.href = '/me';\n", set()),  # literal
    ("a.js", "location.href = next;\n", {"XSS-JS-URL"}),
    ("a.jsx", "<form method=\"post\" action={action}>\n",
     {"XSS-JS-URL"}),
    ("a.tsx", "<UpdateComposer action={composer.action} />\n",
     set()),                                              # component prop
    ("a.jsx", "<form action={`${base}/pay`}>\n",
     {"XSS-JS-URL"}),
    ("a.js", "s = '<a href=\"${u}\">';\n", {"XSS-JS-URL"}),
    ("a.js", "el.setAttribute('href', u);\n", set()),    # opaque
    # --- inline handler ---
    ("a.js", "s = `<img onerror=\"${p}\">`;\n",
     {"XSS-INLINE-HANDLER"}),
    ("a.js", "h = '<div onclick=\"go(' + id + ')\">';\n",
     {"XSS-INLINE-HANDLER"}),
    ("a.html", "<div onclick=\"go(1)\">x</div>\n", set()),
    # --- postMessage ---
    ("a.js", "w.postMessage(data, '*');\n", {"XSS-POSTMESSAGE"}),
    ("a.js", "w.postMessage(d, 'https://a.com');\n", set()),
    # --- SSR helpers / identifier FPs ---
    ("a.tsx", "return c.html(\n    <div>{page}</div>\n);\n", set()),
    ("a.ts", "res.html(rendered);\n", set()),
    ("a.js", "el.html(x);\n", {"XSS-DOM-SINK"}),
    ("a.ts", "if (raw.startsWith('//')) return null;\n", set()),
    ("a.ts", "const raw = f(); raw(html);\n", set()),
    ("a.ts", "const m = Markup(x);\n", set()),           # .py-scoped
    ("a.erb", "<%= raw(note) %>\n", {"XSS-RAW-TEMPLATE"}),
    ("a.erb", "<%= note.html_safe %>\n", {"XSS-RAW-TEMPLATE"}),
    # --- plain code ---
    ("a.js", "const x = {inner: 1};\n", set()),
    ("a.py", "def f(x):\n    return x\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), 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([f.as_dict() for f in findings], 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())
