fold/bench/run_bench.py
trvon 3f1234f7bb
fold: bounded symbolic SAST for C++
Clang LibTooling + Z3 BV-64 encoding.
11 detectors: clone, equiv, class-dup, dead, null-deref,
div-by-zero, int-overflow, oob, int-truncation,
sign-conversion, taint-track.

Interfaces: CLI, MCP (JSON-RPC 2.0), LSP.
Output: SARIF 2.1.0, JSON, Markdown.
2026-05-14 23:14:54 -04:00

277 lines
10 KiB
Python
Executable file

#!/usr/bin/env python3
"""Drive fold + comparator SAST tools across a corpus and dump SARIF/JSON.
Output layout:
bench/results/<UTC-iso-date>/
<tool>.json # raw findings keyed by (file, function, kind)
meta.json # tool versions, elapsed seconds, exit codes
summary.md # precision/recall table written by score.py
Each tool's findings are normalized into a flat list of records:
{"file": "...", "line": 0, "kind": "clone|equiv|dead|class-dup", "id": "..."}
which is what score.py compares against the gold standard.
POJ-104 / BigCloneBench / SARD fetchers live in bench/corpora/<corpus>/fetch.sh.
The runner doesn't fetch corpora itself — keep that explicit so the user
controls when ~GB of source gets downloaded.
"""
from __future__ import annotations
import argparse
import datetime
import json
import os
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DEFAULT_FOLD = REPO_ROOT / "build" / "release" / "src" / "cli" / "fold"
def now_iso() -> str:
return datetime.datetime.now(datetime.UTC).strftime("%Y-%m-%dT%H-%M-%SZ")
def write_compile_db(corpus_dir: Path, db_path: Path) -> None:
"""Generate a compile_commands.json that covers every .cpp under corpus."""
entries = []
# Auto-detect include directories for corpora like Juliet that have
# a testcasesupport/ directory and subdirectories with headers.
include_dirs = set()
for p in sorted(corpus_dir.rglob("*")):
if p.is_dir() and p.name == "testcasesupport":
include_dirs.add(str(p))
if p.is_dir() and any(p.glob("*.h")):
include_dirs.add(str(p))
inc_flags = " ".join(f"-I{d}" for d in sorted(include_dirs))
for cpp in sorted(corpus_dir.rglob("*.cpp")):
entries.append({
"directory": str(corpus_dir),
"command": f"clang++ -std=c++23 {inc_flags} -c {cpp}",
"file": str(cpp),
})
for c_file in sorted(corpus_dir.rglob("*.c")):
entries.append({
"directory": str(corpus_dir),
"command": f"clang -std=c99 {inc_flags} -c {c_file}",
"file": str(c_file),
})
db_path.parent.mkdir(parents=True, exist_ok=True)
db_path.write_text(json.dumps(entries))
def run_fold(corpus_dir: Path, out_dir: Path, fold_bin: Path,
detectors: list[str], processes: int) -> dict:
db = out_dir / "compile_commands.json"
write_compile_db(corpus_dir, db)
sarif = out_dir / "fold.sarif"
args = [
str(fold_bin), "scan",
"--compile-db", str(db),
"--format", "sarif",
"--output", str(sarif),
"--min-body-stmts", "1",
"--processes", str(processes),
]
for d in detectors:
args += ["--detectors", d]
t0 = time.monotonic()
rc = subprocess.run(args, capture_output=True, text=True)
elapsed = time.monotonic() - t0
findings = []
if sarif.exists():
sarif_doc = json.loads(sarif.read_text())
for run in sarif_doc.get("runs", []):
for result in run.get("results", []):
kind = (result.get("ruleId", "fold.unknown")
.removeprefix("fold."))
fid = result.get("properties", {}).get("id", "")
for loc in result.get("locations", []):
art = loc.get("physicalLocation", {}).get(
"artifactLocation", {})
region = loc.get("physicalLocation", {}).get("region", {})
findings.append({
"file": art.get("uri", ""),
"line": region.get("startLine", 0),
"kind": kind,
"id": fid,
})
return {
"tool": "fold",
"version": "0.1.0",
"elapsed_s": elapsed,
"exit_code": rc.returncode,
"stderr_tail": rc.stderr[-2000:] if rc.stderr else "",
"findings": findings,
}
def run_clang_tidy(corpus_dir: Path, out_dir: Path) -> dict:
db = out_dir / "compile_commands.json"
write_compile_db(corpus_dir, db)
# Run clang-tidy with a focused redundancy check set. Any check that
# roughly maps to "fold also reports this" is in the list.
checks = ",".join([
"-*",
"readability-redundant-*",
"readability-function-size",
"misc-redundant-expression",
"misc-unused-*",
"bugprone-branch-clone",
])
findings = []
t0 = time.monotonic()
rc_total = 0
for cpp in sorted(corpus_dir.rglob("*.cpp")):
rc = subprocess.run(
["clang-tidy", "-p", str(out_dir), "--checks", checks,
"--quiet", str(cpp)],
capture_output=True, text=True)
rc_total |= rc.returncode
for line in (rc.stdout or "").splitlines():
if ": warning: " not in line and ": error: " not in line:
continue
try:
where, _msg = line.split(": ", 1)
file_, line_no, *_ = where.split(":")
except ValueError:
continue
findings.append({
"file": file_,
"line": int(line_no) if line_no.isdigit() else 0,
"kind": "clang-tidy",
"id": line.strip(),
})
elapsed = time.monotonic() - t0
return {
"tool": "clang-tidy",
"version": subprocess.run(["clang-tidy", "--version"],
capture_output=True, text=True
).stdout.splitlines()[0]
if shutil.which("clang-tidy") else "missing",
"elapsed_s": elapsed,
"exit_code": rc_total,
"findings": findings,
}
def run_clang_sa(corpus_dir: Path, out_dir: Path) -> dict:
"""Run Clang Static Analyzer (clang --analyze) per-file."""
db = out_dir / "compile_commands.json"
write_compile_db(corpus_dir, db)
def map_csa_kind(line: str) -> str:
"""Map CSA warning to fold's finding kind."""
low = line.lower()
if "dereference of null" in low or "null pointer" in low:
return "null-deref"
if "division by zero" in low or "divide by zero" in low:
return "div-by-zero"
if "overflow" in low and ("integer" in low or "signed" in low):
return "int-overflow"
if "array subscript" in low or "out of bound" in low or \
"out-of-bound" in low or "buffer overflow" in low:
return "oob-access"
return "clang-sa"
findings = []
t0 = time.monotonic()
rc_total = 0
for cpp in sorted(corpus_dir.rglob("*.cpp")):
rc = subprocess.run(
["clang", "--analyze", "-std=c++17",
"-Xanalyzer", "-analyzer-output=text",
str(cpp)],
capture_output=True, text=True)
rc_total |= rc.returncode
for line in (rc.stderr or "").splitlines():
if ": warning:" not in line:
continue
try:
parts = line.split(":", 4)
file_ = parts[0] if len(parts) > 0 else str(cpp)
line_no = int(parts[1]) if len(parts) > 1 and parts[1].strip().isdigit() else 0
kind = map_csa_kind(line)
except (ValueError, IndexError):
continue
findings.append({
"file": file_,
"line": line_no,
"kind": kind,
"id": line.strip(),
})
elapsed = time.monotonic() - t0
return {
"tool": "clang-sa",
"version": subprocess.run(["clang", "--version"],
capture_output=True, text=True
).stdout.splitlines()[0]
if shutil.which("clang") else "missing",
"elapsed_s": elapsed,
"exit_code": rc_total,
"findings": findings,
}
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--corpus", required=True,
help="Path to corpus directory (containing .cpp files)")
p.add_argument("--name",
help="Logical corpus name (default: corpus dir basename)")
p.add_argument("--tools", default="fold,clang-tidy",
help="Comma-separated tools to run")
p.add_argument("--processes", type=int, default=4,
help="--processes for fold")
p.add_argument("--detectors", default="clone,equiv,dead",
help="Comma-separated fold detectors")
p.add_argument("--fold-bin", default=str(DEFAULT_FOLD))
p.add_argument("--out-root", default=str(REPO_ROOT / "bench" / "results"))
args = p.parse_args()
corpus = Path(args.corpus).resolve()
if not corpus.is_dir():
print(f"corpus path is not a directory: {corpus}", file=sys.stderr)
return 2
name = args.name or corpus.name
stamp = now_iso()
out_dir = Path(args.out_root) / f"{stamp}_{name}"
out_dir.mkdir(parents=True, exist_ok=True)
print(f"writing to {out_dir}")
tool_list = [t.strip() for t in args.tools.split(",") if t.strip()]
results = {}
if "fold" in tool_list:
results["fold"] = run_fold(corpus, out_dir, Path(args.fold_bin),
args.detectors.split(","),
args.processes)
if "clang-tidy" in tool_list and shutil.which("clang-tidy"):
results["clang-tidy"] = run_clang_tidy(corpus, out_dir)
if "clang-sa" in tool_list and shutil.which("clang"):
results["clang-sa"] = run_clang_sa(corpus, out_dir)
for tool, payload in results.items():
(out_dir / f"{tool}.json").write_text(
json.dumps(payload, indent=2, sort_keys=True))
print(f"{tool}: {len(payload['findings'])} findings "
f"in {payload['elapsed_s']:.1f}s (rc={payload['exit_code']})")
meta = {
"stamp": stamp,
"corpus": str(corpus),
"name": name,
"tools": list(results.keys()),
"processes": args.processes,
"detectors": args.detectors,
}
(out_dir / "meta.json").write_text(json.dumps(meta, indent=2, sort_keys=True))
return 0
if __name__ == "__main__":
raise SystemExit(main())