Includes AirPlan design documents, AircOding-alpha1-plan, AirPlanV2, AirPlan-ParaV2, AirPlan-Para V1 reference docs, and all working code changes across packages. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
321 lines
11 KiB
Python
Executable File
321 lines
11 KiB
Python
Executable File
#!/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()
|