工程底座:单一生成入口、验证期写闸门与并行分片

- Makefile:新增 验收数据库(生成→检查→库层全量,前置校验隔离库连接串)、数据库分片、浏览器测试三个入口;
  格式写入 末尾就地重新生成;数据库测试 与 验收数据库 统一排除 浏览器/网络;快检 不再静默跳过类型门;
  前端旅程 预检补齐四个必需变量;pytest 目标改用仓内解释器(uv run 在嵌套检出会解析到外层环境)。
- 工具/验证锁.py:验证会话持共享锁,写入口用 --执行 在独占锁内落盘,生成/格式写入/索引生成走同一闸门。
- 工具/并行数据库测试.py:按文件分片并行,缺连接串在采集前拒绝。
- 工具/构建编排.py、环境预检.py、构建资源包.py、维护索引.py 与上述口径对齐。
- .gitignore / CI / README / AGENTS:忽略构建产物、CI 与 Makefile 单一口径、README 按实际实现陈述、AGENTS 补日常入口与验证纪律。
This commit is contained in:
zizi 2026-09-18 01:14:50 +08:00
parent f76c3cd04a
commit 0658d178ad
21 changed files with 2301 additions and 343 deletions

View File

@ -50,11 +50,15 @@ jobs:
make 前端测试 make 前端测试
- name: 离线测试 - name: 离线测试
run: make 测试 run: make 测试
- name: 构建并验收同批安装包
run: |
make 构建
make 包验收 包=dist/muse-0.1.0-py3-none-any.whl
- name: 初始化隔离用途角色 - name: 初始化隔离用途角色
run: psql "$MUSE_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f 数据库/初始化/用途角色.sql run: psql "$MUSE_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f 数据库/初始化/用途角色.sql
- name: 隔离数据库测试 - name: 隔离数据库测试
run: make 数据库测试 run: make 数据库测试
- name: 固定Pi宿主整链 - name: 固定Pi宿主整链
run: | run: |
npm install --prefix "$RUNNER_TEMP/muse-pi" --no-save --ignore-scripts @earendil-works/pi-coding-agent@0.84.4 npm install --prefix "$RUNNER_TEMP/muse-pi" --no-save --ignore-scripts @earendil-works/pi-coding-agent@0.85.1
MUSE_PI_NODE="$(command -v node)" MUSE_PI_PACKAGE="$RUNNER_TEMP/muse-pi/node_modules/@earendil-works/pi-coding-agent" make 宿主测试 MUSE_PI_NODE="$(command -v node)" MUSE_PI_PACKAGE="$RUNNER_TEMP/muse-pi/node_modules/@earendil-works/pi-coding-agent" make 宿主测试

6
.gitignore vendored
View File

