#!/usr/bin/env python3 """按 manifest 选择并执行离线测试条目的最小 harness。 该入口只在明确选择条目或传入 ``--all-offline`` 时运行,不承担全仓默认门禁。 执行器自身不连接数据库、网络或模型;危险依赖只有在 ``--allow-requires`` 明确 放行后才会启动对应子进程。 """ from __future__ import annotations import argparse import ast import json import os import re import subprocess import sys import time from collections import Counter from pathlib import Path from typing import Any, Iterable, Optional, Sequence SCHEMA_VERSION = 1 DEFAULT_MANIFEST_PATH = Path("harness") / "manifests" / "test-inventory.json" BLOCKED_REQUIRES = frozenset({"postgresql", "network", "model", "credentials"}) ENTRY_STATUSES = frozenset( {"passed", "failed", "timeout", "blocked_dependency", "not_found"} ) OUTPUT_SUMMARY_LIMIT = 2000 DEFAULT_TIMEOUT_SECONDS = 60.0 _TEST_ASSET_SCAN_ROOTS = ( Path(".agent") / "skills", Path("tests"), Path("humanization") / "tests", Path("harness"), Path("dashboard"), ) _IGNORED_TEST_ASSET_PARTS = frozenset({".git", ".venv", "__pycache__"}) _NO_TESTS_OUTPUT_PATTERN = re.compile( r"(?im)^\s*(?:" r"no\s+tests?(?:\s+(?:were\s+)?(?:found|collected|run|ran|to\s+run))?" r"|collected\s+0\s+items?" r"|ran\s+0\s+tests?" r"|0\s+tests?\s+ran" r")\b[^\n]*$" ) _SKIP_ONLY_LINE_PATTERN = re.compile( r"(?i)^\s*(?:(?:\d+\s+)?skipped\b.*|" r"skip(?:ped)?\s*(?:[:-]\s*.*)?)\s*$" ) _UNITTEST_SKIP_SUMMARY_PATTERN = re.compile( r"(?im)^\s*OK\s*\(\s*skipped\s*=\s*(\d+)\s*\)\s*$" ) _UNITTEST_RUN_PATTERN = re.compile(r"(?i)\bRan\s+(\d+)\s+tests?\b") _EXECUTION_RESULT_PATTERN = re.compile( r"(?i)\b(?:\d+\s+(?:passed|failed|errors?|xfailed|xpassed)|" r"(?:ran|collected)\s+[1-9]\d*)\b" ) def _resolve_root(root: str | Path) -> Path: path = Path(root).expanduser() if not path.is_absolute(): path = Path.cwd() / path return path.resolve() def _resolve_manifest_path(root: Path, manifest: str | Path | None) -> Path: path = DEFAULT_MANIFEST_PATH if manifest is None else Path(manifest).expanduser() if not path.is_absolute(): path = root / path return path.resolve() def _relative_path(path: Path, root: Path) -> str: try: return path.resolve().relative_to(root).as_posix() except ValueError: return path.resolve().as_posix() def _issue(code: str, message: str, **details: Any) -> dict[str, Any]: issue: dict[str, Any] = {"code": code, "message": message} issue.update(details) return issue def _normalise_manifest_entry_path(root: Path, value: str) -> tuple[Optional[str], Optional[str]]: """校验并规范 manifest 中的相对路径。""" declared = Path(value) if declared.is_absolute(): return None, "manifest_path_not_relative" try: candidate = (root / declared).resolve() relative = candidate.relative_to(root).as_posix() except (OSError, RuntimeError, ValueError): return None, "manifest_path_outside_root" if not relative or relative == ".": return None, "manifest_path_empty" return relative, None def _normalise_selector_path(root: Path, value: str) -> str: """把路径选择器转换为与 manifest 相同的 POSIX 相对表示。""" declared = Path(value).expanduser() if declared.is_absolute(): try: return declared.resolve().relative_to(root).as_posix() except (OSError, RuntimeError, ValueError): return declared.resolve().as_posix() try: return (root / declared).resolve().relative_to(root).as_posix() except (OSError, RuntimeError, ValueError): return declared.as_posix() def _string_values(values: Iterable[str] | None) -> list[str]: if values is None: return [] return [str(value).strip() for value in values if str(value).strip()] def _is_named_python_test(path: Path) -> bool: return path.suffix.casefold() == ".py" and ( path.name.startswith("test_") or path.name.endswith("_test.py") ) def _is_test_asset_path(relative: str) -> bool: path = Path(relative) parts = path.parts if not parts or any(part in _IGNORED_TEST_ASSET_PARTS for part in parts): return False if path.name == "__init__.py" or path.suffix.casefold() == ".pyc": return False if parts[:2] == (".agent", "skills"): return len(parts) >= 3 and path.suffix.casefold() == ".py" and ( path.name.startswith("test_") or path.name.endswith("_test.py") ) if parts[0] == "tests": return len(parts) > 1 and _is_named_python_test(path) if parts[:2] == ("humanization", "tests"): return len(parts) > 2 and _is_named_python_test(path) if parts[0] in {"harness", "dashboard"}: return len(parts) > 1 and path.suffix.casefold() == ".py" and path.name.startswith( "test_" ) return False def _scan_test_assets(root: Path) -> tuple[set[str], list[dict[str, Any]]]: """扫描 generated_scope 约定的测试资产,失败时保留明确的扫描问题。""" assets: set[str] = set() issues: list[dict[str, Any]] = [] def onerror(error: OSError) -> None: error_path = getattr(error, "filename", None) path = Path(str(error_path)) if error_path else root issues.append( _issue( "test_asset_scan_failed", "无法完整扫描测试资产目录", path=_relative_path(path, root), error=str(error), ) ) for relative_root in _TEST_ASSET_SCAN_ROOTS: scan_root = root / relative_root try: if not scan_root.exists(): continue if not scan_root.is_dir(): issues.append( _issue( "test_asset_scan_root_invalid", "测试资产扫描根路径不是目录", path=_relative_path(scan_root, root), ) ) continue except OSError as exc: issues.append( _issue( "test_asset_scan_failed", "无法检查测试资产扫描根目录", path=_relative_path(scan_root, root), error=str(exc), ) ) continue for current, directory_names, file_names in os.walk( scan_root, topdown=True, followlinks=False, onerror=onerror, ): directory_names[:] = sorted( name for name in directory_names if name not in _IGNORED_TEST_ASSET_PARTS ) for file_name in sorted(file_names): candidate = Path(current) / file_name if candidate.is_symlink(): continue try: if not candidate.is_file(): continue relative = candidate.relative_to(root).as_posix() except (OSError, ValueError) as exc: issues.append( _issue( "test_asset_scan_failed", "无法读取测试资产路径", path=_relative_path(candidate, root), error=str(exc), ) ) continue if _is_test_asset_path(relative): assets.add(relative) return assets, issues def _load_manifest( root: Path, manifest_path: Path ) -> tuple[Optional[list[dict[str, Any]]], dict[str, Any], list[dict[str, Any]]]: """读取并校验执行所需的 manifest 字段,任何合同错误都关闭执行。""" metadata: dict[str, Any] = { "path": _relative_path(manifest_path, root), "loaded": False, "generated_scope_present": False, } issues: list[dict[str, Any]] = [] if not manifest_path.exists(): issues.append( _issue("manifest_missing", "测试 manifest 不存在", path=metadata["path"]) ) return None, metadata, issues if not manifest_path.is_file(): issues.append( _issue("manifest_not_file", "测试 manifest 路径不是普通文件", path=metadata["path"]) ) return None, metadata, issues try: raw = json.loads(manifest_path.read_text(encoding="utf-8")) except UnicodeError as exc: issues.append( _issue( "manifest_unreadable", "测试 manifest 不是合法 UTF-8 文本", path=metadata["path"], error=str(exc), ) ) return None, metadata, issues except OSError as exc: issues.append( _issue( "manifest_unreadable", "无法读取测试 manifest", path=metadata["path"], error=str(exc), ) ) return None, metadata, issues except json.JSONDecodeError as exc: issues.append( _issue( "manifest_invalid_json", "测试 manifest 不是合法 JSON", path=metadata["path"], line=exc.lineno, column=exc.colno, error=exc.msg, ) ) return None, metadata, issues metadata["loaded"] = True if not isinstance(raw, dict): issues.append( _issue( "manifest_invalid_structure", "测试 manifest 顶层必须是对象", path=metadata["path"], ) ) return None, metadata, issues metadata["generated_scope_present"] = "generated_scope" in raw if metadata["generated_scope_present"]: generated_scope = raw["generated_scope"] if not isinstance(generated_scope, str) or not generated_scope.strip(): issues.append( _issue( "manifest_generated_scope_invalid", "测试 manifest 的 generated_scope 必须是非空字符串", path=metadata["path"], actual=generated_scope, ) ) else: metadata["generated_scope"] = generated_scope.strip() if "schema_version" in raw and raw["schema_version"] != SCHEMA_VERSION: issues.append( _issue( "manifest_schema_version_invalid", "测试 manifest 的 schema_version 不受支持", path=metadata["path"], expected=SCHEMA_VERSION, actual=raw["schema_version"], ) ) raw_entries = raw.get("entries") if not isinstance(raw_entries, list): issues.append( _issue( "manifest_entries_invalid", "测试 manifest.entries 必须是数组", path=metadata["path"], ) ) return None, metadata, issues entries: list[dict[str, Any]] = [] seen_paths: dict[str, int] = {} required_string_fields = ("scope", "owner_skill_or_domain", "kind") for index, raw_entry in enumerate(raw_entries): if not isinstance(raw_entry, dict): issues.append( _issue( "manifest_entry_invalid", "测试 manifest 条目必须是对象", path=metadata["path"], entry_index=index, ) ) continue declared_path = raw_entry.get("path") if not isinstance(declared_path, str) or not declared_path.strip(): issues.append( _issue( "manifest_entry_path_invalid", "测试 manifest 条目的 path 必须是非空字符串", path=metadata["path"], entry_index=index, actual=declared_path, ) ) continue normalised_path, path_error = _normalise_manifest_entry_path(root, declared_path) if path_error is not None or normalised_path is None: issues.append( _issue( path_error or "manifest_entry_path_invalid", "测试 manifest 条目的 path 必须位于项目根目录内且为相对路径", path=metadata["path"], entry_index=index, actual=declared_path, ) ) continue if normalised_path in seen_paths: issues.append( _issue( "manifest_duplicate_path", "测试 manifest 重复登记同一个 path", path=normalised_path, entry_indexes=[seen_paths[normalised_path], index], ) ) else: seen_paths[normalised_path] = index entry: dict[str, Any] = {"path": normalised_path} for field in required_string_fields: value = raw_entry.get(field) if not isinstance(value, str) or not value.strip(): issues.append( _issue( f"manifest_entry_{field}_invalid", f"测试 manifest 条目的 {field} 必须是非空字符串", path=metadata["path"], entry_index=index, actual=value, ) ) else: entry[field] = value.strip() requires = raw_entry.get("requires") if not isinstance(requires, list) or any( not isinstance(requirement, str) or not requirement.strip() for requirement in requires ): issues.append( _issue( "manifest_entry_requires_invalid", "测试 manifest 条目的 requires 必须是字符串数组", path=metadata["path"], entry_index=index, actual=requires, ) ) else: entry["requires"] = [requirement.strip() for requirement in requires] behavior_eval = raw_entry.get("skill_behavior_eval") if behavior_eval is not None and not isinstance(behavior_eval, bool): issues.append( _issue( "manifest_entry_skill_behavior_eval_invalid", "测试 manifest 条目的 skill_behavior_eval 必须是布尔值", path=metadata["path"], entry_index=index, actual=behavior_eval, ) ) elif behavior_eval: entry["skill_behavior_eval"] = True if all(field in entry for field in (*required_string_fields, "requires")): entries.append(entry) if issues: return None, metadata, issues return entries, metadata, [] def _reconcile_test_assets( root: Path, entries: Sequence[dict[str, Any]], manifest_metadata: dict[str, Any], ) -> list[dict[str, Any]]: """对 generated_scope 清单做磁盘路径与登记路径的一一对账。 行为评测入口(skill_behavior_eval=true)不是测试资产:不参与双向等值, 但登记文件必须存在,避免清单指向空气。 """ if not manifest_metadata.get("generated_scope_present"): return [] disk_assets, issues = _scan_test_assets(root) test_entries = [entry for entry in entries if not entry.get("skill_behavior_eval")] declared_assets = {entry["path"] for entry in test_entries} manifest_metadata["test_assets_scanned"] = len(disk_assets) for entry in entries: if not entry.get("skill_behavior_eval"): continue candidate = root / Path(entry["path"]) try: exists = candidate.is_file() except OSError: exists = False if not exists: issues.append( _issue( "manifest_eval_entry_missing", "行为评测入口登记文件不存在", path=entry["path"], ) ) for path in sorted(disk_assets - declared_assets): issues.append( _issue( "manifest_test_asset_missing", "磁盘测试资产未在 manifest 中登记", path=path, ) ) # 登记条目不要求都是 test_* 形状(如 skill_behavior_eval 入口),但必须真实存在; # 磁盘侧反孤儿不变量仍由上面的 missing 检查承担。 for path in sorted(declared_assets - disk_assets): candidate = root / path try: exists = candidate.is_file() except OSError: exists = False if not exists: issues.append( _issue( "manifest_test_asset_extra", "manifest 登记了磁盘中不存在的测试资产", path=path, ) ) return issues def _matches( entry: dict[str, Any], *, paths: Sequence[str], owners: Sequence[str], kinds: Sequence[str], scopes: Sequence[str], ) -> bool: return ( (not paths or entry["path"] in paths) and (not owners or entry["owner_skill_or_domain"] in owners) and (not kinds or entry["kind"] in kinds) and (not scopes or entry["scope"] in scopes) ) def _blocked_requires(requires: Sequence[str], allow_requires: set[str]) -> list[str]: return [ requirement for requirement in requires if requirement.casefold() in BLOCKED_REQUIRES and requirement.casefold() not in allow_requires ] def _coerce_output(value: Any) -> str: if value is None: return "" if isinstance(value, bytes): return value.decode("utf-8", errors="replace") return str(value) def _output_summary(value: Any, limit: int = OUTPUT_SUMMARY_LIMIT) -> str: """只保留有限摘要,避免把子进程 raw 输出写入报告。""" text = _coerce_output(value).strip() if len(text) <= limit: return text marker = "\n...[output summary truncated]...\n" available = max(0, limit - len(marker)) head = available // 2 tail = available - head return text[:head] + marker + text[-tail:] def _is_no_execution_evidence(stdout: Any, stderr: Any) -> bool: """判断 0 返回码是否只有空输出或明确的跳过/无测试结果。""" text = "\n".join( value for value in (_coerce_output(stdout), _coerce_output(stderr)) if value ).strip() if not text: return True if _NO_TESTS_OUTPUT_PATTERN.search(text): return _EXECUTION_RESULT_PATTERN.search(text) is None summary_match = _UNITTEST_SKIP_SUMMARY_PATTERN.search(text) if summary_match: run_match = _UNITTEST_RUN_PATTERN.search(text) if run_match is None: return True return int(run_match.group(1)) == int(summary_match.group(1)) lines = [line.strip() for line in text.splitlines() if line.strip()] return bool(lines) and all(_SKIP_ONLY_LINE_PATTERN.match(line) for line in lines) def _dotted_name(node: ast.AST) -> Optional[str]: if isinstance(node, ast.Name): return node.id if isinstance(node, ast.Attribute): parent = _dotted_name(node.value) return f"{parent}.{node.attr}" if parent else None return None def _test_case_aliases(tree: ast.AST) -> tuple[set[str], set[str]]: unittest_names = {"unittest"} test_case_names = {"TestCase"} for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: if alias.name == "unittest": unittest_names.add(alias.asname or alias.name) elif isinstance(node, ast.ImportFrom) and node.module: if node.module == "unittest" or node.module.startswith("unittest."): for alias in node.names: if alias.name == "TestCase": test_case_names.add(alias.asname or alias.name) return unittest_names, test_case_names def _is_test_case_class( node: ast.ClassDef, unittest_names: set[str], test_case_names: set[str], ) -> bool: for base in node.bases: if isinstance(base, ast.Name) and base.id in test_case_names: return True dotted = _dotted_name(base) if dotted and dotted.endswith(".TestCase"): module_name = dotted[: -len(".TestCase")] if module_name in unittest_names or any( module_name.startswith(f"{name}.") for name in unittest_names ): return True return False def _is_main_guard(test: ast.AST) -> bool: if not isinstance(test, ast.Compare): return False values = [test.left, *test.comparators] for left, right in zip(values, values[1:]): if ( isinstance(left, ast.Name) and left.id == "__name__" and isinstance(right, ast.Constant) and right.value == "__main__" ) or ( isinstance(right, ast.Name) and right.id == "__name__" and isinstance(left, ast.Constant) and left.value == "__main__" ): return True return False def _test_shape_error(candidate: Path, root: Path) -> Optional[dict[str, Any]]: try: source = candidate.read_text(encoding="utf-8") except (OSError, UnicodeError) as exc: return { "code": "test_shape_unreadable", "message": "无法读取 Python 测试文件进行形状检查", "path": _relative_path(candidate, root), "error": str(exc), } try: tree = ast.parse(source, filename=str(candidate)) except SyntaxError as exc: return { "code": "test_shape_invalid", "message": "Python 测试文件无法通过 AST 解析", "path": _relative_path(candidate, root), "line": exc.lineno, "column": exc.offset, "error": exc.msg, } has_test_function = any( isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and node.name.startswith("test_") for node in ast.walk(tree) ) unittest_names, test_case_names = _test_case_aliases(tree) has_test_case = any( isinstance(node, ast.ClassDef) and _is_test_case_class(node, unittest_names, test_case_names) for node in ast.walk(tree) ) has_main_entry = any( isinstance(node, ast.If) and _is_main_guard(node.test) for node in ast.walk(tree) ) if has_test_function or has_test_case or has_main_entry: return None return { "code": "test_shape_missing", "message": ( "Python 测试文件必须包含 test_ 函数、unittest.TestCase 测试类" "或 __main__ 入口" ), "path": _relative_path(candidate, root), } def _entry_base(entry: dict[str, Any]) -> dict[str, Any]: return { "path": entry["path"], "status": "failed", "returncode": None, "duration_ms": 0, "requires": list(entry["requires"]), "stdout": "", "stderr": "", } def _run_entry( root: Path, python_executable: Path, entry: dict[str, Any], *, timeout_seconds: float, allow_requires: set[str], enforce_test_shape: bool = False, ) -> dict[str, Any]: result = _entry_base(entry) candidate = root / Path(entry["path"]) try: path_exists = candidate.is_file() except OSError as exc: path_exists = False result["error"] = {"code": "path_unreadable", "message": str(exc)} if not path_exists: result["status"] = "not_found" result.setdefault( "error", {"code": "path_not_found", "message": "测试路径不存在或不是普通文件"}, ) return result if enforce_test_shape and candidate.suffix.casefold() == ".py": shape_error = _test_shape_error(candidate, root) if shape_error is not None: result["error"] = shape_error return result blocked = _blocked_requires(entry["requires"], allow_requires) if blocked: result["status"] = "blocked_dependency" result["blocked_requires"] = blocked return result if not python_executable.is_file(): result["error"] = { "code": "python_executable_not_found", "message": "项目 .venv/bin/python 不存在", } return result if not os.access(python_executable, os.X_OK): result["error"] = { "code": "python_executable_not_executable", "message": "项目 .venv/bin/python 不可执行", } return result started = time.monotonic() try: completed = subprocess.run( [str(python_executable), entry["path"]], cwd=str(root), stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, encoding="utf-8", errors="replace", timeout=timeout_seconds, check=False, ) except subprocess.TimeoutExpired as exc: result["status"] = "timeout" result["duration_ms"] = max(0, round((time.monotonic() - started) * 1000)) result["stdout"] = _output_summary(exc.stdout) result["stderr"] = _output_summary(exc.stderr) result["error"] = { "code": "timeout", "message": f"子进程超过 {timeout_seconds:g} 秒限制", } return result except OSError as exc: result["duration_ms"] = max(0, round((time.monotonic() - started) * 1000)) result["stdout"] = _output_summary(getattr(exc, "stdout", "")) result["stderr"] = _output_summary(getattr(exc, "stderr", "")) result["error"] = {"code": "subprocess_start_failed", "message": str(exc)} return result result["returncode"] = completed.returncode result["duration_ms"] = max(0, round((time.monotonic() - started) * 1000)) result["stdout"] = _output_summary(completed.stdout) result["stderr"] = _output_summary(completed.stderr) if completed.returncode == 0 and _is_no_execution_evidence( completed.stdout, completed.stderr ): result["status"] = "failed" result["error"] = { "code": "no_execution_evidence", "message": "子进程返回 0,但没有发现测试执行证据", } else: result["status"] = "passed" if completed.returncode == 0 else "failed" if result["status"] == "failed": result["error"] = { "code": "nonzero_returncode", "message": f"子进程返回码为 {completed.returncode}", } return result def _selectors_present( paths: Sequence[str], owners: Sequence[str], kinds: Sequence[str], scopes: Sequence[str] ) -> bool: return bool(paths or owners or kinds or scopes) def _base_report( root: Path, manifest_path: Path, *, paths: Sequence[str], owners: Sequence[str], kinds: Sequence[str], scopes: Sequence[str], all_offline: bool, offline_only: bool, allow_requires: Sequence[str], timeout_seconds: float, ) -> dict[str, Any]: return { "schema_version": SCHEMA_VERSION, "status": "failed", "root": str(root), "manifest": {"path": _relative_path(manifest_path, root), "loaded": False}, "selectors": { "path": list(paths), "owner": list(owners), "kind": list(kinds), "scope": list(scopes), "all_offline": all_offline, }, "offline_only": offline_only, "allow_requires": list(allow_requires), "timeout_seconds": timeout_seconds, "selected_count": 0, "entries": [], "issues": [], "summary": {"selected": 0, "by_status": {}}, } def run_selected( root: str | Path = ".", manifest: str | Path | None = None, *, paths: Sequence[str] | None = None, owners: Sequence[str] | None = None, skills: Sequence[str] | None = None, kinds: Sequence[str] | None = None, scopes: Sequence[str] | None = None, all_offline: bool = False, offline_only: bool = True, allow_requires: Sequence[str] | None = None, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, ) -> dict[str, Any]: """执行 manifest 中被选择的条目并返回 JSON 可序列化报告。""" root_path = _resolve_root(root) manifest_path = _resolve_manifest_path(root_path, manifest) path_values = [_normalise_selector_path(root_path, value) for value in _string_values(paths)] owner_values = _string_values(owners) + _string_values(skills) kind_values = _string_values(kinds) scope_values = _string_values(scopes) allow_values = _string_values(allow_requires) allow_set = {value.casefold() for value in allow_values} report = _base_report( root_path, manifest_path, paths=path_values, owners=owner_values, kinds=kind_values, scopes=scope_values, all_offline=all_offline, offline_only=offline_only, allow_requires=allow_values, timeout_seconds=timeout_seconds, ) if not _selectors_present(path_values, owner_values, kind_values, scope_values) and not all_offline: report["status"] = "invalid_selector" report["issues"] = [ _issue( "selector_required", "至少需要 --path、--owner/--skill、--kind 或 --scope,或显式传入 --all-offline", ) ] return report if not root_path.exists(): report["status"] = "root_not_found" report["issues"] = [_issue("root_missing", "项目根目录不存在", path=".")] return report if not root_path.is_dir(): report["status"] = "root_invalid" report["issues"] = [_issue("root_not_directory", "项目根路径不是目录", path=".")] return report entries, manifest_metadata, manifest_issues = _load_manifest(root_path, manifest_path) report["manifest"] = manifest_metadata if manifest_issues or entries is None: report["status"] = "manifest_invalid" report["issues"] = manifest_issues return report reconciliation_issues = _reconcile_test_assets( root_path, entries, manifest_metadata, ) if reconciliation_issues: report["status"] = "manifest_invalid" report["issues"] = reconciliation_issues return report has_selectors = _selectors_present(path_values, owner_values, kind_values, scope_values) if all_offline and not has_selectors: selected = list(entries) else: selected = [ entry for entry in entries if _matches( entry, paths=path_values, owners=owner_values, kinds=kind_values, scopes=scope_values, ) ] if not selected: report["status"] = "no_matches" report["issues"] = [ _issue( "no_matches", "没有条目匹配当前选择器", selectors=report["selectors"], ) ] return report python_executable = root_path / ".venv" / "bin" / "python" results = [ _run_entry( root_path, python_executable, entry, timeout_seconds=timeout_seconds, allow_requires=allow_set, enforce_test_shape=bool(manifest_metadata.get("generated_scope_present")), ) for entry in selected ] counts = Counter(result["status"] for result in results) report["entries"] = results report["selected_count"] = len(results) report["summary"] = { "selected": len(results), "by_status": {status: counts[status] for status in sorted(counts)}, } report["status"] = "passed" if all(result["status"] == "passed" for result in results) else "failed" return report def human_summary(report: dict[str, Any]) -> str: """输出不包含完整子进程 raw 的人读摘要。""" lines = [ f"选择性测试:{report['status']}", f"项目根:{report['root']}", f"选择条目:{report['selected_count']}", ] for entry in report["entries"]: lines.append( f"- {entry['path']} [{entry['status']}] " f"returncode={entry['returncode']} duration_ms={entry['duration_ms']}" ) for issue in report["issues"]: lines.append(f"- issue [{issue['code']}] {issue['message']}") return "\n".join(lines) def _positive_timeout(value: str) -> float: try: parsed = float(value) except ValueError as exc: raise argparse.ArgumentTypeError("--timeout-seconds 必须是数字") from exc if parsed <= 0: raise argparse.ArgumentTypeError("--timeout-seconds 必须大于 0") return parsed def _build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="按 manifest 选择性执行项目测试。") parser.add_argument("--root", default=".", help="项目根目录,默认为当前目录") parser.add_argument( "--manifest", default=None, help="manifest 路径;相对路径按项目根目录解析,默认 harness/manifests/test-inventory.json", ) parser.add_argument("--path", dest="paths", action="append", default=[], help="按相对路径选择条目,可重复") parser.add_argument( "--owner", "--skill", dest="owners", action="append", default=[], help="按 owner_skill_or_domain 选择条目,可使用 --skill 作为别名", ) parser.add_argument("--kind", dest="kinds", action="append", default=[], help="按 kind 选择条目,可重复") parser.add_argument("--scope", dest="scopes", action="append", default=[], help="按 scope 选择条目,可重复") parser.add_argument( "--all-offline", action="store_true", help="没有其它 selector 时选择 manifest 全部条目;危险依赖仍逐条报告为 blocked_dependency", ) parser.add_argument( "--offline-only", action="store_true", default=True, help="启用离线依赖门;默认开启,危险依赖须配合 --allow-requires", ) parser.add_argument( "--allow-requires", dest="allow_requires", action="append", default=[], help="显式放行依赖名,可重复;例如 --allow-requires postgresql", ) parser.add_argument( "--timeout-seconds", type=_positive_timeout, default=DEFAULT_TIMEOUT_SECONDS, help="每个子进程的超时秒数,默认 60", ) parser.add_argument("--json", action="store_true", help="输出结构化 JSON 报告") return parser def _exit_code(report: dict[str, Any]) -> int: return 0 if report["status"] == "passed" else 1 def main(argv: Optional[Sequence[str]] = None) -> int: args = _build_parser().parse_args(argv) report = run_selected( args.root, args.manifest, paths=args.paths, owners=args.owners, kinds=args.kinds, scopes=args.scopes, all_offline=args.all_offline, offline_only=args.offline_only, allow_requires=args.allow_requires, timeout_seconds=args.timeout_seconds, ) if args.json: print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)) else: print(human_summary(report)) return _exit_code(report) if __name__ == "__main__": sys.exit(main())