Files
skill-template/scripts/util/slug_naming.py
chendelian 8c8811e777
All checks were successful
技能自动化发布 / release (push) Successful in 9s
Allow skill slugs without a platform suffix.
2026-09-21 11:30:38 +08:00

234 lines
7.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""技能 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 = 2
_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 占位末段。不再当成合法 scope;校验只警告,避免同步后挡住已发布仓库。
_DEPRECATED_PLATFORM_PLACEHOLDER = "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' | 'plain' | 'invalid'
- legacy_exempt: slug in LEGACY_EXEMPT_SLUGS
- standard: 末段匹配 platforms,首段 in verbs,2<=len<=5,且动词后至少还有一段名词
- scope: 末段 in scopes(all/batch/multi),首段 in verbs,至少 verb-noun-scope
- plain: 动词 + 名词,无平台、无 scope
- 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:
if len(segments) < 3:
return "invalid"
return "scope"
if _match_platform_suffix(segments, platforms) is not None:
return "standard"
return "plain"
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}(动词-名词,平台可选),"
f"当前为 {segment_count} 段:{slug!r}"
)
(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":
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
elif form == "plain":
noun_count = segment_count - 1
if segments[-1] == _DEPRECATED_PLATFORM_PLACEHOLDER:
warnings.append(
"slug 末段 default 已不再作为平台占位;没有外部平台时直接省略。"
f"已发布的技能不用改名:{slug!r}"
)
else:
if segments and segments[-1] in scopes and segment_count < 3:
msg = f"scope 型 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