#!/usr/bin/env python3 """Drive fold + comparator SAST tools across a corpus and dump SARIF/JSON. Output layout: bench/results// .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//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())