From 0658d178adb6136797db40113045bf280a9b6a06 Mon Sep 17 00:00:00 2001 From: zizi Date: Fri, 18 Sep 2026 01:14:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E7=A8=8B=E5=BA=95=E5=BA=A7=EF=BC=9A?= =?UTF-8?q?=E5=8D=95=E4=B8=80=E7=94=9F=E6=88=90=E5=85=A5=E5=8F=A3=E3=80=81?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E6=9C=9F=E5=86=99=E9=97=B8=E9=97=A8=E4=B8=8E?= =?UTF-8?q?=E5=B9=B6=E8=A1=8C=E5=88=86=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Makefile:新增 验收数据库(生成→检查→库层全量,前置校验隔离库连接串)、数据库分片、浏览器测试三个入口; 格式写入 末尾就地重新生成;数据库测试 与 验收数据库 统一排除 浏览器/网络;快检 不再静默跳过类型门; 前端旅程 预检补齐四个必需变量;pytest 目标改用仓内解释器(uv run 在嵌套检出会解析到外层环境)。 - 工具/验证锁.py:验证会话持共享锁,写入口用 --执行 在独占锁内落盘,生成/格式写入/索引生成走同一闸门。 - 工具/并行数据库测试.py:按文件分片并行,缺连接串在采集前拒绝。 - 工具/构建编排.py、环境预检.py、构建资源包.py、维护索引.py 与上述口径对齐。 - .gitignore / CI / README / AGENTS:忽略构建产物、CI 与 Makefile 单一口径、README 按实际实现陈述、AGENTS 补日常入口与验证纪律。 --- .github/workflows/检查.yml | 6 +- .gitignore | 6 + AGENTS.md | 53 ++-- Makefile | 95 +++++-- README.md | 27 +- pyproject.toml | 10 +- uv.lock | 19 +- 工具/前端用例身份.mjs | 185 +++++++++++++ 工具/并行数据库测试.py | 120 +++++++++ 工具/效果依赖.py | 131 +++++++++ 工具/构建发布.py | 47 ++-- 工具/构建编排.py | 372 ++++++++++++++++++++++++++ 工具/构建资源包.py | 126 ++++++++- 工具/源码资源.py | 92 +++++++ 工具/环境预检.py | 132 ++++++--- 工具/生成接口.py | 168 +++++++++--- 工具/用例身份.py | 159 +++++++++++ 工具/维护索引.py | 533 ++++++++++++++++++++++++++----------- 工具/资源登记.json | 40 +-- 工具/验收安装包.py | 164 ++++++++++++ 工具/验证锁.py | 159 +++++++++++ 21 files changed, 2301 insertions(+), 343 deletions(-) create mode 100644 工具/前端用例身份.mjs create mode 100644 工具/并行数据库测试.py create mode 100644 工具/效果依赖.py create mode 100644 工具/构建编排.py create mode 100644 工具/源码资源.py create mode 100644 工具/用例身份.py create mode 100644 工具/验收安装包.py create mode 100644 工具/验证锁.py diff --git a/.github/workflows/检查.yml b/.github/workflows/检查.yml index 5d6ac60..3d0f925 100644 --- a/.github/workflows/检查.yml +++ b/.github/workflows/检查.yml @@ -50,11 +50,15 @@ jobs: make 前端测试 - name: 离线测试 run: make 测试 + - name: 构建并验收同批安装包 + run: | + make 构建 + make 包验收 包=dist/muse-0.1.0-py3-none-any.whl - name: 初始化隔离用途角色 run: psql "$MUSE_TEST_DATABASE_URL" -v ON_ERROR_STOP=1 -f 数据库/初始化/用途角色.sql - name: 隔离数据库测试 run: make 数据库测试 - name: 固定Pi宿主整链 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 宿主测试 diff --git a/.gitignore b/.gitignore index 363dcd2..38f340c 100644 --- a/.gitignore +++ b/.gitignore @@ -43,3 +43,9 @@ web/node_modules/ web/dist/ web/test-results/ web/playwright-report/ + +# 内容增量构建缓存可删除重建,不作为发布权威 +/构建缓存/ + +# TypeScript增量编译缓存(已有跟踪项需另获索引操作授权后取消跟踪) +web/*.tsbuildinfo diff --git a/AGENTS.md b/AGENTS.md index 0e274ef..494f461 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,6 @@ # 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/作者/指令.md`](.agent/作者/指令.md),再只打开命中的一个场景文件;作者层负责路由,不替代正式技能、角色合同或用户确认。 @@ -10,9 +10,9 @@ `agent-example` 的目标定位是物理位于 `oh-my-muse` 内、拥有独立 `.git/` 的单用户缩小版 Muse,以 PostgreSQL 为正式内容权威。 -- **正式内容权威**:PostgreSQL(`muse-example` 库)是系统正式内容的唯一权威。作品、章、正文、实体、范式、用户决策、运行回执和 raw 都在库里。判断“系统里有没有这个东西”,以库里能不能查到为准;严禁把库外临时文件或快照当作正式内容。 -- **运行态账本**:本地 SQLite(`data/muse.db`,gitignore)是默认派发与单机工作面的运行态账本(2026-08-28 存储裁决):运行留痕(runs/events)、本地人审(reviews/revisions)、卡片与向量镜像、lesson 本地状态机和外部反馈数据落在其中;它不产生 Canonical,与 PG 冲突时以 PG 为准。表清单见 [`muse/authority/db/表映射.md`](docs/系统架构/新版设计/数据模型/存储与对象关系.md)。 -- **代码与配置权威**:Git 是代码、技能、智能体提示词、`muse/content/meta/schemas/`、文档和 DDL 的权威,并对作品信息和文本留痕备份;但 Git 留痕不是正式内容权威,正式内容以数据库为准,只读看板只读库,两者冲突时以库为准。 +- **正式内容权威**:PostgreSQL(连接目标由当前运行配置明确选择,见[运行手册](docs/运行手册.md))是系统正式内容的唯一权威。作品、章、正文、实体、方法、用户决策及运行回执在库里。raw暂存文件受租约管理,归档后以PG归档及回执为准;私人探索文件不作为正式内容。判断“系统里有没有这个东西”,以库里能不能查到为准;严禁把库外临时文件或快照当作正式内容。 +- **运行态账本**:任务、步骤、调用、预算、事件与回执由 PostgreSQL 维护;旧 SQLite 账本只属于历史实现。对象归属见[存储与对象关系](docs/系统架构/新版设计/数据模型/存储与对象关系.md)。 +- **代码与配置权威**:Git 维护代码、技能、角色提示词、内置结构种子、文档和 DDL。`src/muse/元数据/内置结构/` 的 YAML 是发布输入;运行结构以 PostgreSQL 已发布版本为准。Git 不承担创作数据备份。 - **能力边界**:由主会话派发角色智能体、技能和确定性工具协作完成创作与治理;不实现管理员、多用户、租户、市场、计费或资产交易。 --- @@ -28,6 +28,10 @@ agent-example/ ├── 配置/ # 无凭据配置模板 ├── web/ # 前端:React/TS 用户工作台 ├── 数据库/ # 数据库迁移脚本与旧库迁移工具链 +├── 部署/ # 部署模板与进程配置 +├── upstream/ # 固定版本的上游协议资料 +├── .github/ # CI 工作流 +├── data/ # 运行文件(不跟踪,不作为源码) ├── .agent/ # 智能体能力中枢(作者入口、角色提示词、角色合同、方法技能、规则、约束与规范) ├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录 ├── .venv/ # 仓内解释器(uv 管理) @@ -45,41 +49,40 @@ SoT 按主题分域,不做跨主题的全局排序。可执行脚本与书面 | 类别 | 载体 / 路径 | 权威职责 | |---|---|---| -| 总体设计 | `../design-docs/`(原路径 ../design-docs,已随旧实现退出,见固定基线) | Muse 的概念、产品、业务和总体架构 SoT(设计 SSOT)。 | +| 总体设计 | [总体架构](docs/系统架构/新版设计/总体架构.md) | 本仓模块、对象归属、依赖和运行边界;外部历史设计不充当当前实现。 | | 新版重写目标 | [docs/系统架构/新版设计/目录.md](docs/系统架构/新版设计/目录.md) | 新版主体完整目标设计、元数据、文件职责及研究依据;不据此宣称现有实现已完成。 | -| 领域 SoT | [`muse/sot/domains/`](docs/系统架构/新版设计/阅读指南.md) | 本仓各业务领域边界、数据权威、落库合同与领域协作 SoT(01-08 域)。 | -| 边界合同 | [`muse/sot/边界合同.md`](docs/系统架构/新版设计/总体架构.md) | 组件职责边界与约束归属唯一事实源:智能体/技能/工具 server/主代理职责划分。 | +| 领域 SoT | [阅读指南](docs/系统架构/新版设计/阅读指南.md) | 按主题定位现行业务领域、数据权威与协作合同。 | +| 边界合同 | [总体架构](docs/系统架构/新版设计/总体架构.md) | 组件职责边界、跨域公开接口与约束归属。 | | 角色合同 | [`.agent/角色/角色合同.md`](.agent/角色/角色合同.md) | 5 个角色(写手/规划/抽取/检测/裁判)的稳定输入边界、模型策略、工具权限与派发合同。 | -| 结构契约 | `muse/content/meta/schemas/`(原路径 muse/content/meta/schemas,已随旧实现退出,见固定基线) | 23 型结构本体与规划产物的字段合同;库内 payload 结构以此为准。 | -| 创作导读 | [`muse/sot/创作周期与Skill导读.md`](docs/系统架构/新版设计/调用链路/关键旅程.md) | 创作生命周期各阶段流转、门禁、人机分界与技能责任方导读地图。 | -| 技能目录 | [`.agent/skills/目录.md`](.agent/skills/目录.md) / [`muse/技能目录.md`](.agent/skills/目录.md) | 59 个技能的方法目录与编排目录(物理清单以 `skills.json` 为准)。 | -| 创作链条 | `muse/lifecycle/flow/chains/`(原路径 muse/lifecycle/flow/chains,已随旧实现退出,见固定基线) | scenario、purpose、功能 skill、角色槽位和保护节点的链路登记。 | -| 红线约束 | [`.agent/约束/红线约束.md`](.agent/约束/作者主权.md) | 数据权威、探索边界、模型治理与代码提交的不可逾越红线。 | -| 执行流程 | [`.agent/rules/执行流程.md`](.agent/rules/模块依赖.md) | 按条件适用的研发职责、分层验证与批次收尾;任务与依赖编排见[计划与提交](.agent/rules/计划与提交.md)。 | +| 结构契约 | [内置结构](src/muse/元数据/内置结构/) | 24 型的内置种子与发布输入;公共字段不是独立类型。运行实例遵守库内已发布结构版本。 | +| 创作导读 | [关键旅程](docs/系统架构/新版设计/调用链路/关键旅程.md) | 创作流转、门禁、人机边界及正式内容的确认路径。 | +| 技能目录 | [技能目录](.agent/skills/目录.md) | 方法和操作入口;以各组实际 SKILL.md 及目录发现,不维护另一份虚构清单。 | +| 创作链条 | [流程登记](src/muse/编排/流程登记.py)、[流程版本](src/muse/编排/流程版本.py)、[槽位约束](src/muse/编排/槽位约束.py) | 当前流程模板、冻结版本和角色槽位约束;旧 muse/lifecycle/flow/chains 已退出。 | +| 红线约束 | [作者主权](.agent/约束/作者主权.md)、[约束目录](.agent/约束/目录.md) | 数据权威、作者确认、读取范围与外部调用边界。 | +| 执行流程 | [模块依赖](.agent/rules/模块依赖.md)、[计划与提交](.agent/rules/计划与提交.md) | 模块边界、任务依赖、分层验证和批次收尾。 | | 子代理交付 | [`.agent/rules/子代理交付.md`](.agent/rules/子代理交付.md) | 派发子代理的交付物形状:三要素交付、证据指针、仅阻断级汇报、最小必读。 | -| 审查标准 | [`.agent/规范/审查标准.md`](.agent/rules/文档与资源生成.md) | 智能体提示词 4 问、技能质量 7 问 + D8 复利审查规程与严重度定义。 | -| 工程规范 | [`.agent/规范/`](.agent/规范/) | [`去AI味道工程规范.md`](.agent/rules/审校与修订.md)、[`指令集规范.md`](.agent/规范/命名.md)、[`术语规范.md`](.agent/规范/术语.md)。 | +| 审查标准 | [审查标准](.agent/规范/审查标准.md) | 由专项检查附录确认的提示词四问、技能七问和严重度;不冒称恢复缺失的旧 D8 原文。 | +| 工程规范 | [规范目录](.agent/规范/目录.md)、[审校与修订](.agent/rules/审校与修订.md) | 命名、术语、中文正文及受控修订的现行规范。 | | 任务与沉淀 | [`docs/`](docs) | 单次任务探索、计划、评测资料与历史执行证据;稳定结论回填 SoT。 | --- ## 4. 工作协议(硬约束) -1. **读后动手与渐进发现**:复杂任务开工前必须先读 SoT、角色合同与技能索引;技能发现遵循 `AGENTS.md → 索引 → SKILL.md` 渐进展开,不依赖宿主私有文件投影或猜测。 -2. **机械验证优先与完成=验证**:遵循父仓反假绿规则,新版工程统一使用仓内解释器 `.venv`(uv 管理;旧代码用 `.venv-旧`);日常入口 `make 检查`(格式+类型+模块边界+索引)、`make 测试`(默认离线进程隔离)、`make 数据库测试`(需 `MUSE_TEST_DATABASE_URL`);必须通过相关单元测试、门禁与 `git diff --check`,无自动化证据严禁声称“完成/修复/通过”。 +1. **读后动手与渐进发现**:复杂任务先读对应 SoT;涉及角色时读角色合同。技能发现遵守本文件开头的唯一入口,按当前任务展开,不预载无关资料。 +2. **机械验证优先与完成=验证**:工程统一使用仓内解释器 `.venv`(uv 管理);日常入口 `make 快检 范围=<受影响路径>`(开发期)、`make 检查`(格式+类型+模块边界+索引)、`make 测试`(默认离线进程隔离)、`make 数据库测试`(需 `MUSE_TEST_DATABASE_URL`);改完源码后的库层全量走 `make 验收数据库`(唯一入口,先生成再门禁后跑库),发现期要并行用 `make 数据库分片`,浏览器层用例走 `make 浏览器测试`(需隔离库与 `MUSE_BROWSER_EXECUTABLE`)。必须通过相关单元测试、门禁与 `git diff --check`,无自动化证据严禁声称“完成/修复/通过”。 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 味(直陈事实、动作与后果,禁止清嗓子套话与空转缓冲词);需要用户决策时,必须交代清楚前因后果及各选项对下游的影响。 -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)为准。 --- -## 5. 知识与经验复利机制 +## 5. 交付与经验沉淀 -系统的核心价值在于复利:每一次创作与开发任务后,系统必须比上一次更强。 -- **创作复利**:创作过程中提炼的技法沉淀为范式卡、AI 味规则、声音账与 `example_lesson` 登记入库,走 [06-质量与复利领域 §6](docs/系统架构/新版设计/模块设计/B06-审校修订.md) 升格链。 -- **工程复利**:开发交付后,将稳定设计与规则回写对应 SoT、`.agent/规范/`、`.agent/约束/` 或技能,过时内容及时清理,控制长期熵增。 +- 创作方法和规则沿[审校修订](docs/系统架构/新版设计/模块设计/B06-审校修订.md)、[作者经验](docs/系统架构/新版设计/模块设计/B07-作者经验.md)的候选、评测与批准链承接,不自动写入正式内容或沿用旧豁免。 +- 工程交付按[计划与提交](.agent/rules/计划与提交.md)同步所属权威合同,按[文档与资源生成](.agent/rules/文档与资源生成.md)维护受影响索引并验证,清理被替代实现、重复路径、旧配置与无用途测试;有兼容、迁移、回滚或追溯用途的内容保留并注明用途。 --- @@ -97,8 +100,8 @@ SoT 按主题分域,不做跨主题的全局排序。可执行脚本与书面 ### 维护纪律 -处理任何代码变更时,以项目已有架构、领域模型、ADR、编码规范和测试为准,优先保持代码可读、简单、局部且可维护,禁止无证据叠加抽象;新方案完成后必须清理被替代的实现、重复路径、旧配置、旧测试和文档残留,控制长期熵增。 +代码变更以现行架构、领域模型、ADR、规范与测试为准,保持可读、简单、局部,不凭空叠加抽象;清理和沉淀按本文件§5执行。 -提交:`AGENTS.md`、`docs/`、`.agent/` 全部内容。 +纳入版本控制:`AGENTS.md`、`docs/`、`.agent/`;实际暂存和提交仍须明确授权。 不提交:`.agents.local/`、`.claude/`、`.codex/`、`.pi/`、`.opencode/`、`.cursor/`。 diff --git a/Makefile b/Makefile index ef251e2..e6a41f3 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,13 @@ +export PYTHONPATH := $(CURDIR)/src:$(CURDIR)/工具 + +# 用例与库层直接用仓内解释器:uv run 在嵌套检出里会解析到外层 agent-example/.venv, +# 那个环境没有 psycopg_pool,库层会以导入失败告终。工具链目标仍走 uv run。 +仓内解释器 := $(CURDIR)/.venv/bin/python + # Muse 安装、生成、检查、测试和构建入口。 # 每个声明命令都必须真实可执行(见 .agent/rules/文档与资源生成.md)。 -.PHONY: 安装 格式 格式写入 类型 模块边界 检查 测试 数据库测试 宿主测试 真实模型测试 前端安装 前端检查 前端测试 生成 构建 资源核对 +.PHONY: 迁移 前端旅程 快检 索引生成 包验收 安装 格式 格式写入 类型 模块边界 检查 测试 数据库测试 数据库分片 验收数据库 浏览器测试 宿主测试 真实模型测试 前端安装 前端检查 前端测试 生成 构建 资源核对 # 安装:建立并锁定 Python 依赖环境 安装: @@ -18,65 +24,104 @@ 前端测试: 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 + 格式校验;只覆盖新版测试与工具目录) 新测试目录 := $(shell for d in tests/单元 tests/契约 tests/集成 tests/架构 tests/迁移 tests/端到端 tests/真实调用; do [ -d "$$d" ] && printf '%s ' "$$d"; done) 新工具目录 := 工具 $(wildcard 数据库/旧库迁移) 格式: - uv run ruff check src $(新测试目录) tests/conftest.py $(新工具目录) - uv run ruff format --check src $(新测试目录) tests/conftest.py $(新工具目录) + uv run --no-sync ruff 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 ruff format src $(新测试目录) tests/conftest.py $(新工具目录) + uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync ruff check --fix src $(新测试目录) tests/conftest.py $(新工具目录) + uv run --no-sync python 工具/验证锁.py --执行 uv run --no-sync ruff format src $(新测试目录) tests/conftest.py $(新工具目录) + $(MAKE) 生成 # 类型:pyright 类型检查 类型: - uv run pyright + uv run --no-sync pyright # 模块边界:固定执行架构门禁,不继承调用者的 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 防护下运行) # 用法:make 测试 全部离线用例 # 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; } - 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; } - 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; } - 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 - 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 --执行 uv run --no-sync python 工具/构建编排.py -# 构建:先保证生成物最新,再做可安装包 -构建: 生成 - uv build +构建: + uv run --no-sync python 工具/构建编排.py --wheel + +包验收: + 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['资源']))" diff --git a/README.md b/README.md index 87402cb..3549290 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ muse 审校 <作者配置.toml> 返修比较证明 <比较回执ID> 来源请求固定返修会话、轮次、实际候选、批准引用和有效期;实验请求固定数据集及哈希、两份独立judge配置、维度和预算。请求结构见[B10固定返修比较合同](docs/系统架构/新版设计/模块设计/B10-效果评测.md)。评测任务也可在evaluation工作台按“执行下一步”推进,每次只执行一个冻结步骤;缺少计价仍记录未知费用。 -返修页可按回执读取比较记录、未完整或未标定原因,作者仍可保留原文。维护/来源连接只由本地显式配置选择,HTTP不接受服务器凭据路径。当前发布策略只有单一固定评委模型,真实双模型执行须先解决角色策略冲突;隔离合成测试不替代真实模型验收,也不授予生产启用许可。 +返修页可按回执读取比较记录、未完整或未标定原因,作者仍可保留原文。维护/来源连接只由本地显式配置选择,HTTP不接受服务器凭据路径。当前允许模型以[角色策略](配置/角色策略.yaml)为准;默认初评名单已有两项,独立仲裁所需的第三模型仍需登记精确身份和验证能力。验证范围见[验证纪律](#验证纪律)。 ## 新库配置与迁移 @@ -143,9 +143,9 @@ muse 审校 <作者配置.toml> 返修比较证明 <比较回执ID> ## 参考分析与历史承接 ```bash -uv run muse 研究 /private/作者配置.toml 进度 <来源ID> -uv run muse 研究 /private/作者配置.toml 分析 <分析ID> -uv run muse 研究 /private/作者配置.toml 承接 <已完成研究任务ID> +.venv/bin/python -m muse 研究 /private/作者配置.toml 进度 <来源ID> +.venv/bin/python -m muse 研究 /private/作者配置.toml 分析 <分析ID> +.venv/bin/python -m muse 研究 /private/作者配置.toml 承接 <已完成研究任务ID> ``` 完整分析结果由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),不再作为运行入口。旧库默认只作历史参照;代码退出不证明当前实际写权限已经关闭。 +## 当前运行底座 + +[数据库连接](src/muse/基础设施/数据库/连接.py)按用途构造受控连接及其生命周期,运行装配走常驻连接池(`启动.py` 传 `常驻=True`,池在装配生命周期内打开、退出时关闭;缺 `psycopg_pool` 时明确报「连接池未就绪」,不静默降级);[可观测性](src/muse/基础设施/可观测性.py)提供日志、关联身份与脱敏,尚无指标采集接口。两者都按实际实现分别验收,不从旧设计表推定已具备。 + ## 目录速览 ```text @@ -166,13 +170,18 @@ agent-example/ ├── 配置/ # 无凭据配置模板 ├── web/ # React/TS 作者工作台 ├── 数据库/ # 数据库迁移脚本与旧库迁移工具链 +├── 部署/ # 发布镜像与实例启停模板 +├── upstream/ # 上游溯源记录 +├── .github/ # 自动化检查流程 ├── .agent/ # 智能体能力中枢(规则、约束、规范、角色合同、技能) ├── docs/ # 设计 SSOT、系统架构、运行手册与历史执行记录 -└── data/ # 运行态数据(gitignore) +└── data/ # 配置指定的本地工作文件;非 PG 正式内容权威(gitignore) ``` ## 验证纪律 +隔离合成验证只覆盖其执行的协议与业务行为;真实外部模型行为、文学效果、标定和正式启用分别需要对应证据与批准。合成提供方、读取报告或安装成功均不授予生产启用资格。 + 完成 = 机械验证:无自动化绿证据不得声称完成。测试默认离线禁网禁库;空收集、全跳过、超时与缺依赖都如实呈现,不冒充通过。详见 [`.agent/rules/测试隔离.md`](.agent/rules/测试隔离.md),现行身份登记见 [tests/用例清单.json](tests/用例清单.json)。 所有任务从 [`AGENTS.md`](AGENTS.md) 开始。 @@ -205,11 +214,11 @@ CLI 使用 `.venv/bin/python -m muse 审阅 <作者配置> 列表 <章节ID>` “审校 <作者配置> 案例采集/案例审阅/案例决定/案例撤回 <请求JSON>”调用同一B06/S01链;“案例 ”与“案例清单”只读查询。采集只形成待复核案例,真实引用的字段与许可见[案例操作合同](.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#方法版本对照),不改变方法启用状态。 @@ -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使用索引及关联正文;目标正文只进入答案区。评测账号不能直接读取正式正文表。 -这类实验使用`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回合哈希,只在暂停或失败任务补登,原样重放幂等,已停止实验拒绝新增结果。 @@ -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运行。镜像构建不执行数据库迁移。 diff --git a/pyproject.toml b/pyproject.toml index 121f55b..57b644c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,7 +17,7 @@ dependencies = [ "itsdangerous>=2.2", "jsonschema>=4.23", "referencing>=0.28.4", - "psycopg[binary]>=3.2", + "psycopg[binary,pool]>=3.2", "pydantic>=2.0", "pyyaml>=6.0", "uvicorn>=0.30", @@ -54,14 +54,18 @@ packages = ["src/muse"] # 只收集新版用例目录;旧实现与旧测试树已退出本仓,不参与同一收集。 testpaths = ["tests/单元", "tests/契约", "tests/集成", "tests/架构", "tests/迁移", "tests/端到端", "tests/真实调用"] # 独立维护脚本以其目录为导入根;不把旧 muse 树加入路径。 -pythonpath = ["数据库/旧库迁移"] +pythonpath = ["src", "工具", "数据库/旧库迁移"] # 架构/契约/集成/单元 是目录级组织;完整用例身份绑定登记文件与符号。 addopts = "--strict-markers --timeout=120" markers = [ + "安装包: 实际wheel/发布资源验收,不属于无包开发层", + "case_id(id): 源码中的稳定用例身份与人工语义", "数据库: 显式外部执行需要 MUSE_TEST_DATABASE_URL;缺配置失败", "网络: 需要真实网络访问;必须显式标记", "真实模型: 需要真实模型调用与预算授权;必须显式标记", "浏览器: 需要真实浏览器与后端;必须显式标记", + "宿主: 需要固定真实宿主与隔离数据库;必须显式选择", + "慢: 单例超过三分钟的真实链用例;发现期默认排除,最终全量才跑", ] [tool.ruff] @@ -76,6 +80,6 @@ select = ["E", "F", "W", "I", "B", "UP"] venvPath = "." venv = ".venv" include = ["src", "工具", "数据库/旧库迁移"] -extraPaths = ["数据库/旧库迁移"] +extraPaths = ["src", "工具", "数据库/旧库迁移"] pythonVersion = "3.12" typeCheckingMode = "standard" diff --git a/uv.lock b/uv.lock index 2d76b24..588c165 100644 --- a/uv.lock +++ b/uv.lock @@ -185,7 +185,7 @@ dependencies = [ { name = "httpx" }, { name = "itsdangerous" }, { name = "jsonschema" }, - { name = "psycopg", extra = ["binary"] }, + { name = "psycopg", extra = ["binary", "pool"] }, { name = "pydantic" }, { name = "pyyaml" }, { name = "referencing" }, @@ -206,7 +206,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.28" }, { name = "itsdangerous", specifier = ">=2.2" }, { 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 = "pyyaml", specifier = ">=6.0" }, { name = "referencing", specifier = ">=0.28.4" }, @@ -265,6 +265,9 @@ wheels = [ binary = [ { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, ] +pool = [ + { name = "psycopg-pool" }, +] [[package]] 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" }, ] +[[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]] name = "pydantic" version = "2.13.5" diff --git a/工具/前端用例身份.mjs b/工具/前端用例身份.mjs new file mode 100644 index 0000000..f2f22dc --- /dev/null +++ b/工具/前端用例身份.mjs @@ -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; + } +} diff --git a/工具/并行数据库测试.py b/工具/并行数据库测试.py new file mode 100644 index 0000000..4a2aee0 --- /dev/null +++ b/工具/并行数据库测试.py @@ -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()) diff --git a/工具/效果依赖.py b/工具/效果依赖.py new file mode 100644 index 0000000..e4e394d --- /dev/null +++ b/工具/效果依赖.py @@ -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": 能力, + } diff --git a/工具/构建发布.py b/工具/构建发布.py index 966a2cc..8da6239 100644 --- a/工具/构建发布.py +++ b/工具/构建发布.py @@ -3,55 +3,51 @@ import argparse import hashlib import json +import os import shutil import subprocess +import sys import tempfile from pathlib import Path from zipfile import ZipFile +from 构建编排 import 执行构建 + 根 = Path(__file__).resolve().parents[1] def 运行(args): - subprocess.run(args, cwd=根, check=True) + subprocess.run(args, cwd=根, env={**os.environ, "PYTHONPATH": str(根 / "src")}, check=True) def 构建发布(output: Path, *, 生成=True): output = output.resolve() if output.exists(): raise ValueError("发布目录已存在;使用新目录保留已有版本") - output.parent.mkdir(parents=True, exist_ok=True) - if 生成: - 运行(["uv", "run", "python", "工具/生成接口.py"]) - 运行( - [ - "pnpm", - "--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", "--检查"]) + # 生成参数为旧调用者保留;共享入口始终按内容和产物存在性判断复用。 + 报告 = 执行构建(轮子=True) + 路径 = 报告.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", "--检查"]) 运行( [ - "uv", - "run", - "python", + sys.executable, "-c", "from muse.资源加载 import 核对资源; " "from muse.基础设施.系统运行 import 运行代码哈希; " "assert 核对资源()['代码哈希'] == 运行代码哈希(), '资源与当前代码不匹配,先重新构建'", ] ) + output.parent.mkdir(parents=True, exist_ok=True) with tempfile.TemporaryDirectory(prefix=".muse-release-", dir=output.parent) as temporary: stage = Path(temporary) - 运行(["uv", "build", "--wheel", "--out-dir", str(stage / "wheels")]) + (stage / "wheels").mkdir() + 本批轮子 = stage / "wheels" / wheel.name + shutil.copyfile(wheel, 本批轮子) 运行( [ "uv", @@ -129,6 +125,9 @@ def 构建发布(output: Path, *, 生成=True): if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) 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() print(json.dumps(构建发布(args.输出, 生成=not args.已生成), ensure_ascii=False)) diff --git a/工具/构建编排.py b/工具/构建编排.py new file mode 100644 index 0000000..ec51a33 --- /dev/null +++ b/工具/构建编排.py @@ -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(主()) diff --git a/工具/构建资源包.py b/工具/构建资源包.py index af59d55..bc0595f 100644 --- a/工具/构建资源包.py +++ b/工具/构建资源包.py @@ -15,12 +15,14 @@ import argparse import hashlib import json import shutil +import tempfile import tomllib import zipfile -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Any from muse.资源加载 import 解析能力声明 +from 效果依赖 import 构建效果依赖 仓库根 = Path(__file__).resolve().parent.parent 登记文件 = 仓库根 / "工具" / "资源登记.json" @@ -78,7 +80,67 @@ def 编译能力资源( 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("发布身份", "")) if not 发布身份.strip(): @@ -99,6 +161,7 @@ def 打包() -> dict[str, Any]: 能力资源["能力/目录.json"] = json.dumps( 目录, ensure_ascii=False, sort_keys=True, indent=2 ).encode() + 迁移文件, web文件 = _预检资源(登记, 能力资源) 资源目录.mkdir(parents=True, exist_ok=True) 压缩包路径 = 资源目录 / 压缩包名 @@ -112,8 +175,9 @@ def 打包() -> dict[str, Any]: 信息 = zipfile.ZipInfo(打包路径, date_time=_固定时间戳) 信息.compress_type = zipfile.ZIP_DEFLATED 信息.external_attr = 0o644 << 16 - zf.writestr(信息, 源.read_bytes()) - 已打包[打包路径] = _sha256(源) + 数据 = 源.read_bytes() + zf.writestr(信息, 数据) + 已打包[打包路径] = hashlib.sha256(数据).hexdigest() for 路径, 数据 in sorted(能力资源.items()): if 路径 in 已打包: raise ValueError("生成能力资源与直接登记路径重复") @@ -133,9 +197,23 @@ def 打包() -> dict[str, Any]: if 迁移资源.exists(): shutil.rmtree(迁移资源) 迁移资源.mkdir() - for 文件 in sorted(源目录.glob("V*.sql")): - shutil.copyfile(文件, 迁移资源 / 文件.name) - 已打包[f"迁移/{文件.name}"] = _sha256(文件) + for 文件 in 迁移文件: + 数据 = 文件.read_bytes() + (迁移资源 / 文件.name).write_bytes(数据) + 已打包[f"迁移/{文件.name}"] = hashlib.sha256(数据).hexdigest() + + # 效果依赖按登记/编译/迁移生成阶段取全量保守闭包,不纳入工作台产物。 + 效果依赖 = 构建效果依赖( + 仓库根, + dict(已打包), + { + "registered": sorted(str(条["打包路径"]) for 条 in 条目), + "capabilities": sorted(能力资源), + "migrations": sorted( + set(已打包) - {str(条["打包路径"]) for 条 in 条目} - set(能力资源) + ), + }, + ) # 工作台投影:web 构建产物存在时带上;不存在时如实留空。 工作台目录 = 资源目录 / "工作台" @@ -143,14 +221,20 @@ def 打包() -> dict[str, Any]: shutil.rmtree(工作台目录) web_dist = 仓库根 / "web" / "dist" if web_dist.is_dir(): - shutil.copytree(web_dist, 工作台目录) - for 文件 in sorted(工作台目录.rglob("*")): - if 文件.is_file(): - 相对 = 文件.relative_to(资源目录).as_posix() - 已打包[相对] = _sha256(文件) + 工作台目录.mkdir() + for 文件 in web文件: + if 文件.is_symlink() or any(p.is_symlink() for p in 文件.parents if p != 仓库根): + raise ValueError("web/dist 在预检后变为软链") + 相对 = 文件.relative_to(web_dist) + 目标 = 工作台目录 / 相对 + 目标.parent.mkdir(parents=True, exist_ok=True) + 数据 = 文件.read_bytes() + 目标.write_bytes(数据) + 已打包["工作台/" + 相对.as_posix()] = hashlib.sha256(数据).hexdigest() 清单 = { "schema_version": 1, + "效果依赖": 效果依赖, "发布身份": 发布身份, "资源": {路径: {"sha256": 哈希} for 路径, 哈希 in sorted(已打包.items())}, } @@ -173,6 +257,24 @@ def 打包() -> dict[str, Any]: 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: 解析器 = argparse.ArgumentParser(description="生成运行资源与发布清单") 解析器.parse_args() diff --git a/工具/源码资源.py b/工具/源码资源.py new file mode 100644 index 0000000..2a0a9c7 --- /dev/null +++ b/工具/源码资源.py @@ -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.加载清单() diff --git a/工具/环境预检.py b/工具/环境预检.py index 895bbd9..ea5fb06 100644 --- a/工具/环境预检.py +++ b/工具/环境预检.py @@ -9,42 +9,96 @@ import subprocess import tempfile from pathlib import Path -from muse.共享.错误 import Muse错误, 配置错误 +from muse.共享.错误 import Muse错误, 环境缺失错误, 资源缺失错误, 配置错误 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: - if not node.is_file() or not cli.is_file(): - raise 配置错误("Node或Pi入口不存在;需要明确的本地可执行路径") - 结果 = subprocess.run( - [str(node), "--version"], capture_output=True, text=True, timeout=10, check=True - ) - node_version = 结果.stdout.strip().removeprefix("v") - parts = tuple(int(v) for v in node_version.split(".")) - if parts < (22, 19, 0): - raise 配置错误("当前Pi需要Node至少22.19.0;不能使用shell默认旧版本") - with tempfile.TemporaryDirectory(prefix="muse-pi-preflight-") as 临时: - 环境 = { - **os.environ, - "PI_CODING_AGENT_DIR": 临时, - "PI_OFFLINE": "1", - "PI_TELEMETRY": "0", - } + 动作 = "HOST_NODE" + try: + if not node.is_file(): + raise FileNotFoundError 结果 = subprocess.run( - [str(node), str(cli), "--version"], - cwd=临时, - env=环境, - capture_output=True, - text=True, - timeout=15, - check=True, + [str(node), "--version"], capture_output=True, text=True, timeout=10, check=True ) - 实际 = 结果.stdout.strip() - if 实际 != 预期版本: - raise 配置错误("Pi实际版本与显式要求不符") - return {"node_version": node_version, "pi_version": 实际, "state": "version_verified"} + node_version = 结果.stdout.strip().removeprefix("v") + 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): + raise 配置错误("Node版本未达到已声明的宿主要求") + 动作 = "HOST_PI" + if not cli.is_file(): + raise FileNotFoundError + with tempfile.TemporaryDirectory(prefix="muse-pi-preflight-") as 临时: + 环境 = { + **os.environ, + "PI_CODING_AGENT_DIR": 临时, + "PI_OFFLINE": "1", + "PI_TELEMETRY": "0", + } + 结果 = subprocess.run( + [str(node), str(cli), "--version"], + cwd=临时, + env=环境, + capture_output=True, + text=True, + timeout=15, + check=True, + ) + 实际 = 结果.stdout.strip() + if 实际 != 预期版本: + raise 配置错误("Pi实际版本与显式要求不符") + return {"node_version": node_version, "pi_version": 实际, "state": "version_verified"} + except Exception as 错误: + raise 预检失败(安全失败(动作, 错误)) from None def 主() -> int: @@ -61,6 +115,7 @@ def 主() -> int: (参数.node, 参数.pi, 参数.pi_version) ): 解析.error("Pi检查必须同时给出node、pi和pi-version") + 动作 = "RESOURCE" try: 清单 = 核对资源() 能力 = 读取能力目录() @@ -76,25 +131,26 @@ def 主() -> int: "model_calls": 0, } if 参数.配置: + 动作 = "CONFIG" 配置 = 读取配置(参数.配置) if 配置.资源发布身份 != 清单["构建身份"]: raise 配置错误("配置必须固定当前资源构建身份;先无配置运行预检取得身份") if 参数.数据库: - 构建(配置).要求数据库().检查连接() + 动作 = "DATABASE" + # 常驻连接池只在装配生命周期内打开;不进生命周期会以「连接池未就绪」必然失败。 + 装配 = 构建(配置) + with 装配.生命周期(): + 装配.要求数据库().检查连接() 报告["database"] = "connection_verified" if 参数.node: + 动作 = "HOST" 报告["host"] = 检查Pi(参数.node.resolve(), 参数.pi.resolve(), 参数.pi_version) print(json.dumps(报告, ensure_ascii=False, indent=2)) return 0 - except Muse错误 as exc: - print(json.dumps(exc.呈现(), ensure_ascii=False)) - except (OSError, ValueError, KeyError, subprocess.SubprocessError): - print( - json.dumps( - {"code": "PREFLIGHT_FAILED", "message": "资源或宿主检查失败,未验证项目不计通过"}, - ensure_ascii=False, - ) - ) + except 预检失败 as 错误: + print(json.dumps(错误.报告, ensure_ascii=False)) + except Exception as 错误: + print(json.dumps(安全失败(动作, 错误), ensure_ascii=False)) return 1 diff --git a/工具/生成接口.py b/工具/生成接口.py index 6a0ea3f..ac88bf4 100644 --- a/工具/生成接口.py +++ b/工具/生成接口.py @@ -1,6 +1,7 @@ -"""从登记的实际路由工厂生成 OpenAPI;不启动应用、不连接数据库或读取凭据。 +"""从登记路由工厂生成 OpenAPI 与客户端外壳;类型由后续步骤生成。 -未支持的非空输入失败,生成结果由同一公开合同产生。 +先核对全部登记与输出位置,再导入工厂并生成内存合同,最后写入。 +不启动应用、不连接数据库或读取凭据。 """ from __future__ import annotations @@ -8,55 +9,146 @@ from __future__ import annotations import argparse import importlib import json +import keyword +import sys +from collections.abc import Callable from pathlib import Path from typing import Any 仓库根 = Path(__file__).resolve().parent.parent 登记文件 = 仓库根 / "工具" / "生成输入.json" +_客户端路径 = "web/src/接口/生成/客户端.ts" +_客户端正文 = ( + "// 由工具/生成接口.py生成;稳定外壳来自同一OpenAPI。\n" + 'import createClient from "openapi-fetch";\n' + 'import type { paths } from "./类型";\n' + 'import { 传输 } from "../传输";\n\n' + "export const 客户端 = createClient({\n" + " baseUrl: globalThis.location.origin, fetch: 传输,\n" + ' credentials: "same-origin"\n' + "});\n" +) + + +class 生成错误(ValueError): + """仅携带可安全展示的登记或合同错误,不包含原始工厂异常。""" 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 含 资源 表)") + """缺登记与坏登记都拒绝;显式空资源表仍是合法的无输出登记。""" + 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 主() -> int: - 解析器 = argparse.ArgumentParser(description="生成 Schema、OpenAPI 和 TypeScript") - 解析器.parse_args() +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 输出 - 输入 = 已登记输入() - 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 合同") - 输出 = 仓库根 / 条目["输出"] + +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(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" - 'import createClient from "openapi-fetch";\n' - 'import type { paths } from "./类型";\n' - 'import { 传输 } from "../传输";\n\n' - "export const 客户端 = createClient({\n" - " baseUrl: globalThis.location.origin, fetch: 传输,\n" - ' credentials: "same-origin"\n' - "});\n", - encoding="utf-8", - ) - print(f"生成接口:{条目['输出']}") + 输出.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 diff --git a/工具/用例身份.py b/工具/用例身份.py new file mode 100644 index 0000000..91f07ba --- /dev/null +++ b/工具/用例身份.py @@ -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 diff --git a/工具/维护索引.py b/工具/维护索引.py index cd1515b..fe02373 100644 --- a/工具/维护索引.py +++ b/工具/维护索引.py @@ -1,15 +1,8 @@ -"""索引生成、查重及检查。 +"""由源文件派生导航,并复用 pytest 收集的稳定用例身份。 -工程阶段的生成或预检入口: -- 核对 Markdown 索引(目录.md)表内相对链接有效; -- 核对技能目录(.agent/skills/ 下分组)中每个技能有 SKILL.md,名称不重复; -- 核对测试符号与用例清单的 case 身份一致:仓库内每个 test_ 符号必须带 - ``__`` 后缀且能在清单定位;清单登记的目标符号必须在树上真实存在; - 只有 print 没有 assert/raise 的用例在执行前即被标记(反假绿)。 - -边界:只报告差异与失败,不改变业务数据、不自行批准发布。 -生成物与唯一输入一致;命令可独立重跑。所有检查函数接受显式根目录, -供测试在临时项目上验证同一语义。 +显式 --写入 生成目录表;--检查 不写磁盘。技能说明来自 SKILL.md +frontmatter,作者导航语义随源文件保存,手写导读保留。历史逐文件设计 +不再参与每日源码定位门禁。所有入口可用显式根目录在临时项目复现。 """ from __future__ import annotations @@ -17,17 +10,29 @@ from __future__ import annotations import argparse import ast import json +import os import re +import subprocess import sys import unicodedata +from fnmatch import fnmatch from pathlib import Path from typing import Any +import yaml + 仓库根 = Path(__file__).resolve().parent.parent -_目录表行 = re.compile(r"^\|\s*([^|]+)\|\s*\[([^\]]+)\]\(([^)]+)\)\s*\|") -_用例后缀 = re.compile(r"__([0-9a-f]{6})$") _忽略目录片段 = frozenset( - {".venv", ".venv-旧", ".git", "node_modules", "__pycache__", ".agents.local", "研究依据"} + { + ".venv", + ".venv-旧", + ".git", + "node_modules", + "__pycache__", + ".agents.local", + "研究依据", + "文件设计", + } ) # 新版用例目录:旧测试树(tests/skills、tests/adapters、tests/architecture 英文目录等) # 由旧环境运行,不进入新版用例身份检查 @@ -42,37 +47,47 @@ def _在忽略目录(路径: Path, 根: Path) -> bool: 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]: """核对仓库内 目录.md 的表格相对链接;返回问题列表。""" 问题: list[str] = [] - for 目录文件 in 根.rglob("目录.md"): + for 目录文件 in 遍历文件(根, "目录.md"): if _在忽略目录(目录文件, 根): continue - for 行号, 行 in enumerate(目录文件.read_text(encoding="utf-8").splitlines(), 1): - 匹配 = _目录表行.match(行) - if not 匹配: - continue - 地址 = 匹配.group(3).split("#")[0].strip() - if not 地址 or 地址.startswith(("http://", "https://")): + for 序, _, 原地址 in _表格行(目录文件.read_text(encoding="utf-8")): + 地址 = 原地址.split("#")[0].strip() + if not 地址 or 地址 == "暂无" or 地址.startswith(("http://", "https://", "mailto:")): continue 目标 = (目录文件.parent / 地址).resolve() if not 目标.exists(): - 问题.append(f"{目录文件.relative_to(根)}:{行号} 链接不存在:{地址}") + 问题.append(f"{目录文件.relative_to(根)}:{序 + 1} 链接不存在:{地址}") return 问题 -def _读frontmatter名(文件: Path) -> str | None: - """读 SKILL.md frontmatter 的 name 字段;无 frontmatter 返回 None。""" +def _读frontmatter(文件: Path) -> dict: + """与源文件一起移动的声明;支持 YAML 折叠及引号,不解析正文。""" 文本 = 文件.read_text(encoding="utf-8") - if not 文本.startswith("---"): - return None - 结束 = 文本.find("\n---", 3) - if 结束 < 0: - return None - for 行 in 文本[3:结束].splitlines(): - if 行.startswith("name:"): - return 行[5:].strip() - return None + 匹配 = re.match(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|$)", 文本, re.S) + if 匹配 is None: + return {} + try: + 声明 = yaml.safe_load(匹配[1]) + except yaml.YAMLError as 错误: + raise ValueError(f"frontmatter 无法解析:{文件}") from 错误 + return 声明 if isinstance(声明, dict) else {} + + +def _读frontmatter名(文件: Path) -> str | None: + 名称 = _读frontmatter(文件).get("name") + return 名称 if isinstance(名称, str) else None def _含中文(名称: str) -> bool: @@ -99,7 +114,11 @@ def 检查技能目录(根: Path) -> list[str]: if not 正文 or 正文 == "---": 问题.append(f"SKILL.md 为空:{相对}") continue - front名 = _读frontmatter名(技能文件) + try: + front名 = _读frontmatter名(技能文件) + except ValueError as 错误: + 问题.append(str(错误)) + continue if front名 is None: 问题.append(f"SKILL.md 缺少 frontmatter name:{相对}") elif front名 != 名称: @@ -117,6 +136,248 @@ def 检查技能目录(根: Path) -> list[str]: 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"(? 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") + 注记 = "\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]: """用 AST 提取 test_ 开头的函数与方法;不执行任何代码。""" 树 = ast.parse(文件.read_text(encoding="utf-8")) @@ -140,144 +401,118 @@ def _有判定语句(节点: ast.FunctionDef) -> bool: return False -def 检查用例身份( - 根: Path, - 清单路径: Path | None = None, - *, - 核对缺失目标: 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")) +def 收集索引用例(根: Path) -> list[dict[str, Any]]: + """Python、前端各由本语言的唯一收集实现导出,再验证跨语言身份。""" + from 用例身份 import 收集用例 - 登记符号: dict[str, str] = {} - for 用例 in 清单["cases"]: - 文件, 符号 = 用例["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)); -""" + 用例 = 收集用例(根) + if (根 / "web/tests").is_dir(): try: 结果 = subprocess.run( - ["node", "--input-type=module", "-e", 脚本], - cwd=根 / "web", - input=json.dumps([str(p) for p in 前端测试]), + ["node", str(根 / "工具/前端用例身份.mjs"), str(根)], + cwd=根, capture_output=True, text=True, - timeout=20, + timeout=30, check=True, ) - for 文件, 符号 in json.loads(结果.stdout): - 键 = f"{Path(文件).relative_to(根).as_posix()}::{符号}" - 树上符号.add(键) - if 键 not in 登记符号: - 问题.append(f"前端用例未在清单登记:{键}") - except (OSError, ValueError, subprocess.SubprocessError): - 问题.append("前端用例解析失败;需要已锁定的 Node 与 TypeScript 工具链") - - for 键, case_id in sorted(登记符号.items()): - if 核对缺失目标: - if 键 not in 树上符号: - 问题.append(f"用例清单登记的目标不存在:{键}({case_id})") - return 问题 + 前端 = json.loads(结果.stdout) + if not isinstance(前端, list): + raise ValueError("前端用例导出必须为列表") + 用例.extend(前端) + except subprocess.CalledProcessError as 错误: + raise ValueError(f"前端用例收集失败(exit {错误.returncode}):{错误.stderr}") from 错误 + except (OSError, subprocess.TimeoutExpired) as 错误: + raise ValueError(f"前端用例收集未完成:{错误}") from 错误 + 已见 = set() + for 条 in 用例: + if not isinstance(条, dict) or not isinstance(条.get("case_id"), str): + raise ValueError("用例导出缺少完整 case_id") + 身份 = 条["case_id"] + if 身份 in 已见: + raise ValueError(f"用例身份重复:{身份}") + 已见.add(身份) + if not 用例: + raise ValueError("未收集到用例,不能通过身份检查") + return sorted(用例, key=lambda 条: 条["case_id"]) -def 检查文件定位(根: Path) -> list[str]: - """设计清单的文件定位必须恰好命中一个定义,不把目录树文字当作锚点。""" - 设计根 = 根 / "docs/系统架构/新版设计" - 清单 = 设计根 / "目标文件清单.json" - 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']}") +def _检查用例判定(根: Path) -> list[str]: + 问题 = [] + for 文件 in sorted(遍历文件(根 / "tests", "test_*.py")): + if _在忽略目录(文件, 根) or 文件.relative_to(根 / "tests").parts[0] not in _新版用例目录: continue - if 文件 not in 已读: - 已读[文件] = 文件.read_text(encoding="utf-8") - 次数 = len(re.findall(r' 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: 解析器 = argparse.ArgumentParser(description="索引生成、查重及检查") - 解析器.add_argument("--写入", action="store_true", help="执行索引写入(当前为幂等检查)") + 解析器.add_argument("--写入", action="store_true", help="按源文件声明与实际文件生成导航表") 解析器.add_argument("--检查", action="store_true", help="只检查并报告差异") 参数 = 解析器.parse_args() + try: + 用例 = 收集索引用例(仓库根) + except ValueError as 错误: + print(f"索引问题:{错误}", file=sys.stderr) + return 1 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 问题: for 条 in 问题: print(f"索引问题:{条}", file=sys.stderr) return 1 - print("索引检查通过:目录链接、技能目录、用例身份及文件定位均一致") + print("索引检查通过:目录链接、技能目录与用例身份一致(历史逐文件设计不参与活跃门禁)") return 0 diff --git a/工具/资源登记.json b/工具/资源登记.json index c22a100..078ca9b 100644 --- a/工具/资源登记.json +++ b/工具/资源登记.json @@ -179,30 +179,14 @@ "源路径": "src/muse/审校修订/提示词/连续性审阅.md", "打包路径": "审校/连续性审阅.md" }, - { - "源路径": "数据库/迁移/V0041__评测语义检测单元.sql", - "打包路径": "迁移/V0041__评测语义检测单元.sql" - }, { "源路径": "src/muse/效果评测/效果标准.json", "打包路径": "评测/效果标准.json" }, - { - "源路径": "数据库/迁移/V0042__效果判据凭据.sql", - "打包路径": "迁移/V0042__效果判据凭据.sql" - }, { "源路径": "配置/流程模板/定稿交付.yaml", "打包路径": "流程/定稿交付.yaml" }, - { - "源路径": "src/muse/交付连载/提示词/全书审阅.md", - "打包路径": "交付/全书审阅.md" - }, - { - "源路径": "src/muse/交付连载/提示词/整理交付材料.md", - "打包路径": "交付/整理交付材料.md" - }, { "源路径": "配置/流程模板/数据维护.yaml", "打包路径": "流程/数据维护.yaml" @@ -230,6 +214,26 @@ { "源路径": "配置/流程模板/审校修订.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" ] } diff --git a/工具/验收安装包.py b/工具/验收安装包.py new file mode 100644 index 0000000..a7b9241 --- /dev/null +++ b/工具/验收安装包.py @@ -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(主()) diff --git a/工具/验证锁.py b/工具/验证锁.py new file mode 100644 index 0000000..f2f9a3b --- /dev/null +++ b/工具/验证锁.py @@ -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())