muse-agent-example/tests/架构/test_模块依赖.py
zizi 9e6f1c4481 R2 改造交付:新版模块化单体全量成果
- src/muse 新版全模块(装配/共享/上下文/任务运行/作品规划/故事世界/正文写作/审校修订/知识方法/作者经验/效果评测/交付连载/资料研究/正式变更/元数据/接入/基础设施/编排)+ 测试树(单元/契约/集成/架构/迁移/端到端/夹具)
- 129 项功能全部实现与自动验证(功能覆盖.json/矩阵),含 W31 补齐的规则与代价/节奏安排/伏笔与承诺
- 旧实现按处置清单退出(702 条中 324 删,保护合同与未迁移条目留存有据);web/app.py 旧工作台退役,新工作台为唯一写入口
- 数据库/旧库迁移:真实旧库内容批次迁移链(端点守卫/PG作品正文映射/质量资产缺省投影)
- 运行手册 docs/运行手册.md;W30 本机服务阶段一已运行(infra PG 为正式内容权威)
- R2 执行证据与私有运行材料在 .agents.local/改造/R2-20260909/(不入库)
2026-09-15 12:47:42 +08:00

470 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""模块依赖行为验证。
保护:安装产物边界与依赖方向——业务只经公共接口、Skill 不携带实现、
接入不直写表、共享基础不反向依赖业务、装配层不承载创作规则。
已实现子集(W02):
- 安装包在任意 cwd 可导入且导入不产生外部连接(含旧树同名包遮挡守卫);
- 技能目录不注入 sys.path、不携带实现脚本;
- src/muse 活跃运行代码不依赖外部专有 CLI;
- 共享基础类型不导入业务模块;__main__ 只是薄命令入口。
依赖 W06/W07/W22/W24 的用例(宿主适配、协议、角色路由、旧库参数守卫、
技能目录合同)在对应包落地后逐例实现,不以形状检查代替。
"""
from __future__ import annotations
import ast
import json
import re
import subprocess
import sys
from importlib.util import resolve_name
from pathlib import Path
import muse
仓库根 = Path(__file__).resolve().parents[2]
源码根 = 仓库根 / "src" / "muse"
def _遍历py(根: Path) -> list[Path]:
return sorted(p for p in 根.rglob("*.py") if "__pycache__" not in p.parts)
def _导入名(文件: Path) -> set[str]:
"""AST 提取文件内全部 import 的顶层模块名。"""
树 = ast.parse(文件.read_text(encoding="utf-8"))
名单: set[str] = set()
for 节点 in ast.walk(树):
if isinstance(节点, ast.Import):
for 别名 in 节点.names:
名单.add(别名.name.split(".")[0])
elif isinstance(节点, ast.ImportFrom) and 节点.module and 节点.level == 0:
名单.add(节点.module.split(".")[0])
return 名单
def test_skills_do_not_syspath_into_shared_implementations__f4e7a4() -> None:
"""技能目录是方法与操作说明,不携带实现脚本,不注入 sys.path。"""
技能根 = 仓库根 / ".agent" / "skills"
脚本 = [p for p in 技能根.rglob("*.py") if "__pycache__" not in p.parts]
assert 脚本 == [], [str(p.relative_to(仓库根)) for p in 脚本]
for md in 技能根.rglob("*.md"):
文本 = md.read_text(encoding="utf-8")
assert "sys.path" not in 文本, f"技能文档出现 sys.path 注入:{md.relative_to(仓库根)}"
def _字符串常量(文件: Path) -> list[str]:
"""AST 提取全部字符串常量,排除模块/类/函数 docstring。"""
树 = ast.parse(文件.read_text(encoding="utf-8"))
文档: set[int] = set()
for 节点 in ast.walk(树):
if isinstance(节点, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
主体 = 节点.body
if 主体 and isinstance(主体[0], ast.Expr) and isinstance(主体[0].value, ast.Constant):
文档.add(id(主体[0].value))
return [
节点.value
for 节点 in ast.walk(树)
if isinstance(节点, ast.Constant) and isinstance(节点.value, str) and id(节点) not in 文档
]
def test_dashboard_does_not_import_skills__ba6ad5() -> None:
"""src/mude 不以代码依赖 .agent 技能目录(文档中的合同引用不算依赖)。"""
for 文件 in _遍历py(源码根):
for 值 in _字符串常量(文件):
assert ".agent" not in 值, f"源码以路径方式引用 .agent:{文件.relative_to(仓库根)}"
assert "skills/" not in 值, f"源码引用技能路径:{文件.relative_to(仓库根)}"
def test_active_runtime_has_no_claude_cli_dependency__1ec1b5() -> None:
"""活跃运行代码不依赖专有外部 CLI(claude/dsh 等);子进程只允许工程工具。"""
for 文件 in _遍历py(源码根):
树 = ast.parse(文件.read_text(encoding="utf-8"))
for 节点 in ast.walk(树):
if isinstance(节点, ast.Call) and getattr(节点.func, "attr", "") == "run":
for 参数 in 节点.args:
if isinstance(参数, ast.List):
for 元素 in 参数.elts:
if isinstance(元素, ast.Constant) and isinstance(元素.value, str):
值 = 元素.value
assert not (值.startswith("claude") or 值.startswith("dsh")), (
f"依赖专有 CLI:{文件.relative_to(仓库根)} -> {值}"
)
def test_runtime_does_not_import_muse_business__5d7edd() -> None:
"""共享纯基础不反向导入业务;启动装配可以绑定具体实现。"""
业务模块 = {
"作品规划",
"故事世界",
"资料研究",
"知识方法",
"正文写作",
"审校修订",
"作者经验",
"交付连载",
"上下文",
"效果评测",
}
基础文件 = list((源码根 / "共享").glob("*.py")) + [
源码根 / "配置.py",
源码根 / "资源加载.py",
]
assert 基础文件, "共享与装配文件缺失"
for 文件 in 基础文件:
树 = ast.parse(文件.read_text(encoding="utf-8"))
for 节点 in ast.walk(树):
if isinstance(节点, ast.ImportFrom) and 节点.module:
顶层 = 节点.module.split(".")[0] == "muse"
if 顶层 and len(节点.module.split(".")) > 1:
第二层 = 节点.module.split(".")[1]
assert 第二层 not in 业务模块, (
f"基础层导入业务模块:{文件.relative_to(仓库根)} -> {节点.module}"
)
def test_dispatch_agent_task_is_thin_cli__b141f1() -> None:
"""python -m muse 是薄入口:只做参数解析与转交,无业务逻辑与连接。"""
文件 = 源码根 / "__main__.py"
文本 = 文件.read_text(encoding="utf-8")
导入 = _导入名(文件)
assert 导入 <= {"__future__", "argparse", "sys", "pathlib", "muse"}, 导入
for 禁止 in ("socket", "psycopg", "http", "urllib", "subprocess"):
assert 禁止 not in 文本, f"命令入口包含 {禁止}"
# ---------------------------------------------------------------------------
# 新增用例(W02 补充):安装与导入边界
# ---------------------------------------------------------------------------
def test_import_from_any_cwd_without_external_connections__e0a1b2() -> None:
"""任意 cwd 导入 muse 全部顶层模块且不产生任何 socket 连接(安装验收)。"""
代码 = (
"import socket\n"
"def _拒绝(self, 地址):\n"
" raise AssertionError(f'导入期间尝试连接 {地址}')\n"
"socket.socket.connect = _拒绝\n"
"import muse, muse.启动, muse.配置, muse.资源加载, muse.共享\n"
"print(muse.__version__)\n"
)
进程 = subprocess.run(
[sys.executable, "-c", 代码], cwd="/", capture_output=True, text=True, timeout=60
)
assert 进程.returncode == 0, 进程.stderr
assert 进程.stdout.strip() == muse.__version__
def test_import_resolves_to_new_package_not_old_tree__c1d2e3() -> None:
"""import muse 解析到 src/muse 安装包,不被旧树同名目录遮挡。"""
代码 = "import muse\nprint(muse.__file__)\n"
进程 = subprocess.run(
[sys.executable, "-c", 代码],
cwd=仓库根,
capture_output=True,
text=True,
timeout=60,
)
assert 进程.returncode == 0, 进程.stderr
assert "src/muse" in 进程.stdout, 进程.stdout
def _跨域导入(代码: str, 模块: str, 业务模块: set[str]) -> list[str]:
"""解析绝对/相对以及模块别名导入;装配入口由调用者单独排除。"""
本域 = 模块.split(".")[1]
包 = 模块.rsplit(".", 1)[0]
错误 = []
for 节点 in ast.walk(ast.parse(代码)):
导入项: list[str] = []
if isinstance(节点, ast.Import):
导入项 = [项.name for 项 in 节点.names]
elif isinstance(节点, ast.ImportFrom):
来源 = 节点.module or ""
if 节点.level:
来源 = resolve_name("." * 节点.level + 来源, 包)
部分 = 来源.split(".")
# from muse import 业务域 / from muse.业务域 import 私有文件同样不能绕过。
导入项 = [来源 + "." + 项.name for 项 in 节点.names] if len(部分) <= 2 else [来源]
for 导入 in 导入项:
部分 = 导入.split(".")
if len(部分) < 2 or 部分[0] != "muse" or 部分[1] == 本域:
continue
if 部分[1] in 业务模块 and 部分[2:] != ["接口"]:
错误.append(f"{节点.lineno}: {导入}")
if 本域 in 业务模块 and 部分[1] in {"编排", "接入"}:
错误.append(f"{节点.lineno}: 业务反向依赖 {导入}")
return 错误
def test_跨模块仅经公共用例且编排不操作私表__20d01a() -> None:
"""公共类型与用例可导出;存储不能伪装成公开用例,接入/编排不能直写表。"""
业务模块 = {p.parent.name for p in 源码根.glob("*/接口.py")} - {"编排", "接入"}
违规 = []
for 文件 in _遍历py(源码根):
相对 = 文件.relative_to(源码根)
if len(相对.parts) < 2:
continue # 启动.py 负责选择具体实现,根部共享边界由独立规则保护。
模块 = "muse." + ".".join(相对.with_suffix("").parts)
代码 = 文件.read_text(encoding="utf-8")
违规.extend(f"{相对}:{项}" for 项 in _跨域导入(代码, 模块, 业务模块))
树 = ast.parse(代码)
if 相对.parts[0] in {"编排", "接入"}:
for 节点 in ast.walk(树):
if isinstance(节点, ast.Call) and isinstance(节点.func, ast.Attribute):
if 节点.func.attr in {"execute", "executemany", "cursor"}:
违规.append(f"{相对}:{节点.lineno}: 接入/编排不能使用数据库游标")
if 文件.name == "接口.py":
存储符号 = {
项.asname or 项.name
for 节点 in ast.walk(树)
if isinstance(节点, ast.ImportFrom) and (节点.module or "").endswith(".存储")
for 项 in 节点.names
}
for 节点 in 树.body:
if isinstance(节点, ast.Assign) and any(
isinstance(目标, ast.Name) and 目标.id == "__all__" for 目标 in 节点.targets
):
泄露 = 存储符号 & set(ast.literal_eval(节点.value))
if 泄露:
违规.append(f"{相对}: 不能公开内部存储 {sorted(泄露)}")
assert not 违规, "\n".join(违规)
def test_公共边界检查识别别名和相对导入__20d02b() -> None:
"""同一非法依赖的语法变体必须一致拒绝,合法公开入口与本域内部导入不误报。"""
业务 = {"正文写作", "任务运行"}
for 代码 in (
"from muse.任务运行.模型 import 任务请求",
"import muse.任务运行.存储 as 别名",
"from ..任务运行.存储 import 任务存储",
"from muse.任务运行 import 存储",
"from muse import 任务运行",
"from muse.编排.生成正文 import 发起生成正文",
):
assert _跨域导入(代码, "muse.正文写作.接口", 业务), 代码
for 代码 in (
"from muse.任务运行.接口 import 任务请求",
"from ..任务运行.接口 import 任务请求",
"from muse.任务运行 import 接口",
"import muse.任务运行.接口 as 任务",
"from .存储 import 正文存储",
):
assert not _跨域导入(代码, "muse.正文写作.接口", 业务), 代码
# ---------------------------------------------------------------------------
# W13 承接:作者自然语言入口与角色路由合同(原 AuthorInstructionContractTest)
# ---------------------------------------------------------------------------
作者根 = 仓库根 / ".agent" / "作者"
场景根 = 作者根 / "场景"
标准索引表头 = (
"| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |\n"
"|------|----------|----------|----------|----------|"
)
场景清单 = ("定故事", "排故事", "塑人物", "写下一章", "改正文", "查作品")
技能链接 = re.compile(r"\[([^\]\n]+)\]\(([^)\n]+/SKILL\.md)\)")
frontmatter名称 = re.compile(r"^name:\s*(.+?)\s*$", re.MULTILINE)
# 已有新版实现并登记进运行资源包的技能:场景路由必须指向新版路径,不再回到旧 muse 树。
已替代技能 = {
"完善故事基础设定": ".agent/skills/操作/完善故事基础设定/SKILL.md",
"决定正文候选去留": ".agent/skills/操作/决定正文候选去留/SKILL.md",
"确认规划候选": ".agent/skills/操作/确认规划候选/SKILL.md",
}
退役技能别名 = {
"生成下一章",
"局部重写",
"场景扩写",
"正文润色",
"作品定盘",
"全书规划",
"章级细纲",
"全书解析",
"章后抽取",
"一致性检测",
"质量评分",
}
def _章节(文本: str, 标题: str) -> str:
匹配 = re.search(
rf"^## {re.escape(标题)}\s*$\n(?P<正文>.*?)(?=^## |\Z)",
文本,
flags=re.MULTILINE | re.DOTALL,
)
assert 匹配 is not None, f"缺少章节:## {标题}"
return 匹配.group("正文")
def test_author_layer_has_discoverable_structure__41d4cb() -> None:
"""作者层文件完整且各级索引可发现;路由不依赖技能自动发现。"""
期望 = {
作者根 / "目录.md",
作者根 / "指令.md",
作者根 / "原生中文写作.md",
场景根 / "目录.md",
*(场景根 / f"{名称}.md" for 名称 in 场景清单),
}
缺失 = sorted(str(p.relative_to(仓库根)) for p in 期望 if not p.is_file())
assert 缺失 == [], "作者指令层文件不完整:\n" + "\n".join(缺失)
根索引 = (仓库根 / ".agent" / "目录.md").read_text(encoding="utf-8")
项目入口 = (仓库根 / "AGENTS.md").read_text(encoding="utf-8")
作者索引 = (作者根 / "目录.md").read_text(encoding="utf-8")
场景索引 = (场景根 / "目录.md").read_text(encoding="utf-8")
assert "作者/目录.md" in 根索引
assert ".agent/作者/指令.md" in 项目入口
assert 作者索引.startswith(标准索引表头)
assert 场景索引.startswith(标准索引表头)
assert "[指令.md](指令.md)" in 作者索引
assert "[原生中文写作.md](原生中文写作.md)" in 作者索引
assert "[目录.md](场景/目录.md)" in 作者索引
for 场景 in 场景清单:
assert f"[{场景}.md]({场景}.md)" in 场景索引
def test_scenes_route_only_to_registered_chinese_skills__5e199d() -> None:
"""场景路由只指向真实存在且 frontmatter 名称一致的技能;已替代技能不再路由旧实现。"""
登记能力 = {
Path(条目).name
for 条目 in json.loads((仓库根 / "工具" / "资源登记.json").read_text(encoding="utf-8"))[
"能力文件"
]
}
for 场景 in 场景清单:
源 = 场景根 / f"{场景}.md"
路由章节 = _章节(源.read_text(encoding="utf-8"), "正式技能")
for 标签, 原目标 in 技能链接.findall(路由章节):
目标 = (源.parent / 原目标).resolve()
assert 目标.is_file(), f"{源}: 技能链接不存在:{原目标}"
匹配 = frontmatter名称.search(目标.read_text(encoding="utf-8"))
assert 匹配 is not None, f"{目标}: 缺少 frontmatter name"
assert 标签 == 匹配.group(1).strip(), f"{源}: 链接文字必须使用正式技能名"
相对 = 目标.relative_to(仓库根).as_posix()
if 标签 in 已替代技能:
assert 相对 == 已替代技能[标签], (
f"{源}: {标签} 已有新版实现,应路由 {已替代技能[标签]} 而不是 {相对}"
)
if "muse/" not in 相对:
assert Path(相对).name in 登记能力, f"{源}: 未登记的新版技能:{相对}"
assert 标签 not in 退役技能别名
# 作者层不是技能:不得出现作者入口冒充技能登记。
assert 登记能力.isdisjoint({"目录.md"})
def test_interaction_and_native_chinese_contracts_are_complete__7239c7() -> None:
"""交互边界与原生中文写作合同的关键短语在作者层保持完整。"""
入口 = (作者根 / "指令.md").read_text(encoding="utf-8")
for 必含 in (
"一轮只问一个关键问题",
"二至三个中文选项",
"采用",
"修改",
"丢弃",
"Shadow",
"Canonical",
"只给正文",
"主会话不代替角色",
):
assert 必含 in 入口, f"作者指令缺少交互合同短语:{必含}"
写作 = (作者根 / "原生中文写作.md").read_text(encoding="utf-8")
for 必含 in (
"中文语序",
"具体名词、动作和关系优先",
"作者声音、叙述者声音和角色声音",
"不得凭空增加",
"润色不得偷改情节事实",
"扩写不得新开场景或改变落点",
"重写不得越过作者指定范围",
"精确诊断",
):
assert 必含 in 写作, f"原生中文写作合同缺少:{必含}"
诊断场景 = (场景根 / "查作品.md").read_text(encoding="utf-8")
修改场景 = (场景根 / "改正文.md").read_text(encoding="utf-8")
assert "只查不改" in 诊断场景
assert "作者指定范围" in 修改场景
def test_role_prompts_route_with_registered_skill_names__4bead8() -> None:
"""五个角色文件与角色合同一致并登记进运行资源包;角色层不使用退役技能别名。"""
登记 = json.loads((仓库根 / "工具" / "资源登记.json").read_text(encoding="utf-8"))
能力 = set(登记["能力文件"])
角色合同 = {
"writer": "写手",
"planner": "规划员",
"extractor": "知识抽取员",
"detector": "检测员",
"judge": "评委",
}
for 角色ID, 名称 in 角色合同.items():
相对 = f".agent/角色/{名称}.md"
assert 相对 in 能力, f"角色未登记进运行资源包:{相对}"
文本 = (仓库根 / 相对).read_text(encoding="utf-8")
头部 = 文本.split("---")[1]
assert f"id: {角色ID}" in 头部
assert f"name: {名称}" in 头部
assert "contract_version:" in 头部
for 别名 in 退役技能别名:
assert f"`{别名}`" not in 文本, f"{相对}: 角色文件引用退役技能别名 {别名}"
def test_装配与结构入口在任意cwd解析安装产物__9c16cc() -> None:
"""按新版装配与结构入口验证安装产物;不固定旧 framework/runtime 目录或仓根层数。
旧三类 Studio 接入的可加载性由完整安装包业务入口验收承接;本用例只验证
当期装配入口、结构入口与任意 cwd 的安装产物可解析,不宣称未实现的 owner 已接通。
"""
代码 = (
"import socket\n"
"def _拒绝(self, 地址):\n"
" raise AssertionError(f'入口解析期间尝试连接 {地址}')\n"
"socket.socket.connect = _拒绝\n"
"from muse.启动 import 应用装配, 构建\n"
"from muse.资源加载 import 加载清单, 核对资源\n"
"清单 = 加载清单()\n"
"assert 清单['发布身份'] and 清单['构建身份']\n"
"assert 核对资源()['构建身份'] == 清单['构建身份']\n"
"print(清单['发布身份'])\n"
)
进程 = subprocess.run(
[sys.executable, "-c", 代码], cwd="/", capture_output=True, text=True, timeout=60
)
assert 进程.returncode == 0, 进程.stderr
assert 进程.stdout.strip()
def test_声音归属不重复经B06合同拒绝__254d1e() -> None:
"""角色级“同时归属”不再由旧账本校验:B06 声音请求合同拒绝该输入。"""
import pytest
from pydantic import ValidationError
from muse.审校修订.接口 import 声音提案请求, 声音样张引用
样张 = 声音样张引用("document", "chapter-1", 1, "a" * 64, "main")
with pytest.raises(ValidationError):
声音提案请求(
"synthetic:demo",
0,
"voice",
1,
{"叙事": "冷峻"},
(样张,),
characters={"甲": ["嗯"], "乙": ["嗯"]},
)
with pytest.raises(ValidationError):
声音提案请求(
"synthetic:demo",
0,
"voice",
1,
{"叙事": "冷峻"},
(样张,),
untouchable_verbal_tics={"甲": ["嗯"], "乙": ["嗯"]},
)