fold/bench/diagnose.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

175 lines
5.7 KiB
Python

#!/usr/bin/env python3
"""Diagnose why Juliet test cases are missed by fold.
Usage:
# 1. Run fold with trace JSON output:
fold scan --compile-db ... --detectors null-deref --trace --format sarif \
--output results.sarif --processes 1 2>trace.log
# 2. Parse trace and compare against gold:
python3 bench/diagnose.py --trace trace.log --gold bench/gold/juliet-476.json \
--results results.sarif --detector null-deref
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
from collections import defaultdict
from pathlib import Path
TRACE_RE = re.compile(
r'trace_json \{"detector":"(\S+)","file":"([^"]+)",'
r'"line":(\d+),"col":(\d+),"result":"(\S+)","detail":"([^"]+)"\}'
)
def parse_trace(log_path: str, detector: str) -> dict[str, list[dict]]:
"""Parse trace JSON lines, return {basename: [trace_entry, ...]}."""
by_file = defaultdict(list)
with open(log_path) as f:
for line in f:
m = TRACE_RE.search(line)
if not m:
continue
if m.group(1) != detector:
continue
by_file[Path(m.group(2)).name].append({
"file": m.group(2),
"line": int(m.group(3)),
"col": int(m.group(4)),
"result": m.group(5),
"detail": m.group(6),
})
return dict(by_file)
def load_gold(gold_path: str) -> list[dict]:
return json.loads(Path(gold_path).read_text())["expected"]
def load_findings(sarif_path: str) -> set[str]:
"""Return set of file paths that were found (had at least one finding)."""
found = set()
if not Path(sarif_path).exists():
return found
doc = json.loads(Path(sarif_path).read_text())
for run in doc.get("runs", []):
for result in run.get("results", []):
for loc in result.get("locations", []):
uri = loc.get("physicalLocation", {}).get(
"artifactLocation", {}).get("uri", "")
if uri:
found.add(uri)
return found
def diagnose(gold_path: str, log_path: str, sarif_path: str,
detector: str) -> dict:
gold_entries = load_gold(gold_path)
traces = parse_trace(log_path, detector)
found_files = load_findings(sarif_path)
categories = defaultdict(list)
for g in gold_entries:
bn = Path(g["files"][0]).name
has_trace = bn in traces
found = any(bn in f for f in found_files)
if found:
continue # already detected, no diagnosis needed
file_traces = traces.get(bn, [])
sat_traces = [t for t in file_traces if t["result"] == "sat"]
unsat_traces = [t for t in file_traces if t["result"] == "unsat"]
if not file_traces:
categories["no-trace"].append({
"file": bn,
"id": g["id"],
"diagnosis": "encoder never reached Z3 — walker/encoder gap",
})
elif sat_traces:
categories["sat-but-missed"].append({
"file": bn,
"id": g["id"],
"traces": len(sat_traces),
"diagnosis": "Z3 found bug (SAT) but not in scoring — gold/file mismatch",
})
elif unsat_traces:
categories["unsat-only"].append({
"file": bn,
"id": g["id"],
"traces": len(unsat_traces),
"diagnosis": "Z3 proved safe (UNSAT) — analysis precision gap",
})
# Also categorize FP files
fp_files = set()
for fn in traces:
is_gold = any(Path(g["files"][0]).name == fn for g in gold_entries)
if not is_gold:
fp_files.add(fn)
for fn in sorted(fp_files):
file_traces = traces[fn]
sat = [t for t in file_traces if t["result"] == "sat"]
if sat:
categories["fp-sat"].append({
"file": fn,
"traces": len(sat),
"diagnosis": "Z3 flagged clean file (FP) — path too weak or operands over-approx",
})
return dict(categories)
def render(categories: dict, gold_total: int) -> str:
lines = [
f"# fold diagnosis — {gold_total} gold entries",
"",
"## Miss categories",
]
cat_labels = {
"no-trace": "No Z3 queries (encoder/walker gap)",
"unsat-only": "Z3 proved safe (analysis precision)",
"sat-but-missed": "Z3 found bug but scoring mismatch",
"fp-sat": "False positive (Z3 flagged clean file)",
}
for cat, label in cat_labels.items():
entries = categories.get(cat, [])
if not entries:
continue
pct = len(entries) * 100.0 / max(1, gold_total)
lines.append(f"\n### {label} — {len(entries)} ({pct:.1f}%)")
for e in entries[:10]:
lines.append(f"- `{e['file']}` — {e['diagnosis']}")
if len(entries) > 10:
lines.append(f"- ... and {len(entries)-10} more")
return "\n".join(lines) + "\n"
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--trace", required=True, help="fold stderr with trace_json lines")
p.add_argument("--gold", required=True, help="Juliet gold JSON")
p.add_argument("--results", required=True, help="fold SARIF output")
p.add_argument("--detector", required=True,
help="Detector name (null-deref, div-by-zero, int-overflow, oob-access)")
args = p.parse_args()
gold = load_gold(args.gold)
categories = diagnose(args.gold, args.trace, args.results, args.detector)
print(render(categories, len(gold)))
return 0
if __name__ == "__main__":
raise SystemExit(main())