From 7136689efeeb3a05c89d91e0fd537141549f8df3 Mon Sep 17 00:00:00 2001 From: chendelian <116870791@qq.com> Date: Fri, 3 Jul 2026 10:24:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=9B=BA=E5=8C=96=20verb-noun-platform?= =?UTF-8?q?=20slug=20=E5=91=BD=E5=90=8D=E8=A7=84=E8=8C=83=E4=B8=8E?= =?UTF-8?q?=E8=AF=AD=E4=B9=89=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- SKILL.md | 6 +- assets/naming/platforms.txt | 13 ++ assets/naming/scopes.txt | 4 + assets/naming/verbs.txt | 25 +++ development/DEVELOPMENT.md | 42 +++- development/NAMING.md | 236 +++++++++++++++++++++++ development/README.md | 13 +- development/REQUIREMENTS.md | 8 + development/TESTING.md | 1 + references/SCHEMA.md | 2 +- scripts/db/display_metadata_validator.py | 9 + scripts/util/constants.py | 2 +- scripts/util/slug_naming.py | 227 ++++++++++++++++++++++ tests/test_display_metadata.py | 2 +- tests/test_docs_standards.py | 9 + tests/test_slug_naming.py | 87 +++++++++ 16 files changed, 674 insertions(+), 12 deletions(-) create mode 100644 assets/naming/platforms.txt create mode 100644 assets/naming/scopes.txt create mode 100644 assets/naming/verbs.txt create mode 100644 development/NAMING.md create mode 100644 scripts/util/slug_naming.py create mode 100644 tests/test_slug_naming.py diff --git a/SKILL.md b/SKILL.md index 7bd73cf..7783100 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,7 +1,7 @@ --- name: 技能开发模板(通用业务版) description: "OpenClaw 通用业务技能开发模板,供复制后定制新业务 skill。定制步骤见 development/DEVELOPMENT.md。" -version: 1.0.14 +version: 1.0.32 author: 深圳匠厂科技有限公司 metadata: openclaw: @@ -38,6 +38,7 @@ allowed-tools: - **只有当**用户明确询问命令、参数、输出结构、开发、调试、集成或排错时,才读取 `references/` 或 `development/`。 - 若 `README.md` 与 `SKILL.md` / `references` 表述冲突:对用户展示与市场说明以 `README.md` 为准;对执行契约、CLI、schema、运行约束以 `SKILL.md` / `references` 为准。 - 回答用户时**不要**暴露 Playwright、DOM、Python、RPA 等实现细节,除非用户明确询问技术实现。 +- 用户未问技术实现时,**不要**讨论 slug 命名规则;开发者问命名或 slug 时,读取 `development/NAMING.md`。 ## 文档分工 @@ -86,6 +87,9 @@ python {baseDir}/scripts/main.py version ## 技能命名与 slug +- 标准格式:`{verb}-{noun-phrase}-{platform}`(详见 `development/NAMING.md`)。 +- slug / repo / 目录 / `SKILL_SLUG` / DB 文件名:**英文** kebab-case;`name`、`description`、根 `README.md`:**中文**。 +- 复制为新业务技能前**必须**阅读 `development/NAMING.md`。 - `name`(frontmatter):**用户可见的中文业务名称**;`description` 是说明,不是名称。 - `metadata.openclaw.slug`:**英文 kebab-case** 机器标识;目录名、默认 `{slug}.db` 使用 slug,不使用中文。 - 复制后全局替换 `your-skill-slug`;正式技能不得保留英文占位 `name` 或 slug。 diff --git a/assets/naming/platforms.txt b/assets/naming/platforms.txt new file mode 100644 index 0000000..db8a041 --- /dev/null +++ b/assets/naming/platforms.txt @@ -0,0 +1,13 @@ +amazon +shopee +xinghang +alibaba +kingdee +icbc +pingpong +worldfirst +paypal +lianlian +single-window +export-rebate +etax diff --git a/assets/naming/scopes.txt b/assets/naming/scopes.txt new file mode 100644 index 0000000..3c41cf5 --- /dev/null +++ b/assets/naming/scopes.txt @@ -0,0 +1,4 @@ +all +batch +multi +default diff --git a/assets/naming/verbs.txt b/assets/naming/verbs.txt new file mode 100644 index 0000000..afca45b --- /dev/null +++ b/assets/naming/verbs.txt @@ -0,0 +1,25 @@ +scrape +receive +download +export +add +fill +enter +bind +withdraw +reconcile +verify +submit +query +process +reply +generate +draft +review +confirm +track +upload +match +triage +ship +reprice diff --git a/development/DEVELOPMENT.md b/development/DEVELOPMENT.md index 71f8718..a059c87 100644 --- a/development/DEVELOPMENT.md +++ b/development/DEVELOPMENT.md @@ -79,8 +79,9 @@ 你不应该直接在这个仓库里开发业务,而应该: +0. 按 [`NAMING.md`](NAMING.md) 确定 slug(`{verb}-{noun-phrase}-{platform}`) 1. 复制这个目录 -2. 改成新 skill 的目录名 +2. 改成新 skill 的目录名(与 slug 一致) 3. 把占位内容替换掉 4. 再开始写业务逻辑 @@ -682,6 +683,8 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea 每个新 skill 发布前,建议技术人员逐条确认: - [ ] 目录结构符合当前模板 +- [ ] slug 符合 [`NAMING.md`](NAMING.md)(verb-noun-platform) +- [ ] 目录名、`SKILL.md` slug、`constants.SKILL_SLUG` 三者一致 - [ ] `SKILL.md` 中 slug、名称、描述都已替换 - [ ] `scripts/util/constants.py` 已修改 - [ ] `../references/CLI.md` 示例命令已改成真实命令 @@ -751,11 +754,46 @@ uses: client-jiangchang/jiangchang-platform-kit/.github/workflows/reusable-relea - 用户说明在根 `README.md`,Agent 资料在 `references/`,开发规范在 `development/` - 统一走 `scripts/main.py` 作为 CLI 入口 +### 错误 5:平台放 slug 最前 + +表现: + +- slug 形如 `amazon-download-settlement-report` +- 语义校验或 `test_slug_naming` 失败 + +要改: + +- 动词放首段,平台放末段,例如 `download-settlement-report-amazon` +- 见 [`NAMING.md`](NAMING.md) + +### 错误 6:noun 段过长导致超 48 字符 + +表现: + +- slug 超过 48 字符 +- 中间名词段堆砌过多 + +要改: + +- 精简 noun-phrase,保留核心业务含义 +- 见 [`NAMING.md`](NAMING.md) §3 硬约束 + +### 错误 7:只改目录名未改 slug + +表现: + +- 与「错误 1」相同,但目录已改名而 `SKILL.md` / `constants.py` 仍为占位 + +要检查: + +- `SKILL.md` 的 `metadata.openclaw.slug` +- `scripts/util/constants.py` 的 `SKILL_SLUG` + ## 18. 推荐开发顺序总结 如果让一个新人照着做,我建议他按这个顺序: -1. 复制模板并改目录名 +1. 按 [`NAMING.md`](NAMING.md) 确定 slug,复制模板并改目录名 2. 改 `SKILL.md` 与根 `README.md` 3. 改 `scripts/util/constants.py` 4. 改 `references/` 与 `development/` diff --git a/development/NAMING.md b/development/NAMING.md new file mode 100644 index 0000000..022e4a1 --- /dev/null +++ b/development/NAMING.md @@ -0,0 +1,236 @@ +# 技能 slug 命名规范(verb-noun-platform) + +> 本文是匠厂**新业务 skill** 的 slug / Git 仓库名 / 目录名命名权威说明。复制 `skill-template` 出去开发新技能前**必读**;Agent 在开发者询问命名或 slug 时也应先读本文。 + +--- + +## 1. 适用范围 + +| 范围 | 说明 | +|------|------| +| ✅ **适用** | 从 `skill-template` **复制出去的新技能** | +| ❌ **不追溯** | 已上线历史技能(如 `account-manager`、`reconcile-finance`、`scrape-contacts-lianjia` 等)保持不动 | +| ❌ **不修改** | 其他仓库(尤其 `account-manager`)不在本文约束范围内 | +| ✅ **模板占位** | 模板本体的 `your-skill-slug` 占位必须继续通过全部测试 | + +**新技能勿仿**历史 2 段无平台形态(如 `reconcile-finance`)或不符合 verb-noun-platform 的旧 repo 名;校验器对历史 repo **不追溯**,但规范中明确标注为反模式。 + +--- + +## 2. 命名格式 + +标准形态(约 95% 新技能): + +```text +{verb}-{noun-phrase}-{platform} +``` + +| 部分 | 规则 | +|------|------| +| **verb** | 1 个词,来自 [动词白名单](#5-动词白名单) | +| **noun-phrase** | 1~3 个词(按 `-` 分段),描述业务对象 | +| **platform** | 1 个词,来自 [平台白名单](#6-平台白名单);可含连字符(如 `single-window`) | + +**展示层分工不变:** + +| 字段 | 语言 | 示例 | +|------|------|------| +| slug / repo / 目录 / `SKILL_SLUG` / DB 文件名 | **英文** kebab-case | `download-settlement-report-amazon` | +| `SKILL.md` 的 `name`、`description`、根 `README.md` | **中文** | `亚马逊结算报表下载` | + +--- + +## 3. 硬约束(新技能强制校验) + +| 约束 | 值 | +|------|-----| +| 格式 | `{verb}-{noun-phrase}-{platform}`(标准型,默认) | +| 总段数(按 `-` 分割) | **3~5 段**(标准型);scope 型见 [§4](#4-三类合法形态) | +| 总长度 | **≤ 48 字符** | +| 字符集 | 小写字母、数字、连字符;kebab-case | +| 禁止 | 中文 slug、下划线、**平台放最前**(如 `amazon-download-xxx`) | + +实现校验见 `scripts/util/slug_naming.py`;`scripts/db/display_metadata_validator.py` 在 `SKILL.md` frontmatter 自检时调用。 + +--- + +## 4. 三类合法形态 + +### 4.1 标准型(95%) + +`verb-noun-platform` 或 `verb-noun-noun-platform`(3~5 段) + +- 第 1 段 ∈ 动词白名单 +- 最后 1 段(或 hyphenated 平台的最后 2 段)∈ 平台白名单 +- 中间为 noun-phrase(1~3 段) + +示例: + +- `download-settlement-report-amazon` +- `add-product-shopee` +- `enter-sales-receipt-kingdee` +- `fill-export-declaration-single-window` + +### 4.2 scope 型(少见) + +`verb-noun-scope` + +- 最后 1 段 ∈ scope 白名单:`all` | `batch` | `multi` | `default` +- 第 1 段 ∈ 动词白名单 +- 总段数 3~5 + +示例:`generate-article-all` + +> 说明:`geo-generate-article-all` 若作为新技能且不在豁免表、首段 `geo` 不在动词表,应写为 `generate-article-all`。 + +### 4.3 历史豁免(不校验语义) + +以下 slug 跳过 verb/platform 语义校验(见 `LEGACY_EXEMPT_SLUGS`): + +- `account-manager` +- `skill-template` +- `your-skill-slug` / `your_skill_slug`(模板占位) + +--- + +## 5. 动词白名单 + +词表文件:[`assets/naming/verbs.txt`](../assets/naming/verbs.txt)(每行一词,便于后续扩展) + +| 动词 | 动词 | 动词 | 动词 | +|------|------|------|------| +| scrape | receive | download | export | +| add | fill | enter | bind | +| withdraw | reconcile | verify | submit | +| query | process | reply | generate | +| draft | review | confirm | track | +| upload | match | triage | ship | +| reprice | | | | + +--- + +## 6. 平台白名单 + +词表文件:[`assets/naming/platforms.txt`](../assets/naming/platforms.txt) + +| 平台 | 平台 | 平台 | +|------|------|------| +| amazon | shopee | xinghang | +| alibaba | kingdee | icbc | +| pingpong | worldfirst | paypal | +| lianlian | single-window | export-rebate | +| etax | | | + +scope 词表:[`assets/naming/scopes.txt`](../assets/naming/scopes.txt) — `all`、`batch`、`multi`、`default` + +--- + +## 7. slug 与展示字段分工 + +```yaml +--- +name: 亚马逊结算报表下载 # 中文,用户可见 +description: "从卖家中心下载结算报表并入库" # 中文说明 +metadata: + openclaw: + slug: download-settlement-report-amazon # 英文机器标识 +--- +``` + +同步修改: + +- 目录名 +- `metadata.openclaw.slug` +- `scripts/util/constants.py` 中的 `SKILL_SLUG` +- 默认数据库路径 `{slug}/{slug}.db` + +三者必须一致。 + +--- + +## 8. 与 account-manager 的关系 + +slug **末段 platform** 与 `account-manager` 中的 `platform_key` **语义对应**,但**不要求字符串完全相等**。 + +| slug 末段 (platform) | account-manager platform_key(示例) | +|----------------------|--------------------------------------| +| amazon | `amazon_sim` / `amazon` | +| shopee | `shopee` | +| kingdee | `kingdee` | + +新技能若需账号下发,在 `REQUIREMENTS.md` §0 填写拟用的 `platform_key`,并与兄弟技能调用参数对齐。 + +--- + +## 9. 复制新技能流程 + +1. **先定 slug** — 按本文规范拟定 `{verb}-{noun-phrase}-{platform}`,确认在白名单内且 ≤ 48 字符 +2. **复制目录** — 将 `skill-template` 复制为新目录,**目录名 = slug** +3. **全局替换** — `your-skill-slug` → 新 slug;更新 `SKILL.md`、`constants.py`、文档中的占位 +4. **填写 REQUIREMENTS** — 完成 [`REQUIREMENTS.md`](REQUIREMENTS.md) §0 技能标识 +5. **开发 & 自检** — `python tests/run_tests.py -v`,含 slug 语义测试 + +--- + +## 10. 正例 / 反例 + +### ✅ 正例 + +| slug | 说明 | +|------|------| +| `download-settlement-report-amazon` | 标准型,4 段 | +| `add-product-shopee` | 标准型,3 段 | +| `enter-sales-receipt-kingdee` | 标准型 | +| `fill-export-declaration-single-window` | 标准型,平台含连字符 | +| `generate-article-all` | scope 型 | + +### ❌ 反例 + +| slug | 原因 | +|------|------| +| `amazon-download-settlement-report` | 平台在最前 | +| `enter-cross-border-sales-receipt-voucher-kingdee` | 过长、noun 臃肿 | +| `reconcile-finance` | 新技能禁止 2 段无平台 | +| `download_settlement_amazon` | 非 kebab-case(下划线) | + +--- + +## 11. 反模式 + +- **平台前置**:`amazon-download-...`、`shopee-add-...` +- **英文占位 name**:复制后仍保留 `My Skill` 或纯英文 slug 风格名称 +- **只改目录名**:`SKILL.md` slug 与 `constants.SKILL_SLUG` 未同步 +- **noun 堆砌**:中间段过多导致超 48 字符或难以阅读 +- **仿历史 2 段 repo**:`reconcile-finance`、`content-manager` 等 + +--- + +## 12. 演进说明(当前不启用) + +当技能库规模 **> 200** 时,可再评估引入 `{domain}-verb-noun-platform` 前缀形态。**当前不启用**,新技能一律使用本文 verb-noun-platform 标准型或 scope 型。 + +--- + +## 13. 历史形态说明 + +以下为代表性**历史 repo**,新技能**勿仿**: + +| 历史 slug | 说明 | +|-----------|------| +| `account-manager` | 跨平台账号管理,非 verb-noun-platform | +| `reconcile-finance` | 2 段无 platform | +| `scrape-contacts-lianjia` | 平台名不在当前白名单(链家) | +| `skill-template` | 模板仓库本身 | + +校验器通过 `LEGACY_EXEMPT_SLUGS` 对模板占位与少数豁免 slug 跳过语义检查;**新复制出去的业务 skill 不在豁免表内**,必须满足 §3 硬约束。 + +--- + +## 14. 相关文档 + +| 文档 | 内容 | +|------|------| +| [`DEVELOPMENT.md`](DEVELOPMENT.md) | 复制模板步骤、发布前检查清单 | +| [`REQUIREMENTS.md`](REQUIREMENTS.md) | §0 技能标识必填项 | +| [`../references/SCHEMA.md`](../references/SCHEMA.md) | kebab-case 与数据库路径 | +| [`../SKILL.md`](../SKILL.md) | frontmatter 命名摘要 | diff --git a/development/README.md b/development/README.md index 7e1f607..5d26ed6 100644 --- a/development/README.md +++ b/development/README.md @@ -3,12 +3,13 @@ 本目录面向**人类开发者**与 **AI 编程代理**。开始定制 skill 前,建议按以下顺序阅读: 1. [`REQUIREMENTS.md`](REQUIREMENTS.md) — 需求文档模板与验收标准 -2. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范 -3. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关 -4. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时 -5. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时 -6. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制 -7. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 +2. [`NAMING.md`](NAMING.md) — slug / 仓库名命名规范(复制模板前必读) +3. [`DEVELOPMENT.md`](DEVELOPMENT.md) — 完整开发步骤与目录规范 +4. [`TESTING.md`](TESTING.md) — 测试分层、隔离数据根与档位开关 +5. [`ADAPTER.md`](ADAPTER.md) — 涉及外部系统对接时 +6. [`RPA.md`](RPA.md) — 涉及浏览器 / 桌面 / 手机自动化时 +7. [`CONFIG.md`](CONFIG.md) — `.env` 规范与 bootstrap 机制 +8. [`RUNTIME.md`](RUNTIME.md) — 共享 runtime、数据路径、发布打包与编码约定 Agent 调用契约见 [`../references/CLI.md`](../references/CLI.md)、[`../references/SCHEMA.md`](../references/SCHEMA.md)。 用户市场说明见根目录 [`README.md`](../README.md),不要写进本目录。 diff --git a/development/REQUIREMENTS.md b/development/REQUIREMENTS.md index 73ff944..5898945 100644 --- a/development/REQUIREMENTS.md +++ b/development/REQUIREMENTS.md @@ -15,6 +15,14 @@ # REQUIREMENTS +## 0. 技能标识(复制 REQUIREMENTS 时必填) + +- 拟定 slug(英文,符合 `development/NAMING.md`): +- 中文 name: +- 中文 description(一句话): +- account-manager platform_key(如有): +- 命名形态:标准型 / scope 型 + ## 1. 文档目标 说明这份需求文档的作用,以及它解决什么问题。 diff --git a/development/TESTING.md b/development/TESTING.md index 0755f8d..8820f7a 100644 --- a/development/TESTING.md +++ b/development/TESTING.md @@ -32,6 +32,7 @@ - 标准时间字段:`created_at` / `updated_at` 默认值、trigger 维护与 `datetime_unix_seconds` 元数据(见 `test_timestamp_columns.py`); - **adapter profile**:[`tests/test_adapter_profile_policy.py`](../tests/test_adapter_profile_policy.py) + [`adapter_test_utils`](../tests/adapter_test_utils.py) ——验证在未授权情况下绝不误判开启真实网络/RPA。 - **发布打包守护**:[`tests/test_release_packaging_constraints.py`](../tests/test_release_packaging_constraints.py) ——`scripts/**/*.py` 单文件 < 1000 行、文本文件 UTF-8 without BOM。 +- **slug 语义**:[`tests/test_slug_naming.py`](../tests/test_slug_naming.py) ——verb-noun-platform 命名规范校验。 **原则:`tests/test_*.py` 不允许隐形访问外网、不允许拉起真实浏览器、不允许读写开发者机器的真实数据根**。如果需要仿真服务器,也应仅在 integration(并由档位变量放行)。 diff --git a/references/SCHEMA.md b/references/SCHEMA.md index f21b9e6..d654264 100644 --- a/references/SCHEMA.md +++ b/references/SCHEMA.md @@ -29,7 +29,7 @@ metadata: - `name`:用户可见的中文业务名称(可含品牌,如 `GEO 文章生成`、`1688 联系人采集`)。 - `description`:能力说明,不是技能名称。 -- `metadata.openclaw.slug`:机器标识,kebab-case 英文;目录名与默认数据库文件名使用 slug。 +- `metadata.openclaw.slug`:机器标识,kebab-case 英文;目录名与默认数据库文件名使用 slug。slug 语义规范见 [`../development/NAMING.md`](../development/NAMING.md);本文件仅描述数据库与 kebab-case 格式。 - 复制模板后不得将 `your-skill-slug`、`My Skill` 等占位内容作为正式技能发布名称。 ## 数据库路径与命名 diff --git a/scripts/db/display_metadata_validator.py b/scripts/db/display_metadata_validator.py index 6be3823..1b21cec 100644 --- a/scripts/db/display_metadata_validator.py +++ b/scripts/db/display_metadata_validator.py @@ -18,6 +18,7 @@ from db.display_metadata import ( ) from db.timestamp_columns import has_updated_at_trigger from util.constants import SKILL_SLUG +from util.slug_naming import LEGACY_EXEMPT_SLUGS, validate_slug_semantics _TEMPLATE_PLACEHOLDER_SLUGS = frozenset({"your-skill-slug", "your_skill_slug"}) _ENGLISH_PLACEHOLDER_NAME_PATTERNS = ( @@ -127,6 +128,14 @@ def validate_skill_frontmatter(skill_root: str, *, allow_template_placeholders: f"SKILL.md slug ({slug!r}) 与 util.constants.SKILL_SLUG ({constants_slug!r}) 不一致" ) + if slug and slug not in LEGACY_EXEMPT_SLUGS and _KEBAB_SLUG.fullmatch(slug): + naming_errors, naming_warnings = validate_slug_semantics( + slug, + strict=not allow_template_placeholders, + ) + report.errors.extend(naming_errors) + report.warnings.extend(naming_warnings) + return report diff --git a/scripts/util/constants.py b/scripts/util/constants.py index db3dd65..0c18b10 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.14" +SKILL_VERSION = "1.0.32" LOG_LOGGER_NAME = "openclaw.skill.your_skill_slug" PLATFORM_KIT_MIN_VERSION = "1.0.17" diff --git a/scripts/util/slug_naming.py b/scripts/util/slug_naming.py new file mode 100644 index 0000000..0dda7cd --- /dev/null +++ b/scripts/util/slug_naming.py @@ -0,0 +1,227 @@ +"""技能 slug 语义校验(verb-noun-platform 规范,见 development/NAMING.md)。""" + +from __future__ import annotations + +import os +import re + +_KEBAB_SLUG = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$") +_MAX_SLUG_LENGTH = 48 +_MIN_SEGMENTS = 3 +_MAX_SEGMENTS = 5 +_MAX_NOUN_SEGMENTS = 3 + +_SKILL_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +_NAMING_DIR = os.path.join(_SKILL_ROOT, "assets", "naming") + +_BUILTIN_VERBS = frozenset( + { + "scrape", + "receive", + "download", + "export", + "add", + "fill", + "enter", + "bind", + "withdraw", + "reconcile", + "verify", + "submit", + "query", + "process", + "reply", + "generate", + "draft", + "review", + "confirm", + "track", + "upload", + "match", + "triage", + "ship", + "reprice", + } +) +_BUILTIN_PLATFORMS = frozenset( + { + "amazon", + "shopee", + "xinghang", + "alibaba", + "kingdee", + "icbc", + "pingpong", + "worldfirst", + "paypal", + "lianlian", + "single-window", + "export-rebate", + "etax", + } +) +_BUILTIN_SCOPES = frozenset({"all", "batch", "multi", "default"}) + +LEGACY_EXEMPT_SLUGS: frozenset[str] = frozenset( + { + "account-manager", + "skill-template", + "your-skill-slug", + "your_skill_slug", + } +) + + +def load_wordlist(filename: str) -> frozenset[str]: + """从 assets/naming/{filename} 加载词表;文件不存在时 fallback 内置集合。""" + fallbacks = { + "verbs.txt": _BUILTIN_VERBS, + "platforms.txt": _BUILTIN_PLATFORMS, + "scopes.txt": _BUILTIN_SCOPES, + } + fallback = fallbacks.get(filename, frozenset()) + path = os.path.join(_NAMING_DIR, filename) + if not os.path.isfile(path): + return fallback + words: set[str] = set() + with open(path, encoding="utf-8") as f: + for line in f: + word = line.strip() + if word and not word.startswith("#"): + words.add(word) + return frozenset(words) if words else fallback + + +def parse_slug_segments(slug: str) -> list[str]: + """按 '-' 分割;非法字符由上层 kebab 校验处理。""" + slug = slug.strip() + if not slug: + return [] + return slug.split("-") + + +def _match_platform_suffix( + segments: list[str], + platforms: frozenset[str], +) -> tuple[str, int] | None: + """返回 (platform_name, platform_part_count) 或 None。""" + for platform in sorted(platforms, key=lambda item: (-item.count("-"), item)): + parts = platform.split("-") + part_count = len(parts) + if len(segments) < part_count + 2: + continue + if segments[-part_count:] == parts: + return platform, part_count + return None + + +def classify_slug_form(segments: list[str], *, slug: str = "") -> str: + """ + 返回: 'legacy_exempt' | 'standard' | 'scope' | 'invalid' + + - legacy_exempt: slug in LEGACY_EXEMPT_SLUGS + - standard: 末段匹配 platforms, 首段 in verbs, 3<=len<=5 + - scope: 末段 in scopes, 首段 in verbs, 3<=len<=5 + - invalid: 其他 + """ + if slug in LEGACY_EXEMPT_SLUGS: + return "legacy_exempt" + + if not segments or not (_MIN_SEGMENTS <= len(segments) <= _MAX_SEGMENTS): + return "invalid" + + verbs = load_wordlist("verbs.txt") + platforms = load_wordlist("platforms.txt") + scopes = load_wordlist("scopes.txt") + + if segments[0] not in verbs: + return "invalid" + + if segments[-1] in scopes: + return "scope" + + if _match_platform_suffix(segments, platforms) is not None: + return "standard" + + return "invalid" + + +def validate_slug_semantics( + slug: str, + *, + strict: bool = True, +) -> tuple[list[str], list[str]]: + """返回 (errors, warnings)。""" + slug = slug.strip() + if slug in LEGACY_EXEMPT_SLUGS: + return [], [] + + errors: list[str] = [] + warnings: list[str] = [] + + if not _KEBAB_SLUG.fullmatch(slug): + msg = f"slug 必须为 kebab-case(小写字母、数字、连字符):{slug!r}" + (errors if strict else warnings).append(msg) + return errors, warnings + + if len(slug) > _MAX_SLUG_LENGTH: + msg = f"slug 长度不得超过 {_MAX_SLUG_LENGTH} 字符(当前 {len(slug)}):{slug!r}" + (errors if strict else warnings).append(msg) + + segments = parse_slug_segments(slug) + segment_count = len(segments) + + if segment_count < _MIN_SEGMENTS: + msg = ( + f"slug 段数应为 {_MIN_SEGMENTS}~{_MAX_SEGMENTS}(verb-noun-platform)," + f"当前为 {segment_count} 段:{slug!r};新技能禁止 2 段无平台形态(如 reconcile-finance)" + ) + (errors if strict else warnings).append(msg) + return errors, warnings + + if segment_count > _MAX_SEGMENTS: + msg = f"slug 段数不得超过 {_MAX_SEGMENTS}(当前 {segment_count}):{slug!r}" + (errors if strict else warnings).append(msg) + + verbs = load_wordlist("verbs.txt") + platforms = load_wordlist("platforms.txt") + scopes = load_wordlist("scopes.txt") + + if segments[0] in platforms and segments[0] not in verbs: + msg = f"平台名不应放在 slug 最前:{slug!r}(应为 verb-noun-platform)" + (errors if strict else warnings).append(msg) + + if segments[0] not in verbs: + msg = f"slug 首段应为动词白名单中的词:{segments[0]!r}(slug={slug!r})" + (errors if strict else warnings).append(msg) + + form = classify_slug_form(segments, slug=slug) + if form == "scope": + if segments[-1] not in scopes: + msg = f"slug 末段应为 scope 白名单中的词:{segments[-1]!r}(slug={slug!r})" + (errors if strict else warnings).append(msg) + noun_count = segment_count - 2 + elif form == "standard": + platform_match = _match_platform_suffix(segments, platforms) + if platform_match is None: + msg = f"slug 末段应匹配平台白名单:{slug!r}" + (errors if strict else warnings).append(msg) + noun_count = 0 + else: + _platform_name, platform_part_count = platform_match + noun_count = segment_count - 1 - platform_part_count + else: + if segment_count >= _MIN_SEGMENTS: + if segments[-1] not in scopes: + platform_match = _match_platform_suffix(segments, platforms) + if platform_match is None: + msg = f"slug 末段应匹配平台或 scope 白名单:{slug!r}" + (errors if strict else warnings).append(msg) + noun_count = 0 + + if noun_count > _MAX_NOUN_SEGMENTS: + warnings.append( + f"slug 中间名词段建议不超过 {_MAX_NOUN_SEGMENTS} 个词(当前 {noun_count}):{slug!r}" + ) + + return errors, warnings diff --git a/tests/test_display_metadata.py b/tests/test_display_metadata.py index 76bc61c..cc35eeb 100644 --- a/tests/test_display_metadata.py +++ b/tests/test_display_metadata.py @@ -324,7 +324,7 @@ name: My Skill description: demo metadata: openclaw: - slug: demo-skill + slug: query-demo-info --- """ with open(os.path.join(tmp, "SKILL.md"), "w", encoding="utf-8") as f: diff --git a/tests/test_docs_standards.py b/tests/test_docs_standards.py index 608b083..eb26761 100644 --- a/tests/test_docs_standards.py +++ b/tests/test_docs_standards.py @@ -141,6 +141,15 @@ class TestDocsStandards(unittest.TestCase): msg="SKILL.md must state README.md is user-facing documentation", ) + def test_naming_md_exists_and_covers_standards(self) -> None: + text = self._read("development/NAMING.md") + self.assertIn("verb-noun-platform", text) + self.assertIn("48", text) + self.assertTrue("LEGACY" in text or "历史" in text) + self.assertTrue("verbs.txt" in text or "动词白名单" in text) + self.assertTrue("platforms.txt" in text or "平台白名单" in text) + self.assertTrue("仅适用于新创建" in text or "新技能" in text) + def test_release_workflow_uses_main_channel(self) -> None: text = self._read(".github/workflows/release_skill.yaml") uses_line = _extract_workflow_uses_line(text) diff --git a/tests/test_slug_naming.py b/tests/test_slug_naming.py new file mode 100644 index 0000000..791d177 --- /dev/null +++ b/tests/test_slug_naming.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +"""slug 语义校验(verb-noun-platform)。""" +from __future__ import annotations + +import unittest + +from util.slug_naming import ( + LEGACY_EXEMPT_SLUGS, + classify_slug_form, + parse_slug_segments, + validate_slug_semantics, +) + + +class TestSlugNaming(unittest.TestCase): + def test_parse_slug_segments(self) -> None: + self.assertEqual(parse_slug_segments("download-settlement-report-amazon"), [ + "download", + "settlement", + "report", + "amazon", + ]) + + def test_classify_standard(self) -> None: + segments = parse_slug_segments("download-settlement-report-amazon") + self.assertEqual(classify_slug_form(segments, slug="download-settlement-report-amazon"), "standard") + + def test_classify_scope(self) -> None: + segments = parse_slug_segments("generate-article-all") + self.assertEqual(classify_slug_form(segments, slug="generate-article-all"), "scope") + + def test_classify_legacy_exempt(self) -> None: + segments = parse_slug_segments("your-skill-slug") + self.assertEqual(classify_slug_form(segments, slug="your-skill-slug"), "legacy_exempt") + + def test_valid_hyphenated_platform_slug(self) -> None: + slug = "fill-export-declaration-single-window" + segments = parse_slug_segments(slug) + self.assertEqual(classify_slug_form(segments, slug=slug), "standard") + errors, warnings = validate_slug_semantics(slug) + self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}") + + def test_valid_standard_slugs(self) -> None: + for slug in ( + "download-settlement-report-amazon", + "add-product-shopee", + "generate-article-all", + "your-skill-slug", + "account-manager", + ): + with self.subTest(slug=slug): + errors, warnings = validate_slug_semantics(slug) + self.assertEqual(errors, [], msg=f"errors={errors!r} warnings={warnings!r}") + + def test_invalid_slugs(self) -> None: + cases = ( + "amazon-download-settlement-report", + "enter-cross-border-sales-receipt-voucher-kingdee", + "reconcile-finance", + "download-settlement-report-unknownplat", + "foobar-settlement-report-amazon", + ) + for slug in cases: + with self.subTest(slug=slug): + errors, _warnings = validate_slug_semantics(slug) + self.assertTrue(errors, msg=f"expected errors for {slug!r}") + + def test_length_boundary_48_passes(self) -> None: + slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon" + self.assertEqual(len(slug), 48) + errors, _warnings = validate_slug_semantics(slug) + self.assertEqual(errors, []) + + def test_length_boundary_49_fails(self) -> None: + slug = "query-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-amazon" + self.assertEqual(len(slug), 49) + errors, _warnings = validate_slug_semantics(slug) + self.assertTrue(errors) + self.assertTrue(any("48" in err for err in errors)) + + def test_legacy_exempt_set(self) -> None: + self.assertIn("your-skill-slug", LEGACY_EXEMPT_SLUGS) + self.assertIn("account-manager", LEGACY_EXEMPT_SLUGS) + + +if __name__ == "__main__": + unittest.main()