Files
airsdb/scripts/airsdb_cppcheck.py
2026-05-18 11:45:06 +08:00

321 lines
11 KiB
Python

#!/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("<?xml")
if start >= 0:
return text[start:]
start = text.find("<results")
if start >= 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()