Release v1.0.60: harden business logging policies (LOGGING-006/007/008)
All checks were successful
技能自动化发布 / release (push) Successful in 8s

This commit is contained in:
2026-07-25 11:23:32 +08:00
parent 79d21c063d
commit 8fa7ec2743
12 changed files with 330 additions and 44 deletions

View File

@@ -42,6 +42,10 @@
推荐配合根目录下的 **`tests/fixtures/sample_request.json`** 与 **`tests/fixtures/expected_response.json`**(复制技能后按域替换字段,保持脱敏)。
### 5. `test_logging_contract.py.sample`
**适合:** 长任务 / RPA 技能,需断言统一文件日志含 `task_start`、`task_failed|task_success`、`error_code=` 等事件词(见 [`development/LOGGING.md`](../../development/LOGGING.md) §3)。复制后用 `IsolatedDataRoot` + mock 域 RPA,**不要**依赖真实企微/浏览器。
---
## 如何启用 service contract 样例

View File

@@ -0,0 +1,56 @@
# -*- coding: utf-8 -*-
"""日志契约样例:复制为 tests/test_logging_contract.py 后按业务改入口。
验证主任务 mock/离线路径写入统一日志事件词(见 development/LOGGING.md §3)。
默认套件不收集本文件(.sample)。
"""
from __future__ import annotations
import io
import logging
import unittest
from contextlib import redirect_stderr, redirect_stdout
from unittest.mock import patch
# 复制到 tests/ 根目录后取消下一行注释,并按技能改 import
# from _support import IsolatedDataRoot
class TestLoggingContractSample(unittest.TestCase):
"""示范:捕获 skill logger,断言关键事件词出现。"""
def test_main_path_emits_task_start_and_terminal_event(self) -> None:
self.skipTest(
"复制本文件为 tests/test_logging_contract.py 后:"
"用 IsolatedDataRoot + patch 域 RPA,调用 cmd_run/cmd_add_one,"
"对 logging.Handler 断言消息含 task_start 与 task_success|task_failed"
)
# 示例骨架(复制后实现):
# with IsolatedDataRoot():
# log = logging.getLogger("openclaw.skill.your_skill_slug")
# records: list[str] = []
# class _H(logging.Handler):
# def emit(self, record):
# records.append(record.getMessage())
# h = _H()
# log.addHandler(h)
# try:
# with patch(...), redirect_stdout(io.StringIO()), redirect_stderr(io.StringIO()):
# rc = cmd_add_one(...) # 或 cmd_run
# finally:
# log.removeHandler(h)
# blob = "\n".join(records)
# self.assertIn("task_start", blob)
# self.assertTrue("task_failed" in blob or "task_success" in blob)
# if "task_failed" in blob:
# self.assertIn("error_code=", blob)
def test_failure_path_logs_error_code_before_print(self) -> None:
self.skipTest(
"复制后:注入业务错误(如 ELEMENT_NOT_FOUND),断言 logger 含 "
"task_failed error_code=...,且 stdout 含 ERROR:CODE"
)
if __name__ == "__main__":
unittest.main()

View File

