Initial release: airsdb
This commit is contained in:
318
scripts/airsdb_mode.py
Normal file
318
scripts/airsdb_mode.py
Normal file
@@ -0,0 +1,318 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bootstrap AirSDB static-analysis context and cppcheck tooling."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
MARKER_BEGIN = "<!-- AIRSDB:BEGIN -->"
|
||||
MARKER_END = "<!-- AIRSDB:END -->"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def stamp() -> str:
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def read_env(path: Path) -> Dict[str, str]:
|
||||
vals: Dict[str, str] = {}
|
||||
if path.exists():
|
||||
for raw in path.read_text(encoding="utf-8-sig").splitlines():
|
||||
line = raw.strip()
|
||||
if line and not line.startswith("#") and "=" in line:
|
||||
key, value = line.split("=", 1)
|
||||
vals[key.strip()] = value.strip().strip('"').strip("'")
|
||||
for key in ["AIRSDB_CPPCHECK", "AIRSDB_CPPCHECK_OPTIONS"]:
|
||||
if os.environ.get(key):
|
||||
vals[key] = os.environ[key]
|
||||
return vals
|
||||
|
||||
|
||||
def write_env(path: Path, updates: Dict[str, str]) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
old = path.read_text(encoding="utf-8") if path.exists() else ""
|
||||
keys = set(updates)
|
||||
lines = []
|
||||
for raw in old.splitlines():
|
||||
key = raw.split("=", 1)[0].strip() if "=" in raw and not raw.strip().startswith("#") else None
|
||||
if key not in keys:
|
||||
lines.append(raw)
|
||||
if lines and lines[-1].strip():
|
||||
lines.append("")
|
||||
for key, value in updates.items():
|
||||
if value:
|
||||
lines.append(f"{key}={value}")
|
||||
path.write_text("\n".join(lines).rstrip() + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def ensure_files(project: Path) -> None:
|
||||
air = project / "AirPlan" / "state" / "airsdb"
|
||||
air.mkdir(parents=True, exist_ok=True)
|
||||
example = air / "tool.env.example"
|
||||
if not example.exists():
|
||||
example.write_text(
|
||||
"# AirSDB local cppcheck configuration\n"
|
||||
"AIRSDB_CPPCHECK=cppcheck\n"
|
||||
"AIRSDB_CPPCHECK_OPTIONS=\n"
|
||||
"# AirSDB defaults to --check-level=exhaustive for maximum branch analysis detail.\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
gitignore = air / ".gitignore"
|
||||
old = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
||||
for item in ["tool.env", "remote-device.env", "reports/", "cppcheck-build/"]:
|
||||
if item not in old.splitlines():
|
||||
old = (old.rstrip() + f"\n{item}\n").lstrip()
|
||||
gitignore.write_text(old, encoding="utf-8", newline="\n")
|
||||
|
||||
static = project / "AirPlan" / "docs" / "staticanalysis.md"
|
||||
if not static.exists():
|
||||
static.write_text(
|
||||
"# Static Analysis\n\n"
|
||||
"Short AI-context reports for AirSDB cppcheck runs. Keep entries concise.\n\n"
|
||||
"## Entry Template\n\n"
|
||||
"### YYYY-MM-DD HH:MM:SS: AirSDB cppcheck\n\n"
|
||||
"- Target: TODO\n"
|
||||
"- Tool: TODO\n"
|
||||
"- Command: TODO\n"
|
||||
"- Result: TODO\n"
|
||||
"- Counts: TODO\n"
|
||||
"- Reports: TODO\n"
|
||||
"- Top findings: TODO\n"
|
||||
"- AirDbg/AirDo handoff: TODO\n"
|
||||
"- Residual risk: TODO\n",
|
||||
encoding="utf-8",
|
||||
newline="\n",
|
||||
)
|
||||
|
||||
|
||||
def airsdb_agents_block() -> str:
|
||||
return f"""{MARKER_BEGIN}
|
||||
## AirSDB Static Analysis Workflow
|
||||
|
||||
1. Use AirSDB for `/airsdb` sessions that run cppcheck static analysis for C/C++ code quality, security-relevant defects, and debugging evidence.
|
||||
2. On first entry, detect cppcheck; if missing, auto-configure it with the best available package manager or stop with install instructions.
|
||||
3. Maintain `AirPlan/docs/staticanalysis.md` as the short AI-context report for AirDbg and AirDo. Keep detailed XML/JSON artifacts under `AirPlan/state/airsdb/reports/`.
|
||||
4. Prefer `compile_commands.json` when available; otherwise scan the narrowest useful source tree with common generated/vendor directories excluded.
|
||||
5. Default local and remote cppcheck scans to `--check-level=exhaustive` so AirSDB provides the most detailed branch-analysis evidence it can.
|
||||
6. For local analysis, use `airsdb_cppcheck.py`; for remote targets over SSH, use `airsdb_remote_device.py` before local analysis.
|
||||
7. When remote cppcheck is missing, the remote helper may auto-configure it with non-interactive package-manager commands; if sudo/password or unsupported OS blocks it, stop and record the blocker.
|
||||
8. Record command, target, report paths, counts, top findings, AirDbg/AirDo handoff, and residual risk in `AirPlan/docs/staticanalysis.md`.
|
||||
9. Update ADR/C4 only when static analysis tooling becomes a durable project boundary or changes implementation decisions.
|
||||
{MARKER_END}
|
||||
"""
|
||||
|
||||
|
||||
def upsert_agents_md(path: Path) -> str:
|
||||
block = airsdb_agents_block().rstrip() + "\n"
|
||||
if path.exists():
|
||||
original = path.read_text(encoding="utf-8")
|
||||
existed = True
|
||||
else:
|
||||
original = "# AGENTS.md\n\n"
|
||||
existed = False
|
||||
begin = original.find(MARKER_BEGIN)
|
||||
end = original.find(MARKER_END)
|
||||
if begin >= 0 and end > begin:
|
||||
end += len(MARKER_END)
|
||||
updated = original[:begin].rstrip() + "\n\n" + block + original[end:].lstrip()
|
||||
status = "updated"
|
||||
else:
|
||||
updated = original.rstrip() + "\n\n" + block
|
||||
status = "updated" if existed else "created"
|
||||
path.write_text(updated, encoding="utf-8", newline="\n")
|
||||
return status
|
||||
|
||||
|
||||
def run_args(args: list[str], timeout: int = 600) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(args, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
||||
|
||||
|
||||
def run_shell(cmd: str, timeout: int = 600) -> subprocess.CompletedProcess[str]:
|
||||
return subprocess.run(cmd, shell=True, capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=timeout)
|
||||
|
||||
|
||||
def cppcheck_version(tool: str) -> str:
|
||||
try:
|
||||
result = run_args([tool, "--version"], timeout=20)
|
||||
except Exception:
|
||||
return ""
|
||||
text = (result.stdout or result.stderr).strip()
|
||||
return text.splitlines()[0] if text else ""
|
||||
|
||||
|
||||
def detect_cppcheck(project: Path) -> Tuple[Optional[str], str]:
|
||||
env = read_env(project / "AirPlan" / "state" / "airsdb" / "tool.env")
|
||||
candidates = []
|
||||
configured = env.get("AIRSDB_CPPCHECK", "").strip()
|
||||
if configured:
|
||||
candidates.append(configured)
|
||||
candidates.append("cppcheck")
|
||||
if platform.system().lower() == "windows":
|
||||
candidates.extend(
|
||||
[
|
||||
r"C:\Program Files\Cppcheck\cppcheck.exe",
|
||||
r"C:\Program Files (x86)\Cppcheck\cppcheck.exe",
|
||||
]
|
||||
)
|
||||
for candidate in candidates:
|
||||
path = shutil.which(candidate) or candidate
|
||||
if Path(path).exists() or shutil.which(path):
|
||||
version = cppcheck_version(path)
|
||||
if version:
|
||||
return path, version
|
||||
return None, ""
|
||||
|
||||
|
||||
def install_cppcheck(project: Path) -> Dict[str, str]:
|
||||
system = platform.system().lower()
|
||||
attempts = []
|
||||
if system == "windows":
|
||||
package_commands = [
|
||||
["winget", "install", "--id", "Cppcheck.Cppcheck", "-e", "--accept-source-agreements", "--accept-package-agreements", "--silent"],
|
||||
["choco", "install", "cppcheck", "-y"],
|
||||
["scoop", "install", "cppcheck"],
|
||||
]
|
||||
for cmd in package_commands:
|
||||
exe = shutil.which(cmd[0])
|
||||
if not exe:
|
||||
continue
|
||||
run = run_args([exe] + cmd[1:], timeout=1200)
|
||||
attempts.append({"command": " ".join(cmd), "returncode": str(run.returncode)})
|
||||
tool, version = detect_cppcheck(project)
|
||||
if tool:
|
||||
write_env(project / "AirPlan" / "state" / "airsdb" / "tool.env", {"AIRSDB_CPPCHECK": tool})
|
||||
return {"status": "ok", "tool": tool, "version": version, "installer": cmd[0], "attempts": json.dumps(attempts)}
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "cppcheck_not_found",
|
||||
"hint": "Install Cppcheck from https://cppcheck.sourceforge.io/ or configure AIRSDB_CPPCHECK.",
|
||||
"attempts": json.dumps(attempts),
|
||||
}
|
||||
|
||||
commands = [
|
||||
"if command -v apt-get >/dev/null 2>&1; then sudo -n apt-get update && sudo -n apt-get install -y cppcheck; "
|
||||
"elif command -v dnf >/dev/null 2>&1; then sudo -n dnf install -y cppcheck; "
|
||||
"elif command -v yum >/dev/null 2>&1; then sudo -n yum install -y cppcheck; "
|
||||
"elif command -v apk >/dev/null 2>&1; then sudo -n apk add cppcheck; "
|
||||
"elif command -v pacman >/dev/null 2>&1; then sudo -n pacman -Sy --noconfirm cppcheck; "
|
||||
"elif command -v brew >/dev/null 2>&1; then brew install cppcheck; "
|
||||
"elif command -v port >/dev/null 2>&1; then sudo -n port install cppcheck; "
|
||||
"else exit 42; fi"
|
||||
]
|
||||
run = run_shell(commands[0], timeout=1200)
|
||||
attempts.append({"command": "system-package-manager", "returncode": str(run.returncode)})
|
||||
tool, version = detect_cppcheck(project)
|
||||
if tool:
|
||||
write_env(project / "AirPlan" / "state" / "airsdb" / "tool.env", {"AIRSDB_CPPCHECK": tool})
|
||||
return {"status": "ok", "tool": tool, "version": version, "installer": "system-package-manager", "attempts": json.dumps(attempts)}
|
||||
return {
|
||||
"status": "blocked",
|
||||
"reason": "cppcheck_not_found",
|
||||
"hint": "Install cppcheck with the OS package manager or configure AIRSDB_CPPCHECK.",
|
||||
"attempts": json.dumps(attempts),
|
||||
}
|
||||
|
||||
|
||||
def setup_cppcheck(project: Path, auto: bool = True) -> Dict[str, str]:
|
||||
ensure_files(project)
|
||||
tool, version = detect_cppcheck(project)
|
||||
if tool:
|
||||
write_env(project / "AirPlan" / "state" / "airsdb" / "tool.env", {"AIRSDB_CPPCHECK": tool})
|
||||
return {"status": "ok", "tool": tool, "version": version, "autoConfigure": "skipped"}
|
||||
if not auto:
|
||||
return {"status": "blocked", "reason": "cppcheck_not_found", "example": str(project / "AirPlan" / "state" / "airsdb" / "tool.env.example")}
|
||||
return install_cppcheck(project)
|
||||
|
||||
|
||||
def artifact_map(project: Path) -> Dict[str, Path]:
|
||||
return {
|
||||
"AGENTS.md": project / "AirPlan" / "AGENTS.md",
|
||||
"staticanalysis": project / "AirPlan" / "docs" / "staticanalysis.md",
|
||||
"state": project / "AirPlan" / "state" / "airsdb" / "state.json",
|
||||
"tool_env": project / "AirPlan" / "state" / "airsdb" / "tool.env",
|
||||
}
|
||||
|
||||
|
||||
def write_state(path: Path, enabled: bool, project: Path, tool_info: Dict[str, str]) -> None:
|
||||
artifacts = artifact_map(project)
|
||||
payload = {
|
||||
"enabled": enabled,
|
||||
"updatedAt": now_iso(),
|
||||
"projectRoot": str(project),
|
||||
"cppcheck": tool_info,
|
||||
"artifactHealth": {name: p.exists() for name, p in artifacts.items() if name != "state"},
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def enter_mode(project: Path, auto: bool = True) -> Tuple[str, Dict[str, str]]:
|
||||
ensure_files(project)
|
||||
results = {"AGENTS.md": upsert_agents_md(project / "AirPlan" / "AGENTS.md"), "staticanalysis": "exists"}
|
||||
tool_info = setup_cppcheck(project, auto=auto)
|
||||
for key, value in tool_info.items():
|
||||
results[f"cppcheck_{key}"] = value
|
||||
write_state(project / "AirPlan" / "state" / "airsdb" / "state.json", True, project, tool_info)
|
||||
return "enabled", results
|
||||
|
||||
|
||||
def exit_mode(project: Path) -> Tuple[str, Dict[str, str]]:
|
||||
ensure_files(project)
|
||||
tool_info = setup_cppcheck(project, auto=False)
|
||||
write_state(project / "AirPlan" / "state" / "airsdb" / "state.json", False, project, tool_info)
|
||||
return "disabled", {}
|
||||
|
||||
|
||||
def status_mode(project: Path) -> Tuple[str, Dict[str, str]]:
|
||||
ensure_files(project)
|
||||
state = project / "AirPlan" / "state" / "airsdb" / "state.json"
|
||||
enabled = False
|
||||
if state.exists():
|
||||
try:
|
||||
enabled = bool(json.loads(state.read_text(encoding="utf-8")).get("enabled"))
|
||||
except json.JSONDecodeError:
|
||||
enabled = False
|
||||
tool_info = setup_cppcheck(project, auto=False)
|
||||
results = {name: ("ok" if path.exists() else "missing") for name, path in artifact_map(project).items() if name != "state"}
|
||||
for key, value in tool_info.items():
|
||||
results[f"cppcheck_{key}"] = value
|
||||
return ("enabled" if enabled else "disabled"), results
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Manage AirSDB static-analysis artifacts.")
|
||||
parser.add_argument("--mode", choices=["enter", "setup", "exit", "status"], default="enter")
|
||||
parser.add_argument("--project", 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.mode in ["enter", "setup"]:
|
||||
mode_state, result = enter_mode(project, auto=not args.no_auto_configure)
|
||||
elif args.mode == "exit":
|
||||
mode_state, result = exit_mode(project)
|
||||
else:
|
||||
mode_state, result = status_mode(project)
|
||||
print(f"airsdb_mode={mode_state}")
|
||||
print(f"project_root={project}")
|
||||
for key, value in result.items():
|
||||
print(f"{key}={value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user