chore(v2): 从兴邦预审v2白名单抽取独立可运行基座

按 docs/v2/05-implementation-plan.md §1 候选迁移表与 claude_use/T00 白名单,
从 55e2cdb 抽取 224 个文件(2.48 MB)到独立目录 F:/mini-OpenClaw-v2。

- 排除:凭据(.env/server-deploy.conf/旧DELIVERY.md)、运行数据(xingbang_data 等
  4 个 SQLite)、依赖(venv/node_modules 符号链接)、归档(_backup*/test-evidence/
  ori_file)、Office 临时文件
- 目标目录独立 git 仓库,初始恢复点;Gitea 远程与分支待 OQ-06 Q2 定案
- 未部署、未启动服务、未调用模型

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 12:58:26 +08:00
co-authored by Claude Code
commit d56c699ce6
224 changed files with 33392 additions and 0 deletions
+83
View File
@@ -0,0 +1,83 @@
"""Reproducible offline acceptance and latency evidence in an isolated database."""
import argparse
import base64
import json
import os
import statistics
import sys
import tempfile
import time
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "backend"))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--output", type=Path, default=ROOT / "test-evidence" / ("assistant-acceptance-" + datetime.now().strftime("%Y%m%d-%H%M%S")))
args = parser.parse_args()
output = args.output.resolve()
if not output.is_relative_to(ROOT / "test-evidence"):
raise ValueError("验收输出必须位于当前仓库 test-evidence")
output.mkdir(parents=True, exist_ok=False)
os.environ.update(XINGBANG_LOCAL_MODEL_URL="", XINGBANG_AUTH_USERS="", XINGBANG_REQUIRE_AUTH="0")
from api import risk_agent_config as catalog
from services.project_assistant import ProjectAssistantService
from services.assistant_worker import DurableWorker
from services.assistant_store import listing
identity = {"id": "offline-acceptance", "role": "admin", "projects": ["*"], "preview": True}
records = []
with tempfile.TemporaryDirectory(dir=output, ignore_cleanup_errors=True) as tmp:
catalog.DB_PATH = Path(tmp) / "catalog.sqlite3"
service = ProjectAssistantService(Path(tmp) / "assistant.sqlite3")
worker = DurableWorker(service)
def run(project_id):
start = time.perf_counter()
queued = service.enqueue(project_id, identity, "核对现金流与来源")
queued_ms = (time.perf_counter() - start) * 1000
worker.process_next()
result = service.resource("run", queued["id"])
records.append({"project": project_id, "run_id": result["id"], "enqueue_ms": round(queued_ms, 2), "total_ms": round((time.perf_counter() - start) * 1000, 2), "status": result["status"], "summary": result["summary"]})
return result
first = run("DEMO-B")
assert first["summary"]["coverage"] == "1.0800" and first["summary"]["open"] == 4
project = service.resource("project", "DEMO-B")
service.supplement("DEMO-B", identity["id"], {"due_diligence_liabilities": "13200", "duplicate_asset_count": 0}, [], project["revision"], "离线验收补件")
second = run("DEMO-B")
assert len(second["change_summary"]["resolved"]) == 2
scenario = service.compare_scenario("DEMO-B", "4000", identity["id"])
assert scenario["scenario"]["coverage"] == "1.3500" and len(scenario["unresolved"]) == 1
feedback = service.feedback("DEMO-B", identity["id"], {"category": "rule_omission", "target_kind": "run", "target_id": second["id"], "note": "追加回款凭证检查"})
experience = service.save_experience("DEMO-B", identity["id"], {"title": "回款凭证检查", "trigger": "存在回款依赖", "action": "补充到账依据", "exceptions": "已有到账凭证", "scope": "team", "team": "demo", "feedback_id": feedback["id"], "conditions": {"collection.dependency": True}, "required_field": "collection.evidence", "exception_field": "collection.evidence", "expires_at": "2099-01-01T00:00:00+00:00"})
experience = service.experience_action("DEMO-B", experience["id"], identity, "evaluate", experience["revision"], "隔离正反例评测")
service.experience_action("DEMO-B", experience["id"], identity, "publish", experience["revision"], "本机模拟独立审核")
c = run("DEMO-C")
a = run("DEMO-A")
assert any(f["code"].startswith("experience.") for f in c["findings"])
assert not any(f["code"].startswith("experience.") for f in a["findings"])
reports = []
for template in ("template.due-diligence", "template.review", "template.committee"):
report = service.create_report("DEMO-B", second["id"], identity["id"], template)
target = output / (template + ".docx")
target.write_bytes(service.export_report("DEMO-B", report["id"]))
reports.append({"path": target.name, "template": template, "snapshot_id": report["snapshot_id"], "visual_qa": "pending-renderer"})
times = []
for _ in range(30):
start = time.perf_counter()
service.resource("run", second["id"])
times.append((time.perf_counter() - start) * 1000)
with service.connect() as db:
for document in listing(db, "document"):
(output / document["name"]).write_bytes(base64.b64decode(document["raw_base64"]))
summary = {"mode": "offline-deterministic-no-live-model", "records": records, "scenario": scenario,
"poll_ms_p95": round(sorted(times)[28], 2), "poll_ms_median": round(statistics.median(times), 2),
"reports": reports, "experience_positive_and_negative": "passed", "preserved_live_database": True}
(output / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"output": str(output), "mode": summary["mode"], "poll_ms_p95": summary["poll_ms_p95"], "records": records}, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+86
View File
@@ -0,0 +1,86 @@
"""Read-only migration audit by default; apply creates a consistent backup first.
Use backend/venv/Scripts/python.exe scripts/assistant_migrate.py --dry-run.
Restore produces a separate recovered database, never overwrites post-release writes.
"""
import argparse
import json
import sqlite3
import sys
from datetime import datetime
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "backend"))
def audit(path):
if not path.is_file():
return {"database": str(path), "exists": False, "action": "initialize-demo-instance", "dangling_references": []}
with sqlite3.connect(path.as_uri() + "?mode=ro", uri=True) as db:
tables = {r[0] for r in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
counts = {name: db.execute('SELECT count(*) FROM "' + name + '"').fetchone()[0] for name in tables if name in ("projects", "runs", "snapshots", "reports", "experiences", "assistant_objects", "assistant_jobs")}
dangling = []
if "assistant_objects" in tables:
objects = {(r[0], r[1]): json.loads(r[2]) for r in db.execute("SELECT kind,id,body FROM assistant_objects")}
for (kind, key), body in objects.items():
for field, target in (("snapshot_id", "snapshot"), ("input_snapshot_id", "input_snapshot"), ("run_id", "run"), ("document_ids", "document")):
if kind not in ("run", "report", "report_final", "project", "experience_application"):
continue
values = body.get(field)
for value in (values if isinstance(values, list) else [values]):
if value and (target, value) not in objects:
dangling.append({"kind": kind, "id": key, "field": field, "missing": value})
return {"database": str(path), "exists": True, "counts": counts, "integrity": db.execute("PRAGMA integrity_check").fetchone()[0], "dangling_references": dangling}
def backup(source, target):
if target.exists() or source == target:
raise ValueError("备份目标已存在或与源相同,拒绝覆盖")
target.parent.mkdir(parents=True, exist_ok=True)
original = sqlite3.connect(source.as_uri() + "?mode=ro", uri=True)
output = sqlite3.connect(target)
try:
original.backup(output)
finally:
output.close()
original.close()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--database", type=Path, default=ROOT / "backend/xingbang_data/project_assistant.sqlite3")
parser.add_argument("--dry-run", action="store_true")
parser.add_argument("--apply", action="store_true")
parser.add_argument("--restore-copy", type=Path, help="Output recovered DB alongside current DB; never replace live data")
args = parser.parse_args()
path = args.database.resolve()
if not path.is_relative_to(ROOT):
raise ValueError("迁移数据库须位于当前工作区")
before = audit(path)
if before.get("integrity", "ok") != "ok" or before["dangling_references"]:
print(json.dumps(before, ensure_ascii=False, indent=2))
return 1
if args.restore_copy:
target = args.restore_copy.resolve()
if not target.is_relative_to(ROOT) or target == path:
raise ValueError("恢复副本须为工作区内的新路径")
backup(path, target)
print(json.dumps({"restored_copy": str(target), "live_data_untouched": True, "audit": audit(target)}, ensure_ascii=False, indent=2))
return 0
if args.apply and not args.dry_run:
if path.exists():
target = ROOT / ("_backup_pre_assistant_migration_" + datetime.now().strftime("%Y%m%d_%H%M%S")) / path.name
backup(path, target)
before["backup"] = str(target)
from services.project_assistant import ProjectAssistantService
ProjectAssistantService(path)
result = {"before": before, "after": audit(path), "legacy_tables": "preserved-read-only"}
else:
result = {"mode": "dry-run", **before, "writes": 0}
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+38
View File
@@ -0,0 +1,38 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const assistant = read("src/components/xingbang/BusinessAssistant.tsx");
const message = read("src/components/chat/ChatMessage.tsx");
const coreProxy = read("src/app/api/core/[...path]/route.ts");
assert.match(
assistant,
/<section className="ba-workspace">/,
"业务助手应保留单一、直接的项目操作入口",
);
assert.doesNotMatch(
assistant,
/mini-openclaw|ChatPanel|AppProvider|nativeAccess|ba-mode-tabs/,
"业务人员页面不应继续展示或加载重复的 mini-openclaw 入口",
);
assert.match(
assistant,
/当前身份只会调用本项目授权的 Skills、规则和业务工具/,
"受限工作台应明确说明权限边界",
);
assert.match(
message,
/authentication\.\?fail[\s\S]*return "provider";[\s\S]*\/401\|unauthorized[\s\S]*return "identity";/,
"401 身份错误不能继续误报为模型 API Key 失败",
);
assert.match(
coreProxy,
/user\.role !== "admin"/,
"修复不得放开原版终端、文件和网页工具的管理员边界",
);
console.log("Cloud business-assistant single-entry checks passed");
+15
View File
@@ -0,0 +1,15 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const nav = fs.readFileSync(path.join(root, "src/components/xingbang/AgentNav.tsx"), "utf8");
const selector = fs.readFileSync(path.join(root, "src/components/xingbang/IdentitySelector.tsx"), "utf8");
assert.match(nav, /response\.status===401/, "导航应识别无效访问令牌");
assert.match(nav, /removeItem\("xingbang-access-token"\)/, "无效旧令牌应自动清理");
assert.match(nav, /setItem\("xingbang-demo-role","manager"\)/, "清理后应恢复到客户经理演示身份");
assert.match(nav, /pending=\{!identity\}/, "身份连接失败时仍应显示身份选择入口");
assert.match(selector, /pending&&<option value="" disabled>选择身份<\/option>/, "待连接状态应给出明确的身份选择提示");
console.log("Identity recovery behavior checks passed");
+21
View File
@@ -0,0 +1,21 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const component = fs.readFileSync(path.join(root, "src/components/xingbang/ReviewerWorkspace.tsx"), "utf8");
const page = fs.readFileSync(path.join(root, "src/app/xingbang-workbench/page.tsx"), "utf8");
assert.match(component, /aria-label=\{`\$\{topic\.title\}评审意见`\}/, "每个评审节点应提供独立意见输入框");
assert.match(component, /onChange=\{\(event\)=>setDraft\(event\.target\.value\)\}/, "输入框应允许修改节点意见");
assert.match(component, /draft\.trim\(\)\|\|"已复核本章节数据来源和AI检查结果"/, "正常节点应保存人工输入或明确的默认复核说明");
assert.match(component, /disabled=\{busy\|\|requiredOpinion\}/, "需要说明依据的评审动作不应允许空意见提交");
assert.match(component, /aria-label="退回补充材料原因"/, "退回操作旁应直接提供原因输入框");
assert.match(component, /disabled=\{busy\|\|!hasRun\|\|!returnReason\.trim\(\)\}/, "未填写退回原因时不应允许退回");
assert.match(component, /onComplete\("退回",returnReason\.trim\(\)\)/, "退回原因应随退回决定一并保存");
assert.match(page, /setReviewOpinion\]=useState\(""\)/, "常用模板默认不应冒充人工意见");
assert.match(page, /x\.workflow_state==="returned"/, "客户经理工作台应识别评审退回项目");
assert.match(page, /xb-project-card\$\{returned\?" is-returned":""\}/, "退回项目卡应使用醒目的独立样式");
assert.match(page, /x\.return_reason\|\|"请打开项目查看评审退回要求"/, "退回项目卡应直接展示退回原因");
console.log("Reviewer workspace input behavior checks passed");
+31
View File
@@ -0,0 +1,31 @@
const assert = require("node:assert/strict");
const fs = require("node:fs");
const path = require("node:path");
const root = path.resolve(__dirname, "..");
const read = (file) => fs.readFileSync(path.join(root, file), "utf8");
const globals = read("src/app/globals.css");
const navigation = read("src/components/xingbang/navigation.css");
const workspace = read("src/app/data-access/workspace.css");
const assistant = read("src/components/xingbang/business-assistant.css");
const assistantComponent = read("src/components/xingbang/BusinessAssistant.tsx");
const operations = read("src/app/operations-center/operations.css");
assert.match(globals, /font-size:\s*16px/, "全局正文应提供清晰的基础字号");
assert.match(globals, /:focus-visible/, "键盘操作应有明显焦点提示");
assert.match(navigation, /\.xb-agent-nav a\{min-height:42px[^}]*font-size:14px/, "主导航应扩大文字和点击区域");
assert.match(navigation, /\.xb-role-switch\{min-height:42px/, "身份选择器应保留足够大的点击区域");
assert.match(workspace, /\.ra-button\{min-height:42px[^}]*font-size:14px[^}]*font-weight:700/, "工作台按钮应清晰且易点击");
assert.match(workspace, /\.ra-button\.primary\{background:#17634c/, "主操作按钮应采用稳定的主色强调");
assert.match(workspace, /\.xb-review-final>\.ra-button:not\(\.primary\)\{border-color:#d7837b/, "退回操作应与批准操作清楚区分");
assert.match(workspace, /\.xb-panel>p,.xb-panel p,.xb-panel details\{font-size:14px/, "业务面板正文不应继续使用过小字号");
assert.match(assistant, /\.ba-messages article p\{font-size:15px/, "助手对话正文应易于阅读");
assert.match(assistant, /\.ba-messages article\.user\{width:100%;max-width:100%;margin-left:0\}/, "用户消息应占据整行以便贴右显示");
assert.match(assistant, /\.ba-messages article\.user>div\{width:100%;justify-content:flex-end/, "用户气泡内容应靠右对齐");
assert.match(assistant, /@media\(max-width:820px\)\{\.ba-messages article\.user p\{max-width:88%\}\}/, "移动端用户气泡应保留可读宽度");
assert.match(assistantComponent, /className="ba-user-icon" aria-label="本人"><UserRound/, "用户消息应显示清晰的人类图标");
assert.match(assistant, /\.ba-workspace aside button\{min-height:46px/, "助手快捷操作应有明显点击区域");
assert.match(operations, /\.td-hero-actions button,.td-hero-actions select\{height:46px/, "时效看板操作控件应易于辨认和点击");
console.log("Business UI readability checks passed");
+237
View File
@@ -0,0 +1,237 @@
#!/usr/bin/env python3
"""Run project verification and retain machine-readable test evidence."""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
import urllib.request
from datetime import datetime, timezone
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
BACKEND = ROOT / "backend"
def executable(name: str) -> str:
found = shutil.which(name)
if not found:
raise RuntimeError(f"Required executable is unavailable: {name}")
return found
def project_python() -> str:
candidates = [
BACKEND / "venv" / "Scripts" / "python.exe",
BACKEND / "venv" / "bin" / "python",
]
return str(next((p for p in candidates if p.exists()), Path(sys.executable)))
def git_output(*args: str) -> str:
result = subprocess.run(
[executable("git"), *args], cwd=ROOT, text=True,
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False,
)
return result.stdout.strip()
def changed_files(base: str | None) -> list[str]:
command = ["diff", "--name-only"]
if base:
command.append(f"{base}...HEAD")
files = set(filter(None, git_output(*command).splitlines()))
files.update(filter(None, git_output("ls-files", "--others", "--exclude-standard").splitlines()))
return sorted(files)
def make_plan(files: list[str], docker_runtime: bool) -> list[dict[str, str]]:
plan = [
{"case": "repository-diff", "reason": "Reject whitespace and patch formatting errors"},
{"case": "backend-unit", "reason": "Run all deterministic API and agent unit tests"},
{"case": "frontend-build", "reason": "Type-check and create the production Next.js bundle"},
{"case": "compose-config", "reason": "Validate local and Alibaba Cloud Compose manifests"},
]
if any(path.startswith("backend/") for path in files):
plan.append({"case": "backend-change-coverage", "reason": "Backend files changed; full backend suite is mandatory"})
if any(path.startswith("src/") for path in files):
plan.append({"case": "frontend-change-coverage", "reason": "Frontend files changed; production build is mandatory"})
if any("docker" in path.lower() or path.startswith("deploy/") for path in files):
plan.append({"case": "deployment-change-coverage", "reason": "Deployment files changed; both manifests are validated"})
if docker_runtime:
plan.append({"case": "docker-runtime-smoke", "reason": "Build, start and probe the real container"})
return plan
def run_case(name: str, command: list[str], cwd: Path, evidence_dir: Path) -> dict:
started = time.monotonic()
result = subprocess.run(
command, cwd=cwd, text=True, encoding="utf-8", errors="replace",
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False,
)
duration = round(time.monotonic() - started, 3)
log_path = evidence_dir / f"{name}.log"
log_path.write_text(result.stdout, encoding="utf-8")
print(f"[{name}] {'PASS' if result.returncode == 0 else 'FAIL'} ({duration}s)")
return {
"name": name,
"status": "passed" if result.returncode == 0 else "failed",
"exit_code": result.returncode,
"duration_seconds": duration,
"command": command,
"working_directory": str(cwd),
"log": log_path.name,
}
def wait_for(url: str, timeout: int = 150) -> None:
deadline = time.monotonic() + timeout
last_error = "not attempted"
while time.monotonic() < deadline:
try:
with urllib.request.urlopen(url, timeout=5) as response:
if 200 <= response.status < 400:
return
except Exception as exc: # evidence captures the eventual timeout
last_error = str(exc)
time.sleep(2)
raise RuntimeError(f"Timed out waiting for {url}: {last_error}")
def docker_smoke(docker: str, evidence_dir: Path, keep: bool) -> dict:
started = time.monotonic()
output: list[str] = []
status = "passed"
exit_code = 0
compose = [docker, "compose", "-f", str(ROOT / "docker-compose.yml")]
try:
for args in (["build"], ["up", "-d", "--wait"]):
result = subprocess.run(compose + args, cwd=ROOT, text=True, encoding="utf-8",
errors="replace", stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, check=False)
output.append(f"$ {' '.join(compose + args)}\n{result.stdout}")
if result.returncode:
raise RuntimeError(f"Docker Compose {' '.join(args)} failed with {result.returncode}")
wait_for("http://127.0.0.1:3200/")
wait_for("http://127.0.0.1:8002/api/xingbang/health")
output.append("HTTP smoke checks passed for frontend and backend")
except Exception as exc:
status, exit_code = "failed", 1
output.append(f"ERROR: {exc}")
finally:
ps = subprocess.run(compose + ["ps"], cwd=ROOT, text=True, encoding="utf-8",
errors="replace", stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, check=False)
output.append(f"$ {' '.join(compose + ['ps'])}\n{ps.stdout}")
if not keep:
down = subprocess.run(compose + ["down"], cwd=ROOT, text=True, encoding="utf-8",
errors="replace", stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, check=False)
output.append(f"$ {' '.join(compose + ['down'])}\n{down.stdout}")
log_path = evidence_dir / "docker-runtime-smoke.log"
log_path.write_text("\n\n".join(output), encoding="utf-8")
duration = round(time.monotonic() - started, 3)
print(f"[docker-runtime-smoke] {status.upper()} ({duration}s)")
return {"name": "docker-runtime-smoke", "status": status, "exit_code": exit_code,
"duration_seconds": duration, "command": compose, "working_directory": str(ROOT),
"log": log_path.name}
def write_report(evidence_dir: Path, summary: dict) -> None:
(evidence_dir / "summary.json").write_text(
json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8"
)
rows = "\n".join(
f"| {item['name']} | {item['status']} | {item['duration_seconds']}s | [{item['log']}]({item['log']}) |"
for item in summary["results"]
)
report = f"""# Verification Evidence
- Run ID: `{summary['run_id']}`
- Commit: `{summary['commit']}`
- Branch: `{summary['branch']}`
- Started: `{summary['started_at']}`
- Result: **{summary['status'].upper()}**
## Generated test plan
```json
{json.dumps(summary['test_plan'], ensure_ascii=False, indent=2)}
```
## Results
| Check | Result | Duration | Evidence |
| --- | --- | ---: | --- |
{rows}
## Changed files used to generate the plan
```text
{os.linesep.join(summary['changed_files']) or '(clean working tree)'}
```
"""
(evidence_dir / "REPORT.md").write_text(report, encoding="utf-8")
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("--base", help="Git base revision used to generate the change-aware test plan")
parser.add_argument("--docker", action="store_true", help="Build and smoke-test the local Docker deployment")
parser.add_argument("--keep-docker", action="store_true", help="Keep containers running after Docker smoke tests")
args = parser.parse_args()
run_id = datetime.now().strftime("%Y%m%d-%H%M%S")
evidence_dir = ROOT / "test-evidence" / f"{run_id}-{git_output('rev-parse', '--short', 'HEAD') or 'uncommitted'}"
evidence_dir.mkdir(parents=True, exist_ok=False)
files = changed_files(args.base)
summary = {
"run_id": run_id,
"started_at": datetime.now(timezone.utc).isoformat(),
"commit": git_output("rev-parse", "HEAD"),
"branch": git_output("branch", "--show-current"),
"changed_files": files,
"test_plan": make_plan(files, args.docker),
"results": [],
}
try:
git = executable("git")
npm = executable("npm")
docker = executable("docker")
checks = [
("repository-diff", [git, "diff", "--check"], ROOT),
("backend-unit", [project_python(), "-m", "unittest", "discover", "-s", ".", "-p", "test_*.py", "-v"], BACKEND),
("reviewer-workspace-ui", [executable("node"), "scripts/test_reviewer_workspace.cjs"], ROOT),
("identity-recovery-ui", [executable("node"), "scripts/test_identity_recovery.cjs"], ROOT),
("cloud-assistant-access-ui", [executable("node"), "scripts/test_cloud_assistant_access.cjs"], ROOT),
("ui-readability", [executable("node"), "scripts/test_ui_readability.cjs"], ROOT),
("frontend-build", [npm, "run", "build"], ROOT),
("compose-local-config", [docker, "compose", "-f", "docker-compose.yml", "config", "--quiet"], ROOT),
("compose-aliyun-config", [docker, "compose", "--env-file", "deploy/aliyun.env.example", "-f", "deploy/docker-compose.aliyun.yml", "config", "--quiet"], ROOT),
]
for name, command, cwd in checks:
summary["results"].append(run_case(name, command, cwd, evidence_dir))
if args.docker:
summary["results"].append(docker_smoke(docker, evidence_dir, args.keep_docker))
except Exception as exc:
error_log = evidence_dir / "runner-error.log"
error_log.write_text(str(exc), encoding="utf-8")
summary["results"].append({"name": "runner", "status": "failed", "exit_code": 1,
"duration_seconds": 0, "command": [], "working_directory": str(ROOT),
"log": error_log.name})
summary["status"] = "passed" if all(x["status"] == "passed" for x in summary["results"]) else "failed"
write_report(evidence_dir, summary)
print(f"Evidence: {evidence_dir / 'REPORT.md'}")
return 0 if summary["status"] == "passed" else 1
if __name__ == "__main__":
raise SystemExit(main())