"""AC-4 / 计划 §8(#1/#6) 真源围栏守卫:`ai_messages` 绝不进生成链。 `ai_messages` 是 append-only 旁路侧记录(与 `usage_ledger` 同物种)——记录「作者↔AI 说了 什么」,**不是手稿真源**。守不变量 #1(DB 单一真源指手稿/审稿真源)/ #6(记忆注入是确定性 选择,非把聊天记录喂进 prompt):`assemble()` 与任何 memory/orchestrator 节点**永不读它**。 这是一道**能变红的**真守卫(非空断言): - 若日后有人把 `AiMessageRepo`/`AiMessage` 接入 `packages/core/ww_core/{memory,orchestrator}` 下任一模块(import 或引用),`test_generation_path_never_references_ai_messages` 直接失败。 - 若有人把 ai_message repo 塞进 `assemble()` 消费的 `MemoryRepos` 捆绑, `test_assemble_repo_bundle_excludes_ai_messages` 直接失败。 - `test_detector_flags_a_planted_violation` 用植入的违规源证明探测器确会命中——守卫非空。 纯静态分析(AST + 反射),无 DB / 无网络 / 无 LLM。 """ from __future__ import annotations import ast import dataclasses import inspect from pathlib import Path import ww_core.memory.assemble as assemble_mod from ww_core.domain.repositories import MemoryRepos from ww_core.memory import assemble # 生成链禁止出现的标识符(repo/ORM 模型/视图/行)。 _FORBIDDEN_NAMES = frozenset( { "AiMessage", "AiMessageRepo", "SqlAiMessageRepo", "AiMessageView", "AiTurnRow", } ) # 禁止 import 的模块子串(catch `from ww_core.domain.ai_message_repo import ...`)。 _FORBIDDEN_MODULE_SUBSTR = "ai_message" # 受守卫的生成链目录(记忆装配 + 编排器;相对仓库根)。 _GUARDED_DIRS = ( "packages/core/ww_core/memory", "packages/core/ww_core/orchestrator", ) _REPO_ROOT = Path(__file__).resolve().parents[1] def _ai_message_refs(source: str, filename: str) -> list[str]: """返回 `source` 里对 ai_messages 生成链禁忌标识符的所有引用(AST 精确匹配)。 命中面(真实的「接入生成链」向量): - `import ...ai_message...` / `from ...ai_message... import ...`(模块子串); - `from X import AiMessageRepo`(导入名在禁忌集); - 代码里任意 `AiMessageRepo` 名引用 / `x.AiMessageView` 属性访问(Name/Attribute)。 docstring/注释是 `ast.Constant`,不计入——避免「文档提到它」的假阳性。 """ hits: list[str] = [] tree = ast.parse(source, filename=filename) for node in ast.walk(tree): if isinstance(node, ast.ImportFrom): if node.module and _FORBIDDEN_MODULE_SUBSTR in node.module.lower(): hits.append(f"from {node.module} import ...") for alias in node.names: if alias.name in _FORBIDDEN_NAMES: hits.append(f"from {node.module} import {alias.name}") elif isinstance(node, ast.Import): for alias in node.names: if _FORBIDDEN_MODULE_SUBSTR in alias.name.lower(): hits.append(f"import {alias.name}") elif isinstance(node, ast.Name) and node.id in _FORBIDDEN_NAMES: hits.append(f"name {node.id}") elif isinstance(node, ast.Attribute) and node.attr in _FORBIDDEN_NAMES: hits.append(f"attr .{node.attr}") return hits def _guarded_py_files() -> list[Path]: files: list[Path] = [] for rel in _GUARDED_DIRS: d = _REPO_ROOT / rel assert d.is_dir(), f"guarded dir missing (moved?): {d}" files.extend(sorted(d.rglob("*.py"))) return files def test_generation_path_never_references_ai_messages() -> None: """memory/orchestrator 下任一模块都不得 import/引用 ai_messages(否则真源蔓延)。""" files = _guarded_py_files() # 非空守卫:确有文件被扫(否则断言形同虚设)。 assert len(files) >= 20, f"expected the generation path to have modules; scanned {len(files)}" violations: dict[str, list[str]] = {} for path in files: refs = _ai_message_refs(path.read_text(encoding="utf-8"), str(path)) if refs: violations[str(path.relative_to(_REPO_ROOT))] = refs assert not violations, ( "ai_messages leaked into the generation path (violates invariant #1/#6 — it is an " f"append-only side-record, never a truth-source): {violations}" ) def test_assemble_repo_bundle_excludes_ai_messages() -> None: """`assemble()` 消费的 `MemoryRepos` 捆绑不得含任何 ai_message repo 字段。""" for field in dataclasses.fields(MemoryRepos): assert "ai_message" not in field.name.lower(), ( f"MemoryRepos.{field.name} wires ai_messages into assemble() — forbidden (#1/#6)" ) annotation = str(field.type) assert "AiMessage" not in annotation, ( f"MemoryRepos.{field.name} type {annotation!r} references AiMessage — forbidden (#1/#6)" ) def test_assemble_signature_only_takes_memory_repos_bundle() -> None: """`assemble` 的 repo 入参恒为 `MemoryRepos`——阻止旁路加一个 ai_message repo 形参。""" # eval_str 求值 PEP 563 字符串化注解(assemble.py 有 `from __future__ import annotations`)。 sig = inspect.signature(assemble, eval_str=True) repos_param = sig.parameters["repos"] assert repos_param.annotation is MemoryRepos # assemble 模块本身也不得引用禁忌标识符(双保险,直接扫其源)。 source = inspect.getsource(assemble_mod) assert _ai_message_refs(source, "assemble.py") == [] def test_detector_flags_a_planted_violation() -> None: """证明守卫非空:植入的违规源确被探测器命中(否则上面的绿全是假绿)。""" planted_import = "from ww_core.domain.ai_message_repo import AiMessageRepo\n" assert _ai_message_refs(planted_import, "") planted_usage = ( "from ww_core.domain import AiMessageRepo\n" "async def assemble(repos, ai_log: AiMessageRepo):\n" " rows = await ai_log.list_for_chapter(...)\n" " return rows\n" ) assert _ai_message_refs(planted_usage, "") # 干净源不命中(避免误报——探测器有区分度)。 clean = "from ww_core.domain.repositories import MemoryRepos\nx = 1\n" assert _ai_message_refs(clean, "") == []