chore: auto release commit (2026-07-14 11:56:45)
All checks were successful
技能自动化发布 / release (push) Successful in 5s
All checks were successful
技能自动化发布 / release (push) Successful in 5s
This commit is contained in:
@@ -5,9 +5,10 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sqlite3
|
||||
import unittest
|
||||
|
||||
from _support import get_skill_root
|
||||
from _support import IsolatedDataRoot, get_skill_root
|
||||
|
||||
from cli.app import build_parser
|
||||
from util.constants import SKILL_SLUG
|
||||
@@ -15,9 +16,13 @@ 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"})
|
||||
_RESERVED_ACTION_FIELDS = frozenset({"concurrency", "locks"})
|
||||
_ALLOWED_EXECUTION_PROFILES = frozenset({"sync", "async"})
|
||||
_REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"})
|
||||
_REQUIRED_ACTION_KEYS = frozenset({"id", "label", "description", "placements", "entrypoint"})
|
||||
_REQUIRED_ACTION_KEYS = frozenset(
|
||||
{"id", "label", "description", "placements", "entrypoint", "executionProfile"}
|
||||
)
|
||||
_TABLE_NAME_PATTERN = re.compile(r"^[a-z][a-z0-9_]*$")
|
||||
_FORBIDDEN_BUSINESS_TERMS = (
|
||||
"wechat",
|
||||
"微信",
|
||||
@@ -66,6 +71,88 @@ def _load_actions_schema() -> dict:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
def _validate_bind_tables(action_id: str, bind: object) -> list[str]:
|
||||
errors: list[str] = []
|
||||
if not isinstance(bind, dict):
|
||||
return [f"{action_id}: bind must be object"]
|
||||
if set(bind.keys()) - {"tables"}:
|
||||
errors.append(f"{action_id}: bind only allows tables, got {sorted(bind.keys())}")
|
||||
tables = bind.get("tables")
|
||||
if not isinstance(tables, list) or len(tables) < 1:
|
||||
errors.append(f"{action_id}: bind.tables must be non-empty array")
|
||||
return errors
|
||||
seen: set[str] = set()
|
||||
for table in tables:
|
||||
if not isinstance(table, str) or not table.strip():
|
||||
errors.append(f"{action_id}: bind.tables entries must be non-empty strings")
|
||||
continue
|
||||
if table != table.strip() or not _TABLE_NAME_PATTERN.fullmatch(table):
|
||||
errors.append(f"{action_id}: bind.tables entry {table!r} must be snake_case")
|
||||
if table in seen:
|
||||
errors.append(f"{action_id}: bind.tables duplicate {table!r}")
|
||||
seen.add(table)
|
||||
return errors
|
||||
|
||||
|
||||
def _load_bindable_business_tables() -> set[str]:
|
||||
"""在隔离临时数据根下 init_db,返回可绑定到 toolbar 的业务表集合。
|
||||
|
||||
表必须同时满足:
|
||||
- SQLite 物理表存在(排除 sqlite_* 与 _jiangchang_* 元数据表)
|
||||
- ``_jiangchang_tables`` 中有对应行且 ``visible=1``
|
||||
|
||||
失败时抛出 RuntimeError(带诊断),不得静默返回空集供测试 skip。
|
||||
"""
|
||||
from db.connection import init_db
|
||||
from db.display_metadata import METADATA_TABLES
|
||||
from util.runtime_paths import get_db_path
|
||||
|
||||
physical: set[str] = set()
|
||||
visible_meta: set[str] = set()
|
||||
try:
|
||||
with IsolatedDataRoot() as data_root:
|
||||
init_db()
|
||||
db_path = get_db_path()
|
||||
if not os.path.isfile(db_path):
|
||||
raise RuntimeError(
|
||||
f"init_db did not create database file under isolated root "
|
||||
f"{data_root!r}: expected {db_path}"
|
||||
)
|
||||
conn = sqlite3.connect(db_path)
|
||||
try:
|
||||
cur = conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
||||
)
|
||||
physical = {
|
||||
str(row[0])
|
||||
for row in cur.fetchall()
|
||||
if str(row[0]) not in METADATA_TABLES
|
||||
}
|
||||
cur.execute(
|
||||
"SELECT table_name FROM _jiangchang_tables WHERE visible = 1"
|
||||
)
|
||||
visible_meta = {str(row[0]) for row in cur.fetchall()}
|
||||
finally:
|
||||
conn.close()
|
||||
bindable = physical & visible_meta
|
||||
if not bindable:
|
||||
raise RuntimeError(
|
||||
"isolated init_db produced no bindable tables "
|
||||
f"(physical={sorted(physical)}, visible_meta={sorted(visible_meta)}, "
|
||||
f"db_path={db_path!r})"
|
||||
)
|
||||
return bindable
|
||||
except RuntimeError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
"failed to load bindable business tables via isolated init_db: "
|
||||
f"{type(exc).__name__}: {exc}"
|
||||
) from exc
|
||||
|
||||
|
||||
class TestActionsManifest(unittest.TestCase):
|
||||
def test_manifest_exists_and_schema(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
@@ -108,6 +195,157 @@ class TestActionsManifest(unittest.TestCase):
|
||||
for placement in placements:
|
||||
self.assertIn(placement, _ALLOWED_PLACEMENTS, msg=f"{action_id} invalid placement {placement!r}")
|
||||
|
||||
def test_every_action_declares_execution_profile(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
for action in manifest["actions"]:
|
||||
profile = action.get("executionProfile")
|
||||
self.assertIn(
|
||||
profile,
|
||||
_ALLOWED_EXECUTION_PROFILES,
|
||||
msg=f"{action['id']} executionProfile must be sync|async, got {profile!r}",
|
||||
)
|
||||
|
||||
def test_schema_requires_execution_profile_and_defines_bind(self) -> None:
|
||||
schema = _load_actions_schema()
|
||||
action_def = schema["$defs"]["action"]
|
||||
self.assertIn("executionProfile", action_def.get("required") or [])
|
||||
profile = action_def["properties"]["executionProfile"]
|
||||
self.assertEqual(set(profile.get("enum") or []), {"sync", "async"})
|
||||
self.assertIn("bind", schema["$defs"])
|
||||
self.assertIn("bind", action_def["properties"])
|
||||
bind_def = schema["$defs"]["bind"]
|
||||
self.assertEqual(bind_def.get("required"), ["tables"])
|
||||
self.assertIs(bind_def.get("additionalProperties"), False)
|
||||
|
||||
def test_manifest_validates_against_json_schema(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.skipTest("jsonschema not installed")
|
||||
schema = _load_actions_schema()
|
||||
manifest = _load_actions_manifest()
|
||||
jsonschema.validate(instance=manifest, schema=schema)
|
||||
|
||||
def test_bind_structure_when_present(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
errors: list[str] = []
|
||||
for action in manifest["actions"]:
|
||||
if "bind" not in action:
|
||||
continue
|
||||
errors.extend(_validate_bind_tables(action["id"], action["bind"]))
|
||||
self.assertEqual(errors, [], msg="\n".join(errors))
|
||||
|
||||
def test_toolbar_actions_require_bind_tables(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
errors: list[str] = []
|
||||
for action in manifest["actions"]:
|
||||
placements = action.get("placements") or []
|
||||
if "toolbar" not in placements:
|
||||
continue
|
||||
bind = action.get("bind")
|
||||
if bind is None:
|
||||
errors.append(f"{action['id']}: toolbar placement requires bind.tables")
|
||||
continue
|
||||
errors.extend(_validate_bind_tables(action["id"], bind))
|
||||
self.assertEqual(errors, [], msg="\n".join(errors))
|
||||
|
||||
def test_bindable_business_tables_helper_loads_template_db(self) -> None:
|
||||
"""helper 本身必须可用:即便默认 manifest 无 toolbar bind,也要验证隔离库可读。"""
|
||||
bindable = _load_bindable_business_tables()
|
||||
self.assertIn("task_logs", bindable)
|
||||
self.assertNotIn("_jiangchang_tables", bindable)
|
||||
self.assertNotIn("_jiangchang_columns", bindable)
|
||||
|
||||
def test_bind_tables_exist_in_physical_and_visible_metadata(self) -> None:
|
||||
bindable = _load_bindable_business_tables()
|
||||
self.assertTrue(bindable, msg="bindable table set must be non-empty after init_db")
|
||||
manifest = _load_actions_manifest()
|
||||
missing: list[str] = []
|
||||
for action in manifest["actions"]:
|
||||
bind = action.get("bind")
|
||||
if not isinstance(bind, dict):
|
||||
continue
|
||||
for table in bind.get("tables") or []:
|
||||
if table not in bindable:
|
||||
missing.append(
|
||||
f"{action['id']}: bind.tables entry {table!r} must exist as "
|
||||
f"physical table AND _jiangchang_tables.visible=1 "
|
||||
f"(bindable={sorted(bindable)})"
|
||||
)
|
||||
self.assertEqual(missing, [], msg="\n".join(missing))
|
||||
|
||||
def test_schema_allows_all_placement_execution_profile_combinations(self) -> None:
|
||||
"""POLICY-SKILL-ACTION-004:4 placements × 2 profiles 必须全部通过 Schema。"""
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
self.fail("jsonschema is required to validate placement×executionProfile orthogonality")
|
||||
schema = _load_actions_schema()
|
||||
placements = ("toolbar", "cron", "agent", "skill-detail")
|
||||
profiles = ("sync", "async")
|
||||
for placement in placements:
|
||||
for profile in profiles:
|
||||
action: dict = {
|
||||
"id": "probe-action",
|
||||
"label": "正交探测",
|
||||
"description": "验证 placements 与 executionProfile 无条件耦合",
|
||||
"placements": [placement],
|
||||
"executionProfile": profile,
|
||||
"entrypoint": {"type": "cli", "command": "health", "args": []},
|
||||
}
|
||||
if placement == "toolbar":
|
||||
action["bind"] = {"tables": ["task_logs"]}
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [action],
|
||||
}
|
||||
with self.subTest(placement=placement, executionProfile=profile):
|
||||
jsonschema.validate(instance=manifest, schema=schema)
|
||||
if placement != "toolbar":
|
||||
self.assertNotIn("bind", action)
|
||||
|
||||
def test_schema_rejects_toolbar_without_bind(self) -> None:
|
||||
try:
|
||||
import jsonschema
|
||||
from jsonschema import ValidationError
|
||||
except ImportError:
|
||||
self.fail("jsonschema is required")
|
||||
schema = _load_actions_schema()
|
||||
manifest = {
|
||||
"schemaVersion": 1,
|
||||
"skill": "your-skill-slug",
|
||||
"actions": [
|
||||
{
|
||||
"id": "probe-toolbar",
|
||||
"label": "缺 bind 探测",
|
||||
"description": "toolbar 无 bind 应被 Schema 拒绝",
|
||||
"placements": ["toolbar"],
|
||||
"executionProfile": "sync",
|
||||
"entrypoint": {"type": "cli", "command": "health", "args": []},
|
||||
}
|
||||
],
|
||||
}
|
||||
with self.assertRaises(ValidationError):
|
||||
jsonschema.validate(instance=manifest, schema=schema)
|
||||
|
||||
def test_no_execution_profile_placement_coupling_in_tests(self) -> None:
|
||||
"""守护:本文件不得引入入口位置与 sync/async 的错误硬耦合断言。"""
|
||||
path = os.path.join(get_skill_root(), "tests", "test_actions_manifest.py")
|
||||
with open(path, encoding="utf-8") as f:
|
||||
text = f.read()
|
||||
# 拆开拼接,避免本测试源码自身触发字符串扫描。
|
||||
forbidden_snippets = (
|
||||
"toolbar " + "只能 async",
|
||||
"toolbar only " + "async",
|
||||
'toolbar"' + " and profile != " + '"async"',
|
||||
'toolbar")' + " and profile != " + '"async"',
|
||||
"toolbar must be " + "async",
|
||||
"executionProfile" + " == " + '"async"' + " and " + '"toolbar"',
|
||||
)
|
||||
for snippet in forbidden_snippets:
|
||||
self.assertNotIn(snippet, text)
|
||||
|
||||
def test_template_example_does_not_use_reserved_placements(self) -> None:
|
||||
manifest = _load_actions_manifest()
|
||||
for action in manifest["actions"]:
|
||||
@@ -215,6 +453,17 @@ class TestActionsManifest(unittest.TestCase):
|
||||
msg=f"{action['id']} uses reserved action fields not supported in template Phase 1: {overlap}",
|
||||
)
|
||||
|
||||
def test_default_manifest_does_not_place_health_on_toolbar(self) -> None:
|
||||
"""脚手架默认健康类 Action 不应出现在数据管理 toolbar。"""
|
||||
manifest = _load_actions_manifest()
|
||||
for action in manifest["actions"]:
|
||||
if action["id"] in {"health", "version", "config-path"}:
|
||||
self.assertNotIn(
|
||||
"toolbar",
|
||||
action.get("placements") or [],
|
||||
msg=f"{action['id']} must not be auto-placed on toolbar",
|
||||
)
|
||||
|
||||
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:
|
||||
@@ -223,14 +472,23 @@ class TestActionsManifest(unittest.TestCase):
|
||||
"严格规范",
|
||||
"向后兼容",
|
||||
"Phase 1",
|
||||
"bind",
|
||||
"bind.tables",
|
||||
"concurrency",
|
||||
"locks",
|
||||
"row",
|
||||
"batch",
|
||||
"additionalProperties",
|
||||
"placements",
|
||||
"executionProfile",
|
||||
):
|
||||
self.assertIn(marker, text, msg=f"ACTIONS.md missing {marker!r}")
|
||||
for forbidden in (
|
||||
"数据管理 toolbar " + "只挂",
|
||||
"toolbar " + "只放 async",
|
||||
"toolbar)→ " + "只挂",
|
||||
"只挂 **" + "async**",
|
||||
):
|
||||
self.assertNotIn(forbidden, text, msg=f"ACTIONS.md must not contain deprecated coupling: {forbidden!r}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user