diff --git a/CHANGELOG.md b/CHANGELOG.md index d469c44..312eed3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,13 @@ - 小节正文写用户能看懂的变化(新能力、修复、注意事项),避免堆砌内部实现细节 - 打 tag 前**必须**为即将发布的版本新增一节;找不到匹配小节时,本次发布不会更新 changelog 字段 +## 1.0.60 + +- 强化日志规范:验收铁律、固定事件词表、失败须先写文件日志再 print +- 新增 hard 门禁 POLICY-LOGGING-006/007/008(域模块有日志、RPA 事件词、ERROR 与 logger 共现) +- 模板金样 `task_service` 示范 `entitlement_ok`、`rpa_start`/`stage=`、`task_failed error_code=` +- 增加 `tests/samples/test_logging_contract.py.sample` 与交活 Checklist 日志 P0 项 + ## 1.0.59 - 新增浏览器、Windows 桌面、Android/iOS 三端元素拾取与定位标准,要求先真实拾取、填写 element map、验证唯一性和业务后置状态 diff --git a/SKILL.md b/SKILL.md index 4336914..c9921e8 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.59 +version: 1.0.60 author: 深圳匠厂科技有限公司 metadata: openclaw: diff --git a/development/CHECKLIST.md b/development/CHECKLIST.md index 7afe19c..f799b97 100644 --- a/development/CHECKLIST.md +++ b/development/CHECKLIST.md @@ -129,9 +129,11 @@ checklist_version: 1.0 ## 7. 可观测与安全观感(所有技能) - [ ] A7-1 **P0** 失败提示或任务结论不泄露密码、完整 Cookie、明文 token 等敏感信息 -- [ ] A7-2 **P1** 关键步骤有据可查(能对应到开始/关键节点/结束) -- [ ] A7-3 **P1** 若宣称支持录屏或截图存证:打开开关后关键步骤或失败能留下可查看产物;宣称支持却完全无产物 → **FAIL** -- [ ] A7-4 **P2** 用户可见文案偏业务语言,而非大段开发黑话 +- [ ] A7-2 **P0** 统一文件日志(运行日志 / `jiangchang.log`)能定位失败阶段:可见 `task_start` 与 `task_failed|task_success`;失败含 `error_code=`;**不只** `cli_start` +- [ ] A7-3 **P0** 故意失败一条时,文件日志先有 `task_failed`/`warning`,再有 `ERROR:CODE` 或任务中心失败态 +- [ ] A7-4 **P1** 关键步骤有据可查(能对应到开始/关键节点/结束;RPA 宜有多个 `stage=`) +- [ ] A7-5 **P1** 若宣称支持录屏或截图存证:打开开关后关键步骤或失败能留下可查看产物;宣称支持却完全无产物 → **FAIL** +- [ ] A7-6 **P2** 用户可见文案偏业务语言,而非大段开发黑话 --- diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index d71ddbc..54abf37 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -885,7 +885,9 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea - [ ] `release.ps1` 存在(发布前会自动跑 `python tests/run_tests.py -v`;失败不得打 tag) - [ ] `.github/workflows/release_skill.yaml` 存在 - [ ] `python tests/run_tests.py -v` 全部通过(含 `test_development_policy_guard.py`) -- [ ] 主任务入口(如 `cmd_run`)有关键日志:`task_start`、`task_log_saved`;失败路径使用 `logger.exception` +- [ ] 主任务入口有关键日志事件词:`task_start`、`task_log_saved`;失败路径 `task_failed error_code=` + `logger.exception`/`warning`(见 [`LOGGING.md`](LOGGING.md) §0、§3) +- [ ] RPA / 域模块(`scripts/service//` 等)非零文件日志;`print(ERROR:)` 旁有 logger(`POLICY-LOGGING-006`~`008`) +- [ ] 本地跑一次主任务后,统一日志能 grep 到 §3.4 最低可验收串(不只 `cli_start`) - [ ] 日志不泄露 password / token / cookie 等敏感明文(见 [`LOGGING.md`](LOGGING.md)) - [ ] 新增 hard 规范已写入 `development/POLICY_MATRIX.md` 并有对应自动检测 - [ ] `scripts/**/*.py` 单文件 **< 1000 行**(PyArmor 试用/免费模式限制;推荐 < 900 行) diff --git a/development/LOGGING.md b/development/LOGGING.md index 0cea01d..bdb2d6c 100644 --- a/development/LOGGING.md +++ b/development/LOGGING.md @@ -1,6 +1,26 @@ # 日志规范 -本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文,并在 code review 时对照「必打日志节点清单」人工检查。 +本文件是 skill 日志规范的**权威入口**,面向技术人员与 AI 编程代理。涉及长任务、RPA、外部系统对接的技能,**必须**在实现前通读本文。 + +自动 hard policy(`POLICY-LOGGING-001`~`008`)只保证底线;阶段是否够细仍须对照本文事件词表做 review。复制技能后可用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample) 做契约断言。 + +--- + +## 0. 验收铁律(先读) + +| 通道 | 给谁看 | 能否单独当排障依据 | +|------|--------|-------------------| +| 统一文件日志 `jiangchang.log` | 运维 / 离线打包 | **必须能单独定位到失败阶段与 error_code** | +| Activity / 任务中心 `emit` | 用户进度 | **不能**替代文件日志 | +| `print` / stdout | CLI 一眼结果 | **禁止**作为唯一失败记录 | +| `task_logs` | 终态审计 | 摘要层;**不能**替代逐步 trace | + +硬规定: + +1. **没有关键节点文件日志 = 客户现场不可排障。** +2. **`emit` ≠ 文件日志**:任务中心有进度,而 `jiangchang.log` 只有 `cli_start`,视为交付不合格。 +3. **失败出口必须先写文件日志,再 `print` / `finish`**:凡 `print(...ERROR:...)`、`finish(status=failed|partial)`、队列 `mark_failed`,同一出口须有 `log.warning` / `log.exception`(含 `error_code=` 或 `task_failed`)。 +4. **域模块也要打点**:`scripts/service//`、`*_flow.py`、`*_driver.py` 等业务文件不得零日志(见 `POLICY-LOGGING-006`)。 --- @@ -24,8 +44,6 @@ OpenClaw 技能常在客户电脑上异步执行,且大量依赖 RPA、浏览 | 重试与人工介入 | 第几次重试、是否等待验证码 / U 盾 | | 存证路径 | 截图、录屏、`video_log`、失败 artifacts | -**没有关键节点日志 = 客户现场不可排障。** 不要把 `print` 当作日志;stdout 面向用户与 Agent,文件日志与 Run Journal 面向运维。 - --- ## 2. 日志分层 @@ -130,36 +148,73 @@ Journal 会写入 `type: lifecycle` 事件(`paused` / `resumed` / `stopping` --- -## 3. 必打日志节点清单 +## 3. 固定事件词表(生产代码须出现) -以下节点应在**生产代码**中有对应 `log.info` / `log.warning` / `log.exception`(或 Activity / RPA step)。复制模板后按业务增删,但**不可整体省略**。 +日志消息推荐 **key=value**。下列**事件名字符串**须出现在 `log.info` / `log.warning` / `log.exception` 的消息中(可用 f-string / `%s`,但事件名本身须可 grep)。 -| 阶段 | 建议日志 / emit | -|------|-----------------| -| CLI 入口启动 | `cli_start`(`cli/app.py`,已由模板提供) | -| 参数解析完成 | `run_args target_id=... input_id=...` | -| 鉴权开始 | `entitlement_check_start` | -| 鉴权成功 | `entitlement_ok` | -| 鉴权失败 | `entitlement_failed`(`warning`) | -| 任务开始 | `task_start task_type=... target_id=... input_id=... batch_id=...` + `emit("开始处理任务")` | -| adapter / test target 选择 | `adapter_selected adapter=... system=...` | -| 账号 / profile / lease 获取 | `account_acquired account_id=...` | -| 账号 / lease 释放 | `account_released account_id=...` | -| 外部 API 开始 | `external_call_start system=... operation=...` | -| 外部 API 结束 | `external_call_done system=... operation=... elapsed_ms=... status=...` | -| 兄弟技能调用开始 / 结束 | 同上,或 `sibling_call_start/done skill_slug=...` | -| RPA 操作开始 / 结束 | `rpa_start` / `rpa_done` + `video.add_step` 中文步骤 | -| 打开页面 | step:「打开 xxx 页面」 | -| 检查登录 | step + log | -| 定位输入框 / 点击提交 | step + log | -| 等待结果 | step + log(含 timeout 预期) | -| 批量循环 | `batch_progress current=... total=...` | -| 重试 | `retry_attempt n=... reason=...`(`warning`) | -| 跳过 / 部分失败 | `skipped` / `partial_failure`(`warning`) | -| 人工介入 | `human_intervention type=captcha|login|ukey|otp`(`warning`) | -| 失败截图 / 视频 | `artifact_saved artifact_path=...` / `video_path=...` | -| 任务成功 | `task_success ...` + `task_log_saved status=success` | -| 任务失败 | `task_failed ...`(`exception` 保留 traceback)+ `task_log_saved status=failed` | +### 3.1 长任务通用最低集(所有业务 skill) + +| 事件名 | 级别 | 说明 | +|--------|------|------| +| `cli_start` | info | CLI 入口(模板已提供) | +| `task_start` | info | 主任务开始(含 `task_type=`) | +| `entitlement_ok` / `entitlement_failed` | info / warning | 鉴权结果 | +| `item_start` / `item_done` | info | 批量逐条时;单条可用 `task_start`/`task_success` 代替 | +| `task_success` 或 `task_failed` | info / warning\|exception | 终态;失败须含 `error_code=` | +| `task_log_saved` | info | 写入 `task_logs` 后 | + +### 3.2 RPA / 桌面 / 浏览器额外最低集 + +当 `scripts/service/` 使用 `RpaVideoSession` / `uiautomation` / `playwright` / `@rpa_step` 时,额外要求: + +| 事件名 | 说明 | +|--------|------| +| `rpa_start` / `rpa_done` 或 `rpa_failed` | RPA 段起止(可与 `task_*` 同条合并,但事件名须可 grep) | +| `stage=` | 至少 3 个不同阶段里程碑(如 `open_window` / `locate_control` / `submit` / `wait_result`) | +| `risk_blocked` | 若有配额/工作时段/熔断等前置拦截 | + +**不要**要求每个 Click 一行日志;要求的是**阶段里程碑 + 全部失败出口**。 + +### 3.3 其它常见节点(按业务选用) + +| 阶段 | 建议事件 | +|------|----------| +| adapter 选择 | `adapter_selected adapter=...` | +| 账号 / lease | `account_acquired` / `account_released` | +| 外部 API | `external_call_start` / `external_call_done`(含 `elapsed_ms=` / `status=`) | +| 兄弟技能 | `sibling_call_start` / `sibling_call_done` | +| 重试 / 跳过 / 人工 | `retry_attempt` / `skipped` / `human_intervention` | +| 存证 | `artifact_saved` / `video_path=` | + +### 3.4 最低可验收日志串(示例) + +mock 或真实跑完一条主路径后,`jiangchang.log`(或宿主「运行日志」)应能 grep 到类似: + +```text +cli_start argv=... +task_start task_type=add-one target_id=... +entitlement_ok task_type=add-one +rpa_start stage=open_window +rpa_start stage=locate_control +rpa_start stage=submit +task_failed error_code=ELEMENT_NOT_FOUND ... +task_log_saved status=failed +``` + +成功路径则出现 `task_success` 而非 `task_failed`。若只有 `cli_start` → **不合格**。 + +--- + +## 3.5 必打节点与 Activity 对照 + +以下节点除文件日志外,长任务仍应 `emit` / `video.add_step`(用户可见),但**不能只 emit 不打文件日志**。 + +| 阶段 | 文件日志 | Activity | +|------|----------|----------| +| 任务开始 | `task_start` | `emit("开始…")` | +| RPA 步骤 | `stage=...` | `add_step` / `@rpa_step` | +| 批量进度 | `item_start` / `batch_progress` | `emit(..., progress=)` | +| 失败 | `task_failed error_code=` | `finish(failed\|partial)` | --- diff --git a/development/POLICY_MATRIX.md b/development/POLICY_MATRIX.md index 3a5f45f..ab20a10 100644 --- a/development/POLICY_MATRIX.md +++ b/development/POLICY_MATRIX.md @@ -30,6 +30,9 @@ | POLICY-LOGGING-003 | 主任务入口须含 `log.info`/`logger.info`、`log.exception`/`logger.exception`,并写入 `save_task_log` | development/LOGGING.md | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyLogging003` | | POLICY-LOGGING-004 | 长任务 / RPA 模板须使用 Activity 或 RPA video step 输出进度 | development/LOGGING.md;development/RPA.md | hard | 扫描 `scripts/service/task_service.py` 含 `emit(` / `activity.emit` / `video.add_step` | `tests/test_development_policy_guard.py::TestPolicyLogging004` | | POLICY-LOGGING-005 | 交付代码不得在 logging 调用行以 `key=value` 形式记录明显敏感字段 | development/LOGGING.md;development/CONFIG.md | hard | 扫描 `scripts/**/*.py` 的 logging 调用行(不含注释) | `tests/test_development_policy_guard.py::TestPolicyLogging005` | +| POLICY-LOGGING-006 | `scripts/service/**/*.py` 业务域文件须有 `log/logger/_logger.(info|warning|error|exception)`;豁免薄工具:`__init__.py`、`task_run_support.py`、`entitlement_service.py`、`sibling_bridge.py` | development/LOGGING.md §0、§3 | hard | 扫描 service 树 | `tests/test_development_policy_guard.py::TestPolicyLogging006` | +| POLICY-LOGGING-007 | 若 service 层含 RPA 标记(`RpaVideoSession` / `uiautomation` / `playwright` / `rpa_step`),则须出现事件词 `task_start`、`task_failed` 或 `rpa_failed`、以及 `stage=` | development/LOGGING.md §3.1–§3.2 | hard | 合并扫描 `scripts/service/**/*.py` | `tests/test_development_policy_guard.py::TestPolicyLogging007` | +| POLICY-LOGGING-008 | `scripts/service/**/*.py` 中若 `print` 含 `ERROR:`,同一函数体内须有 `log/logger/_logger.(warning|error|exception)` | development/LOGGING.md §0 铁律 3 | hard | 按 `def` 切分共现检查 | `tests/test_development_policy_guard.py::TestPolicyLogging008` | | POLICY-CONTROL-001 | 不得存在 `scripts/util/progress.py` 或自建 stdout 进度模块 | development/LOGGING.md §2.5;development/RPA.md §0.1 | hard | 文件不存在检查 | `tests/test_development_policy_guard.py::TestPolicyControl001` | | POLICY-CONTROL-002 | `task_service.py` 须使用 `job_context(` 与 `finish(` | development/LOGGING.md §2.5、§7 | hard | 扫描 `scripts/service/task_service.py` | `tests/test_development_policy_guard.py::TestPolicyControl002` | | POLICY-CONTROL-003 | `scripts/service/*.py` 不得裸 `asyncio.sleep`(RPA 等待须 `interruptible_sleep`) | development/RPA.md §0.1 | hard | 扫描 service 层 Python | `tests/test_development_policy_guard.py::TestPolicyControl003` | @@ -59,6 +62,7 @@ | 复制模板不得保留 `.git` / template marker | development/DEVELOPMENT.md §4 | 由 `tests/test_scaffold_guard.py` 守护,属脚手架场景 | | 共享 runtime 导入来源非技能目录 | development/RUNTIME.md | 运行时依赖已安装包,见 `tests/test_platform_import.py` | | RPA 真实轨迹、选择器纪律、状态等待、HITL 超时与有限重试是否正确实现 | development/AI_RPA.md;development/RPA.md §0–§1 | 文档和默认节奏已守护,真实行为与 DOM 质量仍需人工验收 | +| RPA 阶段日志是否「够细」(每个控件) | development/LOGGING.md §3.2 | hard 已要求里程碑事件词与域模块有日志;逐步粒度靠 sample 契约与验收 | | adapter 四档契约测试覆盖 timeout/unauthorized 等 | development/ADAPTER.md §contract tests | 需业务实现后人工补测 | | `SKILL.md` / `constants.SKILL_SLUG` 一致性 | development/DEVELOPMENT.md §16 | 已有 `tests/test_skill_metadata.py` | | platform_kit_min_version >= 1.2.2 | development/RUNTIME.md | 已有 `tests/test_platform_import.py` | diff --git a/development/TESTING.md b/development/TESTING.md index b66bb2c..1a5429f 100644 --- a/development/TESTING.md +++ b/development/TESTING.md @@ -201,8 +201,11 @@ Golden fixture 流程同理([`tests/samples/test_golden_cases.py.sample`](../t - `task_service.py` 是否使用 `get_skill_logger`、含 `log.info` / `log.exception`、写入 `save_task_log`(`POLICY-LOGGING-002` / `003`) - 长任务模板是否使用 Activity 或 RPA video step(`POLICY-LOGGING-004`) - 交付代码是否在 logging 调用中拼接明显敏感字段(`POLICY-LOGGING-005`) +- **域模块**(`scripts/service/**`,豁免薄工具)是否有文件日志调用(`POLICY-LOGGING-006`) +- RPA 技能是否具备事件词 `task_start` / `task_failed|rpa_failed` / `stage=` / 鉴权事件(`POLICY-LOGGING-007`) +- `print(...ERROR:)` 是否与同函数内 `log.warning|error|exception` 共现(`POLICY-LOGGING-008`) -**局限**:自动检测只覆盖可静态判断的**底线**,**不**判断业务日志是否足够完整。复制新 skill 后,仍须人工对照 [`LOGGING.md`](LOGGING.md) §必打日志节点清单做 code review。 +**局限**:hard 保证事件词与「失败先落盘」底线,**不**判断每个控件一步一日志。复制新 skill 后仍建议对照 [`LOGGING.md`](LOGGING.md) §3 事件词表,并按需启用 [`tests/samples/test_logging_contract.py.sample`](../tests/samples/test_logging_contract.py.sample)。 新增或调整开发规范时:**先更新 `POLICY_MATRIX.md`,再补/改 `test_development_policy_guard.py`(或注明已有测试覆盖)**。 diff --git a/scripts/service/task_service.py b/scripts/service/task_service.py index dbacb9e..7c241ef 100644 --- a/scripts/service/task_service.py +++ b/scripts/service/task_service.py @@ -70,7 +70,7 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int with job_context(skill=SKILL_SLUG): log.info( - "task_start task_type=%s target_id=%s input_id=%s", + "task_start task_type=%s target_id=%s input_id=%s stage=run", task_type, target, input_id, @@ -79,7 +79,10 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int try: ok, reason = check_entitlement(SKILL_SLUG) if not ok: - log.warning("entitlement_failed task_type=%s reason=%s", task_type, reason) + log.warning( + "entitlement_failed task_type=%s stage=auth error_code=ENTITLEMENT_DENIED", + task_type, + ) emit("鉴权失败,已记录诊断信息", type="warn", skill=SKILL_SLUG, stage="auth") tlr.save_task_log( task_type=task_type, @@ -103,12 +106,19 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int error_code="ENTITLEMENT_DENIED", stage="auth", ) - print(f"❌ {reason}") + print(f"ERROR:ENTITLEMENT_DENIED {reason}") return 1 - log.info("task_run_demo_start target_id=%s input_id=%s", target, input_id) + log.info("entitlement_ok task_type=%s stage=auth", task_type) + log.info("rpa_start stage=demo_prepare task_type=%s", task_type) + log.info("task_run_demo_start target_id=%s input_id=%s stage=demo_prepare", target, input_id) rc, video_info = asyncio.run(_run_template_demo(target, input_id)) - log.info("task_run_demo_done target_id=%s input_id=%s status=failed", target, input_id) + log.info("rpa_done stage=demo_prepare status=ok") + log.info( + "task_run_demo_done target_id=%s input_id=%s stage=demo_finish status=failed", + target, + input_id, + ) _print_video_summary(video_info) summary_payload = merge_video_into_result_summary( @@ -135,6 +145,10 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int input_id, ) + log.warning( + "task_failed task_type=%s stage=demo_finish error_code=TEMPLATE_STUB", + task_type, + ) finish( status="failed", message="模板仓库未实现真实业务", @@ -142,8 +156,9 @@ def cmd_run(target: Optional[str] = None, input_id: Optional[str] = None) -> int template_demo=True, target=target, input_id=input_id, + error_code="TEMPLATE_STUB", ) - print("❌ 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。") + print("ERROR:TEMPLATE_STUB 这是模板仓库,请复制后在 scripts/service/task_service.py 中实现 cmd_run 的真实业务逻辑。") return rc except JobStopped: raise diff --git a/scripts/util/constants.py b/scripts/util/constants.py index dc872bb..c8ccf69 100644 --- a/scripts/util/constants.py +++ b/scripts/util/constants.py @@ -1,6 +1,6 @@ """技能标识、版本与平台公共库约束(复制后请修改 slug/version/logger)。""" SKILL_SLUG = "your-skill-slug" -SKILL_VERSION = "1.0.59" +SKILL_VERSION = "1.0.60" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" PLATFORM_KIT_MIN_VERSION = "1.2.2" diff --git a/tests/samples/README.md b/tests/samples/README.md index 2b59fa4..5880813 100644 --- a/tests/samples/README.md +++ b/tests/samples/README.md @@ -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 样例 diff --git a/tests/samples/test_logging_contract.py.sample b/tests/samples/test_logging_contract.py.sample new file mode 100644 index 0000000..98124e8 --- /dev/null +++ b/tests/samples/test_logging_contract.py.sample @@ -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() diff --git a/tests/test_development_policy_guard.py b/tests/test_development_policy_guard.py index e195197..1f2736e 100644 --- a/tests/test_development_policy_guard.py +++ b/tests/test_development_policy_guard.py @@ -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()