3
0
Fork 0
mirror of https://github.com/Z3Prover/z3 synced 2026-03-14 17:19:59 +00:00
z3/.github/skills/static-analysis/scripts/static_analysis.py
Angelica Moreira d349b93d1d Add Copilot skill architecture with 10 skills, 2 agents, and shared infra
Introduce .github/skills/ with solve, prove, optimize, simplify,
encode, explain, benchmark, memory-safety, static-analysis, and
deeptest skills. Each skill follows a SKILL.md + scripts/ pattern
with Python scripts backed by a shared SQLite logging library
(z3db.py). Two orchestrator agents (z3-solver, z3-verifier) route
requests to the appropriate skills.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-11 17:41:29 +00:00

255 lines
7.7 KiB
Python

#!/usr/bin/env python3
"""
static_analysis.py: run Clang Static Analyzer on Z3 source.
Usage:
python static_analysis.py --build-dir build
python static_analysis.py --build-dir build --output-dir /tmp/sa-results
python static_analysis.py --build-dir build --debug
"""
import argparse
import logging
import os
import plistlib
import shutil
import subprocess
import sys
import time
from collections import Counter
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent.parent / "shared"))
from z3db import Z3DB, setup_logging
logger = logging.getLogger("z3agent")
SCAN_BUILD_NAMES = ["scan-build", "scan-build-14", "scan-build-15", "scan-build-16"]
def find_scan_build() -> str:
"""Locate the scan-build binary on PATH."""
for name in SCAN_BUILD_NAMES:
path = shutil.which(name)
if path:
logger.debug("found scan-build: %s", path)
return path
logger.error(
"scan-build not found. Install clang-tools or set PATH. "
"Searched: %s", ", ".join(SCAN_BUILD_NAMES)
)
sys.exit(1)
def run_configure(scan_build: str, build_dir: Path, output_dir: Path,
timeout: int) -> bool:
"""Run scan-build cmake to configure the project."""
repo_root = build_dir.parent
cmd = [
scan_build,
"-o", str(output_dir),
"cmake",
str(repo_root),
]
logger.info("configuring: %s", " ".join(cmd))
try:
proc = subprocess.run(
cmd, cwd=str(build_dir),
capture_output=True, text=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
logger.error("cmake configuration timed out after %ds", timeout)
return False
if proc.returncode != 0:
logger.error("cmake configuration failed (exit %d)", proc.returncode)
logger.error("stderr: %s", proc.stderr[:2000])
return False
logger.info("configuration complete")
return True
def run_build(scan_build: str, build_dir: Path, output_dir: Path,
timeout: int) -> bool:
"""Run scan-build make to build and analyze."""
nproc = os.cpu_count() or 4
cmd = [
scan_build,
"-o", str(output_dir),
"--status-bugs",
"make",
f"-j{nproc}",
]
logger.info("building with analysis: %s", " ".join(cmd))
try:
proc = subprocess.run(
cmd, cwd=str(build_dir),
capture_output=True, text=True, timeout=timeout,
)
except subprocess.TimeoutExpired:
logger.error("build timed out after %ds", timeout)
return False
# scan-build returns nonzero when bugs are found (due to --status-bugs),
# so a nonzero exit code is not necessarily a build failure.
if proc.returncode != 0:
logger.info(
"scan-build exited with code %d (nonzero may indicate findings)",
proc.returncode,
)
else:
logger.info("build complete, no bugs reported by scan-build")
if proc.stderr:
logger.debug("build stderr (last 2000 chars): %s", proc.stderr[-2000:])
return True
def collect_plist_files(output_dir: Path) -> list:
"""Recursively find all .plist diagnostic files under the output directory."""
plists = sorted(output_dir.rglob("*.plist"))
logger.debug("found %d plist files in %s", len(plists), output_dir)
return plists
def parse_plist_findings(plist_path: Path) -> list:
"""Extract findings from a single Clang plist diagnostic file.
Returns a list of dicts with keys: file, line, col, category, type, description.
"""
findings = []
try:
with open(plist_path, "rb") as f:
data = plistlib.load(f)
except Exception as exc:
logger.warning("could not parse %s: %s", plist_path, exc)
return findings
source_files = data.get("files", [])
for diag in data.get("diagnostics", []):
location = diag.get("location", {})
file_idx = location.get("file", 0)
source_file = source_files[file_idx] if file_idx < len(source_files) else "<unknown>"
findings.append({
"file": source_file,
"line": location.get("line", 0),
"col": location.get("col", 0),
"category": diag.get("category", "uncategorized"),
"type": diag.get("type", ""),
"description": diag.get("description", ""),
})
return findings
def collect_all_findings(output_dir: Path) -> list:
"""Parse every plist file under output_dir and return merged findings."""
all_findings = []
for plist_path in collect_plist_files(output_dir):
all_findings.extend(parse_plist_findings(plist_path))
return all_findings
def log_findings(db, run_id: int, findings: list):
"""Persist each finding to z3agent.db."""
for f in findings:
db.log_finding(
run_id,
category=f["category"],
message=f["description"],
severity=f.get("type"),
file=f["file"],
line=f["line"],
details={"col": f["col"], "type": f["type"]},
)
def print_findings(findings: list):
"""Print individual findings and a category summary."""
if not findings:
print("No findings reported.")
return
for f in findings:
label = f["category"]
if f["type"]:
label = f["type"]
print(f"[{label}] {f['file']}:{f['line']}: {f['description']}")
print()
counts = Counter(f["category"] for f in findings)
print(f"Total findings: {len(findings)}")
print("By category:")
for cat, cnt in counts.most_common():
print(f" {cat}: {cnt}")
def main():
parser = argparse.ArgumentParser(
prog="static_analysis",
description="Run Clang Static Analyzer on Z3 and log findings.",
)
parser.add_argument(
"--build-dir", required=True,
help="path to the CMake build directory",
)
parser.add_argument(
"--output-dir", default=None,
help="directory for scan-build results (default: BUILD/scan-results)",
)
parser.add_argument(
"--timeout", type=int, default=1200,
help="seconds allowed for the full analysis build",
)
parser.add_argument("--db", default=None, help="path to z3agent.db")
parser.add_argument("--debug", action="store_true", help="verbose tracing")
args = parser.parse_args()
setup_logging(args.debug)
scan_build = find_scan_build()
build_dir = Path(args.build_dir).resolve()
build_dir.mkdir(parents=True, exist_ok=True)
output_dir = Path(args.output_dir) if args.output_dir else build_dir / "scan-results"
output_dir = output_dir.resolve()
output_dir.mkdir(parents=True, exist_ok=True)
db = Z3DB(args.db)
run_id = db.start_run("static-analysis", f"build_dir={build_dir}")
start = time.monotonic()
if not run_configure(scan_build, build_dir, output_dir, timeout=args.timeout):
elapsed = int((time.monotonic() - start) * 1000)
db.finish_run(run_id, "error", elapsed, exit_code=1)
db.close()
sys.exit(1)
if not run_build(scan_build, build_dir, output_dir, timeout=args.timeout):
elapsed = int((time.monotonic() - start) * 1000)
db.finish_run(run_id, "error", elapsed, exit_code=1)
db.close()
sys.exit(1)
elapsed = int((time.monotonic() - start) * 1000)
findings = collect_all_findings(output_dir)
log_findings(db, run_id, findings)
status = "clean" if len(findings) == 0 else "findings"
db.finish_run(run_id, status, elapsed, exit_code=0)
db.log(
f"static analysis complete: {len(findings)} finding(s) in {elapsed}ms",
run_id=run_id,
)
print_findings(findings)
db.close()
sys.exit(0)
if __name__ == "__main__":
main()