From 766d162245a5304514b9b0788525dbf12458a396 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Sat, 11 Jul 2026 11:55:46 +0800 Subject: [PATCH] fix: harden skill template contracts and scaffolding Align scaffold slug replacement, Action manifest strict boundaries, metadata prune API, field permission validation, and task_logs readonly semantics with documented template contracts. Co-authored-by: Cursor --- SKILL.md | 2 +- assets/README.md | 6 +- assets/schemas/skill-actions.schema.json | 14 +- development/DEVELOPMENT.md | 2 +- references/ACTIONS.md | 34 +++++ references/README.md | 2 +- references/SCHEMA.md | 59 +++++++- scripts/db/display_metadata.py | 43 +++--- scripts/db/display_metadata_validator.py | 44 +++++- tests/test_actions_manifest.py | 51 +++++++ tests/test_data_management_contract.py | 91 +++++++++++- tests/test_display_metadata.py | 179 ++++++++++++++++++++++- tests/test_docs_standards.py | 6 + tests/test_scaffold_guard.py | 41 +++++- tools/README.md | 5 +- tools/scaffold_skill.ps1 | 12 +- tools/scaffold_skill.py | 17 ++- 17 files changed, 536 insertions(+), 72 deletions(-) diff --git a/SKILL.md b/SKILL.md index 1ea37bd..cbcd46a 100644 --- a/SKILL.md +++ b/SKILL.md @@ -94,7 +94,7 @@ python {baseDir}/scripts/main.py version - 复制为新业务技能前**必须**阅读 `development/NAMING.md`。 - `name`(frontmatter):**用户可见的中文业务名称**;`description` 是说明,不是名称。 - `metadata.openclaw.slug`:**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug,不使用中文。 -- 复制后全局替换 `your-skill-slug`;正式技能不得保留英文占位 `name` 或 slug。 +- 复制后全局替换 `your-skill-slug` 与 `your_skill_slug`(脚手架会自动处理);正式技能不得保留英文占位 `name` 或 slug。 ## 平台元数据 diff --git a/assets/README.md b/assets/README.md index 89c8aa3..240724a 100644 --- a/assets/README.md +++ b/assets/README.md @@ -1,8 +1,10 @@ # assets -- `actions.json`:Skill Action manifest 最小示例(可选;不需要宿主入口时可删除)。 +- `actions.json`:Skill Action manifest 最小示例(**可选**;不需要宿主入口时可删除)。 - `examples/`:CLI 成功输出形状示例(虚构路径与数据)。 -- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json`、`task-log-record.schema.json` 等)。 +- `schemas/`:轻量 JSON Schema(`skill-actions.schema.json` 为**新技能严格规范**;`task-log-record.schema.json` 等)。 + +`skill-actions.schema.json` 使用 `additionalProperties: false` 表达模板推荐契约;宿主运行时可能更宽松以兼容历史 manifest。`row`/`batch` placements 与 `bind`/`concurrency`/`locks` 为预留能力,当前模板示例不得使用。 - 用户市场说明见根目录 [`README.md`](../README.md) - Agent 调用/编排参考见 [`references/`](../references/)(含 [`ACTIONS.md`](../references/ACTIONS.md)) diff --git a/assets/schemas/skill-actions.schema.json b/assets/schemas/skill-actions.schema.json index 5410052..38d37a4 100644 --- a/assets/schemas/skill-actions.schema.json +++ b/assets/schemas/skill-actions.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://openclaw.local/skill-template/skill-actions.schema.json", "title": "SkillActionManifest", - "description": "匠厂宿主 Skill Action manifest 通用契约(schemaVersion 1)", + "description": "匠厂 skill-template 对新技能的严格 Action manifest 规范(schemaVersion 1)。additionalProperties=false 表示模板推荐契约;宿主运行时可能为兼容历史 manifest 接受部分缺省字段,但新技能不应依赖缺省行为。", "type": "object", "required": ["schemaVersion", "skill", "actions"], "additionalProperties": false, @@ -25,7 +25,8 @@ "$defs": { "placement": { "type": "string", - "enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"] + "enum": ["toolbar", "row", "batch", "cron", "agent", "skill-detail"], + "description": "模板 Phase 1 稳定支持 toolbar/cron/agent/skill-detail;row/batch 为预留入口,新技能示例不得使用" }, "scalarArg": { "type": ["string", "number", "boolean"] @@ -37,7 +38,8 @@ "properties": { "type": { "type": "string", - "enum": ["string", "number", "boolean", "object"] + "enum": ["string", "number", "boolean", "object"], + "description": "模板 Phase 1 仅推荐 string/number/boolean;根 inputSchema.type=object 的 properties 使用基础标量类型" }, "title": { "type": "string" }, "description": { "type": "string" }, @@ -77,7 +79,8 @@ }, "args": { "type": "array", - "items": { "$ref": "#/$defs/scalarArg" } + "items": { "$ref": "#/$defs/scalarArg" }, + "description": "仅允许 string/number/boolean 标量数组" } } }, @@ -118,7 +121,8 @@ "entrypoint": { "$ref": "#/$defs/entrypoint" }, "inputSchema": { "$ref": "#/$defs/inputSchema" }, "confirmation": { "$ref": "#/$defs/confirmation" } - } + }, + "description": "模板 Phase 1 不包含 bind/concurrency/locks 等预留能力字段;宿主类型可能定义这些字段,但新技能不得依赖" } } } diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index 5172870..2939795 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -516,7 +516,7 @@ metadata: - JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) - 模板最小示例仅暴露 `health` / `version` / `config-path` 三个 CLI 子命令 - 没有宿主直接操作需求时,**可以删除** `actions.json`;删除后勿保留失效引用 -- 复制为新技能后,scaffold 会自动将 `your-skill-slug` 替换为新 slug;action 业务命令由技能作者自行调整 +- 复制为新技能后,scaffold 会自动将 `your-skill-slug` 与 `your_skill_slug` 替换为新 slug(后者用于 `LOG_LOGGER_NAME` 等下划线形式);action 业务命令由技能作者自行调整 宿主执行方式: diff --git a/references/ACTIONS.md b/references/ACTIONS.md index 821f632..3ded582 100644 --- a/references/ACTIONS.md +++ b/references/ACTIONS.md @@ -4,6 +4,17 @@ **边界:** 用户市场说明见根目录 [`README.md`](../README.md);CLI 参数与错误码细节见 [`CLI.md`](CLI.md)。 +## 模板严格规范 vs 宿主向后兼容 + +| 角色 | 定位 | +|------|------| +| **skill-template** | 新技能推荐的**严格规范**:必填字段、明确类型、`additionalProperties: false` | +| **宿主运行时** | **向后兼容**的历史 manifest 解析器:可能接受部分缺省字段(如 `description`、`placements`、`entrypoint.args`、input property `type`) | + +**新技能不应依赖宿主缺省行为。** 复制模板后按 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json) 与本文完整填写 manifest。 + +JSON Schema 使用 `additionalProperties: false` 表达模板严格边界;这与宿主共享类型中的可选字段并不矛盾——宿主为兼容旧技能可能更宽松,模板为防漂移要求更严。 + ## Action 是否必需 Action 是**可选能力**: @@ -33,6 +44,29 @@ Action 是**可选能力**: 模板最小示例见 [`assets/actions.json`](../assets/actions.json);JSON Schema 见 [`assets/schemas/skill-actions.schema.json`](../assets/schemas/skill-actions.schema.json)。 +## Phase 1 当前稳定支持(新技能应只使用这些) + +| 层级 | 稳定字段 / 能力 | +|------|----------------| +| 顶层 | `schemaVersion`、`skill`、`actions` | +| action | `id`、`label`、`description`、`placements`、`entrypoint`;可选 `inputSchema`、`confirmation` | +| entrypoint | `type = cli`、`command`、`args`(**必填** JSON 数组,项为 `string` / `number` / `boolean`) | +| placements | `toolbar`、`skill-detail`、`agent`、`cron` | +| inputSchema | 根 `type: object`;properties 使用 `string` / `number` / `boolean`;支持 `enum`、`default`、`required`、`sensitive` | +| confirmation | `{ "message": "..." }` | + +## 预留能力(当前不可当作已实现能力) + +以下在宿主共享类型或 Schema 枚举中可能出现,但 **Phase 1 Runtime 尚未完整支持**,**新模板示例不得使用**: + +| 类别 | 预留项 | 说明 | +|------|--------|------| +| placements | `row`、`batch` | 行内 / 批量入口,未来扩展 | +| action 字段 | `bind`、`concurrency`、`locks` | 绑定、并发与锁控制,宿主后续实现 | +| inputSchema | `array`、nested `object`、`oneOf` 等 | 复杂表单与联合类型,不属于当前推荐模板范围 | + +可以在文档或 Schema 枚举中保留这些值供未来对齐,但不要让技能作者误以为它们已经可执行。 + ## 执行模型 宿主将 action 转为 argv,再执行: diff --git a/references/README.md b/references/README.md index ce7c310..3e864f6 100644 --- a/references/README.md +++ b/references/README.md @@ -14,7 +14,7 @@ | [`SKILL.md`](../SKILL.md) | LLM / OpenClaw 平台技能入口 | | [`CLI.md`](CLI.md) | 命令行入口、参数与调用契约 | | [`SCHEMA.md`](SCHEMA.md) | 本地 SQLite 与数据管理展示契约 | -| [`ACTIONS.md`](ACTIONS.md) | Skill Action manifest 与宿主入口契约 | +| [`ACTIONS.md`](ACTIONS.md) | Skill Action manifest(**可选**能力)与宿主入口契约 | | [`../development/`](../development/) | 开发、测试、技术规范(给开发者 / AI 编程代理) | 按需阅读:编排任务时优先 `CLI.md`;解析结构化字段时读 `SCHEMA.md`;配置宿主按钮/定时任务/Agent 入口时读 `ACTIONS.md`;运行时环境细节见 `../development/RUNTIME.md`。 diff --git a/references/SCHEMA.md b/references/SCHEMA.md index 4057b6d..43681c6 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -236,6 +236,16 @@ readonly = 0 ### 模板默认:`task_logs` +`task_logs` 是技能内部**系统自动写入**的任务审计日志(由 `task_logs_repository` INSERT/UPDATE),不是供用户在宿主数据管理中手工维护的业务表。 + +权限设计: + +| 层级 | 值 | 说明 | +|------|-----|------| +| 表级 `readonly` | `1` | 宿主数据管理只读展示,不提供新增/编辑/删除表单 | +| 所有业务字段 `editable` | `0` | 含 `task_type`、`status`、`error_msg`、`result_summary` 等 | +| 技能内部 repository | 正常 INSERT/UPDATE | 元数据权限只约束宿主 UI,不限制技能代码写库 | + 物理定义(权威顺序): ```sql @@ -258,17 +268,17 @@ CREATE TABLE IF NOT EXISTS task_logs ( | 物理字段 | 中文名 | editable | |----------|--------|----------| | id | 编号 | 0 | -| task_type | 任务类型 | 1 | -| target_id | 目标编号 | 1 | -| input_id | 输入编号 | 1 | -| input_title | 输入标题 | 1 | -| status | 状态 | 1 | -| error_msg | 错误信息 | 1 | -| result_summary | 结果摘要 | 1 | +| task_type | 任务类型 | 0 | +| target_id | 目标编号 | 0 | +| input_id | 输入编号 | 0 | +| input_title | 输入标题 | 0 | +| status | 状态 | 0 | +| error_msg | 错误信息 | 0 | +| result_summary | 结果摘要 | 0 | | created_at | 创建时间 | 0 | | updated_at | 更新时间 | 0 | -表中文名:**任务日志**;表级 `readonly = 0`。 +表中文名:**任务日志**;表级 `readonly = 1`(宿主只读;技能 repository 仍可写入)。 ### 通用示例:`demo_items`(测试 fixture 参考) @@ -325,8 +335,41 @@ CREATE TABLE demo_items ( 实现 helper: - `upsert_table_metadata` / `upsert_column_metadata` — 幂等写入 +- `seed_column_metadata_batch` — 批量 upsert;**可选**传入 `valid_column_names` 时在 upsert 后自动 prune - `prune_stale_column_metadata` — 清理已删除物理字段的元数据 +### 新增业务表时如何初始化 + +1. 在 `connection.py`(或迁移脚本)中 `CREATE TABLE` 真实物理表。 +2. 用 `PRAGMA table_info` 或 `list_physical_column_names` 取得**真实物理字段列表**(顺序以 `cid` 为准)。 +3. 调用 `upsert_table_metadata` 写入表级元数据。 +4. 调用 `seed_column_metadata_batch`,并传入 `valid_column_names=physical_column_names`: + +```python +from db.display_metadata import list_physical_column_names, seed_column_metadata_batch, upsert_table_metadata + +physical = list_physical_column_names(cursor, "demo_items") +upsert_table_metadata(cursor, table_name="demo_items", display_name="示例记录", readonly=0) +seed_column_metadata_batch( + cursor, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=physical, +) +``` + +传入 `valid_column_names` 时:先 upsert,再删除不在物理表中的陈旧 `_jiangchang_columns` 行。 + +### 字段删除或重命名时如何清理 + +1. 在 DDL / 迁移中完成物理字段删除或重命名。 +2. 重新执行 `seed_column_metadata_batch(..., valid_column_names=当前物理字段列表)`,自动 prune 旧字段元数据。 +3. 或单独调用 `prune_stale_column_metadata(cursor, table_name, valid_column_names)`。 + +**不传入 `valid_column_names` 时不会自动 prune**——模板不会猜测真实物理字段,避免误删;文档与实现均以此为准。 + +不要直接修改用户真实数据库作为迁移方式;元数据初始化必须使用真实物理字段列表。 + ## 中文命名质量 - 使用普通用户能理解的业务用语:`task_logs` → **任务日志**,`error_msg` → **错误信息**。 diff --git a/scripts/db/display_metadata.py b/scripts/db/display_metadata.py index 5ba3f8e..5326431 100644 --- a/scripts/db/display_metadata.py +++ b/scripts/db/display_metadata.py @@ -47,20 +47,20 @@ CREATE TABLE IF NOT EXISTS _jiangchang_columns ( TASK_LOGS_TABLE_METADATA = { "table_name": TASK_LOGS_TABLE, "display_name": "任务日志", - "description": "记录技能任务的执行状态和结果", + "description": "记录技能任务的执行状态和结果(系统自动写入,宿主数据管理只读展示)", "visible": 1, - "readonly": 0, + "readonly": 1, } TASK_LOGS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( {"column_name": "id", "display_name": "编号", "description": "任务日志唯一编号", "visible": 1, "searchable": 1, "editable": 0}, - {"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 1}, - {"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 1}, - {"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 1}, - {"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 1}, - {"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 1}, - {"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 1}, - {"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 1}, + {"column_name": "task_type", "display_name": "任务类型", "description": "任务的业务类型", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "target_id", "display_name": "目标编号", "description": "任务目标对象编号", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "input_id", "display_name": "输入编号", "description": "输入对象编号", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "input_title", "display_name": "输入标题", "description": "输入对象标题", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "status", "display_name": "状态", "description": "任务当前状态", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "error_msg", "display_name": "错误信息", "description": "任务失败时的错误说明", "visible": 1, "searchable": 1, "editable": 0}, + {"column_name": "result_summary", "display_name": "结果摘要", "description": "任务执行结果摘要", "visible": 1, "searchable": 1, "editable": 0}, { "column_name": "created_at", "display_name": "创建时间", @@ -157,27 +157,22 @@ def seed_task_logs_display_metadata(cursor) -> None: visible=int(meta["visible"]), readonly=int(meta["readonly"]), ) - for column in TASK_LOGS_COLUMN_METADATA: - upsert_column_metadata( - cursor, - table_name=TASK_LOGS_TABLE, - column_name=str(column["column_name"]), - display_name=str(column["display_name"]), - description=str(column["description"]) if column.get("description") else None, - visible=int(column["visible"]), - searchable=int(column["searchable"]), - editable=int(column["editable"]), - display_type=str(column["display_type"]) if column.get("display_type") else None, - options_json=str(column["options_json"]) if column.get("options_json") else None, - ) + seed_column_metadata_batch( + cursor, + table_name=TASK_LOGS_TABLE, + columns=TASK_LOGS_COLUMN_METADATA, + valid_column_names=[str(column["column_name"]) for column in TASK_LOGS_COLUMN_METADATA], + ) def seed_column_metadata_batch( cursor, *, table_name: str, - columns: tuple[dict[str, object], ...], + columns: Iterable[dict[str, object]], + valid_column_names: Iterable[str] | None = None, ) -> None: + """批量 upsert 字段元数据;传入 valid_column_names 时会在 upsert 后清理陈旧字段元数据。""" for column in columns: upsert_column_metadata( cursor, @@ -191,6 +186,8 @@ def seed_column_metadata_batch( display_type=str(column["display_type"]) if column.get("display_type") else None, options_json=str(column["options_json"]) if column.get("options_json") else None, ) + if valid_column_names is not None: + prune_stale_column_metadata(cursor, table_name, valid_column_names) def prune_stale_column_metadata(cursor, table_name: str, valid_column_names: Iterable[str]) -> None: diff --git a/scripts/db/display_metadata_validator.py b/scripts/db/display_metadata_validator.py index ee83470..f2646e7 100644 --- a/scripts/db/display_metadata_validator.py +++ b/scripts/db/display_metadata_validator.py @@ -161,6 +161,33 @@ def _table_column_info(cursor, table_name: str) -> dict[str, dict[str, object]]: return info +def _table_column_xinfo(cursor, table_name: str) -> dict[str, dict[str, object]]: + """PRAGMA table_xinfo 可识别 generated/hidden 字段(SQLite 3.31+)。""" + cursor.execute(f"PRAGMA table_xinfo({_quote_identifier(table_name)})") + info: dict[str, dict[str, object]] = {} + for row in cursor.fetchall(): + hidden = int(row[6] or 0) if len(row) > 6 else 0 + info[str(row[1])] = { + "cid": int(row[0]), + "name": str(row[1]), + "type": str(row[2] or ""), + "notnull": int(row[3] or 0), + "dflt_value": row[4], + "pk": int(row[5] or 0), + "hidden": hidden, + } + return info + + +def _is_generated_column(xinfo: dict[str, object]) -> bool: + hidden = int(xinfo.get("hidden") or 0) + return hidden in (2, 3) + + +def _is_blob_column(column_info: dict[str, object]) -> bool: + return "BLOB" in str(column_info.get("type") or "").upper() + + def _default_uses_unixepoch(default_value: object) -> bool: if default_value is None: return False @@ -202,6 +229,7 @@ def validate_column_permissions( warnings: list[str] = [] ordered = physical_columns or list_physical_column_names(cursor, table_name) column_info = _table_column_info(cursor, table_name) + column_xinfo = _table_column_xinfo(cursor, table_name) cursor.execute( """ @@ -216,13 +244,13 @@ def validate_column_permissions( for row in cursor.fetchall() } - for column_name in ordered: - meta = meta_by_column.get(column_name) - if not meta: + for column_name, meta in meta_by_column.items(): + physical = column_info.get(column_name) or {} + xinfo = column_xinfo.get(column_name) or physical + if not physical and column_name not in column_xinfo: continue - physical = column_info.get(column_name) or {} - pk = int(physical.get("pk") or 0) + pk = int(physical.get("pk") or xinfo.get("pk") or 0) editable = meta["editable"] if pk and editable is not None and int(editable) != 0: errors.append(f"{table_name}.{column_name} 是主键,editable 应为 0") @@ -230,6 +258,12 @@ def validate_column_permissions( if column_name == "id" and editable is not None and int(editable) != 0: errors.append(f"{table_name}.id 的 editable 应为 0(自增主键)") + if _is_generated_column(xinfo) and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.{column_name} 是 generated 字段,editable 应为 0") + + if _is_blob_column(physical or xinfo) and editable is not None and int(editable) != 0: + errors.append(f"{table_name}.{column_name} 是 BLOB 字段,editable 应为 0") + opt_errors, _ = validate_options_json(meta.get("options_json")) for item in opt_errors: errors.append(f"{table_name}.{column_name}: {item}") diff --git a/tests/test_actions_manifest.py b/tests/test_actions_manifest.py index c5c1e75..cbccb0c 100644 --- a/tests/test_actions_manifest.py +++ b/tests/test_actions_manifest.py @@ -15,6 +15,9 @@ from util.constants import SKILL_SLUG _TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" _ALLOWED_PLACEMENTS = frozenset({"toolbar", "row", "batch", "cron", "agent", "skill-detail"}) _RESERVED_PLACEMENTS = frozenset({"row", "batch"}) +_RESERVED_ACTION_FIELDS = frozenset({"bind", "concurrency", "locks"}) +_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"}) +_REQUIRED_ACTION_KEYS = frozenset({"id", "label", "description", "placements", "entrypoint"}) _FORBIDDEN_BUSINESS_TERMS = ( "wechat", "微信", @@ -57,6 +60,12 @@ def _collect_template_vars(args: list) -> set[str]: return found +def _load_actions_schema() -> dict: + path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") + with open(path, encoding="utf-8") as f: + return json.load(f) + + class TestActionsManifest(unittest.TestCase): def test_manifest_exists_and_schema(self) -> None: manifest = _load_actions_manifest() @@ -181,6 +190,48 @@ class TestActionsManifest(unittest.TestCase): self.assertEqual(schema.get("title"), "SkillActionManifest") self.assertIn("actions", schema.get("properties", {})) + def test_skill_actions_schema_json_parses(self) -> None: + schema = _load_actions_schema() + self.assertEqual(schema["properties"]["schemaVersion"]["const"], 1) + self.assertTrue(schema.get("additionalProperties") is False) + self.assertIn("action", schema.get("$defs", {})) + + def test_template_manifest_matches_schema_required_keys(self) -> None: + manifest = _load_actions_manifest() + schema = _load_actions_schema() + for key in _REQUIRED_MANIFEST_KEYS: + self.assertIn(key, manifest) + self.assertIn(key, schema.get("properties", {})) + for action in manifest["actions"]: + for key in _REQUIRED_ACTION_KEYS: + self.assertIn(key, action) + + def test_template_example_does_not_use_reserved_action_fields(self) -> None: + manifest = _load_actions_manifest() + for action in manifest["actions"]: + overlap = set(action.keys()) & _RESERVED_ACTION_FIELDS + self.assertFalse( + overlap, + msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}", + ) + + def test_actions_md_documents_strict_vs_host_boundary(self) -> None: + path = os.path.join(get_skill_root(), "references", "ACTIONS.md") + with open(path, encoding="utf-8") as f: + text = f.read() + for marker in ( + "严格规范", + "向后兼容", + "Phase 1", + "bind", + "concurrency", + "locks", + "row", + "batch", + "additionalProperties", + ): + self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_data_management_contract.py b/tests/test_data_management_contract.py index 0cca207..07d135f 100644 --- a/tests/test_data_management_contract.py +++ b/tests/test_data_management_contract.py @@ -75,11 +75,10 @@ DEMO_ITEMS_COLUMN_METADATA: tuple[dict[str, object], ...] = ( ) -def _init_demo_items_fixture(conn: sqlite3.Connection) -> None: +def _init_demo_items_fixture(conn: sqlite3.Connection, *, prune: bool = True) -> None: from db.display_metadata import ( init_display_metadata_tables, list_physical_column_names, - prune_stale_column_metadata, seed_column_metadata_batch, upsert_table_metadata, ) @@ -96,9 +95,13 @@ def _init_demo_items_fixture(conn: sqlite3.Connection) -> None: visible=int(meta["visible"]), readonly=int(meta["readonly"]), ) - seed_column_metadata_batch(cur, table_name="demo_items", columns=DEMO_ITEMS_COLUMN_METADATA) physical = list_physical_column_names(cur, "demo_items") - prune_stale_column_metadata(cur, "demo_items", physical) + seed_column_metadata_batch( + cur, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=physical if prune else None, + ) conn.commit() @@ -371,11 +374,16 @@ class TestDataManagementContract(unittest.TestCase): """ ) conn.commit() - from db.display_metadata import list_physical_column_names, prune_stale_column_metadata + from db.display_metadata import list_physical_column_names, seed_column_metadata_batch cur = conn.cursor() physical = list_physical_column_names(cur, "demo_items") - prune_stale_column_metadata(cur, "demo_items", physical) + seed_column_metadata_batch( + cur, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=physical, + ) conn.commit() cur.execute( "SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'" @@ -384,6 +392,77 @@ class TestDataManagementContract(unittest.TestCase): finally: conn.close() + def test_seed_batch_without_valid_column_names_does_not_prune(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn, prune=False) + conn.execute( + """ + INSERT INTO _jiangchang_columns + (table_name, column_name, display_name, visible, searchable, editable) + VALUES ('demo_items', 'removed_field', '已删除', 0, 0, 0) + """ + ) + conn.commit() + from db.display_metadata import seed_column_metadata_batch + + seed_column_metadata_batch( + conn.cursor(), + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + ) + conn.commit() + cur = conn.cursor() + cur.execute( + "SELECT 1 FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'removed_field'" + ) + self.assertIsNotNone(cur.fetchone()) + finally: + conn.close() + + def test_seed_batch_accepts_list_columns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn, prune=False) + from db.display_metadata import get_column_editable, seed_column_metadata_batch + + columns = list(DEMO_ITEMS_COLUMN_METADATA) + columns[1] = {**columns[1], "display_name": "标题(列表)"} + seed_column_metadata_batch( + conn.cursor(), + table_name="demo_items", + columns=columns, + valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA], + ) + conn.commit() + cur = conn.cursor() + cur.execute( + "SELECT display_name FROM _jiangchang_columns WHERE table_name = 'demo_items' AND column_name = 'title'" + ) + self.assertEqual(cur.fetchone()[0], "标题(列表)") + self.assertEqual(get_column_editable(cur, "demo_items", "title"), 1) + finally: + conn.close() + + def test_prune_does_not_remove_existing_column_metadata(self) -> None: + conn = sqlite3.connect(":memory:") + try: + _init_demo_items_fixture(conn) + from db.display_metadata import get_column_editable, seed_column_metadata_batch + + cur = conn.cursor() + before = get_column_editable(cur, "demo_items", "title") + seed_column_metadata_batch( + cur, + table_name="demo_items", + columns=DEMO_ITEMS_COLUMN_METADATA, + valid_column_names=[str(item["column_name"]) for item in DEMO_ITEMS_COLUMN_METADATA], + ) + conn.commit() + self.assertEqual(get_column_editable(cur, "demo_items", "title"), before) + finally: + conn.close() + def test_demo_items_validation_passes(self) -> None: conn = sqlite3.connect(":memory:") try: diff --git a/tests/test_display_metadata.py b/tests/test_display_metadata.py index cc35eeb..514b1ae 100644 --- a/tests/test_display_metadata.py +++ b/tests/test_display_metadata.py @@ -2,6 +2,7 @@ """数据管理展示元数据:幂等初始化、中文名称、PRAGMA 字段顺序、校验规则。""" from __future__ import annotations +import os import sqlite3 import unittest @@ -35,22 +36,39 @@ class TestDisplayMetadata(unittest.TestCase): try: cur = conn.cursor() cur.execute( - "SELECT display_name FROM _jiangchang_tables WHERE table_name = 'task_logs'" + "SELECT display_name, readonly FROM _jiangchang_tables WHERE table_name = 'task_logs'" ) - self.assertEqual(cur.fetchone()[0], "任务日志") + display_name, readonly = cur.fetchone() + self.assertEqual(display_name, "任务日志") + self.assertEqual(int(readonly), 1) cur.execute( """ - SELECT column_name, display_name + SELECT column_name, display_name, editable FROM _jiangchang_columns WHERE table_name = 'task_logs' ORDER BY column_name """ ) - by_name = dict(cur.fetchall()) - self.assertEqual(by_name["status"], "状态") - self.assertEqual(by_name["error_msg"], "错误信息") - self.assertEqual(by_name["created_at"], "创建时间") + by_name = {row[0]: (row[1], row[2]) for row in cur.fetchall()} + self.assertEqual(by_name["status"][0], "状态") + self.assertEqual(int(by_name["status"][1]), 0) + self.assertEqual(by_name["error_msg"][0], "错误信息") + self.assertEqual(int(by_name["error_msg"][1]), 0) + self.assertEqual(by_name["created_at"][0], "创建时间") + self.assertEqual(int(by_name["created_at"][1]), 0) + finally: + conn.close() + + def test_task_logs_not_manual_create_in_host_ui(self) -> None: + with IsolatedDataRoot(): + from db.connection import get_conn, init_db + from db.display_metadata import table_allows_manual_create + + init_db() + conn = get_conn() + try: + self.assertFalse(table_allows_manual_create(conn.cursor(), "task_logs")) finally: conn.close() @@ -342,6 +360,153 @@ metadata: ) self.assertTrue(any("id" in item for item in warnings)) + def test_generated_column_editable_must_be_zero(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE gen_demo ( + id INTEGER PRIMARY KEY, + base_value INTEGER NOT NULL, + generated_value INTEGER GENERATED ALWAYS AS (base_value * 2) STORED + ); + """ + ) + from db.display_metadata import init_display_metadata_tables, upsert_column_metadata + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_column_metadata( + cur, + table_name="gen_demo", + column_name="generated_value", + display_name="生成值", + editable=1, + ) + conn.commit() + errors, _ = validate_column_permissions(cur, "gen_demo") + self.assertTrue(any("generated" in err for err in errors)) + finally: + conn.close() + + def test_blob_column_editable_must_be_zero(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.execute( + "CREATE TABLE blob_demo (id INTEGER PRIMARY KEY, payload BLOB NOT NULL)" + ) + from db.display_metadata import init_display_metadata_tables, upsert_column_metadata + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_column_metadata( + cur, + table_name="blob_demo", + column_name="payload", + display_name="载荷", + editable=1, + ) + conn.commit() + errors, _ = validate_column_permissions(cur, "blob_demo") + self.assertTrue(any("BLOB" in err for err in errors)) + finally: + conn.close() + + def test_primary_key_editable_must_be_zero(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.execute("CREATE TABLE pk_demo (id INTEGER PRIMARY KEY, title TEXT)") + from db.display_metadata import init_display_metadata_tables, upsert_column_metadata + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_column_metadata( + cur, + table_name="pk_demo", + column_name="id", + display_name="编号", + editable=1, + ) + conn.commit() + errors, _ = validate_column_permissions(cur, "pk_demo") + self.assertTrue(any("主键" in err for err in errors)) + finally: + conn.close() + + def test_readonly_table_with_editable_fields_warns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE warn_demo (id INTEGER PRIMARY KEY, title TEXT); + """ + ) + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_table_metadata(cur, table_name="warn_demo", display_name="警告演示", readonly=1) + upsert_column_metadata( + cur, + table_name="warn_demo", + column_name="title", + display_name="标题", + editable=1, + ) + conn.commit() + _, warnings = validate_column_permissions(cur, "warn_demo") + self.assertTrue(any("readonly=1" in item for item in warnings)) + finally: + conn.close() + + def test_writable_table_without_editable_fields_warns(self) -> None: + conn = sqlite3.connect(":memory:") + try: + conn.executescript( + """ + CREATE TABLE warn_demo2 (id INTEGER PRIMARY KEY, title TEXT); + """ + ) + from db.display_metadata import ( + init_display_metadata_tables, + upsert_column_metadata, + upsert_table_metadata, + ) + from db.display_metadata_validator import validate_column_permissions + + cur = conn.cursor() + init_display_metadata_tables(cur) + upsert_table_metadata(cur, table_name="warn_demo2", display_name="警告演示二", readonly=0) + upsert_column_metadata( + cur, + table_name="warn_demo2", + column_name="title", + display_name="标题", + editable=0, + ) + conn.commit() + _, warnings = validate_column_permissions(cur, "warn_demo2") + self.assertTrue(any("readonly=0" in item and "editable=0" in item for item in warnings)) + finally: + conn.close() + + def test_schema_md_documents_task_logs_readonly_semantics(self) -> None: + path = os.path.join(get_skill_root(), "references", "SCHEMA.md") + with open(path, encoding="utf-8") as f: + text = f.read() + self.assertIn("readonly = 1", text) + self.assertIn("task_logs", text) + self.assertIn("seed_column_metadata_batch", text) + self.assertIn("valid_column_names", text) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_docs_standards.py b/tests/test_docs_standards.py index ba40a91..7cbb62e 100644 --- a/tests/test_docs_standards.py +++ b/tests/test_docs_standards.py @@ -242,6 +242,12 @@ class TestDocsStandards(unittest.TestCase): text = self._read("tools/README.md") self.assertIn("actions.json", text) self.assertIn("your-skill-slug", text) + self.assertIn("your_skill_slug", text) + + def test_development_md_documents_both_slug_placeholders(self) -> None: + text = self._read("development/DEVELOPMENT.md") + self.assertIn("your-skill-slug", text) + self.assertIn("your_skill_slug", text) def test_schema_md_documents_data_management_contract(self) -> None: text = self._read("references/SCHEMA.md") diff --git a/tests/test_scaffold_guard.py b/tests/test_scaffold_guard.py index d410a80..0722e43 100644 --- a/tests/test_scaffold_guard.py +++ b/tests/test_scaffold_guard.py @@ -10,8 +10,10 @@ import unittest from _support import get_skill_root TEMPLATE_MARKER = ".openclaw-skill-template" -TEMPLATE_SLUG = "your-skill-slug" +TEMPLATE_SLUG_KEBAB = "your-skill-slug" +TEMPLATE_SLUG_UNDERSCORE = "your_skill_slug" SCAFFOLD_TEST_SLUG = "query-demo-records" +SCAFFOLD_TEST_SLUG_UNDERSCORE = "query_demo_records" def _read_skill_slug() -> str: @@ -27,7 +29,7 @@ def _read_skill_slug() -> str: class TestScaffoldGuard(unittest.TestCase): def test_template_repo_has_marker(self) -> None: slug = _read_skill_slug() - if slug != TEMPLATE_SLUG: + if slug != TEMPLATE_SLUG_KEBAB: self.skipTest(f"当前 SKILL_SLUG={slug!r},非模板占位,跳过 template marker 断言") marker = os.path.join(get_skill_root(), TEMPLATE_MARKER) self.assertTrue( @@ -37,7 +39,7 @@ class TestScaffoldGuard(unittest.TestCase): def test_business_skill_must_not_keep_template_marker(self) -> None: slug = _read_skill_slug() - if slug == TEMPLATE_SLUG: + if slug == TEMPLATE_SLUG_KEBAB: self.skipTest("当前仍为模板占位 slug,由 test_template_repo_has_marker 守护") marker = os.path.join(get_skill_root(), TEMPLATE_MARKER) self.assertFalse( @@ -64,7 +66,7 @@ class TestScaffoldGuard(unittest.TestCase): def test_scaffold_replaces_slug_and_copies_actions_manifest(self) -> None: slug = _read_skill_slug() - if slug != TEMPLATE_SLUG: + if slug != TEMPLATE_SLUG_KEBAB: self.skipTest("仅在 skill-template 源仓库运行 scaffold 集成测试") import subprocess @@ -94,7 +96,9 @@ class TestScaffoldGuard(unittest.TestCase): with open(actions_path, encoding="utf-8") as f: manifest = json.load(f) self.assertEqual(manifest.get("skill"), SCAFFOLD_TEST_SLUG) - self.assertNotIn(TEMPLATE_SLUG, json.dumps(manifest)) + manifest_raw = json.dumps(manifest) + self.assertNotIn(TEMPLATE_SLUG_KEBAB, manifest_raw) + self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, manifest_raw) actions_md = os.path.join(dest, "references", "ACTIONS.md") self.assertTrue(os.path.isfile(actions_md), msg="scaffold must copy references/ACTIONS.md") @@ -109,7 +113,32 @@ class TestScaffoldGuard(unittest.TestCase): constants_path = os.path.join(dest, "scripts", "util", "constants.py") with open(constants_path, encoding="utf-8") as f: constants_text = f.read() - self.assertNotIn(TEMPLATE_SLUG, constants_text) + self.assertNotIn(TEMPLATE_SLUG_KEBAB, constants_text) + self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, constants_text) + self.assertIn(f'SKILL_SLUG = "{SCAFFOLD_TEST_SLUG}"', constants_text) + self.assertIn( + f'LOG_LOGGER_NAME = "openclaw.skill.{SCAFFOLD_TEST_SLUG_UNDERSCORE}"', + constants_text, + ) + + skill_md_path = os.path.join(dest, "SKILL.md") + with open(skill_md_path, encoding="utf-8") as f: + skill_md = f.read() + self.assertIn(f"slug: {SCAFFOLD_TEST_SLUG}", skill_md) + self.assertNotIn(TEMPLATE_SLUG_KEBAB, skill_md) + self.assertNotIn(TEMPLATE_SLUG_UNDERSCORE, skill_md) + + def test_scaffold_ps1_matches_python_placeholder_rules(self) -> None: + slug = _read_skill_slug() + if slug != TEMPLATE_SLUG_KEBAB: + self.skipTest("仅在 skill-template 源仓库运行 scaffold PS1 文案测试") + + ps1_path = os.path.join(get_skill_root(), "tools", "scaffold_skill.ps1") + with open(ps1_path, encoding="utf-8") as f: + text = f.read() + self.assertIn("your-skill-slug", text) + self.assertIn("your_skill_slug", text) + self.assertIn('.Replace("-", "_")', text) if __name__ == "__main__": diff --git a/tools/README.md b/tools/README.md index b785f63..133b8ed 100644 --- a/tools/README.md +++ b/tools/README.md @@ -22,7 +22,10 @@ python tools/scaffold_skill.py --slug my-new-skill --destination /path/to/my-new - 复制模板到 `-Destination`(目录末段须与 `-Slug` 一致) - **排除**:`.git`、`.pytest_cache`、`__pycache__`、`.env`、`.env.local`、`*.pyc` - 删除目标中的 `.openclaw-skill-template` 标记 -- 将文本文件中的 `your-skill-slug` 替换为新 slug(含 `assets/actions.json`、`SKILL.md`、`constants.py` 等) +- 将文本文件中的模板占位 slug 替换为新 slug: + - `your-skill-slug` → 新技能 kebab-case slug(如 `scrape-demo-records`) + - `your_skill_slug` → 连字符替换为下划线(如 `scrape_demo_records`,用于 `LOG_LOGGER_NAME` 等) + - 覆盖 `assets/actions.json`、`SKILL.md`、`scripts/util/constants.py` 等生成文件;文档中的路径示例可保留占位,但生成后的技能文件不得残留占位符 - **不**自动修改 `actions.json` 中的 CLI 业务命令;技能作者自行增删 action - **不**自动 `git init`;打印后续 Git 与测试命令 diff --git a/tools/scaffold_skill.ps1 b/tools/scaffold_skill.ps1 index 84db8c5..dcd9b65 100644 --- a/tools/scaffold_skill.ps1 +++ b/tools/scaffold_skill.ps1 @@ -71,7 +71,8 @@ if ($targetExists) { $excludeDirs = @(".git", ".pytest_cache", "__pycache__") $excludeFiles = @(".env", ".env.local") -$templatePlaceholderSlug = "your-skill-slug" +$templatePlaceholderSlugKebab = "your-skill-slug" +$templatePlaceholderSlugUnderscore = "your_skill_slug" $textFileSuffixes = @(".md", ".py", ".json", ".yaml", ".yml", ".txt", ".ini", ".ps1", ".sample") function Copy-TemplateTree { @@ -104,6 +105,7 @@ function Replace-TemplatePlaceholders { [string]$Root, [string]$Slug ) + $underscoreSlug = $Slug.Replace("-", "_") Get-ChildItem -LiteralPath $Root -Recurse -File -Force | ForEach-Object { $name = $_.Name if ($excludeFiles -contains $name) { return } @@ -115,8 +117,12 @@ function Replace-TemplatePlaceholders { catch { return } - if ($text -notlike "*$templatePlaceholderSlug*") { return } - $updated = $text.Replace($templatePlaceholderSlug, $Slug) + if ( + $text -notlike "*$templatePlaceholderSlugKebab*" -and + $text -notlike "*$templatePlaceholderSlugUnderscore*" + ) { return } + $updated = $text.Replace($templatePlaceholderSlugKebab, $Slug) + $updated = $updated.Replace($templatePlaceholderSlugUnderscore, $underscoreSlug) [System.IO.File]::WriteAllText($_.FullName, $updated) } } diff --git a/tools/scaffold_skill.py b/tools/scaffold_skill.py index 689e6ba..6af5367 100644 --- a/tools/scaffold_skill.py +++ b/tools/scaffold_skill.py @@ -11,7 +11,8 @@ from pathlib import Path EXCLUDE_DIR_NAMES = {".git", ".pytest_cache", "__pycache__"} EXCLUDE_FILE_NAMES = {".env", ".env.local"} KEBAB_SLUG = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$") -TEMPLATE_PLACEHOLDER_SLUG = "your-skill-slug" +TEMPLATE_PLACEHOLDER_SLUG_KEBAB = "your-skill-slug" +TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE = "your_skill_slug" TEXT_FILE_SUFFIXES = { ".md", ".py", @@ -46,8 +47,13 @@ def _should_replace_placeholders(path: Path) -> bool: return False +def _slug_to_underscore(slug: str) -> str: + return slug.replace("-", "_") + + def _replace_template_placeholders(target: Path, slug: str) -> None: """将模板占位 slug 替换为新技能 slug(不修改 action 业务命令结构)。""" + underscore_slug = _slug_to_underscore(slug) for path in target.rglob("*"): if not path.is_file(): continue @@ -59,9 +65,14 @@ def _replace_template_placeholders(target: Path, slug: str) -> None: text = path.read_text(encoding="utf-8") except UnicodeDecodeError: continue - if TEMPLATE_PLACEHOLDER_SLUG not in text: + if ( + TEMPLATE_PLACEHOLDER_SLUG_KEBAB not in text + and TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE not in text + ): continue - path.write_text(text.replace(TEMPLATE_PLACEHOLDER_SLUG, slug), encoding="utf-8") + updated = text.replace(TEMPLATE_PLACEHOLDER_SLUG_KEBAB, slug) + updated = updated.replace(TEMPLATE_PLACEHOLDER_SLUG_UNDERSCORE, underscore_slug) + path.write_text(updated, encoding="utf-8") def _print_next_steps(target: Path) -> None: