test_prompt_integrity.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. Prompt 完整性静态校验。
  5. 验证 agents/*.md 和 skills/*/SKILL.md 的结构、引用、CLI 命令等,
  6. 不需要 LLM 调用,可加入 CI。
  7. """
  8. from __future__ import annotations
  9. import re
  10. import sys
  11. from pathlib import Path
  12. import pytest
  13. # ---------------------------------------------------------------------------
  14. # 基础路径
  15. # ---------------------------------------------------------------------------
  16. PLUGIN_ROOT = Path(__file__).resolve().parent.parent.parent.parent
  17. AGENTS_DIR = PLUGIN_ROOT / "agents"
  18. SKILLS_DIR = PLUGIN_ROOT / "skills"
  19. REFERENCES_DIR = PLUGIN_ROOT / "references"
  20. SCRIPTS_DIR = PLUGIN_ROOT / "scripts"
  21. AGENT_FILES = sorted(AGENTS_DIR.glob("*.md"))
  22. SKILL_FILES = sorted(SKILLS_DIR.glob("*/SKILL.md"))
  23. ALL_PROMPT_FILES = AGENT_FILES + SKILL_FILES
  24. # webnovel.py 注册的子命令(从 add_parser 提取)
  25. REGISTERED_CLI_SUBCOMMANDS = {
  26. "where", "preflight", "use",
  27. "index", "state", "rag", "style", "entity", "context", "memory",
  28. "migrate", "status", "update-state", "backup", "archive",
  29. "init", "extract-context", "memory-contract", "review-pipeline",
  30. }
  31. # ---------------------------------------------------------------------------
  32. # Helpers
  33. # ---------------------------------------------------------------------------
  34. def _read_text(path: Path) -> str:
  35. return path.read_text(encoding="utf-8")
  36. def _extract_frontmatter(text: str) -> dict:
  37. """提取 YAML frontmatter 为 dict。"""
  38. m = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
  39. if not m:
  40. return {}
  41. result = {}
  42. for line in m.group(1).splitlines():
  43. if ":" in line:
  44. key, _, value = line.partition(":")
  45. result[key.strip()] = value.strip()
  46. return result
  47. def _extract_referenced_paths(text: str, base_dir: Path) -> list[tuple[str, Path]]:
  48. """从 markdown 中提取被引用的文件路径(references/, skills/, agents/ 等)。
  49. 返回 (raw_ref, resolved_path) 列表。
  50. """
  51. refs = []
  52. # 匹配 `references/xxx.md`、`../../references/xxx.md`、`skills/xxx` 等相对路径
  53. for m in re.finditer(r'[`"]((?:\.\./)*(?:references|skills|agents)/[^\s`"]+\.md)[`"]', text):
  54. raw = m.group(1)
  55. resolved = (base_dir / raw).resolve()
  56. refs.append((raw, resolved))
  57. # 匹配 references 段落中列出的路径(不带引号)
  58. for m in re.finditer(r'^- `((?:\.\./)*(?:references|skills|agents)/[^\s`]+\.md)`', text, re.MULTILINE):
  59. raw = m.group(1)
  60. resolved = (base_dir / raw).resolve()
  61. refs.append((raw, resolved))
  62. return refs
  63. def _extract_cli_subcommands(text: str) -> list[str]:
  64. """从 prompt 中提取 webnovel.py 调用的子命令。"""
  65. cmds = set()
  66. for m in re.finditer(r'webnovel\.py["\s]+--project-root\s+[^\s]+\s+([a-z][\w-]*)', text):
  67. cmd = m.group(1)
  68. cmds.add(cmd)
  69. return sorted(cmds)
  70. # ---------------------------------------------------------------------------
  71. # 1. Frontmatter 完整性
  72. # ---------------------------------------------------------------------------
  73. @pytest.mark.parametrize("agent_file", AGENT_FILES, ids=lambda f: f.name)
  74. def test_agent_frontmatter_complete(agent_file: Path):
  75. """每个 agent 必须有 name, description, tools。"""
  76. fm = _extract_frontmatter(_read_text(agent_file))
  77. assert "name" in fm, f"{agent_file.name}: 缺少 name"
  78. assert "description" in fm, f"{agent_file.name}: 缺少 description"
  79. assert "tools" in fm, f"{agent_file.name}: 缺少 tools"
  80. @pytest.mark.parametrize("skill_file", SKILL_FILES, ids=lambda f: f.parent.name)
  81. def test_skill_frontmatter_complete(skill_file: Path):
  82. """每个 skill 必须有 name, description。"""
  83. fm = _extract_frontmatter(_read_text(skill_file))
  84. assert "name" in fm, f"{skill_file.parent.name}: 缺少 name"
  85. assert "description" in fm, f"{skill_file.parent.name}: 缺少 description"
  86. # ---------------------------------------------------------------------------
  87. # 2. Agent 模板结构(9 段)
  88. # ---------------------------------------------------------------------------
  89. EXPECTED_AGENT_SECTIONS = [
  90. "1. 身份与目标",
  91. "2. 可用工具",
  92. "3. 思维链",
  93. "4. 输入",
  94. "5. 执行流程",
  95. "6. 边界与禁区",
  96. "7. 检查清单",
  97. "8. 输出格式",
  98. "9. 错误处理",
  99. ]
  100. @pytest.mark.parametrize("agent_file", AGENT_FILES, ids=lambda f: f.name)
  101. def test_agent_template_structure(agent_file: Path):
  102. """每个 agent 必须包含 9 个编号段。"""
  103. text = _read_text(agent_file)
  104. missing = []
  105. for section in EXPECTED_AGENT_SECTIONS:
  106. # 匹配 "## 1. 身份与目标" 或 "## 2. 可用工具与脚本"(允许后缀)
  107. pattern = rf"^## {re.escape(section)}"
  108. if not re.search(pattern, text, re.MULTILINE):
  109. missing.append(section)
  110. assert not missing, f"{agent_file.name}: 缺少段落 {missing}"
  111. # ---------------------------------------------------------------------------
  112. # 3. 引用完整性
  113. # ---------------------------------------------------------------------------
  114. @pytest.mark.parametrize("prompt_file", ALL_PROMPT_FILES, ids=lambda f: f.name)
  115. def test_all_references_exist(prompt_file: Path):
  116. """prompt 中引用的所有文件路径都必须真实存在。"""
  117. text = _read_text(prompt_file)
  118. base_dir = prompt_file.parent
  119. refs = _extract_referenced_paths(text, base_dir)
  120. missing = []
  121. for raw, resolved in refs:
  122. if not resolved.exists():
  123. missing.append(raw)
  124. assert not missing, f"{prompt_file.name}: 引用了不存在的文件 {missing}"
  125. # ---------------------------------------------------------------------------
  126. # 4. CLI 命令有效性
  127. # ---------------------------------------------------------------------------
  128. @pytest.mark.parametrize("prompt_file", ALL_PROMPT_FILES, ids=lambda f: f.name)
  129. def test_cli_commands_valid(prompt_file: Path):
  130. """prompt 中的 webnovel.py 子命令都必须在 CLI 注册表中。"""
  131. text = _read_text(prompt_file)
  132. cmds = _extract_cli_subcommands(text)
  133. # 排除已知例外(如 webnovel-review 的 workflow 命令待重构)
  134. skill_name = prompt_file.parent.name
  135. exceptions = _KNOWN_CLI_EXCEPTIONS.get(skill_name, set())
  136. invalid = [c for c in cmds if c not in REGISTERED_CLI_SUBCOMMANDS and c not in exceptions]
  137. assert not invalid, f"{prompt_file.name}: 使用了未注册的 CLI 子命令 {invalid}"
  138. # ---------------------------------------------------------------------------
  139. # 5. Review Schema 一致性
  140. # ---------------------------------------------------------------------------
  141. def test_review_schema_consistency():
  142. """reviewer.md 输出格式中的字段必须与 review_schema.py 定义匹配。"""
  143. reviewer_text = _read_text(AGENTS_DIR / "reviewer.md")
  144. # 从 reviewer.md 的 JSON 示例中提取 issue 字段
  145. issue_fields_in_prompt = set()
  146. json_block = re.search(r'"issues":\s*\[\s*\{([^}]+)\}', reviewer_text, re.DOTALL)
  147. if json_block:
  148. for m in re.finditer(r'"(\w+)":', json_block.group(1)):
  149. issue_fields_in_prompt.add(m.group(1))
  150. # 从 review_schema.py 提取 ReviewIssue 字段
  151. schema_path = SCRIPTS_DIR / "data_modules" / "review_schema.py"
  152. schema_text = _read_text(schema_path)
  153. schema_fields = set()
  154. in_review_issue = False
  155. for line in schema_text.splitlines():
  156. if "class ReviewIssue" in line:
  157. in_review_issue = True
  158. continue
  159. if in_review_issue:
  160. if line.strip().startswith("class ") or line.strip().startswith("def "):
  161. break
  162. m = re.match(r"\s+(\w+):\s+", line)
  163. if m:
  164. schema_fields.add(m.group(1))
  165. # reviewer prompt 中的字段应该是 schema 字段的子集
  166. assert issue_fields_in_prompt, "无法从 reviewer.md 提取 issue 字段"
  167. assert schema_fields, "无法从 review_schema.py 提取字段"
  168. extra = issue_fields_in_prompt - schema_fields
  169. assert not extra, f"reviewer.md 中有字段不在 review_schema.py 中: {extra}"
  170. # ---------------------------------------------------------------------------
  171. # 6. 无残留引用(已删文件)
  172. # ---------------------------------------------------------------------------
  173. KNOWN_DELETED_FILES = [
  174. "step-1.5-contract.md",
  175. "step-3-review-gate.md",
  176. "step-5-debt-switch.md",
  177. "workflow-details.md",
  178. "checker-output-schema.md",
  179. "workflow_manager.py",
  180. "webnovel-resume",
  181. ]
  182. _KNOWN_CLI_EXCEPTIONS = {}
  183. @pytest.mark.parametrize("prompt_file", ALL_PROMPT_FILES, ids=lambda f: f.name)
  184. def test_no_stale_references(prompt_file: Path):
  185. """不得引用已知已删除的文件。"""
  186. text = _read_text(prompt_file)
  187. found = [name for name in KNOWN_DELETED_FILES if name in text]
  188. assert not found, f"{prompt_file.name}: 残留引用已删除文件 {found}"
  189. def test_webnovel_review_skill_uses_unified_reviewer_pipeline():
  190. """webnovel-review 必须与 webnovel-write 使用同一套 reviewer + review-pipeline 链路。"""
  191. skill_text = _read_text(SKILLS_DIR / "webnovel-review" / "SKILL.md")
  192. assert "`reviewer`" in skill_text
  193. assert "review-pipeline" in skill_text
  194. assert ".webnovel/tmp/review_results.json" in skill_text
  195. assert ".webnovel/tmp/review_metrics.json" in skill_text
  196. for legacy_agent in (
  197. "consistency-checker",
  198. "continuity-checker",
  199. "ooc-checker",
  200. "reader-pull-checker",
  201. "high-point-checker",
  202. "pacing-checker",
  203. ):
  204. assert legacy_agent not in skill_text
  205. assert " workflow " not in skill_text