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.
175 lines
5.7 KiB
Python
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())
|