feat: P1-19 P1-20 实现 - 边界测试强制 + 高风险审计 + UI Skill 路由

P1-19.1: Arc 边界测试强制
- TaskNode 新增 test_required 字段
- _inject_boundary_tests() 为每个模块注入接口测试和单元测试任务
- Done When 验证必须包含"测试通过"

P1-19.2: AirRvr 高风险审计
- 新增 HighRiskAudit, HighRiskFinding 数据类
- ReviewReport 新增 highRiskAudit 字段,含 lifecycle/nullPointer/danglingPointer/exceptionSafety/concurrency + overallRisk + deliveryVerdict
- 序列化/反序列化支持

P1-19.3: block-release 集成
- dispatch_worker_group() 派发前扫描最新审查报告
- deliveryVerdict=block-release 时阻止所有后续派发
- 记录 eng.blocked 事件

P1-20: frontend-design Skill 集成
- is_ui_task() UI 任务检测
- ensure_frontend_design_skill() 自动安装 Skill
- route_ui_task() UI 任务路由决策
- enter_worker() 集成 UI 检测,skill 不可用时阻止执行
- commands/do.md 更新 UI 处理说明
- SKILL.md 新增 INV-12/INV-13/INV-14

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AirLongDian
2026-06-10 17:06:48 +08:00
commit e73a4da354
54 changed files with 6054 additions and 0 deletions

View File

@@ -0,0 +1,183 @@
"""
AirTst 测试运行器运行时 — V2 新增组件。
统一测试执行接口,支持多框架,产出结构化结果。
"""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass, field
from pathlib import Path
from air_runtime.io import atomic_json_write
from air_runtime.paths import tst_state_path
@dataclass
class TestCase:
name: str
status: str # passed | failed | skipped
duration: str = ""
message: str = ""
@dataclass
class TestSuite:
name: str
total: int = 0
passed: int = 0
failed: int = 0
skipped: int = 0
cases: list[TestCase] = field(default_factory=list)
@dataclass
class TestRunResult:
framework: str
total: int = 0
passed: int = 0
failed: int = 0
disabled: int = 0
duration: str = ""
suites: list[TestSuite] = field(default_factory=list)
failures: list[dict] = field(default_factory=list)
class TestRunner:
"""统一测试执行器。"""
FRAMEWORKS = {
"pytest": ["python", "-m", "pytest", "--json-report", "-q"],
"googletest": ["ctest", "--output-on-failure"],
"jest": ["npx", "jest", "--json"],
"vitest": ["npx", "vitest", "run", "--reporter=json"],
"go": ["go", "test", "-json", "./..."],
"cargo": ["cargo", "test", "--", "--format=json"],
}
def run(self, task_id: str, project_root: Path, framework: str,
target_path: Path | None = None,
extra_args: list[str] | None = None) -> TestRunResult:
if framework not in self.FRAMEWORKS:
return TestRunResult(framework=framework, failures=[{"error": f"unsupported framework: {framework}"}])
cmd = list(self.FRAMEWORKS[framework])
if target_path:
cmd.append(str(target_path))
if extra_args:
cmd.extend(extra_args)
try:
result = subprocess.run(cmd, capture_output=True, text=True,
timeout=600, cwd=str(project_root))
except subprocess.TimeoutExpired:
return TestRunResult(framework=framework, failures=[{"error": "timeout"}])
run_result = self._parse_result(framework, result.stdout)
self._save_report(task_id, project_root, run_result)
return run_result
def _parse_result(self, framework: str, stdout: str) -> TestRunResult:
if framework == "pytest":
return self._parse_pytest(stdout)
if framework in ("jest", "vitest"):
return self._parse_jest(stdout)
if framework == "googletest":
return self._parse_googletest(stdout)
if framework == "go":
return self._parse_go(stdout)
if framework == "cargo":
return self._parse_cargo(stdout)
return TestRunResult(framework=framework, total=0)
def _parse_pytest(self, stdout: str) -> TestRunResult:
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return TestRunResult(framework="pytest", failures=[{"error": "json parse failed"}])
return TestRunResult(
framework="pytest",
total=data.get("summary", {}).get("total", 0),
passed=data.get("summary", {}).get("passed", 0),
failed=data.get("summary", {}).get("failed", 0),
duration=str(data.get("duration", "")),
)
def _parse_jest(self, stdout: str) -> TestRunResult:
try:
data = json.loads(stdout)
except json.JSONDecodeError:
return TestRunResult(framework="jest", failures=[{"error": "json parse failed"}])
return TestRunResult(
framework="jest",
total=data.get("numTotalTests", 0),
passed=data.get("numPassedTests", 0),
failed=data.get("numFailedTests", 0),
)
def _parse_googletest(self, stdout: str) -> TestRunResult:
"""解析 ctest 输出。ctest 不输出 JSON从文本提取统计。"""
import re
total = passed = failed = disabled = 0
for line in stdout.splitlines():
m = re.match(r"(\d+)% tests passed, (\d+) tests failed out of (\d+)", line)
if m:
failed = int(m.group(2))
total = int(m.group(3))
passed = total - failed
# GoogleTest 也支持 --gtest_output=json
try:
data = json.loads(stdout)
if isinstance(data, dict):
total = sum(s.get("tests", 0) for s in data.get("testsuites", []))
failed = sum(s.get("failures", 0) for s in data.get("testsuites", []))
passed = total - failed
disabled = sum(s.get("disabled", 0) for s in data.get("testsuites", []))
except json.JSONDecodeError:
pass
return TestRunResult(
framework="googletest", total=total, passed=passed, failed=failed, disabled=disabled,
)
def _parse_go(self, stdout: str) -> TestRunResult:
"""解析 go test -json 输出JSONL 格式,每行一个事件)。"""
total = passed = failed = 0
for line in stdout.splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
action = ev.get("Action", "")
if action == "pass":
passed += 1
total += 1
elif action == "fail":
failed += 1
total += 1
elif action == "skip":
total += 1
return TestRunResult(framework="go", total=total, passed=passed, failed=failed)
def _parse_cargo(self, stdout: str) -> TestRunResult:
"""解析 cargo test --format=json 输出JSONL 格式)。"""
total = passed = failed = 0
for line in stdout.splitlines():
try:
ev = json.loads(line)
except json.JSONDecodeError:
continue
if ev.get("type") == "test":
total += 1
if ev.get("event") == "ok":
passed += 1
elif ev.get("event") == "failed":
failed += 1
return TestRunResult(framework="cargo", total=total, passed=passed, failed=failed)
def _save_report(self, task_id: str, project_root: Path, result: TestRunResult) -> None:
report_dir = tst_state_path(project_root).parent / "reports"
report_dir.mkdir(parents=True, exist_ok=True)
from air_runtime.utils import session_stamp
report_path = report_dir / f"{task_id}-{session_stamp()}.json"
atomic_json_write(report_path, result.__dict__)