| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649 |
- #!/usr/bin/env python3
- # -*- coding: utf-8 -*-
- """
- Prompt 完整性静态校验。
- 验证 agents/*.md 和 skills/*/SKILL.md 的结构、引用、CLI 命令等,
- 不需要 LLM 调用,可加入 CI。
- """
- from __future__ import annotations
- import re
- import sys
- from pathlib import Path
- import pytest
- # ---------------------------------------------------------------------------
- # 基础路径
- # ---------------------------------------------------------------------------
- PLUGIN_ROOT = Path(__file__).resolve().parent.parent.parent.parent
- AGENTS_DIR = PLUGIN_ROOT / "agents"
- SKILLS_DIR = PLUGIN_ROOT / "skills"
- REFERENCES_DIR = PLUGIN_ROOT / "references"
- SCRIPTS_DIR = PLUGIN_ROOT / "scripts"
- AGENT_FILES = sorted(AGENTS_DIR.glob("*.md"))
- SKILL_FILES = sorted(SKILLS_DIR.glob("*/SKILL.md"))
- ALL_PROMPT_FILES = AGENT_FILES + SKILL_FILES
- # webnovel.py 注册的子命令(从 add_parser 提取)
- REGISTERED_CLI_SUBCOMMANDS = {
- "where", "preflight", "project-status", "doctor", "write-gate", "projections", "use",
- "index", "state", "rag", "style", "entity", "context", "memory",
- "migrate", "status", "update-state", "backup", "archive",
- "init", "extract-context", "memory-contract", "project-memory", "review-pipeline",
- "placeholder-scan", "master-outline-sync",
- "story-system", "chapter-commit", "story-events", "knowledge",
- }
- # ---------------------------------------------------------------------------
- # Helpers
- # ---------------------------------------------------------------------------
- def _read_text(path: Path) -> str:
- return path.read_text(encoding="utf-8")
- def _extract_frontmatter(text: str) -> dict:
- """提取 YAML frontmatter 为 dict。"""
- m = re.match(r"^---\s*\n(.*?)\n---", text, re.DOTALL)
- if not m:
- return {}
- result = {}
- for line in m.group(1).splitlines():
- if ":" in line:
- key, _, value = line.partition(":")
- result[key.strip()] = value.strip()
- return result
- def _extract_referenced_paths(text: str, base_dir: Path) -> list[tuple[str, Path]]:
- """从 markdown 中提取被引用的文件路径(references/, skills/, agents/ 等)。
- 返回 (raw_ref, resolved_path) 列表。
- """
- refs = []
- # 匹配 `references/xxx.md`、`../../references/xxx.md`、`skills/xxx` 等相对路径
- for m in re.finditer(r'[`"]((?:\.\./)*(?:references|skills|agents)/[^\s`"]+\.md)[`"]', text):
- raw = m.group(1)
- resolved = (base_dir / raw).resolve()
- refs.append((raw, resolved))
- # 匹配 references 段落中列出的路径(不带引号)
- for m in re.finditer(r'^- `((?:\.\./)*(?:references|skills|agents)/[^\s`]+\.md)`', text, re.MULTILINE):
- raw = m.group(1)
- resolved = (base_dir / raw).resolve()
- refs.append((raw, resolved))
- return refs
- def _extract_cli_subcommands(text: str) -> list[str]:
- """从 prompt 中提取 webnovel.py 调用的子命令。"""
- cmds = set()
- for m in re.finditer(r'webnovel\.py["\s]+--project-root\s+[^\s]+\s+([a-z][\w-]*)', text):
- cmd = m.group(1)
- cmds.add(cmd)
- return sorted(cmds)
- # ---------------------------------------------------------------------------
- # 1. Frontmatter 完整性
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize("agent_file", AGENT_FILES, ids=lambda f: f.name)
- def test_agent_frontmatter_complete(agent_file: Path):
- """每个 agent 必须有 name, description, tools。"""
- fm = _extract_frontmatter(_read_text(agent_file))
- assert "name" in fm, f"{agent_file.name}: 缺少 name"
- assert "description" in fm, f"{agent_file.name}: 缺少 description"
- assert "tools" in fm, f"{agent_file.name}: 缺少 tools"
- @pytest.mark.parametrize("skill_file", SKILL_FILES, ids=lambda f: f.parent.name)
- def test_skill_frontmatter_complete(skill_file: Path):
- """每个 skill 必须有 name, description。"""
- fm = _extract_frontmatter(_read_text(skill_file))
- assert "name" in fm, f"{skill_file.parent.name}: 缺少 name"
- assert "description" in fm, f"{skill_file.parent.name}: 缺少 description"
- # ---------------------------------------------------------------------------
- # 2. Agent 模板结构(9 段)
- # ---------------------------------------------------------------------------
- EXPECTED_AGENT_SECTIONS = [
- "1.",
- "2.",
- "3.",
- "4.",
- "5.",
- "6.",
- "7.",
- "8.",
- ]
- @pytest.mark.parametrize("agent_file", AGENT_FILES, ids=lambda f: f.name)
- def test_agent_template_structure(agent_file: Path):
- """每个 agent 至少包含 8 个编号段。"""
- text = _read_text(agent_file)
- missing = []
- for section in EXPECTED_AGENT_SECTIONS:
- # 匹配 "## 1. 身份与目标" 或 "## 2. 可用工具与脚本"(允许后缀)
- pattern = rf"^## {re.escape(section)}"
- if not re.search(pattern, text, re.MULTILINE):
- missing.append(section)
- assert not missing, f"{agent_file.name}: 缺少段落 {missing}"
- # ---------------------------------------------------------------------------
- # 3. 引用完整性
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize("prompt_file", ALL_PROMPT_FILES, ids=lambda f: f.name)
- def test_all_references_exist(prompt_file: Path):
- """prompt 中引用的所有文件路径都必须真实存在。"""
- text = _read_text(prompt_file)
- base_dir = prompt_file.parent
- refs = _extract_referenced_paths(text, base_dir)
- missing = []
- for raw, resolved in refs:
- if not resolved.exists():
- missing.append(raw)
- assert not missing, f"{prompt_file.name}: 引用了不存在的文件 {missing}"
- # ---------------------------------------------------------------------------
- # 4. CLI 命令有效性
- # ---------------------------------------------------------------------------
- @pytest.mark.parametrize("prompt_file", ALL_PROMPT_FILES, ids=lambda f: f.name)
- def test_cli_commands_valid(prompt_file: Path):
- """prompt 中的 webnovel.py 子命令都必须在 CLI 注册表中。"""
- text = _read_text(prompt_file)
- cmds = _extract_cli_subcommands(text)
- # 排除已知例外(如 webnovel-review 的 workflow 命令待重构)
- skill_name = prompt_file.parent.name
- exceptions = _KNOWN_CLI_EXCEPTIONS.get(skill_name, set())
- invalid = [c for c in cmds if c not in REGISTERED_CLI_SUBCOMMANDS and c not in exceptions]
- assert not invalid, f"{prompt_file.name}: 使用了未注册的 CLI 子命令 {invalid}"
- # ---------------------------------------------------------------------------
- # 5. Review Schema 一致性
- # ---------------------------------------------------------------------------
- def test_review_schema_consistency():
- """reviewer.md 输出格式中的字段必须与 review_schema.py 定义匹配。"""
- reviewer_text = _read_text(AGENTS_DIR / "reviewer.md")
- # 从 reviewer.md 的 JSON 示例中提取 issue 字段
- issue_fields_in_prompt = set()
- json_block = re.search(r'"issues":\s*\[\s*\{([^}]+)\}', reviewer_text, re.DOTALL)
- if json_block:
- for m in re.finditer(r'"(\w+)":', json_block.group(1)):
- issue_fields_in_prompt.add(m.group(1))
- # 从 review_schema.py 提取 ReviewIssue 字段
- schema_path = SCRIPTS_DIR / "data_modules" / "review_schema.py"
- schema_text = _read_text(schema_path)
- schema_fields = set()
- in_review_issue = False
- for line in schema_text.splitlines():
- if "class ReviewIssue" in line:
- in_review_issue = True
- continue
- if in_review_issue:
- if line.strip().startswith("class ") or line.strip().startswith("def "):
- break
- m = re.match(r"\s+(\w+):\s+", line)
- if m:
- schema_fields.add(m.group(1))
- # reviewer prompt 中的字段应该是 schema 字段的子集
- assert issue_fields_in_prompt, "无法从 reviewer.md 提取 issue 字段"
- assert schema_fields, "无法从 review_schema.py 提取字段"
- extra = issue_fields_in_prompt - schema_fields
- assert not extra, f"reviewer.md 中有字段不在 review_schema.py 中: {extra}"
- # ---------------------------------------------------------------------------
- # 6. 无残留引用(已删文件)
- # ---------------------------------------------------------------------------
- KNOWN_DELETED_FILES = [
- "step-1.5-contract.md",
- "step-3-review-gate.md",
- "step-5-debt-switch.md",
- "workflow-details.md",
- "checker-output-schema.md",
- "workflow_manager.py",
- "webnovel-resume",
- "golden_three_checker.py",
- "snapshot_manager.py",
- ]
- _KNOWN_CLI_EXCEPTIONS = {}
- @pytest.mark.parametrize("prompt_file", ALL_PROMPT_FILES, ids=lambda f: f.name)
- def test_no_stale_references(prompt_file: Path):
- """不得引用已知已删除的文件。"""
- text = _read_text(prompt_file)
- found = [name for name in KNOWN_DELETED_FILES if name in text]
- assert not found, f"{prompt_file.name}: 残留引用已删除文件 {found}"
- def test_webnovel_review_skill_uses_unified_reviewer_pipeline():
- """webnovel-review 必须与 webnovel-write 使用同一套 reviewer + review-pipeline 链路。"""
- skill_text = _read_text(SKILLS_DIR / "webnovel-review" / "SKILL.md")
- assert "`reviewer`" in skill_text
- assert "Agent(" in skill_text
- assert 'subagent_type: "webnovel-writer:reviewer"' in skill_text
- assert "review-pipeline" in skill_text
- assert ".webnovel/tmp/review_results.json" in skill_text
- assert ".webnovel/tmp/review_metrics.json" in skill_text
- for legacy_agent in (
- "consistency-checker",
- "continuity-checker",
- "ooc-checker",
- "reader-pull-checker",
- "high-point-checker",
- "pacing-checker",
- ):
- assert legacy_agent not in skill_text
- assert " workflow " not in skill_text
- def test_active_skills_use_agent_tool_name_not_legacy_task():
- """Claude Code 2.1.63+ 将 Task 工具改名为 Agent;active skills 不应再声明 Task。"""
- for skill_file in SKILL_FILES:
- text = _read_text(skill_file)
- fm = _extract_frontmatter(text)
- allowed_tools = fm.get("allowed-tools", "")
- assert "Task" not in allowed_tools, f"{skill_file.parent.name}: allowed-tools 仍声明 Task"
- assert "Task 调用" not in text, f"{skill_file.parent.name}: 仍使用软性的 Task 调用描述"
- assert "必须通过 `Task`" not in text, f"{skill_file.parent.name}: 仍要求旧 Task 工具名"
- def test_webnovel_write_skill_uses_explicit_agent_invocation_templates():
- """关键 subagent 必须经 Agent 工具按注册名 webnovel-writer:X 显式调用;不再用伪函数 subagent_type 块(plan §4.4.2/§8.4)。"""
- text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
- fm = _extract_frontmatter(text)
- assert "Agent" in fm.get("allowed-tools", "")
- for subagent in ("context-agent", "reviewer", "data-agent"):
- assert f"webnovel-writer:{subagent}" in text, f"缺少 {subagent} 的注册名显式调用"
- assert "subagent_type:" not in text, "不应再使用伪函数 subagent_type 调用块"
- assert "不得用主流程口头代替 subagent 输出" in text
- def test_story_system_runtime_contract_commands_exist():
- text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
- assert "story-system" in text
- assert "--emit-runtime-contracts" in text
- def test_webnovel_write_skill_uses_chapter_commit_as_step5_mainline():
- text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
- assert "chapter-commit" in text
- assert "CHAPTER_COMMIT" in text
- assert "state process-chapter" not in text
- def test_webnovel_write_skill_uses_project_root_backup_not_bare_git_add():
- text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
- assert "webnovel.py" in text
- assert "--project-root \"${PROJECT_ROOT}\" backup" in text
- assert "git add ." not in text
- def test_webnovel_query_skill_prefers_story_system_and_memory_contract():
- text = (SKILLS_DIR / "webnovel-query" / "SKILL.md").read_text(encoding="utf-8")
- assert "memory-contract load-context" in text
- assert ".story-system/" in text
- assert 'cat "$PROJECT_ROOT/.webnovel/state.json"' not in text
- def test_context_agent_prefers_contract_and_latest_commit_mainline():
- text = (AGENTS_DIR / "context-agent.md").read_text(encoding="utf-8")
- assert "story_contracts" in text or ".story-system/" in text
- assert "CHAPTER_COMMIT" in text or "chapter-commit" in text
- assert "load-context" in text
- def test_context_agent_loads_fixed_guides_and_outputs_writer_brief():
- text = (AGENTS_DIR / "context-agent.md").read_text(encoding="utf-8")
- # core-constraints 和 anti-ai-guide 已内化为"写作铁律"段落
- assert "写作铁律" in text or "Anti-AI" in text
- assert "写作任务书" in text
- assert "Step 2 直写提示词" not in text
- assert "Context Contract" not in text
- def test_agents_do_not_name_nonexistent_writing_dna_files():
- for filename in ("context-agent.md", "reviewer.md"):
- text = (AGENTS_DIR / filename).read_text(encoding="utf-8")
- assert "P20_WRITING_DNA" not in text
- assert "WRITING_DNA.md" not in text
- assert ".claude/rules/P20_" not in text
- def test_data_agent_is_described_as_extraction_only_not_direct_write_mainline():
- text = (AGENTS_DIR / "data-agent.md").read_text(encoding="utf-8")
- assert "chapter-commit" in text
- assert "extraction_result.json" in text
- assert "planned_nodes" in text
- assert "missed_nodes" in text
- assert "pending" in text
- assert "event_id" in text
- assert "event_type" in text
- assert "subject" in text
- assert "直接写入 index.db 和 state.json" not in text
- # (已按 plan §12.2 退役) test_webnovel_write_data_agent_prompt_requires_extraction_schema:
- # 该测试逐字要求主 Skill 写出 data artifact 的 schema 字段名,与判据一冲突。schema 字段保障已迁到
- # data-agent.md 生产方(test_data_agent_is_described_as_extraction_only_not_direct_write_mainline)
- # + precommit 负向用例(Task 7)。主 Skill 不再内联长 schema。
- def test_dashboard_and_plan_skills_surface_story_runtime_mainline():
- dashboard_text = (SKILLS_DIR / "webnovel-dashboard" / "SKILL.md").read_text(encoding="utf-8")
- plan_text = (SKILLS_DIR / "webnovel-plan" / "SKILL.md").read_text(encoding="utf-8")
- assert "story-runtime/health" in dashboard_text
- assert ".story-system/" in plan_text
- def test_webnovel_write_skill_routes_step2_through_writing_brief():
- text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
- assert "写作任务书" in text
- assert "context-agent" in text
- assert "Step 0.5" not in text
- assert 'cat "${SKILL_ROOT}/../../references/shared/core-constraints.md"' not in text
- assert 'cat "${SKILL_ROOT}/references/anti-ai-guide.md"' not in text
- def test_context_agent_and_write_skill_form_isolated_write_chain():
- context_text = (AGENTS_DIR / "context-agent.md").read_text(encoding="utf-8")
- skill_text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
- assert "写作任务书" in context_text
- assert "写作任务书" in skill_text
- assert "context-agent" in skill_text
- assert "Context Contract" not in context_text
- assert "Step 2 直写提示词" not in context_text
- def test_no_direct_state_writes_in_write_skill():
- """webnovel-write SKILL.md 中不应有 set-chapter-status 调用。"""
- text = (SKILLS_DIR / "webnovel-write" / "SKILL.md").read_text(encoding="utf-8")
- assert "state set-chapter-status" not in text, (
- "webnovel-write 中不应直接调用 state set-chapter-status,"
- "chapter_status 由 state_projection_writer 在 commit 时自动推进"
- )
- def test_no_direct_state_writes_in_agents():
- """agents 目录中不应有直接写 state/index 的指令。"""
- for agent_file in AGENT_FILES:
- text = _read_text(agent_file)
- assert "state set-chapter-status" not in text, (
- f"{agent_file.name}: 不应直接调用 state set-chapter-status"
- )
- def test_deconstruction_agent_preserves_init_handoff_and_boundaries():
- """reference deconstruction must remain extraction-only and init-scoped."""
- text = _read_text(AGENTS_DIR / "deconstruction-agent.md")
- assert "init_reference_research" in text
- assert ".webnovel/tmp/reference_analyses/<safe-title>/" not in text
- assert "不写任何文件" in text
- assert "不得写 `_progress.md`" in text
- assert "resume_state" in text
- assert "tools: Read, Grep, Bash" in text
- assert "快速模式" in text
- assert "深度模式" in text
- assert "黄金三章" in text
- assert "情节点" in text
- assert "质量门控" in text
- assert "不得凭记忆" in text
- assert "条件框架" in text
- assert "情绪链条" in text
- assert "核心梗边界" in text
- for field in (
- "reader_promise",
- "opening_hook_patterns",
- "cool_point_loops",
- "protagonist_patterns",
- "antagonist_pressure_patterns",
- "pacing_notes",
- "borrowable_structures",
- "do_not_copy",
- "differentiation_requirements",
- "init_candidates",
- "quality",
- "resume_state",
- "orphan_plot_fallback",
- "canon_contamination_warnings",
- ):
- assert f'"{field}"' in text
- for forbidden_path in (
- ".story-system/",
- "设定集/",
- "大纲/",
- "正文/",
- ".webnovel/",
- ):
- assert forbidden_path in text
- assert "不写 `idea_bank.json`" in text
- assert "用户确认后" in text
- assert "MIT License attribution" not in text
- def test_webnovel_init_deconstruction_wiring_keeps_confirmation_gate():
- """init may consume only confirmed, transformed reference patterns."""
- text = _read_text(SKILLS_DIR / "webnovel-init" / "SKILL.md")
- assert 'subagent_type: "webnovel-writer:deconstruction-agent"' in text
- assert "Step 1.5:灵感来源询问" in text
- assert "进入故事核采集前" in text
- assert "不要默认拆书" in text
- assert "你这本书的灵感来源想从哪里开始" in text
- assert "init_reference_research" in text
- assert "init_reference_research JSON 对象" in text
- assert ".webnovel/tmp/reference_analyses/<safe-title>/" not in text
- assert "project_root=${PROJECT_ROOT" not in text
- assert "不写任何文件" in text
- assert "不得由 init 主流程口头替代拆解结果" in text
- assert "`quality`" in text
- assert "`quality.passed=false`" in text
- assert "`confidence < 0.85`" in text
- for handoff_field in (
- "reader_promise",
- "opening_hook_patterns",
- "cool_point_loops",
- "protagonist_patterns",
- "antagonist_pressure_patterns",
- "pacing_notes",
- "borrowable_structures",
- "differentiation_requirements",
- "init_candidates",
- ):
- assert handoff_field in text
- for forbidden_path in (
- "idea_bank.json",
- ".story-system",
- "设定集",
- "大纲",
- "正文",
- ".webnovel/state.json",
- ):
- assert forbidden_path in text
- assert "用户确认前" in text
- assert "Step 2-6 只能使用用户确认过、并已变形为本书差异化表达的模式" in text
- assert "汇总 Step 1.5 已确认的灵感来源" in text
- # ---------------------------------------------------------------------------
- # 7. A 类跨层红线:行为/契约级断言(Phase 0 守护)
- # 这些断言守护「已实现」的业务红线,全部应为绿。优先断言结构不变量
- # (命令存在/顺序、节点 schema、变量化的真实参数),不做脆弱的文案匹配。
- # ---------------------------------------------------------------------------
- # A 类红线 2:placeholder-scan 必须出现在 plan 与 write 两层的关键节点。
- def test_placeholder_scan_runs_in_both_plan_and_write_skills():
- """红线 2:plan 与 write 都必须显式调用 placeholder-scan CLI。"""
- plan_text = _read_text(SKILLS_DIR / "webnovel-plan" / "SKILL.md")
- write_text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
- for name, text in (("webnovel-plan", plan_text), ("webnovel-write", write_text)):
- cmds = _extract_cli_subcommands(text)
- assert "placeholder-scan" in cmds, (
- f"{name}: 关键节点缺少 placeholder-scan CLI 调用"
- )
- # A 类红线 3:story-system 章级刷新必须传入真实 CHAPTER_GOAL 变量,
- # 不得把 {章纲目标} / 第N章章纲目标 这类占位文本当作 positional query。
- @pytest.mark.parametrize("skill_name", ["webnovel-plan", "webnovel-write"])
- def test_story_system_chapter_refresh_uses_real_goal_not_placeholder_query(skill_name: str):
- """红线 3:story-system 的 query 实参是 ${CHAPTER_GOAL} 变量,且禁占位文本写在命令里。"""
- text = _read_text(SKILLS_DIR / skill_name / "SKILL.md")
- # 命令必须用变量化的真实目标作为 query 实参
- assert 'story-system "${CHAPTER_GOAL}"' in text, (
- f"{skill_name}: story-system 未使用真实 ${{CHAPTER_GOAL}} 作为 query 实参"
- )
- # 占位 query 绝不能作为 story-system 的 positional 实参出现
- for placeholder in ("{章纲目标}", "第N章章纲目标"):
- assert f'story-system "{placeholder}"' not in text, (
- f"{skill_name}: story-system 不得把占位文本 {placeholder} 当作 query"
- )
- # 必须显式声明「禁止占位 query」这一约束(断言事实存在,不锁具体措辞)
- assert "{章纲目标}" in text and "第N章章纲目标" in text, (
- f"{skill_name}: 缺少对占位 query 的明确禁止说明"
- )
- # A 类红线 4:story-system 章级刷新必须 --persist 且 --emit-runtime-contracts。
- @pytest.mark.parametrize("skill_name", ["webnovel-plan", "webnovel-write"])
- def test_story_system_chapter_refresh_persists_runtime_contracts(skill_name: str):
- """红线 4:章级 story-system 刷新必须同时 --persist 与 --emit-runtime-contracts。"""
- text = _read_text(SKILLS_DIR / skill_name / "SKILL.md")
- cmd_start = text.find('story-system "${CHAPTER_GOAL}"')
- assert cmd_start >= 0, f"{skill_name}: 缺少章级 story-system 调用"
- # 取该调用所在的命令行(到下一空行/段落结束),断言两个关键开关都在
- cmd_tail = text[cmd_start:cmd_start + 400]
- assert "--persist" in cmd_tail, f"{skill_name}: 章级 story-system 缺少 --persist"
- assert "--emit-runtime-contracts" in cmd_tail, (
- f"{skill_name}: 章级 story-system 缺少 --emit-runtime-contracts"
- )
- assert "--chapter" in cmd_tail, f"{skill_name}: 章级 story-system 缺少 --chapter"
- # A 类红线 5:write-gate 三道闸门必须齐全且顺序为 prewrite→precommit→postcommit。
- def test_write_skill_gate_stages_ordered_prewrite_precommit_postcommit():
- """红线 5:write-gate 三道 gate 顺序不可乱。"""
- text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
- prewrite = text.find("write-gate --chapter {chapter_num} --stage prewrite")
- precommit = text.find("write-gate --chapter {chapter_num} --stage precommit")
- postcommit = text.find("write-gate --chapter {chapter_num} --stage postcommit")
- assert prewrite >= 0, "缺少 prewrite gate"
- assert precommit >= 0, "缺少 precommit gate"
- assert postcommit >= 0, "缺少 postcommit gate"
- assert prewrite < precommit < postcommit, (
- "write-gate 三道 gate 顺序必须为 prewrite→precommit→postcommit"
- )
- # A 类红线 7:reviewer 原始 JSON 必须经 review-pipeline --save-metrics 落库(write 与 review 两层)。
- @pytest.mark.parametrize("skill_name", ["webnovel-write", "webnovel-review"])
- def test_review_pipeline_persists_metrics_in_review_chain(skill_name: str):
- """红线 7:reviewer JSON 经 review-pipeline --save-metrics 落库。"""
- text = _read_text(SKILLS_DIR / skill_name / "SKILL.md")
- cmds = _extract_cli_subcommands(text)
- assert "review-pipeline" in cmds, f"{skill_name}: 缺少 review-pipeline CLI 调用"
- assert "--save-metrics" in text, f"{skill_name}: review-pipeline 未带 --save-metrics 落库"
- # A 类红线 10:postcommit 必须验证 projection 五项;失败只 projections retry。
- def test_write_skill_postcommit_verifies_five_projections_and_retry_only():
- """红线 10:projection 五项(state/index/summary/memory/vector)验证,失败只 retry。"""
- text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
- assert "state/index/summary/memory/vector" in text, (
- "缺少 projection 五项(state/index/summary/memory/vector)验证说明"
- )
- # 失败兜底唯一手段是 projections retry(命令以续行书写,直接断言字面调用)
- assert "projections retry --chapter {chapter_num}" in text, (
- "projection 失败兜底必须是 projections retry --chapter {chapter_num}"
- )
- # A 类红线 12:plan 必须覆盖节拍表/时间线/结构化章纲节点/结构化总纲写回/状态更新。
- def test_plan_skill_covers_outline_writeback_and_state_sync_contract():
- """红线 12:plan 的节拍表/时间线/章纲节点/总纲写回 JSON/master-outline-sync/update-state。"""
- text = _read_text(SKILLS_DIR / "webnovel-plan" / "SKILL.md")
- # 节拍表 / 时间线 输出物
- assert "大纲/第{volume_id}卷-节拍表.md" in text
- assert "大纲/第{volume_id}卷-时间线.md" in text
- # 结构化章纲节点
- for node in ("CBN", "CPNs", "CEN", "必须覆盖节点", "本章禁区"):
- assert node in text, f"plan 缺少结构化章纲节点标记 {node}"
- # 结构化总纲写回文件(不可从自由文本推断伏笔)
- assert "大纲/第{volume_id}卷-总纲写回.json" in text
- # 设定写回 + 状态同步命令
- cmds = _extract_cli_subcommands(text)
- assert "master-outline-sync" in cmds, "plan 缺少 master-outline-sync 写回命令"
- assert "update-state" in cmds, "plan 缺少 update-state 状态更新命令"
- # ---------------------------------------------------------------------------
- # 8. B 类跨层新契约(plan §5.2-B / §4.5 写入所有权矩阵)
- # tools↔落盘一致性现状已满足 → 作通过型守护;
- # 提交前只读 git diff 变更面校验现状缺失 → xfail,Task 5(Phase 1)落地后移除标记转正。
- # ---------------------------------------------------------------------------
- def _agent_tools(agent_name: str) -> list[str]:
- """解析某 agent frontmatter 的 tools 列表。"""
- fm = _extract_frontmatter(_read_text(AGENTS_DIR / f"{agent_name}.md"))
- return [t.strip() for t in fm.get("tools", "").split(",") if t.strip()]
- # B 类红线(写入所有权 ↔ tools 一致,单一写入者):
- # data-agent 是三份 tmp artifact 的唯一写入者 → 必须持 Write;
- # reviewer/context-agent/deconstruction-agent 只返回结果、由主流程落盘 → 不得持 Write。
- def test_agent_write_ownership_matches_tools_frontmatter():
- """红线(写入所有权):仅 data-agent 持 Write,其余三个 agent 不持 Write。"""
- assert "Write" in _agent_tools("data-agent"), (
- "data-agent 必须持有 Write(它是三份 tmp artifact 的唯一写入者)"
- )
- for agent_name in ("reviewer", "context-agent", "deconstruction-agent"):
- assert "Write" not in _agent_tools(agent_name), (
- f"{agent_name} 不得持有 Write(它只返回结果,由主流程落盘)"
- )
- # B 类红线(提交前变更面校验):write SKILL 在 chapter-commit 前必须执行只读 git diff 变更面校验。
- # 现状 write SKILL 尚无此步 → 标 xfail;Task 5(Phase 1)实现后移除本标记,转为硬守护。
- # B 类红线(提交前变更面校验):write SKILL 在 chapter-commit 前必须执行只读 git diff 变更面校验。
- # Phase 1 (Task 5) 已落地 → 转为硬守护(移除 xfail 标记)。
- def test_write_skill_has_readonly_git_diff_change_surface_check():
- """红线(提交前变更面校验):write SKILL 在 chapter-commit 前执行只读 git diff 校验。"""
- text = _read_text(SKILLS_DIR / "webnovel-write" / "SKILL.md")
- assert "diff --name-status" in text, (
- "write SKILL 缺少提交前只读 git diff --name-status 变更面校验"
- )
- assert "diff --check" in text, (
- "write SKILL 缺少 git diff --check 空白/冲突标记校验"
- )
|