#!/usr/bin/env python3 """Run local cppcheck scans and maintain AirSDB reports.""" from __future__ import annotations import argparse import json import os import shlex import subprocess import sys import xml.etree.ElementTree as ET from collections import Counter from datetime import datetime, timezone from pathlib import Path from typing import Any, Dict, List, Optional from airsdb_mode import ensure_files, read_env, setup_cppcheck, stamp DEFAULT_ENABLE = "warning,style,performance,portability,information" DEFAULT_CHECK_LEVEL = "exhaustive" DEFAULT_EXCLUDES = [ ".git", ".airsdb", "node_modules", "build", "cmake-build-debug", "cmake-build-release", "dist", "out", "vendor", "third_party", "external", ] def iso() -> str: return datetime.now(timezone.utc).isoformat() def rel(project: Path, path: str) -> str: if not path: return "" try: return str(Path(path).resolve().relative_to(project)) except Exception: return path def find_project_file(project: Path, configured: str = "") -> Optional[Path]: candidates = [] if configured: candidates.append(Path(configured)) candidates.extend( [ project / "compile_commands.json", project / "build" / "compile_commands.json", project / "cmake-build-debug" / "compile_commands.json", project / "cmake-build-release" / "compile_commands.json", ] ) for candidate in candidates: p = candidate if candidate.is_absolute() else project / candidate if p.exists(): return p.resolve() return None def build_args( project: Path, tool: str, project_file: str = "", target: str = "", enable: str = DEFAULT_ENABLE, check_level: str = DEFAULT_CHECK_LEVEL, jobs: int = 1, std: str = "", extra: Optional[List[str]] = None, ) -> List[str]: build_dir = project / "AirPlan" / "state" / "airsdb" / "cppcheck-build" build_dir.mkdir(parents=True, exist_ok=True) args = [ tool, f"--enable={enable}", f"--check-level={check_level}", "--inconclusive", "--inline-suppr", "--quiet", "--xml", "--xml-version=2", f"--cppcheck-build-dir={build_dir}", ] if jobs > 1: args.append(f"-j{jobs}") if std: args.append(f"--std={std}") for item in extra or []: if item: args.extend(shlex.split(item, posix=(os.name != "nt"))) pf = find_project_file(project, project_file) if pf: args.append(f"--project={pf}") else: for ignored in DEFAULT_EXCLUDES: args.append(f"-i{ignored}") args.append(target or ".") return args def command_text(args: List[str]) -> str: if os.name == "nt": return subprocess.list2cmdline(args) return shlex.join(args) def extract_xml(text: str) -> str: start = text.find("= 0: return text[start:] start = text.find("= 0: return text[start:] return text def parse_cppcheck_xml(xml_text: str, project: Path) -> Dict[str, Any]: xml_text = extract_xml(xml_text) findings: List[Dict[str, Any]] = [] counts: Counter[str] = Counter() if not xml_text.strip(): return {"counts": {}, "findings": [], "parseError": "empty_xml"} try: root = ET.fromstring(xml_text) except ET.ParseError as exc: return {"counts": {}, "findings": [], "parseError": str(exc)} for error in root.findall(".//error"): severity = error.attrib.get("severity", "unknown") counts[severity] += 1 locations = [] for loc in error.findall("location"): locations.append( { "file": rel(project, loc.attrib.get("file", "")), "line": loc.attrib.get("line", ""), "info": loc.attrib.get("info", ""), } ) findings.append( { "severity": severity, "id": error.attrib.get("id", ""), "msg": error.attrib.get("msg", ""), "cwe": error.attrib.get("cwe", ""), "locations": locations, } ) return {"counts": dict(counts), "findings": findings, "parseError": ""} def top_findings(findings: List[Dict[str, Any]], limit: int = 12) -> List[str]: rank = {"error": 0, "warning": 1, "performance": 2, "portability": 3, "style": 4, "information": 5} def key(item: Dict[str, Any]) -> tuple[int, str]: return (rank.get(item.get("severity", ""), 9), item.get("id", "")) lines = [] for item in sorted(findings, key=key)[:limit]: loc = item.get("locations") or [{}] first = loc[0] where = first.get("file", "") if first.get("line"): where += f":{first['line']}" cwe = f" CWE-{item['cwe']}" if item.get("cwe") else "" lines.append(f"[{item.get('severity')}:{item.get('id')}{cwe}] {where} {item.get('msg')}".strip()) return lines def append_staticanalysis(project: Path, data: Dict[str, Any]) -> None: ensure_files(project) path = project / "AirPlan" / "docs" / "staticanalysis.md" counts = data.get("counts") or {} counts_text = ", ".join(f"{k}={v}" for k, v in sorted(counts.items())) or "none" tops = data.get("topFindings") or [] top_text = "\n".join(f" - {line}" for line in tops) if tops else " - none" result = "failed" if data.get("returnCode", 0) not in [0, None] else ("findings" if data.get("findingCount", 0) else "ok") entry = ( f"\n### {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirSDB cppcheck\n\n" f"- Target: {data.get('target', 'local')}\n" f"- Tool: `{data.get('tool', '')}` {data.get('version', '')}\n" f"- Command: `{data.get('command', '')}`\n" f"- Result: {result}; returnCode={data.get('returnCode', '')}\n" f"- Counts: {counts_text}\n" f"- Reports: `{data.get('xmlReport', '')}`, `{data.get('jsonReport', '')}`\n" f"- Top findings:\n{top_text}\n" f"- AirDbg/AirDo handoff: {data.get('handoff', 'Review top findings before code-level repair or todo validation.')}\n" f"- Residual risk: cppcheck is static analysis and may miss runtime, integration, configuration, or dependency issues.\n" ) path.open("a", encoding="utf-8", newline="\n").write(entry) def write_reports(project: Path, data: Dict[str, Any], xml_text: str, prefix: str = "cppcheck") -> Dict[str, Any]: reports = project / "AirPlan" / "state" / "airsdb" / "reports" reports.mkdir(parents=True, exist_ok=True) ts = stamp() xml_path = reports / f"{ts}-{prefix}.xml" json_path = reports / f"{ts}-{prefix}.json" xml_path.write_text(xml_text, encoding="utf-8", newline="\n") data["xmlReport"] = str(xml_path) data["jsonReport"] = str(json_path) json_path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n") return data def run_scan(args: argparse.Namespace) -> int: project = Path(args.project).expanduser().resolve() ensure_files(project) setup = setup_cppcheck(project, auto=not args.no_auto_configure) if setup.get("status") != "ok": print(f"airsdb_scan_status=blocked") for key, value in setup.items(): print(f"{key}={value}") return 2 tool = setup["tool"] env = read_env(project / "AirPlan" / "state" / "airsdb" / "tool.env") extra = list(args.extra or []) if env.get("AIRSDB_CPPCHECK_OPTIONS"): extra.append(env["AIRSDB_CPPCHECK_OPTIONS"]) cmd = build_args(project, tool, args.project_file, args.target, args.enable, args.check_level, args.jobs, args.std, extra) if args.action == "command": print("airsdb_command_status=ok") print(f"command={command_text(cmd)}") return 0 try: run = subprocess.run(cmd, cwd=project, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=args.timeout) except subprocess.TimeoutExpired as exc: data = { "target": "local", "status": "timeout", "tool": tool, "version": setup.get("version", ""), "checkLevel": args.check_level, "command": command_text(cmd), "returnCode": "timeout", "stdout": exc.stdout or "", "stderr": exc.stderr or "", "capturedAt": iso(), "counts": {}, "findings": [], "findingCount": 0, "topFindings": [], } data = write_reports(project, data, str(exc.stderr or ""), "cppcheck-timeout") append_staticanalysis(project, data) print("airsdb_scan_status=timeout") print(f"jsonReport={data['jsonReport']}") return 1 xml_text = extract_xml(run.stderr or "") parsed = parse_cppcheck_xml(xml_text, project) findings = parsed.get("findings", []) data = { "target": "local", "status": "ok" if run.returncode == 0 else "failed", "tool": tool, "version": setup.get("version", ""), "checkLevel": args.check_level, "command": command_text(cmd), "returnCode": run.returncode, "stdout": run.stdout.strip(), "stderrPreview": (run.stderr or "")[:2000], "capturedAt": iso(), "counts": parsed.get("counts", {}), "findings": findings, "findingCount": len(findings), "topFindings": top_findings(findings, args.limit), "parseError": parsed.get("parseError", ""), } data = write_reports(project, data, xml_text, "cppcheck") append_staticanalysis(project, data) print(f"airsdb_scan_status={data['status']}") print(f"findingCount={data['findingCount']}") print(f"xmlReport={data['xmlReport']}") print(f"jsonReport={data['jsonReport']}") return 0 if run.returncode == 0 else 1 def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Run local AirSDB cppcheck analysis.") parser.add_argument("--project", default=".") parser.add_argument("--action", choices=["setup", "status", "command", "scan"], default="scan") parser.add_argument("--project-file", default="") parser.add_argument("--target", default="") parser.add_argument("--enable", default=DEFAULT_ENABLE) parser.add_argument("--check-level", default=DEFAULT_CHECK_LEVEL) parser.add_argument("--std", default="") parser.add_argument("--jobs", type=int, default=1) parser.add_argument("--timeout", type=int, default=900) parser.add_argument("--limit", type=int, default=12) parser.add_argument("--extra", action="append", default=[]) parser.add_argument("--no-auto-configure", action="store_true") return parser.parse_args() def main() -> None: args = parse_args() project = Path(args.project).expanduser().resolve() if args.action in ["setup", "status"]: ensure_files(project) setup = setup_cppcheck(project, auto=(args.action == "setup" and not args.no_auto_configure)) print(f"airsdb_cppcheck_status={setup.get('status', 'unknown')}") for key, value in setup.items(): if key != "status": print(f"{key}={value}") raise SystemExit(0 if setup.get("status") == "ok" else 2) raise SystemExit(run_scan(args)) if __name__ == "__main__": main()