@ -43,3 +43,9 @@ web/node_modules/
web/dist/ web/dist/
web/test-results/ web/test-results/
web/playwright-report/ web/playwright-report/
# 内容增量构建缓存可删除重建,不作为发布权威
/构建缓存/
# TypeScript增量编译缓存(已有跟踪项需另获索引操作授权后取消跟踪)
web/*.tsbuildinfo

View File

@ -1,6 +1,6 @@
# AGENTS.md —— agent-example 项目工作入口 # AGENTS.md —— agent-example 项目工作入口
> 适用范围:本文件只约束 `agent-example/`。父仓 `../AGENTS.md`(原路径 ../AGENTS.md,已随旧实现退出,见固定基线) 的通用工程、证据和协作规则继续生效;本文件只补充本地创作仓的规则,不重复父仓规范。 > 适用范围:本文件只约束 `agent-example/`。用户及上级提供的通用工程规则继续生效;本仓所需的批次、验证与交付约定见[计划与提交](.agent/rules/计划与提交.md),不依赖检出目录外的规则文件。
> **技能发现入口(必读)**:读完本文件后必须读 [`.agent/skills/目录.md`](.agent/skills/目录.md)(技能目录);需要某项能力时,按目录的“相对地址”读取对应 `SKILL.md`。发现只靠 AGENTS.md → 技能目录 → SKILL.md 的渐进披露,不依赖任何宿主的技能自动发现。 > **技能发现入口(必读)**:读完本文件后必须读 [`.agent/skills/目录.md`](.agent/skills/目录.md)(技能目录);需要某项能力时,按目录的“相对地址”读取对应 `SKILL.md`。发现只靠 AGENTS.md → 技能目录 → SKILL.md 的渐进披露,不依赖任何宿主的技能自动发现。
> **作者创作入口(按需)**:作者用自然中文提出定故事、排故事、塑人物、续写、修改或诊断请求时,先读 [`.agent/作者/指令.md`](.agent/作者/指令.md),再只打开命中的一个场景文件;作者层负责路由,不替代正式技能、角色合同或用户确认。 > **作者创作入口(按需)**:作者用自然中文提出定故事、排故事、塑人物、续写、修改或诊断请求时,先读 [`.agent/作者/指令.md`](.agent/作者/指令.md),再只打开命中的一个场景文件;作者层负责路由,不替代正式技能、角色合同或用户确认。
@ -10,9 +10,9 @@
`agent-example` 的目标定位是物理位于 `oh-my-muse` 内、拥有独立 `.git/` 的单用户缩小版 Muse,以 PostgreSQL 为正式内容权威。 `agent-example` 的目标定位是物理位于 `oh-my-muse` 内、拥有独立 `.git/` 的单用户缩小版 Muse,以 PostgreSQL 为正式内容权威。
- **正式内容权威**:PostgreSQL(`muse-example` 库)是系统正式内容的唯一权威。作品、章、正文、实体、范式、用户决策、运行回执和 raw 都在库里。判断“系统里有没有这个东西”,以库里能不能查到为准;严禁把库外临时文件或快照当作正式内容。 - **正式内容权威**:PostgreSQL(连接目标由当前运行配置明确选择,见[运行手册](docs/运行手册.md))是系统正式内容的唯一权威。作品、章、正文、实体、方法、用户决策及运行回执在库里。raw暂存文件受租约管理,归档后以PG归档及回执为准;私人探索文件不作为正式内容。判断“系统里有没有这个东西”,以库里能不能查到为准;严禁把库外临时文件或快照当作正式内容。
- **运行态账本**:本地 SQLite(`data/muse.db`,gitignore)是默认派发与单机工作面的运行态账本(2026-08-28 存储裁决):运行留痕(runs/events)、本地人审(reviews/revisions)、卡片与向量镜像、lesson 本地状态机和外部反馈数据落在其中;它不产生 Canonical,与 PG 冲突时以 PG 为准。表清单见 [`muse/authority/db/表映射.md`](docs/系统架构/新版设计/数据模型/存储与对象关系.md)。 - **运行态账本**:任务、步骤、调用、预算、事件与回执由 PostgreSQL 维护;旧 SQLite 账本只属于历史实现。对象归属见[存储与对象关系](docs/系统架构/新版设计/数据模型/存储与对象关系.md)。
- **代码与配置权威**:Git 是代码、技能、智能体提示词、`muse/content/meta/schemas/`、文档和 DDL 的权威,并对作品信息和文本留痕备份;但 Git 留痕不是正式内容权威,正式内容以数据库为准,只读看板只读库,两者冲突时以库为准。 - **代码与配置权威**:Git 维护代码、技能、角色提示词、内置结构种子、文档和 DDL。`src/muse/元数据/内置结构/` 的 YAML 是发布输入;运行结构以 PostgreSQL 已发布版本为准。Git 不承担创作数据备份。
- **能力边界**:由主会话派发角色智能体、技能和确定性工具协作完成创作与治理;不实现管理员、多用户、租户、市场、计费或资产交易。 - **能力边界**:由主会话派发角色智能体、技能和确定性工具协作完成创作与治理;不实现管理员、多用户、租户、市场、计费或资产交易。
--- ---
@ -28,6 +28,10 @@ agent-example/
├── 配置/ # 无凭据配置模板 ├── 配置/ # 无凭据配置模板
├── web/ # 前端:React/TS 用户工作台 ├── web/ # 前端:React/TS 用户工作台
├── 数据库/ # 数据库迁移脚本与旧库迁移工具链 ├── 数据库/ # 数据库迁移脚本与旧库迁移工具链
├── 部署/ # 部署模板与进程配置
├── upstream/ # 固定版本的上游协议资料
├── .github/ # CI 工作流
├── data/ # 运行文件(不跟踪,不作为源码)
├── .agent/ # 智能体能力中枢(作者入口、角色提示词、角色合同、方法技能、规则、约束与规范) ├── .agent/ # 智能体能力中枢(作者入口、角色提示词、角色合同、方法技能、规则、约束与规范)
├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录 ├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录
├── .venv/ # 仓内解释器(uv 管理) ├── .venv/ # 仓内解释器(uv 管理)
@ -45,41 +49,40 @@ SoT 按主题分域,不做跨主题的全局排序。可执行脚本与书面
| 类别 | 载体 / 路径 | 权威职责 | | 类别 | 载体 / 路径 | 权威职责 |
|---|---|---| |---|---|---|
| 总体设计 | `../design-docs/`(原路径 ../design-docs,已随旧实现退出,见固定基线) | Muse 的概念、产品、业务和总体架构 SoT(设计 SSOT)。 | | 总体设计 | [总体架构](docs/系统架构/新版设计/总体架构.md) | 本仓模块、对象归属、依赖和运行边界;外部历史设计不充当当前实现。 |
| 新版重写目标 | [docs/系统架构/新版设计/目录.md](docs/系统架构/新版设计/目录.md) | 新版主体完整目标设计、元数据、文件职责及研究依据;不据此宣称现有实现已完成。 | | 新版重写目标 | [docs/系统架构/新版设计/目录.md](docs/系统架构/新版设计/目录.md) | 新版主体完整目标设计、元数据、文件职责及研究依据;不据此宣称现有实现已完成。 |
| 领域 SoT | [`muse/sot/domains/`](docs/系统架构/新版设计/阅读指南.md) | 本仓各业务领域边界、数据权威、落库合同与领域协作 SoT(01-08 域)。 | | 领域 SoT | [阅读指南](docs/系统架构/新版设计/阅读指南.md) | 按主题定位现行业务领域、数据权威与协作合同。 |
| 边界合同 | [`muse/sot/边界合同.md`](docs/系统架构/新版设计/总体架构.md) | 组件职责边界与约束归属唯一事实源:智能体/技能/工具 server/主代理职责划分。 | | 边界合同 | [总体架构](docs/系统架构/新版设计/总体架构.md) | 组件职责边界、跨域公开接口与约束归属。 |
| 角色合同 | [`.agent/角色/角色合同.md`](.agent/角色/角色合同.md) | 5 个角色(写手/规划/抽取/检测/裁判)的稳定输入边界、模型策略、工具权限与派发合同。 | | 角色合同 | [`.agent/角色/角色合同.md`](.agent/角色/角色合同.md) | 5 个角色(写手/规划/抽取/检测/裁判)的稳定输入边界、模型策略、工具权限与派发合同。 |
| 结构契约 | `muse/content/meta/schemas/`(原路径 muse/content/meta/schemas,已随旧实现退出,见固定基线) | 23 型结构本体与规划产物的字段合同;库内 payload 结构以此为准。 | | 结构契约 | [内置结构](src/muse/元数据/内置结构/) | 24 型的内置种子与发布输入;公共字段不是独立类型。运行实例遵守库内已发布结构版本。 |
| 创作导读 | [`muse/sot/创作周期与Skill导读.md`](docs/系统架构/新版设计/调用链路/关键旅程.md) | 创作生命周期各阶段流转、门禁、人机分界与技能责任方导读地图。 | | 创作导读 | [关键旅程](docs/系统架构/新版设计/调用链路/关键旅程.md) | 创作流转、门禁、人机边界及正式内容的确认路径。 |
| 技能目录 | [`.agent/skills/目录.md`](.agent/skills/目录.md) / [`muse/技能目录.md`](.agent/skills/目录.md) | 59 个技能的方法目录与编排目录(物理清单以 `skills.json` 为准)。 | | 技能目录 | [技能目录](.agent/skills/目录.md) | 方法和操作入口;以各组实际 SKILL.md 及目录发现,不维护另一份虚构清单。 |
| 创作链条 | `muse/lifecycle/flow/chains/`(原路径 muse/lifecycle/flow/chains,已随旧实现退出,见固定基线) | scenario、purpose、功能 skill、角色槽位和保护节点的链路登记。 | | 创作链条 | [流程登记](src/muse/编排/流程登记.py)、[流程版本](src/muse/编排/流程版本.py)、[槽位约束](src/muse/编排/槽位约束.py) | 当前流程模板、冻结版本和角色槽位约束;旧 muse/lifecycle/flow/chains 已退出。 |
| 红线约束 | [`.agent/约束/红线约束.md`](.agent/约束/作者主权.md) | 数据权威、探索边界、模型治理与代码提交的不可逾越红线。 | | 红线约束 | [作者主权](.agent/约束/作者主权.md)、[约束目录](.agent/约束/目录.md) | 数据权威、作者确认、读取范围与外部调用边界。 |
| 执行流程 | [`.agent/rules/执行流程.md`](.agent/rules/模块依赖.md) | 按条件适用的研发职责、分层验证与批次收尾;任务与依赖编排见[计划与提交](.agent/rules/计划与提交.md)。 | | 执行流程 | [模块依赖](.agent/rules/模块依赖.md)、[计划与提交](.agent/rules/计划与提交.md) | 模块边界、任务依赖、分层验证和批次收尾。 |
| 子代理交付 | [`.agent/rules/子代理交付.md`](.agent/rules/子代理交付.md) | 派发子代理的交付物形状:三要素交付、证据指针、仅阻断级汇报、最小必读。 | | 子代理交付 | [`.agent/rules/子代理交付.md`](.agent/rules/子代理交付.md) | 派发子代理的交付物形状:三要素交付、证据指针、仅阻断级汇报、最小必读。 |
| 审查标准 | [`.agent/规范/审查标准.md`](.agent/rules/文档与资源生成.md) | 智能体提示词 4 问、技能质量 7 问 + D8 复利审查规程与严重度定义。 | | 审查标准 | [审查标准](.agent/规范/审查标准.md) | 由专项检查附录确认的提示词四问、技能七问和严重度;不冒称恢复缺失的旧 D8 原文。 |
| 工程规范 | [`.agent/规范/`](.agent/规范/) | [`去AI味道工程规范.md`](.agent/rules/审校与修订.md)、[`指令集规范.md`](.agent/规范/命名.md)、[`术语规范.md`](.agent/规范/术语.md)。 | | 工程规范 | [规范目录](.agent/规范/目录.md)、[审校与修订](.agent/rules/审校与修订.md) | 命名、术语、中文正文及受控修订的现行规范。 |
| 任务与沉淀 | [`docs/`](docs) | 单次任务探索、计划、评测资料与历史执行证据;稳定结论回填 SoT。 | | 任务与沉淀 | [`docs/`](docs) | 单次任务探索、计划、评测资料与历史执行证据;稳定结论回填 SoT。 |
--- ---
## 4. 工作协议(硬约束) ## 4. 工作协议(硬约束)
1. **读后动手与渐进发现**:复杂任务开工前必须先读 SoT、角色合同与技能索引;技能发现遵循 `AGENTS.md → 索引 → SKILL.md` 渐进展开,不依赖宿主私有文件投影或猜测。 1. **读后动手与渐进发现**:复杂任务先读对应 SoT;涉及角色时读角色合同。技能发现遵守本文件开头的唯一入口,按当前任务展开,不预载无关资料。
2. **机械验证优先与完成=验证**:遵循父仓反假绿规则,新版工程统一使用仓内解释器 `.venv`(uv 管理;旧代码用 `.venv-旧`);日常入口 `make 检查`(格式+类型+模块边界+索引)、`make 测试`(默认离线进程隔离)、`make 数据库测试`(需 `MUSE_TEST_DATABASE_URL`);必须通过相关单元测试、门禁与 `git diff --check`,无自动化证据严禁声称“完成/修复/通过”。 2. **机械验证优先与完成=验证**:工程统一使用仓内解释器 `.venv`(uv 管理);日常入口 `make 快检 范围=<受影响路径>`(开发期)、`make 检查`(格式+类型+模块边界+索引)、`make 测试`(默认离线进程隔离)、`make 数据库测试`(需 `MUSE_TEST_DATABASE_URL`);改完源码后的库层全量走 `make 验收数据库`(唯一入口,先生成再门禁后跑库),发现期要并行用 `make 数据库分片`,浏览器层用例走 `make 浏览器测试`(需隔离库与 `MUSE_BROWSER_EXECUTABLE`)。必须通过相关单元测试、门禁与 `git diff --check`,无自动化证据严禁声称“完成/修复/通过”。
3. **数据权威与先审后入**:数据库为唯一正式权威,严禁裸连操作;正文、规划与知识抽取默认生成 Shadow 候选,经用户明确确认后方可写入 Canonical 正典事实。 3. **数据权威与先审后入**:数据库为唯一正式权威,严禁裸连操作;正文、规划与知识抽取默认生成 Shadow 候选,经用户明确确认后方可写入 Canonical 正典事实。
4. **模型治理与受控探索**:模型调用严格遵守 5 小时额度窗口与受控治理链,角色严格锁定[角色合同](.agent/角色/角色合同.md)指定模型(写手/规划固定顶级推理模型,裁判使用独立精确白名单);确定性逻辑、门禁与报告组装由脚本完成,严禁调用模型;智能体探索仅限圈定只读工具并留痕。 4. **模型治理与受控探索**:模型调用遵守[预算管理](src/muse/任务运行/预算管理.py)的固定日界窗口(默认 Asia/Shanghai,每日00/05/10/15/20开始,末窗20至24为4小时)与受控治理链;角色允许模型与策略版本以[角色策略](配置/角色策略.yaml)为准,并遵守[角色合同](.agent/角色/角色合同.md)(写手/规划固定顶级推理模型,裁判使用独立精确白名单);确定性逻辑、门禁与报告组装由脚本完成,严禁调用模型;智能体探索仅限圈定只读工具并留痕。
5. **会话交互与汇报纪律**:全程使用简体中文白话,坚决去除 AI 味(直陈事实、动作与后果,禁止清嗓子套话与空转缓冲词);需要用户决策时,必须交代清楚前因后果及各选项对下游的影响。 5. **会话交互与汇报纪律**:全程使用简体中文白话,坚决去除 AI 味(直陈事实、动作与后果,禁止清嗓子套话与空转缓冲词);需要用户决策时,必须交代清楚前因后果及各选项对下游的影响。
6. **提交授权与形而上审查**:严禁未经用户明确授权执行 `git add` 或 `git commit`;按[计划与提交](.agent/rules/计划与提交.md)预先确定交付批次,L3 重任务批次(判据见 `../.agents/rules/verification-principles.md`:跨模块、超过 2 个文件、修改超 100 行、核心逻辑变动、不可逆操作,任一命中)门禁通过后由独立只读子代理完成四维整体审查,通过前不得宣布该批完成,L1/L2 批次以门禁绿加抽查代替;不逐笔提交叠加审查,必要的事前方案评审、修复重审和最终集成验收仍保留;框架与创作内容分开审查、分开提交。 6. **提交授权与整体审查**:严禁未经用户明确授权执行 `git add` 或 `git commit`;按[计划与提交](.agent/rules/计划与提交.md)预先确定交付批次,L3 重任务批次(判据及验证分层见同一规则)门禁通过后由独立只读子代理完成四维整体审查,通过前不得宣布该批完成,L1/L2 批次以门禁绿加抽查代替;不逐笔提交叠加审查,必要的事前方案评审、修复重审和最终集成验收仍保留;框架与创作内容分开审查、分开提交。
7. **计划边界与提交节奏**:以可独立验收的完整结果拆任务,明确边界、依赖、维护者与验证方式;前置满足且有收益的独立任务分工并发,依赖任务按已验证能力衔接;验证不过不收尾,获授权后每个完整任务单独提交。具体编排、隔离和批次收尾以[计划与提交](.agent/rules/计划与提交.md)为准。 7. **计划边界与提交节奏**:以可独立验收的完整结果拆任务,明确边界、依赖、维护者与验证方式;前置满足且有收益的独立任务分工并发,依赖任务按已验证能力衔接;验证不过不收尾,获授权后每个完整任务单独提交。具体编排、隔离和批次收尾以[计划与提交](.agent/rules/计划与提交.md)为准。
--- ---
## 5. 知识与经验复利机制 ## 5. 交付与经验沉淀
系统的核心价值在于复利:每一次创作与开发任务后,系统必须比上一次更强。 - 创作方法和规则沿[审校修订](docs/系统架构/新版设计/模块设计/B06-审校修订.md)、[作者经验](docs/系统架构/新版设计/模块设计/B07-作者经验.md)的候选、评测与批准链承接,不自动写入正式内容或沿用旧豁免。
- **创作复利**:创作过程中提炼的技法沉淀为范式卡、AI 味规则、声音账与 `example_lesson` 登记入库,走 [06-质量与复利领域 §6](docs/系统架构/新版设计/模块设计/B06-审校修订.md) 升格链。 - 工程交付按[计划与提交](.agent/rules/计划与提交.md)同步所属权威合同,按[文档与资源生成](.agent/rules/文档与资源生成.md)维护受影响索引并验证,清理被替代实现、重复路径、旧配置与无用途测试;有兼容、迁移、回滚或追溯用途的内容保留并注明用途。
- **工程复利**:开发交付后,将稳定设计与规则回写对应 SoT、`.agent/规范/`、`.agent/约束/` 或技能,过时内容及时清理,控制长期熵增。
--- ---
@ -97,8 +100,8 @@ SoT 按主题分域,不做跨主题的全局排序。可执行脚本与书面
### 维护纪律 ### 维护纪律
处理任何代码变更时,以项目已有架构、领域模型、ADR、编码规范和测试为准,优先保持代码可读、简单、局部且可维护,禁止无证据叠加抽象;新方案完成后必须清理被替代的实现、重复路径、旧配置、旧测试和文档残留,控制长期熵增。 代码变更以现行架构、领域模型、ADR、规范与测试为准,保持可读、简单、局部,不凭空叠加抽象;清理和沉淀按本文件§5执行。
提交:`AGENTS.md`、`docs/`、`.agent/` 全部内容。 纳入版本控制:`AGENTS.md`、`docs/`、`.agent/`;实际暂存和提交仍须明确授权。
不提交:`.agents.local/`、`.claude/`、`.codex/`、`.pi/`、`.opencode/`、`.cursor/`。 不提交:`.agents.local/`、`.claude/`、`.codex/`、`.pi/`、`.opencode/`、`.cursor/`。
<!-- my-skills-cli:end --> <!-- my-skills-cli:end -->

View File

@ -1,7 +1,13 @@
export PYTHONPATH := $(CURDIR)/src:$(CURDIR)/工具
# 用例与库层直接用仓内解释器:uv run 在嵌套检出里会解析到外层 agent-example/.venv,
# 那个环境没有 psycopg_pool,库层会以导入失败告终。工具链目标仍走 uv run。
仓内解释器 := $(CURDIR)/.venv/bin/python
# Muse 安装、生成、检查、测试和构建入口。 # Muse 安装、生成、检查、测试和构建入口。
# 每个声明命令都必须真实可执行(见 .agent/rules/文档与资源生成.md)。 # 每个声明命令都必须真实可执行(见 .agent/rules/文档与资源生成.md)。
.PHONY: 安装 格式 格式写入 类型 模块边界 检查 测试 数据库测试 宿主测试 真实模型测试 前端安装 前端检查 前端测试 生成 构建 资源核对 .PHONY: 迁移 前端旅程 快检 索引生成 包验收 安装 格式 格式写入 类型 模块边界 检查 测试 数据库测试 数据库分片 验收数据库 浏览器测试 宿主测试 真实模型测试 前端安装 前端检查 前端测试 生成 构建 资源核对
# 安装:建立并锁定 Python 依赖环境 # 安装:建立并锁定 Python 依赖环境
安装: 安装:
@ -18,65 +24,104 @@
前端测试: 前端测试:
pnpm --dir web run test pnpm --dir web run test
# 浏览器旅程需调用者明确选择真实后端与浏览器;不属于默认离线层。
前端旅程:
@test -n "$$MUSE_WORKBENCH_URL" -a -n "$$MUSE_BROWSER_EXECUTABLE" || { echo "缺少明确的 MUSE_WORKBENCH_URL / MUSE_BROWSER_EXECUTABLE" >&2; exit 1; }
@test -x "$$MUSE_BROWSER_EXECUTABLE" || { echo "MUSE_BROWSER_EXECUTABLE 不是可执行浏览器路径" >&2; exit 1; }
@test "$$MUSE_ISOLATED_TEST_ENVIRONMENT" = "1" || { echo "缺 MUSE_ISOLATED_TEST_ENVIRONMENT=1(旅程环境在配置加载期强校验)" >&2; exit 1; }
@test -n "$$MUSE_AUTHOR_PASSWORD_FILE" -a -f "$$MUSE_AUTHOR_PASSWORD_FILE" || { echo "MUSE_AUTHOR_PASSWORD_FILE 必须是可读的绝对路径" >&2; exit 1; }
pnpm --dir web run test:journey $(参数)
# 格式:静态检查(ruff lint + 格式校验;只覆盖新版测试与工具目录) # 格式:静态检查(ruff lint + 格式校验;只覆盖新版测试与工具目录)
新测试目录 := $(shell for d in tests/单元 tests/契约 tests/集成 tests/架构 tests/迁移 tests/端到端 tests/真实调用; do [ -d "$$d" ] && printf '%s ' "$$d"; done) 新测试目录 := $(shell for d in tests/单元 tests/契约 tests/集成 tests/架构 tests/迁移 tests/端到端 tests/真实调用; do [ -d "$$d" ] && printf '%s ' "$$d"; done)
新工具目录 := 工具 $(wildcard 数据库/旧库迁移) 新工具目录 := 工具 $(wildcard 数据库/旧库迁移)
格式: 格式:
uv run ruff check src $(新测试目录) tests/conftest.py $(新工具目录) uv run --no-sync ruff check src $(新测试目录) tests/conftest.py $(新工具目录)
uv run ruff format --check src $(新测试目录) tests/conftest.py $(新工具目录) uv run --no-sync ruff format --check src $(新测试目录) tests/conftest.py $(新工具目录)
# 格式写入:按项目规则实际格式化 # 格式写入:按项目规则实际格式化;格式化同样改源码,就地重新生成,避免之后整层报身份不符
# 每步用 --执行 在独占锁内完成:检查通过后到写入前不再有可被验证会话插入的窗口。
格式写入: 格式写入:
uv run ruff check --fix src $(新测试目录) tests/conftest.py $(新工具目录) uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync ruff check --fix src $(新测试目录) tests/conftest.py $(新工具目录)
uv run ruff format src $(新测试目录) tests/conftest.py $(新工具目录) uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync ruff format src $(新测试目录) tests/conftest.py $(新工具目录)
$(MAKE) 生成
# 类型:pyright 类型检查 # 类型:pyright 类型检查
类型: 类型:
uv run pyright uv run --no-sync pyright
# 模块边界:固定执行架构门禁,不继承调用者的 pytest 筛选条件。 # 模块边界:固定执行架构门禁,不继承调用者的 pytest 筛选条件。
模块边界: 模块边界:
PYTEST_ADDOPTS= uv run pytest tests/架构/test_模块依赖.py -q PYTEST_ADDOPTS= $(仓内解释器) -m pytest tests/架构/test_模块依赖.py -m "not 安装包" -q
# 检查:格式 + 类型 + 模块边界 + 索引核对的统一入口 # 检查:格式 + 类型 + 模块边界 + 索引核对的统一入口
检查: 格式 类型 模块边界 检查: 格式 类型 模块边界
uv run python 工具/维护索引.py --检查 uv run --no-sync python 工具/维护索引.py --检查
# 测试:默认离线(按标记剔除外部环境用例;未标记用例在 socket 防护下运行) # 测试:默认离线(按标记剔除外部环境用例;未标记用例在 socket 防护下运行)
# 用法:make 测试 全部离线用例 # 用法:make 测试 全部离线用例
# make 测试 用例=TC-a5c7d4adafe3 按稳定用例 ID 选择 # make 测试 用例=TC-a5c7d4adafe3 按稳定用例 ID 选择
测试: 测试:
uv run pytest -m "not 数据库 and not 网络 and not 真实模型 and not 浏览器 and not 宿主" $(if $(用例),--case $(用例),) $(仓内解释器) -m pytest -m "not 数据库 and not 网络 and not 真实模型 and not 浏览器 and not 宿主 and not 安装包" $(范围) $(if $(用例),--case $(用例),)
# 数据库测试:需要 MUSE_TEST_DATABASE_URL;缺失即失败,不静默跳过 # 验收数据库:改完源码后唯一该跑的全量入口。先生成、再门禁、最后跑库,
# 顺序由目标固定,避免“改了 src 没重新生成”这类整轮作废。
验收数据库:
@test -n "$$MUSE_TEST_DATABASE_URL" || { echo "缺少 MUSE_TEST_DATABASE_URL(隔离库连接串);拒绝先付生成与门禁成本再失败" >&2; exit 1; }
$(MAKE) 生成
$(MAKE) 检查
$(仓内解释器) -m pytest --外部环境 -m "数据库 and not 宿主 and not 真实模型 and not 浏览器 and not 网络" $(范围)
# 数据库分片:发现与修复期用分片并行,全量只跑一次做最终判定
数据库分片:
$(仓内解释器) 工具/并行数据库测试.py --片数 $(if $(片数),$(片数),3) $(范围)
# 数据库测试:需要 MUSE_TEST_DATABASE_URL;缺失即失败,不静默跳过。
# 与 验收数据库 同口径排除 浏览器/网络:浏览器用例另有 浏览器测试 入口,不该在这里被静默 skip。
数据库测试: 数据库测试:
@test -n "$$MUSE_TEST_DATABASE_URL" || { echo "缺少 MUSE_TEST_DATABASE_URL(隔离库连接串);拒绝静默跳过" >&2; exit 1; } @test -n "$$MUSE_TEST_DATABASE_URL" || { echo "缺少 MUSE_TEST_DATABASE_URL(隔离库连接串);拒绝静默跳过" >&2; exit 1; }
uv run pytest --外部环境 -m "数据库 and not 宿主 and not 真实模型" $(仓内解释器) -m pytest --外部环境 -m "数据库 and not 宿主 and not 真实模型 and not 浏览器 and not 网络" $(范围) $(if $(用例),--case $(用例),)
# 浏览器测试:需要隔离库与显式浏览器路径;11 个 数据库+浏览器 用例的唯一入口。
# MUSE_ISOLATED_TEST_ENVIRONMENT 由本目标声明:Playwright 配置加载期就要求它,缺了连 spec 都跑不到。
浏览器测试:
@test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_BROWSER_EXECUTABLE" || { echo "缺少隔离库或 MUSE_BROWSER_EXECUTABLE" >&2; exit 1; }
MUSE_RUN_BROWSER=1 MUSE_ISOLATED_TEST_ENVIRONMENT=1 $(仓内解释器) -m pytest --外部环境 -m "浏览器 and not 宿主 and not 真实模型" $(范围)
真实模型测试: 真实模型测试:
@test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_REAL_MODEL_URL" -a -n "$$MUSE_REAL_MODEL_KEY_FILE" || { echo "缺少隔离库或真实模型地址/凭据文件"; exit 1; } @test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_REAL_MODEL_URL" -a -n "$$MUSE_REAL_MODEL_KEY_FILE" || { echo "缺少隔离库或真实模型地址/凭据文件"; exit 1; }
uv run pytest --外部环境 -m 真实模型 --timeout=300 $(仓内解释器) -m pytest --外部环境 -m 真实模型 --timeout=300
宿主测试: 宿主测试:
@test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_PI_NODE" -a -n "$$MUSE_PI_PACKAGE" || { echo "缺少隔离库或固定Pi/Node路径"; exit 1; } @test -n "$$MUSE_TEST_DATABASE_URL" -a -n "$$MUSE_PI_NODE" -a -n "$$MUSE_PI_PACKAGE" || { echo "缺少隔离库或固定Pi/Node路径"; exit 1; }
uv run pytest --外部环境 -m 宿主 $(仓内解释器) -m pytest --外部环境 -m 宿主
# 迁移:对配置指向的目标库执行显式迁移(维护命令,不在启动时自动执行) # 迁移:对配置指向的目标库执行显式迁移(维护命令,不在启动时自动执行)
迁移: 迁移:
uv run python -m muse 迁移 $(配置) uv run --no-sync python -m muse 迁移 $(配置)
# 生成:资源包、接口合同与索引(生成物只由登记输入产生) # 快检只检查指定Python源码/测试;不构建资源或前端。
# 范围写成具体路径(如 `范围=src/muse/元数据`、`范围=tests/单元`):
# 含非测试路径就跑类型门,含测试路径才跑用例门;范围缺省时按源码 + 单元/契约用例。
快检:
uv run --no-sync ruff check $(if $(范围),$(范围),src tests 工具)
uv run --no-sync ruff format --check $(if $(范围),$(范围),src tests 工具)
$(if $(范围),$(if $(filter-out tests/%,$(范围)),uv run --no-sync pyright,:),uv run --no-sync pyright)
$(if $(filter tests/%,$(范围)),$(仓内解释器) -m pytest -m "not 数据库 and not 网络 and not 真实模型 and not 浏览器 and not 宿主 and not 安装包" $(filter tests/%,$(范围)) $(if $(用例),--case $(用例),),$(if $(范围),:,$(仓内解释器) -m pytest -m "not 数据库 and not 网络 and not 真实模型 and not 浏览器 and not 宿主 and not 安装包" tests/单元 tests/契约 $(if $(用例),--case $(用例),)))
索引生成:
uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync python 工具/维护索引.py --写入
# 聚合入口按内容哈希增量执行;wheel必须与同次资源输入绑定。
生成: 生成:
uv run python 工具/生成接口.py uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync python 工具/构建编排.py
cd web && pnpm exec openapi-typescript ../docs/接口契约/生成/openapi.generated.json -o src/接口/生成/类型.ts
pnpm --dir web run build
uv run python 工具/构建资源包.py
uv run python 工具/维护索引.py --写入
# 构建:先保证生成物最新,再做可安装包 构建:
构建: 生成 uv run --no-sync python 工具/构建编排.py --wheel
uv build
包验收:
uv run --no-sync python 工具/验收安装包.py $(包)
# 资源核对:安装包内资源与清单哈希一致 # 资源核对:安装包内资源与清单哈希一致
资源核对: 资源核对:
uv run python -c "from muse.资源加载 import 核对资源; m = 核对资源(); print('资源清单发布身份:', m['发布身份'], '条目数:', len(m['资源']))" uv run --no-sync python -c "from muse.资源加载 import 核对资源; m = 核对资源(); print('资源清单发布身份:', m['发布身份'], '条目数:', len(m['资源']))"

View File

@ -123,7 +123,7 @@ muse 审校 <作者配置.toml> 返修比较证明 <比较回执ID>
来源请求固定返修会话、轮次、实际候选、批准引用和有效期;实验请求固定数据集及哈希、两份独立judge配置、维度和预算。请求结构见[B10固定返修比较合同](docs/系统架构/新版设计/模块设计/B10-效果评测.md)。评测任务也可在evaluation工作台按“执行下一步”推进,每次只执行一个冻结步骤;缺少计价仍记录未知费用。 来源请求固定返修会话、轮次、实际候选、批准引用和有效期;实验请求固定数据集及哈希、两份独立judge配置、维度和预算。请求结构见[B10固定返修比较合同](docs/系统架构/新版设计/模块设计/B10-效果评测.md)。评测任务也可在evaluation工作台按“执行下一步”推进,每次只执行一个冻结步骤;缺少计价仍记录未知费用。
返修页可按回执读取比较记录、未完整或未标定原因,作者仍可保留原文。维护/来源连接只由本地显式配置选择,HTTP不接受服务器凭据路径。当前发布策略只有单一固定评委模型,真实双模型执行须先解决角色策略冲突;隔离合成测试不替代真实模型验收,也不授予生产启用许可。 返修页可按回执读取比较记录、未完整或未标定原因,作者仍可保留原文。维护/来源连接只由本地显式配置选择,HTTP不接受服务器凭据路径。当前允许模型以[角色策略](配置/角色策略.yaml)为准;默认初评名单已有两项,独立仲裁所需的第三模型仍需登记精确身份和验证能力。验证范围见[验证纪律](#验证纪律)。
## 新库配置与迁移 ## 新库配置与迁移
@ -143,9 +143,9 @@ muse 审校 <作者配置.toml> 返修比较证明 <比较回执ID>
## 参考分析与历史承接 ## 参考分析与历史承接
```bash ```bash
uv run muse 研究 /private/作者配置.toml 进度 <来源ID> .venv/bin/python -m muse 研究 /private/作者配置.toml 进度 <来源ID>
uv run muse 研究 /private/作者配置.toml 分析 <分析ID> .venv/bin/python -m muse 研究 /private/作者配置.toml 分析 <分析ID>
uv run muse 研究 /private/作者配置.toml 承接 <已完成研究任务ID> .venv/bin/python -m muse 研究 /private/作者配置.toml 承接 <已完成研究任务ID>
``` ```
完整分析结果由B03保存。S02只保存执行证据与分析引用;旧任务显示`history_not_imported`时,明确运行承接命令才能转存,查询不会隐式写库。承接核原来源、全部窗口和归并,不创建任务或重跑模型。旧参考卡经[旧库迁移入口](docs/系统架构/新版设计/数据模型/旧知识记录分流.md)导入为未决分析;保存或模型分析完成都不等于作者确认、当前生成资格或真实资料迁移验收。 完整分析结果由B03保存。S02只保存执行证据与分析引用;旧任务显示`history_not_imported`时,明确运行承接命令才能转存,查询不会隐式写库。承接核原来源、全部窗口和归并,不创建任务或重跑模型。旧参考卡经[旧库迁移入口](docs/系统架构/新版设计/数据模型/旧知识记录分流.md)导入为未决分析;保存或模型分析完成都不等于作者确认、当前生成资格或真实资料迁移验收。
@ -154,6 +154,10 @@ uv run muse 研究 /private/作者配置.toml 承接 <已完成研究任务ID>
旧实现(`muse/` 主体、`runtime/`、`framework/`、`tests/skills/` 等旧代码与测试)已完全退出;`muse/sot/角色合同.md` 已迁入 [`.agent/角色/角色合同.md`](.agent/角色/角色合同.md)。历史脉络与固定 Git 基线见[R2 改造历史留痕](docs/实现回顾/R2改造历史留痕.md),不再作为运行入口。旧库默认只作历史参照;代码退出不证明当前实际写权限已经关闭。 旧实现(`muse/` 主体、`runtime/`、`framework/`、`tests/skills/` 等旧代码与测试)已完全退出;`muse/sot/角色合同.md` 已迁入 [`.agent/角色/角色合同.md`](.agent/角色/角色合同.md)。历史脉络与固定 Git 基线见[R2 改造历史留痕](docs/实现回顾/R2改造历史留痕.md),不再作为运行入口。旧库默认只作历史参照;代码退出不证明当前实际写权限已经关闭。
## 当前运行底座
[数据库连接](src/muse/基础设施/数据库/连接.py)按用途构造受控连接及其生命周期,运行装配走常驻连接池(`启动.py` 传 `常驻=True`,池在装配生命周期内打开、退出时关闭;缺 `psycopg_pool` 时明确报「连接池未就绪」,不静默降级);[可观测性](src/muse/基础设施/可观测性.py)提供日志、关联身份与脱敏,尚无指标采集接口。两者都按实际实现分别验收,不从旧设计表推定已具备。
## 目录速览 ## 目录速览
```text ```text
@ -166,13 +170,18 @@ agent-example/
├── 配置/ # 无凭据配置模板 ├── 配置/ # 无凭据配置模板
├── web/ # React/TS 作者工作台 ├── web/ # React/TS 作者工作台
├── 数据库/ # 数据库迁移脚本与旧库迁移工具链 ├── 数据库/ # 数据库迁移脚本与旧库迁移工具链
├── 部署/ # 发布镜像与实例启停模板
├── upstream/ # 上游溯源记录
├── .github/ # 自动化检查流程
├── .agent/ # 智能体能力中枢(规则、约束、规范、角色合同、技能) ├── .agent/ # 智能体能力中枢(规则、约束、规范、角色合同、技能)
├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录 ├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录
└── data/ # 运行态数据(gitignore) └── data/ # 配置指定的本地工作文件;非 PG 正式内容权威(gitignore)
``` ```
## 验证纪律 ## 验证纪律
隔离合成验证只覆盖其执行的协议与业务行为;真实外部模型行为、文学效果、标定和正式启用分别需要对应证据与批准。合成提供方、读取报告或安装成功均不授予生产启用资格。
完成 = 机械验证:无自动化绿证据不得声称完成。测试默认离线禁网禁库;空收集、全跳过、超时与缺依赖都如实呈现,不冒充通过。详见 [`.agent/rules/测试隔离.md`](.agent/rules/测试隔离.md),现行身份登记见 [tests/用例清单.json](tests/用例清单.json)。 完成 = 机械验证:无自动化绿证据不得声称完成。测试默认离线禁网禁库;空收集、全跳过、超时与缺依赖都如实呈现,不冒充通过。详见 [`.agent/rules/测试隔离.md`](.agent/rules/测试隔离.md),现行身份登记见 [tests/用例清单.json](tests/用例清单.json)。
所有任务从 [`AGENTS.md`](AGENTS.md) 开始。 所有任务从 [`AGENTS.md`](AGENTS.md) 开始。
@ -205,11 +214,11 @@ CLI 使用 `.venv/bin/python -m muse 审阅 <作者配置> 列表 <章节ID>`
“审校 <作者配置> 案例采集/案例审阅/案例决定/案例撤回 <请求JSON>”调用同一B06/S01链;“案例 <case_id>”与“案例清单”只读查询。采集只形成待复核案例,真实引用的字段与许可见[案例操作合同](.agent/skills/操作/记录机器味案例/references/案例合同.md)。四类跨书确认案例可经“案例规则 <请求JSON>”提出候选,不能直接启用。 “审校 <作者配置> 案例采集/案例审阅/案例决定/案例撤回 <请求JSON>”调用同一B06/S01链;“案例 <case_id>”与“案例清单”只读查询。采集只形成待复核案例,真实引用的字段与许可见[案例操作合同](.agent/skills/操作/记录机器味案例/references/案例合同.md)。四类跨书确认案例可经“案例规则 <请求JSON>”提出候选,不能直接启用。
“行为评测 <场景JSON> --适配器 scripted --观察 <观察JSON>”核对逐例动作、报告结构和前后文档哈希。角色模式显式提供`--配置 评测.toml --业务配置 合成资料.toml --动作 准备 --目标 请求.json`,固定S02任务并返回可审请求;`--动作 执行/报告 --目标 任务ID`分别点名执行与读回。请求字段见[角色行为命令](src/muse/接入/cli/角色行为命令.py),缺少配置不回退脚本。场景固定输入见[六类合成场景](tests/夹具/行为评测/诊断机器味场景.json),runtime_verified仅证明实际工具协议,合成调用不认证真实外部模型行为。 “行为评测 <场景JSON> --适配器 scripted --观察 <观察JSON>”核对逐例动作、报告结构和前后文档哈希。角色模式显式提供`--配置 评测.toml --业务配置 合成资料.toml --动作 准备 --目标 请求.json`,固定S02任务并返回可审请求;`--动作 执行/报告 --目标 任务ID`分别点名执行与读回。请求字段见[角色行为命令](src/muse/接入/cli/角色行为命令.py),缺少配置不回退脚本。场景固定输入见[六类合成场景](tests/夹具/行为评测/诊断机器味场景.json),runtime_verified仅证明实际工具协议;效果边界见[验证纪律](#验证纪律)。
## 隔离数据集与实验条件 ## 隔离数据集与实验条件
方法的合格评测证明须先运行`评测 <评测配置> 导出启用凭据 <请求JSON> --维护配置 <同库维护配置>`,请求含experiment_id、approval_ref和valid_until。导出不会启用方法;在方法详情页填写凭据编号,核对已测版本后再确认启用写手用途。停止来源实验或标定会阻断新启用与新消费,历史记录保留。合成验证不作为真实外部模型效果验收。 方法的合格评测证明须先运行`评测 <评测配置> 导出启用凭据 <请求JSON> --维护配置 <同库维护配置>`,请求含experiment_id、approval_ref和valid_until。导出不会启用方法;在方法详情页填写凭据编号,核对已测版本后再确认启用写手用途。停止来源实验或标定会阻断新启用与新消费,历史记录保留。评测证据的边界见[验证纪律](#验证纪律)。
方法写手对照先运行`评测 <维护配置> 发布方法数据集 <请求JSON>`,由B04读取确切确认版本;用返回的`method_target`创建实验。两臂共享写手模板,只在处理组加入获准方法投影。请求与边界见[B10方法版本对照](docs/系统架构/新版设计/模块设计/B10-效果评测.md#方法版本对照),不改变方法启用状态。 方法写手对照先运行`评测 <维护配置> 发布方法数据集 <请求JSON>`,由B04读取确切确认版本;用返回的`method_target`创建实验。两臂共享写手模板,只在处理组加入获准方法投影。请求与边界见[B10方法版本对照](docs/系统架构/新版设计/模块设计/B10-效果评测.md#方法版本对照),不改变方法启用状态。
@ -233,7 +242,7 @@ CLI 使用 `.venv/bin/python -m muse 审阅 <作者配置> 列表 <章节ID>`
正文来源对照先用`评测 <维护配置> 发布回放数据集 <请求JSON>`或`POST /api/v1/evaluation/replay-datasets`封存资料。请求按[回放数据集合同](src/muse/效果评测/数据集.py)声明样本、分割、许可引用、批准及到期时间;每例按[B09材料选择](src/muse/上下文/回放材料.py)指定作品、目标章、历史文稿版本和预算。A使用历史正文,B使用卡片索引,C使用索引及关联正文;目标正文只进入答案区。评测账号不能直接读取正式正文表。 正文来源对照先用`评测 <维护配置> 发布回放数据集 <请求JSON>`或`POST /api/v1/evaluation/replay-datasets`封存资料。请求按[回放数据集合同](src/muse/效果评测/数据集.py)声明样本、分割、许可引用、批准及到期时间;每例按[B09材料选择](src/muse/上下文/回放材料.py)指定作品、目标章、历史文稿版本和预算。A使用历史正文,B使用卡片索引,C使用索引及关联正文;目标正文只进入答案区。评测账号不能直接读取正式正文表。
这类实验使用`kind=code`、`target_ref=B09.writer-replay`,目标版本和内容哈希均指向实际安装构建身份,实验臂为A/B/C。比较覆盖三组文本对,报告以A/C为主,其余两组单列诊断;调用上限需覆盖三次生成及每名评委的三次比较。实验期限不得超过资料批准期限,字段策略变化阻止新派发。真实文学标定和启用判断仍须独立证据,不能把合成提供方或读取报告当作通过。 这类实验使用`kind=code`、`target_ref=B09.writer-replay`,目标版本和内容哈希均指向实际安装构建身份,实验臂为A/B/C。比较覆盖三组文本对,报告以A/C为主,其余两组单列诊断;调用上限需覆盖三次生成及每名评委的三次比较。实验期限不得超过资料批准期限,字段策略变化阻止新派发。真实文学标定和启用所需证据见[验证纪律](#验证纪律)。
评测实验可用`max_judge_corrections`固定0—2次引文纠正(默认1,实际次数还受原调用配额约束)。工作面和报告保留每次判断拒绝、实际费用及单元纠正上限。若S02已经保存而实验登记中断,恢复会要求先补登原交付;不要通过重建调用替换已发生回合。以评测用途配置运行`muse 评测 <配置.toml> 补登记交付 <请求.json>`,请求包含`unit_id`、`call_id`和原始`output`对象;HTTP对应`POST /api/v1/evaluation/deliveries/restore`。服务核对原S02回合哈希,只在暂停或失败任务补登,原样重放幂等,已停止实验拒绝新增结果。 评测实验可用`max_judge_corrections`固定0—2次引文纠正(默认1,实际次数还受原调用配额约束)。工作面和报告保留每次判断拒绝、实际费用及单元纠正上限。若S02已经保存而实验登记中断,恢复会要求先补登原交付;不要通过重建调用替换已发生回合。以评测用途配置运行`muse 评测 <配置.toml> 补登记交付 <请求.json>`,请求包含`unit_id`、`call_id`和原始`output`对象;HTTP对应`POST /api/v1/evaluation/deliveries/restore`。服务核对原S02回合哈希,只在暂停或失败任务补登,原样重放幂等,已停止实验拒绝新增结果。
@ -276,7 +285,7 @@ JSON请求按[经验接入合同](src/muse/接入/经验操作.py)填写,规
## 独立发布与系统管理 ## 独立发布与系统管理
`uv run python 工具/构建发布.py --输出 <全新发布目录>` 一次生成接口、工作台、资源清单、wheel、带哈希的运行依赖及部署文件。生成目录不含工作树、测试或私人资料;重用目录会拒绝覆盖。`--已生成` 只用于复用刚构建的资源,仍会核对当前代码哈希。 `.venv/bin/python 工具/构建发布.py --输出 <全新发布目录>` 一次生成接口、工作台、资源清单、wheel、带哈希的运行依赖及部署文件。生成目录不含工作树、测试或私人资料;重用目录会拒绝覆盖。`--已生成` 只用于复用刚构建的资源,仍会核对当前代码哈希。
发布目录可直接作为 Docker 构建上下文:`docker build --platform linux/amd64 -t <本地标签> <发布目录>`。Dockerfile固定 Python 和 Node 的镜像摘要,Pi 及其依赖由锁文件固定;应用以 UID/GID 10001运行。镜像构建不执行数据库迁移。 发布目录可直接作为 Docker 构建上下文:`docker build --platform linux/amd64 -t <本地标签> <发布目录>`。Dockerfile固定 Python 和 Node 的镜像摘要,Pi 及其依赖由锁文件固定;应用以 UID/GID 10001运行。镜像构建不执行数据库迁移。

View File

@ -17,7 +17,7 @@ dependencies = [
"itsdangerous>=2.2", "itsdangerous>=2.2",
"jsonschema>=4.23", "jsonschema>=4.23",
"referencing>=0.28.4", "referencing>=0.28.4",
"psycopg[binary]>=3.2", "psycopg[binary,pool]>=3.2",
"pydantic>=2.0", "pydantic>=2.0",
"pyyaml>=6.0", "pyyaml>=6.0",
"uvicorn>=0.30", "uvicorn>=0.30",
@ -54,14 +54,18 @@ packages = ["src/muse"]
# 只收集新版用例目录;旧实现与旧测试树已退出本仓,不参与同一收集。 # 只收集新版用例目录;旧实现与旧测试树已退出本仓,不参与同一收集。
testpaths = ["tests/单元", "tests/契约", "tests/集成", "tests/架构", "tests/迁移", "tests/端到端", "tests/真实调用"] testpaths = ["tests/单元", "tests/契约", "tests/集成", "tests/架构", "tests/迁移", "tests/端到端", "tests/真实调用"]
# 独立维护脚本以其目录为导入根;不把旧 muse 树加入路径。 # 独立维护脚本以其目录为导入根;不把旧 muse 树加入路径。
pythonpath = ["数据库/旧库迁移"] pythonpath = ["src", "工具", "数据库/旧库迁移"]
# 架构/契约/集成/单元 是目录级组织;完整用例身份绑定登记文件与符号。 # 架构/契约/集成/单元 是目录级组织;完整用例身份绑定登记文件与符号。
addopts = "--strict-markers --timeout=120" addopts = "--strict-markers --timeout=120"
markers = [ markers = [
"安装包: 实际wheel/发布资源验收,不属于无包开发层",
"case_id(id): 源码中的稳定用例身份与人工语义",
"数据库: 显式外部执行需要 MUSE_TEST_DATABASE_URL;缺配置失败", "数据库: 显式外部执行需要 MUSE_TEST_DATABASE_URL;缺配置失败",
"网络: 需要真实网络访问;必须显式标记", "网络: 需要真实网络访问;必须显式标记",
"真实模型: 需要真实模型调用与预算授权;必须显式标记", "真实模型: 需要真实模型调用与预算授权;必须显式标记",
"浏览器: 需要真实浏览器与后端;必须显式标记", "浏览器: 需要真实浏览器与后端;必须显式标记",
"宿主: 需要固定真实宿主与隔离数据库;必须显式选择",
"慢: 单例超过三分钟的真实链用例;发现期默认排除,最终全量才跑",
] ]
[tool.ruff] [tool.ruff]
@ -76,6 +80,6 @@ select = ["E", "F", "W", "I", "B", "UP"]
venvPath = "." venvPath = "."
venv = ".venv" venv = ".venv"
include = ["src", "工具", "数据库/旧库迁移"] include = ["src", "工具", "数据库/旧库迁移"]
extraPaths = ["数据库/旧库迁移"] extraPaths = ["src", "工具", "数据库/旧库迁移"]
pythonVersion = "3.12" pythonVersion = "3.12"
typeCheckingMode = "standard" typeCheckingMode = "standard"

19
uv.lock generated
View File

@ -185,7 +185,7 @@ dependencies = [
{ name = "httpx" }, { name = "httpx" },
{ name = "itsdangerous" }, { name = "itsdangerous" },
{ name = "jsonschema" }, { name = "jsonschema" },
{ name = "psycopg", extra = ["binary"] }, { name = "psycopg", extra = ["binary", "pool"] },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "referencing" }, { name = "referencing" },
@ -206,7 +206,7 @@ requires-dist = [
{ name = "httpx", specifier = ">=0.28" }, { name = "httpx", specifier = ">=0.28" },
{ name = "itsdangerous", specifier = ">=2.2" }, { name = "itsdangerous", specifier = ">=2.2" },
{ name = "jsonschema", specifier = ">=4.23" }, { name = "jsonschema", specifier = ">=4.23" },
{ name = "psycopg", extras = ["binary"], specifier = ">=3.2" }, { name = "psycopg", extras = ["binary", "pool"], specifier = ">=3.2" },
{ name = "pydantic", specifier = ">=2.0" }, { name = "pydantic", specifier = ">=2.0" },
{ name = "pyyaml", specifier = ">=6.0" }, { name = "pyyaml", specifier = ">=6.0" },
{ name = "referencing", specifier = ">=0.28.4" }, { name = "referencing", specifier = ">=0.28.4" },
@ -265,6 +265,9 @@ wheels = [
binary = [ binary = [
{ name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, { name = "psycopg-binary", marker = "implementation_name != 'pypy'" },
] ]
pool = [
{ name = "psycopg-pool" },
]
[[package]] [[package]]
name = "psycopg-binary" name = "psycopg-binary"
@ -306,6 +309,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" }, { url = "https://files.pythonhosted.org/packages/fc/c7/b7ebf601c307f93e7c4c4ebac0edc9db3b2729ca038efe700a18f86b5517/psycopg_binary-3.3.5-cp314-cp314-win_amd64.whl", hash = "sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab", size = 3745885, upload-time = "2026-08-31T22:45:40.537Z" },
] ]
[[package]]
name = "psycopg-pool"
version = "3.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/90/82/7a23d26039827ecd4ebe93905651029ddd307c5182ad59296dfb6f67b528/psycopg_pool-3.3.1.tar.gz", hash = "sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c", size = 31661, upload-time = "2026-05-01T23:31:59.809Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/37/ed/89c2c620af0e1660354cd8aabf9f5b21f911597ce22acb37c805d6c86bc8/psycopg_pool-3.3.1-py3-none-any.whl", hash = "sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5", size = 40023, upload-time = "2026-05-01T23:31:53.136Z" },
]
[[package]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.13.5" version = "2.13.5"

View File

@ -0,0 +1,185 @@
/** 前端用例身份与人工合同驻留测试源码,当前位置由 TypeScript AST 派生。 */
import { readFileSync, readdirSync } from "node:fs";
import { createRequire } from "node:module";
import { resolve, relative, join } from "node:path";
import { fileURLToPath } from "node:url";
const 位置字段 = new Set([
"file",
"symbol",
"line",
"parameter_ids",
"node_ids",
"fixtures",
"markers",
]);
const 标签 = "@muse-case ";
export function 分析文件(root, file) {
const ts = createRequire(join(resolve(root), "web/package.json"))(
"typescript",
);
const text = readFileSync(file, "utf8");
const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true);
if (source.parseDiagnostics.length)
throw new Error(`TypeScript 解析失败:${file}`);
const testers = new Set();
for (const node of source.statements) {
if (
!ts.isImportDeclaration(node) ||
!["vitest", "@playwright/test"].includes(node.moduleSpecifier.text)
)
continue;
const bindings = node.importClause?.namedBindings;
if (bindings && ts.isNamedImports(bindings)) {
for (const item of bindings.elements) {
if (["test", "it"].includes((item.propertyName ?? item.name).text))
testers.add(item.name.text);
}
}
}
const declarations = [];
const metadataComments = new Set();
function visit(node) {
for (const comment of [
...(ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []),
...(ts.getTrailingCommentRanges(text, node.end) ?? []),
]) {
if (text.slice(comment.pos, comment.end).includes(标签))
metadataComments.add(comment.pos);
}
if (ts.isCallExpression(node)) {
let callee = node.expression;
const modifiers = [];
while (ts.isPropertyAccessExpression(callee)) {
modifiers.push(callee.name.text);
callee = callee.expression;
}
if (
ts.isIdentifier(callee) &&
testers.has(callee.text) &&
modifiers.every((m) =>
[
"only",
"skip",
"todo",
"fails",
"concurrent",
"sequential",
"fixme",
].includes(m),
)
) {
const title = node.arguments[0];
if (!title || !ts.isStringLiteralLike(title))
throw new Error(
`测试标题必须是静态完整身份:${file}:${source.getLineAndCharacterOfPosition(node.pos).line + 1}`,
);
const start = node.getStart(source);
const comments =
ts.getLeadingCommentRanges(text, node.getFullStart()) ?? [];
const adjacent = comments.filter((c) =>
text.slice(c.pos, c.end).includes(标签),
);
if (adjacent.length > 1)
throw new Error(`同一测试出现多个身份:${file}:${title.text}`);
let metadata = null;
let comment = null;
if (adjacent.length) {
comment = adjacent[0];
const raw = text.slice(comment.pos, comment.end);
if (
!raw.startsWith(`// ${标签}`) ||
text.slice(comment.end, start).trim()
)
throw new Error(`用例元信息须紧邻测试声明:${file}:${title.text}`);
metadata = JSON.parse(raw.slice(3 + 标签.length));
}
declarations.push({
title: title.text,
start,
titleStart: title.getStart(source),
titleEnd: title.end,
comment,
metadata,
});
}
// each 的展开须显式赋予各行独立身份,不能静默漏收。
if (
ts.isPropertyAccessExpression(node.expression) &&
node.expression.name.text === "each"
) {
let owner = node.expression.expression;
while (ts.isPropertyAccessExpression(owner)) owner = owner.expression;
if (ts.isIdentifier(owner) && testers.has(owner.text))
throw new Error(`参数化测试请逐行声明稳定身份:${file}`);
}
}
ts.forEachChild(node, visit);
}
visit(source);
if (!declarations.length)
throw new Error(`测试文件没有可收集的具名测试:${file}`);
if (
metadataComments.size !==
declarations.filter((d) => d.metadata !== null).length
)
throw new Error(`存在未绑定测试的用例元信息:${file}`);
return { text, declarations };
}
export function 测试文件(root) {
const result = [];
function walk(dir) {
for (const item of readdirSync(dir, { withFileTypes: true })) {
if (item.isDirectory()) walk(join(dir, item.name));
else if (/\.(test|spec)\.[jt]sx?$/.test(item.name))
result.push(join(dir, item.name));
}
}
walk(join(root, "web/tests"));
walk(join(root, "web/src"));
return result.sort();
}
export function 收集前端用例(root) {
root = resolve(root);
const seen = new Set();
const result = [];
for (const file of 测试文件(root)) {
for (const { title, metadata } of 分析文件(root, file).declarations) {
if (!metadata || typeof metadata !== "object" || Array.isArray(metadata))
throw new Error(`缺失用例元信息:${file}:${title}`);
const id = metadata.case_id;
if (typeof id !== "string" || !/^(?:TC|NC|O\d+)-[^\s::]+$/.test(id))
throw new Error(`无效完整身份:${file}:${id}`);
if (!title.startsWith(`${id}:`))
throw new Error(`元信息与测试身份漂移:${file}:${id} / ${title}`);
if (seen.has(id)) throw new Error(`重复用例身份:${id}`);
seen.add(id);
for (const key of Object.keys(metadata))
if (位置字段.has(key))
throw new Error(`元信息不得登记派生位置 ${key}:${id}`);
result.push({
...metadata,
file: relative(root, file).replaceAll("\\", "/"),
symbol: title,
});
}
}
return result.sort((a, b) => a.case_id.localeCompare(b.case_id, "en"));
}
if (
process.argv[1] &&
resolve(process.argv[1]) === fileURLToPath(import.meta.url)
) {
try {
process.stdout.write(
`${JSON.stringify(收集前端用例(process.argv[2] ?? process.cwd()), null, 2)}\n`,
);
} catch (error) {
process.stderr.write(`${error.message}\n`);
process.exitCode = 1;
}
}

View File

@ -0,0 +1,120 @@
"""隔离库层的分片并行入口:发现与修复期用分片,全量只跑一次做最终判定。
为什么能并行:每个 pytest 会话自带会话前缀的克隆库(`muse_test_<会话>_<随机>`),
角色初始化走共享目录的咨询锁,模板按指纹缓存——互不共享可写状态。
分片只按测试文件切,避免同一文件的夹具被拆到两个进程。
用法:
python 工具/并行数据库测试.py --片数 3 # 全部隔离库用例
python 工具/并行数据库测试.py --片数 3 tests/迁移 # 只跑指定范围
退出码:任一分片非零即非零;每片日志落在 /tmp/muse-数据库分片-<序号>.log。
"""
from __future__ import annotations
import argparse
import os
import shlex
import subprocess
import sys
import tempfile
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
根 = Path(__file__).resolve().parents[1]
基础表达式 = "数据库 and not 宿主 and not 真实模型 and not 浏览器 and not 网络"
def 收集分片范围(范围: list[str], 片数: int, 表达式: str) -> list[list[str]]:
"""按文件把用例均分到各片;同一文件的用例永不拆开。"""
命令 = [
sys.executable,
"-m",
"pytest",
"--外部环境",
"-m",
表达式,
"--collect-only",
"-q",
*范围,
]
结果 = subprocess.run(命令, cwd=根, capture_output=True, text=True, timeout=600)
if 结果.returncode != 0:
print(结果.stdout[-2000:], file=sys.stderr)
print(结果.stderr[-2000:], file=sys.stderr)
raise SystemExit("分片前采集失败;先修采集错误再分片")
计数: dict[str, int] = {}
for 行 in 结果.stdout.splitlines():
if "::" not in 行:
continue
文件 = 行.split("::", 1)[0].strip()
if 文件.endswith(".py"):
计数[文件] = 计数.get(文件, 0) + 1
if not 计数:
raise SystemExit("没有采集到隔离库用例;检查标记表达式与范围")
桶: list[list[str]] = [[] for _ in range(片数)]
负载 = [0] * 片数
for 文件, 数量 in sorted(计数.items(), key=lambda kv: (-kv[1], kv[0])):
最轻 = 负载.index(min(负载))
桶[最轻].append(文件)
负载[最轻] += 数量
return 桶
def 跑一片(序号: int, 文件: list[str], 表达式: str) -> tuple[int, str, str]:
日志 = Path(tempfile.gettempdir()) / f"muse-数据库分片-{序号}.log"
命令 = [
sys.executable,
"-m",
"pytest",
"--外部环境",
"-m",
表达式,
"-q",
"-p",
"no:cacheprovider",
"--durations=10",
*文件,
]
with 日志.open("w", encoding="utf-8") as 输出:
输出.write(" ".join(shlex.quote(x) for x in 命令) + "\n\n")
输出.flush()
进程 = subprocess.run(命令, cwd=根, stdout=输出, stderr=subprocess.STDOUT)
尾部 = "\n".join(日志.read_text(encoding="utf-8").splitlines()[-3:])
return 进程.returncode, str(日志), 尾部
def main(argv: list[str] | None = None) -> int:
解析 = argparse.ArgumentParser(description=__doc__)
解析.add_argument("--片数", type=int, default=3)
解析.add_argument(
"--含慢", action="store_true", help="含单例超过三分钟的慢用例;最终全量才需要"
)
解析.add_argument("范围", nargs="*", default=[])
值 = 解析.parse_args(argv)
if 值.片数 < 1:
raise SystemExit("片数必须为正整数")
# 连接串先查:采集期不连库,缺配置只会在采集之后暴露成误导性的「采集失败」。
环境 = {**os.environ}
if not 环境.get("MUSE_TEST_DATABASE_URL"):
print("缺少 MUSE_TEST_DATABASE_URL;隔离库层拒绝隐式回退", file=sys.stderr)
return 2
范围 = 值.范围 or ["tests"]
表达式 = 基础表达式 if 值.含慢 else f"{基础表达式} and not 慢"
桶 = 收集分片范围(范围, 值.片数, 表达式)
print(f"分片:{值.片数} 片,共 {sum(len(b) for b in 桶)} 个测试文件;表达式 {表达式}")
for 序号, 文件 in enumerate(桶, start=1):
print(f" 第 {序号} 片:{len(文件)} 个文件")
with ThreadPoolExecutor(max_workers=值.片数) as 池:
结果 = list(池.map(lambda 项: 跑一片(项[0], 项[1], 表达式), enumerate(桶, start=1)))
失败 = 0
for 序号, (码, 日志, 尾部) in enumerate(结果, start=1):
print(f"\n=== 第 {序号} 片 退出码 {码};日志 {日志}\n{尾部}")
失败 += 码 != 0
print(f"\n分片汇总:{值.片数} 片,{失败} 片非零")
return 1 if 失败 else 0
if __name__ == "__main__":
raise SystemExit(main())