@@ -46,6 +46,9 @@ POLICY_IDS = (
"POLICY-LOGGING-003",
"POLICY-LOGGING-004",
"POLICY-LOGGING-005",
"POLICY-LOGGING-006",
"POLICY-LOGGING-007",
"POLICY-LOGGING-008",
"POLICY-CONTROL-001",
"POLICY-CONTROL-002",
"POLICY-CONTROL-003",
@@ -201,6 +204,26 @@ LOGGING_002_SOURCE = "development/LOGGING.md; development/DEVELOPMENT.md"
LOGGING_003_SOURCE = "development/LOGGING.md"
LOGGING_004_SOURCE = "development/LOGGING.md; development/RPA.md"
LOGGING_005_SOURCE = "development/LOGGING.md; development/CONFIG.md"
LOGGING_006_SOURCE = "development/LOGGING.md §0、§3"
LOGGING_007_SOURCE = "development/LOGGING.md §3.1–§3.2"
LOGGING_008_SOURCE = "development/LOGGING.md §0 铁律 3"
LOGGING_006_EXEMPT_BASENAMES = frozenset(
{
"__init__.py",
"task_run_support.py",
"entitlement_service.py",
"sibling_bridge.py",
}
)
LOGGING_RPA_MARKERS = (
"RpaVideoSession",
"uiautomation",
"playwright",
"@rpa_step",
"rpa_step(",
)
LOGGING_CALL_PATTERN = re.compile(
r"\b(log|logger|get_skill_logger\s*\(\s*\))\s*\.\s*(info|warning|error|debug|exception|critical)\s*\(",
@@ -911,6 +934,121 @@ class TestPolicyLogging005(unittest.TestCase):
)
def _iter_service_py_files(skill_root: str) -> list[str]:
service_dir = os.path.join(skill_root, "scripts", "service")
if not os.path.isdir(service_dir):
return []
return list(_walk_files(skill_root, os.path.join("scripts", "service"), suffix=".py"))
def _service_layer_combined_text(skill_root: str) -> str:
parts: list[str] = []
for path in _iter_service_py_files(skill_root):
parts.append(_read_text(path))
return "\n".join(parts)
_LOGGING_DOMAIN_CALL_RE = re.compile(
r"\b(log|logger|_logger)\s*\.\s*(info|warning|error|exception)\s*\(",
re.IGNORECASE,
)
_PRINT_ERROR_RE = re.compile(r"\bprint\s*\(.*ERROR:", re.IGNORECASE | re.DOTALL)
_FUNC_DEF_RE = re.compile(r"(?m)^(async\s+)?def\s+\w+\s*\(")
def _scan_print_error_without_logger(skill_root: str, path: str, text: str) -> list[str]:
"""同一函数体内 print(...ERROR:) 须伴随 warning/error/exception 日志。"""
offenders: list[str] = []
matches = list(_FUNC_DEF_RE.finditer(text))
if not matches:
return offenders
for i, m in enumerate(matches):
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
body = text[start:end]
if not _PRINT_ERROR_RE.search(body):
continue
if re.search(
r"\b(log|logger|_logger)\s*\.\s*(warning|error|exception)\s*\(",
body,
re.IGNORECASE,
):
continue
lineno = text[:start].count("\n") + 1
offenders.append(
f"{_rel(skill_root, path)}:{lineno}: print(ERROR:) without log.warning|error|exception in same function"
)
return offenders
class TestPolicyLogging006(unittest.TestCase):
def test_service_domain_modules_have_file_logging(self) -> None:
skill_root = get_skill_root()
offenders: list[str] = []
for path in _iter_service_py_files(skill_root):
base = os.path.basename(path)
if base in LOGGING_006_EXEMPT_BASENAMES:
continue
text = _read_text(path)
if not _LOGGING_DOMAIN_CALL_RE.search(text):
offenders.append(_rel(skill_root, path))
self.assertEqual(
offenders,
[],
msg=_policy_msg(
"POLICY-LOGGING-006",
LOGGING_006_SOURCE,
"service domain modules missing log/logger/_logger.(info|warning|error|exception):\n"
+ "\n".join(offenders),
),
)
class TestPolicyLogging007(unittest.TestCase):
def test_rpa_service_layer_has_event_tokens(self) -> None:
skill_root = get_skill_root()
combined = _service_layer_combined_text(skill_root)
if not any(marker in combined for marker in LOGGING_RPA_MARKERS):
self.skipTest("no RPA markers in scripts/service; LOGGING-007 N/A")
missing: list[str] = []
if "task_start" not in combined:
missing.append("task_start")
if "task_failed" not in combined and "rpa_failed" not in combined:
missing.append("task_failed|rpa_failed")
if "stage=" not in combined:
missing.append("stage=")
# entitlement tokens strongly recommended; require at least one
if "entitlement_ok" not in combined and "entitlement_failed" not in combined:
missing.append("entitlement_ok|entitlement_failed")
self.assertEqual(
missing,
[],
msg=_policy_msg(
"POLICY-LOGGING-007",
LOGGING_007_SOURCE,
"RPA service layer missing event tokens: " + ", ".join(missing),
),
)
class TestPolicyLogging008(unittest.TestCase):
def test_print_error_paired_with_logger(self) -> None:
skill_root = get_skill_root()
offenders: list[str] = []
for path in _iter_service_py_files(skill_root):
text = _read_text(path)
offenders.extend(_scan_print_error_without_logger(skill_root, path, text))
self.assertEqual(
offenders,
[],
msg=_policy_msg(
"POLICY-LOGGING-008",
LOGGING_008_SOURCE,
"\n".join(offenders) or "print(ERROR:) must share function with log.warning|error|exception",
),
)
class TestPolicyControl001(unittest.TestCase):
def test_no_util_progress_module(self) -> None:
skill_root = get_skill_root()