test_prompt_integrity.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503
  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", "project-memory", "review-pipeline",
  30. "placeholder-scan", "master-outline-sync",
  31. "story-system", "chapter-commit", "story-events", "knowledge",
  32. }
  33. # ---------------------------------------------------------------------------
  34. # Helpers
  35. # ---------------------------------------------------------------------------
  36. def _read_text(path: Path) -> str:
  37. return path.read_text(encoding="utf-8")
  38. def _extract_frontmatter(text: str) -> dict:
  39. """提取 YAML frontmatter 为 dict。"""
  40. m = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
  41. if not m:
  42. return {}
  43. result = {}
  44. for line in m.group(1).splitlines():
  45. if ":" in line:
  46. key, _, value = line.partition(":")
  47. result[key.strip()] = value.strip()
  48. return result
  49. def _extract_referenced_paths(text: str, base_dir: Path) -> list[tuple[str, Path]]:
  50. """从 markdown 中提取被引用的文件路径(references/, skills/, agents/ 等)。
  51. 返回 (raw_ref, resolved_path) 列表。
  52. """
  53. refs = []
  54. # 匹配 `references/xxx.md`、`../../references/xxx.md`、`skills/xxx` 等相对路径
  55. for m in re.finditer(r'[`"]((?:\.\./)*(?:references|skills|agents)/[^\s`"]+\.md)[`"]', text):
  56. raw = m.group(1)
  57. resolved = (base_dir / raw).resolve()
  58. refs.append((raw, resolved))
  59. # 匹配 references 段落中列出的路径(不带引号)
  60. for m in re.finditer(r'^- `((?:\.\./)*(?:references|skills|agents)/[^\s`]+\.md)`', text, re.MULTILINE):
  61. raw = m.group(1)
  62. resolved = (base_dir / raw).resolve()
  63. refs.append((raw, resolved))
  64. return refs
  65. def _extract_cli_subcommands(text: str) -> list[str]:
  66. """从 prompt 中提取 webnovel.py 调用的子命令。"""
  67. cmds = set()
  68. for m in re.finditer(r'webnovel\.py["\s]+--project-root\s+[^\s]+\s+([a-z][\w-]*)', text):
  69. cmd = m.group(1)
  70. cmds.add(cmd)
  71. return sorted(cmds)
  72. # ---------------------------------------------------------------------------
  73. # 1. Frontmatter 完整性
  74. # ---------------------------------------------------------------------------
  75. @pytest.mark.parametrize("agent_file", AGENT_FILES, ids=lambda f: f.name)
  76. def test_agent_frontmatter_complete(agent_file: Path):
  77. """每个 agent 必须有 name, description, tools。"""
  78. fm = _extract_frontmatter(_read_text(agent_file))
  79. assert "name" in fm, f"{agent_file.name}: 缺少 name"
  80. assert "description" in fm, f"{agent_file.name}: 缺少 description"
  81. assert "tools" in fm, f"{agent_file.name}: 缺少 tools"
  82. @pytest.mark.parametrize("skill_file", SKILL_FILES, ids=lambda f: f.parent.name)
  83. def test_skill_frontmatter_complete(skill_file: Path):
  84. """每个 skill 必须有 name, description。"""
  85. fm = _extract_frontmatter(_read_text(skill_file))
  86. assert "name" in fm, f"{skill_file.parent.name}: 缺少 name"
  87. assert "description" in fm, f"{skill_file.parent.name}: 缺少 description"
  88. # ---------------------------------------------------------------------------
  89. # 2. Agent 模板结构(9 段)
  90. # ---------------------------------------------------------------------------
  91. EXPECTED_AGENT_SECTIONS = [
  92. "1.",
  93. "2.",
  94. "3.",
  95. "4.",
  96. "5.",
  97. "6.",
  98. "7.",
  99. "8.",
  100. ]
  101. @pytest.mark.parametrize("agent_file", AGENT_FILES, ids=lambda f: f.name)
  102. def test_agent_template_structure(agent_file: Path):
  103. """每个 agent 至少包含 8 个编号段。"""
  104. text = _read_text(agent_file)
  105. missing = []
  106. for section in EXPECTED_AGENT_SECTIONS:
  107. # 匹配 "## 1. 身份与目标" 或 "## 2. 可用工具与脚本"(允许后缀)
  108. pattern = rf"^## {re.escape(section)}"
  109. if not re.search(pattern, text, re.MULTILINE):
  110. missing.append(section)
  111. assert not missing, f"{agent_file.name}: 缺少段落 {missing}"
  112. # ---------------------------------------------------------------------------
  113. # 3. 引用完整性
  114. # ---------------------------------------------------------------------------
  115. @pytest.mark.parametrize("prompt_file", ALL_PROMPT_FILES, ids=lambda f: f.name)
  116. def test_all_references_exist(prompt_file: Path):
  117. """prompt 中引用的所有文件路径都必须真实存在。"""
  118. text = _read_text(prompt_file)
  119. base_dir = prompt_file.parent
  120. refs = _extract_referenced_paths(text, base_dir)
  121. missing = []
  122. for raw, resolved in refs:
  123. if not resolved.exists():
  124. missing.append(raw)
  125. assert not missing, f"{prompt_file.name}: 引用了不存在的文件 {missing}"
  126. # ---------------------------------------------------------------------------
  127. # 4. CLI 命令有效性
  128. # ---------------------------------------------------------------------------
  129. @pytest.mark.parametrize("prompt_file", ALL_PROMPT_FILES, ids=lambda f: f.name)
  130. def test_cli_commands_valid(prompt_file: Path):
  131. """prompt 中的 webnovel.py 子命令都必须在 CLI 注册表中。"""
  132. text = _read_text(prompt_file)
  133. cmds = _extract_cli_subcommands(text)
  134. # 排除已知例外(如 webnovel-review 的 workflow 命令待重构)
  135. skill_name = prompt_file.parent.name
  136. exceptions = _KNOWN_CLI_EXCEPTIONS.get(skill_name, set())
  137. invalid = [c for c in cmds if c not in REGISTERED_CLI_SUBCOMMANDS and c not in exceptions]
  138. assert not invalid, f"{prompt_file.name}: 使用了未注册的 CLI 子命令 {invalid}"
  139. # ---------------------------------------------------------------------------
  140. # 5. Review Schema 一致性
  141. # ---------------------------------------------------------------------------
  142. def test_review_schema_consistency():
  143. """reviewer.md 输出格式中的字段必须与 review_schema.py 定义匹配。"""
  144. reviewer_text = _read_text(AGENTS_DIR / "reviewer.md")
  145. # 从 reviewer.md 的 JSON 示例中提取 issue 字段
  146. issue_fields_in_prompt = set()
  147. json_block = re.search(r'"issues":\s*\[\s*\{([^}]+)\}', reviewer_text, re.DOTALL)
  148. if json_block:
  149. for m in re.finditer(r'"(\w+)":', json_block.group(1)):
  150. issue_fields_in_prompt.add(m.group(1))
  151. # 从 review_schema.py 提取 ReviewIssue 字段
  152. schema_path = SCRIPTS_DIR / "data_modules" / "review_schema.py"
  153. schema_text = _read_text(schema_path)
  154. schema_fields = set()
  155. in_review_issue = False
  156. for line in schema_text.splitlines():
  157. if "class ReviewIssue" in line:
  158. in_review_issue = True
  159. continue
  160. if in_review_issue:
  161. if line.strip().startswith("class ") or line.strip().startswith("def "):
  162. break
  163. m = re.match(r"\s+(\w+):\s+", line)
  164. if m:
  165. schema_fields.add(m.group(1))
  166. # reviewer prompt 中的字段应该是 schema 字段的子集
  167. assert issue_fields_in_prompt, "无法从 reviewer.md 提取 issue 字段"
  168. assert schema_fields, "无法从 review_schema.py 提取字段"
  169. extra = issue_fields_in_prompt - schema_fields
  170. assert not extra, f"reviewer.md 中有字段不在 review_schema.py 中: {extra}"
  171. # ---------------------------------------------------------------------------
  172. # 6. 无残留引用(已删文件)
  173. # ---------------------------------------------------------------------------
  174. KNOWN_DELETED_FILES = [
  175. "step-1.5-contract.md",
  176. "step-3-review-gate.md",
  177. "step-5-debt-switch.md",
  178. "workflow-details.md",
  179. "checker-output-schema.md",
  180. "workflow_manager.py",
  181. "webnovel-resume",
  182. "golden_three_checker.py",
  183. "snapshot_manager.py",
  184. ]
  185. _KNOWN_CLI_EXCEPTIONS = {}
  186. @pytest.mark.parametrize("prompt_file", ALL_PROMPT_FILES, ids=lambda f: f.name)
  187. def test_no_stale_references(prompt_file: Path):
  188. """不得引用已知已删除的文件。"""
  189. text = _read_text(prompt_file)
  190. found = [name for name in KNOWN_DELETED_FILES if name in text]
  191. assert not found, f"{prompt_file.name}: 残留引用已删除文件 {found}"
  192. def test_webnovel_review_skill_uses_unified_reviewer_pipeline():
  193. """webnovel-review 必须与 webnovel-write 使用同一套 reviewer + review-pipeline 链路。"""
  194. skill_text = _read_text(SKILLS_DIR / "webnovel-review" / "SKILL.md")
  195. assert "`reviewer`" in skill_text
  196. assert "Agent(" in skill_text
  197. assert 'subagent_type: "webnovel-writer:reviewer"' in skill_text
  198. assert "review-pipeline" in skill_text
  199. assert ".webnovel/tmp/review_results.json" in skill_text
  200. assert ".webnovel/tmp/review_metrics.json" in skill_text
  201. for legacy_agent in (
  202. "consistency-checker",
  203. "continuity-checker",
  204. "ooc-checker",
  205. "reader-pull-checker",
  206. "high-point-checker",
  207. "pacing-checker",
  208. ):
  209. assert legacy_agent not in skill_text
  210. assert " workflow " not in skill_text
  211. def test_active_skills_use_agent_tool_name_not_legacy_task():
  212. """Claude Code 2.1.63+ 将 Task 工具改名为 Agent;active skills 不应再声明 Task。"""
  213. for skill_file in SKILL_FILES:
  214. text = _read_text(skill_file)
  215. fm = _extract_frontmatter(text)
  216. allowed_tools = fm.get("allowed-tools", "")
  217. assert "Task" not in allowed_tools, f"{skill_file.parent.name}: allowed-tools 仍声明 Task"
  218. assert "Task 调用" not in text, f"{skill_file.parent.name}: 仍使用软性的 Task 调用描述"
  219. assert "必须通过 `Task`" not in text, f"{skill_file.parent.name}: 仍要求旧 Task 工具名"
  220. def test_webnovel_write_skill_uses_explicit_agent_invocation_templates():
  221. """webnovel-write 的关键 subagent 必须用显式 Agent(subagent_type=...) 调用模板。"""
  222. text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
  223. fm = _extract_frontmatter(text)
  224. assert "Agent" in fm.get("allowed-tools", "")
  225. for subagent in ("context-agent", "reviewer", "data-agent"):
  226. assert f'subagent_type: "webnovel-writer:{subagent}"' in text
  227. assert f'subagent_type: "{subagent}"' not in text
  228. assert "不得用主流程口头代替 subagent 输出" in text
  229. def test_story_system_runtime_contract_commands_exist():
  230. text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  231. assert "story-system" in text
  232. assert "--emit-runtime-contracts" in text
  233. def test_webnovel_write_skill_uses_chapter_commit_as_step5_mainline():
  234. text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  235. assert "chapter-commit" in text
  236. assert "CHAPTER_COMMIT" in text
  237. assert "state process-chapter" not in text
  238. def test_webnovel_write_skill_uses_project_root_backup_not_bare_git_add():
  239. text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  240. assert "webnovel.py" in text
  241. assert "--project-root \"${PROJECT_ROOT}\" backup" in text
  242. assert "git add ." not in text
  243. def test_webnovel_query_skill_prefers_story_system_and_memory_contract():
  244. text = (SKILLS_DIR / "webnovel-query" / "SKILL.md").read_text(encoding="utf-8")
  245. assert "memory-contract load-context" in text
  246. assert ".story-system/" in text
  247. assert 'cat "$PROJECT_ROOT/.webnovel/state.json"' not in text
  248. def test_context_agent_prefers_contract_and_latest_commit_mainline():
  249. text = (AGENTS_DIR / "context-agent.md").read_text(encoding="utf-8")
  250. assert "story_contracts" in text or ".story-system/" in text
  251. assert "CHAPTER_COMMIT" in text or "chapter-commit" in text
  252. assert "load-context" in text
  253. def test_context_agent_loads_fixed_guides_and_outputs_writer_brief():
  254. text = (AGENTS_DIR / "context-agent.md").read_text(encoding="utf-8")
  255. # core-constraints 和 anti-ai-guide 已内化为"写作铁律"段落
  256. assert "写作铁律" in text or "Anti-AI" in text
  257. assert "写作任务书" in text
  258. assert "Step 2 直写提示词" not in text
  259. assert "Context Contract" not in text
  260. def test_agents_do_not_name_nonexistent_writing_dna_files():
  261. for filename in ("context-agent.md", "reviewer.md"):
  262. text = (AGENTS_DIR / filename).read_text(encoding="utf-8")
  263. assert "P20_WRITING_DNA" not in text
  264. assert "WRITING_DNA.md" not in text
  265. assert ".claude/rules/P20_" not in text
  266. def test_data_agent_is_described_as_extraction_only_not_direct_write_mainline():
  267. text = (AGENTS_DIR / "data-agent.md").read_text(encoding="utf-8")
  268. assert "chapter-commit" in text
  269. assert "extraction_result.json" in text
  270. assert "planned_nodes" in text
  271. assert "missed_nodes" in text
  272. assert "pending" in text
  273. assert "event_id" in text
  274. assert "event_type" in text
  275. assert "subject" in text
  276. assert "直接写入 index.db 和 state.json" not in text
  277. def test_webnovel_write_data_agent_prompt_requires_extraction_schema():
  278. text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  279. assert "webnovel-writer:data-agent" in text
  280. assert "fulfillment_result.json 必须顶层" in text
  281. assert "planned_nodes/covered_nodes/missed_nodes/extra_nodes" in text
  282. assert "disambiguation_result.json 必须顶层包含 pending" in text
  283. assert "extraction_result.json 必须严格" in text
  284. assert "accepted_events/state_deltas/entity_deltas" in text
  285. assert "禁止包在 chapter/fulfillment/disambiguation/extraction" in text
  286. assert "event_id/chapter/event_type/subject/payload" in text
  287. def test_dashboard_and_plan_skills_surface_story_runtime_mainline():
  288. dashboard_text = (SKILLS_DIR / "webnovel-dashboard" / "SKILL.md").read_text(encoding="utf-8")
  289. plan_text = (SKILLS_DIR / "webnovel-plan" / "SKILL.md").read_text(encoding="utf-8")
  290. assert "story-runtime/health" in dashboard_text
  291. assert ".story-system/" in plan_text
  292. def test_webnovel_write_skill_routes_step2_through_writing_brief():
  293. text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  294. assert "写作任务书" in text
  295. assert "context-agent" in text
  296. assert "Step 0.5" not in text
  297. assert 'cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"' not in text
  298. assert 'cat "${SKILL_ROOT}/references/anti-ai-guide.md"' not in text
  299. def test_context_agent_and_write_skill_form_isolated_write_chain():
  300. context_text = (AGENTS_DIR / "context-agent.md").read_text(encoding="utf-8")
  301. skill_text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  302. assert "写作任务书" in context_text
  303. assert "写作任务书" in skill_text
  304. assert "context-agent" in skill_text
  305. assert "Context Contract" not in context_text
  306. assert "Step 2 直写提示词" not in context_text
  307. def test_no_direct_state_writes_in_write_skill():
  308. """webnovel-write SKILL.md 中不应有 set-chapter-status 调用。"""
  309. text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  310. assert "state set-chapter-status" not in text, (
  311. "webnovel-write 中不应直接调用 state set-chapter-status,"
  312. "chapter_status 由 state_projection_writer 在 commit 时自动推进"
  313. )
  314. def test_no_direct_state_writes_in_agents():
  315. """agents 目录中不应有直接写 state/index 的指令。"""
  316. for agent_file in AGENT_FILES:
  317. text = _read_text(agent_file)
  318. assert "state set-chapter-status" not in text, (
  319. f"{agent_file.name}: 不应直接调用 state set-chapter-status"
  320. )
  321. def test_deconstruction_agent_preserves_init_handoff_and_boundaries():
  322. """reference deconstruction must remain extraction-only and init-scoped."""
  323. text = _read_text(AGENTS_DIR / "deconstruction-agent.md")
  324. assert "init_reference_research" in text
  325. assert ".webnovel/tmp/reference_analyses/<safe-title>/" not in text
  326. assert "不写任何文件" in text
  327. assert "不得写 `_progress.md`" in text
  328. assert "resume_state" in text
  329. assert "tools: Read, Grep, Bash" in text
  330. assert "快速模式" in text
  331. assert "深度模式" in text
  332. assert "黄金三章" in text
  333. assert "情节点" in text
  334. assert "质量门控" in text
  335. assert "不得凭记忆" in text
  336. assert "条件框架" in text
  337. assert "情绪链条" in text
  338. assert "核心梗边界" in text
  339. for field in (
  340. "reader_promise",
  341. "opening_hook_patterns",
  342. "cool_point_loops",
  343. "protagonist_patterns",
  344. "antagonist_pressure_patterns",
  345. "pacing_notes",
  346. "borrowable_structures",
  347. "do_not_copy",
  348. "differentiation_requirements",
  349. "init_candidates",
  350. "quality",
  351. "resume_state",
  352. "orphan_plot_fallback",
  353. "canon_contamination_warnings",
  354. ):
  355. assert f'"{field}"' in text
  356. for forbidden_path in (
  357. ".story-system/",
  358. "设定集/",
  359. "大纲/",
  360. "正文/",
  361. ".webnovel/",
  362. ):
  363. assert forbidden_path in text
  364. assert "不写 `idea_bank.json`" in text
  365. assert "用户确认后" in text
  366. assert "MIT License attribution" not in text
  367. def test_webnovel_init_deconstruction_wiring_keeps_confirmation_gate():
  368. """init may consume only confirmed, transformed reference patterns."""
  369. text = _read_text(SKILLS_DIR / "webnovel-init" / "SKILL.md")
  370. assert 'subagent_type: "webnovel-writer:deconstruction-agent"' in text
  371. assert "Step 1.5:灵感来源询问" in text
  372. assert "进入故事核采集前" in text
  373. assert "不要默认拆书" in text
  374. assert "你这本书的灵感来源想从哪里开始" in text
  375. assert "init_reference_research" in text
  376. assert "init_reference_research JSON 对象" in text
  377. assert ".webnovel/tmp/reference_analyses/<safe-title>/" not in text
  378. assert "project_root=${PROJECT_ROOT" not in text
  379. assert "不写任何文件" in text
  380. assert "不得由 init 主流程口头替代拆解结果" in text
  381. assert "`quality`" in text
  382. assert "`quality.passed=false`" in text
  383. assert "`confidence < 0.85`" in text
  384. for handoff_field in (
  385. "reader_promise",
  386. "opening_hook_patterns",
  387. "cool_point_loops",
  388. "protagonist_patterns",
  389. "antagonist_pressure_patterns",
  390. "pacing_notes",
  391. "borrowable_structures",
  392. "differentiation_requirements",
  393. "init_candidates",
  394. ):
  395. assert handoff_field in text
  396. for forbidden_path in (
  397. "idea_bank.json",
  398. ".story-system",
  399. "设定集",
  400. "大纲",
  401. "正文",
  402. ".webnovel/state.json",
  403. ):
  404. assert forbidden_path in text
  405. assert "用户确认前" in text
  406. assert "Step 2-6 只能使用用户确认过、并已变形为本书差异化表达的模式" in text
  407. assert "汇总 Step 1.5 已确认的灵感来源" in text