test_prompt_integrity.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649
  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", "project-status", "doctor", "write-gate", "projections", "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. """关键 subagent 必须经 Agent 工具按注册名 webnovel-writer:X 显式调用;不再用伪函数 subagent_type 块(plan §4.4.2/§8.4)。"""
  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"webnovel-writer:{subagent}" in text, f"缺少 {subagent} 的注册名显式调用"
  227. assert "subagent_type:" not in text, "不应再使用伪函数 subagent_type 调用块"
  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. # (已按 plan §12.2 退役) test_webnovel_write_data_agent_prompt_requires_extraction_schema:
  278. # 该测试逐字要求主 Skill 写出 data artifact 的 schema 字段名,与判据一冲突。schema 字段保障已迁到
  279. # data-agent.md 生产方(test_data_agent_is_described_as_extraction_only_not_direct_write_mainline)
  280. # + precommit 负向用例(Task 7)。主 Skill 不再内联长 schema。
  281. def test_dashboard_and_plan_skills_surface_story_runtime_mainline():
  282. dashboard_text = (SKILLS_DIR / "webnovel-dashboard" / "SKILL.md").read_text(encoding="utf-8")
  283. plan_text = (SKILLS_DIR / "webnovel-plan" / "SKILL.md").read_text(encoding="utf-8")
  284. assert "story-runtime/health" in dashboard_text
  285. assert ".story-system/" in plan_text
  286. def test_webnovel_write_skill_routes_step2_through_writing_brief():
  287. text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  288. assert "写作任务书" in text
  289. assert "context-agent" in text
  290. assert "Step 0.5" not in text
  291. assert 'cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"' not in text
  292. assert 'cat "${SKILL_ROOT}/references/anti-ai-guide.md"' not in text
  293. def test_context_agent_and_write_skill_form_isolated_write_chain():
  294. context_text = (AGENTS_DIR / "context-agent.md").read_text(encoding="utf-8")
  295. skill_text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  296. assert "写作任务书" in context_text
  297. assert "写作任务书" in skill_text
  298. assert "context-agent" in skill_text
  299. assert "Context Contract" not in context_text
  300. assert "Step 2 直写提示词" not in context_text
  301. def test_no_direct_state_writes_in_write_skill():
  302. """webnovel-write SKILL.md 中不应有 set-chapter-status 调用。"""
  303. text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
  304. assert "state set-chapter-status" not in text, (
  305. "webnovel-write 中不应直接调用 state set-chapter-status,"
  306. "chapter_status 由 state_projection_writer 在 commit 时自动推进"
  307. )
  308. def test_no_direct_state_writes_in_agents():
  309. """agents 目录中不应有直接写 state/index 的指令。"""
  310. for agent_file in AGENT_FILES:
  311. text = _read_text(agent_file)
  312. assert "state set-chapter-status" not in text, (
  313. f"{agent_file.name}: 不应直接调用 state set-chapter-status"
  314. )
  315. def test_deconstruction_agent_preserves_init_handoff_and_boundaries():
  316. """reference deconstruction must remain extraction-only and init-scoped."""
  317. text = _read_text(AGENTS_DIR / "deconstruction-agent.md")
  318. assert "init_reference_research" in text
  319. assert ".webnovel/tmp/reference_analyses/<safe-title>/" not in text
  320. assert "不写任何文件" in text
  321. assert "不得写 `_progress.md`" in text
  322. assert "resume_state" in text
  323. assert "tools: Read, Grep, Bash" in text
  324. assert "快速模式" in text
  325. assert "深度模式" in text
  326. assert "黄金三章" in text
  327. assert "情节点" in text
  328. assert "质量门控" in text
  329. assert "不得凭记忆" in text
  330. assert "条件框架" in text
  331. assert "情绪链条" in text
  332. assert "核心梗边界" in text
  333. for field in (
  334. "reader_promise",
  335. "opening_hook_patterns",
  336. "cool_point_loops",
  337. "protagonist_patterns",
  338. "antagonist_pressure_patterns",
  339. "pacing_notes",
  340. "borrowable_structures",
  341. "do_not_copy",
  342. "differentiation_requirements",
  343. "init_candidates",
  344. "quality",
  345. "resume_state",
  346. "orphan_plot_fallback",
  347. "canon_contamination_warnings",
  348. ):
  349. assert f'"{field}"' in text
  350. for forbidden_path in (
  351. ".story-system/",
  352. "设定集/",
  353. "大纲/",
  354. "正文/",
  355. ".webnovel/",
  356. ):
  357. assert forbidden_path in text
  358. assert "不写 `idea_bank.json`" in text
  359. assert "用户确认后" in text
  360. assert "MIT License attribution" not in text
  361. def test_webnovel_init_deconstruction_wiring_keeps_confirmation_gate():
  362. """init may consume only confirmed, transformed reference patterns."""
  363. text = _read_text(SKILLS_DIR / "webnovel-init" / "SKILL.md")
  364. assert 'subagent_type: "webnovel-writer:deconstruction-agent"' in text
  365. assert "Step 1.5:灵感来源询问" in text
  366. assert "进入故事核采集前" in text
  367. assert "不要默认拆书" in text
  368. assert "你这本书的灵感来源想从哪里开始" in text
  369. assert "init_reference_research" in text
  370. assert "init_reference_research JSON 对象" in text
  371. assert ".webnovel/tmp/reference_analyses/<safe-title>/" not in text
  372. assert "project_root=${PROJECT_ROOT" not in text
  373. assert "不写任何文件" in text
  374. assert "不得由 init 主流程口头替代拆解结果" in text
  375. assert "`quality`" in text
  376. assert "`quality.passed=false`" in text
  377. assert "`confidence < 0.85`" in text
  378. for handoff_field in (
  379. "reader_promise",
  380. "opening_hook_patterns",
  381. "cool_point_loops",
  382. "protagonist_patterns",
  383. "antagonist_pressure_patterns",
  384. "pacing_notes",
  385. "borrowable_structures",
  386. "differentiation_requirements",
  387. "init_candidates",
  388. ):
  389. assert handoff_field in text
  390. for forbidden_path in (
  391. "idea_bank.json",
  392. ".story-system",
  393. "设定集",
  394. "大纲",
  395. "正文",
  396. ".webnovel/state.json",
  397. ):
  398. assert forbidden_path in text
  399. assert "用户确认前" in text
  400. assert "Step 2-6 只能使用用户确认过、并已变形为本书差异化表达的模式" in text
  401. assert "汇总 Step 1.5 已确认的灵感来源" in text
  402. # ---------------------------------------------------------------------------
  403. # 7. A 类跨层红线:行为/契约级断言(Phase 0 守护)
  404. # 这些断言守护「已实现」的业务红线,全部应为绿。优先断言结构不变量
  405. # (命令存在/顺序、节点 schema、变量化的真实参数),不做脆弱的文案匹配。
  406. # ---------------------------------------------------------------------------
  407. # A 类红线 2:placeholder-scan 必须出现在 plan 与 write 两层的关键节点。
  408. def test_placeholder_scan_runs_in_both_plan_and_write_skills():
  409. """红线 2:plan 与 write 都必须显式调用 placeholder-scan CLI。"""
  410. plan_text = _read_text(SKILLS_DIR / "webnovel-plan" / "SKILL.md")
  411. write_text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
  412. for name, text in (("webnovel-plan", plan_text), ("webnovel-write", write_text)):
  413. cmds = _extract_cli_subcommands(text)
  414. assert "placeholder-scan" in cmds, (
  415. f"{name}: 关键节点缺少 placeholder-scan CLI 调用"
  416. )
  417. # A 类红线 3:story-system 章级刷新必须传入真实 CHAPTER_GOAL 变量,
  418. # 不得把 {章纲目标} / 第N章章纲目标 这类占位文本当作 positional query。
  419. @pytest.mark.parametrize("skill_name", ["webnovel-plan", "webnovel-write"])
  420. def test_story_system_chapter_refresh_uses_real_goal_not_placeholder_query(skill_name: str):
  421. """红线 3:story-system 的 query 实参是 ${CHAPTER_GOAL} 变量,且禁占位文本写在命令里。"""
  422. text = _read_text(SKILLS_DIR / skill_name / "SKILL.md")
  423. # 命令必须用变量化的真实目标作为 query 实参
  424. assert 'story-system "${CHAPTER_GOAL}"' in text, (
  425. f"{skill_name}: story-system 未使用真实 ${{CHAPTER_GOAL}} 作为 query 实参"
  426. )
  427. # 占位 query 绝不能作为 story-system 的 positional 实参出现
  428. for placeholder in ("{章纲目标}", "第N章章纲目标"):
  429. assert f'story-system "{placeholder}"' not in text, (
  430. f"{skill_name}: story-system 不得把占位文本 {placeholder} 当作 query"
  431. )
  432. # 必须显式声明「禁止占位 query」这一约束(断言事实存在,不锁具体措辞)
  433. assert "{章纲目标}" in text and "第N章章纲目标" in text, (
  434. f"{skill_name}: 缺少对占位 query 的明确禁止说明"
  435. )
  436. # A 类红线 4:story-system 章级刷新必须 --persist 且 --emit-runtime-contracts。
  437. @pytest.mark.parametrize("skill_name", ["webnovel-plan", "webnovel-write"])
  438. def test_story_system_chapter_refresh_persists_runtime_contracts(skill_name: str):
  439. """红线 4:章级 story-system 刷新必须同时 --persist 与 --emit-runtime-contracts。"""
  440. text = _read_text(SKILLS_DIR / skill_name / "SKILL.md")
  441. cmd_start = text.find('story-system "${CHAPTER_GOAL}"')
  442. assert cmd_start >= 0, f"{skill_name}: 缺少章级 story-system 调用"
  443. # 取该调用所在的命令行(到下一空行/段落结束),断言两个关键开关都在
  444. cmd_tail = text[cmd_start:cmd_start + 400]
  445. assert "--persist" in cmd_tail, f"{skill_name}: 章级 story-system 缺少 --persist"
  446. assert "--emit-runtime-contracts" in cmd_tail, (
  447. f"{skill_name}: 章级 story-system 缺少 --emit-runtime-contracts"
  448. )
  449. assert "--chapter" in cmd_tail, f"{skill_name}: 章级 story-system 缺少 --chapter"
  450. # A 类红线 5:write-gate 三道闸门必须齐全且顺序为 prewrite→precommit→postcommit。
  451. def test_write_skill_gate_stages_ordered_prewrite_precommit_postcommit():
  452. """红线 5:write-gate 三道 gate 顺序不可乱。"""
  453. text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
  454. prewrite = text.find("write-gate --chapter {chapter_num} --stage prewrite")
  455. precommit = text.find("write-gate --chapter {chapter_num} --stage precommit")
  456. postcommit = text.find("write-gate --chapter {chapter_num} --stage postcommit")
  457. assert prewrite >= 0, "缺少 prewrite gate"
  458. assert precommit >= 0, "缺少 precommit gate"
  459. assert postcommit >= 0, "缺少 postcommit gate"
  460. assert prewrite < precommit < postcommit, (
  461. "write-gate 三道 gate 顺序必须为 prewrite→precommit→postcommit"
  462. )
  463. # A 类红线 7:reviewer 原始 JSON 必须经 review-pipeline --save-metrics 落库(write 与 review 两层)。
  464. @pytest.mark.parametrize("skill_name", ["webnovel-write", "webnovel-review"])
  465. def test_review_pipeline_persists_metrics_in_review_chain(skill_name: str):
  466. """红线 7:reviewer JSON 经 review-pipeline --save-metrics 落库。"""
  467. text = _read_text(SKILLS_DIR / skill_name / "SKILL.md")
  468. cmds = _extract_cli_subcommands(text)
  469. assert "review-pipeline" in cmds, f"{skill_name}: 缺少 review-pipeline CLI 调用"
  470. assert "--save-metrics" in text, f"{skill_name}: review-pipeline 未带 --save-metrics 落库"
  471. # A 类红线 10:postcommit 必须验证 projection 五项;失败只 projections retry。
  472. def test_write_skill_postcommit_verifies_five_projections_and_retry_only():
  473. """红线 10:projection 五项(state/index/summary/memory/vector)验证,失败只 retry。"""
  474. text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
  475. assert "state/index/summary/memory/vector" in text, (
  476. "缺少 projection 五项(state/index/summary/memory/vector)验证说明"
  477. )
  478. # 失败兜底唯一手段是 projections retry(命令以续行书写,直接断言字面调用)
  479. assert "projections retry --chapter {chapter_num}" in text, (
  480. "projection 失败兜底必须是 projections retry --chapter {chapter_num}"
  481. )
  482. # A 类红线 12:plan 必须覆盖节拍表/时间线/结构化章纲节点/结构化总纲写回/状态更新。
  483. def test_plan_skill_covers_outline_writeback_and_state_sync_contract():
  484. """红线 12:plan 的节拍表/时间线/章纲节点/总纲写回 JSON/master-outline-sync/update-state。"""
  485. text = _read_text(SKILLS_DIR / "webnovel-plan" / "SKILL.md")
  486. # 节拍表 / 时间线 输出物
  487. assert "大纲/第{volume_id}卷-节拍表.md" in text
  488. assert "大纲/第{volume_id}卷-时间线.md" in text
  489. # 结构化章纲节点
  490. for node in ("CBN", "CPNs", "CEN", "必须覆盖节点", "本章禁区"):
  491. assert node in text, f"plan 缺少结构化章纲节点标记 {node}"
  492. # 结构化总纲写回文件(不可从自由文本推断伏笔)
  493. assert "大纲/第{volume_id}卷-总纲写回.json" in text
  494. # 设定写回 + 状态同步命令
  495. cmds = _extract_cli_subcommands(text)
  496. assert "master-outline-sync" in cmds, "plan 缺少 master-outline-sync 写回命令"
  497. assert "update-state" in cmds, "plan 缺少 update-state 状态更新命令"
  498. # ---------------------------------------------------------------------------
  499. # 8. B 类跨层新契约(plan §5.2-B / §4.5 写入所有权矩阵)
  500. # tools↔落盘一致性现状已满足 → 作通过型守护;
  501. # 提交前只读 git diff 变更面校验现状缺失 → xfail,Task 5(Phase 1)落地后移除标记转正。
  502. # ---------------------------------------------------------------------------
  503. def _agent_tools(agent_name: str) -> list[str]:
  504. """解析某 agent frontmatter 的 tools 列表。"""
  505. fm = _extract_frontmatter(_read_text(AGENTS_DIR / f"{agent_name}.md"))
  506. return [t.strip() for t in fm.get("tools", "").split(",") if t.strip()]
  507. # B 类红线(写入所有权 ↔ tools 一致,单一写入者):
  508. # data-agent 是三份 tmp artifact 的唯一写入者 → 必须持 Write;
  509. # reviewer/context-agent/deconstruction-agent 只返回结果、由主流程落盘 → 不得持 Write。
  510. def test_agent_write_ownership_matches_tools_frontmatter():
  511. """红线(写入所有权):仅 data-agent 持 Write,其余三个 agent 不持 Write。"""
  512. assert "Write" in _agent_tools("data-agent"), (
  513. "data-agent 必须持有 Write(它是三份 tmp artifact 的唯一写入者)"
  514. )
  515. for agent_name in ("reviewer", "context-agent", "deconstruction-agent"):
  516. assert "Write" not in _agent_tools(agent_name), (
  517. f"{agent_name} 不得持有 Write(它只返回结果,由主流程落盘)"
  518. )
  519. # B 类红线(提交前变更面校验):write SKILL 在 chapter-commit 前必须执行只读 git diff 变更面校验。
  520. # 现状 write SKILL 尚无此步 → 标 xfail;Task 5(Phase 1)实现后移除本标记,转为硬守护。
  521. # B 类红线(提交前变更面校验):write SKILL 在 chapter-commit 前必须执行只读 git diff 变更面校验。
  522. # Phase 1 (Task 5) 已落地 → 转为硬守护(移除 xfail 标记)。
  523. def test_write_skill_has_readonly_git_diff_change_surface_check():
  524. """红线(提交前变更面校验):write SKILL 在 chapter-commit 前执行只读 git diff 校验。"""
  525. text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
  526. assert "diff --name-status" in text, (
  527. "write SKILL 缺少提交前只读 git diff --name-status 变更面校验"
  528. )
  529. assert "diff --check" in text, (
  530. "write SKILL 缺少 git diff --check 空白/冲突标记校验"
  531. )