# -*- coding: utf-8 -*- """assets/actions.json 结构与 CLI 映射守护(通用模板,不含具体业务技能名称)。""" from __future__ import annotations import json import os import re import sqlite3 import unittest from _support import IsolatedDataRoot, get_skill_root from cli.app import build_parser 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({"concurrency", "locks"}) _ALLOWED_EXECUTION_PROFILES = frozenset({"sync", "async"}) _REQUIRED_MANIFEST_KEYS = frozenset({"schemaVersion", "skill", "actions"}) _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", "微信", "contacts", "wxid", "account_wxid", "contact.db", "scrape-contacts", "douyin", "抖音", "xiaohongshu", "小红书", "公众号", "logistics", "物流", ) _TEMPLATE_ARG_PATTERN = re.compile(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}") def _load_actions_manifest() -> dict: path = os.path.join(get_skill_root(), "assets", "actions.json") with open(path, encoding="utf-8") as f: return json.load(f) def _cli_subcommands() -> set[str]: parser = build_parser() subs = parser._subparsers._group_actions[0].choices # type: ignore[attr-defined] return set(subs.keys()) def _collect_template_vars(args: list) -> set[str]: found: set[str] = set() for item in args: if not isinstance(item, str): continue for match in _TEMPLATE_ARG_PATTERN.finditer(item): found.add(match.group(1)) 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) 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() self.assertEqual(manifest.get("schemaVersion"), 1) skill = manifest.get("skill") self.assertIn(skill, (SKILL_SLUG, _TEMPLATE_PLACEHOLDER_SLUG)) self.assertIsInstance(manifest.get("actions"), list) self.assertGreater(len(manifest["actions"]), 0) def test_action_ids_unique_and_cli_valid(self) -> None: manifest = _load_actions_manifest() subs = _cli_subcommands() seen: set[str] = set() for action in manifest["actions"]: action_id = action["id"] self.assertNotIn(action_id, seen, msg=f"duplicate action id: {action_id}") seen.add(action_id) self.assertTrue(str(action.get("label", "")).strip(), msg=f"{action_id} label empty") self.assertTrue(str(action.get("description", "")).strip(), msg=f"{action_id} description empty") entry = action["entrypoint"] self.assertEqual(entry["type"], "cli") self.assertIn(entry["command"], subs, msg=f"{action_id} command not in CLI") args = entry.get("args") self.assertIsInstance(args, list, msg=f"{action_id} args must be array") for item in args: self.assertIsInstance( item, (str, int, float, bool), msg=f"{action_id} arg item must be scalar", ) self.assertNotIsInstance(item, dict, msg=f"{action_id} arg must not be object") self.assertIsNotNone(item, msg=f"{action_id} arg must not be null") placements = action.get("placements") self.assertIsInstance(placements, list, msg=f"{action_id} placements must be array") self.assertGreater(len(placements), 0, msg=f"{action_id} placements empty") 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"]: placements = set(action.get("placements") or []) overlap = placements & _RESERVED_PLACEMENTS self.assertFalse( overlap, msg=f"{action['id']} uses reserved placements not yet supported in template: {overlap}", ) def test_template_variables_have_input_schema(self) -> None: manifest = _load_actions_manifest() for action in manifest["actions"]: args = action.get("entrypoint", {}).get("args") or [] template_vars = _collect_template_vars(args) if not template_vars: continue schema = action.get("inputSchema") or {} properties = schema.get("properties") or {} for var in template_vars: self.assertIn( var, properties, msg=f"{action['id']} uses {{{{{var}}}}} but inputSchema.properties missing it", ) def test_required_fields_exist_in_properties(self) -> None: manifest = _load_actions_manifest() for action in manifest["actions"]: schema = action.get("inputSchema") or {} required = schema.get("required") or [] properties = schema.get("properties") or {} for field in required: self.assertIn( field, properties, msg=f"{action['id']} required field {field!r} missing from properties", ) def test_sensitive_params_not_on_toolbar(self) -> None: manifest = _load_actions_manifest() for action in manifest["actions"]: props = (action.get("inputSchema") or {}).get("properties") or {} has_sensitive = any(p.get("sensitive") for p in props.values()) if has_sensitive and "toolbar" in (action.get("placements") or []): self.fail(f"{action['id']} has sensitive input but is on toolbar") def test_confirmation_message_structure(self) -> None: manifest = _load_actions_manifest() for action in manifest["actions"]: confirmation = action.get("confirmation") if confirmation is None: continue self.assertIsInstance(confirmation, dict) message = confirmation.get("message") self.assertTrue(str(message or "").strip(), msg=f"{action['id']} confirmation.message empty") def test_manifest_has_no_specific_business_skill_names(self) -> None: path = os.path.join(get_skill_root(), "assets", "actions.json") with open(path, encoding="utf-8") as f: raw = f.read().lower() for term in _FORBIDDEN_BUSINESS_TERMS: self.assertNotIn(term.lower(), raw, msg=f"actions.json must not contain {term!r}") def test_actions_md_exists_and_links_manifest(self) -> None: path = os.path.join(get_skill_root(), "references", "ACTIONS.md") self.assertTrue(os.path.isfile(path)) with open(path, encoding="utf-8") as f: text = f.read() self.assertIn("actions.json", text) self.assertIn("schemaVersion", text) for term in _FORBIDDEN_BUSINESS_TERMS: self.assertNotIn(term.lower(), text.lower(), msg=f"ACTIONS.md must not contain {term!r}") def test_skill_actions_schema_file_exists(self) -> None: path = os.path.join(get_skill_root(), "assets", "schemas", "skill-actions.schema.json") self.assertTrue(os.path.isfile(path)) with open(path, encoding="utf-8") as f: schema = json.load(f) 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_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: text = f.read() for marker in ( "严格规范", "向后兼容", "Phase 1", "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__": unittest.main()