131
工具/效果依赖.py Normal file
View File

@ -0,0 +1,131 @@
"""从实际业务入口递归提取本仓 Python 导入闭包,用于效果资质投影。
入口是能力合同,不是待维护的文件白名单。未知动态导入拒绝封闭;类型专用导入
不参与运行闭包。资源由构建器按生成阶段传入,不按文件名猜测是否影响效果。
"""
from __future__ import annotations
import ast
import hashlib
from pathlib import Path
能力入口 = {
"writer_generation": ["muse.编排.生成正文", "muse.编排.执行评测"],
"literary_judging": [
"muse.编排.执行评测",
"muse.效果评测.文学评分",
"muse.效果评测.启用判据",
],
"semantic_detection": [
"muse.编排.审校修订",
"muse.编排.执行评测",
"muse.效果评测.语义检测",
],
}
def _运行节点(树: ast.AST):
"""保留函数内部实际导入;跳过静态类型检查分支。"""
yield 树
for 子 in ast.iter_child_nodes(树):
if isinstance(子, ast.If) and (
isinstance(子.test, ast.Name)
and 子.test.id == "TYPE_CHECKING"
or isinstance(子.test, ast.Attribute)
and 子.test.attr == "TYPE_CHECKING"
):
for 节点 in 子.orelse:
yield from _运行节点(节点)
else:
yield from _运行节点(子)
def _闭包(源码根: Path, 入口: list[str]) -> tuple[dict[str, str], list[str]]:
模块文件 = {}
for 文件 in 源码根.rglob("*.py"):
if "__pycache__" in 文件.parts:
continue
片段 = list(文件.relative_to(源码根).with_suffix("").parts)
if 片段[-1] == "__init__":
片段.pop()
模块文件[".".join(片段)] = 文件
结果: dict[str, str] = {}
未封闭: set[str] = set()
待查 = list(入口)
while 待查:
名 = 待查.pop()
if 名 in 结果:
continue
文件 = 模块文件.get(名)
if 文件 is None:
未封闭.add(f"缺本仓模块:{名}")
continue
树 = ast.parse(文件.read_text(encoding="utf-8"))
结果[名] = hashlib.sha256(ast.dump(树, include_attributes=False).encode()).hexdigest()
依赖: set[str] = set()
节点们 = list(_运行节点(树))
动态名称 = {"__import__"}
for 节点 in 节点们:
if isinstance(节点, ast.ImportFrom) and 节点.module == "importlib":
动态名称.update(v.asname or v.name for v in 节点.names if v.name == "import_module")
for 节点 in 节点们:
if isinstance(节点, ast.Import):
依赖.update(v.name for v in 节点.names)
elif isinstance(节点, ast.ImportFrom):
基 = 节点.module or ""
if 节点.level:
包 = 名.split(".") if 文件.name == "__init__.py" else 名.split(".")[:-1]
基 = ".".join(包[: len(包) - 节点.level + 1] + ([基] if 基 else []))
依赖.add(基)
依赖.update(f"{基}.{v.name}" for v in 节点.names if f"{基}.{v.name}" in 模块文件)
elif isinstance(节点, ast.Call):
函数 = 节点.func
动态 = isinstance(函数, ast.Name) and 函数.id in 动态名称
动态 = (
动态
or isinstance(函数, ast.Attribute)
and 函数.attr in {"import_module", "__import__"}
)
if 动态:
if (
节点.args
and isinstance(节点.args[0], ast.Constant)
and isinstance(节点.args[0].value, str)
and not 节点.args[0].value.startswith(".")
):
依赖.add(节点.args[0].value)
else:
未封闭.add(f"动态导入:{名}:{节点.lineno}")
if isinstance(函数, ast.Name) and 函数.id in {"exec", "eval"}:
未封闭.add(f"动态代码:{名}:{节点.lineno}")
# 导入子模块也会执行各级包的 __init__。
依赖.update(".".join(名.split(".")[:i]) for i in range(1, len(名.split("."))))
for 目标 in sorted(依赖):
if 目标 == "muse" or 目标.startswith("muse."):
if 目标 in 模块文件:
if 目标 not in 结果:
待查.append(目标)
else:
未封闭.add(f"缺本仓模块:{目标}")
return dict(sorted(结果.items())), sorted(未封闭)
def 构建效果依赖(根: Path, 资源: dict[str, str], 分组: dict[str, list[str]]) -> dict:
能力 = {}
for 名, 根模块 in 能力入口.items():
入口 = sorted(set(根模块 + ["muse.编排.运行装配"]))
代码, 未封闭 = _闭包(根 / "src", 入口)
能力[名] = {
"roots": 入口,
"code": 代码,
"resources": dict(sorted(资源.items())),
"resource_groups": 分组,
"closed": not 未封闭,
"unresolved": 未封闭,
}
return {
"version": "effect-dependencies-v1",
"dependency_lock_sha256": hashlib.sha256((根 / "uv.lock").read_bytes()).hexdigest(),
"capabilities": 能力,
}

View File

