#!/usr/bin/env python3
"""race_prescan.py — deterministic TOCTOU / check-then-act race pre-scan.

ARION service artifact. Scans source files for check-then-use pairs that
give a second actor (thread, process, request) a window to change the
resource between the test and the action — the CWE-367 shape — line-wise,
fully offline, no inference.

Findings (base severity):
  RC-EXISTS-USE      MEDIUM  existence/access/stat check on a path
                             identifier, then a file operation on the
                             SAME identifier within ~12 lines —
                             os.path.exists->open, fs.existsSync->
                             readFileSync, File.exists()->FileInputStream,
                             os.Stat->os.ReadFile, file_exists->fopen,
                             File.Exists->File.ReadAllText, [ -e ]->cat
  RC-SYMLINK-USE     MEDIUM  islink/lstat/isSymbolicLink/-L check then
                             use of the same path — attacker swaps the
                             link target between the test and the open
                             (fix = O_NOFOLLOW / O_NOFOLLOW_ANY / fd ops /
                             Files.* with LinkOption.NOFOLLOW_LINKS, not
                             a check-then-open)
  RC-LOCKFILE-RACE   MEDIUM  exists-check then create/write on a lock-
                             or pid-named file — two starters both see
                             "no lock" and both take it (fix = O_EXCL
                             create / mkdir / flock / File.createNewFile)
  RC-DIR-CREATE-RACE LOW     exists/isdir check then a non-atomic mkdir
                             on the same identifier (usually benign but
                             the code is one edit away from a real race —
                             fix = exist_ok=True / recursive:true /
                             catch FileAlreadyExists / mkdir -p)
  RC-DB-CHECK-WRITE  LOW     read (find_one/SELECT/fetch) whose result
                             gates an if, then an insert/save/create on
                             the same entity within ~15 lines —
                             non-atomic uniqueness check (fix = unique
                             constraint + upsert / transaction /
                             get_or_create)

Context rules:
  * the checked identifier and the used identifier must match after
    stripping quotes/deref marks; a reassignment of that identifier
    between check and use breaks the pair (fresh binding — no race)
  * atomic alternatives on the USE line suppress the pair entirely:
    'x'/'wx'/O_EXCL/O_CREAT|O_EXCL open modes, exist_ok=True,
    {recursive:true}, mkdir -p, deleteIfExists, noclobber — the fix is
    already applied
  * a lock/sync token (with lock, .acquire(, mutex, synchronized,
    flock, Semaphore) within 4 lines before the check or between check
    and use downgrades one tier — the author is serializing, though a
    mutex still does not close a cross-process file race
  * literal-path pairs ("fixed.conf" checked and opened) downgrade to
    LOW — still racy but no attacker-controlled name
  * upsert/transaction markers on the write line silence the DB
    heuristic (upsert, ON CONFLICT, INSERT IGNORE, ON DUPLICATE,
    replace_one, get_or_create, first_or_create, MERGE)
  * test/spec/fixture paths downgrade one tier
  * comment lines and block-comment bodies are skipped

Scope: deterministic pre-scan for human review — flags check-then-act
shapes; it cannot prove a second writer exists, and a clean report is
not a race-free guarantee.

  race_prescan.py <file-or-dir> [--json]
  race_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", ".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*$)")

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

# per-line prefilters — a line without these tokens can never match a
# pattern below; keeps multi-thousand-file scans fast
FS_HINT = re.compile(
    r"(?i)exists|file|dir|path|open|read|write|stat|access|link|"
    r"lock|pid|unlink|delete|remove|rename|chmod|chown|mkdir|rmdir|"
    r"touch|truncate|copy|move|mount|temp|shutil|pathlib|io\.|fs\.|"
    r"os\.|cat\s|rm\s|cp\s|mv\s|tar\s|zip|inclu|requir|\btest\b|"
    r"[\[\(]?\s*!?\s*-[a-z]\s|>")
DB_HINT = re.compile(
    r"(?i)select|insert|query|execute|find|fetch|save|creat|update|"
    r"delete|merge|replace|upsert|\bget\b|first|single|select")

WINDOW = 12
DB_WINDOW = 15

# ---------- check patterns: (regex, kind, mode) ----------
# kinds: exists | link | access | dir
# modes: "arg" = balanced first-call-arg extracted at the '(';
#        "recv" = group(1) is the receiver identifier;
#        "sh"   = group(1) is the tested token
ARG = r"\s*\("


def _extract_arg(code, open_idx):
    """first top-level argument of the call whose '(' sits at open_idx"""
    depth = 0
    start = open_idx + 1
    for j in range(start, len(code)):
        ch = code[j]
        if ch in "([{":
            depth += 1
        elif ch in ")]}":
            if depth == 0:
                return code[start:j]
            depth -= 1
        elif ch == "," and depth == 0:
            return code[start:j]
    return code[start:]

def P(pat, kind):
    return (re.compile(pat + r"\s*\("), kind, "arg")


def R(pat, kind):
    return (re.compile(pat), kind, "recv")


def S(pat, kind):
    return (re.compile(pat), kind, "sh")


CHECKS = {
    ".py": [
        P(r"\bos\.path\.islink", "link"),
        P(r"\bos\.lstat", "link"),
        P(r"\bos\.path\.(?:exists|isfile|isdir|lexists|ismount)",
          "exists"),
        P(r"\bos\.(?:access|stat)", "exists"),
        R(r"\b(\w+)\.is_symlink\s*\(\s*\)", "link"),
        R(r"\b(\w+)\.(?:exists|is_file|is_dir|is_mount|is_socket|"
          r"is_fifo)\s*\(\s*\)", "exists"),
    ],
    ".js": [
        P(r"\bfs\.lstat(?:Sync)?", "link"),
        P(r"\bfs\.(?:existsSync|exists|accessSync|access|statSync|"
          r"stat)", "exists"),
        P(r"\bfs\.promises\.(?:access|stat|lstat)", "exists"),
        P(r"\bawait\s+fs\.(?:access|stat|lstat)", "exists"),
    ],
    ".php": [
        P(r"\bis_link", "link"),
        P(r"\b(?:file_exists|is_file|is_readable|is_writable|"
          r"filetype|lstat)", "exists"),
        P(r"\bis_dir", "dir"),
    ],
    ".rb": [
        P(r"\bFile\.symlink\?", "link"),
        P(r"\b(?:File|FileTest)\.(?:exist[s]?\?|file\?|readable\?|"
          r"writable\?|executable\?|owned\?)", "exists"),
        P(r"\bFile\.directory\?", "dir"),
        R(r"\b(\w+)\.exist[s]?\?\b", "exists"),
    ],
    ".go": [
        P(r"\bos\.Lstat", "link"),
        P(r"\bos\.Stat", "exists"),
    ],
    ".java": [
        P(r"\bFiles\.isSymbolicLink", "link"),
        P(r"\bFiles\.(?:exists|isRegularFile|isDirectory|isReadable|"
          r"isWritable|isExecutable|notExists)", "exists"),
        R(r"\b(\w+)\.(?:exists|isFile|isDirectory|canRead|canWrite|"
          r"canExecute)\s*\(\s*\)", "exists"),
    ],
    ".cs": [
        P(r"\bFile\.Exists", "exists"),
        P(r"\bDirectory\.Exists", "dir"),
        R(r"\b(\w+)\.Exists\b(?!\s*\()", "exists"),
    ],
    ".sh": [
        S(r"[\[\[]+\s*!?\s*-L\s+([^\]\s]+)", "link"),
        S(r"[\[\[]+\s*!?\s*-[ewfrxs]\s+([^\]\s]+)", "exists"),
        S(r"[\[\[]+\s*!?\s*-d\s+([^\]\s]+)", "dir"),
        S(r"\btest\s+!?\s*-[ewfxsrL]\s+(\S+)", "exists"),
        S(r"\btest\s+!?\s*-d\s+(\S+)", "dir"),
    ],
}
CHECKS[".ts"] = CHECKS[".js"]
CHECKS[".tsx"] = CHECKS[".js"]
CHECKS[".jsx"] = CHECKS[".js"]
CHECKS[".mjs"] = CHECKS[".js"]
CHECKS[".cjs"] = CHECKS[".js"]
CHECKS[".kt"] = CHECKS[".java"]
CHECKS[".groovy"] = CHECKS[".java"]

# ---------- use patterns ----------
# kinds: open | read | write | delete | rename | chmod | mkdir |
#        copy | exec-include | create | stat
USES = {
    ".py": [
        P(r"\bos\.(?:mkdir|makedirs)", "mkdir"),
        P(r"\bos\.(?:remove|unlink|rmdir)", "delete"),
        P(r"\bos\.(?:rename|replace|chmod|chown|open|truncate)",
          "write"),
        P(r"\bshutil\.(?:rmtree|copy|copyfile|copy2|move)", "delete"),
        P(r"\bio\.open", "open"),
        P(r"(?<![\w.])open", "open"),
        R(r"\b(\w+)\.(?:open|read_text|read_bytes|write_text|"
          r"write_bytes|unlink|rmdir|chmod|rename|replace|touch|"
          r"hardlink_to|symlink_to)\s*\(", "write"),
        R(r"\b(\w+)\.mkdir\s*\(", "mkdir"),
        P(r"\bexec", "exec-include"),
    ],
    ".js": [
        P(r"\bfs\.(?:mkdirSync|mkdir|promises\.mkdir)", "mkdir"),
        P(r"\b(?:await\s+)?fs\.(?:unlinkSync|unlink|rmSync|rm|"
          r"rmdirSync|rmdir|promises\.(?:unlink|rm|rmdir))", "delete"),
        P(r"\b(?:await\s+)?fs\.(?:renameSync|rename|chmodSync|chmod|"
          r"chownSync|chown|promises\.(?:rename|chmod|chown))", "write"),
        P(r"\b(?:await\s+)?fs\.(?:readFileSync|readFile|readdirSync|"
          r"readdir|promises\.(?:readFile|readdir))", "read"),
        P(r"\b(?:await\s+)?fs\.(?:writeFileSync|writeFile|"
          r"appendFileSync|appendFile|copyFileSync|copyFile|openSync|"
          r"open|truncateSync|truncate|promises\.(?:writeFile|"
          r"appendFile|copyFile|open|truncate))", "write"),
        P(r"\bfs\.create(?:Read|Write)Stream", "read"),
        P(r"\brequire", "exec-include"),
    ],
    ".php": [
        P(r"\bmkdir", "mkdir"),
        P(r"\b(?:unlink|rmdir)", "delete"),
        P(r"\b(?:fopen|file_put_contents|rename|chmod|chown|copy|"
          r"touch)", "write"),
        P(r"\b(?:file_get_contents|readfile|file|parse_ini_file)",
          "read"),
        S(r"\b(?:include|include_once|require|require_once)\s*\(?\s*"
          r"([^\s)]+)", "exec-include"),
    ],
    ".rb": [
        P(r"\b(?:Dir\.mkdir|FileUtils\.mkdir|FileUtils\.mkdir_p)",
          "mkdir"),
        P(r"\b(?:File\.delete|File\.unlink|Dir\.(?:rmdir|delete|"
          r"unlink)|FileUtils\.(?:rm|rm_rf|remove))", "delete"),
        P(r"\b(?:File\.(?:open|write|binwrite|rename|chmod|chown)|"
          r"FileUtils\.(?:cp|copy|mv|move|touch))", "write"),
        P(r"\b(?:File\.(?:read|binread|foreach|readlines)|IO\.(?:read|"
          r"binread|foreach|readlines))", "read"),
        S(r"\brequire(?:_relative)?\s+([^;,]+)", "exec-include"),
    ],
    ".go": [
        P(r"\bos\.Mkdir(?:All)?", "mkdir"),
        P(r"\bos\.(?:Remove|RemoveAll)", "delete"),
        P(r"\b(?:os|ioutil)\.WriteFile", "write"),
        P(r"\b(?:os|ioutil)\.ReadFile", "read"),
        P(r"\bos\.(?:Open|Create|Rename|Chmod|Chown|OpenFile|"
          r"Truncate)", "write"),
    ],
    ".java": [
        P(r"\bFiles\.(?:createDirectory|createDirectories)", "mkdir"),
        P(r"\bFiles\.(?:delete|deleteIfExists)", "delete"),
        P(r"\bFiles\.(?:write|writeString|move|copy|newOutputStream|"
          r"newBufferedWriter|setAttribute|setPosixFilePermissions)",
          "write"),
        P(r"\bFiles\.(?:readAllBytes|readString|readAllLines|lines|"
          r"newInputStream|newBufferedReader|walk|list|"
          r"probeContentType)", "read"),
        P(r"\bnew\s+(?:FileInputStream|FileOutputStream|FileReader|"
          r"FileWriter|RandomAccessFile)", "open"),
        R(r"\b(\w+)\.(?:mkdir|mkdirs)\s*\(\s*\)", "mkdir"),
        R(r"\b(\w+)\.(?:delete|renameTo|createNewFile|setWritable|"
          r"setReadable|setExecutable|deleteOnExit)\s*\(\s*\)",
          "write"),
    ],
    ".cs": [
        P(r"\bDirectory\.(?:CreateDirectory|Delete|Move|GetFiles|"
          r"GetDirectories|EnumerateFiles)", "mkdir"),
        P(r"\bFile\.(?:Delete|Move|Replace|Copy)", "delete"),
        P(r"\bFile\.(?:Open|OpenWrite|OpenText|Create|CreateText|"
          r"WriteAllText|WriteAllBytes|WriteAllLines|AppendAllText|"
          r"AppendText|OpenHandle)", "write"),
        P(r"\bFile\.(?:OpenRead|ReadAllText|ReadAllBytes|"
          r"ReadAllLines|ReadLines)", "read"),
        P(r"\bnew\s+(?:StreamReader|StreamWriter|FileStream|"
          r"BinaryReader|BinaryWriter)", "open"),
        R(r"\b(\w+)\.(?:Delete|MoveTo|CopyTo|OpenRead|OpenWrite|"
          r"OpenText|Create|CreateText|AppendText)\s*\(\s*\)",
          "write"),
    ],
    ".sh": [
        S(r"\bmkdir\s+(?:-\S+\s+)*(\S+)", "mkdir"),
        S(r"\b(?:rm|rmdir)\s+(?:-\S+\s+)*(\S+)", "delete"),
        S(r"\b(?:mv|cp|chmod|chown|ln|touch)\s+(?:-\S+\s+)*(\S+)",
          "write"),
        S(r"\b(?:cat|head|tail|less|xxd|file|tar|gzip|gunzip|source|"
          r"\.)\s+(\S+)", "read"),
        S(r"(?<![<>=-])>>?\s*(\S+)", "write"),
    ],
}
USES[".ts"] = USES[".js"]
USES[".tsx"] = USES[".js"]
USES[".jsx"] = USES[".js"]
USES[".mjs"] = USES[".js"]
USES[".cjs"] = USES[".js"]
USES[".kt"] = USES[".java"]
USES[".groovy"] = USES[".java"]

# use lines that are already atomic -> the pair is the fix, not the bug
ATOMIC_USE = re.compile(
    r"exist_ok\s*=\s*True|recursive\s*:\s*true|"
    r"mkdir\s+-p\b|mkdir_p|deleteIfExists|"
    r"O_EXCL|O_CREAT[^;\n]*O_EXCL|['\"]wx['\"]|['\"]x['\"]|"
    r"flags\s*[:=]\s*['\"]?w?x['\"]|mode\s*[:=]\s*['\"]x['\"]|"
    r"FileAlreadyExists|EEXIST|noclobber|set\s+-[oC]\s+noclobber|"
    r"OpenOptions[^;\n]*create_new\s*\(\s*true|"
    r"Mode\s*=\s*OpenMode\.CREATE_NEW|FileMode\.createNew|"
    r"createNewFile|CREATE_NEW|flock|FileMode\.Append|"
    r"CreateDirectory|createDirectories|MkdirAll")

# synchronization context -> downgrade one tier
LOCK_CTX = re.compile(
    r"\b(?:mutex|Mutex|synchronized|flock|Semaphore|CriticalSection|"
    r"RLock|Lock\(\)|\.acquire\(|\.lock\(|with\s+\w*[Ll]ock|"
    r"@synchronized|fcntl[^;\n]*F_SETLK|sync\.Mutex|"
    r"Monitor\.Enter)|(?<![\w])lock[f]?\s*\(")

LOCK_NAME = re.compile(r"(?i)(lock|pidfile|\.pid\b|\.lck\b|\.lock\b)")

# the DB heuristic: read -> if result -> write on the same entity
DB_READ = re.compile(
    r"(?P<var>\w+)\s*[:=]?={0,1}\s*(?:await\s+)?[\w.]*?"
    r"(?P<recv>\b(?:db|conn|session|cursor|cur|coll|collection|repo|"
    r"store|table|model|em|tx|prisma|knex|sequelize|mongo|orm|"
    r"users|accounts|redis|cache|bucket|index)[\w.]*)"
    r"\.(?:find_one|findOne|findFirst|findById|find_by|fetchone|"
    r"fetch_one|find_unique|findUnique|getOne|get_one|selectOne|"
    r"query|execute|find|first|single|get)\s*\(|"
    r"(?P<var2>\w+)\s*=\s*\w+\.execute\s*\(\s*['\"][^'\"]*"
    r"\bSELECT\b[^'\"]*\bFROM\s+(?P<tbl>\w+)")
DB_COND = re.compile(
    r"\bif\s+(?:not\s+|\(\s*!\s*|\()?(?P<var>\w+)(?!\s*=)|"
    r"\bif\s+(?P<var2>\w+)\s+(?:is|==|!=|===|!==)\s+"
    r"(?:None|null|nil|undefined)")
DB_WRITE = re.compile(
    r"(?i)\b(?:insert_one|insertOne|insert_many|insertMany|"
    r"insert|save|create|persist|add_to|push|setNX|hset)\s*\(|"
    r"\b(?:INSERT\s+INTO|REPLACE\s+INTO|MERGE\s+INTO)\b")
DB_UPSERT = re.compile(
    r"(?i)upsert|replace_one|replaceOne|ON\s+CONFLICT|INSERT\s+IGNORE|"
    r"INSERT\s+OR\s+(?:IGNORE|REPLACE|ABORT|FAIL|ROLLBACK)|"
    r"ON\s+DUPLICATE|findAndModify|get_or_create|first_or_create|"
    r"update_or_create|MERGE\s+INTO|save_or_update|create_or_update")


def _strip_line_comment(code, ext):
    if ext == ".py" or ext == ".rb" or ext == ".sh":
        i = code.find("#")
        if i >= 0:
            return code[:i]
        return code
    i = code.find("//")
    if i >= 0:
        return code[:i]
    if ext == ".php":
        i = code.find("#")
        if i >= 0:
            return code[:i]
    return code


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


SELF_VARS = {"self", "this", "cls", "self()", "this()"}


def _norm_var(expr):
    """canonical compare key: drop quotes, deref marks, whitespace"""
    e = expr.strip()
    e = re.sub(r"[;,]+$", "", e)
    e = re.sub(r"^['\"]", "", e)
    e = re.sub(r"['\"]$", "", e)
    e = re.sub(r"^[@*&$]+", "", e)
    e = re.sub(r"^\$\{", "", e)
    e = re.sub(r"\}$", "", e)
    e = re.sub(r"\s+", "", e)
    # normalize python/ruby string prefixes on literals
    e = re.sub(r"^([rubf]+)['\"]", "'", e)
    return e


def _is_literal(expr):
    e = expr.strip()
    if e[:1] not in "\"'":
        return False
    inner = e.strip("\"'")
    return not re.search(r"[$({}|]", inner)


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


def _lock_near(codes, i, use_i):
    lo = max(0, i - 4)
    span = "\n".join(codes[lo:use_i + 1])
    return bool(LOCK_CTX.search(span))


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:
                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
            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)
        codes.append(code or "")

    check_pats = CHECKS.get(ext, [])
    use_pats = USES.get(ext, [])

    # receiver checks on Path-like receivers need a marker in the file —
    # `x.exists()` in a file that never touches paths is not our finding
    has_pathlib = bool(re.search(r"\bpathlib\b|\bPath\s*\(|\bos\.path\b|"
                                 r"java\.io\.File|\bnew\s+File\s*\(|"
                                 r"Pathname|FileTest|FileInfo|"
                                 r"\bFile\s*\.\s*\w+|Files\b", "\n"
                                 .join(codes)))

    def take_var(m, mode, code):
        if mode == "arg":
            raw = _extract_arg(code, m.end() - 1)
        else:
            raw = m.group(1)
        return raw, _norm_var(raw)

    checks = []   # (idx, col, var, kind, raw_expr)
    uses = []     # (idx, col, var, kind, raw_expr)
    for i, code in enumerate(codes):
        if not code.strip() or IMPORT_LINE.search(code):
            continue
        hinted = bool(FS_HINT.search(code))
        if hinted:
            for rx, kind, mode in check_pats:
                m = rx.search(code)
                if not m:
                    continue
                if mode == "recv" and not has_pathlib:
                    continue
                raw, var = take_var(m, mode, code)
                if var and var not in SELF_VARS:
                    checks.append((i, m.start(), var, kind, raw))
            for rx, kind, mode in use_pats:
                m = rx.search(code)
                if not m:
                    continue
                if mode == "recv" and not has_pathlib:
                    continue
                raw, var = take_var(m, mode, code)
                if var and var not in SELF_VARS:
                    uses.append((i, m.start(), var, kind, raw))

    def add(cls, sev, i, note):
        if (cls, i + 1) in seen:
            return
        seen.add((cls, i + 1))
        if _is_test_path(rel) and sev != "LOW":
            sev = "LOW"
        findings.append(Finding(cls, sev, rel, i + 1, lines[i], note))

    # ---- pair check -> use on the same identifier ----
    for ci, ccol, var, ckind, craw in checks:
        target = None
        for ui, ucol, uvar, ukind, uraw in uses:
            if ui > ci + WINDOW or (ui == ci and ucol <= ccol):
                continue
            if ui < ci:
                continue
            if uvar != var:
                continue
            if _reassigned(codes, var, ci + 1, ui):
                continue
            target = (ui, ukind, uraw)
            break
        if not target:
            continue
        ui, ukind, uraw = target
        use_line = codes[ui]
        if ATOMIC_USE.search(use_line):
            continue
        lit = _is_literal(craw) and _is_literal(uraw)
        locked = _lock_near(codes, ci, ui)

        if ckind == "link":
            cls, sev = "RC-SYMLINK-USE", "MEDIUM"
            note = ("symlink check then %s of the same path (line %d) — "
                    "target can be swapped between test and use; fix "
                    "with O_NOFOLLOW / NOFOLLOW_LINKS / fd-relative ops"
                    % (ukind, ui + 1))
        elif ukind == "mkdir":
            cls, sev = "RC-DIR-CREATE-RACE", "LOW"
            note = ("existence check then non-atomic mkdir of %r "
                    "(line %d) — usually benign but one edit from a "
                    "real race; prefer exist_ok=True / recursive:true / "
                    "catch EEXIST" % (var, ui + 1))
        elif ukind in ("write", "open", "create") and (
                LOCK_NAME.search(craw) or LOCK_NAME.search(uraw) or
                LOCK_NAME.search(use_line)):
            cls, sev = "RC-LOCKFILE-RACE", "MEDIUM"
            note = ("exists-check then create/write of lock/pid file %r "
                    "(line %d) — two starters can both take the lock; "
                    "use O_EXCL create / mkdir / flock"
                    % (var, ui + 1))
        else:
            cls, sev = "RC-EXISTS-USE", "MEDIUM"
            note = ("check-then-act on %r — %s here then %s at line %d; "
                    "the resource can change between test and use "
                    "(fix = try/except, atomic ops, or lock)"
                    % (var, ckind, ukind, ui + 1))
        if locked and sev != "LOW":
            sev = "LOW"
            note += " [lock context nearby — downgraded]"
        elif lit and sev != "LOW":
            sev = "LOW"
            note += " [literal path — downgraded]"
        add(cls, sev, ci, note)

    # ---- DB check-then-write heuristic (LOW only) ----
    reads = []   # (idx, result_var, entity_key)
    for i, code in enumerate(codes):
        if not code.strip() or IMPORT_LINE.search(code):
            continue
        if not DB_HINT.search(code):
            continue
        m = DB_READ.search(code)
        if not m:
            continue
        rvar = m.group("var") or m.group("var2")
        ent = m.group("tbl") or ""
        if not ent:
            mm = re.search(r"\.(\w+)\.(?:find_one|findOne|find|get|"
                           r"query|execute|first|single)\s*\(", code)
            ent = mm.group(1) if mm else (m.group("recv") or "")
            qq = re.search(r"[\"'](\w{3,30})[\"']", code)
            if qq and qq.group(1) not in ("SELECT", "WHERE"):
                ent = qq.group(1)
        if rvar and ent:
            reads.append((i, rvar, ent))

    for ri, rvar, ent in reads:
        cond_i = None
        for j in range(ri + 1, min(len(codes), ri + 9)):
            cm = DB_COND.search(codes[j])
            if cm and (cm.group("var") == rvar or
                       cm.group("var2") == rvar):
                cond_i = j
                break
            if re.search(r"(?<![\w.])" + re.escape(rvar) +
                         r"\s*(?:=|:=)\s*(?!=)", codes[j]):
                break
        if cond_i is None:
            continue
        for j in range(cond_i + 1, min(len(codes), cond_i + DB_WINDOW)):
            wl = codes[j]
            if not DB_WRITE.search(wl):
                continue
            if DB_UPSERT.search(wl):
                break
            if ent not in wl and rvar not in wl:
                continue
            if LOCK_CTX.search("\n".join(codes[max(0, ri - 2):j + 1])):
                break
            add("RC-DB-CHECK-WRITE", "LOW", j,
                "check-then-write on '%s' — the %s gate at line %d and "
                "this write are not atomic; two writers can both pass "
                "(fix = unique constraint + upsert / transaction)"
                % (ent, rvar, cond_i + 1))
            break

    return findings


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 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 = ["race_prescan report — %d file(s), %d finding(s)"
             % (len(targets), len(findings)), ""]
    for f in findings:
        lines.append("%-6s %-19s %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 check-then-act "
                 "shapes for review; not an audit, clean != race-free")
    return "\n".join(lines)


SELFTEST = [
    # --- core exists -> use pairs (MEDIUM) ---
    ("a.py", "import os\nif os.path.exists(cfg):\n    open(cfg)\n",
     {"RC-EXISTS-USE"}),
    ("a.py", "import os.path\nif not os.path.exists(p):\n"
             "    os.mkdir(p)\n", {"RC-DIR-CREATE-RACE"}),
    ("a.js", "const fs=require('fs');\n"
             "if (fs.existsSync(f)) {\n  fs.unlinkSync(f);\n}\n",
     {"RC-EXISTS-USE"}),
    ("a.js", "const fs=require('fs');\n"
             "if (!fs.existsSync(dir)) {\n  fs.mkdirSync(dir);\n}\n",
     {"RC-DIR-CREATE-RACE"}),
    ("a.js", "const fs=require('fs');\n"
             "if (!fs.existsSync(dir)) {\n"
             "  fs.mkdirSync(dir, {recursive:true});\n}\n",
     set()),                                    # atomic -> silent
    ("a.js", "const fs=require('fs');\n"
             "if (fs.existsSync(f)) {\n"
             "  fs.writeFileSync(f, data, {flag:'wx'});\n}\n",
     set()),                                    # wx atomic -> silent
    ("a.js", "const fs=require('fs');\n"
             "fs.access(f, () => {\n  fs.readFile(f, cb);\n});\n",
     {"RC-EXISTS-USE"}),      # callback form is still check-then-act
    ("a.java", "File f = new File(p);\n"
               "if (f.exists()) {\n"
               "  InputStream in = new FileInputStream(f);\n}\n",
     {"RC-EXISTS-USE"}),
    ("a.java", "if (!Files.exists(p)) {\n"
               "  Files.createDirectory(p);\n}\n",
     {"RC-DIR-CREATE-RACE"}),
    ("a.java", "if (!Files.exists(p)) {\n"
               "  Files.deleteIfExists(q);\n}\n",
     set()),                                    # different var
    ("a.go", "if _, e := os.Stat(f); e == nil {\n"
             "  b, _ := os.ReadFile(f)\n  _ = b\n}\n",
     {"RC-EXISTS-USE"}),
    ("a.go", "if _, e := os.Stat(f); e == nil {\n"
             "  b, _ := os.ReadFile(g)\n  _ = b\n}\n",
     set()),                                    # different var
    ("a.php", "<?php\nif (file_exists($f)) {\n"
              "  $c = file_get_contents($f);\n}\n", {"RC-EXISTS-USE"}),
    ("a.php", "<?php\nif (is_dir($d)) {\n  mkdir($d);\n}\n",
     {"RC-DIR-CREATE-RACE"}),       # dir-check then mkdir = same pair
    ("a.php", "<?php\nif (!is_dir($d)) {\n  mkdir($d);\n}\n",
     {"RC-DIR-CREATE-RACE"}),
    ("a.rb", "if File.exist?(path)\n  File.read(path)\nend\n",
     {"RC-EXISTS-USE"}),
    ("a.rb", "unless File.directory?(d)\n  Dir.mkdir(d)\nend\n",
     {"RC-DIR-CREATE-RACE"}),
    ("a.cs", "if (File.Exists(path)) {\n"
             "  var s = File.ReadAllText(path);\n}\n",
     {"RC-EXISTS-USE"}),
    ("a.cs", "if (!Directory.Exists(d)) {\n"
             "  Directory.CreateDirectory(d);\n}\n", set()),
    # Directory.CreateDirectory is idempotent -> atomic marker silences
    ("a.sh", "if [ ! -e \"$f\" ]; then\n  touch \"$f\"\nfi\n",
     {"RC-EXISTS-USE"}),
    ("a.sh", "[ -f $pidfile ] && exit 1\necho $$ > $pidfile\n",
     {"RC-LOCKFILE-RACE"}),
    # --- symlink race (MEDIUM) ---
    ("a.py", "import os\nif os.path.islink(p):\n    os.remove(p)\n",
     {"RC-SYMLINK-USE"}),
    ("a.js", "const fs=require('fs');\n"
             "if (fs.lstatSync(p).isSymbolicLink()) {\n"
             "  fs.unlinkSync(p);\n}\n", {"RC-SYMLINK-USE"}),
    ("a.php", "<?php\nif (is_link($p)) {\n  unlink($p);\n}\n",
     {"RC-SYMLINK-USE"}),
    ("a.java", "if (Files.isSymbolicLink(p)) {\n"
               "  Files.delete(p);\n}\n", {"RC-SYMLINK-USE"}),
    ("a.go", "if _, e := os.Lstat(f); e == nil {\n"
             "  os.Remove(f)\n}\n", {"RC-SYMLINK-USE"}),
    ("a.rb", "if File.symlink?(p)\n  File.delete(p)\nend\n",
     {"RC-SYMLINK-USE"}),
    # --- lockfile race (MEDIUM) ---
    ("a.py", "import os\nif not os.path.exists(lockfile):\n"
             "    open(lockfile, 'w').write(str(os.getpid()))\n",
     {"RC-LOCKFILE-RACE"}),
    ("a.js", "const fs=require('fs');\n"
             "if (!fs.existsSync(pidfile)) {\n"
             "  fs.writeFileSync(pidfile, '' + process.pid);\n}\n",
     {"RC-LOCKFILE-RACE"}),
    ("a.py", "import os\nif not os.path.exists('app.lock'):\n"
             "    open('app.lock', 'w')\n", {"RC-LOCKFILE-RACE"}),
    # literal-path non-lock pair -> LOW
    ("a.py", "import os\nif os.path.exists('cfg.yaml'):\n"
             "    open('cfg.yaml')\n", {"RC-EXISTS-USE"}),
    # --- reassignment breaks the pair ---
    ("a.py", "import os\nif os.path.exists(p):\n"
             "    p = other()\n    open(p)\n", set()),
    ("a.js", "const fs=require('fs');\n"
             "if (fs.existsSync(f)) {\n  f = alt();\n"
             "  fs.unlinkSync(f);\n}\n", set()),
    # --- window: use too far away -> silent ---
    ("a.py", "import os\nif os.path.exists(p):\n" +
             "    x = 1\n" * 13 + "    open(p)\n", set()),
    # --- lock context downgrades to LOW (code still emitted) ---
    ("a.py", "import os, threading\nl = threading.Lock()\n"
             "with l:\n    if os.path.exists(p):\n        open(p)\n",
     {"RC-EXISTS-USE"}),
    # --- comments skipped ---
    ("a.py", "import os\n# if os.path.exists(p): open(p)\n", set()),
    ("a.js", "// if (fs.existsSync(f)) fs.unlinkSync(f);\n", set()),
    ("a.java", "/* if (f.exists()) f.delete(); */\n", set()),
    # --- receiver checks gated on path vocabulary ---
    ("a.py", "from pathlib import Path\np = Path(x)\n"
             "if p.exists():\n    p.unlink()\n", {"RC-EXISTS-USE"}),
    ("a.py", "if p.exists():\n    p.unlink()\n", set()),  # no marker
    # --- DB heuristic (LOW) ---
    ("a.py", "row = db.users.find_one({'id': uid})\n"
             "if not row:\n    db.users.insert_one({'id': uid})\n",
     {"RC-DB-CHECK-WRITE"}),
    ("a.js", "const u = await db.users.findOne({id});\n"
             "if (!u) {\n  await db.users.insertOne({id});\n}\n",
     {"RC-DB-CHECK-WRITE"}),
    ("a.py", "row = db.users.find_one({'id': uid})\n"
             "if not row:\n    db.users.insert_one({'id': uid}, "
             "upsert=True)\n", set()),                    # upsert silent
    ("a.py", "row = db.users.find_one({'id': uid})\n"
             "if not row:\n    db.other.insert_one({'id': uid})\n",
     set()),                              # different collection
    ("a.py", "row = cur.execute('SELECT id FROM users WHERE id=?')\n"
             "if row is None:\n"
             "    cur.execute('INSERT INTO users VALUES (?)')\n",
     {"RC-DB-CHECK-WRITE"}),
    ("a.py", "row = cur.execute('SELECT id FROM users WHERE id=?')\n"
             "if row is None:\n"
             "    cur.execute('INSERT INTO users VALUES (?) "
             "ON CONFLICT DO NOTHING')\n", set()),
    # --- use of same var but different identifier name ---
    ("a.py", "import os\nif os.path.exists(p):\n    open(q)\n", set()),
    # --- stat/statSync checks also count as existence checks ---
    ("a.js", "const fs=require('fs');\n"
             "if (fs.statSync(f).size > 0) {\n"
             "  fs.truncateSync(f);\n}\n", {"RC-EXISTS-USE"}),
    ("a.go", "if st, e := os.Stat(f); e == nil && st.IsDir() {\n"
             "  os.Mkdir(f, 0755)\n}\n", {"RC-DIR-CREATE-RACE"}),
    # --- safe atomic creates ---
    ("a.py", "import os\nif not os.path.exists(p):\n"
             "    os.makedirs(p, exist_ok=True)\n", set()),
    ("a.py", "import os\nif not os.path.exists(p):\n"
             "    open(p, 'x')\n", set()),                  # 'x' atomic
    ("a.sh", "[ ! -d $d ] && mkdir -p $d\n", set()),
    # --- shell -L symlink ---
    ("a.sh", "if [ -L $p ]; then\n  rm $p\nfi\n",
     {"RC-SYMLINK-USE"}),
    # --- include/require after exists (PHP) ---
    ("a.php", "<?php\nif (file_exists($tpl)) {\n"
              "  include $tpl;\n}\n", {"RC-EXISTS-USE"}),
    # --- ruby require ---
    ("a.rb", "if File.exist?(f)\n  require f\nend\n",
     {"RC-EXISTS-USE"}),
    # --- File.createNewFile IS the atomic fix -> suppressed ---
    ("a.java", "File f = new File(p);\n"
               "if (!f.exists()) {\n  f.createNewFile();\n}\n",
     set()),
    # --- test path downgrade ---
    ("tests/a.py", "import os\nif os.path.exists(p):\n    open(p)\n",
     {"RC-EXISTS-USE"}),
    # --- import lines ignored ---
    ("a.py", "import os.path.exists\nopen(p)\n", set()),
    # --- C# FileInfo receiver ---
    ("a.cs", "FileInfo fi = new FileInfo(p);\n"
             "if (fi.Exists) {\n  fi.Delete();\n}\n",
     {"RC-EXISTS-USE"}),
    # --- sh assign-only lines must not produce uses ---
    ("a.sh", "x=1\n[ -e $f ] && rm $f\n", {"RC-EXISTS-USE"}),
]


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())