@ -3,55 +3,51 @@
import argparse import argparse
import hashlib import hashlib
import json import json
import os
import shutil import shutil
import subprocess import subprocess
import sys
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from zipfile import ZipFile from zipfile import ZipFile
from 构建编排 import 执行构建
根 = Path(__file__).resolve().parents[1] 根 = Path(__file__).resolve().parents[1]
def 运行(args): def 运行(args):
subprocess.run(args, cwd=根, check=True) subprocess.run(args, cwd=根, env={**os.environ, "PYTHONPATH": str(根 / "src")}, check=True)
def 构建发布(output: Path, *, 生成=True): def 构建发布(output: Path, *, 生成=True):
output = output.resolve() output = output.resolve()
if output.exists(): if output.exists():
raise ValueError("发布目录已存在;使用新目录保留已有版本") raise ValueError("发布目录已存在;使用新目录保留已有版本")
output.parent.mkdir(parents=True, exist_ok=True) # 生成参数为旧调用者保留;共享入口始终按内容和产物存在性判断复用。
if 生成: 报告 = 执行构建(轮子=True)
运行(["uv", "run", "python", "工具/生成接口.py"]) 路径 = 报告.get("wheel")
if not isinstance(路径, str):
raise ValueError("增量构建未返回本批轮子")
wheel = Path(路径).resolve()
if not wheel.is_relative_to(根.resolve()) or wheel.suffix != ".whl" or not wheel.is_file():
raise ValueError("本批轮子不存在或不在仓库构建范围内")
运行([sys.executable, "工具/维护索引.py", "--检查"])
运行( 运行(
[ [
"pnpm", sys.executable,
"--dir",
"web",
"exec",
"openapi-typescript",
"../docs/接口契约/生成/openapi.generated.json",
"-o",
"src/接口/生成/类型.ts",
]
)
运行(["pnpm", "--dir", "web", "run", "build"])
运行(["uv", "run", "python", "工具/构建资源包.py"])
运行(["uv", "run", "python", "工具/维护索引.py", "--检查"])
运行(
[
"uv",
"run",
"python",
"-c", "-c",
"from muse.资源加载 import 核对资源; " "from muse.资源加载 import 核对资源; "
"from muse.基础设施.系统运行 import 运行代码哈希; " "from muse.基础设施.系统运行 import 运行代码哈希; "
"assert 核对资源()['代码哈希'] == 运行代码哈希(), '资源与当前代码不匹配,先重新构建'", "assert 核对资源()['代码哈希'] == 运行代码哈希(), '资源与当前代码不匹配,先重新构建'",
] ]
) )
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".muse-release-", dir=output.parent) as temporary: with tempfile.TemporaryDirectory(prefix=".muse-release-", dir=output.parent) as temporary:
stage = Path(temporary) stage = Path(temporary)
运行(["uv", "build", "--wheel", "--out-dir", str(stage / "wheels")]) (stage / "wheels").mkdir()
本批轮子 = stage / "wheels" / wheel.name
shutil.copyfile(wheel, 本批轮子)
运行( 运行(
[ [
"uv", "uv",
@ -129,6 +125,9 @@ def 构建发布(output: Path, *, 生成=True):
if __name__ == "__main__": if __name__ == "__main__":
parser = argparse.ArgumentParser(description=__doc__) parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--输出", required=True, type=Path) parser.add_argument("--输出", required=True, type=Path)
parser.add_argument("--已生成", action="store_true", help="复用刚通过构建的资源,仍核对索引") parser.add_argument(
"--已生成", action="store_true", help="兼容参数;统一增量入口自动复用产物,仍校验资源和索引"
)
parser.add_argument("--缓存根", type=Path, help="显式发布缓存根;不接管普通目录或备份")
args = parser.parse_args() args = parser.parse_args()
print(json.dumps(构建发布(args.输出, 生成=not args.已生成), ensure_ascii=False)) print(json.dumps(构建发布(args.输出, 生成=not args.已生成), ensure_ascii=False))

372
工具/构建编排.py Normal file
View File

@ -0,0 +1,372 @@
"""按内容推进生成与 wheel 构建;失败时不激活本轮中间结果。
缓存可随时删除。输入只来自源码、登记资源与构建配置,不扫描作品或运行数据。
"""
from __future__ import annotations
import argparse
import fcntl
import hashlib
import importlib.metadata
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
from pathlib import Path
from typing import Any
根 = Path(__file__).resolve().parents[1]
缓存 = 根 / "构建缓存"
接口 = "docs/接口契约/生成/openapi.generated.json"
客户端 = "web/src/接口/生成/客户端.ts"
类型 = "web/src/接口/生成/类型.ts"
def 文件摘要(路径: Path) -> str:
with 路径.open("rb") as 文件:
return hashlib.file_digest(文件, "sha256").hexdigest()
def 路径摘要(目录: Path, 路径们: list[str]) -> dict[str, str]:
结果 = {}
for 相对 in sorted(set(路径们)):
路径 = 目录 / 相对
文件们 = sorted(路径.rglob("*")) if 路径.is_dir() else [路径]
for 文件 in 文件们:
if 文件.is_file() and "__pycache__" not in 文件.parts:
结果[文件.relative_to(目录).as_posix()] = 文件摘要(文件)
return 结果
def 摘要(值: Any) -> str:
return hashlib.sha256(json.dumps(值, sort_keys=True).encode()).hexdigest()
def 原子写(路径: Path, 值: Any) -> None:
临时 = 路径.with_suffix(".tmp")
临时.write_text(json.dumps(值, ensure_ascii=False, indent=2) + "\n")
临时.replace(路径)
def 生产前端输入() -> list[str]:
"""当前 Vite HTML 入口及本地导入决定生产输入,tsc 测试检查由前端检查承担。
src 入口目录整体保守纳入,目录外的本地导入递归跟随。自定义解析、非字面量
动态导入或 glob 无法静态封闭时退回全 web 输入,不能漏算后悄悄缓存。
"""
web = 根 / "web"
所有 = {
p
for p in web.rglob("*")
if p.is_file()
and not {"node_modules", "dist", "test-results", "playwright-report"}.intersection(
p.relative_to(web).parts
)
and not p.name.endswith(".tsbuildinfo")
}
def 保守输入() -> list[str]:
return sorted(p.relative_to(根).as_posix() for p in 所有)
配置 = web / "vite.config.ts"
配置文本 = 配置.read_text()
# 这些配置可以改变入口/解析/复制边界;未知配置仍走保守闭包。
if re.search(r"\b(root|publicDir|rollupOptions|resolve|assetsInclude|lib)\s*:", 配置文本):
return 保守输入()
配置导入 = re.findall(r"(?:from\s*|import\s*)[\"']([^\"']+)[\"']", 配置文本)
if any(v not in {"vite", "@vitejs/plugin-react"} for v in 配置导入):
return 保守输入()
待查 = {
web / n
for n in ("index.html", "vite.config.ts", "tsconfig.json", "package.json", "pnpm-lock.yaml")
}
html = (web / "index.html").read_text()
for 入口 in re.findall(r"(?:src|href)=[\"']([^\"']+)[\"']", html):
if ":" in 入口 or 入口.startswith("#"):
continue
文件 = web / 入口.lstrip("/")
if not 文件.is_file():
return 保守输入()
# 入口所属源码目录含声明文件/隐式CSS依赖,整体保守纳入。
入口目录 = web / 文件.relative_to(web).parts[0]
待查.update(p for p in 所有 if p.is_relative_to(入口目录))
待查.add(文件)
待查.update(p for p in 所有 if p.is_relative_to(web / "public") or p.name.startswith(".env"))
已查: set[Path] = set()
导入式 = (
r"(?:from\s*|import\s*(?:\(\s*)?|require\s*\(\s*|@import\s*|url\(\s*)[\"']([^\"']+)[\"']"
)
后缀 = (
"",
".ts",
".tsx",
".js",
".jsx",
".json",
".css",
"/index.ts",
"/index.tsx",
"/index.js",
)
while 待查:
文件 = 待查.pop()
if 文件 in 已查 or not 文件.is_file():
continue
已查.add(文件)
if 文件.suffix not in {".ts", ".tsx", ".js", ".jsx", ".css"}:
continue
文本 = 文件.read_text()
if re.search(r"import\.meta\.glob|(?:import|require)\s*\(\s*(?![\s\"'])", 文本):
return 保守输入()
for 名 in re.findall(导入式, 文本):
if not 名.startswith((".", "/")):
continue
路径 = web / 名.lstrip("/") if 名.startswith("/") else 文件.parent / 名
路径 = 路径.resolve()
if not 路径.is_relative_to(web):
raise ValueError("生产前端导入越出 web;请先显式登记跨目录构建输入")
候选 = [Path(str(路径).split("?", 1)[0] + 后) for 后 in 后缀]
实际 = next((p for p in 候选 if p.is_file()), None)
if 实际 is None:
if 根 / 类型 in 候选 or 根 / 客户端 in 候选:
continue
return 保守输入()
if 实际 not in 已查:
待查.add(实际)
return sorted(p.relative_to(根).as_posix() for p in 已查)
def 源清单() -> list[str]:
登记 = json.loads((根 / "工具/资源登记.json").read_text())
列表 = ["pyproject.toml", "uv.lock", "README.md"]
列表 += [
"工具/生成接口.py",
"工具/生成输入.json",
"工具/构建资源包.py",
"工具/效果依赖.py",
"工具/资源登记.json",
"工具/构建编排.py",
]
列表 += [
p.relative_to(根).as_posix()
for p in (根 / "src/muse").rglob("*")
if p.is_file()
and not {"资源", "__pycache__"}.intersection(p.relative_to(根 / "src/muse").parts)
]
列表 += 生产前端输入()
列表 += [v["源路径"] for v in 登记["资源"]] + 登记.get("能力文件", [])
列表 += [p.relative_to(根).as_posix() for p in (根 / 登记["迁移目录"]).glob("*.sql")]
for 相对 in 列表:
路径 = 根 / 相对
if not 路径.resolve().is_relative_to(根) or not 路径.is_file():
raise ValueError(f"构建输入必须是仓内文件:{相对}")
return sorted(set(列表))
def 构建(轮子: bool) -> dict:
清单 = 源清单()
原始 = 路径摘要(根, 清单)
try:
旧缓存 = json.loads((缓存 / "状态.json").read_text())
if not isinstance(旧缓存, dict):
旧缓存 = {}
except (OSError, ValueError):
旧缓存 = {}
版本 = {"python": sys.version, "编排": 文件摘要(Path(__file__))}
for 名 in ("fastapi", "pydantic", "pyyaml"):
版本[名] = importlib.metadata.version(名)
for 名 in ("pnpm", "node", "uv"):
路径 = shutil.which(名)
if 路径 is None:
raise RuntimeError(f"缺少已安装工具:{名}")
版本[名] = 文件摘要(Path(路径).resolve())
for 名 in ("typescript", "vite", "openapi-typescript"):
文件 = 根 / "web/node_modules" / 名 / "package.json"
版本[名] = json.loads(文件.read_text())["version"]
新缓存: dict[str, Any] = {}
事件: list[dict] = []
待激活: list[str] = []
with tempfile.TemporaryDirectory(prefix="构建-", dir=缓存) as 临时:
暂存 = Path(临时)
# 同批全部步骤在暂存树运行;任何失败均不触及已有发布输出。
def 复制(相对: str) -> None:
源 = 根 / 相对
目标 = 暂存 / 相对
if not 源.exists():
return
目标.parent.mkdir(parents=True, exist_ok=True)
if 源.is_dir():
if 源.is_symlink() or any(p.is_symlink() for p in 源.rglob("*")):
raise ValueError(f"构建产物目录不得含软链:{相对}")
shutil.copytree(源, 目标, dirs_exist_ok=True)
else:
shutil.copyfile(源, 目标)
for 相对 in 清单 + [接口, "web/dist", "src/muse/资源"]:
复制(相对)
(暂存 / "web/node_modules").symlink_to((根 / "web/node_modules").resolve())
环境 = {
**os.environ,
"PYTHONPATH": str(暂存 / "src"),
"PYTHONDONTWRITEBYTECODE": "1",
"UV_OFFLINE": "true",
"UV_NO_SYNC": "true",
}
环境["SOURCE_DATE_EPOCH"] = "1767225600"
def 执行(名称: str, 输入: list[str], 输出: list[str], 命令: list[list[str]]) -> None:
指纹 = 摘要({"输入": 路径摘要(暂存, 输入), "版本": 版本})
旧 = 旧缓存.get(名称, {})
if not isinstance(旧, dict):
旧 = {}
当前 = 路径摘要(根, 输出)
命中 = (
bool(当前)
and all((根 / p).exists() for p in 输出)
and 旧.get("输入") == 指纹
and 旧.get("输出") == 当前
)
if not 命中:
for 参数 in 命令:
subprocess.run(参数, cwd=暂存, env=环境, check=True)
if not all((暂存 / p).exists() for p in 输出):
raise RuntimeError(f"{名称} 未产生约定输出")
待激活.extend(输出)
当前 = 路径摘要(暂存, 输出)
新缓存[名称] = {"输入": 指纹, "输出": 当前}
事件.append({"步骤": 名称, "执行": not 命中})
py = [p for p in 清单 if p.startswith("src/") or p in {"pyproject.toml", "uv.lock"}]
执行(
"OpenAPI",
py + ["工具/生成接口.py", "工具/生成输入.json"],
[接口, 客户端],
[[sys.executable, "工具/生成接口.py"]],
)
执行(
"TypeScript",
[接口, "web/package.json", "web/pnpm-lock.yaml"],
[类型],
[
[
"pnpm",
"--dir",
"web",
"exec",
"openapi-typescript",
"../" + 接口,
"-o",
"src/接口/生成/类型.ts",
]
],
)
web = [p for p in 清单 if p.startswith("web/")]
# 发布只检查生产源码;完整 src/tests 类型门仍由 make 前端检查承担。
(暂存 / "web/tsconfig.生产.json").write_text(
json.dumps({"extends": "./tsconfig.json", "include": ["src"]}) + "\n"
)
执行(
"web",
web + [类型, 客户端],
["web/dist"],
[
[
"pnpm",
"--dir",
"web",
"exec",
"tsc",
"--project",
"tsconfig.生产.json",
"--noEmit",
],
["pnpm", "--dir", "web", "exec", "vite", "build"],
],
)
资源 = [p for p in 清单 if not p.startswith("web/")]
执行(
"资源", 资源 + ["web/dist"], ["src/muse/资源"], [[sys.executable, "工具/构建资源包.py"]]
)
wheel = None
if 轮子:
import tomllib
配置 = tomllib.loads((暂存 / "pyproject.toml").read_text())
包名 = 配置["project"]["name"].replace("-", "_")
wheel = f"dist/{包名}-{配置['project']['version']}-py3-none-any.whl"
执行(
"wheel",
py + ["src/muse/资源", "README.md"],
[wheel],
[["uv", "build", "--offline", "--wheel", "--out-dir", "dist"]],
)
elif "wheel" in 旧缓存:
新缓存["wheel"] = 旧缓存["wheel"]
# 构建期间输入有变化则拒绝发布,避免不同时间点的代码和清单混装。
if 源清单() != 清单 or 路径摘要(根, 清单) != 原始:
raise RuntimeError("构建期间源码发生变化,拒绝发布本轮产物")
备份 = 暂存 / "原输出"
已移动: list[str] = []
try:
for 相对 in dict.fromkeys(待激活):
目标, 保存 = 根 / 相对, 备份 / 相对
目标.parent.mkdir(parents=True, exist_ok=True)
保存.parent.mkdir(parents=True, exist_ok=True)
if 目标.exists():
目标.rename(保存)
已移动.append(相对)
(暂存 / 相对).rename(目标)
原子写(缓存 / "状态.json", 新缓存)
except BaseException:
for 相对 in reversed(已移动):
目标, 保存 = 根 / 相对, 备份 / 相对
if 目标.is_dir():
shutil.rmtree(目标)
elif 目标.exists():
目标.unlink()
if 保存.exists():
保存.rename(目标)
raise
return {
"步骤": 事件,
"执行数": sum(e["执行"] for e in 事件),
"wheel": str(根 / wheel) if wheel else None,
"工具身份": 版本,
}
def 执行构建(轮子: bool = False) -> dict:
"""共享聚合入口;进程锁覆盖构建和激活,返回本批实际 wheel 路径。"""
缓存.mkdir(exist_ok=True)
with (缓存 / "构建.lock").open("w") as 锁:
fcntl.flock(锁, fcntl.LOCK_EX)
return 构建(轮子)
def 主() -> int:
解析器 = argparse.ArgumentParser(description=__doc__)
解析器.add_argument("--wheel", action="store_true")
解析器.add_argument("--报告", type=Path)
参数 = 解析器.parse_args()
# 生成会改写 src/muse/资源;验证在跑时改写会让本轮结论作废。
from 验证锁 import 正在验证
说明 = 正在验证()
if 说明:
print(f"拒绝生成:验证正在运行({说明});等它结束再改源码与资源", file=sys.stderr)
return 1
报告 = 执行构建(参数.wheel)
if 参数.报告:
参数.报告.parent.mkdir(parents=True, exist_ok=True)
原子写(参数.报告, 报告)
print(json.dumps(报告, ensure_ascii=False, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(主())

View File

@ -15,12 +15,14 @@ import argparse
import hashlib import hashlib
import json import json
import shutil import shutil
import tempfile
import tomllib import tomllib
import zipfile import zipfile
from pathlib import Path from pathlib import Path, PurePosixPath
from typing import Any from typing import Any
from muse.资源加载 import 解析能力声明 from muse.资源加载 import 解析能力声明
from 效果依赖 import 构建效果依赖
仓库根 = Path(__file__).resolve().parent.parent 仓库根 = Path(__file__).resolve().parent.parent
登记文件 = 仓库根 / "工具" / "资源登记.json" 登记文件 = 仓库根 / "工具" / "资源登记.json"
@ -78,7 +80,67 @@ def 编译能力资源(
return 目录, 资源 return 目录, 资源
def 打包() -> dict[str, Any]: def _仓内源(相对: str, *, 目录: bool = False) -> Path:
路径 = 仓库根 / 相对
if Path(相对).is_absolute() or not 路径.resolve().is_relative_to(仓库根.resolve()):
raise ValueError(f"登记源必须位于仓库内:{相对}")
if not (路径.is_dir() if 目录 else 路径.is_file()):
raise ValueError(f"登记源不存在或类型不符:{相对}")
return 路径
def _预检资源(登记: dict, 能力资源: dict[str, bytes]) -> tuple[list[Path], list[Path]]:
"""先统一核对源边界和所有目标身份;此阶段不创建 ZIP 或读取 web 文件字节。"""
目标们: set[str] = set()
def 登记目标(路径: str) -> None:
规范 = PurePosixPath(路径)
if (
not 路径
or "\\" in 路径
or "\x00" in 路径
or 规范.is_absolute()
or (规范.parts and ":" in 规范.parts[0])
or ".." in 规范.parts
or 规范.as_posix() != 路径
or 路径 == "."
):
raise ValueError(f"资源目标必须是规范化相对路径:{路径}")
if 路径 in 目标们:
raise ValueError(f"资源目标重复:{路径}")
目标们.add(路径)
for 条 in 登记["资源"]:
_仓内源(str(条["源路径"]))
登记目标(str(条["打包路径"]))
for 路径 in 能力资源:
登记目标(路径)
迁移文件 = []
if 登记.get("迁移目录"):
源目录 = _仓内源(str(登记["迁移目录"]), 目录=True)
for 文件 in sorted(源目录.glob("V*.sql")):
_仓内源(文件.relative_to(仓库根).as_posix())
登记目标(f"迁移/{文件.name}")
迁移文件.append(文件)
web_dist = 仓库根 / "web/dist"
if web_dist.is_symlink():
raise ValueError("web/dist 不得是软链")
web文件 = []
if web_dist.exists():
if not web_dist.is_dir():
raise ValueError("web/dist 必须是目录")
后代 = sorted(web_dist.rglob("*"))
if any(p.is_symlink() for p in 后代):
raise ValueError("web/dist 后代不得含软链")
for 文件 in 后代:
if 文件.is_file():
_仓内源(文件.relative_to(仓库根).as_posix())
登记目标("工作台/" + 文件.relative_to(web_dist).as_posix())
web文件.append(文件)
return 迁移文件, web文件
def _写入资源(资源目录: Path) -> dict[str, Any]:
登记 = 读取登记() 登记 = 读取登记()
发布身份 = str(登记.get("发布身份", "")) 发布身份 = str(登记.get("发布身份", ""))
if not 发布身份.strip(): if not 发布身份.strip():
@ -99,6 +161,7 @@ def 打包() -> dict[str, Any]:
能力资源["能力/目录.json"] = json.dumps( 能力资源["能力/目录.json"] = json.dumps(
目录, ensure_ascii=False, sort_keys=True, indent=2 目录, ensure_ascii=False, sort_keys=True, indent=2
).encode() ).encode()
迁移文件, web文件 = _预检资源(登记, 能力资源)
资源目录.mkdir(parents=True, exist_ok=True) 资源目录.mkdir(parents=True, exist_ok=True)
压缩包路径 = 资源目录 / 压缩包名 压缩包路径 = 资源目录 / 压缩包名
@ -112,8 +175,9 @@ def 打包() -> dict[str, Any]:
信息 = zipfile.ZipInfo(打包路径, date_time=_固定时间戳) 信息 = zipfile.ZipInfo(打包路径, date_time=_固定时间戳)
信息.compress_type = zipfile.ZIP_DEFLATED 信息.compress_type = zipfile.ZIP_DEFLATED
信息.external_attr = 0o644 << 16 信息.external_attr = 0o644 << 16
zf.writestr(信息, 源.read_bytes()) 数据 = 源.read_bytes()
已打包[打包路径] = _sha256(源) zf.writestr(信息, 数据)
已打包[打包路径] = hashlib.sha256(数据).hexdigest()
for 路径, 数据 in sorted(能力资源.items()): for 路径, 数据 in sorted(能力资源.items()):
if 路径 in 已打包: if 路径 in 已打包:
raise ValueError("生成能力资源与直接登记路径重复") raise ValueError("生成能力资源与直接登记路径重复")
@ -133,9 +197,23 @@ def 打包() -> dict[str, Any]:
if 迁移资源.exists(): if 迁移资源.exists():
shutil.rmtree(迁移资源) shutil.rmtree(迁移资源)
迁移资源.mkdir() 迁移资源.mkdir()
for 文件 in sorted(源目录.glob("V*.sql")): for 文件 in 迁移文件:
shutil.copyfile(文件, 迁移资源 / 文件.name) 数据 = 文件.read_bytes()
已打包[f"迁移/{文件.name}"] = _sha256(文件) (迁移资源 / 文件.name).write_bytes(数据)
已打包[f"迁移/{文件.name}"] = hashlib.sha256(数据).hexdigest()
# 效果依赖按登记/编译/迁移生成阶段取全量保守闭包,不纳入工作台产物。
效果依赖 = 构建效果依赖(
仓库根,
dict(已打包),
{
"registered": sorted(str(条["打包路径"]) for 条 in 条目),
"capabilities": sorted(能力资源),
"migrations": sorted(
set(已打包) - {str(条["打包路径"]) for 条 in 条目} - set(能力资源)
),
},
)
# 工作台投影:web 构建产物存在时带上;不存在时如实留空。 # 工作台投影:web 构建产物存在时带上;不存在时如实留空。
工作台目录 = 资源目录 / "工作台" 工作台目录 = 资源目录 / "工作台"
@ -143,14 +221,20 @@ def 打包() -> dict[str, Any]:
shutil.rmtree(工作台目录) shutil.rmtree(工作台目录)
web_dist = 仓库根 / "web" / "dist" web_dist = 仓库根 / "web" / "dist"
if web_dist.is_dir(): if web_dist.is_dir():
shutil.copytree(web_dist, 工作台目录) 工作台目录.mkdir()
for 文件 in sorted(工作台目录.rglob("*")): for 文件 in web文件:
if 文件.is_file(): if 文件.is_symlink() or any(p.is_symlink() for p in 文件.parents if p != 仓库根):
相对 = 文件.relative_to(资源目录).as_posix() raise ValueError("web/dist 在预检后变为软链")
已打包[相对] = _sha256(文件) 相对 = 文件.relative_to(web_dist)
目标 = 工作台目录 / 相对
目标.parent.mkdir(parents=True, exist_ok=True)
数据 = 文件.read_bytes()
目标.write_bytes(数据)
已打包["工作台/" + 相对.as_posix()] = hashlib.sha256(数据).hexdigest()
清单 = { 清单 = {
"schema_version": 1, "schema_version": 1,
"效果依赖": 效果依赖,
"发布身份": 发布身份, "发布身份": 发布身份,
"资源": {路径: {"sha256": 哈希} for 路径, 哈希 in sorted(已打包.items())}, "资源": {路径: {"sha256": 哈希} for 路径, 哈希 in sorted(已打包.items())},
} }
@ -173,6 +257,24 @@ def 打包() -> dict[str, Any]:
return 清单 return 清单
def 打包() -> dict[str, Any]:
"""在独立目录完成全部输入校验与输出,再替换旧资源;失败保留旧产物。"""
资源目录.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".资源构建-", dir=资源目录.parent) as 临时:
暂存 = Path(临时) / "新资源"
清单 = _写入资源(暂存)
旧资源 = Path(临时) / "旧资源"
if 资源目录.exists():
资源目录.rename(旧资源)
try:
暂存.rename(资源目录)
except BaseException:
if 旧资源.exists():
旧资源.rename(资源目录)
raise
return 清单
def 主() -> int: def 主() -> int:
解析器 = argparse.ArgumentParser(description="生成运行资源与发布清单") 解析器 = argparse.ArgumentParser(description="生成运行资源与发布清单")
解析器.parse_args() 解析器.parse_args()

92
工具/源码资源.py Normal file
View File

@ -0,0 +1,92 @@
"""开发测试显式源资源视图,不生成发布包,也不提供安装资格。"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
from muse.共享.错误 import 资源缺失错误
class 源码资源提供器:
"""只读取登记的工程资源;测试装配必须显式注入。"""
def __init__(self, 根: Path):
self._根 = 根
self._已加载 = False
self._资源: dict[str, bytes] = {}
self._清单: dict[str, Any] = {}
def _确保加载(self) -> None:
if self._已加载:
return
根 = self._根
from muse.接入.http.应用 import 导出合同
from 构建资源包 import 编译能力资源
登记 = json.loads((根 / "工具/资源登记.json").read_text())
for 项 in 登记["资源"]:
源 = (根 / 项["源路径"]).resolve()
if not 源.is_relative_to(根.resolve()):
raise ValueError("源码资源越过登记根")
self._资源[项["打包路径"]] = 源.read_bytes()
命令 = {
操作["operationId"]
for 路径 in 导出合同()["paths"].values()
for 操作 in 路径.values()
if isinstance(操作, dict) and "operationId" in 操作
}
目录, 能力 = 编译能力资源(根, 登记.get("能力文件", []), 命令)
self._资源.update(能力)
self._资源["能力/目录.json"] = json.dumps(
目录, ensure_ascii=False, sort_keys=True, indent=2
).encode()
for 文件 in sorted((根 / 登记["迁移目录"]).glob("V*.sql")):
self._资源[f"迁移/{文件.name}"] = 文件.read_bytes()
资源表 = {
路径: {"sha256": hashlib.sha256(数据).hexdigest()}
for 路径, 数据 in sorted(self._资源.items())
}
身份 = hashlib.sha256(json.dumps(资源表, sort_keys=True).encode()).hexdigest()
self._清单 = {
"schema_version": 1,
"资源来源": "开发源码",
"发布身份": "development-source",
"构建身份": 身份,
"代码版本": "development",
"代码哈希": "development-not-an-installation",
"合同版本": 1,
"资源": 资源表,
}
from 效果依赖 import 构建效果依赖
登记路径 = {项["打包路径"] for 项 in 登记["资源"]}
能力路径 = {*能力, "能力/目录.json"}
self._清单["效果依赖"] = 构建效果依赖(
根,
{路径: 项["sha256"] for 路径, 项 in 资源表.items()},
{
"registered": sorted(登记路径),
"capabilities": sorted(能力路径),
"migrations": sorted(set(资源表) - 登记路径 - 能力路径),
},
)
self._已加载 = True
def 加载清单(self) -> dict[str, Any]:
self._确保加载()
# 返回独立结构,测试不能污染后续用例的共享输入。
return json.loads(json.dumps(self._清单))
def 打开资源(self, 名称: str) -> bytes:
self._确保加载()
try:
return self._资源[名称]
except KeyError as exc:
raise 资源缺失错误(f"源码测试资源未登记:{名称}") from exc
def 核对资源(self) -> dict[str, Any]:
return self.加载清单()

View File

@ -9,22 +9,74 @@ import subprocess
import tempfile import tempfile
from pathlib import Path from pathlib import Path
from muse.共享.错误 import Muse错误, 配置错误 from muse.共享.错误 import Muse错误, 环境缺失错误, 资源缺失错误, 配置错误
from muse.启动 import 构建 from muse.启动 import 构建
from muse.资源加载 import 核对资源, 读取能力目录 from muse.资源加载 import 核对资源, 读取能力目录
from muse.配置 import 读取配置 from muse.配置 import 读取配置
def 安全失败(动作: str, 错误: Exception) -> dict:
"""输出固定原因,不展示异常文本、命令、stdout/stderr 或配置上下文。"""
可重试 = False
详情 = {}
if isinstance(错误, subprocess.TimeoutExpired):
类别, 说明, 可重试 = "TIMEOUT", "所选检查超过时限", True
elif isinstance(错误, subprocess.CalledProcessError):
类别, 说明 = "PROCESS_FAILED", "所选检查进程非零退出"
if isinstance(错误.returncode, int):
详情 = {"exit_code": 错误.returncode}
elif isinstance(错误, PermissionError):
类别, 说明 = "PERMISSION_DENIED", "所选检查没有所需文件或执行权限"
elif isinstance(错误, FileNotFoundError):
类别, 说明 = "NOT_FOUND", "所选检查需要的文件或可执行程序不存在"
elif isinstance(错误, 资源缺失错误):
类别, 说明 = "RESOURCE_INVALID", "安装资源缺失或完整性核对失败"
elif isinstance(错误, 环境缺失错误):
类别, 说明 = "ENVIRONMENT_MISSING", "所选检查需要的环境未就绪"
elif isinstance(错误, 配置错误):
类别, 说明 = "CONFIG_INVALID", "所选检查的显式配置或版本要求不匹配"
elif isinstance(错误, Muse错误) and 错误.错误码 == "MUSE_DB_CONNECTION":
类别, 说明, 可重试 = "CONNECTION_FAILED", "数据库连接或会话初始化失败", True
elif isinstance(错误, Muse错误):
类别, 说明 = "REJECTED", "所选检查被运行合同拒绝"
elif isinstance(错误, (ValueError, KeyError, TypeError)):
类别, 说明 = "INVALID_DATA", "所选检查的数据格式、字段或版本无效"
elif isinstance(错误, OSError):
类别, 说明 = "IO_FAILED", "所选检查的文件、执行或连接操作失败"
else:
类别, 说明 = "INTERNAL_ERROR", "所选检查遇到未预期错误"
return {
"code": f"PREFLIGHT_{动作}_{类别}",
"action": 动作,
"message": 说明,
"retryable": 可重试,
"details": 详情,
}
class 预检失败(Exception):
def __init__(self, 报告: dict) -> None:
self.报告 = 报告
super().__init__(报告["message"])
def 检查Pi(node: Path, cli: Path, 预期版本: str) -> dict: def 检查Pi(node: Path, cli: Path, 预期版本: str) -> dict:
if not node.is_file() or not cli.is_file(): 动作 = "HOST_NODE"
raise 配置错误("Node或Pi入口不存在;需要明确的本地可执行路径") try:
if not node.is_file():
raise FileNotFoundError
结果 = subprocess.run( 结果 = subprocess.run(
[str(node), "--version"], capture_output=True, text=True, timeout=10, check=True [str(node), "--version"], capture_output=True, text=True, timeout=10, check=True
) )
node_version = 结果.stdout.strip().removeprefix("v") node_version = 结果.stdout.strip().removeprefix("v")
parts = tuple(int(v) for v in node_version.split(".")) parts = tuple(int(v) for v in node_version.split("."))
if len(parts) != 3 or any(v < 0 for v in parts):
raise ValueError
if parts < (22, 19, 0): if parts < (22, 19, 0):
raise 配置错误("当前Pi需要Node至少22.19.0;不能使用shell默认旧版本") raise 配置错误("Node版本未达到已声明的宿主要求")
动作 = "HOST_PI"
if not cli.is_file():
raise FileNotFoundError
with tempfile.TemporaryDirectory(prefix="muse-pi-preflight-") as 临时: with tempfile.TemporaryDirectory(prefix="muse-pi-preflight-") as 临时:
环境 = { 环境 = {
**os.environ, **os.environ,
@ -45,6 +97,8 @@ def 检查Pi(node: Path, cli: Path, 预期版本: str) -> dict:
if 实际 != 预期版本: if 实际 != 预期版本:
raise 配置错误("Pi实际版本与显式要求不符") raise 配置错误("Pi实际版本与显式要求不符")
return {"node_version": node_version, "pi_version": 实际, "state": "version_verified"} return {"node_version": node_version, "pi_version": 实际, "state": "version_verified"}
except Exception as 错误:
raise 预检失败(安全失败(动作, 错误)) from None
def 主() -> int: def 主() -> int:
@ -61,6 +115,7 @@ def 主() -> int:
(参数.node, 参数.pi, 参数.pi_version) (参数.node, 参数.pi, 参数.pi_version)
): ):
解析.error("Pi检查必须同时给出node、pi和pi-version") 解析.error("Pi检查必须同时给出node、pi和pi-version")
动作 = "RESOURCE"
try: try:
清单 = 核对资源() 清单 = 核对资源()
能力 = 读取能力目录() 能力 = 读取能力目录()
@ -76,25 +131,26 @@ def 主() -> int:
"model_calls": 0, "model_calls": 0,
} }
if 参数.配置: if 参数.配置:
动作 = "CONFIG"
配置 = 读取配置(参数.配置) 配置 = 读取配置(参数.配置)
if 配置.资源发布身份 != 清单["构建身份"]: if 配置.资源发布身份 != 清单["构建身份"]:
raise 配置错误("配置必须固定当前资源构建身份;先无配置运行预检取得身份") raise 配置错误("配置必须固定当前资源构建身份;先无配置运行预检取得身份")
if 参数.数据库: if 参数.数据库:
构建(配置).要求数据库().检查连接() 动作 = "DATABASE"
# 常驻连接池只在装配生命周期内打开;不进生命周期会以「连接池未就绪」必然失败。
装配 = 构建(配置)
with 装配.生命周期():
装配.要求数据库().检查连接()
报告["database"] = "connection_verified" 报告["database"] = "connection_verified"
if 参数.node: if 参数.node:
动作 = "HOST"
报告["host"] = 检查Pi(参数.node.resolve(), 参数.pi.resolve(), 参数.pi_version) 报告["host"] = 检查Pi(参数.node.resolve(), 参数.pi.resolve(), 参数.pi_version)
print(json.dumps(报告, ensure_ascii=False, indent=2)) print(json.dumps(报告, ensure_ascii=False, indent=2))
return 0 return 0
except Muse错误 as exc: except 预检失败 as 错误:
print(json.dumps(exc.呈现(), ensure_ascii=False)) print(json.dumps(错误.报告, ensure_ascii=False))
except (OSError, ValueError, KeyError, subprocess.SubprocessError): except Exception as 错误:
print( print(json.dumps(安全失败(动作, 错误), ensure_ascii=False))
json.dumps(
{"code": "PREFLIGHT_FAILED", "message": "资源或宿主检查失败,未验证项目不计通过"},
ensure_ascii=False,
)
)
return 1 return 1

View File

@ -1,6 +1,7 @@
"""从登记的实际路由工厂生成 OpenAPI;不启动应用、不连接数据库或读取凭据。 """从登记路由工厂生成 OpenAPI 与客户端外壳;类型由后续步骤生成。
未支持的非空输入失败,生成结果由同一公开合同产生。 先核对全部登记与输出位置,再导入工厂并生成内存合同,最后写入。
不启动应用、不连接数据库或读取凭据。
""" """
from __future__ import annotations from __future__ import annotations
@ -8,44 +9,16 @@ from __future__ import annotations
import argparse import argparse
import importlib import importlib
import json import json
import keyword
import sys
from collections.abc import Callable
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
仓库根 = Path(__file__).resolve().parent.parent 仓库根 = Path(__file__).resolve().parent.parent
登记文件 = 仓库根 / "工具" / "生成输入.json" 登记文件 = 仓库根 / "工具" / "生成输入.json"
_客户端路径 = "web/src/接口/生成/客户端.ts"
_客户端正文 = (
def 已登记输入() -> dict[str, Any]:
"""读取生成登记;文件不存在视为空登记(如实报告,不猜测)。"""
if not 登记文件.is_file():
return {"资源": []}
数据: Any = json.loads(登记文件.read_text(encoding="utf-8"))
if not isinstance(数据, dict) or not isinstance(数据.get("资源"), list):
raise SystemExit(f"生成输入登记不合法:{登记文件}(需要 dict 含 资源 表)")
return 数据
def 主() -> int:
解析器 = argparse.ArgumentParser(description="生成 Schema、OpenAPI 和 TypeScript")
解析器.parse_args()
输入 = 已登记输入()
if not 输入["资源"]:
print("生成接口:无登记输入,未生成任何合同(业务外壳定稿后登记)")
return 0
for 条目 in 输入["资源"]:
if 条目.get("格式") != "OpenAPI" or not 条目.get("工厂") or not 条目.get("输出"):
raise SystemExit("生成接口:未接入此输入的生成器,未生成合同")
模块, 名称 = 条目["工厂"].split(":", 1)
合同 = getattr(importlib.import_module(模块), 名称)()
if not isinstance(合同, dict) or "openapi" not in 合同:
raise SystemExit("生成接口:工厂未返回 OpenAPI 合同")
输出 = 仓库根 / 条目["输出"]
输出.parent.mkdir(parents=True, exist_ok=True)
输出.write_text(json.dumps(合同, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
客户端 = 仓库根 / "web/src/接口/生成/客户端.ts"
客户端.parent.mkdir(parents=True, exist_ok=True)
客户端.write_text(
"// 由工具/生成接口.py生成;稳定外壳来自同一OpenAPI。\n" "// 由工具/生成接口.py生成;稳定外壳来自同一OpenAPI。\n"
'import createClient from "openapi-fetch";\n' 'import createClient from "openapi-fetch";\n'
'import type { paths } from "./类型";\n' 'import type { paths } from "./类型";\n'
@ -53,10 +26,129 @@ def 主() -> int:
"export const 客户端 = createClient<paths>({\n" "export const 客户端 = createClient<paths>({\n"
" baseUrl: globalThis.location.origin, fetch: 传输,\n" " baseUrl: globalThis.location.origin, fetch: 传输,\n"
' credentials: "same-origin"\n' ' credentials: "same-origin"\n'
"});\n", "});\n"
encoding="utf-8",
) )
print(f"生成接口:{条目['输出']}")
class 生成错误(ValueError):
"""仅携带可安全展示的登记或合同错误,不包含原始工厂异常。"""
def 已登记输入() -> dict[str, Any]:
"""缺登记与坏登记都拒绝;显式空资源表仍是合法的无输出登记。"""
try:
数据 = json.loads(登记文件.read_text(encoding="utf-8"))
except FileNotFoundError:
raise 生成错误("生成输入登记不存在") from None
except (OSError, UnicodeError, ValueError):
raise 生成错误("生成输入登记无法读取或不是合法 JSON") from None
if (
not isinstance(数据, dict)
or type(数据.get("schema_version")) is not int
or 数据.get("schema_version") != 1
or not isinstance(数据.get("资源"), list)
):
raise 生成错误("生成输入登记需要 schema_version=1 和资源列表")
return 数据
def _输出位置(地址: Any) -> Path:
if not isinstance(地址, str) or not 地址.strip() or "\\" in 地址:
raise 生成错误("输出必须为仓库内相对文件路径")
路径 = Path(地址)
if 路径.is_absolute() or ".." in 路径.parts or 路径 == Path("."):
raise 生成错误("输出拒绝绝对路径、父目录跳转或仓库根目录")
try:
输出 = (仓库根 / 路径).resolve()
except (OSError, ValueError, RuntimeError):
raise 生成错误("输出路径无法解析") from None
if not 输出.is_relative_to(仓库根.resolve()) or 输出 == 仓库根.resolve():
raise 生成错误("输出经软链接解析后超出仓库范围")
if 输出.is_dir() or any(p.exists() and not p.is_dir() for p in 输出.parents):
raise 生成错误("输出位置被现有目录或非目录父路径占用")
return 输出
def 预检输入(输入: dict[str, Any]) -> list[tuple[str, str, Path]]:
"""所有条目在导入或写入前校验;固定客户端输出同样不能逃逸。"""
if (
not isinstance(输入, dict)
or type(输入.get("schema_version")) is not int
or 输入.get("schema_version") != 1
or not isinstance(输入.get("资源"), list)
):
raise 生成错误("生成输入登记需要 schema_version=1 和资源列表")
计划 = []
目标集 = {_输出位置(_客户端路径)} if 输入["资源"] else set()
for 序, 条目 in enumerate(输入["资源"], 1):
if not isinstance(条目, dict) or 条目.get("格式") != "OpenAPI":
raise 生成错误(f"第 {序} 项必须是 OpenAPI 生成登记")
工厂 = 条目.get("工厂")
if not isinstance(工厂, str) or 工厂.count(":") != 1:
raise 生成错误(f"第 {序} 项工厂必须使用 module:symbol 格式")
模块, 名称 = 工厂.split(":")
if not all(p.isidentifier() and not keyword.iskeyword(p) for p in [*模块.split("."), 名称]):
raise 生成错误(f"第 {序} 项工厂必须使用合法 module:symbol 格式")
输出 = _输出位置(条目.get("输出"))
if any(输出 == p or 输出 in p.parents or p in 输出.parents for p in 目标集):
raise 生成错误(f"第 {序} 项输出与其它产物重复或父子路径冲突")
目标集.add(输出)
计划.append((模块, 名称, 输出))
return 计划
def 生成接口(输入: dict[str, Any]) -> list[Path]:
计划 = 预检输入(输入)
工厂组: list[tuple[Callable, Path]] = []
for 序, (模块, 名称, 输出) in enumerate(计划, 1):
try:
工厂 = getattr(importlib.import_module(模块), 名称)
except Exception:
raise 生成错误(f"第 {序} 项工厂无法导入") from None
if not callable(工厂):
raise 生成错误(f"第 {序} 项工厂不是可调用对象")
工厂组.append((工厂, 输出))
产物 = {}
for 序, (工厂, 输出) in enumerate(工厂组, 1):
try:
合同 = 工厂()
except Exception:
raise 生成错误(f"第 {序} 项工厂执行失败") from None
if (
not isinstance(合同, dict)
or not isinstance(合同.get("openapi"), str)
or not 合同["openapi"].strip()
):
raise 生成错误(f"第 {序} 项工厂未返回 OpenAPI 合同")
try:
产物[输出] = json.dumps(合同, ensure_ascii=False, indent=2, allow_nan=False) + "\n"
except (TypeError, ValueError):
raise 生成错误(f"第 {序} 项合同无法序列化") from None
if 产物:
产物[_输出位置(_客户端路径)] = _客户端正文
for 输出, 正文 in 产物.items():
输出.parent.mkdir(parents=True, exist_ok=True)
输出.write_text(正文, encoding="utf-8")
return list(产物)
def 主() -> int:
解析器 = argparse.ArgumentParser(
description="生成 OpenAPI 与客户端外壳;类型.ts 由后续 openapi-typescript 步骤生成"
)
解析器.parse_args()
try:
产物 = 生成接口(已登记输入())
except 生成错误 as 错误:
print(f"生成接口失败,未生成合同:{错误}", file=sys.stderr)
return 1
except OSError:
print("生成接口失败:无法写入已校验的输出位置", file=sys.stderr)
return 1
if not 产物:
print("生成接口:显式空登记,未生成任何合同")
for 输出 in 产物:
print(f"生成接口:{输出.relative_to(仓库根)}")
return 0 return 0

159
工具/用例身份.py Normal file
View File

@ -0,0 +1,159 @@
"""pytest 源码用例身份;清单只是收集结果的导出,不参与执行选择。"""
from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
from types import SimpleNamespace
from typing import Any
import pytest
_位置字段 = {"case_id", "file", "symbol", "parameter_ids", "node_ids", "fixtures", "markers"}
def 原样参数ID(显示名: str) -> Any:
"""按 pytest 的命名对象协议保留历史中文 ID,避免普通字符串被转义。
pytest 运行时支持 __name__ 对象,但 ids 的类型声明未覆盖此分支。
"""
return SimpleNamespace(__name__=显示名)
def 读取用例身份(项: pytest.Item) -> dict[str, Any] | None:
"""从函数或 pytest.param 的唯一 marker 读取原作者元信息。"""
标记 = list(项.iter_markers("case_id"))
if not 标记:
return None
if len(标记) != 1:
raise pytest.UsageError(f"用例身份重叠:{项.nodeid}")
标 = 标记[0]
if len(标.args) != 1 or not isinstance(标.args[0], str):
raise pytest.UsageError(f"case_id 必须是一个完整字符串:{项.nodeid}")
身份 = 标.args[0]
if (
not 身份.startswith(("TC-", "NC-"))
or len(身份) <= 3
or any(字符.isspace() or 字符 in "[]" for 字符 in 身份)
):
raise pytest.UsageError(f"case_id 无效:{身份!r}({项.nodeid})")
if _位置字段.intersection(标.kwargs):
raise pytest.UsageError(f"case_id 元信息不能登记派生位置:{项.nodeid}")
try:
元信息 = json.loads(json.dumps(标.kwargs, ensure_ascii=False, allow_nan=False))
except (ValueError, TypeError) as exc:
raise pytest.UsageError(f"case_id 元信息必须可导出 JSON:{项.nodeid}") from exc
return {"case_id": 身份, **元信息}
def 导出用例(项目: list[pytest.Item], 根: Path) -> list[dict[str, Any]]:
"""聚合同一函数的参数节点;不填造未提供的 given/when/then。"""
按身份: dict[str, dict[str, Any]] = {}
绑定: dict[str, str] = {}
for 项 in 项目:
元信息 = 读取用例身份(项)
if 元信息 is None:
raise pytest.UsageError(f"未标记 case_id:{项.nodeid}")
身份 = 元信息["case_id"]
函数节点 = 项.nodeid.split("[", 1)[0]
if 绑定.setdefault(身份, 函数节点) != 函数节点:
raise pytest.UsageError(f"同一 case_id 绑定不同函数:{身份}")
位置 = {
"file": 项.path.relative_to(根).as_posix(),
"symbol": getattr(项, "originalname", None) or 项.name.split("[", 1)[0],
}
条 = 按身份.setdefault(
身份,
{**元信息, **位置, "parameter_ids": [], "node_ids": [], "fixtures": [], "markers": []},
)
if any(条[键] != 值 for 键, 值 in {**元信息, **位置}.items()) or set(元信息) != (
set(条) - (_位置字段 - {"case_id"})
):
raise pytest.UsageError(f"同一 case_id 绑定不同函数或元信息:{身份}")
参数 = getattr(getattr(项, "callspec", None), "id", None)
if 参数 is not None:
条["parameter_ids"].append(参数)
条["node_ids"].append(项.nodeid)
条["fixtures"].extend(getattr(项, "fixturenames", []))
条["markers"].extend(标.name for 标 in 项.iter_markers() if 标.name != "case_id")
for 条 in 按身份.values():
for 键 in ("parameter_ids", "node_ids", "fixtures", "markers"):
条[键] = sorted(set(条[键]))
return sorted(按身份.values(), key=lambda 条: 条["case_id"])
def 选择用例(配置: pytest.Config, 项目: list[pytest.Item], 选择: list[str] | None = None) -> None:
"""按本次收集选择完整 ID 或 ID[参数 ID],并保留 JUnit 身份。"""
# 即使未指定 --case 也拒绝跨函数重号,不靠导出文件才发现身份冲突。
导出用例(项目, 配置.rootpath)
请求 = set(选择 if 选择 is not None else 配置.getoption("--case") or [])
命中: set[str] = set()
保留, 排除 = [], []
for 项 in 项目:
元信息 = 读取用例身份(项)
对应: set[str] = set()
if 元信息 is not None:
身份 = 元信息["case_id"]
参数 = getattr(getattr(项, "callspec", None), "id", None)
项.user_properties[:] = [
(键, 值) for 键, 值 in 项.user_properties if 键 not in {"case_id", "parameter_id"}
]
项.user_properties.append(("case_id", 身份))
对应.add(身份)
if 参数 is not None:
项.user_properties.append(("parameter_id", 参数))
对应.add(f"{身份}[{参数}]")
该项命中 = 请求 & 对应
命中.update(该项命中)
(保留 if not 请求 or 该项命中 else 排除).append(项)
if 请求 - 命中:
raise pytest.UsageError(f"--case 未收集到完整用例 ID 或参数:{sorted(请求 - 命中)}")
if 排除:
配置.hook.pytest_deselected(items=排除)
项目[:] = 保留
def 收集用例(根: Path, 路径: list[str] | None = None) -> list[dict[str, Any]]:
"""独立 pytest 进程仅收集;临时导出不成为收集前置或仓内缓存。"""
解释器 = 根 / ".venv/bin/python"
if not 解释器.is_file():
解释器 = Path(sys.executable)
with tempfile.TemporaryDirectory(prefix="muse-case-collection-") as 目录:
导出 = Path(目录) / "cases.json"
命令 = [
str(解释器),
"-m",
"pytest",
"--collect-only",
"-q",
"-p",
"no:cacheprovider",
f"--导出用例={导出}",
*(路径 or []),
]
try:
结果 = subprocess.run(
命令,
cwd=根,
capture_output=True,
text=True,
timeout=180,
env={**os.environ, "PYTHONDONTWRITEBYTECODE": "1"},
)
except (OSError, subprocess.TimeoutExpired) as exc:
raise ValueError(f"pytest 收集未完成:{exc}") from exc
if 结果.returncode not in (0, 5) or not 导出.is_file():
raise ValueError(
f"pytest 收集失败(exit {结果.returncode}):\n{结果.stdout}\n{结果.stderr}"
)
try:
内容 = json.loads(导出.read_text(encoding="utf-8"))
if not isinstance(内容, dict) or not isinstance(内容.get("cases"), list):
raise ValueError("导出缺少 cases 列表")
return 内容["cases"]
except (OSError, ValueError) as exc:
raise ValueError(f"pytest 用例导出无效:{exc}") from exc

View File

@ -1,15 +1,8 @@
"""索引生成、查重及检查。 """由源文件派生导航,并复用 pytest 收集的稳定用例身份。
工程阶段的生成或预检入口: 显式 --写入 生成目录表;--检查 不写磁盘。技能说明来自 SKILL.md
- 核对 Markdown 索引(目录.md)表内相对链接有效; frontmatter,作者导航语义随源文件保存,手写导读保留。历史逐文件设计
- 核对技能目录(.agent/skills/ 下分组)中每个技能有 SKILL.md,名称不重复; 不再参与每日源码定位门禁。所有入口可用显式根目录在临时项目复现。
- 核对测试符号与用例清单的 case 身份一致:仓库内每个 test_ 符号必须带
``__<case6>`` 后缀且能在清单定位;清单登记的目标符号必须在树上真实存在;
只有 print 没有 assert/raise 的用例在执行前即被标记(反假绿)。
边界:只报告差异与失败,不改变业务数据、不自行批准发布。
生成物与唯一输入一致;命令可独立重跑。所有检查函数接受显式根目录,
供测试在临时项目上验证同一语义。
""" """
from __future__ import annotations from __future__ import annotations
@ -17,17 +10,29 @@ from __future__ import annotations
import argparse import argparse
import ast import ast
import json import json
import os
import re import re
import subprocess
import sys import sys
import unicodedata import unicodedata
from fnmatch import fnmatch
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
import yaml
仓库根 = Path(__file__).resolve().parent.parent 仓库根 = Path(__file__).resolve().parent.parent
_目录表行 = re.compile(r"^\|\s*([^|]+)\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|")
_用例后缀 = re.compile(r"__([0-9a-f]{6})$")
_忽略目录片段 = frozenset( _忽略目录片段 = frozenset(
{".venv", ".venv-旧", ".git", "node_modules", "__pycache__", ".agents.local", "研究依据"} {
".venv",
".venv-旧",
".git",
"node_modules",
"__pycache__",
".agents.local",
"研究依据",
"文件设计",
}
) )
# 新版用例目录:旧测试树(tests/skills、tests/adapters、tests/architecture 英文目录等) # 新版用例目录:旧测试树(tests/skills、tests/adapters、tests/architecture 英文目录等)
# 由旧环境运行,不进入新版用例身份检查 # 由旧环境运行,不进入新版用例身份检查
@ -42,37 +47,47 @@ def _在忽略目录(路径: Path, 根: Path) -> bool:
return any(片段 in 相对.parts for 片段 in _忽略目录片段) return any(片段 in 相对.parts for 片段 in _忽略目录片段)
def 遍历文件(根: Path, 模式: str):
"""在进入目录前剪枝,忽略缓存和历史树不承担扫描成本。"""
for 当前, 目录名, 文件名 in os.walk(根, followlinks=False):
目录名[:] = sorted(名 for 名 in 目录名 if 名 not in _忽略目录片段)
for 名 in sorted(文件名):
if fnmatch(名, 模式):
yield Path(当前) / 名
def 检查目录链接(根: Path) -> list[str]: def 检查目录链接(根: Path) -> list[str]:
"""核对仓库内 目录.md 的表格相对链接;返回问题列表。""" """核对仓库内 目录.md 的表格相对链接;返回问题列表。"""
问题: list[str] = [] 问题: list[str] = []
for 目录文件 in 根.rglob("目录.md"): for 目录文件 in 遍历文件(根, "目录.md"):
if _在忽略目录(目录文件, 根): if _在忽略目录(目录文件, 根):
continue continue
for 行号, 行 in enumerate(目录文件.read_text(encoding="utf-8").splitlines(), 1): for 序, _, 原地址 in _表格行(目录文件.read_text(encoding="utf-8")):
匹配 = _目录表行.match(行) 地址 = 原地址.split("#")[0].strip()
if not 匹配: if not 地址 or 地址 == "暂无" or 地址.startswith(("http://", "https://", "mailto:")):
continue
地址 = 匹配.group(3).split("#")[0].strip()
if not 地址 or 地址.startswith(("http://", "https://")):
continue continue
目标 = (目录文件.parent / 地址).resolve() 目标 = (目录文件.parent / 地址).resolve()
if not 目标.exists(): if not 目标.exists():
问题.append(f"{目录文件.relative_to(根)}:{行号} 链接不存在:{地址}") 问题.append(f"{目录文件.relative_to(根)}:{序 + 1} 链接不存在:{地址}")
return 问题 return 问题
def _读frontmatter名(文件: Path) -> str | None: def _读frontmatter(文件: Path) -> dict:
"""读 SKILL.md frontmatter 的 name 字段;无 frontmatter 返回 None。""" """与源文件一起移动的声明;支持 YAML 折叠及引号,不解析正文。"""
文本 = 文件.read_text(encoding="utf-8") 文本 = 文件.read_text(encoding="utf-8")
if not 文本.startswith("---"): 匹配 = re.match(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|$)", 文本, re.S)
return None if 匹配 is None:
结束 = 文本.find("\n---", 3) return {}
if 结束 < 0: try:
return None 声明 = yaml.safe_load(匹配[1])
for 行 in 文本[3:结束].splitlines(): except yaml.YAMLError as 错误:
if 行.startswith("name:"): raise ValueError(f"frontmatter 无法解析:{文件}") from 错误
return 行[5:].strip() return 声明 if isinstance(声明, dict) else {}
return None
def _读frontmatter名(文件: Path) -> str | None:
名称 = _读frontmatter(文件).get("name")
return 名称 if isinstance(名称, str) else None
def _含中文(名称: str) -> bool: def _含中文(名称: str) -> bool:
@ -99,7 +114,11 @@ def 检查技能目录(根: Path) -> list[str]:
if not 正文 or 正文 == "---": if not 正文 or 正文 == "---":
问题.append(f"SKILL.md 为空:{相对}") 问题.append(f"SKILL.md 为空:{相对}")
continue continue
try:
front名 = _读frontmatter名(技能文件) front名 = _读frontmatter名(技能文件)
except ValueError as 错误:
问题.append(str(错误))
continue
if front名 is None: if front名 is None:
问题.append(f"SKILL.md 缺少 frontmatter name:{相对}") 问题.append(f"SKILL.md 缺少 frontmatter name:{相对}")
elif front名 != 名称: elif front名 != 名称:
@ -117,6 +136,248 @@ def 检查技能目录(根: Path) -> list[str]:
return 问题 return 问题
_导航表头 = "| 名称 | 相对地址 | 内容描述 | 使用场景 | 使用要求 |"
_导航分隔 = "|------|----------|----------|----------|----------|"
_导航元信息 = re.compile(r"<!-- 导航元信息: (.*?) -->")
_表格链接 = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
def _表格行(文本: str) -> list[tuple[int, list[str], str]]:
结果 = []
for 序, 行 in enumerate(文本.splitlines()):
if not 行.startswith("|"):
continue
列 = [v.strip() for v in re.split(r"(?<!\\)\|", 行)[1:-1]]
if len(列) != 5 or 列[0] == "名称" or re.fullmatch(r"[- :]+", 列[0]):
continue
链接 = _表格链接.fullmatch(列[1])
结果.append((序, 列, 链接[2] if 链接 else 列[1]))
return 结果
def _导航声明(文件: Path) -> dict[str, str]:
if 文件.suffix != ".md" or not 文件.is_file():
return {}
匹配 = _导航元信息.findall(文件.read_text(encoding="utf-8"))
if len(匹配) > 1:
raise ValueError(f"导航元信息重复:{文件}")
if not 匹配:
return {}
数据 = json.loads(匹配[0])
if not isinstance(数据, dict) or any(
k not in {"内容描述", "使用场景", "使用要求"} or not isinstance(v, str)
for k, v in 数据.items()
):
raise ValueError(f"导航元信息应为内容描述、使用场景、使用要求的字符串对象:{文件}")
return 数据
def _标题(文件: Path) -> str:
if 文件.suffix == ".md" and 文件.is_file():
匹配 = re.search(r"^# +(.+?) *$", 文件.read_text(encoding="utf-8"), re.M)
if 匹配:
return 匹配[1]
return 文件.parent.name if 文件.name == "目录.md" else 文件.stem
def _目录文件(根: Path) -> list[Path]:
文件组 = {
p
for 树 in (根 / "docs", 根 / ".agent")
for p in 遍历文件(树, "目录.md")
if not _在忽略目录(p, 根)
}
技能根 = 根 / ".agent/skills"
if 技能根.is_dir():
文件组.update(p / "目录.md" for p in 技能根.iterdir() if p.is_dir())
return sorted(文件组)
def 导航元信息迁移方案(根: Path) -> dict[Path, str]:
"""一次性保全旧索引独有语义,返回源文件修改提案;不写磁盘。
只承接本目录的 Markdown 文件及下一级目录的入口。跨目录导读、
非 Markdown 材料简介与“暂无”是手写导航,仍留原处;不把它们变成
发布资源声明,也不从名称补造场景或要求。
"""
声明组: dict[Path, dict[str, str]] = {}
for 索引 in _目录文件(根):
if not 索引.is_file():
continue
for _, 列, 地址 in _表格行(索引.read_text(encoding="utf-8")):
if 地址.startswith(("../", "http:", "https:")) or "#" in 地址:
continue
目标 = 索引.parent / 地址
if not 目标.is_file() or 目标.suffix != ".md":
continue
_导航声明(目标)
if _导航元信息.search(目标.read_text(encoding="utf-8")):
continue
字段 = (
("使用场景", "使用要求")
if 目标.name == "SKILL.md"
else ("内容描述", "使用场景", "使用要求")
)
新声明 = {
k: 列[("名称", "相对地址", "内容描述", "使用场景", "使用要求").index(k)]
for k in 字段
}
if not any(新声明.values()):
continue
已有 = 声明组.setdefault(目标, {})
for 键, 值 in 新声明.items():
if 值 and 值 not in 已有.get(键, "").split("\n"):
已有[键] = "\n".join(filter(None, (已有.get(键, ""), 值)))
修改 = {}
for 文件, 声明 in 声明组.items():
文本 = 文件.read_text(encoding="utf-8")
注记 = "<!-- 导航元信息: " + json.dumps(声明, ensure_ascii=False) + " -->\n"
# frontmatter 必须仍在文件首部,正文及作者措辞逐字保留。
匹配 = re.match(r"\A---\r?\n.*?\r?\n---(?:\r?\n|$)", 文本, re.S)
位置 = 匹配.end() if 匹配 else 0
修改[文件] = 文本[:位置] + 注记 + 文本[位置:]
return 修改
def _转义单元格(值: str) -> str:
return " ".join(值.splitlines()).replace("|", "\\|")
def 派生目录(根: Path) -> dict[Path, str]:
"""按真实文件派生本级目录表;手写导读、分节与跨目录导航原样保留。"""
产物 = {}
技能根 = 根 / ".agent/skills"
for 索引 in _目录文件(根):
原文 = 索引.read_text(encoding="utf-8") if 索引.is_file() else ""
旧行 = _表格行(原文)
分组 = 索引.parent.parent == 技能根
if 分组:
文件组 = [
p / "SKILL.md"
for p in sorted(索引.parent.iterdir())
if p.is_dir() and (p / "SKILL.md").is_file()
]
else:
文件组 = [
(p / "目录.md" if (p / "目录.md").is_file() else p)
for p in sorted(索引.parent.iterdir())
if p != 索引 and not p.name.startswith(".") and p.name != "__pycache__"
]
新行 = {}
for 文件 in 文件组:
地址 = 文件.relative_to(索引.parent).as_posix() + ("/" if 文件.is_dir() else "")
元信息 = _导航声明(文件)
if 分组:
声明 = _读frontmatter(文件)
名称 = str(声明.get("name", 文件.parent.name))
描述 = str(声明.get("description", ""))
else:
名称 = _标题(文件)
描述 = 元信息.get("内容描述", "")
# 非 Markdown 材料没有强塞额外元信息,保留作者已有的唯一简介。
旧 = next((列 for _, 列, 路径 in 旧行 if 路径 == 地址), None)
if 文件.suffix != ".md" and 旧:
名称, 描述 = 旧[0], 旧[2]
元信息 = dict(zip(("内容描述", "使用场景", "使用要求"), 旧[2:], strict=True))
列 = [
名称,
f"[{文件.name if 文件.is_file() else 文件.name + '/'}]({地址})",
描述,
元信息.get("使用场景", ""),
元信息.get("使用要求", ""),
]
新行[地址] = "| " + " | ".join(_转义单元格(v) for v in 列) + " |"
行组 = 原文.splitlines()
旧行映射 = {序: (列, 地址) for 序, 列, 地址 in 旧行}
结果 = []
插入位置 = None
表数量 = 0
for 序, 行 in enumerate(行组):
if 序 in 旧行映射:
_, 地址 = 旧行映射[序]
if 地址 in 新行:
结果.append(新行.pop(地址))
elif 地址.startswith(("../", "http:", "https:")) or 地址 == "暂无" or "#" in 地址:
结果.append(行) # 手写导读,不作为本级文件清单。
continue
结果.append(行)
if re.fullmatch(r"\|[- :|]+\|", 行):
表数量 += 1
if 插入位置 is None:
插入位置 = len(结果)
if 插入位置 is None:
if 结果 and 结果[-1]:
结果.append("")
结果.extend((_导航表头, _导航分隔))
插入位置 = len(结果)
if 表数量 > 1 and 新行:
# 未声明作者分节的新来源不被悄悄归到第一个语义分节。
结果.extend(("", "## 未分节条目", "", _导航表头, _导航分隔))
插入位置 = len(结果)
结果[插入位置:插入位置] = list(新行.values())
产物[索引] = "\n".join(结果).rstrip() + "\n"
return 产物
def 写入目录(根: Path) -> list[Path]:
"""显式生成导航;存量独有语义必须先迁回源文件,不静默丢弃。"""
待迁移 = 导航元信息迁移方案(根)
if 待迁移:
raise ValueError(
"索引独有语义尚未迁回源文件:" + "、".join(str(p.relative_to(根)) for p in 待迁移)
)
已写 = []
for 文件, 文本 in 派生目录(根).items():
if not 文件.is_file() or 文件.read_text(encoding="utf-8") != 文本:
文件.write_text(文本, encoding="utf-8")
已写.append(文件)
return 已写
def 检查派生目录(根: Path) -> list[str]:
"""显式索引检查比较派生结果;不要求历史设计与现行源码逐文件一致。"""
try:
return [
f"目录需要重新生成:{文件.relative_to(根)}"
for 文件, 文本 in 派生目录(根).items()
if not 文件.is_file() or 文件.read_text(encoding="utf-8") != 文本
]
except (ValueError, yaml.YAMLError) as 错误:
return [f"目录源元信息无效:{错误}"]
def 检查技能索引(根: Path) -> list[str]:
"""分组与技能双向核对的唯一实现,供命令和 pytest 共用。"""
技能根 = 根 / ".agent/skills"
if not 技能根.is_dir():
return ["技能目录不存在:.agent/skills"]
问题 = []
for 分组 in sorted(p for p in 技能根.iterdir() if p.is_dir()):
索引 = 分组 / "目录.md"
if not 索引.is_file():
问题.append(f"分组缺目录:{分组.name}")
continue
索引名 = [
地址
for _, _, 地址 in _表格行(索引.read_text(encoding="utf-8"))
if 地址.endswith("/SKILL.md")
]
磁盘名 = {
f"{p.name}/SKILL.md"
for p in 分组.iterdir()
if p.is_dir() and (p / "SKILL.md").is_file()
}
if set(索引名) != 磁盘名:
问题.append(
f"分组 {分组.name} 索引与磁盘不一致:"
f"索引多出 {sorted(set(索引名) - 磁盘名)},"
f"磁盘多出 {sorted(磁盘名 - set(索引名))}"
)
if len(索引名) != len(set(索引名)):
问题.append(f"分组 {分组.name} 索引重复登记技能")
return 问题
def 解析测试符号(文件: Path) -> list[ast.FunctionDef]: def 解析测试符号(文件: Path) -> list[ast.FunctionDef]:
"""用 AST 提取 test_ 开头的函数与方法;不执行任何代码。""" """用 AST 提取 test_ 开头的函数与方法;不执行任何代码。"""
树 = ast.parse(文件.read_text(encoding="utf-8")) 树 = ast.parse(文件.read_text(encoding="utf-8"))
@ -140,144 +401,118 @@ def _有判定语句(节点: ast.FunctionDef) -> bool:
return False return False
def 检查用例身份( def 收集索引用例(根: Path) -> list[dict[str, Any]]:
根: Path, """Python、前端各由本语言的唯一收集实现导出,再验证跨语言身份。"""
清单路径: Path | None = None, from 用例身份 import 收集用例
*,
核对缺失目标: bool = True,
全树扫描: bool = False,
) -> list[str]:
"""核对测试符号与用例清单双向一致:
1. 新版用例目录内符号按完整文件、函数或前端用例名绑定清单;
2. 用例体必须包含可判定语句(assert/raise/fail/skip/xfail);
3. 核对缺失目标=True 时,清单登记的 (file, symbol) 必须在树上真实存在
默认双向核对,不依据历史执行状态豁免目标存在性。
"""
if 清单路径 is None:
清单路径 = 根 / "tests" / "用例清单.json"
问题: list[str] = []
if not 清单路径.is_file():
return [f"用例清单不存在:{清单路径}"]
清单: dict[str, Any] = json.loads(清单路径.read_text(encoding="utf-8"))
登记符号: dict[str, str] = {} 用例 = 收集用例(根)
for 用例 in 清单["cases"]: if (根 / "web/tests").is_dir():
文件, 符号 = 用例["file"], 用例["symbol"]
登记符号[f"{文件}::{符号}"] = 用例["case_id"]
树上符号: set[str] = set()
候选 = sorted(根.glob("**/test_*.py")) if 全树扫描 else sorted(根.glob("tests/**/test_*.py"))
for 测试文件 in 候选:
if _在忽略目录(测试文件, 根):
continue
if not 全树扫描:
# 只检查新版用例目录;旧测试树由旧环境运行
try:
相对路径 = 测试文件.relative_to(根 / "tests")
except ValueError:
continue
if not 相对路径.parts or 相对路径.parts[0] not in _新版用例目录:
continue
try:
相对 = 测试文件.relative_to(根).as_posix()
except ValueError:
continue
for 节点 in 解析测试符号(测试文件):
树上符号.add(f"{相对}::{节点.name}")
匹配 = _用例后缀.search(节点.name)
if not 匹配:
问题.append(f"测试符号缺少用例后缀:{相对}::{节点.name}")
if f"{相对}::{节点.name}" not in 登记符号:
问题.append(f"测试文件与符号未在用例清单登记:{相对}::{节点.name}")
if not _有判定语句(节点):
问题.append(f"用例无可判定语句(print 不能代替断言):{相对}::{节点.name}")
前端测试 = sorted(
[*(根 / "web/tests").rglob("*.test.ts*"), *(根 / "web/tests").rglob("*.spec.ts*")]
)
if 前端测试:
import subprocess
# TypeScript AST 只解析静态用例声明,不执行测试或加载其运行依赖。
脚本 = """
import ts from 'typescript';
import fs from 'node:fs';
const files = JSON.parse(fs.readFileSync(0, 'utf8'));
const found = [];
for (const file of files) {
const source = ts.createSourceFile(file, fs.readFileSync(file, 'utf8'),
ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
function visit(node) {
if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)
&& ['it', 'test'].includes(node.expression.text)
&& node.arguments[0] && ts.isStringLiteral(node.arguments[0]))
found.push([file, node.arguments[0].text]);
ts.forEachChild(node, visit);
}
visit(source);
}
process.stdout.write(JSON.stringify(found));
"""
try: try:
结果 = subprocess.run( 结果 = subprocess.run(
["node", "--input-type=module", "-e", 脚本], ["node", str(根 / "工具/前端用例身份.mjs"), str(根)],
cwd=根 / "web", cwd=根,
input=json.dumps([str(p) for p in 前端测试]),
capture_output=True, capture_output=True,
text=True, text=True,
timeout=20, timeout=30,
check=True, check=True,
) )
for 文件, 符号 in json.loads(结果.stdout): 前端 = json.loads(结果.stdout)
键 = f"{Path(文件).relative_to(根).as_posix()}::{符号}" if not isinstance(前端, list):
树上符号.add(键) raise ValueError("前端用例导出必须为列表")
if 键 not in 登记符号: 用例.extend(前端)
问题.append(f"前端用例未在清单登记:{键}") except subprocess.CalledProcessError as 错误:
except (OSError, ValueError, subprocess.SubprocessError): raise ValueError(f"前端用例收集失败(exit {错误.returncode}):{错误.stderr}") from 错误
问题.append("前端用例解析失败;需要已锁定的 Node 与 TypeScript 工具链") except (OSError, subprocess.TimeoutExpired) as 错误:
raise ValueError(f"前端用例收集未完成:{错误}") from 错误
for 键, case_id in sorted(登记符号.items()): 已见 = set()
if 核对缺失目标: for 条 in 用例:
if 键 not in 树上符号: if not isinstance(条, dict) or not isinstance(条.get("case_id"), str):
问题.append(f"用例清单登记的目标不存在:{键}({case_id})") raise ValueError("用例导出缺少完整 case_id")
return 问题 身份 = 条["case_id"]
if 身份 in 已见:
raise ValueError(f"用例身份重复:{身份}")
已见.add(身份)
if not 用例:
raise ValueError("未收集到用例,不能通过身份检查")
return sorted(用例, key=lambda 条: 条["case_id"])
def 检查文件定位(根: Path) -> list[str]: def _检查用例判定(根: Path) -> list[str]:
"""设计清单的文件定位必须恰好命中一个定义,不把目录树文字当作锚点。""" 问题 = []
设计根 = 根 / "docs/系统架构/新版设计" for 文件 in sorted(遍历文件(根 / "tests", "test_*.py")):
清单 = 设计根 / "目标文件清单.json" if _在忽略目录(文件, 根) or 文件.relative_to(根 / "tests").parts[0] not in _新版用例目录:
if not 清单.is_file():
return []
问题, 已读 = [], {}
for 项 in json.loads(清单.read_text(encoding="utf-8"))["files"]:
文件名, 分隔, 锚点 = 项.get("design_location", "").partition("#")
文件 = (设计根 / 文件名).resolve()
if not 分隔 or not 文件.is_relative_to(设计根.resolve()) or not 文件.is_file():
问题.append(f"文件设计定位不存在:{项['path']}")
continue continue
if 文件 not in 已读: for 节点 in 解析测试符号(文件):
已读[文件] = 文件.read_text(encoding="utf-8") if not _有判定语句(节点):
次数 = len(re.findall(r'<a\s+id=["\x27]' + re.escape(锚点) + r'["\x27]', 已读[文件])) 问题.append(
if 次数 != 1: f"用例无可判定语句(print 不能代替断言):{文件.relative_to(根)}::{节点.name}"
问题.append(f"文件设计锚点应恰一,实际{次数}:{项['path']}({锚点})") )
return 问题 return 问题
def 检查用例身份(根: Path) -> list[str]:
"""源码身份由共享收集入口校验;不读取派生清单或旧后缀。"""
问题 = _检查用例判定(根)
try:
收集索引用例(根)
except ValueError as 错误:
问题.append(str(错误))
return 问题
def 写入用例清单(根: Path, 用例: list[dict[str, Any]]) -> bool:
目标 = 根 / "tests/用例清单.json"
文本 = (
json.dumps(
{
"schema_version": 2,
"scope": "源码收集派生;不作为执行前置,不表示执行或通过。",
"cases": 用例,
},
ensure_ascii=False,
indent=2,
)
+ "\n"
)
if 目标.is_file() and 目标.read_text(encoding="utf-8") == 文本:
return False
目标.write_text(文本, encoding="utf-8")
return True
def 主() -> int: def 主() -> int:
解析器 = argparse.ArgumentParser(description="索引生成、查重及检查") 解析器 = argparse.ArgumentParser(description="索引生成、查重及检查")
解析器.add_argument("--写入", action="store_true", help="执行索引写入(当前为幂等检查)") 解析器.add_argument("--写入", action="store_true", help="按源文件声明与实际文件生成导航表")
解析器.add_argument("--检查", action="store_true", help="只检查并报告差异") 解析器.add_argument("--检查", action="store_true", help="只检查并报告差异")
参数 = 解析器.parse_args() 参数 = 解析器.parse_args()
try:
用例 = 收集索引用例(仓库根)
except ValueError as 错误:
print(f"索引问题:{错误}", file=sys.stderr)
return 1
if 参数.写入: if 参数.写入:
print("索引写入:当前无可写生成物,执行幂等检查") try:
源问题 = 检查技能目录(仓库根)
if 源问题:
raise ValueError(";".join(源问题))
print(f"索引写入:更新 {len(写入目录(仓库根))} 个目录")
print(f"用例导出:{len(用例)} 条,更新 {int(写入用例清单(仓库根, 用例))} 个清单")
except ValueError as 错误:
print(f"索引生成失败:{错误}", file=sys.stderr)
return 1
问题 = 检查目录链接(仓库根) + 检查技能目录(仓库根) + 检查用例身份(仓库根) + 检查文件定位(仓库根) 问题 = (
检查目录链接(仓库根)
+ 检查技能目录(仓库根)
+ _检查用例判定(仓库根)
+ 检查技能索引(仓库根)
+ 检查派生目录(仓库根)
)
if 问题: if 问题:
for 条 in 问题: for 条 in 问题:
print(f"索引问题:{条}", file=sys.stderr) print(f"索引问题:{条}", file=sys.stderr)
return 1 return 1
print("索引检查通过:目录链接、技能目录、用例身份及文件定位均一致") print("索引检查通过:目录链接、技能目录与用例身份一致(历史逐文件设计不参与活跃门禁)")
return 0 return 0

View File

@ -179,30 +179,14 @@
"源路径": "src/muse/审校修订/提示词/连续性审阅.md", "源路径": "src/muse/审校修订/提示词/连续性审阅.md",
"打包路径": "审校/连续性审阅.md" "打包路径": "审校/连续性审阅.md"
}, },
{
"源路径": "数据库/迁移/V0041__评测语义检测单元.sql",
"打包路径": "迁移/V0041__评测语义检测单元.sql"
},
{ {
"源路径": "src/muse/效果评测/效果标准.json", "源路径": "src/muse/效果评测/效果标准.json",
"打包路径": "评测/效果标准.json" "打包路径": "评测/效果标准.json"
}, },
{
"源路径": "数据库/迁移/V0042__效果判据凭据.sql",
"打包路径": "迁移/V0042__效果判据凭据.sql"
},
{ {
"源路径": "配置/流程模板/定稿交付.yaml", "源路径": "配置/流程模板/定稿交付.yaml",
"打包路径": "流程/定稿交付.yaml" "打包路径": "流程/定稿交付.yaml"
}, },
{
"源路径": "src/muse/交付连载/提示词/全书审阅.md",
"打包路径": "交付/全书审阅.md"
},
{
"源路径": "src/muse/交付连载/提示词/整理交付材料.md",
"打包路径": "交付/整理交付材料.md"
},
{ {
"源路径": "配置/流程模板/数据维护.yaml", "源路径": "配置/流程模板/数据维护.yaml",
"打包路径": "流程/数据维护.yaml" "打包路径": "流程/数据维护.yaml"
@ -230,6 +214,26 @@
{ {
"源路径": "配置/流程模板/审校修订.yaml", "源路径": "配置/流程模板/审校修订.yaml",
"打包路径": "流程/审校修订.yaml" "打包路径": "流程/审校修订.yaml"
},
{
"源路径": "src/muse/效果评测/目标标准.json",
"打包路径": "评测/目标标准.json"
},
{
"源路径": "src/muse/作品规划/提示词/生成作品规划.md",
"打包路径": "规划/生成作品规划.md"
},
{
"源路径": "src/muse/作品规划/提示词/细化章节计划.md",
"打包路径": "规划/细化章节计划.md"
},
{
"源路径": "src/muse/审校修订/提示词/创作质量审阅.md",
"打包路径": "审校/创作质量审阅.md"
},
{
"源路径": ".agent/约束/创作方法与选段.md",
"打包路径": "写作/创作方法与选段.md"
} }
], ],
"迁移目录": "数据库/迁移", "迁移目录": "数据库/迁移",
@ -260,6 +264,8 @@
".agent/skills/操作/提出并验证改进/SKILL.md", ".agent/skills/操作/提出并验证改进/SKILL.md",
".agent/skills/操作/整理定稿与导出/SKILL.md", ".agent/skills/操作/整理定稿与导出/SKILL.md",
".agent/skills/操作/登记连载与反馈/SKILL.md", ".agent/skills/操作/登记连载与反馈/SKILL.md",
".agent/skills/操作/备份与恢复数据/SKILL.md" ".agent/skills/操作/备份与恢复数据/SKILL.md",
".agent/skills/操作/写下一章/SKILL.md",
".agent/skills/操作/核对内容一致性/SKILL.md"
] ]
} }

164
工具/验收安装包.py Normal file
View File

@ -0,0 +1,164 @@
"""验收指定实际 wheel:隔离解压,仓外运行,缺失及损坏资源必须拒绝。
指定 wheel 时不重新构建;省略参数则增量构建并消费本批 wheel。
不安装依赖、不启动服务;PG/浏览器/恢复验收仍由各自环境承担。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import sysconfig
import tempfile
import zipfile
from pathlib import Path
_探针 = r"""
import hashlib, json, os, pathlib, socket, sys
def deny_connection(self, address):
raise AssertionError("包入口解析期间不得连接外部服务")
socket.socket.connect = deny_connection
prefix = pathlib.Path(sys.argv[1]).resolve()
source = pathlib.Path(sys.argv[3]).resolve()
def reject_source_reads(event, args):
if event in {"open", "os.listdir", "os.scandir"} and args and isinstance(args[0], (str, bytes)):
target = pathlib.Path(os.fsdecode(args[0])).resolve()
if target.is_relative_to(source) or ".git" in target.parts:
raise AssertionError("installed package attempted to read source or Git")
sys.addaudithook(reject_source_reads)
sys.path.insert(0, str(prefix))
sys.path.append(sys.argv[2])
import muse, muse.启动, muse.配置
from muse.启动 import 应用装配, 构建
assert callable(应用装配) and callable(构建)
assert pathlib.Path(muse.__file__).resolve().is_relative_to(prefix), muse.__file__
from muse.资源加载 import 核对资源
from muse.共享.错误 import 资源缺失错误
try:
manifest = 核对资源()
except 资源缺失错误 as error:
print(json.dumps({"拒绝": type(error).__name__, "原因": str(error)}, ensure_ascii=False))
sys.exit(23)
files = {"src/" + f.relative_to(prefix).as_posix(): hashlib.sha256(f.read_bytes()).hexdigest()
for f in (prefix / "muse").rglob("*")
if f.suffix in {".py", ".ts"} and "资源" not in f.relative_to(prefix / "muse").parts}
code_hash = hashlib.sha256(json.dumps(files, sort_keys=True).encode()).hexdigest()
assert code_hash == manifest["代码哈希"]
from muse.编排.生成规划 import 规划模板
from muse.审校修订.完整审校 import 文学模板
from muse.资源加载 import 读取能力
for kind, resource in [("outline", "规划/生成作品规划.md"),
("fine_outline", "规划/细化章节计划.md")]:
text, digest = 规划模板(kind)
assert text and digest == manifest["资源"][resource]["sha256"]
text, digest = 文学模板()
assert text and digest == manifest["资源"]["审校/创作质量审阅.md"]["sha256"]
for capability in ("write-next-chapter", "check-consistency"):
assert 读取能力("operation", capability)["正文"]
print(json.dumps({"构建身份": manifest["构建身份"], "资源数": len(manifest["资源"]),
"加载位置": str(muse.__file__)}, ensure_ascii=False))
"""
def 验收(轮子: Path) -> dict:
if not 轮子.is_file() or 轮子.suffix != ".whl":
raise ValueError("必须提供本批实际 wheel 文件")
包摘要 = hashlib.sha256(轮子.read_bytes()).hexdigest()
环境 = {k: v for k, v in os.environ.items() if k != "PYTHONPATH" and not k.startswith("MUSE_")}
环境["PYTHONDONTWRITEBYTECODE"] = "1"
with tempfile.TemporaryDirectory(prefix="muse-包验收-") as 临时:
根 = Path(临时)
前缀 = 根 / "installed"
with zipfile.ZipFile(轮子) as zf:
名称们 = zf.namelist()
if len(名称们) != len(set(名称们)):
raise ValueError("wheel 存在重复路径")
for 名 in 名称们:
if Path(名).is_absolute() or {
"..",
".git",
"tests",
".agents.local",
".env",
}.intersection(Path(名).parts):
raise ValueError(f"wheel 包含不允许的路径:{名}")
zf.extractall(前缀)
结果 = []
def 运行(场景: str, 期望: int) -> None:
进程 = subprocess.run(
[
sys.executable,
"-I",
"-S",
"-B",
"-c",
_探针,
str(前缀),
sysconfig.get_path("purelib"),
str(Path(__file__).resolve().parents[1]),
],
cwd=根,
env=环境,
capture_output=True,
text=True,
timeout=120,
)
if 进程.returncode != 期望:
raise RuntimeError(
f"{场景} exit={进程.returncode},期望={期望}\n{进程.stdout}\n{进程.stderr}"
)
结果.append({"场景": 场景, "exit": 进程.returncode, "输出": json.loads(进程.stdout)})
运行("完整实际wheel", 0)
清单文件 = 前缀 / "muse/资源/清单.json"
原清单 = 清单文件.read_bytes()
清单文件.unlink()
运行("缺清单必须拒绝", 23)
清单文件.write_bytes(原清单)
资源 = 前缀 / "muse/资源/运行资源.zip"
原包 = 资源.read_bytes()
资源.unlink()
运行("缺资源必须拒绝", 23)
资源.write_bytes(原包)
with zipfile.ZipFile(资源) as zf:
条目 = [(info, zf.read(info.filename)) for info in zf.infolist()]
with zipfile.ZipFile(资源, "w") as zf:
for 序号, (信息, 数据) in enumerate(条目):
zf.writestr(信息, 数据 + b"tamper" if 序号 == 0 else 数据)
运行("资源字节损坏必须拒绝", 23)
if hashlib.sha256(轮子.read_bytes()).hexdigest() != 包摘要:
raise RuntimeError("验收期间原 wheel 发生变化")
return {"wheel": str(轮子), "sha256": 包摘要, "场景": 结果}
def 主() -> int:
解析器 = argparse.ArgumentParser(description=__doc__)
解析器.add_argument("wheel", type=Path, nargs="?")
解析器.add_argument("--报告", type=Path)
参数 = 解析器.parse_args()
构建报告 = None
if 参数.wheel is None:
from 构建编排 import 执行构建
构建报告 = 执行构建(轮子=True)
轮子 = Path(构建报告["wheel"])
else:
轮子 = 参数.wheel.resolve()
报告 = 验收(轮子)
if 构建报告 is not None:
报告["同批构建"] = 构建报告
文本 = json.dumps(报告, ensure_ascii=False, indent=2) + "\n"
if 参数.报告:
参数.报告.parent.mkdir(parents=True, exist_ok=True)
参数.报告.write_text(文本)
print(文本)
return 0
if __name__ == "__main__":
raise SystemExit(主())

159
工具/验证锁.py Normal file
View File

@ -0,0 +1,159 @@
"""验证期写闸门:验证会话持锁,生成与格式化入口在锁被持有时拒绝改写。
两个反复踩到的坑都从这里堵:
- 验证跑着的时候改 `src/`,本轮结论作废,但报告已经按旧结论写出去;
- `make 格式写入` 也算改源码,改完没重新 `make 生成`,整层用例报「已安装源码与资源发布身份不符」,
几百个错误全是假的。
锁是 `fcntl.flock`,进程退出即释放,不靠残留 PID 文件判断;锁文件里记一句当前验证说明。
"""
from __future__ import annotations
import argparse
import fcntl
import json
import os
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from typing import Any
锁路径 = Path(os.environ.get("MUSE_验证锁", "/tmp/muse-验证锁.lock")) # noqa: S108
_持锁标记 = "MUSE_写入锁已持有"
def 正在验证() -> str | None:
"""返回当前验证说明;没有验证在跑时返回 None。
`--执行` 会在独占锁内跑写命令,被它启动的子进程(如构建编排)本身就是持锁方,
不能把自己判成「验证正在运行」;用环境变量把这一事实传给子进程。
"""
if os.environ.get(_持锁标记) == "1":
return None
if not 锁路径.exists():
return None
with 锁路径.open("a+", encoding="utf-8") as 文件:
try:
fcntl.flock(文件, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
return "有一个或多个验证会话在运行"
else:
fcntl.flock(文件, fcntl.LOCK_UN)
return None
def 登记说明(说明: str) -> None:
"""把本次验证写进锁文件,供人工查看谁在跑。"""
with 锁路径.open("a+", encoding="utf-8") as 文件:
文件.write(f"{说明}\n")
@contextmanager
def 持有(说明: str) -> Iterator[None]:
"""验证会话全程持共享锁;写入方要求独占,故分片并行验证互不阻塞。"""
锁路径.parent.mkdir(parents=True, exist_ok=True)
with 锁路径.open("a+", encoding="utf-8") as 文件:
fcntl.flock(文件, fcntl.LOCK_SH)
try:
yield
finally:
fcntl.flock(文件, fcntl.LOCK_UN)
def 已安装代码哈希() -> str | None:
"""直接读安装清单里的代码哈希;不需要打开资源提供器。"""
清单 = Path(__file__).resolve().parents[1] / "src" / "muse" / "资源" / "清单.json"
if not 清单.is_file():
return None
try:
数据: dict[str, Any] = json.loads(清单.read_text(encoding="utf-8"))
except (OSError, ValueError):
return None
return 数据.get("代码哈希")
def 源码身份核对() -> str | None:
"""返回不一致说明;一致返回 None。只算一次源码哈希,约 0.3 秒。"""
from muse.基础设施.系统运行 import 运行代码哈希
期望 = 已安装代码哈希()
if 期望 is None:
return "缺少已安装资源清单;先执行 make 生成"
当前 = 运行代码哈希()
if 当前 != 期望:
return "已安装源码与资源发布身份不符;先执行 make 生成(改过 src/ 或跑过格式写入都要重跑)"
return None
def 独占执行(命令: list[str]) -> int:
"""在独占锁内执行写命令:写窗口全程持锁,验证会话无法插入。
与 `--要求空闲` 的区别:后者查完即放锁,检查与写入之间仍可被验证会话插入。
"""
import subprocess
锁路径.parent.mkdir(parents=True, exist_ok=True)
with 锁路径.open("a+", encoding="utf-8") as 文件:
try:
fcntl.flock(文件, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
print(
"拒绝改写:验证正在运行;等它结束再改,否则本轮验证结果作废",
file=sys.stderr,
)
return 1
try:
os.environ[_持锁标记] = "1"
return subprocess.run(命令, check=False).returncode
finally:
os.environ.pop(_持锁标记, None)
fcntl.flock(文件, fcntl.LOCK_UN)
def main(argv: list[str] | None = None) -> int:
解析 = argparse.ArgumentParser(description=__doc__)
解析.add_argument(
"--要求空闲", action="store_true", help="锁被持有时非零退出,供写入入口前置检查(查完即放锁)"
)
解析.add_argument("--核对源码身份", action="store_true", help="比较源码哈希与安装清单")
解析.add_argument(
"--执行",
nargs=argparse.REMAINDER,
metavar="命令",
help="在独占锁内执行写命令,例如 --执行 uv run --no-sync python 工具/构建编排.py",
)
值 = 解析.parse_args(argv)
if 值.要求空闲:
说明 = 正在验证()
if 说明:
print(
f"拒绝改写:验证正在运行({说明});等它结束再改,否则本轮验证结果作废",
file=sys.stderr,
)
return 1
print("验证空闲:可以改写源码")
if 值.核对源码身份:
问题 = 源码身份核对()
if 问题:
print(问题, file=sys.stderr)
return 1
print("源码身份一致:安装清单与当前源码同哈希")
if 值.执行 is not None:
if not 值.执行:
print("--执行 需要给出命令", file=sys.stderr)
return 2
return 独占执行(list(值.执行))
return 0
if __name__ == "__main__":
raise SystemExit(main())